diff --git a/.maestro/README.md b/.maestro/README.md index eeda6efba..b54ef5c20 100644 --- a/.maestro/README.md +++ b/.maestro/README.md @@ -32,15 +32,98 @@ Where: ## Available test flows --- -### 001_001_invite_users_using_qr_code_or_link_test - -1. Admin generates invite link (QR/link) for teacher -2. Teacher joins using QR/link → creates account -3. Teacher creates class and generates invite code for student -4. Student joins using invite code → waits for approval -5. Teacher approves student → student joins class -6. Teacher generates parent invite link to join class -7. Parent joins using link → adds child to class +### 001_001a_invite_flow_admin_teacher_approval_on_off_test + +1. Admin Generates New Person Invites: +2. Admin generates invitation codes for: + • System Administrator role + • Teacher role + Both Approval Required ON and Approval Required OFF flows are tested. +3. Admin Creates Class & Teacher Class Invite: +4. Admin creates "TestClass" and generates Teacher class invitation codes for: + • Approval Required ON + • Approval Required OFF +5. Admin & Teacher Onboarding (Approval Required ON): + • AdminA joins using System Administrator invite and enters "Waiting for approval" state. + • TeacherA joins using Teacher QR/link invite and enters "Waiting for approval" state. + • TeacherC joins "TestClass" using Teacher class invite and enters "Waiting for approval" state. +6. Admin Approval Flow: +7. Admin logs into the People section and validates pending join requests for: + • AdminA User + • TeacherA User + • TeacherC User +8. Admin approves all pending requests. +9. Admin & Teacher Onboarding (Approval Required OFF): + • AdminB joins using System Administrator invite and gains immediate access. + • TeacherB joins using Teacher QR/link invite and gains immediate access. + • TeacherD joins "TestClass" using Teacher class invite and gains immediate access. +10. Immediate Access Verification: + AdminB and TeacherB successfully reach the Apps dashboard without requiring approval. + Final Verification: +11. Admin logs in and validates: + • People section contains AdminA, AdminB, TeacherB, TeacherC, and TeacherD users. + • No pending requests remain. + • "TestClass" contains TeacherC User and TeacherD User. +--- +### 001_001b_invite_flow_student_parent_approval_enabled_test + +1. Admin Creates Class & Teacher Account: + Admin creates "TestClass" and manually adds TeacherA User as a Teacher account with login credentials. +2. Admin Generates New Person Invites (Approval Required ON): + Admin generates invitation codes for: + • Student role (New Person invite) + • Parent role (New Person invite) + Both invites are configured with "Approval Required" ON. +3. Admin Generates Class Invites (Approval Required ON): + Inside "TestClass", Admin generates: + • Direct Student class invite + • Parent-to-Class invite + Both class invites are configured with "Approval Required" ON. +4. Student & Parent New Person Onboarding: + • StudentA joins the school using the Student New Person invite and enters "Waiting for approval" state. + • ParentA joins the school using the Parent New Person invite and enters "Waiting for approval" state. +5. Student Joins Class Directly: + • StudentC joins "TestClass" using the direct Student class invite and enters "Waiting for approval" state. +6. Parent Joins Class with Child: + • ParentC joins using the Parent class invite and registers ChildA User during onboarding. + • ParentC and ChildA User both enter "Waiting for approval" state. +7. Teacher Approval Flow: + TeacherA logs in and validates that all Approval Required ON users appear as pending requests in the People section. + TeacherA approves all pending requests from the People section. +8. Final Verification: + TeacherA opens "TestClass" and validates: + • Student User is visible in the class. + • Child User is visible in the class. + • No pending approval requests remain. +--- +### 001_001c_invite_flow_student_parent_approval_disabled_test + +1. Admin Creates Class & Teacher Account: + Admin creates "TestClass" and manually adds TeacherA User as a Teacher account with login credentials. +2. Admin Generates New Person Invites (Approval Required OFF): + Admin generates invitation codes for: + • Student role (New Person invite) + • Parent role (New Person invite) + Both invites are configured with "Approval Required" OFF. +3. Admin Generates Class Invites (Approval Required OFF): + Inside "TestClass", Admin generates: + • Direct Student class invite + • Parent-to-Class invite + Both class invites are configured with "Approval Required" OFF. +4. Student & Parent New Person Onboarding: + • StudentA joins the school using the Student New Person invite and gains immediate access. + • ParentA joins the school using the Parent New Person invite and gains immediate access. +5. Student Joins Class Directly: + • StudentB joins "TestClass" using the direct Student class invite and gains immediate access to the class. +6. Parent Joins Class with Child: + • ParentB joins using the Parent class invite, registers ChildA User during onboarding, and gains immediate access. + • ChildA User is automatically added to "TestClass". +7. Teacher Verification: + TeacherA logs in and validates: + • All onboarded users are visible in the People section. + • No pending approval requests are displayed. + • "TestClass" contains StudentB User and ChildA User. + • ParentB User is not listed inside the class roster. --- ### 001_002_add_user_direct_test @@ -82,11 +165,29 @@ Where: --- ### 003_admin_user_assigns_assignment_to_a_class_test -1. Admin setup includes app, class and teacher creation -2. Teacher logs in and accesses class -3. Teacher creates a new assignment -4. Assignment is linked with lesson content -5. Assignment is saved and verified in class +### 003_admin_user_assigns_assignment_to_a_class_test + +1. Admin Adds Learning App: + Admin logs into the school and adds a learning application to the school environment. +2. Admin Creates Teacher Account: + Admin creates TeacherA User with Teacher role credentials and enables account access. +3. Admin Creates Class: + Admin creates a class named "TestClass". +4. Teacher Login & Navigation: + TeacherA logs into the school and navigates to the Assignments section. +5. Assignment Creation Validation: + Teacher attempts to save a new assignment without entering required details and validates the required field error message. +6. Teacher Creates Assignment: + Teacher creates an assignment named "Homework 1" with: + • Assigned class: TestClass + • Future due date and time + • Linked lesson content from the learning app +7. Assignment Verification: + Teacher successfully saves the assignment and verifies: + • Assignment detail page displays "Homework 1" + • Linked lesson "Lesson 001" is visible + • Assignment appears in the Assignments list. + --- ## Testing using HTTPS diff --git a/.maestro/flows/001_001_invite_users_using_qr_code_or_link_test.yaml b/.maestro/flows/001_001_invite_users_using_qr_code_or_link_test.yaml deleted file mode 100644 index 94623f25a..000000000 --- a/.maestro/flows/001_001_invite_users_using_qr_code_or_link_test.yaml +++ /dev/null @@ -1,266 +0,0 @@ -appId: world.respect.app -onFlowStart: - - clearState: world.respect.app - - runScript: - file: "scripts/school_init.js" - env: - TESTCONTROLLER_URL: ${TESTCONTROLLER_URL} - SCHOOL_ADMIN_PASSWORD: ${SCHOOL_ADMIN_PASSWORD} - DIR_ADMIN_AUTH_HEADER: ${DIR_ADMIN_AUTH_HEADER} - SCHOOL_URL: ${SCHOOL_URL} - SCHOOL_NAME: ${SCHOOL_NAME} - URL_SUBSTITUTION: ${URL_SUBSTITUTION} - NAME: "001_001_invite_users_using_qr_code_or_link_test.yaml" - -onFlowComplete: - - runScript: - file: "scripts/teardown.js" - ---- -# Invite new user: -- runFlow: "subflows/school_admin_login_flow.yaml" -- assertVisible: - id: "app_title" - text: "Apps" -- tapOn: "People" -- tapOn: - id: "ExpandableFab" # +Person button -- tapOn: - text: "Invite person" - index: 1 -- assertVisible: - id: "app_title" - text: "Invite person" -- assertVisible: "Approval required" #switch is On -- assertVisible: "Copy link" -- assertVisible: "Send link via SMS" -- assertVisible: "Send link via Email" -- assertVisible: "Share link" -- tapOn: - id: "role" -- tapOn: - text: "Teacher" -- scroll -- assertVisible: "Reset code" -- tapOn: "Approval required" # turn the switch off -- assertVisible: "Approval not required until:.*" -- copyTextFrom: - id: "invite_url" - -# Teacher user joining using invite QR code -- clearState: world.respect.app -- launchApp: - arguments: - respect_directory: ${output.SCHOOL_URL} -- tapOn: "Get Started" -- tapOn: "Scan QR code/badge" -- assertVisible: - id: "app_title" - text: "Scan QR code badge" -- tapOn: "More Options" -- tapOn: "Paste URL" -- tapOn: "Url" -- pasteText -- tapOn: "OK" -- assertVisible: - id: "app_title" - text: "Invitation" -- assertVisible: "Role" -- assertVisible: "Teacher" -- assertVisible: "School name" -- assertVisible: ${output.SCHOOL_NAME} -- assertVisible: "School server URL" -- assertVisible: ${output.SCHOOL_URL} -- tapOn: "Next" -- assertVisible: - id: "app_title" - text: "Terms and conditions" -- tapOn: "Accept" -- assertVisible: - id: "app_title" - text: "Your profile" -- tapOn: "Your name*" -- inputText: "TeacherA User" -- tapOn: "Gender*" -- tapOn: "Female" -- tapOn: "Your date of birth*" -- runScript: - file: "scripts/setDate.js" -- inputText: ${output.pastYearDateP} -- tapOn: "Next" -- assertVisible: - id: "app_title" - text: "Create account" -- assertVisible: "teacherauser" -- tapOn: "Next" -- tapOn: "Password*" -- inputText: "test123" -- tapOn: "Sign-up" -- assertVisible: - id: "app_title" - text: "Apps" -- assertVisible: "Apps" -- assertVisible: "Assignments" -- assertVisible: "People" -- runFlow: - file: "subflows/admin_add_class.yaml" - env: - CLASSNAME: "New Class" -- assertVisible: - id: "app_title" - text: "New Class" -- tapOn: "Add Student" -- tapOn: "Invite person" -- assertVisible: - id: "app_title" - text: "Invite person" -- assertVisible: "Approval required" #switch is On -- copyTextFrom: - id: "invite_code" - -# C) Student sign-up via invite code to the class -- clearState: world.respect.app -- launchApp: - arguments: - respect_directory: ${output.SCHOOL_URL} -- tapOn: "Get Started" -- runFlow: - file: "subflows/get_started_select_school_by_name.yaml" - env: - SCHOOL_NAME: ${SCHOOL_NAME} -- tapOn: "I have an invite code" -- tapOn: "Invite code" -- pasteText -- tapOn: "Next" -- assertVisible: - id: "app_title" - text: "Invitation" -- assertVisible: "Role" -- assertVisible: "Student" -- assertVisible: "School name" -- assertVisible: ${output.SCHOOL_NAME} -- assertVisible: "Class name" -- assertVisible: "New Class" -- assertVisible: "School server URL" -- assertVisible: ${output.SCHOOL_URL} -- tapOn: "Next" -- assertVisible: - id: "app_title" - text: "Your profile" -- tapOn: "Your name*" -- inputText: "Student User" -- tapOn: "Gender*" -- tapOn: "Female" -- tapOn: "Your date of birth*" -- runScript: - file: "scripts/setDate.js" -- inputText: ${output.pastYearDateC} -- tapOn: "Next" -- assertVisible: - id: "app_title" - text: "Create account" -- assertVisible: "studentuser" #username -- tapOn: "Next" -- tapOn: "Password*" -- inputText: "tests1" -- tapOn: "Sign-up" -- runFlow: - when: - visible: "Save password for Respect?" - file: "subflows/save_password_prompt_cancel.yaml" -- assertVisible: - id: "app_title" - text: "Waiting for approval" -- assertVisible: "Please wait" - -# H) Teacher approve student's request to join the class -- clearState: world.respect.app -- launchApp: - arguments: - respect_directory: ${output.SCHOOL_URL} -- tapOn: "Get Started" -- runFlow: - file: "subflows/get_started_select_school_by_name.yaml" - env: - SCHOOL_NAME: ${SCHOOL_NAME} -- tapOn: - id: "username" -- inputText: "teacherauser" -- tapOn: - id : "password" -- inputText: "test123" -- tapOn: "Login" -- assertVisible: "Apps" -- tapOn: "Classes" -- assertVisible: - id: "app_title" - text: "Classes" -- tapOn: "New Class" -- assertVisible: "Pending requests.*" -- assertVisible: "Student User.*" -- tapOn: "Accept Invite" - -# E) Teacher share invite link for parent device based signup -- tapOn: "Add student" -- tapOn: "Invite person" -- tapOn: "Invite via parents" -- tapOn: "Approval required" # turn the switch off -- assertVisible: "Approval not required until:.*" -- copyTextFrom: - id: "invite_url" - -# Parent sign-up to the app using invite link -- runFlow: - file: "subflows/openlink_flow.yaml" - env: - URL: ${maestro.copiedText} -- tapOn: "Get Started" -- assertVisible: - id: "app_title" - text: "Invitation" -- assertVisible: "Role" -- assertVisible: "Parent" -- assertVisible: "School name" -- assertVisible: ${output.SCHOOL_NAME} -- assertVisible: "Class name" -- assertVisible: "New Class" -- assertVisible: "School server URL" -- assertVisible: ${output.SCHOOL_URL} -- tapOn: "Next" -- assertVisible: - id: "app_title" - text: "Terms and conditions" -- tapOn: "Accept" -- assertVisible: - id: "app_title" - text: "Your profile" -- tapOn: "Your name*" -- inputText: "Parent User" -- tapOn: "Gender*" -- tapOn: "Female" -- tapOn: "Your date of birth*" -- runScript: - file: "scripts/setDate.js" -- inputText: ${output.pastYearDateP} -- tapOn: "Next" -- assertVisible: - id: "app_title" - text: "Create account" -- extendedWaitUntil: - visible: "parentuser" #username - timeout: 10000 -- tapOn: "Next" -# passkeys are not supported on the school domain being used for end to end test so signup flow is bit different -- tapOn: "Password*" -- inputText: "test123" -- tapOn: "Sign-up" -- tapOn: "Child's name*" -- inputText: "Student User2" -- tapOn: "Gender*" -- tapOn: "Female" -- tapOn: "Child's date of birth*" -- runScript: - file: "scripts/setDate.js" -- inputText: ${output.pastYearDateC} -- tapOn: "Done" -# 1/Feb/2026: MD: Maestro AGAIN does not actually deliver the tap. Thanks. diff --git a/.maestro/flows/001_001a_invite_flow_admin_teacher_approval_on_off_test.yaml b/.maestro/flows/001_001a_invite_flow_admin_teacher_approval_on_off_test.yaml new file mode 100644 index 000000000..fad84ea71 --- /dev/null +++ b/.maestro/flows/001_001a_invite_flow_admin_teacher_approval_on_off_test.yaml @@ -0,0 +1,553 @@ +appId: world.respect.app +onFlowStart: + - clearState: world.respect.app + - runScript: + file: "scripts/school_init.js" + env: + TESTCONTROLLER_URL: ${TESTCONTROLLER_URL} + SCHOOL_ADMIN_PASSWORD: ${SCHOOL_ADMIN_PASSWORD} + DIR_ADMIN_AUTH_HEADER: ${DIR_ADMIN_AUTH_HEADER} + SCHOOL_URL: ${SCHOOL_URL} + SCHOOL_NAME: ${SCHOOL_NAME} + URL_SUBSTITUTION: ${URL_SUBSTITUTION} + NAME: "001_001a_invite_flow_admin_teacher_approval_on_off_test" + +onFlowComplete: + - runScript: + file: "scripts/teardown.js" + +--- +- runFlow: "subflows/school_admin_login_flow.yaml" +- assertVisible: + id: "app_title" + text: "Apps" +- tapOn: "People" +- tapOn: + id: "ExpandableFab" # +Person button + +# Invite Admin as new person - Approve Required On +- tapOn: + text: "Invite person" + index: 1 +- assertVisible: + id: "app_title" + text: "Invite person" +- assertVisible: "Copy link" +- assertVisible: "Send link via SMS" +- assertVisible: "Send link via Email" +- assertVisible: "Share link" +- tapOn: + id: "role" +- tapOn: + text: "System administrator" +- scroll +- assertVisible: "Reset code" +- assertVisible: "Approval required" #Approval Required +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.adminApproveOn = maestro.copiedText} + +# Invite Teacher as new person - Approve Required On +- tapOn: + id: "role" +- tapOn: + text: "Teacher" +- scroll +- assertVisible: "Reset code" +- assertVisible: "Approval required" #Approval Required +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.teacherApproveOn = maestro.copiedText} +- back +#Admin create class and invite teachers to class +- runFlow: + file: "subflows/user_add_class.yaml" + env: + CLASSNAME: "TestClass" +- assertVisible: + id: "app_title" + text: "TestClass" +- tapOn: "Add Teacher" + +# Invite Teacher as new teacher to TestClass - Approve Required ON +- tapOn: "Invite person" +- assertVisible: + id: "app_title" + text: "Invite person" +- assertVisible: "Approval required" #Approval Required ON +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.teacherClassApproveOn = maestro.copiedText} + +# Admin A user joining using invite code - Admin Approve Required On +- clearState: world.respect.app +- launchApp: + arguments: + respect_directory: ${output.SCHOOL_URL} +- tapOn: "Get Started" +- runFlow: + file: "subflows/get_started_select_school_by_name.yaml" + env: + SCHOOL_NAME: ${SCHOOL_NAME} +- tapOn: "I have an invite code" +- tapOn: "Invite code" +- inputText: ${output.adminApproveOn} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Role" +- assertVisible: "System administrator" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Terms and conditions" +- tapOn: "Accept" +- assertVisible: + id: "app_title" + text: "Your profile" +- tapOn: "Your name*" +- inputText: "AdminA User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Your date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateP} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Create account" +- assertVisible: "adminauser" +- tapOn: "Next" +- tapOn: "Password*" +- inputText: "test123" +- tapOn: "Sign-up" +- runFlow: + when: + visible: "Save password for Respect?" + file: "subflows/save_password_prompt_cancel.yaml" +- assertVisible: + id: "app_title" + text: "Waiting for approval" +- assertVisible: "Please wait" + +# Teacher A user joining using invite QR code - Approve Required On +- clearState: world.respect.app +- launchApp: + arguments: + respect_directory: ${output.SCHOOL_URL} +- tapOn: "Get Started" +- tapOn: "Scan QR code/badge" +- assertVisible: + id: "app_title" + text: "Scan QR code badge" +- tapOn: "More Options" +- tapOn: "Paste URL" +- tapOn: "Url" +- inputText: ${output.SCHOOL_URL + "respect_school_link/AcceptInvite?inviteCode=" + output.teacherApproveOn} +- tapOn: "OK" +- assertVisible: + id: "app_title" + text: "Login" +- tapOn: "Create new account" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Role" +- assertVisible: "Teacher" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Terms and conditions" +- tapOn: "Accept" +- assertVisible: + id: "app_title" + text: "Your profile" +- tapOn: "Your name*" +- inputText: "TeacherA User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Your date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateP} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Create account" +- assertVisible: "teacherauser" +- tapOn: "Next" +- tapOn: "Password*" +- inputText: "test123" +- tapOn: "Sign-up" +- runFlow: + when: + visible: "Save password for Respect?" + file: "subflows/save_password_prompt_cancel.yaml" +- assertVisible: + id: "app_title" + text: "Waiting for approval" +- assertVisible: "Please wait" + +# Teacher C user joining TestClass using invite code - Approve Required On +- clearState: world.respect.app +- launchApp: + arguments: + respect_directory: ${output.SCHOOL_URL} +- tapOn: "Get Started" +- runFlow: + file: "subflows/get_started_select_school_by_name.yaml" + env: + SCHOOL_NAME: ${SCHOOL_NAME} +- tapOn: "I have an invite code" +- tapOn: "Invite code" +- inputText: ${output.teacherClassApproveOn} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Role" +- assertVisible: "Teacher" +- assertVisible: "Class name" +- assertVisible: "TestClass" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Terms and conditions" +- tapOn: "Accept" +- assertVisible: + id: "app_title" + text: "Your profile" +- tapOn: "Your name*" +- inputText: "TeacherC User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Your date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateP} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Create account" +- assertVisible: "teachercuser" +- tapOn: "Next" +- tapOn: "Password*" +- inputText: "test123" +- tapOn: "Sign-up" +- runFlow: + when: + visible: "Save password for Respect?" + file: "subflows/save_password_prompt_cancel.yaml" +- assertVisible: + id: "app_title" + text: "Waiting for approval" +- assertVisible: "Please wait" + +# Admin login - Approves People and Class invites +# Admin in people page validating and approving join request +- clearState: world.respect.app +- runFlow: "subflows/school_admin_login_flow.yaml" +- assertVisible: + id: "app_title" + text: "Apps" +- tapOn: "People" +- assertVisible: "Pending request to join.*" +- repeat: + times: 3 + while: + visible: "Accept Invite" + commands: + - tapOn: "Accept Invite" +- assertVisible: "AdminA User" +- assertVisible: "TeacherA User" +- assertVisible: "TeacherC User" +- assertNotVisible: "Pending request to join.*" + +# Invite Admin as new person - Approve Required Off +- tapOn: + id: "ExpandableFab" # +Person button +- tapOn: + text: "Invite person" + index: 1 +- assertVisible: + id: "app_title" + text: "Invite person" +- assertVisible: + id: "app_title" + text: "Invite person" +- tapOn: + id: "role" +- tapOn: + text: "System administrator" +- scroll +- runFlow: + when: + notVisible: "Approval not required until:.*" + commands: + - tapOn: "Approval required" + - assertVisible: "Approval not required until:.*" +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.adminApproveOff = maestro.copiedText} + +# Invite Teacher as new person - Approve Required Off +- tapOn: + id: "role" +- tapOn: + text: "Teacher" +- runFlow: + when: + notVisible: "Approval not required until:.*" + commands: + - tapOn: "Approval required" + - assertVisible: "Approval not required until:.*" +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.teacherApproveOff = maestro.copiedText} +- back +- tapOn: "Classes" +- tapOn: "TestClass" +- assertNotVisible: "Pending requests.*" +- runFlow: + when: + notVisible: "TeacherC User" + commands: + - tapOn: "People" + - assertVisible: "TeacherC User" + - tapOn: "Classes" + - tapOn: "TestClass" +- assertVisible: "TeacherC User" + +# Invite Teacher as new teacher to TestClass - Approve Required Off +- tapOn: "Add Teacher" +- tapOn: "Invite person" +- assertVisible: + id: "app_title" + text: "Invite person" +- runFlow: + when: + notVisible: "Approval not required until:.*" + commands: + - tapOn: "Approval required" + - assertVisible: "Approval not required until:.*" +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.teacherClassApproveOff = maestro.copiedText} + +# Admin B user joining using invite code - Admin Approve Required Off +- clearState: world.respect.app +- launchApp: + arguments: + respect_directory: ${output.SCHOOL_URL} +- tapOn: "Get Started" +- runFlow: + file: "subflows/get_started_select_school_by_name.yaml" + env: + SCHOOL_NAME: ${SCHOOL_NAME} +- tapOn: "I have an invite code" +- tapOn: "Invite code" +- inputText: ${output.adminApproveOff} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Role" +- assertVisible: "System administrator" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Terms and conditions" +- tapOn: "Accept" +- assertVisible: + id: "app_title" + text: "Your profile" +- tapOn: "Your name*" +- inputText: "AdminB User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Your date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateP} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Create account" +- assertVisible: "adminbuser" +- tapOn: "Next" +- tapOn: "Password*" +- inputText: "test123" +- tapOn: "Sign-up" +- runFlow: + when: + visible: "Save password for Respect?" + file: "subflows/save_password_prompt_cancel.yaml" +- assertVisible: + id: "app_title" + text: "Apps" +- assertVisible: "Apps" +- assertVisible: "Assignments" +- assertVisible: "Classes" +- assertVisible: "People" + +# Teacher B user joining using invite code - Approve Required Off +- clearState: world.respect.app +- launchApp: + arguments: + respect_directory: ${output.SCHOOL_URL} +- tapOn: "Get Started" +- tapOn: "Scan QR code/badge" +- assertVisible: + id: "app_title" + text: "Scan QR code badge" +- tapOn: "More Options" +- tapOn: "Paste URL" +- tapOn: "Url" +- inputText: ${output.SCHOOL_URL + "respect_school_link/AcceptInvite?inviteCode=" + output.teacherApproveOff} +- tapOn: "OK" +- assertVisible: + id: "app_title" + text: "Login" +- tapOn: "Create new account" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Role" +- assertVisible: "Teacher" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Terms and conditions" +- tapOn: "Accept" +- assertVisible: + id: "app_title" + text: "Your profile" +- tapOn: "Your name*" +- inputText: "TeacherB User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Your date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateP} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Create account" +- assertVisible: "teacherbuser" +- tapOn: "Next" +- tapOn: "Password*" +- inputText: "test123" +- tapOn: "Sign-up" +- runFlow: + when: + visible: "Save password for Respect?" + file: "subflows/save_password_prompt_cancel.yaml" +- assertVisible: + id: "app_title" + text: "Apps" +- assertVisible: "Apps" +- assertVisible: "Assignments" +- assertVisible: "Classes" +- assertVisible: "People" + +# Teacher D user joining TestClass using invite code - Approve Required Off +- clearState: world.respect.app +- launchApp: + arguments: + respect_directory: ${output.SCHOOL_URL} +- tapOn: "Get Started" +- runFlow: + file: "subflows/get_started_select_school_by_name.yaml" + env: + SCHOOL_NAME: ${SCHOOL_NAME} +- tapOn: "I have an invite code" +- tapOn: "Invite code" +- inputText: ${output.teacherClassApproveOff} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Role" +- assertVisible: "Teacher" +- assertVisible: "Class name" +- assertVisible: "TestClass" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Terms and conditions" +- tapOn: "Accept" +- assertVisible: + id: "app_title" + text: "Your profile" +- tapOn: "Your name*" +- inputText: "TeacherD User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Your date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateP} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Create account" +- assertVisible: "teacherduser" +- tapOn: "Next" +- tapOn: "Password*" +- inputText: "test123" +- tapOn: "Sign-up" +- runFlow: + when: + visible: "Save password for Respect?" + file: "subflows/save_password_prompt_cancel.yaml" +- assertVisible: + id: "app_title" + text: "Apps" +- assertVisible: "Apps" +- assertVisible: "Assignments" +- assertVisible: "Classes" + +# Admin login - Verify People and Class users +- clearState: world.respect.app +- runFlow: "subflows/school_admin_login_flow.yaml" +- assertVisible: + id: "app_title" + text: "Apps" +- tapOn: "People" +- assertVisible: "AdminA User" +- assertVisible: "AdminB User" +- assertVisible: "TeacherB User" +- assertVisible: "TeacherC User" +- assertVisible: "TeacherD User" +- tapOn: "Classes" +- tapOn: "TestClass" +- assertVisible: "TeacherC User" +- assertVisible: "TeacherD User" + diff --git a/.maestro/flows/001_001b_invite_flow_student_parent_approval_enabled_test.yaml b/.maestro/flows/001_001b_invite_flow_student_parent_approval_enabled_test.yaml new file mode 100644 index 000000000..2ca7e66d2 --- /dev/null +++ b/.maestro/flows/001_001b_invite_flow_student_parent_approval_enabled_test.yaml @@ -0,0 +1,388 @@ +appId: world.respect.app +onFlowStart: + - clearState: world.respect.app + - runScript: + file: "scripts/school_init.js" + env: + TESTCONTROLLER_URL: ${TESTCONTROLLER_URL} + SCHOOL_ADMIN_PASSWORD: ${SCHOOL_ADMIN_PASSWORD} + DIR_ADMIN_AUTH_HEADER: ${DIR_ADMIN_AUTH_HEADER} + SCHOOL_URL: ${SCHOOL_URL} + SCHOOL_NAME: ${SCHOOL_NAME} + URL_SUBSTITUTION: ${URL_SUBSTITUTION} + NAME: "001_001b_invite_flow_student_parent_approval_enabled_test" + +onFlowComplete: + - runScript: + file: "scripts/teardown.js" + +--- + +- runFlow: "subflows/school_admin_login_flow.yaml" +- assertVisible: + id: "app_title" + text: "Apps" +- tapOn: "People" + +#Admin create class and add teacher to class +- runFlow: + file: "subflows/user_add_class.yaml" + env: + CLASSNAME: "TestClass" +- assertVisible: + id: "app_title" + text: "TestClass" +- tapOn: "Add Teacher" +- tapOn: "Add person" +- assertVisible: + id: "app_title" + text: "Add person" +- tapOn: "First names*" +- inputText: "TeacherA" +- tapOn: "Last name*" +- inputText: "User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Save" +- tapOn: "TeacherA User" +- tapOn: "Create account" +- assertVisible: "teacherauser" +- tapOn: "Password" +- inputText: "test123" +- tapOn: "Save" +- assertVisible: "Manage account" + +# Invite Students as new person - Approve Required On +- tapOn: "People" +- tapOn: + id: "ExpandableFab" # +Person button +- tapOn: + text: "Invite person" + index: 1 +- assertVisible: + id: "app_title" + text: "Invite person" +- tapOn: + id: "role" +- tapOn: + text: "Student" +- scroll +- assertVisible: "Reset code" +- assertVisible: "Approval required" #Approval Required +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.studentApproveOn = maestro.copiedText} + +# Invite Parent as new person - Approve Required On +- tapOn: + id: "role" +- tapOn: + text: "Parent" +- scroll +- assertVisible: "Reset code" +- assertVisible: "Approval required" #Approval Required +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.parentApproveOn = maestro.copiedText} + +- back +- tapOn: "Classes" +- tapOn: "TestClass" +- assertVisible: "TeacherA User" +- tapOn: "Add Student" +- tapOn: "Invite person" +- assertVisible: + id: "app_title" + text: "Invite person" + +# Invite Student as new student to TestClass - Approve Required ON +- tapOn: "Invite students directly" +- assertVisible: "Approval required" #Approval Required ON +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.studentClassApproveOn = maestro.copiedText} + +# Invite Student to TestClass via Parent - Approve Required ON +- tapOn: "Invite via parents" +- assertVisible: "Approval required" #Approval Required ON +- assertNotVisible: "Approval not required until:.*" +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.parentClassApproveOn = maestro.copiedText} + + +#Student A sign-up via invite code as new person - Approve Required On +- clearState: world.respect.app +- launchApp: + arguments: + respect_directory: ${output.SCHOOL_URL} +- tapOn: "Get Started" +- runFlow: + file: "subflows/get_started_select_school_by_name.yaml" + env: + SCHOOL_NAME: ${SCHOOL_NAME} +- tapOn: "I have an invite code" +- tapOn: "Invite code" +- inputText: ${output.studentApproveOn} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Role" +- assertVisible: "Student" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Your profile" +- tapOn: "Your name*" +- inputText: "StudentA User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Your date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateC} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Create account" +- assertVisible: "studentauser" #username +- tapOn: "Next" +- tapOn: "Password*" +- inputText: "test123" +- tapOn: "Sign-up" +- runFlow: + when: + visible: "Save password for Respect?" + file: "subflows/save_password_prompt_cancel.yaml" +- assertVisible: + id: "app_title" + text: "Waiting for approval" +- assertVisible: "Please wait" + + +#Parent A sign-up via invite code as new person - Approve Required On +- clearState: world.respect.app +- launchApp: + arguments: + respect_directory: ${output.SCHOOL_URL} +- tapOn: "Get Started" +- runFlow: + file: "subflows/get_started_select_school_by_name.yaml" + env: + SCHOOL_NAME: ${SCHOOL_NAME} +- tapOn: "I have an invite code" +- tapOn: "Invite code" +- inputText: ${output.parentApproveOn} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Role" +- assertVisible: "Parent" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Terms and conditions" +- tapOn: "Accept" +- assertVisible: + id: "app_title" + text: "Your profile" +- tapOn: "Your name*" +- inputText: "ParentA User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Your date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateC} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Create account" +- assertVisible: "parentauser" #username +- tapOn: "Next" +- tapOn: "Password*" +- inputText: "test123" +- tapOn: "Sign-up" +- runFlow: + when: + visible: "Save password for Respect?" + file: "subflows/save_password_prompt_cancel.yaml" +- assertVisible: + id: "app_title" + text: "Waiting for approval" +- assertVisible: "Please wait" + + +# Student C user joining TestClass using invite code - Approve Required On +- clearState: world.respect.app +- launchApp: + arguments: + respect_directory: ${output.SCHOOL_URL} +- tapOn: "Get Started" +- runFlow: + file: "subflows/get_started_select_school_by_name.yaml" + env: + SCHOOL_NAME: ${SCHOOL_NAME} +- tapOn: "I have an invite code" +- tapOn: "Invite code" +- inputText: ${output.studentClassApproveOn} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Role" +- assertVisible: "Student" +- assertVisible: "Class name" +- assertVisible: "TestClass" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Your profile" +- tapOn: "Your name*" +- inputText: "StudentC User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Your date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateP} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Create account" +- assertVisible: "studentcuser" +- tapOn: "Next" +- tapOn: "Password*" +- inputText: "test123" +- tapOn: "Sign-up" +- runFlow: + when: + visible: "Save password for Respect?" + file: "subflows/save_password_prompt_cancel.yaml" +- assertVisible: + id: "app_title" + text: "Waiting for approval" +- assertVisible: "Please wait" + + +# Parent C joins child user to the class using invite link - Approve required ON +- runFlow: + file: "subflows/openlink_flow.yaml" + env: + URL: ${output.SCHOOL_URL + "respect_school_link/AcceptInvite?inviteCode=" + output.parentClassApproveOn} +- assertVisible: + id: "app_title" + text: "Login" +- tapOn: "Create new account" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Role" +- assertVisible: "Student" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "Class name" +- assertVisible: "TestClass" +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Terms and conditions" +- tapOn: "Accept" +- assertVisible: + id: "app_title" + text: "Your profile" +- tapOn: "Your name*" +- inputText: "ParentC User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Your date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateP} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Create account" +- extendedWaitUntil: + visible: "parentcuser" #username + timeout: 10000 +- tapOn: "Next" +- tapOn: "Password*" +- inputText: "test123" +- tapOn: "Sign-up" +- tapOn: "Child's name*" +- inputText: "ChildA User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Child's date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateC} +- tapOn: "Done" +- runFlow: + when: + visible: "Done" + commands: + - tapOn: "Done" +- runFlow: + when: + visible: "Save password for Respect?" + file: "subflows/save_password_prompt_cancel.yaml" +- assertVisible: + id: "app_title" + text: "Waiting for approval" +- assertVisible: "Please wait" + +#TeacherA User login and send invites to students and parents +- clearState: world.respect.app +- runFlow: + file: "subflows/school_user_login_flow.yaml" + env: + USERNAME: "teacherauser" + PASSWORD: "test123" +- assertVisible: + id: "app_title" + text: "Apps" +- tapOn: "People" + +# Approve Required ON users should be pending +- assertVisible: "Pending request to join.*" +- assertVisible: "StudentA User" +- assertVisible: "ParentA User" +- assertVisible: "StudentC User" +- assertVisible: "ParentC User" +- assertVisible: "ChildA User" #When ParentC added, childA automatically gets added to class people +- repeat: + times: 5 + while: + visible: "Accept Invite" + commands: + - tapOn: "Accept Invite" +- assertNotVisible: "Pending request to join.*" + +#Teacher open class and invite students to class +- tapOn: "Classes" +- tapOn: "TestClass" +- assertVisible: + id: "app_title" + text: "TestClass" + +- assertVisible: "StudentC User" +- assertVisible: "ChildA User" +- assertNotVisible: "Pending request to join.*" \ No newline at end of file diff --git a/.maestro/flows/001_001c_invite_flow_student_parent_approval_disabled_test.yaml b/.maestro/flows/001_001c_invite_flow_student_parent_approval_disabled_test.yaml new file mode 100644 index 000000000..f26914623 --- /dev/null +++ b/.maestro/flows/001_001c_invite_flow_student_parent_approval_disabled_test.yaml @@ -0,0 +1,405 @@ +appId: world.respect.app +onFlowStart: + - clearState: world.respect.app + - runScript: + file: "scripts/school_init.js" + env: + TESTCONTROLLER_URL: ${TESTCONTROLLER_URL} + SCHOOL_ADMIN_PASSWORD: ${SCHOOL_ADMIN_PASSWORD} + DIR_ADMIN_AUTH_HEADER: ${DIR_ADMIN_AUTH_HEADER} + SCHOOL_URL: ${SCHOOL_URL} + SCHOOL_NAME: ${SCHOOL_NAME} + URL_SUBSTITUTION: ${URL_SUBSTITUTION} + NAME: "001_001c_invite_flow_student_parent_approval_disabled_test" + +onFlowComplete: + - runScript: + file: "scripts/teardown.js" + +--- + +- runFlow: "subflows/school_admin_login_flow.yaml" +- assertVisible: + id: "app_title" + text: "Apps" +- tapOn: "People" + +#Admin create class and add teacher to class +- runFlow: + file: "subflows/user_add_class.yaml" + env: + CLASSNAME: "TestClass" +- assertVisible: + id: "app_title" + text: "TestClass" +- tapOn: "Add Teacher" +- tapOn: "Add person" +- assertVisible: + id: "app_title" + text: "Add person" +- tapOn: "First names*" +- inputText: "TeacherA" +- tapOn: "Last name*" +- inputText: "User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Save" +- tapOn: "TeacherA User" +- tapOn: "Create account" +- assertVisible: "teacherauser" +- tapOn: "Password" +- inputText: "test123" +- tapOn: "Save" +- assertVisible: "Manage account" + +# Invite Student as new person - Approve Required Off +- tapOn: "People" +- tapOn: + id: "ExpandableFab" # +Person button +- tapOn: + text: "Invite person" + index: 1 +- assertVisible: + id: "app_title" + text: "Invite person" +- tapOn: + id: "role" +- tapOn: + text: "Student" +- runFlow: + when: + notVisible: "Approval not required until:.*" + commands: + - tapOn: "Approval required" + - assertVisible: "Approval not required until:.*" +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.studentApproveOff = maestro.copiedText} + +# Invite Parent as new person - Approve Required Off +- tapOn: + id: "role" +- tapOn: + text: "Parent" +- runFlow: + when: + notVisible: "Approval not required until:.*" + commands: + - tapOn: "Approval required" + - assertVisible: "Approval not required until:.*" +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.parentApproveOff = maestro.copiedText} +- back + +#Teacher open class and invite students to class +- tapOn: "Classes" +- tapOn: "TestClass" +- assertVisible: + id: "app_title" + text: "TestClass" + +# Invite Student as new student to TestClass - Approve Required Off +- tapOn: "Add Student" +- tapOn: "Invite person" +- assertVisible: + id: "app_title" + text: "Invite person" +- tapOn: "Invite students directly" +- runFlow: + when: + notVisible: "Approval not required until:.*" + commands: + - tapOn: "Approval required" + - assertVisible: "Approval not required until:.*" +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.studentClassApproveOff = maestro.copiedText} + +# Invite Student to TestClass via Parent - Approve Required Off +- tapOn: "Invite via parents" +- runFlow: + when: + notVisible: "Approval not required until:.*" + commands: + - tapOn: "Approval required" + - assertVisible: "Approval not required until:.*" +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.parentClassApproveOff = maestro.copiedText} + + +#Student A sign-up via invite code as new person - Approve Required Off +- clearState: world.respect.app +- launchApp: + arguments: + respect_directory: ${output.SCHOOL_URL} +- tapOn: "Get Started" +- runFlow: + file: "subflows/get_started_select_school_by_name.yaml" + env: + SCHOOL_NAME: ${SCHOOL_NAME} +- tapOn: "I have an invite code" +- tapOn: "Invite code" +- inputText: ${output.studentApproveOff} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Role" +- assertVisible: "Student" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Your profile" +- tapOn: "Your name*" +- inputText: "StudentA User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Your date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateC} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Create account" +- assertVisible: "studentauser" #username +- tapOn: "Next" +- tapOn: "Password*" +- inputText: "test123" +- tapOn: "Sign-up" +- runFlow: + when: + visible: "Save password for Respect?" + file: "subflows/save_password_prompt_cancel.yaml" +- assertVisible: + id: "app_title" + text: "Apps" + + +#Parent A sign-up via invite code as new person - Approve Required Off +- clearState: world.respect.app +- launchApp: + arguments: + respect_directory: ${output.SCHOOL_URL} +- tapOn: "Get Started" +- runFlow: + file: "subflows/get_started_select_school_by_name.yaml" + env: + SCHOOL_NAME: ${SCHOOL_NAME} +- tapOn: "I have an invite code" +- tapOn: "Invite code" +- inputText: ${output.parentApproveOff} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Role" +- assertVisible: "Parent" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Terms and conditions" +- tapOn: "Accept" +- assertVisible: + id: "app_title" + text: "Your profile" +- tapOn: "Your name*" +- inputText: "ParentA User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Your date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateC} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Create account" +- assertVisible: "parentauser" #username +- tapOn: "Next" +- tapOn: "Password*" +- inputText: "test123" +- tapOn: "Sign-up" +- runFlow: + when: + visible: "Save password for Respect?" + file: "subflows/save_password_prompt_cancel.yaml" +- assertVisible: + id: "app_title" + text: "Apps" + + +# Student B user joining using invite code - Approve Required Off +- clearState: world.respect.app +- launchApp: + arguments: + respect_directory: ${output.SCHOOL_URL} +- tapOn: "Get Started" +- runFlow: + file: "subflows/get_started_select_school_by_name.yaml" + env: + SCHOOL_NAME: ${SCHOOL_NAME} +- tapOn: "I have an invite code" +- tapOn: "Invite code" +- inputText: ${output.studentClassApproveOff} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Role" +- assertVisible: "Student" +- assertVisible: "Class name" +- assertVisible: "TestClass" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Your profile" +- tapOn: "Your name*" +- inputText: "StudentB User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Your date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateP} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Create account" +- assertVisible: "studentbuser" +- tapOn: "Next" +- tapOn: "Password*" +- inputText: "test123" +- tapOn: "Sign-up" +- runFlow: + when: + visible: "Save password for Respect?" + file: "subflows/save_password_prompt_cancel.yaml" +- assertVisible: + id: "app_title" + text: "Apps" +- tapOn: "Classes" +- assertVisible: "TestClass" + + +# Parent B joins child user to the class using invite link - Approve required Off +- runFlow: + file: "subflows/openlink_flow.yaml" + env: + URL: ${output.SCHOOL_URL + "respect_school_link/AcceptInvite?inviteCode=" + output.parentClassApproveOff} +- assertVisible: + id: "app_title" + text: "Login" +- tapOn: "Create new account" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Role" +- assertVisible: "Student" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "Class name" +- assertVisible: "TestClass" +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Terms and conditions" +- tapOn: "Accept" +- assertVisible: + id: "app_title" + text: "Your profile" +- tapOn: "Your name*" +- inputText: "ParentB User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Your date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateP} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Create account" +- extendedWaitUntil: + visible: "parentbuser" #username + timeout: 10000 +- tapOn: "Next" +- tapOn: "Password*" +- inputText: "test123" +- tapOn: "Sign-up" +- tapOn: "Child's name*" +- inputText: "ChildA User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Child's date of birth*" +- runScript: + file: "scripts/setDate.js" +- inputText: ${output.pastYearDateC} +- tapOn: "Done" +- runFlow: + when: + visible: "Done" + commands: + - tapOn: "Done" +- runFlow: + when: + visible: "Save password for Respect?" + file: "subflows/save_password_prompt_cancel.yaml" +- assertVisible: + id: "app_title" + text: "Apps" +- tapOn: "Classes" +- assertVisible: "TestClass" + +# TeacherA user login - Approves People and Class invites +# TeacherA user in people page validating and approving join request +- clearState: world.respect.app +- runFlow: + file: "subflows/school_user_login_flow.yaml" + env: + USERNAME: "teacherauser" + PASSWORD: "test123" +- assertVisible: + id: "app_title" + text: "Apps" +- tapOn: "People" +- assertVisible: "StudentA User" +- assertVisible: "StudentB User" +- assertVisible: "ParentA User" +- assertVisible: "ParentB User" +- assertVisible: "ChildA User" +- assertNotVisible: "Pending request to join.*" + +#Teacher User open class +- tapOn: "Classes" +- tapOn: "TestClass" +- runFlow: + when: + notVisible: "StudentB User" + commands: + - tapOn: "People" + - assertVisible: "StudentB User" + - tapOn: "Classes" + - tapOn: "TestClass" +- assertVisible: "StudentB User" +- assertVisible: "ChildA User" +- assertNotVisible: "ParentB User" +- assertNotVisible: "Pending request to join.*" + + diff --git a/.maestro/flows/001_002a_invite_existing_teacher_and_students_to_class_approval_enabled_test.yaml b/.maestro/flows/001_002a_invite_existing_teacher_and_students_to_class_approval_enabled_test.yaml new file mode 100644 index 000000000..8401b4c3e --- /dev/null +++ b/.maestro/flows/001_002a_invite_existing_teacher_and_students_to_class_approval_enabled_test.yaml @@ -0,0 +1,274 @@ +appId: world.respect.app +onFlowStart: + - clearState: world.respect.app + - runScript: + file: "scripts/school_init.js" + env: + TESTCONTROLLER_URL: ${TESTCONTROLLER_URL} + SCHOOL_ADMIN_PASSWORD: ${SCHOOL_ADMIN_PASSWORD} + DIR_ADMIN_AUTH_HEADER: ${DIR_ADMIN_AUTH_HEADER} + SCHOOL_URL: ${SCHOOL_URL} + SCHOOL_NAME: ${SCHOOL_NAME} + URL_SUBSTITUTION: ${URL_SUBSTITUTION} + NAME: "001_002a_invite_existing_teacher_and_students_to_class_approval_enabled_test" + +onFlowComplete: + - runScript: + file: "scripts/teardown.js" + +--- +#Admin add a users to the app and add a class named - TestClass +- runFlow: "subflows/school_admin_login_flow.yaml" + +- assertVisible: + id: "app_title" + text: "Apps" +- runFlow: + file: "subflows/user_add_class.yaml" + env: + CLASSNAME: "TestClass" + +- runFlow: + file: "subflows/add_person_to_app.yaml" + env: + FIRSTNAME: "ParentA" + LASTNAME: "User" + GENDER: "Female" + ROLE: "Parent" + USERNAME: "parentauser" + PASSWORD: "test123" + +- runFlow: + file: "subflows/add_person_to_app.yaml" + env: + FIRSTNAME: "StudentA" + LASTNAME: "User" + GENDER: "Female" + ROLE: "Student" + USERNAME: "studentauser" + PASSWORD: "test123" + +- runFlow: + file: "subflows/add_person_to_app.yaml" + env: + FIRSTNAME: "StudentB" + LASTNAME: "User" + GENDER: "Male" + ROLE: "Student" + USERNAME: "studentbuser" + PASSWORD: "test123" + +- runFlow: + file: "subflows/add_person_to_app.yaml" + env: + FIRSTNAME: "TeacherA" + LASTNAME: "User" + GENDER: "Female" + ROLE: "Teacher" + USERNAME: "teacherauser" + PASSWORD: "test123" + +# Add StudentB User as family member of Parent A +- tapOn: "People" +- tapOn: "ParentA User" +- tapOn: + id: "floating_action_button" # Edit button +- assertVisible: + id: "app_title" + text: "Edit person" +- tapOn: "Family member" +- assertVisible: "StudentA User" +- tapOn: "StudentB User" +- tapOn: "Save" +- assertVisible: "StudentB User" +- assertVisible: + id: "app_title" + text: "ParentA User" + +- tapOn: "Classes" +- tapOn: "TestClass" +- assertVisible: + id: "app_title" + text: "TestClass" +- tapOn: "Add Teacher" +- tapOn: "Invite person" +- assertVisible: + id: "app_title" + text: "Invite person" +- assertVisible: "Copy link" +- assertVisible: "Send link via SMS" +- assertVisible: "Send link via Email" +- assertVisible: "Share link" +# Approval ON +- assertVisible: "Approval required" +- assertNotVisible: "Approval not required until:.*" +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.teacherApproveOn = maestro.copiedText} +- back +- back +- tapOn: "Classes" +- tapOn: "TestClass" +- tapOn: "Add Student" + +# Invite student to class - Approval ON +- tapOn: "Invite person" +- assertVisible: + id: "app_title" + text: "Invite person" +- runFlow: + when: + visible: "Approval not required until:.*" + commands: + - tapOn: "Approval required" # turn the switch ON + - assertNotVisible: "Approval not required until:.*" +- assertNotVisible: "Approval not required until:.*" +- tapOn: "Invite students directly" +- scroll +- assertVisible: "Reset code" +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.studentApproveOn = maestro.copiedText} + +# Invite Students via parents - Approval ON +- tapOn: "Invite via parents" +- runFlow: + when: + visible: "Approval not required until:.*" + commands: + - tapOn: "Approval required" # turn the switch ON + - assertNotVisible: "Approval not required until:.*" +- assertNotVisible: "Approval not required until:.*" +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.parentApproveOn = maestro.copiedText} + + +# Teacher sign-up via invite link to the class as already logged-in user +- clearState: world.respect.app +- runFlow: + file: "subflows/school_user_login_flow.yaml" + env: + USERNAME: "teacherauser" + PASSWORD: "test123" +- assertVisible: + id: "app_title" + text: "Apps" +- tapOn: + id: "user_account_icon" +- tapOn: "Enter invite code" +- assertVisible: + id: "app_title" + text: "Enter invite code" +- tapOn: "Invite code" +- inputText: ${output.teacherApproveOn} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Class name" +- assertVisible: "TestClass" +- assertVisible: "Role" +- assertVisible: "Teacher" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Accept invite" +- assertVisible: + id: "app_title" + text: "Accounts" +- assertVisible: "TeacherA User" +- assertVisible: "Pending requests" +- assertVisible: "TeacherA User: TestClass (Teacher)" +- assertVisible: ".*Pending approval" + +# StudentA User joining class using link to join the class +- runFlow: + file: "subflows/openlink_flow.yaml" + env: + URL: ${output.SCHOOL_URL + "respect_school_link/AcceptInvite?inviteCode=" + output.studentApproveOn} +- assertVisible: + id: "app_title" + text: "Login" +- tapOn: "Username" +- inputText: "studentauser" +- tapOn: "Password" +- inputText: "test123" +- tapOn: "Login" +- runFlow: + when: + visible: "Save password for Respect?" + file: "subflows/save_password_prompt_cancel.yaml" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Class name" +- assertVisible: "TestClass" +- assertVisible: "Role" +- assertVisible: "Student" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Accept invite" +- assertVisible: + id: "app_title" + text: "Accounts" +- assertVisible: "StudentA User" +- assertVisible: "Pending requests" +- assertVisible: "StudentA User: TestClass (Student)" +- assertVisible: ".*Pending approval" + +# Parent user use the link to add StudentA User to class +- runFlow: + file: "subflows/openlink_flow.yaml" + env: + URL: ${output.SCHOOL_URL + "respect_school_link/AcceptInvite?inviteCode=" + output.parentApproveOn} +- assertVisible: + id: "app_title" + text: "Login" +- tapOn: "Username" +- inputText: "parentauser" +- tapOn: "Password" +- inputText: "test123" +- tapOn: "Login" +- assertVisible: + id: "app_title" + text: "Invitation" +- tapOn: "Select child" +- tapOn: "StudentB User" +- assertVisible: "Class name" +- assertVisible: "TestClass" +- assertVisible: "Role" +- assertVisible: "Student" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Accept Invite" +- assertVisible: + id: "app_title" + text: "Accounts" +- assertVisible: "ParentA User" +- assertVisible: "Pending requests" +- assertVisible: "StudentB User: TestClass (Student)" +- assertVisible: ".*Pending approval" +- assertVisible: "Family members" +- assertVisible: "StudentB User" + +# Admin approve user's request to join the class +- clearState: world.respect.app +- runFlow: "subflows/school_admin_login_flow.yaml" +- tapOn: "Classes" +- tapOn: "TestClass" +- repeat: + times: 3 + while: + visible: "Accept Invite" + commands: + - tapOn: "Accept Invite" +- assertVisible: "TeacherA User" +- assertVisible: "StudentA User" +- assertVisible: "StudentB User" +- assertNotVisible: "Pending requests" \ No newline at end of file diff --git a/.maestro/flows/001_002b_invite_existing_teacher_and_students_to_class_approval_disabled_test.yaml b/.maestro/flows/001_002b_invite_existing_teacher_and_students_to_class_approval_disabled_test.yaml new file mode 100644 index 000000000..394cc8c98 --- /dev/null +++ b/.maestro/flows/001_002b_invite_existing_teacher_and_students_to_class_approval_disabled_test.yaml @@ -0,0 +1,276 @@ +appId: world.respect.app +onFlowStart: + - clearState: world.respect.app + - runScript: + file: "scripts/school_init.js" + env: + TESTCONTROLLER_URL: ${TESTCONTROLLER_URL} + SCHOOL_ADMIN_PASSWORD: ${SCHOOL_ADMIN_PASSWORD} + DIR_ADMIN_AUTH_HEADER: ${DIR_ADMIN_AUTH_HEADER} + SCHOOL_URL: ${SCHOOL_URL} + SCHOOL_NAME: ${SCHOOL_NAME} + URL_SUBSTITUTION: ${URL_SUBSTITUTION} + NAME: "001_002b_invite_existing_teacher_and_students_to_class_approval_disabled_test" + +onFlowComplete: + - runScript: + file: "scripts/teardown.js" + +--- +#Admin add a users to the app and add a class named - TestClass +- runFlow: "subflows/school_admin_login_flow.yaml" + +- assertVisible: + id: "app_title" + text: "Apps" +- runFlow: + file: "subflows/user_add_class.yaml" + env: + CLASSNAME: "TestClass" + +- runFlow: + file: "subflows/add_person_to_app.yaml" + env: + FIRSTNAME: "TeacherA" + LASTNAME: "User" + GENDER: "Female" + ROLE: "Teacher" + USERNAME: "teacherauser" + PASSWORD: "test123" + +- runFlow: + file: "subflows/add_person_to_app.yaml" + env: + FIRSTNAME: "StudentA" + LASTNAME: "User" + GENDER: "Female" + ROLE: "Student" + USERNAME: "studentauser" + PASSWORD: "test123" + +- runFlow: + file: "subflows/add_person_to_app.yaml" + env: + FIRSTNAME: "StudentB" + LASTNAME: "User" + GENDER: "Female" + ROLE: "Student" + USERNAME: "studentbuser" + PASSWORD: "test123" + +- runFlow: + file: "subflows/add_person_to_app.yaml" + env: + FIRSTNAME: "ParentA" + LASTNAME: "User" + GENDER: "Female" + ROLE: "Parent" + USERNAME: "parentauser" + PASSWORD: "test123" + +# Add StudentB User as family member of Parent A +- tapOn: "People" +- tapOn: "ParentA User" +- tapOn: + id: "floating_action_button" # Edit button +- assertVisible: + id: "app_title" + text: "Edit person" +- tapOn: "Family member" +- assertVisible: "StudentA User" +- tapOn: "StudentB User" +- tapOn: "Save" +- assertVisible: "StudentB User" +- assertVisible: + id: "app_title" + text: "ParentA User" + +- tapOn: "Classes" +- tapOn: "TestClass" +- assertVisible: + id: "app_title" + text: "TestClass" +- tapOn: "Add Teacher" +- tapOn: "Invite person" +- assertVisible: + id: "app_title" + text: "Invite person" +- assertVisible: "Copy link" +- assertVisible: "Send link via SMS" +- assertVisible: "Send link via Email" +- assertVisible: "Share link" +# Approve OFF +- runFlow: + when: + notVisible: "Approval not required until:.*" + commands: + - tapOn: "Approval required" # turn the switch off + - assertVisible: "Approval not required until:.*" +- assertVisible: "Approval not required until:.*" +- scroll +- assertVisible: "Reset code" +- copyTextFrom: + id: "invite_url" + +# Teacher sign-up via invite link to the class as already logged-in user +- clearState: world.respect.app +- runFlow: + file: "subflows/school_user_login_flow.yaml" + env: + USERNAME: "teacherauser" + PASSWORD: "test123" +- assertVisible: + id: "app_title" + text: "Apps" + +- launchApp: + appId: "world.respect.app" + arguments: + launchUrl: ${maestro.copiedText} + +- assertVisible: + id: "app_title" + text: "Select account to continue" +- assertVisible: "TeacherA User" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "TeacherA User" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Class name" +- assertVisible: "TestClass" +- assertVisible: "Role" +- assertVisible: "Teacher" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Accept invite" +- assertVisible: + id: "app_title" + text: "TestClass" +- assertVisible: "TeacherA User" +- tapOn: "Add Student" +- tapOn: "Invite person" +- assertVisible: + id: "app_title" + text: "Invite person" +# Approve OFF +- runFlow: + when: + notVisible: "Approval not required until:.*" + commands: + - tapOn: "Approval required" # turn the switch ON + - assertVisible: "Approval not required until:.*" +- assertVisible: "Approval not required until:.*" +- tapOn: "Invite students directly" +- scroll +- assertVisible: "Reset code" +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.studentApproveOff = maestro.copiedText} + +- tapOn: "Invite via parents" +# Approve OFF +- runFlow: + when: + notVisible: "Approval not required until:.*" + commands: + - tapOn: "Approval required" # turn the switch ON + - assertVisible: "Approval not required until:.*" +- assertVisible: "Approval not required until:.*" +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.parentApproveOff = maestro.copiedText} + +# StudentA user joining class using invite QR code +- clearState: world.respect.app +- launchApp: + arguments: + respect_directory: ${output.SCHOOL_URL} +- tapOn: "Get Started" +- tapOn: "Scan QR code/badge" +- assertVisible: + id: "app_title" + text: "Scan QR code badge" +- tapOn: "More Options" +- tapOn: "Paste URL" +- tapOn: "Url" +- inputText: ${output.SCHOOL_URL + "respect_school_link/AcceptInvite?inviteCode=" + output.studentApproveOff} +- tapOn: "OK" +- assertVisible: + id: "app_title" + text: "Login" +- tapOn: "Username" +- inputText: "studentauser" +- tapOn: "Password" +- inputText: "test123" +- tapOn: "Login" +- runFlow: + when: + visible: "Save password for Respect?" + file: "subflows/save_password_prompt_cancel.yaml" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Class name" +- assertVisible: "TestClass" +- assertVisible: "Role" +- assertVisible: "Student" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Accept invite" +- assertVisible: + id: "app_title" + text: "TestClass" +- assertVisible: "StudentA User" + +# Parent user use the link to add StudentA User to class +- runFlow: + file: "subflows/openlink_flow.yaml" + env: + URL: ${output.SCHOOL_URL + "respect_school_link/AcceptInvite?inviteCode=" + output.parentApproveOff} +- assertVisible: + id: "app_title" + text: "Login" +- tapOn: "Username" +- inputText: "parentauser" +- tapOn: "Password" +- inputText: "test123" +- tapOn: "Login" +- assertVisible: + id: "app_title" + text: "Invitation" +- tapOn: "Select child" +- tapOn: "StudentB User" +- assertVisible: "Class name" +- assertVisible: "TestClass" +- assertVisible: "Role" +- assertVisible: "Student" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Accept Invite" +- assertVisible: + id: "app_title" + text: "TestClass" +- assertVisible: "StudentB User" + +# Teacher verifies the class members +- clearState: world.respect.app +- runFlow: + file: "subflows/school_user_login_flow.yaml" + env: + USERNAME: "teacherauser" + PASSWORD: "test123" +- tapOn: "Classes" +- assertVisible: + id: "app_title" + text: "Classes" +- tapOn: "TestClass" +- assertNotVisible: "Pending requests.*" +- assertVisible: "StudentA User" +- assertVisible: "StudentB User" \ No newline at end of file diff --git a/.maestro/flows/001_002c_invite_existing_students_and_parent_to_join as_family_member_approval_enabled_test.yaml b/.maestro/flows/001_002c_invite_existing_students_and_parent_to_join as_family_member_approval_enabled_test.yaml new file mode 100644 index 000000000..4ceb38ded --- /dev/null +++ b/.maestro/flows/001_002c_invite_existing_students_and_parent_to_join as_family_member_approval_enabled_test.yaml @@ -0,0 +1,202 @@ +appId: world.respect.app +onFlowStart: + - clearState: world.respect.app + - runScript: + file: "scripts/school_init.js" + env: + TESTCONTROLLER_URL: ${TESTCONTROLLER_URL} + SCHOOL_ADMIN_PASSWORD: ${SCHOOL_ADMIN_PASSWORD} + DIR_ADMIN_AUTH_HEADER: ${DIR_ADMIN_AUTH_HEADER} + SCHOOL_URL: ${SCHOOL_URL} + SCHOOL_NAME: ${SCHOOL_NAME} + URL_SUBSTITUTION: ${URL_SUBSTITUTION} + NAME: "001_002c_invite_existing_students_and_parent_to_join as_family_member_approval_enabled_test" + +onFlowComplete: + - runScript: + file: "scripts/teardown.js" + +--- +#Admin add a users to the app and add a class named - TestClass +- runFlow: "subflows/school_admin_login_flow.yaml" + +- assertVisible: + id: "app_title" + text: "Apps" +- runFlow: + file: "subflows/user_add_class.yaml" + env: + CLASSNAME: "TestClass" + +- runFlow: + file: "subflows/add_person_to_app.yaml" + env: + FIRSTNAME: "ParentA" + LASTNAME: "User" + GENDER: "Female" + ROLE: "Parent" + USERNAME: "parentauser" + PASSWORD: "test123" + +- runFlow: + file: "subflows/add_person_to_app.yaml" + env: + FIRSTNAME: "StudentA" + LASTNAME: "User" + GENDER: "Female" + ROLE: "Student" + USERNAME: "studentauser" + PASSWORD: "test123" + +- runFlow: + file: "subflows/add_person_to_app.yaml" + env: + FIRSTNAME: "StudentB" + LASTNAME: "User" + GENDER: "Male" + ROLE: "Student" + USERNAME: "studentbuser" + PASSWORD: "test123" + +- tapOn: "Classes" +- tapOn: "TestClass" +- assertVisible: + id: "app_title" + text: "TestClass" +- tapOn: "People" + +# Teacher send Family member invite to StudentA user to join ParentA User +- tapOn: "ParentA User" +- tapOn: + id: "floating_action_button" +- assertVisible: + id: "app_title" + text: "Edit person" +- tapOn: "Family member" +- tapOn: "Invite person" +- assertVisible: "ParentA User / Family member" +# Approval ON +- runFlow: + when: + notVisible: "Approval not required until:.*" + commands: + - tapOn: "Approval required" # turn the switch ON + - assertVisible: "Approval not required until:.*" +- assertVisible: "Approval not required until:.*" +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.studentApproveOn = maestro.copiedText} +- back +- back +- back + +# Admin send Family member invite to ParentA user to join StudentB User +- tapOn: "People" +- tapOn: "StudentB User" +- tapOn: + id: "floating_action_button" +- assertVisible: + id: "app_title" + text: "Edit person" +- tapOn: "Family member" +- tapOn: "Invite person" +- assertVisible: "StudentB User / Family member" +# Approval ON +- runFlow: + when: + visible: "Approval not required until:.*" + commands: + - tapOn: "Approval required" # turn the switch ON + - assertNotVisible: "Approval not required until:.*" +- assertNotVisible: "Approval not required until:.*" +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.parentApproveOn = maestro.copiedText} + + +#StudentA user using link to join the family member (ParentA user) +- runFlow: + file: "subflows/openlink_flow.yaml" + env: + URL: ${output.SCHOOL_URL + "respect_school_link/AcceptInvite?inviteCode=" + output.studentApproveOn} +- assertVisible: + id: "app_title" + text: "Login" +- tapOn: "Username" +- inputText: "studentauser" +- tapOn: "Password" +- inputText: "test123" +- tapOn: "Login" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Role" +- assertVisible: "Student" +- assertVisible: "Family member" +- assertVisible: "ParentA User" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Accept Invite" +- assertVisible: + id: "app_title" + text: "Accounts" +- assertVisible: "StudentA User" +- assertVisible: "Pending requests" +- assertVisible: "StudentA User: Family member (Student)" +- assertVisible: ".*Pending approval" + +#ParentA user using link to join the family member (StudentB user) +- runFlow: + file: "subflows/openlink_flow.yaml" + env: + URL: ${output.SCHOOL_URL + "respect_school_link/AcceptInvite?inviteCode=" + output.parentApproveOn} +- assertVisible: + id: "app_title" + text: "Login" +- tapOn: "Username" +- inputText: "parentauser" +- tapOn: "Password" +- inputText: "test123" +- tapOn: "Login" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Role" +- assertVisible: "Parent" +- assertVisible: "Family member" +- assertVisible: "StudentB User" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Accept Invite" +- assertVisible: + id: "app_title" + text: "Accounts" +- assertVisible: "ParentA User" +- assertVisible: "Pending requests" +- assertVisible: "ParentA User: Family member (parent)" +- assertVisible: ".*Pending approval" + +#admin approve user's requests to join the family member +- clearState: world.respect.app +- runFlow: "subflows/school_admin_login_flow.yaml" +- tapOn: "People" +- tapOn: "StudentB User" +- assertVisible: "Pending requests.*" +- tapOn: "Accept Invite" +- assertVisible: "Family members" +- assertVisible: "ParentA User" +- tapOn: "ParentA User" +- assertVisible: "Pending requests.*" +- assertVisible: "StudentA User.*" +- tapOn: "Accept Invite" +- assertNotVisible: "Pending requests.*" +- assertVisible: "Family members" +- assertVisible: "ParentA User" +- tapOn: "ParentA User" +- assertVisible: "Family members" +- assertVisible: "StudentA User" +- assertVisible: "StudentB User" \ No newline at end of file diff --git a/.maestro/flows/001_002d_invite_existing_students_and_parent_to_join as_family_member_approval_disabled_test.yaml b/.maestro/flows/001_002d_invite_existing_students_and_parent_to_join as_family_member_approval_disabled_test.yaml new file mode 100644 index 000000000..d2531ad8d --- /dev/null +++ b/.maestro/flows/001_002d_invite_existing_students_and_parent_to_join as_family_member_approval_disabled_test.yaml @@ -0,0 +1,213 @@ +appId: world.respect.app +onFlowStart: + - clearState: world.respect.app + - runScript: + file: "scripts/school_init.js" + env: + TESTCONTROLLER_URL: ${TESTCONTROLLER_URL} + SCHOOL_ADMIN_PASSWORD: ${SCHOOL_ADMIN_PASSWORD} + DIR_ADMIN_AUTH_HEADER: ${DIR_ADMIN_AUTH_HEADER} + SCHOOL_URL: ${SCHOOL_URL} + SCHOOL_NAME: ${SCHOOL_NAME} + URL_SUBSTITUTION: ${URL_SUBSTITUTION} + NAME: "001_002d_invite_existing_students_and_parent_to_join as_family_member_approval_disabled_test" + +onFlowComplete: + - runScript: + file: "scripts/teardown.js" + +--- +#Admin add a users to the app and add a class named - TestClass +- runFlow: "subflows/school_admin_login_flow.yaml" + +- assertVisible: + id: "app_title" + text: "Apps" +- runFlow: + file: "subflows/user_add_class.yaml" + env: + CLASSNAME: "TestClass" + +- runFlow: + file: "subflows/add_person_to_app.yaml" + env: + FIRSTNAME: "ParentA" + LASTNAME: "User" + GENDER: "Female" + ROLE: "Parent" + USERNAME: "parentauser" + PASSWORD: "test123" + +- runFlow: + file: "subflows/add_person_to_app.yaml" + env: + FIRSTNAME: "StudentA" + LASTNAME: "User" + GENDER: "Female" + ROLE: "Student" + USERNAME: "studentauser" + PASSWORD: "test123" + +- runFlow: + file: "subflows/add_person_to_app.yaml" + env: + FIRSTNAME: "StudentB" + LASTNAME: "User" + GENDER: "Male" + ROLE: "Student" + USERNAME: "studentbuser" + PASSWORD: "test123" + +- runFlow: + file: "subflows/add_person_to_app.yaml" + env: + FIRSTNAME: "TeacherA" + LASTNAME: "User" + GENDER: "Female" + ROLE: "Teacher" + USERNAME: "teacherauser" + PASSWORD: "test123" + +# Admin send Family member invite to StudentA user to join ParentA User +- tapOn: "People" +- tapOn: "ParentA User" +- tapOn: + id: "floating_action_button" +- assertVisible: + id: "app_title" + text: "Edit person" +- tapOn: "Family member" +- tapOn: "Invite person" +- assertVisible: "ParentA User / Family member" +# Approval OFF +- runFlow: + when: + notVisible: "Approval not required until:.*" + commands: + - tapOn: "Approval required" + - assertVisible: "Approval not required until:.*" +- assertVisible: "Approval not required until:.*" +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.studentApproveOff = maestro.copiedText} +- back +- back +- back + +# Admin send Family member invite to ParentA user to join StudentB User +- tapOn: "People" +- tapOn: "StudentB User" +- tapOn: + id: "floating_action_button" +- assertVisible: + id: "app_title" + text: "Edit person" +- tapOn: "Family member" +- tapOn: "Invite person" +- assertVisible: "StudentB User / Family member" +# Approval OFF +- runFlow: + when: + notVisible: "Approval not required until:.*" + commands: + - tapOn: "Approval required" + - assertVisible: "Approval not required until:.*" +- assertVisible: "Approval not required until:.*" +- copyTextFrom: + id: "invite_code" +- evalScript: ${output.parentApproveOff = maestro.copiedText} + + +#StudentA user using link to join the family member (ParentA user) +- runFlow: + file: "subflows/openlink_flow.yaml" + env: + URL: ${output.SCHOOL_URL + "respect_school_link/AcceptInvite?inviteCode=" + output.studentApproveOff} +- assertVisible: + id: "app_title" + text: "Login" +- tapOn: "Username" +- inputText: "studentauser" +- tapOn: "Password" +- inputText: "test123" +- tapOn: "Login" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Role" +- assertVisible: "Student" +- assertVisible: "Family member" +- assertVisible: "ParentA User" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Accept Invite" +- assertVisible: + id: "app_title" + text: "ParentA User" +- assertVisible: "Family members" +- assertVisible: "StudentA User" + +#ParentA user using link to join the family member (StudentB user) +- runFlow: + file: "subflows/openlink_flow.yaml" + env: + URL: ${output.SCHOOL_URL + "respect_school_link/AcceptInvite?inviteCode=" + output.parentApproveOff} +- assertVisible: + id: "app_title" + text: "Login" +- tapOn: "Username" +- inputText: "parentauser" +- tapOn: "Password" +- inputText: "test123" +- tapOn: "Login" +- assertVisible: + id: "app_title" + text: "Invitation" +- assertVisible: "Role" +- assertVisible: "Parent" +- assertVisible: "Family member" +- assertVisible: "StudentB User" +- assertVisible: "School name" +- assertVisible: ${output.SCHOOL_NAME} +- assertVisible: "School server URL" +- assertVisible: ${output.SCHOOL_URL} +- tapOn: "Accept Invite" +- assertVisible: + id: "app_title" + text: "StudentB User" +- assertVisible: "Family members" +- assertVisible: "ParentA User" +- tapOn: "ParentA User" +- assertVisible: "Family members" +- assertVisible: "StudentB User" +- assertVisible: "StudentA User" + +# Teacher tries to join as a family member using student's invite code +- clearState: world.respect.app +- runFlow: + file: "subflows/school_user_login_flow.yaml" + env: + USERNAME: "teacherauser" + PASSWORD: "test123" +- assertVisible: + id: "app_title" + text: "Apps" +- tapOn: + id: "user_account_icon" +- assertVisible: + id: "app_title" + text: "Accounts" +- tapOn: "Enter invite code" +- assertVisible: + id: "app_title" + text: "Enter invite code" +- tapOn: "Invite code" +- inputText: ${output.studentApproveOff} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Invitation" +- tapOn: "Accept Invite" +- assertVisible: "Sorry. Invalid invitation: not available for your user type." \ No newline at end of file diff --git a/.maestro/flows/001_002_add_user_direct_test.yaml b/.maestro/flows/001_003_add_user_direct_test.yaml similarity index 99% rename from .maestro/flows/001_002_add_user_direct_test.yaml rename to .maestro/flows/001_003_add_user_direct_test.yaml index 233225b13..4d38e60bb 100644 --- a/.maestro/flows/001_002_add_user_direct_test.yaml +++ b/.maestro/flows/001_003_add_user_direct_test.yaml @@ -10,7 +10,7 @@ onFlowStart: SCHOOL_URL: ${SCHOOL_URL} SCHOOL_NAME: ${SCHOOL_NAME} URL_SUBSTITUTION: ${URL_SUBSTITUTION} - NAME: "001_002_add_user_direct_test.yaml" + NAME: "001_002_add_user_direct_test" onFlowComplete: - runScript: diff --git a/.maestro/flows/001_003_login_using_school_link_test.yaml b/.maestro/flows/001_004_login_using_school_link_test.yaml similarity index 100% rename from .maestro/flows/001_003_login_using_school_link_test.yaml rename to .maestro/flows/001_004_login_using_school_link_test.yaml diff --git a/.maestro/flows/001_005_add_school_self_registration_test.yaml b/.maestro/flows/001_005_add_school_self_registration_test.yaml index 9c67b6774..7240f363e 100644 --- a/.maestro/flows/001_005_add_school_self_registration_test.yaml +++ b/.maestro/flows/001_005_add_school_self_registration_test.yaml @@ -46,6 +46,7 @@ onFlowComplete: id: "schoolUrl" - inputText: ${output.SCHOOL_URL} - tapOn: "Next" +- tapOn: "Create new account" - assertVisible: id: "app_title" text: "Invitation" diff --git a/.maestro/flows-disabled/003_admin_user_assigns_assignment_to_a_class_test.yaml b/.maestro/flows/flows-disabled/003_admin_user_assigns_assignment_to_a_class_test.yaml similarity index 65% rename from .maestro/flows-disabled/003_admin_user_assigns_assignment_to_a_class_test.yaml rename to .maestro/flows/flows-disabled/003_admin_user_assigns_assignment_to_a_class_test.yaml index 163d04183..e0005e50b 100644 --- a/.maestro/flows-disabled/003_admin_user_assigns_assignment_to_a_class_test.yaml +++ b/.maestro/flows/flows-disabled/003_admin_user_assigns_assignment_to_a_class_test.yaml @@ -16,8 +16,44 @@ onFlowComplete: - runScript: file: "scripts/teardown.js" --- -- runFlow: "subflows/admin_add_app_and_teacher.yaml" +- runFlow: "subflows/school_admin_login_flow.yaml" +- assertVisible: + id: "app_title" + text: "Apps" +- runFlow: "subflows/admin_add_app.yaml" +- tapOn: "People" +- tapOn: + id: "ExpandableFab" # +Person button +- tapOn: "Add new person" +- assertVisible: + id: "app_title" + text: "Add person" +- tapOn: "First names*" +- inputText: "TeacherA" +- tapOn: "Last name*" +- inputText: "User" +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: + id: "role" +- tapOn: "Teacher" +- tapOn: "Save" +- tapOn: "TeacherA User" +- tapOn: "Create account" +- assertVisible: "teacherauser" +- tapOn: "Password" +- inputText: "test123" +- tapOn: "Save" +- assertVisible: "Manage account" +- runFlow: + file: "subflows/user_add_class.yaml" + env: + CLASSNAME: "TestClass" +- assertVisible: + id: "app_title" + text: "TestClass" +#Teacher login to add class and assignment - clearState: world.respect.app - launchApp: arguments: @@ -32,11 +68,12 @@ onFlowComplete: - tapOn: id: "username" -- inputText: "teacheruser" +- inputText: "teacherauser" - tapOn: id : "password" -- inputText: "testt1" +- inputText: "test123" - tapOn: "Login" + - runFlow: when: visible: "Save password for Respect?" @@ -46,7 +83,7 @@ onFlowComplete: - assertVisible: id: "app_title" text: "Classes" -- assertVisible: "New Class" +- assertVisible: "TestClass" - tapOn: "Assignments" - tapOn: id: "floating_action_button" # +Assignment button @@ -58,7 +95,7 @@ onFlowComplete: - tapOn: "Assignment name*" - inputText: "Homework 1" - tapOn: "Class" -- tapOn: "New Class" +- tapOn: "TestClass" - tapOn: "Date" - runScript: file: "scripts/setDate.js" diff --git a/.maestro/flows/subflows/add_person_to_app.yaml b/.maestro/flows/subflows/add_person_to_app.yaml new file mode 100644 index 000000000..a0776ced4 --- /dev/null +++ b/.maestro/flows/subflows/add_person_to_app.yaml @@ -0,0 +1,52 @@ +appId: world.respect.app + +--- +- tapOn: "People" +- assertVisible: + id: "app_title" + text: "People" +- tapOn: + id: "ExpandableFab" # +Person button +- tapOn: "Add new person" +- assertVisible: + id: "app_title" + text: "Add person" +- tapOn: "First names*" +- inputText: ${FIRSTNAME} +- tapOn: "Last name*" +- inputText: ${LASTNAME} +- tapOn: "Gender*" +- tapOn: ${GENDER} +- tapOn: + id: "role" +- tapOn: ${ROLE} +- tapOn: "Save" +- assertVisible: + id: "app_title" + text: ${FIRSTNAME} ${LASTNAME} +- tapOn: "Create account" +- assertVisible: + id: "app_title" + text: "Create account" +- assertVisible: + id: "Username" + text: ${USERNAME} +- runFlow: + when: + notVisible: "Set password" + commands: + - tapOn: "Password" + - inputText: ${PASSWORD} + - tapOn: "Save" + - assertVisible: "Manage account" +- runFlow: + when: + visible: "Set password" + commands: + - tapOn: "Set password" + - tapOn: "Password*" + - inputText: ${PASSWORD} + - tapOn: "Save" + - assertVisible: + id: "password_change_btn" # Change password button +- back \ No newline at end of file diff --git a/.maestro/flows/subflows/admin_add_app.yaml b/.maestro/flows/subflows/admin_add_app.yaml index e5a4be4be..b5dee3870 100644 --- a/.maestro/flows/subflows/admin_add_app.yaml +++ b/.maestro/flows/subflows/admin_add_app.yaml @@ -8,7 +8,7 @@ appId: world.respect.app id: "floating_action_button" - tapOn: "Add from Link" - tapOn: "Link*" -- inputText: "https://respect.world/respect-ds/case_valid/appmanifest.json" +- inputText: ${output.SCHOOL_URL}static-resources/respect-ds/case_valid/appmanifest.json - tapOn: "Next" - assertVisible: id: "app_title" diff --git a/.maestro/flows/subflows/admin_add_app_and_teacher.yaml b/.maestro/flows/subflows/admin_add_app_and_teacher.yaml index 0fa24bc63..775917ad0 100644 --- a/.maestro/flows/subflows/admin_add_app_and_teacher.yaml +++ b/.maestro/flows/subflows/admin_add_app_and_teacher.yaml @@ -122,3 +122,5 @@ appId: world.respect.app - assertVisible: "Pending requests.*" - assertVisible: "Teacher User.*" - tapOn: "Accept Invite" +- assertNotVisible: "Pending requests.*" +- assertVisible: "Teacher User" \ No newline at end of file diff --git a/.maestro/flows/subflows/openlink_flow.yaml b/.maestro/flows/subflows/openlink_flow.yaml index 1e80d0e2f..2022af815 100644 --- a/.maestro/flows/subflows/openlink_flow.yaml +++ b/.maestro/flows/subflows/openlink_flow.yaml @@ -7,3 +7,4 @@ onFlowStart: arguments: launchUrl: ${URL} +- tapOn: "Get Started" \ No newline at end of file diff --git a/.maestro/flows/subflows/school_user_login_flow.yaml b/.maestro/flows/subflows/school_user_login_flow.yaml new file mode 100644 index 000000000..35ec0ad8b --- /dev/null +++ b/.maestro/flows/subflows/school_user_login_flow.yaml @@ -0,0 +1,26 @@ +appId: world.respect.app + +--- +- launchApp: + arguments: + respect_directory: ${output.SCHOOL_URL} + +- tapOn: "Get Started" + +- runFlow: + file: "get_started_select_school_by_name.yaml" + env: + SCHOOL_NAME: ${SCHOOL_NAME} + +- tapOn: + id: "username" +- inputText: ${USERNAME} +- tapOn: + id : "password" +- inputText: ${PASSWORD} +- tapOn: "Login" +- runFlow: + when: + visible: "Save password for Respect?" + file: "save_password_prompt_cancel.yaml" + diff --git a/.maestro/flows/subflows/admin_add_class.yaml b/.maestro/flows/subflows/user_add_class.yaml similarity index 100% rename from .maestro/flows/subflows/admin_add_class.yaml rename to .maestro/flows/subflows/user_add_class.yaml diff --git a/.maestro/flows/subflows/user_signup_using_invite_code.yaml b/.maestro/flows/subflows/user_signup_using_invite_code.yaml new file mode 100644 index 000000000..c34584653 --- /dev/null +++ b/.maestro/flows/subflows/user_signup_using_invite_code.yaml @@ -0,0 +1,39 @@ +appId: world.respect.app + +--- +# subflows/join_via_code.yaml +- clearState: world.respect.app +- launchApp: + arguments: + respect_directory: ${SCHOOL_URL} +- tapOn: "Get Started" +- runFlow: + file: "get_started_select_school_by_name.yaml" + env: + SCHOOL_NAME: ${SCHOOL_NAME} +- tapOn: "I have an invite code" +- tapOn: "Invite code" +- inputText: ${INVITE_CODE} +- tapOn: "Next" +- tapOn: "Next" # Accept Invitation screen +- tapOn: "Accept" # Terms screen +- tapOn: "Your name*" +- inputText: ${FULL_NAME} +- tapOn: "Gender*" +- tapOn: "Female" +- tapOn: "Your date of birth*" +- runScript: "scripts/setDate.js" +- inputText: ${output.pastYearDateP} +- tapOn: "Next" +- assertVisible: + id: "app_title" + text: "Create account" +- assertVisible: ${USERNAME} +- tapOn: "Next" +- tapOn: "Password*" +- inputText: "test123" +- tapOn: "Sign-up" +- runFlow: + when: + visible: "Save password for Respect?" + file: "save_password_prompt_cancel.yaml" \ No newline at end of file diff --git a/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt b/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt index f7c585a48..938c1ce5d 100644 --- a/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt +++ b/respect-app-compose/src/androidMain/kotlin/world/respect/AppKoinModule.kt @@ -109,7 +109,10 @@ import world.respect.shared.domain.account.invite.ApproveOrDeclineInviteRequestU import world.respect.shared.domain.account.invite.GetInviteInfoUseCase import world.respect.shared.domain.account.invite.GetInviteInfoUseCaseClient import world.respect.shared.domain.account.invite.RedeemInviteUseCase +import world.respect.shared.domain.account.invite.RedeemInviteExistingUserUseCase import world.respect.shared.domain.account.invite.RedeemInviteUseCaseClient +import world.respect.shared.domain.account.invite.RedeemInviteExistingUserUseCaseClient +import world.respect.shared.domain.navigation.inviteforexistingusernavigation.NavigateOnExistingUserInviteAcceptedUseCase import world.respect.shared.domain.navigation.onaccountcreated.NavigateOnAccountCreatedUseCase import world.respect.shared.domain.account.passkey.EncodeUserHandleUseCaseImpl import world.respect.shared.domain.account.passkey.GetPasskeyProviderInfoUseCaseImpl @@ -191,6 +194,7 @@ import world.respect.shared.viewmodel.clazz.list.ClazzListViewModel import world.respect.shared.viewmodel.learningunit.detail.LearningUnitDetailViewModel import world.respect.shared.viewmodel.learningunit.list.LearningUnitListViewModel import world.respect.shared.viewmodel.manageuser.accountlist.AccountListViewModel +import world.respect.shared.viewmodel.manageuser.selectaccount.SelectAccountViewModel import world.respect.shared.viewmodel.manageuser.acceptinvite.AcceptInviteViewModel import world.respect.shared.viewmodel.manageuser.enterpasswordsignup.EnterPasswordSignupViewModel import world.respect.shared.viewmodel.manageuser.getstarted.GetStartedViewModel @@ -356,6 +360,7 @@ val appKoinModule = module { viewModelOf(::OtherOptionsSignupViewModel) viewModelOf(::EnterPasswordSignupViewModel) viewModelOf(::AccountListViewModel) + viewModelOf(::SelectAccountViewModel) viewModelOf(::ManageAccountViewModel) viewModelOf(::PersonListViewModel) viewModelOf(::InvitePersonViewModel) @@ -622,7 +627,9 @@ val appKoinModule = module { } single { - ResolveUrlToNavCommandUseCase() + ResolveUrlToNavCommandUseCase( + respectAccountManager = get() + ) } single { @@ -769,6 +776,7 @@ val appKoinModule = module { ) } + scoped { CheckUsernameUniqueUseCaseClient( schoolUrl = SchoolDirectoryEntryScopeId.parse(id).schoolUrl, @@ -816,6 +824,9 @@ val appKoinModule = module { schoolUrl = SchoolDirectoryEntryScopeId.parse(id).schoolUrl ) } + scoped { + NavigateOnExistingUserInviteAcceptedUseCase() + } scoped { NavigateOnAccountCreatedUseCase( @@ -939,6 +950,14 @@ val appKoinModule = module { ) } + scoped { + RedeemInviteExistingUserUseCaseClient( + schoolUrl = RespectAccountScopeId.parse(id).schoolUrl, + httpClient = get(), + authTokenProvider= get() + ) + } + scoped { AddChildAccountUseCaseClient( schoolUrl = RespectAccountScopeId.parse(id).schoolUrl, diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/App.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/App.kt index 58ea4bf0b..b267db43e 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/App.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/App.kt @@ -202,7 +202,7 @@ fun App( topLevelItems = topLevelNavItems, onProfileClick = { if (activeAccount?.isChild == false) { - navController.navigate(AccountList) + navController.navigate(AccountList()) }else { coroutineScope.launch { val result = biometricAuthUseCase( @@ -215,7 +215,7 @@ fun App( ) if(result is BiometricAuthUseCase.BiometricResult.Success) { - navController.navigate(AccountList) + navController.navigate(AccountList()) } } } diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/AppNavHost.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/AppNavHost.kt index ad87b7675..79c5c788d 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/AppNavHost.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/app/AppNavHost.kt @@ -34,6 +34,7 @@ import world.respect.app.view.manageuser.enterinvitecode.EnterInviteCodeScreen import world.respect.app.view.manageuser.login.LoginScreen import world.respect.app.view.manageuser.otheroption.OtherOptionsScreen import world.respect.app.view.manageuser.otheroptionsignup.OtherOptionsSignupScreen +import world.respect.app.view.manageuser.selectAccount.SelectAccountScreen import world.respect.app.view.manageuser.signup.SignupScreen import world.respect.app.view.manageuser.termsandcondition.TermsAndConditionScreen import world.respect.app.view.manageuser.waitingforapproval.WaitingForApprovalScreen @@ -114,6 +115,7 @@ import world.respect.shared.navigation.RespectComposeNavController import world.respect.shared.navigation.ScanQRCode import world.respect.shared.navigation.SchoolDirectoryEdit import world.respect.shared.navigation.SchoolDirectoryList +import world.respect.shared.navigation.SelectAccount import world.respect.shared.navigation.Settings import world.respect.shared.navigation.SignupScreen import world.respect.shared.navigation.TermsAndCondition @@ -465,6 +467,7 @@ fun AppNavHost( ) } + composable { val viewModel: TermsAndConditionViewModel = respectViewModel( onSetAppUiState = onSetAppUiState, @@ -504,6 +507,15 @@ fun AppNavHost( ) } + composable { + SelectAccountScreen( + viewModel = respectViewModel( + onSetAppUiState = onSetAppUiState, + navController = respectNavController + ) + ) + } + composable { PersonListScreen( viewModel = respectViewModel( diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/components/RespectChildrenExposedDropDownMenuField.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/components/RespectChildrenExposedDropDownMenuField.kt new file mode 100644 index 000000000..12cc323df --- /dev/null +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/components/RespectChildrenExposedDropDownMenuField.kt @@ -0,0 +1,41 @@ +package world.respect.app.components + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import world.respect.datalayer.db.school.ext.fullName +import world.respect.datalayer.school.model.Person +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.select_child +import world.respect.shared.generated.resources.required +import world.respect.shared.resources.UiText +import world.respect.shared.util.ext.asUiText + +@Composable +fun RespectChildrenExposedDropDownMenuField( + value: Person?, + options: List, + onValueChanged: (Person) -> Unit, + modifier: Modifier = Modifier, + isError: Boolean = false, + enabled: Boolean = true, + errorText: UiText?, +) { + RespectExposedDropDownMenuField( + value = value, + options = options, + onOptionSelected = onValueChanged, + modifier = modifier, + itemText = { person -> + person.fullName() + }, + label = { + Text(uiTextStringResource(Res.string.select_child.asUiText()) ) + }, + supportingText = { + Text(uiTextStringResource(errorText ?: Res.string.required.asUiText())) + }, + isError = isError, + enabled = enabled, + ) +} \ No newline at end of file diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/acceptinvite/AcceptInviteScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/acceptinvite/AcceptInviteScreen.kt index 1c47c2a89..11c3acea7 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/acceptinvite/AcceptInviteScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/acceptinvite/AcceptInviteScreen.kt @@ -19,18 +19,26 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import world.respect.app.components.RespectChildrenExposedDropDownMenuField import world.respect.app.components.RespectDetailField import world.respect.app.components.defaultItemPadding import world.respect.app.components.uiTextStringResource import world.respect.datalayer.school.model.ClassInvite import world.respect.datalayer.school.model.NewUserInvite +import world.respect.datalayer.school.model.Person +import world.respect.datalayer.school.model.PersonRoleEnum.PARENT import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.accept_invite +import world.respect.shared.generated.resources.child import world.respect.shared.generated.resources.class_name +import world.respect.shared.generated.resources.family_member import world.respect.shared.generated.resources.loading import world.respect.shared.generated.resources.next +import world.respect.shared.generated.resources.parent import world.respect.shared.generated.resources.role import world.respect.shared.generated.resources.school_name import world.respect.shared.generated.resources.school_server_url +import world.respect.shared.generated.resources.student import world.respect.shared.util.ext.isLoading import world.respect.shared.util.ext.label import world.respect.shared.util.ext.roleLabel @@ -49,7 +57,8 @@ fun AcceptInviteScreen( AcceptInviteScreen( uiState = uiState, appUiState = appUiState, - onClickNext = viewModel::onClickNext + onClickNext = viewModel::onClickNext, + onChildSelected = viewModel::onChildSelected ) } @@ -57,10 +66,12 @@ fun AcceptInviteScreen( fun AcceptInviteScreen( uiState: AcceptInviteUiState, appUiState: AppUiState, - onClickNext: () -> Unit + onClickNext: () -> Unit, + onChildSelected: (Person) -> Unit ) { val invite = uiState.inviteInfo?.invite val errorText = uiState.errorText + val isParentInvite = uiState.inviteInfo?.familyPersonRole == PARENT Column(modifier = Modifier.fillMaxSize()) { when { @@ -98,6 +109,20 @@ fun AcceptInviteScreen( } invite != null -> { + if (uiState.showSelectChildDropDown) { + RespectChildrenExposedDropDownMenuField( + value = uiState.selectedChild, + options = uiState.children, + onValueChanged = { child -> + onChildSelected(child) + }, + modifier = Modifier + .fillMaxWidth() + .defaultItemPadding(), + isError = uiState.childError != null, + errorText = uiState.childError + ) + } when(invite) { is NewUserInvite -> { RespectDetailField( @@ -122,7 +147,34 @@ fun AcceptInviteScreen( } else -> { - //Do nothing else + + RespectDetailField( + modifier = Modifier.defaultItemPadding(), + label = { Text(stringResource(Res.string.role)) }, + value = { + Text( + stringResource( + if (isParentInvite) Res.string.student + else Res.string.parent + ) + ) + } + ) + + RespectDetailField( + modifier = Modifier.defaultItemPadding(), + label = { + Text( + stringResource( + if (isParentInvite) Res.string.family_member + else Res.string.child + ) + ) + }, + value = { + Text(uiState.inviteInfo?.familyPersonName.orEmpty()) + } + ) } } @@ -143,7 +195,7 @@ fun AcceptInviteScreen( modifier = Modifier.fillMaxWidth().defaultItemPadding(), enabled = uiState.nextButtonEnabled, ) { - Text(stringResource(Res.string.next)) + Text(stringResource(if (uiState.uid!=null) Res.string.accept_invite else Res.string.next)) } } } diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/accountlist/AccountListScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/accountlist/AccountListScreen.kt index 12e30efb2..0c9679f57 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/accountlist/AccountListScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/accountlist/AccountListScreen.kt @@ -5,11 +5,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.outlined.KeyboardArrowDown import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.ListItem @@ -19,23 +22,35 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate import androidx.compose.ui.unit.dp +import androidx.paging.compose.collectAsLazyPagingItems import org.jetbrains.compose.resources.stringResource import world.respect.app.components.RespectLongVersionInfoItem import world.respect.app.components.RespectPersonAvatar import world.respect.app.components.defaultItemPadding +import world.respect.app.components.respectPagingItems +import world.respect.app.components.respectRememberPager +import world.respect.app.view.clazz.detail.ClassPendingPersonListItem import world.respect.datalayer.db.school.ext.fullName +import world.respect.datalayer.school.model.EnrollmentRoleEnum import world.respect.datalayer.school.model.Person import world.respect.shared.domain.account.RespectAccount import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.add_account +import world.respect.shared.generated.resources.collapse_pending_invites import world.respect.shared.generated.resources.developed_by +import world.respect.shared.generated.resources.enter_code_label +import world.respect.shared.generated.resources.expand_pending_invites import world.respect.shared.generated.resources.family_members import world.respect.shared.generated.resources.license_text import world.respect.shared.generated.resources.logout +import world.respect.shared.generated.resources.pending_requests import world.respect.shared.generated.resources.profile import world.respect.shared.generated.resources.respect_is_open_source +import world.respect.shared.generated.resources.student import world.respect.shared.generated.resources.supported_by_spix_foundation +import world.respect.shared.generated.resources.use_another_account import world.respect.shared.viewmodel.manageuser.accountlist.AccountListUiState import world.respect.shared.viewmodel.manageuser.accountlist.AccountListViewModel @@ -51,7 +66,9 @@ fun AccountListScreen( onClickLogout = viewModel::onClickLogout, onClickFamilyPerson = viewModel::onClickFamilyPerson, onClickProfile = viewModel::onClickProfile, - ) + onClickEnterInviteCode = viewModel::onClickEnterInviteCode, + onTogglePendingSection = viewModel::onTogglePendingSection, + ) } @Composable @@ -60,11 +77,15 @@ fun AccountListScreen( onClickAccount: (RespectAccount) -> Unit, onClickFamilyPerson: (Person) -> Unit, onClickAddAccount: () -> Unit, + onClickEnterInviteCode: () -> Unit, onClickLogout: () -> Unit, + onTogglePendingSection: () -> Unit, onClickProfile: () -> Unit, ) { + val familyPersons = uiState.selectedAccount?.relatedPersons ?: emptyList() + LazyColumn(modifier = Modifier.fillMaxSize()) { uiState.selectedAccount?.also { activeAccount -> item("selected_account") { @@ -90,8 +111,64 @@ fun AccountListScreen( } ) } + + item("enter_invite_code") { + ListItem( + modifier = Modifier.clickable { + onClickEnterInviteCode() + }, + headlineContent = { + Text(stringResource(Res.string.enter_code_label)) + }, + leadingContent = { + Icon(Icons.Default.Code, contentDescription = "") + } + ) + } } + if (uiState.pendingEnrolmentPerson.isNotEmpty()) { + item("pending_header") { + ListItem( + modifier = Modifier + .clickable { onTogglePendingSection() }, + headlineContent = { + Text( + text = stringResource(Res.string.pending_requests) + ) + }, + trailingContent = { + Icon( + imageVector = Icons.Outlined.KeyboardArrowDown, + contentDescription = + if (uiState.isPendingExpanded) { + stringResource( + Res.string.collapse_pending_invites + ) + } else { + stringResource( + Res.string.expand_pending_invites + ) + }, + modifier = Modifier.size(24.dp) + .rotate( + if (uiState.isPendingExpanded) 0f else -90f + ), + ) + } + ) + } + } + if (uiState.isPendingExpanded) { + items( + items = uiState.pendingEnrolmentPerson, + key = { it.person.guid.hashCode() } + ) { item -> + PendingPersonEnrollmentItem( + personWithEnrollment = item + ) + } + } if (!familyPersons.isEmpty()) { item("family_member_header") { Text( @@ -139,12 +216,13 @@ fun AccountListScreen( } item("add_account") { + val text = if (uiState.isSelectAccountMode) Res.string.use_another_account else Res.string.add_account ListItem( modifier = Modifier.clickable { onClickAddAccount() }, headlineContent = { - Text(stringResource(Res.string.add_account)) + Text(stringResource(text)) }, leadingContent = { Icon(Icons.Default.Add, contentDescription = "") diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/accountlist/PendingPersonEnrollmentItem.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/accountlist/PendingPersonEnrollmentItem.kt new file mode 100644 index 000000000..e38fcbc2b --- /dev/null +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/accountlist/PendingPersonEnrollmentItem.kt @@ -0,0 +1,64 @@ +package world.respect.app.view.manageuser.accountlist + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import world.respect.app.components.RespectPersonAvatar +import world.respect.datalayer.db.school.ext.fullName +import world.respect.datalayer.school.model.PersonWithEnrollment +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.dismiss_invite +import world.respect.shared.generated.resources.pending_approval + +@Composable +fun PendingPersonEnrollmentItem( + personWithEnrollment: PersonWithEnrollment?, +) { + ListItem( + modifier = Modifier.fillMaxWidth(), + leadingContent = { + + RespectPersonAvatar( + name = personWithEnrollment?.person?.fullName().toString() + ) + }, + headlineContent = { + Text( + text = personWithEnrollment?.person?.fullName() + ": " + +personWithEnrollment?.clazz + + "(${personWithEnrollment?.person?.roles?.firstOrNull()?.roleEnum?.name})" + ) + }, + supportingContent = { + val pendingApproval = personWithEnrollment?.enrollment?.endDate?.toEpochDays() ?: "" + Text( + text = "$pendingApproval ${stringResource(Res.string.pending_approval)}" + ) + + }, + trailingContent = { + + + IconButton( + onClick = { + }, + enabled = personWithEnrollment != null + ) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = Icons.Outlined.Cancel, + contentDescription = stringResource(resource = Res.string.dismiss_invite) + ) + } + } + ) +} \ No newline at end of file diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/login/LoginScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/login/LoginScreen.kt index 8b6a0f196..94112a3e0 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/login/LoginScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/login/LoginScreen.kt @@ -27,6 +27,7 @@ import world.respect.app.components.defaultScreenPadding import world.respect.app.components.uiTextStringResource import world.respect.shared.domain.account.username.validateusername.ValidateUsernameUseCase import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.create_new_account import world.respect.shared.generated.resources.i_have_an_invite_code import world.respect.shared.generated.resources.login import world.respect.shared.generated.resources.password_label @@ -50,6 +51,7 @@ fun LoginScreen( onPasswordChanged = viewModel::onPasswordChanged, onClickLogin = viewModel::onClickLogin, onClickInviteCode = viewModel::onClickInviteCode, + onClickCreateNewAccount = viewModel::onClickCreateNewAccount, ) } @@ -60,7 +62,8 @@ fun LoginScreen( onUsernameChanged: (String) -> Unit, onPasswordChanged: (String) -> Unit, onClickLogin: () -> Unit, - onClickInviteCode: () -> Unit = {} + onClickInviteCode: () -> Unit = {}, + onClickCreateNewAccount: () -> Unit = {}, ) { Column( modifier = Modifier @@ -110,14 +113,22 @@ fun LoginScreen( ) { Text(text = stringResource(Res.string.login)) } - - OutlinedButton( - onClick = onClickInviteCode, - modifier = Modifier.fillMaxWidth().defaultItemPadding() - ) { - Text(text = stringResource(Res.string.i_have_an_invite_code)) + if (!uiState.acceptInviteMode) { + OutlinedButton( + onClick = onClickInviteCode, + modifier = Modifier.fillMaxWidth().defaultItemPadding() + ) { + Text(text = stringResource(Res.string.i_have_an_invite_code)) + } + } + if (uiState.acceptInviteMode) { + OutlinedButton( + onClick = onClickCreateNewAccount, + modifier = Modifier.fillMaxWidth().defaultItemPadding() + ) { + Text(text = stringResource(Res.string.create_new_account)) + } } - uiState.errorText?.also { Text( uiTextStringResource(it), diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/selectAccount/SelectAccountScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/selectAccount/SelectAccountScreen.kt new file mode 100644 index 000000000..6c0fa9cc9 --- /dev/null +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/manageuser/selectAccount/SelectAccountScreen.kt @@ -0,0 +1,83 @@ +package world.respect.app.view.manageuser.selectAccount + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import org.jetbrains.compose.resources.stringResource +import world.respect.app.view.manageuser.accountlist.AccountListItem +import world.respect.shared.domain.account.RespectAccount +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.use_another_account +import world.respect.shared.viewmodel.manageuser.selectaccount.SelectAccountUiState +import world.respect.shared.viewmodel.manageuser.selectaccount.SelectAccountViewModel + +@Composable +fun SelectAccountScreen( + viewModel: SelectAccountViewModel, +) { + val uiState by viewModel.uiState.collectAsState() + SelectAccountScreen( + uiState = uiState, + onClickAccount = viewModel::onClickAccount, + onClickAddAccount = viewModel::onClickAddAccount, + ) +} + +@Composable +fun SelectAccountScreen( + uiState: SelectAccountUiState, + onClickAccount: (RespectAccount) -> Unit, + onClickAddAccount: () -> Unit, +) { + + + + LazyColumn(modifier = Modifier.fillMaxSize()) { + + + item("divider1") { + HorizontalDivider() + } + + items( + items = uiState.accounts, + key = { it.session.account.userGuid } + ) { account -> + AccountListItem( + account = account, + onClickAccount = onClickAccount, + ) + } + + item("divider2") { + HorizontalDivider() + } + + item("add_account") { + val text = Res.string.use_another_account + ListItem( + modifier = Modifier.clickable { + onClickAddAccount() + }, + headlineContent = { + Text(stringResource(text)) + }, + leadingContent = { + Icon(Icons.Default.Add, contentDescription = "") + } + ) + } + + } +} diff --git a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/person/inviteperson/InvitePersonScreen.kt b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/person/inviteperson/InvitePersonScreen.kt index 1abf71dfd..2791d1128 100644 --- a/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/person/inviteperson/InvitePersonScreen.kt +++ b/respect-app-compose/src/commonMain/kotlin/world/respect/app/view/person/inviteperson/InvitePersonScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable @@ -50,6 +51,7 @@ import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.approval_not_required_until import world.respect.shared.generated.resources.approval_required import world.respect.shared.generated.resources.copy_link +import world.respect.shared.generated.resources.family_member import world.respect.shared.generated.resources.invite_code_label import world.respect.shared.generated.resources.invite_students_directly import world.respect.shared.generated.resources.invite_via_email @@ -135,6 +137,15 @@ fun InvitePersonScreen( uiState.inviteUrl?.also { link -> val linkStr = link.toString() + uiState.childName?.let { + Text( + text = "$it / "+stringResource(Res.string.family_member), + textAlign = TextAlign.Center, + modifier = Modifier.align(Alignment.CenterHorizontally) + .padding(vertical = 8.dp), + ) + } + Image( painter = rememberQrCodePainter(linkStr), contentDescription = stringResource(Res.string.qr_code), diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/InviteDataSourceDb.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/InviteDataSourceDb.kt index 1cd7ce281..0f8436b40 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/InviteDataSourceDb.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/InviteDataSourceDb.kt @@ -16,6 +16,7 @@ import world.respect.datalayer.db.school.adapters.toModel import world.respect.datalayer.exceptions.ForbiddenException import world.respect.datalayer.school.InviteDataSource import world.respect.datalayer.school.InviteDataSourceLocal +import world.respect.datalayer.school.PersonDataSourceLocal import world.respect.datalayer.school.domain.CheckPersonPermissionUseCase import world.respect.datalayer.school.model.ClassInvite import world.respect.datalayer.school.model.EnrollmentRoleEnum @@ -114,7 +115,23 @@ class InviteDataSourceDb( else -> { //Family member invite - val knownPersonUid = (inviteToStore as? FamilyMemberInvite)?.personUid ?: "0" + val knownPersonUid = + (inviteToStore as? FamilyMemberInvite)?.personUid ?: "0" + val authGuid = authenticatedUser.guid + + if (authGuid != knownPersonUid) { + val hasWrite = checkPersonPermissionUseCase( + otherPersonUid = knownPersonUid, + otherPersonKnownRole = null, + permissionsRequiredByRole = CheckPersonPermissionUseCase.PermissionsRequiredByRole.WRITE_PERMISSIONS + ) + + if (!hasWrite) { + throw Exception( + "user does not have permission to create family invite for $knownPersonUid" + ) + } + } } } } diff --git a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/AuthTokenEntityDao.kt b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/AuthTokenEntityDao.kt index 9b2628557..a3d4f7088 100644 --- a/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/AuthTokenEntityDao.kt +++ b/respect-datalayer-db/src/commonMain/kotlin/world/respect/datalayer/db/school/daos/AuthTokenEntityDao.kt @@ -23,5 +23,17 @@ interface AuthTokenEntityDao { timeNow: Long, ): AuthTokenEntity? + @Query(""" + SELECT AuthTokenEntity.* + FROM AuthTokenEntity + WHERE AuthTokenEntity.atPGuid = :guid + AND :timeNow BETWEEN AuthTokenEntity.atTimeCreated + AND (AuthTokenEntity.atTimeCreated + (AuthTokenEntity.atTtl * 1000)) + """) + suspend fun findByPersonGuid( + guid: String, + timeNow: Long, + ): AuthTokenEntity? + } \ No newline at end of file diff --git a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/EnrollmentDataSourceHttp.kt b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/EnrollmentDataSourceHttp.kt index 44e5f74a0..65ebbdbc9 100644 --- a/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/EnrollmentDataSourceHttp.kt +++ b/respect-datalayer-http/src/commonMain/kotlin/world/respect/datalayer/http/school/EnrollmentDataSourceHttp.kt @@ -2,7 +2,6 @@ package world.respect.datalayer.http.school import io.github.aakira.napier.Napier import io.ktor.client.HttpClient -import io.ktor.client.request.delete import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.ContentType diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/respect/model/invite/RespectInviteInfo.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/respect/model/invite/RespectInviteInfo.kt index 33e911dcd..f7783797c 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/respect/model/invite/RespectInviteInfo.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/respect/model/invite/RespectInviteInfo.kt @@ -2,6 +2,7 @@ package world.respect.datalayer.respect.model.invite import kotlinx.serialization.Serializable import world.respect.datalayer.school.model.Invite2 +import world.respect.datalayer.school.model.PersonRoleEnum /** * @property invite: The invite itself @@ -13,6 +14,8 @@ import world.respect.datalayer.school.model.Invite2 class RespectInviteInfo( val classGuid: String?=null, val className: String?=null, + val familyPersonName: String?=null, + val familyPersonRole: PersonRoleEnum?=null, val userInviteType: UserInviteType?=null, val invite: Invite2? = null ) { diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/EnrollmentDataSource.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/EnrollmentDataSource.kt index d7b09ba6a..c3301bbb9 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/EnrollmentDataSource.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/EnrollmentDataSource.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.datetime.LocalDate import world.respect.datalayer.DataLayerParams import world.respect.datalayer.DataLayerParams.ACTIVE_ON_DAY +import world.respect.datalayer.DataLayerParams.INCLUDE_RELATED import world.respect.datalayer.DataLayerParams.ORDER_BY import world.respect.datalayer.DataLoadParams import world.respect.datalayer.DataLoadState @@ -43,6 +44,7 @@ interface EnrollmentDataSource: WritableDataSource { val personUid: String? = null, val activeOnDay: LocalDate? = null, val orderBy: OrderBy = OrderBy.STORED_ASC, + val includeRelated: Boolean = false, ) { companion object { @@ -55,6 +57,7 @@ interface EnrollmentDataSource: WritableDataSource { EnrollmentRoleEnum.fromValue(it) }, personUid = params[FILTER_BY_PERSON_UID], + includeRelated = params[INCLUDE_RELATED].toBoolean(), activeOnDay = params[ACTIVE_ON_DAY]?.let { LocalDate.parse(it) }, orderBy = params[ORDER_BY]?.let { OrderBy.fromValue(it) diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ext/EnrollmentExt.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ext/EnrollmentExt.kt index e4cb91773..3814cc712 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ext/EnrollmentExt.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/ext/EnrollmentExt.kt @@ -1,6 +1,11 @@ package world.respect.datalayer.school.ext +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive import world.respect.datalayer.school.model.Enrollment +import world.respect.datalayer.school.model.Enrollment.Companion.METADATA_KEY_CLASS_NAME import world.respect.datalayer.school.model.EnrollmentRoleEnum fun Enrollment.copyAsApproved(): Enrollment { @@ -12,4 +17,25 @@ fun Enrollment.copyAsApproved(): Enrollment { else -> currentRole } ) +} + +fun Enrollment.copyWithClassName( + className: String +): Enrollment { + return copy( + metadata = buildJsonObject { + this@copyWithClassName.metadata?.also { + putAll(it) + } + + put(METADATA_KEY_CLASS_NAME, JsonPrimitive(className)) + } + ) +} + +fun Enrollment.getClassName(): String? { + return metadata + ?.get(METADATA_KEY_CLASS_NAME) + ?.jsonPrimitive + ?.contentOrNull } \ No newline at end of file diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/Enrollment.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/Enrollment.kt index 1c3424542..165ac4219 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/Enrollment.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/Enrollment.kt @@ -30,6 +30,8 @@ data class Enrollment( ): ModelWithTimes { companion object { + const val METADATA_KEY_CLASS_NAME = "className" + const val TABLE_ID = 6 diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/Invite.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/Invite.kt index 5df9eab25..50542a5e3 100644 --- a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/Invite.kt +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/Invite.kt @@ -120,10 +120,15 @@ data class ClassInvite( data class FamilyMemberInvite( override val uid: String, override val code: String, - override val approvalRequiredAfter: InstantAsISO8601, + override val approvalRequiredAfter: InstantAsISO8601= Instant.fromEpochMilliseconds(0), override val lastModified: InstantAsISO8601 = Clock.System.now(), override val stored: InstantAsISO8601 = Clock.System.now(), override val status: StatusEnum = StatusEnum.ACTIVE, val personUid: String, -): Invite2 +): Invite2 { + companion object { + fun uidFor(personUid: String?): String = "$TYPE_FAMILY_MEMBER:$personUid" + } +} + diff --git a/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PersonWithEnrollment.kt b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PersonWithEnrollment.kt new file mode 100644 index 000000000..a4395a32d --- /dev/null +++ b/respect-datalayer/src/commonMain/kotlin/world/respect/datalayer/school/model/PersonWithEnrollment.kt @@ -0,0 +1,7 @@ +package world.respect.datalayer.school.model + +data class PersonWithEnrollment( + val person: Person, + val clazz : String, + val enrollment: Enrollment +) \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/composeResources/values/strings.xml b/respect-lib-shared/src/commonMain/composeResources/values/strings.xml index 3d960637d..af8f1ab86 100644 --- a/respect-lib-shared/src/commonMain/composeResources/values/strings.xml +++ b/respect-lib-shared/src/commonMain/composeResources/values/strings.xml @@ -110,6 +110,7 @@ Email Phone number Create account + Create new account Username QR Code Badge Manage account @@ -498,6 +499,7 @@ System administrator Site administrator Role + Child Date Time @@ -569,6 +571,12 @@ Supported by Spix Foundation. Select Host + Select child + Pending approval + Message + Link is + Select account to continue + Use another account Username already taken diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/InviteRecipientType.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/InviteRecipientType.kt new file mode 100644 index 000000000..31bea356c --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/InviteRecipientType.kt @@ -0,0 +1,6 @@ +package world.respect.shared.domain.account.invite + +enum class InviteRecipientType { + NEW_USER, + EXISTING_USER +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteExistingUserUseCase.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteExistingUserUseCase.kt new file mode 100644 index 000000000..2189b97e4 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteExistingUserUseCase.kt @@ -0,0 +1,10 @@ +package world.respect.shared.domain.account.invite + +interface RedeemInviteExistingUserUseCase { + + suspend operator fun invoke( + redeemRequest: RespectRedeemInviteRequest, + selectedChildGuid : String ?=null + ) + +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteExistingUserUseCaseClient.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteExistingUserUseCaseClient.kt new file mode 100644 index 000000000..4a0e769cb --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteExistingUserUseCaseClient.kt @@ -0,0 +1,43 @@ +package world.respect.shared.domain.account.invite + + +import io.ktor.client.HttpClient +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.Url +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import world.respect.datalayer.AuthTokenProvider +import world.respect.datalayer.ext.useTokenProvider +import world.respect.libutil.ext.appendEndpointSegments + +class RedeemInviteExistingUserUseCaseClient( + private val schoolUrl: Url, + private val httpClient: HttpClient, + private val authTokenProvider: AuthTokenProvider, + ) : RedeemInviteExistingUserUseCase { + + override suspend fun invoke( + redeemRequest: RespectRedeemInviteRequest, + selectedChildGuid: String? + ) { + val response = httpClient.post( + schoolUrl.appendEndpointSegments("api/school/respect/existingUserRedeem") + ) { + contentType(ContentType.Application.Json) + useTokenProvider(authTokenProvider) + parameter("selectedChildGuid", selectedChildGuid) + setBody(redeemRequest) + } + + if (!response.status.isSuccess()) { + throw IllegalArgumentException( + response.bodyAsText().ifBlank { "Something went wrong with invite" } + ) as Throwable + } + } + +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RespectRedeemInviteRequest.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RespectRedeemInviteRequest.kt index 2960a5477..63d173587 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RespectRedeemInviteRequest.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/account/invite/RespectRedeemInviteRequest.kt @@ -18,7 +18,8 @@ data class RespectRedeemInviteRequest( val deviceName: String? = null, val deviceInfo: DeviceInfo? = null, val invite: Invite2, -) { + val recipientType: InviteRecipientType = InviteRecipientType.NEW_USER, + ) { @Serializable data class PersonInfo( diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/navigation/inviteforexistingusernavigation/NavigateOnExistingUserInviteAcceptedUseCase.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/navigation/inviteforexistingusernavigation/NavigateOnExistingUserInviteAcceptedUseCase.kt new file mode 100644 index 000000000..b9aa8b3c3 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/navigation/inviteforexistingusernavigation/NavigateOnExistingUserInviteAcceptedUseCase.kt @@ -0,0 +1,57 @@ +package world.respect.shared.domain.navigation.inviteforexistingusernavigation + +import kotlinx.coroutines.flow.MutableSharedFlow +import world.respect.datalayer.school.ext.isApprovalRequiredNow +import world.respect.datalayer.school.model.ClassInvite +import world.respect.datalayer.school.model.FamilyMemberInvite +import world.respect.datalayer.school.model.Person +import world.respect.datalayer.school.model.PersonStatusEnum +import world.respect.shared.domain.account.invite.RespectRedeemInviteRequest +import world.respect.shared.navigation.AccountList +import world.respect.shared.navigation.ClazzDetail +import world.respect.shared.navigation.NavCommand +import world.respect.shared.navigation.PersonDetail + +class NavigateOnExistingUserInviteAcceptedUseCase { + + operator fun invoke( + person: Person?, + parentUid: String? = null, + inviteRequest: RespectRedeemInviteRequest, + navCommandFlow: MutableSharedFlow, + ) { + val approvalRequired = inviteRequest.invite.isApprovalRequiredNow() + val destination = when (val invite = inviteRequest.invite) { + + is ClassInvite -> { + + if (approvalRequired) { + AccountList() + } else { + ClazzDetail( + guid = invite.classUid + ) + } + } + + is FamilyMemberInvite -> { + if (person?.status == PersonStatusEnum.PENDING_APPROVAL) { + AccountList() + } else { + PersonDetail( + guid = parentUid ?: invite.personUid + ) + } + } + + else -> AccountList() + } + + navCommandFlow.tryEmit( + NavCommand.Navigate( + destination = destination, + clearBackStack = true + ) + ) + } +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/urltonavcommand/ResolveUrlToNavCommandUseCase.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/urltonavcommand/ResolveUrlToNavCommandUseCase.kt index 127ece4fd..176bd335d 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/urltonavcommand/ResolveUrlToNavCommandUseCase.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/domain/urltonavcommand/ResolveUrlToNavCommandUseCase.kt @@ -2,16 +2,21 @@ package world.respect.shared.domain.urltonavcommand import io.ktor.http.Url import world.respect.libutil.ext.schoolUrlOrNull +import world.respect.shared.domain.account.RespectAccountManager import world.respect.shared.domain.createlink.CreateInviteLinkUseCase import world.respect.shared.navigation.AcceptInvite +import world.respect.shared.navigation.LoginScreen import world.respect.shared.navigation.NavCommand +import world.respect.shared.navigation.SelectAccount /** * Given a Url (that may have come from a deep link, scanned as a qr code, etc) that * follows the respect school link format (See UrlExt.schoolUrlOrNull) resolve this into a * NavCommand. */ -class ResolveUrlToNavCommandUseCase { +class ResolveUrlToNavCommandUseCase( + private val respectAccountManager: RespectAccountManager +) { operator fun invoke( url: Url, @@ -21,15 +26,25 @@ class ResolveUrlToNavCommandUseCase { val lastSegment = url.segments.lastOrNull() ?: return null - return when(lastSegment) { + return when (lastSegment) { CreateInviteLinkUseCase.PATH -> { url.parameters[CreateInviteLinkUseCase.QUERY_PARAM]?.let { inviteCode -> - NavCommand.Navigate( - destination = AcceptInvite.create( + val destination = if (respectAccountManager.activeAccount != null) { + + SelectAccount( + inviteCode = inviteCode + ) + } else { + + LoginScreen.create( schoolUrl = schoolUrl, - code = inviteCode, - canGoBack = canGoBack, - ), clearBackStack = false + inviteCode = inviteCode, + ) + } + + NavCommand.Navigate( + destination = destination, + clearBackStack = false ) } } diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/navigation/AppRoutes.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/navigation/AppRoutes.kt index 073353e1b..58c45cf20 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/navigation/AppRoutes.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/navigation/AppRoutes.kt @@ -48,14 +48,15 @@ data class Acknowledgement ( } @Serializable data class EnterInviteCode( - val schoolUrlStr: String + val schoolUrlStr: String, + val personGuid: String? = null ) : RespectAppRoute { @Transient val schoolUrl = Url(schoolUrlStr) companion object { - fun create(schoolUrl: Url) = EnterInviteCode(schoolUrl.toString()) + fun create(schoolUrl: Url, guid: String? = null ) = EnterInviteCode(schoolUrl.toString(),guid) } } @@ -85,13 +86,20 @@ object SchoolDirectoryEdit : RespectAppRoute @Serializable data class LoginScreen( val schoolUrlStr: String, + val inviteCode: String? = null ) : RespectAppRoute { @Transient val schoolUrl = Url(schoolUrlStr) companion object { - fun create(schoolUrl: Url) = LoginScreen(schoolUrl.toString()) + fun create( + schoolUrl: Url, + inviteCode: String? = null, + ) = LoginScreen( + schoolUrlStr = schoolUrl.toString(), + inviteCode = inviteCode + ) } } @@ -403,6 +411,7 @@ class AcceptInvite( val schoolUrlStr: String, val code: String, val canGoBack: Boolean = true, + val personGuid: String? = null ) : RespectAppRoute { @Transient @@ -413,10 +422,12 @@ class AcceptInvite( schoolUrl: Url, code: String, canGoBack: Boolean = true, + guid: String? = null ) = AcceptInvite( schoolUrlStr = schoolUrl.toString(), code = code, canGoBack = canGoBack, + personGuid = guid ) } } @@ -584,11 +595,15 @@ class LearningUnitViewer( } } - @Serializable -object AccountList : RespectAppRoute - +data class SelectAccount( + val inviteCode: String? = null, +) : RespectAppRoute +@Serializable +data class AccountList( + val inviteCode: String? = null, +) : RespectAppRoute /** * @property addToClassUid if the PersonList screen has been navigated when the user clicks * add student or add teacher on the ClassDetail screen, then the classUid. @@ -840,6 +855,11 @@ data class InvitePerson( val inviteUid: String, ): InvitePersonOptions + @Serializable + @SerialName("family") + data class FamilyInviteOptions( + val personUid: String + ) : InvitePersonOptions @Transient val invitePersonOptions: InvitePersonOptions = Json.decodeFromString( diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/util/ext/InviteExt.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/util/ext/InviteExt.kt index b46fa0525..43175ba19 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/util/ext/InviteExt.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/util/ext/InviteExt.kt @@ -13,7 +13,7 @@ val Invite2.roleLabel: StringResource is NewUserInvite -> this.role.label is ClassInvite -> if(this.inviteMode == ClassInviteModeEnum.VIA_PARENT) { - PersonRoleEnum.PARENT.label + PersonRoleEnum.STUDENT.label }else { this.role.label } diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/acceptinvite/AcceptInviteViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/acceptinvite/AcceptInviteViewModel.kt index b032e6d10..c751f2f4f 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/acceptinvite/AcceptInviteViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/acceptinvite/AcceptInviteViewModel.kt @@ -9,21 +9,36 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json import org.koin.core.component.KoinScopeComponent +import org.koin.core.component.inject import org.koin.core.scope.Scope import world.respect.credentials.passkey.RespectPasswordCredential +import world.respect.datalayer.DataLoadState +import world.respect.datalayer.DataLoadingState import world.respect.datalayer.RespectAppDataSource +import world.respect.datalayer.SchoolDataSource import world.respect.datalayer.ext.dataOrNull import world.respect.datalayer.respect.model.SchoolDirectoryEntry import world.respect.datalayer.respect.model.invite.RespectInviteInfo +import world.respect.datalayer.school.PersonDataSource import world.respect.datalayer.school.ext.isChildUser +import world.respect.datalayer.school.model.ClassInvite +import world.respect.datalayer.school.model.FamilyMemberInvite import world.respect.datalayer.school.model.Person +import world.respect.datalayer.school.model.PersonRoleEnum +import world.respect.datalayer.shared.params.GetListCommonParams import world.respect.lib.opds.model.LangMap +import world.respect.shared.domain.account.RespectAccountManager import world.respect.shared.domain.account.invite.GetInviteInfoUseCase +import world.respect.shared.domain.account.invite.InviteRecipientType +import world.respect.shared.domain.account.invite.RedeemInviteExistingUserUseCase import world.respect.shared.domain.account.invite.RespectRedeemInviteRequest import world.respect.shared.domain.account.invite.RespectRedeemInviteRequest.PersonInfo import world.respect.shared.domain.getdeviceinfo.GetDeviceInfoUseCase import world.respect.shared.domain.getdeviceinfo.toUserFriendlyString +import world.respect.shared.domain.navigation.inviteforexistingusernavigation.NavigateOnExistingUserInviteAcceptedUseCase import world.respect.shared.domain.school.SchoolPrimaryKeyGenerator import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.invitation @@ -32,6 +47,8 @@ import world.respect.shared.navigation.AcceptInvite import world.respect.shared.navigation.NavCommand import world.respect.shared.navigation.SignupScreen import world.respect.shared.navigation.TermsAndCondition +import world.respect.shared.resources.StringResourceUiText +import world.respect.shared.resources.StringUiText import world.respect.shared.resources.UiText import world.respect.shared.util.di.SchoolDirectoryEntryScopeId import world.respect.shared.util.ext.asUiText @@ -43,7 +60,24 @@ data class AcceptInviteUiState( val isTeacherInvite: Boolean = false, val schoolName: LangMap? = null, val schoolUrl: Url? = null, -) { + val uid: String? = null, + val persons: DataLoadState> = DataLoadingState(), + val selectedChildGuid: String? = null, + val showSelectChildDropDown: Boolean = false, + val childError: UiText? = null, + ) { + val person: Person? + get() = persons.dataOrNull()?.firstOrNull { it.guid == uid } + + val familyMembers: List + get() = persons.dataOrNull()?.filter { it.guid != uid } ?: emptyList() + + val children: List + get() = familyMembers.filter { member -> + member.roles.any { it.roleEnum == PersonRoleEnum.STUDENT } + } + val selectedChild: Person? + get() = children.firstOrNull { it.guid == selectedChildGuid } val nextButtonEnabled: Boolean get() = inviteInfo?.invite != null @@ -51,23 +85,41 @@ data class AcceptInviteUiState( class AcceptInviteViewModel( savedStateHandle: SavedStateHandle, + private val json: Json, private val getDeviceInfoUseCase: GetDeviceInfoUseCase, private val respectAppDataSource: RespectAppDataSource, -) : RespectViewModel(savedStateHandle), KoinScopeComponent { + accountManager: RespectAccountManager, + ) : RespectViewModel(savedStateHandle), KoinScopeComponent { private val route: AcceptInvite = savedStateHandle.toRoute() - override val scope: Scope - get() = getKoin().getOrCreateScope( - SchoolDirectoryEntryScopeId(route.schoolUrl, null).scopeId - ) + override val scope: Scope by lazy { + if (route.personGuid != null) { + accountManager.requireActiveAccountScope() + } else { + getKoin().getOrCreateScope( + SchoolDirectoryEntryScopeId(route.schoolUrl, null).scopeId + ) + } + } + + val redeemInviteUseCase: RedeemInviteExistingUserUseCase? by lazy { + if (route.personGuid != null) { + scope.get() + } else { + null + } + } + private val schoolDataSource: SchoolDataSource by inject() + private val navigateOnExistingUserInviteAcceptedUseCase + : NavigateOnExistingUserInviteAcceptedUseCase by inject() private val getInviteInfoUseCase: GetInviteInfoUseCase = scope.get() private val schoolPrimaryKeyGenerator: SchoolPrimaryKeyGenerator = scope.get() private val _uiState = MutableStateFlow( - AcceptInviteUiState(schoolUrl = route.schoolUrl) + AcceptInviteUiState(schoolUrl = route.schoolUrl, uid = route.personGuid) ) val uiState = _uiState.asStateFlow() @@ -95,6 +147,35 @@ class AcceptInviteViewModel( isTeacherInvite = false ) } + + if (route.personGuid != null) { + loadEntity( + json = json, + serializer = ListSerializer(Person.serializer()), + initialStateKey = KEY_INITIAL_STATE, + loadFn = { loadParams -> + schoolDataSource.personDataSource.list( + loadParams = loadParams, + params = PersonDataSource.GetListParams( + common = GetListCommonParams( + guid = route.personGuid + ), + includeRelated = true + ) + ) + }, + uiUpdateFn = { person -> + _uiState.update { + prev -> prev.copy( + persons = person, + showSelectChildDropDown = (uiState.value.person?.roles?.firstOrNull() + ?.roleEnum == PersonRoleEnum.PARENT && uiState.value.children.isNotEmpty()&& + inviteInfo.invite is ClassInvite) + ) + } + } + ) + } } viewModelScope.launch { @@ -107,7 +188,14 @@ class AcceptInviteViewModel( } } } - + fun onChildSelected(child: Person) { + _uiState.update { + it.copy( + selectedChildGuid = child.guid, + childError = null + ) + } + } fun onClickNext() { val invite = uiState.value.inviteInfo?.invite ?: return @@ -115,15 +203,54 @@ class AcceptInviteViewModel( code = invite.code, accountPersonInfo = PersonInfo(), account = RespectRedeemInviteRequest.Account( - guid = schoolPrimaryKeyGenerator.primaryKeyGenerator.nextId(Person.TABLE_ID).toString(), + guid = uiState.value.person?.guid ?: schoolPrimaryKeyGenerator.primaryKeyGenerator + .nextId(Person.TABLE_ID).toString(), username = "", credential = RespectPasswordCredential(username = "", password = ""), ), deviceName = getDeviceInfoUseCase().toUserFriendlyString(), deviceInfo = getDeviceInfoUseCase(), - invite = invite + invite = invite, + recipientType = if (route.personGuid!=null) { + InviteRecipientType.EXISTING_USER + } else { + InviteRecipientType.NEW_USER + } ) + if (route.personGuid != null && redeemInviteUseCase != null) { + viewModelScope.launch { + try { + + redeemInviteUseCase?.invoke( + inviteRedeemRequest, + uiState.value.selectedChildGuid + ) + val parentUid = (invite as? FamilyMemberInvite)?.personUid + + navigateOnExistingUserInviteAcceptedUseCase( + person = uiState.value.person, + parentUid = parentUid, + inviteRequest = inviteRedeemRequest, + navCommandFlow = _navCommandFlow + ) + + } catch (e: Throwable) { + + _uiState.update { + it.copy( + errorText = e.message?.let { message -> + StringUiText(message) + } ?: StringResourceUiText( + Res.string.something_wrong_with_invite + ) + ) + } + } + } + + return + } _navCommandFlow.tryEmit( NavCommand.Navigate( destination = if(!invite.isChildUser()) { diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/accountlist/AccountListViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/accountlist/AccountListViewModel.kt index 4d8c3e865..aba51aaf7 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/accountlist/AccountListViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/accountlist/AccountListViewModel.kt @@ -2,19 +2,29 @@ package world.respect.shared.viewmodel.manageuser.accountlist import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.koin.core.component.KoinScopeComponent +import org.koin.core.component.inject +import org.koin.core.scope.Scope import world.respect.datalayer.DataLoadParams import world.respect.datalayer.SchoolDataSource import world.respect.datalayer.ext.dataOrNull +import world.respect.datalayer.school.EnrollmentDataSource +import world.respect.datalayer.school.PersonDataSource +import world.respect.datalayer.school.ext.getClassName +import world.respect.datalayer.school.model.EnrollmentRoleEnum import world.respect.datalayer.school.model.Person import world.respect.datalayer.school.model.PersonGenderEnum import world.respect.datalayer.school.model.PersonRoleEnum import world.respect.datalayer.school.model.PersonStatusEnum +import world.respect.datalayer.school.model.PersonWithEnrollment +import world.respect.datalayer.shared.params.GetListCommonParams import world.respect.libutil.ext.replaceOrAppend import world.respect.shared.domain.account.RespectAccount import world.respect.shared.domain.account.RespectAccountManager @@ -22,9 +32,14 @@ import world.respect.shared.domain.account.RespectSession import world.respect.shared.domain.account.RespectSessionAndPerson import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.accounts +import world.respect.shared.generated.resources.select_account +import world.respect.shared.navigation.AcceptInvite +import world.respect.shared.navigation.AccountList import world.respect.shared.navigation.AssignmentList +import world.respect.shared.navigation.EnterInviteCode import world.respect.shared.navigation.GetStartedScreen import world.respect.shared.navigation.Home +import world.respect.shared.navigation.LoginScreen import world.respect.shared.navigation.NavCommand import world.respect.shared.navigation.PersonDetail import world.respect.shared.navigation.WaitingForApproval @@ -40,6 +55,9 @@ import world.respect.shared.viewmodel.RespectViewModel data class AccountListUiState( val selectedAccount: RespectSessionAndPerson? = null, val accounts: List = emptyList(), + val isPendingExpanded: Boolean = true, + val isSelectAccountMode : Boolean = false, + val pendingEnrolmentPerson: List = emptyList() ) { val showSelectedAccountProfileButton: Boolean get() = selectedAccount?.person?.status != PersonStatusEnum.PENDING_APPROVAL @@ -52,27 +70,66 @@ data class AccountListUiState( class AccountListViewModel( private val respectAccountManager: RespectAccountManager, savedStateHandle: SavedStateHandle -) : RespectViewModel(savedStateHandle){ +) : RespectViewModel(savedStateHandle), KoinScopeComponent { + override val scope: Scope = respectAccountManager.requireActiveAccountScope() + + private val route: AccountList = savedStateHandle.toRoute() + + private val _uiState = MutableStateFlow(AccountListUiState(isSelectAccountMode = route.inviteCode!=null)) + + private val schoolDataSource: SchoolDataSource by inject() - private val _uiState = MutableStateFlow(AccountListUiState()) val uiState = _uiState.asStateFlow() + private var emittedNavToGetStartedCommand = false init { _appUiState.update { it.copy( - title = Res.string.accounts.asUiText(), + title = if (_uiState.value.isSelectAccountMode) Res.string.select_account.asUiText() else + Res.string.accounts.asUiText(), hideBottomNavigation = true, userAccountIconVisible = false, ) } viewModelScope.launch { + val parentGuid = respectAccountManager.activeAccount?.userGuid ?: return@launch + + val children = schoolDataSource.personDataSource.list( + loadParams = DataLoadParams(), + params = PersonDataSource.GetListParams( + common = GetListCommonParams(guid = parentGuid), + includeRelated = true + ) + ).dataOrNull() + ?.filter { it.guid != parentGuid } + .orEmpty() + + val childMap = children.associateBy { it.guid } + + val enrollments = schoolDataSource.enrollmentDataSource.list( + loadParams = DataLoadParams(), + listParams = EnrollmentDataSource.GetListParams( + role = EnrollmentRoleEnum.PENDING_STUDENT + ) + ).dataOrNull().orEmpty() + + val pending = enrollments.mapNotNull { e -> + val person = childMap[e.personUid] ?: return@mapNotNull null + + + PersonWithEnrollment(person, e.getClassName()?:"", e) + } + respectAccountManager.selectedAccountAndPersonFlow.collect { accountAndPerson -> - _uiState.update { prev -> - prev.copy(selectedAccount = accountAndPerson) + _uiState.update { + it.copy( + selectedAccount = accountAndPerson, + pendingEnrolmentPerson = pending + ) } } } @@ -167,7 +224,16 @@ class AccountListViewModel( _navCommandFlow.tryEmit( NavCommand.Navigate( destination = if(person.dataOrNull()?.status != PersonStatusEnum.PENDING_APPROVAL) { - Home + if (uiState.value.isSelectAccountMode) + { + AcceptInvite.create( + schoolUrl = account.school.self, + code = route.inviteCode?:"", + canGoBack = true, + ) + }else{ + Home + } }else { WaitingForApproval() }, @@ -178,7 +244,9 @@ class AccountListViewModel( } - + fun onTogglePendingSection() { + _uiState.update { it.copy(isPendingExpanded = !it.isPendingExpanded) } + } fun onClickFamilyPerson(person: Person) { viewModelScope.launch { respectAccountManager.switchProfile(person.guid) @@ -197,7 +265,16 @@ class AccountListViewModel( fun onClickAddAccount() { _navCommandFlow.tryEmit( - NavCommand.Navigate(GetStartedScreen(canGoBack = true)) + NavCommand.Navigate( + if (uiState.value.isSelectAccountMode){ + LoginScreen( + schoolUrlStr = respectAccountManager.activeAccount?.school?.self.toString(), + inviteCode = route.inviteCode + ) + }else{ + GetStartedScreen(canGoBack = true) + } + ) ) } @@ -212,6 +289,18 @@ class AccountListViewModel( ) } } + fun onClickEnterInviteCode() { + uiState.value.selectedAccount?.also { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + EnterInviteCode( + schoolUrlStr = it.session.account.school.self.toString(), + personGuid = it.session.account.userGuid + ) + ) + ) + } + } fun onClickLogout() { diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/enterinvitecode/EnterInviteCodeViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/enterinvitecode/EnterInviteCodeViewModel.kt index d68c443e7..2ba3210e8 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/enterinvitecode/EnterInviteCodeViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/enterinvitecode/EnterInviteCodeViewModel.kt @@ -83,7 +83,8 @@ class EnterInviteCodeViewModel( NavCommand.Navigate( AcceptInvite.create( schoolUrl = route.schoolUrl, - code = inviteCode + code = inviteCode, + guid = route.personGuid ) ) ) diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/login/LoginViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/login/LoginViewModel.kt index 11f8d71b2..acdf52054 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/login/LoginViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/login/LoginViewModel.kt @@ -25,6 +25,7 @@ import world.respect.shared.generated.resources.Res import world.respect.shared.generated.resources.login import world.respect.shared.generated.resources.required_field import world.respect.shared.generated.resources.something_went_wrong +import world.respect.shared.navigation.AcceptInvite import world.respect.shared.navigation.EnterInviteCode import world.respect.shared.navigation.Home import world.respect.shared.navigation.LoginScreen @@ -45,6 +46,7 @@ data class LoginUiState( val errorText: UiText? = null, val usernameError: StringResourceUiText? = null, val passwordError: StringResourceUiText? = null, + val acceptInviteMode : Boolean = false ) class LoginViewModel( @@ -56,11 +58,11 @@ class LoginViewModel( private val savePasswordUseCase: SavePasswordUseCase ) : RespectViewModel(savedStateHandle), KoinScopeComponent { - private val _uiState = MutableStateFlow(LoginUiState()) + private val route: LoginScreen = savedStateHandle.toRoute() - val uiState = _uiState.asStateFlow() + private val _uiState = MutableStateFlow(LoginUiState(acceptInviteMode = route.inviteCode!=null)) - private val route: LoginScreen = savedStateHandle.toRoute() + val uiState = _uiState.asStateFlow() override val scope: Scope get() = getKoin().getOrCreateScope( @@ -109,7 +111,14 @@ class LoginViewModel( destination = if(authResponse.person.status == PersonStatusEnum.PENDING_APPROVAL) { WaitingForApproval() }else { - Home + if (uiState.value.acceptInviteMode){ + AcceptInvite.create( + schoolUrl = route.schoolUrl, + code = route.inviteCode?:"", + guid = authResponse.person.guid + ) + }else + Home }, clearBackStack = true ) @@ -218,7 +227,14 @@ class LoginViewModel( destination = if(authResponse.person.status == PersonStatusEnum.PENDING_APPROVAL) { WaitingForApproval() }else { - Home + if (uiState.value.acceptInviteMode){ + AcceptInvite.create( + schoolUrl = route.schoolUrl, + code = route.inviteCode?:"", + guid = authResponse.person.guid + ) + }else + Home }, clearBackStack = true, ) @@ -235,6 +251,16 @@ class LoginViewModel( } } + fun onClickCreateNewAccount() { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + AcceptInvite.create( + schoolUrl = route.schoolUrl, + code = route.inviteCode?:"", + ) + ) + ) + } fun onClickInviteCode() { _navCommandFlow.tryEmit( NavCommand.Navigate(EnterInviteCode.create(route.schoolUrl)) diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/selectaccount/SelectAccountViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/selectaccount/SelectAccountViewModel.kt new file mode 100644 index 000000000..b84b23b57 --- /dev/null +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/manageuser/selectaccount/SelectAccountViewModel.kt @@ -0,0 +1,187 @@ +package world.respect.shared.viewmodel.manageuser.selectaccount + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinScopeComponent +import org.koin.core.scope.Scope +import world.respect.datalayer.DataLoadParams +import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.ext.dataOrNull +import world.respect.datalayer.school.model.Person +import world.respect.datalayer.school.model.PersonGenderEnum +import world.respect.datalayer.school.model.PersonStatusEnum +import world.respect.libutil.ext.replaceOrAppend +import world.respect.shared.domain.account.RespectAccount +import world.respect.shared.domain.account.RespectAccountManager +import world.respect.shared.domain.account.RespectSession +import world.respect.shared.domain.account.RespectSessionAndPerson +import world.respect.shared.generated.resources.Res +import world.respect.shared.generated.resources.select_account +import world.respect.shared.navigation.AcceptInvite +import world.respect.shared.navigation.GetStartedScreen +import world.respect.shared.navigation.LoginScreen +import world.respect.shared.navigation.NavCommand +import world.respect.shared.navigation.SelectAccount +import world.respect.shared.navigation.WaitingForApproval +import world.respect.shared.util.ext.asUiText +import world.respect.shared.util.ext.isSameAccount +import world.respect.shared.viewmodel.RespectViewModel + +/** + * @property selectedAccount if not null, the currently selected account + * @property accounts other accounts that are signed-in, available, and the user can switch to ( + * (not including the selectedAccount) + */ +data class SelectAccountUiState( + val accounts: List = emptyList(), +) + +class SelectAccountViewModel( + private val respectAccountManager: RespectAccountManager, + savedStateHandle: SavedStateHandle +) : RespectViewModel(savedStateHandle), KoinScopeComponent { + override val scope: Scope = respectAccountManager.requireActiveAccountScope() + + private val route: SelectAccount = savedStateHandle.toRoute() + + private val _uiState = MutableStateFlow(SelectAccountUiState()) + + + val uiState = _uiState.asStateFlow() + + + private var emittedNavToGetStartedCommand = false + + init { + _appUiState.update { + it.copy( + title = Res.string.select_account.asUiText(), + hideBottomNavigation = true, + userAccountIconVisible = false, + ) + } + + + viewModelScope.launch { + respectAccountManager.accounts.combine( + respectAccountManager.selectedAccountFlow + ) { storedAccounts, activeAccount -> + Pair(storedAccounts, activeAccount) + }.collectLatest { (storedAccounts, activeAccount) -> + /* + * If there are no stored accounts (eg because they have logged out of all accounts), + * or if a session is terminated remotely (eg password reset), then must go to + * GetStarted screen. + */ + if (storedAccounts.isEmpty() && !emittedNavToGetStartedCommand) { + emittedNavToGetStartedCommand = true + _navCommandFlow.tryEmit( + NavCommand.Navigate( + GetStartedScreen(), clearBackStack = true + ) + ) + + return@collectLatest + } + + + _uiState.update { prev -> + prev.copy( + accounts = storedAccounts.map { + RespectSessionAndPerson( + session = RespectSession(it, null), + person = Person( + guid = it.userGuid, + givenName = "", + familyName = "", + roles = emptyList(), + gender = PersonGenderEnum.UNSPECIFIED, + ) + ) + } + ) + } + + storedAccounts.forEach { account -> + launch { + val accountScope = respectAccountManager.getOrCreateAccountScope(account) + val dataSource: SchoolDataSource = accountScope.get() + dataSource.personDataSource.findByGuidAsFlow( + account.userGuid + ).collect { person -> + _uiState.update { prev -> + prev.copy( + accounts = prev.accounts.replaceOrAppend( + RespectSessionAndPerson( + session = RespectSession(account, null), + person = person.dataOrNull() ?: Person( + guid = account.userGuid, + givenName = "", + familyName = "", + roles = emptyList(), + gender = PersonGenderEnum.UNSPECIFIED, + ) + ) + ) { + it.session.account.isSameAccount(account) + } + ) + } + } + } + } + } + } + } + + fun onClickAccount(account: RespectAccount) { + respectAccountManager.switchAccount(account) + + viewModelScope.launch { + val accountScope = respectAccountManager.getOrCreateAccountScope(account) + val person = accountScope.get().personDataSource.findByGuid( + loadParams = DataLoadParams(onlyIfCached = true), + guid = account.userGuid + ) + + _navCommandFlow.tryEmit( + NavCommand.Navigate( + destination = if (person.dataOrNull()?.status != PersonStatusEnum.PENDING_APPROVAL) { + AcceptInvite.create( + schoolUrl = account.school.self, + code = route.inviteCode ?: "", + canGoBack = true, + guid = person.dataOrNull()?.guid + ) + } else { + WaitingForApproval() + }, + clearBackStack = true + ) + ) + } + + + } + + + fun onClickAddAccount() { + _navCommandFlow.tryEmit( + NavCommand.Navigate( + LoginScreen( + schoolUrlStr = respectAccountManager.activeAccount?.school?.self.toString(), + inviteCode = route.inviteCode + ) + ) + ) + } + + +} \ No newline at end of file diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/edit/PersonEditViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/edit/PersonEditViewModel.kt index 3aa903e8b..d2ef9e30c 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/edit/PersonEditViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/edit/PersonEditViewModel.kt @@ -58,6 +58,10 @@ import world.respect.shared.viewmodel.app.appstate.SnackBarDispatcher import kotlin.collections.first import kotlin.getValue import kotlin.time.Clock +import world.respect.datalayer.school.model.FamilyMemberInvite +import world.respect.datalayer.school.model.Invite2 +import world.respect.datalayer.school.model.StatusEnum +import kotlin.time.Duration.Companion.minutes data class PersonEditUiState( val uid: String = "", @@ -301,12 +305,12 @@ class PersonEditViewModel( NavCommand.Navigate( PersonList.create( filterByRole = filterByRole, - personGuid = route.guid, + personGuid = FamilyMemberInvite.uidFor(route.guid), resultDest = RouteResultDest( resultPopUpTo = route, resultKey = PERSON_SELECT_RESULT ), - hideInvite = true, + hideInvite = false, ) ) ) @@ -395,6 +399,24 @@ class PersonEditViewModel( familyMembersAdded + familyMembersRemoved + personToSave.copy(lastModified = modTime) ) + if ( + route.guid == null && + personToSave.roles.any { + it.roleEnum == PersonRoleEnum.STUDENT || it.roleEnum == PersonRoleEnum.PARENT + } + ) { + val invite = FamilyMemberInvite( + uid = FamilyMemberInvite.uidFor(personToSave.guid), + code = Invite2.newRandomCode(), + approvalRequiredAfter = modTime + Invite2.APPROVAL_NOT_REQUIRED_INTERVAL_MINS.minutes, + lastModified = modTime, + stored = modTime, + status = StatusEnum.ACTIVE, + personUid = personToSave.guid + ) + schoolDataSource.inviteDataSource.store(listOf(invite)) + } + if( !navResultReturner.sendResultIfResultExpected( route = route, diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/inviteperson/InvitePersonViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/inviteperson/InvitePersonViewModel.kt index e386d7d54..4f7e62f2a 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/inviteperson/InvitePersonViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/inviteperson/InvitePersonViewModel.kt @@ -22,6 +22,7 @@ import world.respect.datalayer.DataLoadState import world.respect.datalayer.DataLoadingState import world.respect.datalayer.NoDataLoadedState import world.respect.datalayer.SchoolDataSource +import world.respect.datalayer.db.school.ext.fullName import world.respect.datalayer.ext.dataOrNull import world.respect.datalayer.school.domain.GetWritableRolesListUseCase import world.respect.datalayer.school.ext.copyInvite @@ -30,6 +31,7 @@ import world.respect.datalayer.school.ext.newUserInviteUid import world.respect.datalayer.school.model.ClassInvite import world.respect.datalayer.school.model.ClassInviteModeEnum import world.respect.datalayer.school.model.EnrollmentRoleEnum +import world.respect.datalayer.school.model.FamilyMemberInvite import world.respect.datalayer.school.model.Invite2 import world.respect.datalayer.school.model.NewUserInvite import world.respect.datalayer.school.model.PersonRoleEnum @@ -62,6 +64,7 @@ data class InvitePersonUiState( val selectedRole: PersonRoleEnum? = null, val className: String? = null, val schoolName: String? = null, + val childName: String? = null, val roleOptions: List = emptyList() ) { val inviteCode: String? @@ -132,6 +135,7 @@ class InvitePersonViewModel( } _inviteUid.value = (route.invitePersonOptions as? InvitePerson.ClassInviteOptions)?.inviteUid + ?: (route.invitePersonOptions as? InvitePerson.FamilyInviteOptions)?.personUid ?: selectedRole.newUserInviteUid _inviteUid.collectLatest { inviteUid -> @@ -140,6 +144,17 @@ class InvitePersonViewModel( uid = inviteUid, loadParams = DataLoadParams() ).collectLatest { invite -> + (invite.dataOrNull() as? FamilyMemberInvite)?.let { familyInvite -> + val childPerson= schoolDataSource.personDataSource.findByGuid( + loadParams = DataLoadParams(), + guid = familyInvite.personUid + ).dataOrNull() + _uiState.update { prev -> + prev.copy( + childName = childPerson?.fullName(), + ) + } + } _uiState.update { prev -> prev.copy( invite = invite, diff --git a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/list/PersonListViewModel.kt b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/list/PersonListViewModel.kt index d56ccb8b2..84dbc65ed 100644 --- a/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/list/PersonListViewModel.kt +++ b/respect-lib-shared/src/commonMain/kotlin/world/respect/shared/viewmodel/person/list/PersonListViewModel.kt @@ -251,19 +251,29 @@ class PersonListViewModel( } fun onClickInvitePerson() { + val inviteOptions = when { + route.inviteUid != null -> { + InvitePerson.ClassInviteOptions( + inviteUid = route.inviteUid + ) + } + + route.filterByRole in listOf(PersonRoleEnum.PARENT, PersonRoleEnum.STUDENT) && route.personGuidStr != null -> { + InvitePerson.FamilyInviteOptions( + personUid = route.personGuidStr + ) + } + + else -> { + InvitePerson.NewUserInviteOptions( + presetRole = route.filterByRole + ) + } + } + _navCommandFlow.tryEmit( NavCommand.Navigate( - InvitePerson.create( - invitePersonOptions = if(route.inviteUid != null) { - InvitePerson.ClassInviteOptions( - inviteUid = route.inviteUid - ) - }else { - InvitePerson.NewUserInviteOptions( - presetRole = route.filterByRole - ) - } - ) + InvitePerson.create(invitePersonOptions = inviteOptions) ) ) } diff --git a/respect-lib-shared/src/jvmMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteExistingUserUseCaseDb.kt b/respect-lib-shared/src/jvmMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteExistingUserUseCaseDb.kt new file mode 100644 index 000000000..d4fdb8d58 --- /dev/null +++ b/respect-lib-shared/src/jvmMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteExistingUserUseCaseDb.kt @@ -0,0 +1,164 @@ +package world.respect.shared.domain.account.invite + +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import org.koin.core.component.KoinComponent +import world.respect.datalayer.SchoolDataSourceLocal +import world.respect.datalayer.UidNumberMapper +import world.respect.datalayer.db.RespectSchoolDatabase +import world.respect.datalayer.db.school.adapters.toModel +import world.respect.datalayer.db.school.adapters.toPersonEntities +import world.respect.datalayer.school.ext.accepterEnrollmentRole +import world.respect.datalayer.school.ext.copyWithClassName +import world.respect.datalayer.school.ext.copyWithInviteInfo +import world.respect.datalayer.school.ext.isApprovalRequiredNow +import world.respect.datalayer.school.ext.primaryRole +import world.respect.datalayer.school.model.ClassInvite +import world.respect.datalayer.school.model.ClassInviteModeEnum +import world.respect.datalayer.school.model.Enrollment +import world.respect.datalayer.school.model.EnrollmentRoleEnum +import world.respect.datalayer.school.model.FamilyMemberInvite +import world.respect.datalayer.school.model.PersonRoleEnum +import world.respect.libutil.util.throwable.withHttpStatus +import world.respect.shared.domain.school.SchoolPrimaryKeyGenerator +import kotlin.time.Clock + +class RedeemInviteExistingUserUseCaseDb( + private val schoolDb: RespectSchoolDatabase, + private val uidNumberMapper: UidNumberMapper, + private val schoolPrimaryKeyGenerator: SchoolPrimaryKeyGenerator, + private val schoolDataSource: SchoolDataSourceLocal, +) : RedeemInviteExistingUserUseCase, KoinComponent { + + override suspend fun invoke( + redeemRequest: RespectRedeemInviteRequest, + selectedChildGuid: String? + ) { + val timeNow = Clock.System.now() + + val inviteFromDb = schoolDb.getInviteEntityDao().getInviteByInviteCode( + redeemRequest.code + )?.toModel() + ?: throw IllegalArgumentException("invite not found for code: ${redeemRequest.code}") + .withHttpStatus(404) + + val approvalRequired = inviteFromDb.isApprovalRequiredNow() + val accountGuid = redeemRequest.account.guid + + val accountPerson = + schoolDb.getPersonEntityDao().findByGuidNum(uidNumberMapper(accountGuid)) + ?.toPersonEntities() + ?.toModel() + ?.let { person -> + if (approvalRequired) { + person.copyWithInviteInfo(invite = redeemRequest.invite) + } else { + person + } + } + ?: throw IllegalArgumentException("existing person not found for guid: $accountGuid") + .withHttpStatus(404) + + schoolDataSource.personDataSource.updateLocal( + listOf(accountPerson), + forceOverwrite = true + ) + + val enrollmentRole = inviteFromDb.accepterEnrollmentRole(approvalRequired) + if (enrollmentRole != null && inviteFromDb is ClassInvite) { + val primaryRole = accountPerson.primaryRole() + + val invalidInvite = + (primaryRole == PersonRoleEnum.TEACHER && enrollmentRole == EnrollmentRoleEnum.STUDENT) || + (primaryRole == PersonRoleEnum.TEACHER && enrollmentRole == EnrollmentRoleEnum.PENDING_STUDENT) || + (primaryRole == PersonRoleEnum.STUDENT && enrollmentRole == EnrollmentRoleEnum.TEACHER) || + (primaryRole == PersonRoleEnum.STUDENT && enrollmentRole == EnrollmentRoleEnum.PENDING_TEACHER) + + + if (invalidInvite) { + throw IllegalArgumentException( + "Sorry. Invalid invitation: not available for your user type." + ).withHttpStatus(400) + } + val className = schoolDb.getClassEntityDao() + .findByGuid(uidNumberMapper(inviteFromDb.classUid))?.clazz?.cTitle + + schoolDataSource.enrollmentDataSource.updateLocal( + listOf( + Enrollment( + uid = schoolPrimaryKeyGenerator.primaryKeyGenerator.nextId( + Enrollment.TABLE_ID + ).toString(), + classUid = inviteFromDb.classUid, + personUid = if (inviteFromDb.inviteMode == ClassInviteModeEnum.VIA_PARENT) selectedChildGuid + ?: "" else accountPerson.guid, + role = enrollmentRole, + beginDate = Clock.System.now().toLocalDateTime( + TimeZone.currentSystemDefault() + ).date + ).let { + if (className != null) { + it.copyWithClassName(className) + } else { + it + } + } + + ) + ) + } + if (inviteFromDb is FamilyMemberInvite) { + + val parentUid = inviteFromDb.personUid + val childUid = selectedChildGuid ?: accountPerson.guid + + val parentPerson = + schoolDb.getPersonEntityDao() + .findByGuidNum(uidNumberMapper(parentUid)) + ?.toPersonEntities() + ?.toModel() + ?: throw IllegalStateException("Parent not found: $parentUid") + + val childPerson = + schoolDb.getPersonEntityDao() + .findByGuidNum(uidNumberMapper(childUid)) + ?.toPersonEntities() + ?.toModel() + ?: throw IllegalStateException("Child not found: $childUid") + + val updatedParent = parentPerson.copy( + relatedPersonUids = parentPerson.relatedPersonUids + childUid, + lastModified = timeNow + ) + + val updatedChild = childPerson.copy( + relatedPersonUids = childPerson.relatedPersonUids + parentUid, + lastModified = timeNow + ) + + schoolDataSource.personDataSource.updateLocal( + listOf(updatedParent, updatedChild), + forceOverwrite = true + ) + } + if (!selectedChildGuid.isNullOrBlank()) { + val childPerson = + schoolDb.getPersonEntityDao().findByGuidNum(uidNumberMapper(selectedChildGuid)) + ?.toPersonEntities() + ?.toModel() + ?.copyWithInviteInfo(invite = redeemRequest.invite) + ?.copy( + lastModified = timeNow + ) + + childPerson?.let { + schoolDataSource.personDataSource.updateLocal( + listOf(it), + forceOverwrite = true + ) + } + } + + markFirstUserInviteAsDeleted(inviteFromDb, schoolDataSource) + } +} \ No newline at end of file diff --git a/respect-lib-shared/src/jvmMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteUseCaseDb.kt b/respect-lib-shared/src/jvmMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteUseCaseDb.kt index 96a16b5ca..605515a0d 100644 --- a/respect-lib-shared/src/jvmMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteUseCaseDb.kt +++ b/respect-lib-shared/src/jvmMain/kotlin/world/respect/shared/domain/account/invite/RedeemInviteUseCaseDb.kt @@ -12,11 +12,13 @@ import world.respect.credentials.passkey.RespectQRBadgeCredential import world.respect.credentials.passkey.RespectUserHandle import world.respect.credentials.passkey.request.GetPasskeyProviderInfoUseCase import world.respect.datalayer.AuthenticatedUserPrincipalId +import world.respect.datalayer.DataLoadParams import world.respect.datalayer.SchoolDataSourceLocal import world.respect.datalayer.UidNumberMapper import world.respect.datalayer.db.RespectSchoolDatabase import world.respect.datalayer.db.school.adapters.toEntity import world.respect.datalayer.db.school.adapters.toModel +import world.respect.datalayer.ext.dataOrNull import world.respect.datalayer.school.adapters.toPersonPasskey import world.respect.datalayer.school.ext.accepterEnrollmentRole import world.respect.datalayer.school.ext.accepterPersonRole @@ -28,6 +30,8 @@ import world.respect.datalayer.school.model.NewUserInvite import world.respect.datalayer.school.model.ClassInvite import world.respect.datalayer.school.model.ClassInviteModeEnum import world.respect.datalayer.school.model.Enrollment +import world.respect.datalayer.school.model.FamilyMemberInvite +import world.respect.datalayer.school.model.PersonRoleEnum import world.respect.datalayer.school.model.PersonStatusEnum import world.respect.datalayer.school.model.StatusEnum import world.respect.libutil.ext.randomString @@ -123,7 +127,42 @@ class RedeemInviteUseCaseDb( ) ) } + val timeNow = Clock.System.now() + if (inviteFromDb is FamilyMemberInvite) { + val parentUid = inviteFromDb.personUid + + val inviterPerson = schoolDataSourceVal.personDataSource.findByGuid( + loadParams = DataLoadParams(), + guid = parentUid + ).dataOrNull() ?: throw IllegalStateException("person not found: $parentUid") + + + val updatedInviter = inviterPerson.copy( + relatedPersonUids = inviterPerson.relatedPersonUids + accountPerson.guid, + lastModified = timeNow + ) + + val updatedAccountPerson = accountPerson.copy( + relatedPersonUids = accountPerson.relatedPersonUids + parentUid, + lastModified = timeNow + ) + schoolDataSourceVal.personDataSource.updateLocal(listOf(updatedInviter, updatedAccountPerson)) + } + if (inviteFromDb !is FamilyMemberInvite){ + if ( accountPerson.roles.any { it.roleEnum == PersonRoleEnum.STUDENT }) { + val invite = FamilyMemberInvite( + uid = FamilyMemberInvite.uidFor(accountPerson.guid), + code = Invite2.newRandomCode(), + approvalRequiredAfter = timeNow, + lastModified = timeNow, + stored = timeNow, + status = StatusEnum.ACTIVE, + personUid = accountPerson.guid + ) + schoolDataSourceVal.inviteDataSource.store(listOf(invite)) + } + } val credential = redeemRequest.account.credential val authResponse = when (credential) { diff --git a/respect-lib-shared/src/jvmMain/kotlin/world/respect/shared/domain/account/invite/markFirstUserInviteAsDeleted.kt b/respect-lib-shared/src/jvmMain/kotlin/world/respect/shared/domain/account/invite/markFirstUserInviteAsDeleted.kt new file mode 100644 index 000000000..b9aeac906 --- /dev/null +++ b/respect-lib-shared/src/jvmMain/kotlin/world/respect/shared/domain/account/invite/markFirstUserInviteAsDeleted.kt @@ -0,0 +1,27 @@ +package world.respect.shared.domain.account.invite + +import world.respect.datalayer.SchoolDataSourceLocal +import world.respect.datalayer.school.model.Invite2 +import world.respect.datalayer.school.model.NewUserInvite +import world.respect.datalayer.school.model.StatusEnum +import kotlin.time.Clock + +/** + * Deletes the invite if it's a first user invite (firstUser = true) + * This ensures the first user invite can never be used again after the first user signs up + */ + suspend fun markFirstUserInviteAsDeleted( + redeemedInvite: Invite2, + schoolDataSourceVal: SchoolDataSourceLocal + ) { + // Check if this is a NewUserInvite with firstUser = true + if (redeemedInvite is NewUserInvite && redeemedInvite.firstUser) { + + val deletedInvite = redeemedInvite.copy( + status = StatusEnum.TO_BE_DELETED, + lastModified = Clock.System.now() + ) + + schoolDataSourceVal.inviteDataSource.store(listOf(deletedInvite)) + } + } \ No newline at end of file diff --git a/respect-server/src/main/kotlin/world/respect/server/Application.kt b/respect-server/src/main/kotlin/world/respect/server/Application.kt index b70d01aa1..5fff04eb3 100644 --- a/respect-server/src/main/kotlin/world/respect/server/Application.kt +++ b/respect-server/src/main/kotlin/world/respect/server/Application.kt @@ -53,6 +53,7 @@ import world.respect.server.routes.school.respect.PersonPasskeyRoute import world.respect.server.routes.school.respect.PersonPasswordRoute import world.respect.server.routes.school.respect.PersonRoute import world.respect.server.routes.school.respect.PlaylistRoute +import world.respect.server.routes.school.respect.RedeemInviteExistingUserRoute import world.respect.server.routes.school.respect.RedeemInviteRoute import world.respect.server.routes.school.respect.SchoolAppRoute import world.respect.server.routes.school.respect.SchoolRegistrationRoute @@ -247,7 +248,7 @@ fun Application.module() { } route("invite") { RedeemInviteRoute( - redeemInviteUseCase = { it.getSchoolKoinScope().get() } + redeemInviteUseCase = { it.getSchoolKoinScope().get() }, ) InviteInfoRoute( getInviteInfoUseCase = { it.getSchoolKoinScope().get() } @@ -275,6 +276,9 @@ fun Application.module() { EnrollmentRoute() AssignmentRoute() PersonQrBadgeRoute() + RedeemInviteExistingUserRoute( + redeemInviteExistingUserUseCase = { it.requireAccountScope().get() } + ) AddChildAccountRoute( addChildAccountUseCase = { it.requireAccountScope().get() } ) diff --git a/respect-server/src/main/kotlin/world/respect/server/ServerKoinModule.kt b/respect-server/src/main/kotlin/world/respect/server/ServerKoinModule.kt index 97f4c9cd1..b7b4ca33e 100644 --- a/respect-server/src/main/kotlin/world/respect/server/ServerKoinModule.kt +++ b/respect-server/src/main/kotlin/world/respect/server/ServerKoinModule.kt @@ -56,6 +56,8 @@ import world.respect.shared.domain.account.gettokenanduser.GetTokenAndUserProfil import world.respect.shared.domain.account.invite.CreateInviteUseCase import world.respect.shared.domain.account.invite.CreateInviteUseCaseDb import world.respect.shared.domain.account.invite.GetInviteInfoUseCase +import world.respect.shared.domain.account.invite.RedeemInviteExistingUserUseCase +import world.respect.shared.domain.account.invite.RedeemInviteExistingUserUseCaseDb import world.respect.shared.domain.account.invite.RedeemInviteUseCase import world.respect.shared.domain.account.invite.RedeemInviteUseCaseDb import world.respect.shared.domain.account.passkey.DecodeUserHandleUseCaseImpl @@ -205,6 +207,7 @@ fun serverKoinModule( ) } + scoped { UsernameSuggestionUseCaseServer( schoolDb = get(), @@ -306,6 +309,7 @@ fun serverKoinModule( ) } + scoped { val schoolScopeId = SchoolDirectoryEntryScopeId.parse(id) val accountScopeManager: ServerAccountScopeManager = get() @@ -411,6 +415,15 @@ fun serverKoinModule( schoolDataSource = get(), ) } + factory { + + RedeemInviteExistingUserUseCaseDb( + schoolDb = get(), + schoolPrimaryKeyGenerator = get(), + schoolDataSource = get(), + uidNumberMapper = get(), + ) + } } diff --git a/respect-server/src/main/kotlin/world/respect/server/account/invite/GetInviteInfoUseCaseServer.kt b/respect-server/src/main/kotlin/world/respect/server/account/invite/GetInviteInfoUseCaseServer.kt index 2135308db..999da57b9 100644 --- a/respect-server/src/main/kotlin/world/respect/server/account/invite/GetInviteInfoUseCaseServer.kt +++ b/respect-server/src/main/kotlin/world/respect/server/account/invite/GetInviteInfoUseCaseServer.kt @@ -7,6 +7,7 @@ import world.respect.datalayer.db.school.adapters.toModel import world.respect.datalayer.respect.model.invite.RespectInviteInfo import world.respect.libutil.util.throwable.withHttpStatus import world.respect.shared.domain.account.invite.GetInviteInfoUseCase +import kotlin.collections.first class GetInviteInfoUseCaseServer( private val schoolDb: RespectSchoolDatabase, @@ -26,9 +27,34 @@ class GetInviteInfoUseCaseServer( }else { null } + val familyPersonUid = invite.iForFamilyOfGuid + + val familyPerson = if(familyPersonUid != null) { + schoolDb.getPersonEntityDao().findByGuidNum(uidNumberMapper(familyPersonUid)) + } else { + null + } + + val familyPersonName = if(familyPerson != null) { + buildString { + append(familyPerson.person.pGivenName) + append(" ") + familyPerson.person.pMiddleName?.also { + append(it) + append(" ") + } + append(familyPerson.person.pFamilyName) + } + } else { + null + } + + val familyPersonRole = familyPerson?.roles?.first { it.prIsPrimaryRole }?.prRoleEnum return RespectInviteInfo( className = className, + familyPersonName = familyPersonName, + familyPersonRole = familyPersonRole, invite = invite.toModel(), ) } diff --git a/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/RedeemInviteExistingUserRoute.kt b/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/RedeemInviteExistingUserRoute.kt new file mode 100644 index 000000000..02da3ebca --- /dev/null +++ b/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/RedeemInviteExistingUserRoute.kt @@ -0,0 +1,27 @@ +package world.respect.server.routes.school.respect + +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.post +import world.respect.shared.domain.account.invite.RedeemInviteExistingUserUseCase +import world.respect.shared.domain.account.invite.RespectRedeemInviteRequest + +fun Route.RedeemInviteExistingUserRoute( + redeemInviteExistingUserUseCase : (ApplicationCall) -> RedeemInviteExistingUserUseCase +) { + + post("existingUserRedeem") { + val selectedChildGuid = call.request.queryParameters["selectedChildGuid"] + try { + val redeemRequest: RespectRedeemInviteRequest = call.receive() + + redeemInviteExistingUserUseCase(call).invoke(redeemRequest, selectedChildGuid) + call.respond(HttpStatusCode.OK) + } catch (e: Throwable) { + call.respond(HttpStatusCode.BadRequest, e.message ?: "Unknown error") + } + } +} \ No newline at end of file diff --git a/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/RedeemInviteRoute.kt b/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/RedeemInviteRoute.kt index f49528ec4..be388c188 100644 --- a/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/RedeemInviteRoute.kt +++ b/respect-server/src/main/kotlin/world/respect/server/routes/school/respect/RedeemInviteRoute.kt @@ -9,7 +9,7 @@ import world.respect.shared.domain.account.invite.RespectRedeemInviteRequest import world.respect.shared.domain.account.invite.RedeemInviteUseCase fun Route.RedeemInviteRoute( - redeemInviteUseCase: (ApplicationCall) -> RedeemInviteUseCase + redeemInviteUseCase: (ApplicationCall) -> RedeemInviteUseCase, ) { post("redeem") {