create-mithaq 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSES/openstatus-time-picker.txt +21 -0
- package/index.js +457 -63
- package/package.json +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/package.json +5 -3
- package/template/apps/{asas-platform → asas-backoffice}/src/api.ts +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/src/auth-frame.tsx +12 -12
- package/template/apps/asas-backoffice/src/components.ts +35 -0
- package/template/apps/asas-backoffice/src/first-user.ts +14 -0
- package/template/apps/{asas-platform → asas-backoffice}/src/links.ts +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/src/locale-switch.tsx +7 -5
- package/template/apps/asas-backoffice/src/no-organization.tsx +5 -0
- package/template/apps/asas-backoffice/src/redirect.ts +1 -0
- package/template/apps/{asas-platform → asas-backoffice}/src/request-headers.ts +1 -1
- package/template/apps/asas-backoffice/src/routeTree.gen.ts +206 -0
- package/template/apps/asas-backoffice/src/router.tsx +18 -0
- package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/files.ts +10 -0
- package/template/apps/{asas-platform/src/routes/(screens)/platform → asas-backoffice/src/routes/(screens)/backoffice}/screen.json +426 -64
- package/template/apps/{asas-platform/src/routes/(screens)/platform → asas-backoffice/src/routes/(screens)/backoffice}/types.ts +18 -0
- package/template/apps/{asas-platform/src/routes/(screens)/platform.$.tsx → asas-backoffice/src/routes/(screens)/backoffice.$.tsx} +4 -4
- package/template/apps/{asas-platform/src/routes/(screens)/platform.index.tsx → asas-backoffice/src/routes/(screens)/backoffice.index.tsx} +3 -3
- package/template/apps/{asas-platform/src/routes/(screens)/platform.tsx → asas-backoffice/src/routes/(screens)/backoffice.tsx} +3 -3
- package/template/apps/{asas-platform → asas-backoffice}/src/routes/__root.tsx +2 -2
- package/template/apps/asas-backoffice/src/routes/api/$.ts +34 -0
- package/template/apps/asas-backoffice/src/routes/auth.create-first-user.tsx +113 -0
- package/template/apps/{asas-platform → asas-backoffice}/src/routes/auth.sign-in.tsx +9 -4
- package/template/apps/{asas-platform → asas-backoffice}/src/routes/unauthorized.tsx +24 -12
- package/template/apps/{asas-platform → asas-backoffice}/src/screen-form.tsx +5 -3
- package/template/apps/{asas-platform → asas-backoffice}/src/screen-list.tsx +6 -5
- package/template/apps/{asas-platform → asas-backoffice}/src/screen-names.ts +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/src/screens.tsx +46 -26
- package/template/apps/asas-backoffice/src/server.ts +30 -0
- package/template/apps/{asas-platform → asas-backoffice}/src/slide.ts +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/src/start.ts +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/src/styles.css +9 -7
- package/template/apps/{asas-platform → asas-backoffice}/tsconfig.json +2 -2
- package/template/apps/{asas-platform → asas-backoffice}/vite.config.ts +7 -1
- package/template/apps/asas-tanstack/package.json +4 -2
- package/template/apps/asas-tanstack/src/api.ts +1 -1
- package/template/apps/asas-tanstack/src/auth-frame.tsx +12 -12
- package/template/apps/asas-tanstack/src/choose-password.tsx +150 -0
- package/template/apps/asas-tanstack/src/components.ts +35 -0
- package/template/apps/asas-tanstack/src/copy.ts +416 -79
- package/template/apps/asas-tanstack/src/first-run.ts +16 -0
- package/template/apps/asas-tanstack/src/links.ts +1 -1
- package/template/apps/asas-tanstack/src/locale-switch.tsx +7 -5
- package/template/apps/asas-tanstack/src/no-organization.tsx +108 -0
- package/template/apps/asas-tanstack/src/redirect.ts +20 -1
- package/template/apps/asas-tanstack/src/request-headers.ts +1 -1
- package/template/apps/asas-tanstack/src/routeTree.gen.ts +42 -0
- package/template/apps/asas-tanstack/src/router.tsx +12 -1
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +9 -2
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +475 -116
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +4 -2
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +7 -2
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +83 -319
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +3 -1
- package/template/apps/asas-tanstack/src/routes/__root.tsx +1 -1
- package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +14 -7
- package/template/apps/asas-tanstack/src/routes/auth.first-run.tsx +114 -0
- package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +2 -2
- package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +5 -5
- package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +7 -100
- package/template/apps/asas-tanstack/src/routes/auth.set-password.tsx +16 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +27 -15
- package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +59 -38
- package/template/apps/asas-tanstack/src/routes/index.tsx +18 -14
- package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +24 -12
- package/template/apps/asas-tanstack/src/screen-form.tsx +5 -3
- package/template/apps/asas-tanstack/src/screen-list.tsx +6 -5
- package/template/apps/asas-tanstack/src/screen-names.ts +1 -1
- package/template/apps/asas-tanstack/src/screens.tsx +46 -26
- package/template/apps/asas-tanstack/src/server.ts +10 -0
- package/template/apps/asas-tanstack/src/slide.ts +1 -1
- package/template/apps/asas-tanstack/src/styles.css +7 -3
- package/template/apps/asas-tanstack/tsconfig.json +2 -2
- package/template/apps/asas-tanstack/vite.config.ts +7 -1
- package/template/package.json +3 -3
- package/template/templates/asas/.claude/skills/add-field/SKILL.md +27 -0
- package/template/templates/asas/.claude/skills/add-kayan/SKILL.md +26 -0
- package/template/templates/asas/.claude/skills/eject-admin-piece/SKILL.md +28 -0
- package/template/templates/asas/.claude/skills/upgrade-mithaq/SKILL.md +35 -0
- package/template/templates/asas/.env.example +22 -5
- package/template/templates/asas/AGENTS.md +101 -0
- package/template/templates/asas/CLAUDE.md +1 -0
- package/template/templates/asas/README.md +1056 -0
- package/template/templates/asas/package.json +19 -4
- package/template/templates/asas/src/asas.ts +81 -56
- package/template/templates/asas/src/{platform-http.ts → backoffice-http.ts} +48 -27
- package/template/templates/asas/src/dev-setup.ts +112 -0
- package/template/templates/asas/src/local-database.ts +22 -0
- package/template/templates/asas/src/messages/account.json +129 -103
- package/template/templates/asas/src/messages/auditLog.json +223 -105
- package/template/templates/asas/src/messages/backofficeLog.json +132 -0
- package/template/templates/asas/src/messages/courses.json +256 -214
- package/template/templates/asas/src/messages/enrollments.json +45 -27
- package/template/templates/asas/src/messages/invitation.json +72 -56
- package/template/templates/asas/src/messages/localUsers.json +34 -28
- package/template/templates/asas/src/messages/media.json +31 -21
- package/template/templates/asas/src/messages/member.json +59 -39
- package/template/templates/asas/src/messages/memberRoles.json +37 -31
- package/template/templates/asas/src/messages/mithaq.json +206 -162
- package/template/templates/asas/src/messages/organization.json +63 -41
- package/template/templates/asas/src/messages/organizationRole.json +59 -47
- package/template/templates/asas/src/messages/roles.json +59 -45
- package/template/templates/asas/src/messages/session.json +99 -79
- package/template/templates/asas/src/messages/sessions.json +110 -80
- package/template/templates/asas/src/messages/settings.json +51 -53
- package/template/templates/asas/src/messages/tags.json +31 -21
- package/template/templates/asas/src/messages/user.json +109 -87
- package/template/templates/asas/src/messages/verification.json +59 -47
- package/template/templates/asas/src/migrations/20261014221355_tags_restrict/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20261014221355_tags_restrict/snapshot.json +3881 -0
- package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/snapshot.json +3894 -0
- package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/snapshot.json +3907 -0
- package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/snapshot.json +3907 -0
- package/template/templates/asas/src/migrations/20261015042444_organization_names/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20261015042444_organization_names/snapshot.json +3920 -0
- package/template/templates/asas/src/migrations/20261015053212_member_last_seen/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20261015053212_member_last_seen/snapshot.json +3933 -0
- package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/migration.sql +15 -0
- package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/snapshot.json +4113 -0
- package/template/templates/asas/src/migrations/20261015095752_portal/migration.sql +2 -0
- package/template/templates/asas/src/migrations/20261015095752_portal/snapshot.json +4113 -0
- package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/migration.sql +9 -0
- package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/snapshot.json +4302 -0
- package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/migration.sql +27 -0
- package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/snapshot.json +4289 -0
- package/template/templates/asas/src/migrations/schema.ts +1 -0
- package/template/templates/asas/src/mithaq.ts +4 -406
- package/template/templates/asas/src/organizations.ts +3 -0
- package/template/templates/asas/src/seed.ts +147 -45
- package/template/templates/asas/src/{ui-platform → ui-backoffice}/home.tsx +9 -9
- package/template/templates/asas/src/ui-backoffice/organization-actions.tsx +121 -0
- package/template/templates/asas/src/{ui-platform → ui-backoffice}/record-page.tsx +4 -4
- package/template/templates/asas/src/{ui-platform → ui-backoffice}/role-cells.tsx +13 -6
- package/template/templates/asas/src/{ui-platform → ui-backoffice}/user-actions.tsx +46 -48
- package/template/templates/asas/src/ui-dashboard/dashboard.tsx +10 -4
- package/template/templates/asas/src/ui-dashboard/home.tsx +73 -0
- package/template/templates/asas/src/ui-dashboard/members-card.tsx +147 -0
- package/template/templates/asas/src/ui-dashboard/members.tsx +637 -438
- package/template/templates/asas/src/ui-dashboard/versions.tsx +22 -8
- package/template/templates/asas/src/ui-portal/courses-home.tsx +96 -0
- package/template/templates/asas/src/ui-portal/home.tsx +9 -13
- package/template/templates/features/audit-log/audit-log/audit-log.ts +284 -0
- package/template/templates/features/audit-log/backoffice-log/backoffice-log.ts +67 -0
- package/template/templates/features/audit-log/index.ts +4 -0
- package/template/templates/features/audit-log/package.json +13 -0
- package/template/templates/features/auth/index.ts +11 -0
- package/template/templates/{asas/src → features/auth}/local-users/local-users.ts +7 -7
- package/template/templates/{auth → features/auth}/package.json +2 -3
- package/template/templates/{asas/src/kayan → features}/auth/user/account.access.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/account.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/invitation.access.ts +1 -1
- package/template/templates/{asas/src/kayan → features}/auth/user/invitation.ts +3 -3
- package/template/templates/{asas/src/kayan → features}/auth/user/member.access.ts +4 -4
- package/template/templates/{asas/src/kayan → features}/auth/user/member.screens.ts +5 -5
- package/template/templates/{asas/src/kayan → features}/auth/user/member.ts +4 -3
- package/template/templates/{asas/src/kayan → features}/auth/user/organization.access.ts +6 -6
- package/template/templates/{asas/src/kayan → features}/auth/user/organization.screens.ts +6 -6
- package/template/templates/{asas/src/kayan → features}/auth/user/organization.ts +2 -1
- package/template/templates/{asas/src/kayan → features}/auth/user/organizationRole.access.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/organizationRole.ts +3 -3
- package/template/templates/{asas/src/kayan → features}/auth/user/session.access.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/session.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/user.access.ts +8 -7
- package/template/templates/{asas/src/kayan → features}/auth/user/user.screens.ts +9 -9
- package/template/templates/{asas/src/kayan → features}/auth/user/user.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/verification.access.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/verification.ts +2 -2
- package/template/templates/{asas/src → features/courses}/courses/courses.ts +122 -133
- package/template/templates/features/courses/courses/cover-reads.ts +33 -0
- package/template/templates/features/courses/courses/ui/archived-badge.tsx +9 -0
- package/template/templates/{asas/src → features/courses}/courses/ui/fee-cell.tsx +1 -1
- package/template/templates/{asas/src → features/courses}/courses/ui/status-cell.tsx +5 -3
- package/template/templates/features/courses/courses/ui/unpublish-button.tsx +15 -0
- package/template/templates/features/courses/domain/archive.ts +39 -0
- package/template/templates/features/courses/domain/on-offer.ts +6 -0
- package/template/templates/features/courses/enrollments/enrollments.ts +309 -0
- package/template/templates/{asas/src → features/courses}/enrollments/ui/session-cell.tsx +17 -7
- package/template/templates/{asas/src → features/courses/enrollments}/ui/session-labels.tsx +3 -3
- package/template/templates/features/courses/index.ts +8 -0
- package/template/templates/features/courses/package.json +24 -0
- package/template/templates/features/courses/portal/ui/catalogue.tsx +86 -0
- package/template/templates/features/courses/portal/ui/course.tsx +176 -0
- package/template/templates/features/courses/portal/ui/enrollments.tsx +170 -0
- package/template/templates/features/courses/portal/ui/parts.tsx +95 -0
- package/template/templates/features/courses/portal/ui/reads.ts +76 -0
- package/template/templates/features/courses/portal/ui/rules.ts +87 -0
- package/template/templates/features/courses/portal/ui/shell.tsx +194 -0
- package/template/templates/features/courses/sessions/seats.ts +42 -0
- package/template/templates/{asas/src → features/courses}/sessions/sessions.ts +45 -14
- package/template/templates/features/media/index.ts +10 -0
- package/template/templates/{asas/src → features/media}/media/media.ts +34 -40
- package/template/templates/{asas/src → features/media}/media/ui/preview.tsx +2 -2
- package/template/templates/features/media/package.json +19 -0
- package/template/templates/features/roles/index.ts +4 -0
- package/template/templates/{asas/src → features/roles}/member-roles/member-roles.ts +15 -15
- package/template/templates/features/roles/package.json +21 -0
- package/template/templates/{asas/src → features/roles}/roles/roles.ts +23 -23
- package/template/templates/{asas/src → features/roles}/roles/ui/role-form.tsx +65 -24
- package/template/templates/{asas/src → features/roles}/roles/ui/roles.tsx +49 -25
- package/template/templates/features/settings/index.ts +3 -0
- package/template/templates/features/settings/package.json +13 -0
- package/template/templates/{asas/src → features/settings}/settings/settings.ts +12 -17
- package/template/templates/features/tags/index.ts +3 -0
- package/template/templates/features/tags/package.json +14 -0
- package/template/templates/{asas/src → features/tags}/tags/tags.ts +15 -15
- package/template/templates/mithaq/package.json +33 -0
- package/template/templates/mithaq/src/mail/index.ts +32 -0
- package/template/templates/mithaq/src/mail/mailers.ts +36 -0
- package/template/templates/{asas/src/audit-log/sink.ts → mithaq/src/mithaq/audit-sink.ts} +31 -9
- package/template/templates/{auth/src/platform-roles.ts → mithaq/src/mithaq/auth/backoffice-roles.ts} +44 -23
- package/template/templates/mithaq/src/mithaq/auth/index.ts +1859 -0
- package/template/templates/mithaq/src/mithaq/auth/organizations.ts +80 -0
- package/template/templates/{asas/src → mithaq/src/mithaq}/pages.ts +16 -7
- package/template/templates/mithaq/src/mithaq/rich-text-nodes.ts +19 -0
- package/template/templates/mithaq/src/mithaq.ts +593 -0
- package/template/templates/mithaq/src/roles/default-member-roles.ts +29 -0
- package/template/templates/{asas → mithaq}/src/roles/grants.ts +1 -1
- package/template/templates/mithaq/src/roles/member-access.ts +11 -0
- package/template/templates/{asas/src → mithaq/src/roles}/statements.ts +6 -4
- package/template/templates/mithaq/src/roles/system-roles.ts +16 -0
- package/template/templates/{asas/src → mithaq/src/roles}/tenant-access.ts +96 -57
- package/template/templates/{asas/src → mithaq/src/roles}/tenant-roles.ts +84 -25
- package/template/templates/{asas → mithaq}/src/steps/expected-updated-at.ts +2 -2
- package/template/templates/{asas → mithaq}/src/storage/index.ts +3 -1
- package/template/templates/mithaq/src/ui/activity-page.tsx +33 -0
- package/template/templates/mithaq/src/ui/activity.tsx +527 -0
- package/template/templates/{asas → mithaq}/src/ui/app-shell.tsx +27 -19
- package/template/templates/mithaq/src/ui/rich-text-nodes.tsx +33 -0
- package/template/templates/{asas → mithaq}/src/ui/screen-nav.tsx +1 -1
- package/template/templates/{asas → mithaq}/src/ui/top-shell.tsx +8 -8
- package/template/apps/asas-platform/src/components/mithaq/cells.tsx +0 -112
- package/template/apps/asas-platform/src/components/mithaq/controls.tsx +0 -650
- package/template/apps/asas-platform/src/components/mithaq/field-shell.tsx +0 -53
- package/template/apps/asas-platform/src/components/mithaq/filter-operators.ts +0 -108
- package/template/apps/asas-platform/src/components/mithaq/form.tsx +0 -1467
- package/template/apps/asas-platform/src/components/mithaq/list-columns.tsx +0 -108
- package/template/apps/asas-platform/src/components/mithaq/list-filters.tsx +0 -368
- package/template/apps/asas-platform/src/components/mithaq/list-pagination.tsx +0 -153
- package/template/apps/asas-platform/src/components/mithaq/list-query.ts +0 -432
- package/template/apps/asas-platform/src/components/mithaq/list-selection.tsx +0 -74
- package/template/apps/asas-platform/src/components/mithaq/list.tsx +0 -865
- package/template/apps/asas-platform/src/components/mithaq/nav.tsx +0 -871
- package/template/apps/asas-platform/src/components/mithaq/nodes.tsx +0 -1024
- package/template/apps/asas-platform/src/components/mithaq/relationship-cell.tsx +0 -127
- package/template/apps/asas-platform/src/components/mithaq/relationship-control.tsx +0 -400
- package/template/apps/asas-platform/src/components/mithaq/rich-text-nodes.ts +0 -6
- package/template/apps/asas-platform/src/components/mithaq/rich-text.tsx +0 -1083
- package/template/apps/asas-platform/src/components/mithaq/upload.tsx +0 -686
- package/template/apps/asas-platform/src/components/ui/drawer.tsx +0 -216
- package/template/apps/asas-platform/src/redirect.ts +0 -1
- package/template/apps/asas-platform/src/routeTree.gen.ts +0 -181
- package/template/apps/asas-platform/src/router.tsx +0 -7
- package/template/apps/asas-platform/src/routes/(screens)/platform/files.ts +0 -4
- package/template/apps/asas-platform/src/routes/api/$.ts +0 -30
- package/template/apps/asas-platform/src/server.ts +0 -20
- package/template/apps/asas-tanstack/src/components/mithaq/cells.tsx +0 -112
- package/template/apps/asas-tanstack/src/components/mithaq/controls.tsx +0 -650
- package/template/apps/asas-tanstack/src/components/mithaq/field-shell.tsx +0 -53
- package/template/apps/asas-tanstack/src/components/mithaq/filter-operators.ts +0 -108
- package/template/apps/asas-tanstack/src/components/mithaq/form.tsx +0 -1467
- package/template/apps/asas-tanstack/src/components/mithaq/list-columns.tsx +0 -108
- package/template/apps/asas-tanstack/src/components/mithaq/list-filters.tsx +0 -368
- package/template/apps/asas-tanstack/src/components/mithaq/list-pagination.tsx +0 -153
- package/template/apps/asas-tanstack/src/components/mithaq/list-query.ts +0 -432
- package/template/apps/asas-tanstack/src/components/mithaq/list-selection.tsx +0 -74
- package/template/apps/asas-tanstack/src/components/mithaq/list.tsx +0 -865
- package/template/apps/asas-tanstack/src/components/mithaq/nav.tsx +0 -871
- package/template/apps/asas-tanstack/src/components/mithaq/nodes.tsx +0 -1024
- package/template/apps/asas-tanstack/src/components/mithaq/relationship-cell.tsx +0 -127
- package/template/apps/asas-tanstack/src/components/mithaq/relationship-control.tsx +0 -400
- package/template/apps/asas-tanstack/src/components/mithaq/rich-text-nodes.ts +0 -6
- package/template/apps/asas-tanstack/src/components/mithaq/rich-text.tsx +0 -1083
- package/template/apps/asas-tanstack/src/components/mithaq/upload.tsx +0 -686
- package/template/apps/asas-tanstack/src/components/ui/drawer.tsx +0 -215
- package/template/templates/asas/src/audit-log/audit-log.ts +0 -187
- package/template/templates/asas/src/audit-log/ui/activity.tsx +0 -243
- package/template/templates/asas/src/courses/ui/archive-button.tsx +0 -25
- package/template/templates/asas/src/courses/ui/no-courses-on-offer.tsx +0 -11
- package/template/templates/asas/src/courses/ui/on-offer.ts +0 -6
- package/template/templates/asas/src/enrollments/enrollments.ts +0 -137
- package/template/templates/asas/src/rich-text-nodes.ts +0 -11
- package/template/templates/asas/src/roles.ts +0 -16
- package/template/templates/asas/src/ui/rich-text-nodes.tsx +0 -15
- package/template/templates/asas/src/ui-platform/organization-actions.tsx +0 -114
- package/template/templates/auth/src/index.ts +0 -690
- /package/template/apps/{asas-platform → asas-backoffice}/components.json +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/gitignore +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/public/favicon.svg +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/accordion.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/alert-dialog.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/avatar.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/badge.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/breadcrumb.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/button-group.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/button.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/calendar.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/card.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/chart.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/checkbox.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/collapsible.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/combobox.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/dialog.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/direction.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/directional-icon.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/dropdown-menu.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/field.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input-group.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input-otp.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/label.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/popover.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/radio-group.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/resizable.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/scroll-area.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/select.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/separator.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/sheet.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/sidebar.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/skeleton.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/slider.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/spinner.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/switch.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/table.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/tabs.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/textarea.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/toggle-group.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/toggle.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/tooltip.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/copy.ts +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/hooks/use-mobile.ts +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/lib/utils.ts +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/theme.ts +0 -0
- /package/template/templates/{asas/src/kayan → features}/auth/user/index.ts +0 -0
- /package/template/templates/{asas/src → features/courses}/courses/ui/no-courses.tsx +0 -0
- /package/template/templates/{asas → mithaq}/components.json +0 -0
- /package/template/templates/{asas → mithaq}/src/storage/defaults.ts +0 -0
- /package/template/templates/{asas → mithaq}/src/storage/local.ts +0 -0
- /package/template/templates/{asas → mithaq}/src/storage/r2.ts +0 -0
- /package/template/templates/{asas → mithaq}/src/storage/s3.ts +0 -0
- /package/template/templates/{asas → mithaq}/src/storage/uploadthing.ts +0 -0
- /package/template/templates/{asas → mithaq}/src/ui/kpi-card.tsx +0 -0
- /package/template/templates/{asas → mithaq}/src/ui/record-page.tsx +0 -0
- /package/template/templates/{asas → mithaq}/src/ui/table-page.tsx +0 -0
|
@@ -3,8 +3,9 @@ import { Link, createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
|
3
3
|
import { Button } from '@/components/ui/button'
|
|
4
4
|
import { authArm } from '../api.ts'
|
|
5
5
|
import { documentHead } from '../copy.ts'
|
|
6
|
-
import { AuthFrame, authGate, useRefusal } from '../auth-frame.tsx'
|
|
6
|
+
import { AuthFrame, authGate, useHomeLabel, useRefusal } from '../auth-frame.tsx'
|
|
7
7
|
import { useCopy } from './__root.tsx'
|
|
8
|
+
import { landing } from '../redirect.ts'
|
|
8
9
|
|
|
9
10
|
// Accepting is a POST the person presses, so following a link alone joins nobody to anything.
|
|
10
11
|
// The provider decides whether the invitation is theirs.
|
|
@@ -17,11 +18,12 @@ export const Route = createFileRoute('/auth/accept-invitation')({
|
|
|
17
18
|
})
|
|
18
19
|
|
|
19
20
|
function AcceptInvitation() {
|
|
20
|
-
const { t } = useCopy()
|
|
21
|
+
const { t, locale } = useCopy()
|
|
21
22
|
const navigate = useNavigate()
|
|
22
23
|
const refused = useRefusal()
|
|
23
24
|
const { id } = Route.useSearch()
|
|
24
|
-
const { signedIn } = Route.useRouteContext()
|
|
25
|
+
const { signedIn, home } = Route.useRouteContext()
|
|
26
|
+
const homeLabel = useHomeLabel()
|
|
25
27
|
const [organization, setOrganization] = useState<string>()
|
|
26
28
|
const [gone, setGone] = useState<string>()
|
|
27
29
|
const [pending, setPending] = useState(false)
|
|
@@ -32,13 +34,13 @@ function AcceptInvitation() {
|
|
|
32
34
|
if (!signedIn || !id) return
|
|
33
35
|
fetch(`/api/auth/user/organization/get-invitation?id=${encodeURIComponent(id)}`)
|
|
34
36
|
.then(async (response) => {
|
|
35
|
-
const body = (await response.json().catch(() => null)) as { organizationName?: string; code?: unknown } | null
|
|
36
|
-
if (response.ok) return setOrganization(body?.organizationName
|
|
37
|
+
const body = (await response.json().catch(() => null)) as { organizationName?: string; organizationNames?: Record<string, string>; code?: unknown } | null
|
|
38
|
+
if (response.ok) return setOrganization(body?.organizationNames?.[locale] || body?.organizationName || '')
|
|
37
39
|
// Better Auth answers a missing, spent or expired invitation 400 with no code.
|
|
38
40
|
setGone(refused({ status: response.status, body }, t('auth.invitationGone', 'This invitation has expired, was cancelled or was already accepted. Ask whoever invited you to send a new one.')))
|
|
39
41
|
})
|
|
40
42
|
.catch(() => setGone(t('auth.requestFailed', 'That request did not work.')))
|
|
41
|
-
}, [signedIn, id])
|
|
43
|
+
}, [signedIn, id, locale])
|
|
42
44
|
|
|
43
45
|
const accept = async () => {
|
|
44
46
|
setPending(true)
|
|
@@ -49,7 +51,7 @@ function AcceptInvitation() {
|
|
|
49
51
|
return setRefusal(refused(answered, t('auth.invitationAcceptFailed', 'That invitation could not be accepted.')))
|
|
50
52
|
}
|
|
51
53
|
// The provider made the organization active as it accepted, so the first screen is read as a member.
|
|
52
|
-
await navigate({
|
|
54
|
+
await navigate({ href: landing({ asked: undefined, screens: (await authGate()).screens }) })
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
if (!id) return <AuthFrame title={title} description={t('auth.invitationMissing', 'This link names no invitation.')}>{null}</AuthFrame>
|
|
@@ -75,6 +77,11 @@ function AcceptInvitation() {
|
|
|
75
77
|
<p role="alert" data-invitation-gone="" className="text-sm">
|
|
76
78
|
{gone}
|
|
77
79
|
</p>
|
|
80
|
+
{/* A spent invitation is no dead end for someone already in: the way to their own first screen. An anchor,
|
|
81
|
+
because the path is the screen's own data, not a route the router knows. */}
|
|
82
|
+
<Button nativeButton={false} render={<a href={home.path} data-home="" />}>
|
|
83
|
+
{homeLabel(home, 'marketing.openScreen')}
|
|
84
|
+
</Button>
|
|
78
85
|
{/* The invitation may be another address's, which signing out and in again reaches. */}
|
|
79
86
|
<Button type="button" variant="outline" onClick={() => void authArm('sign-out').then(() => window.location.reload())}>
|
|
80
87
|
{t('admin.signOut', 'Sign out')}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useState, type ComponentProps } from 'react'
|
|
2
|
+
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'
|
|
3
|
+
import { Button } from '@/components/ui/button'
|
|
4
|
+
import { authArm } from '../api.ts'
|
|
5
|
+
import { AuthBox, AuthFrame, authGate, notAnEmail, useRefusal } from '../auth-frame.tsx'
|
|
6
|
+
import { documentHead } from '../copy.ts'
|
|
7
|
+
import { firstRun } from '../first-run.ts'
|
|
8
|
+
import { useCopy } from './__root.tsx'
|
|
9
|
+
|
|
10
|
+
// The first owner of an app of one organization, and the organization itself: open once,
|
|
11
|
+
// to the holder of the setup code, and closed for good once the organization exists. The
|
|
12
|
+
// organization's name lives in code (`organizationName` in `organizations.ts`), so none is asked.
|
|
13
|
+
export const Route = createFileRoute('/auth/first-run')({
|
|
14
|
+
head: ({ match }) => documentHead(match.context, (t) => t('auth.firstRun')),
|
|
15
|
+
beforeLoad: async () => {
|
|
16
|
+
const state = await firstRun()
|
|
17
|
+
// `href`, not `to`: sign-in redirects here too, and two routes typed by each other's paths infer nothing.
|
|
18
|
+
if (!state.open) throw redirect({ href: '/auth/sign-in' })
|
|
19
|
+
return { code: state.code, password: (await authGate()).password }
|
|
20
|
+
},
|
|
21
|
+
component: FirstRun,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
function FirstRun() {
|
|
25
|
+
const navigate = useNavigate()
|
|
26
|
+
const { t } = useCopy()
|
|
27
|
+
const refused = useRefusal()
|
|
28
|
+
const { code: asked, password: bounds } = Route.useRouteContext()
|
|
29
|
+
const [name, setName] = useState('')
|
|
30
|
+
const [email, setEmail] = useState('')
|
|
31
|
+
const [password, setPassword] = useState('')
|
|
32
|
+
const [confirmation, setConfirmation] = useState('')
|
|
33
|
+
const [code, setCode] = useState('')
|
|
34
|
+
const [pending, setPending] = useState(false)
|
|
35
|
+
const [refusal, setRefusal] = useState<string | null>(null)
|
|
36
|
+
const [left, setLeft] = useState<ReadonlySet<string>>(() => new Set())
|
|
37
|
+
const required = t('auth.required', 'This field is required.')
|
|
38
|
+
const wrong = {
|
|
39
|
+
name: () => (name.trim() ? undefined : required),
|
|
40
|
+
email: () => (!email ? required : notAnEmail(email) ? t('auth.invalidEmail', 'Invalid email') : undefined),
|
|
41
|
+
password: () =>
|
|
42
|
+
!password ? required
|
|
43
|
+
: password.length < bounds.min ? t('auth.passwordTooShort', 'Password too short')
|
|
44
|
+
: password.length > bounds.max ? t('auth.passwordTooLong', 'Password too long')
|
|
45
|
+
: undefined,
|
|
46
|
+
confirmation: () => (!confirmation ? required : confirmation === password ? undefined : t('auth.passwordsDiffer', 'The two passwords are not the same.')),
|
|
47
|
+
code: () => (asked !== 'required' || code ? undefined : required),
|
|
48
|
+
}
|
|
49
|
+
const errorOf = (box: keyof typeof wrong) => (left.has(box) ? wrong[box]() : undefined)
|
|
50
|
+
const box = (id: keyof typeof wrong, label: string | undefined, props: ComponentProps<'input'> & { description?: string }) => (
|
|
51
|
+
<AuthBox id={id} label={label} error={errorOf(id)} onLeave={() => setLeft((already) => new Set(already).add(id))} {...props} />
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const submit = async (event: React.FormEvent) => {
|
|
55
|
+
event.preventDefault()
|
|
56
|
+
setLeft(new Set(Object.keys(wrong)))
|
|
57
|
+
if (Object.values(wrong).some((check) => check())) return
|
|
58
|
+
setPending(true)
|
|
59
|
+
setRefusal(null)
|
|
60
|
+
const made = await authArm('first-run', { name, email, password, ...(asked === 'required' && { code }) })
|
|
61
|
+
setPending(false)
|
|
62
|
+
if (!made.ok) return setRefusal(refused(made, t('auth.firstRunFailed', 'That owner could not be made.')))
|
|
63
|
+
await navigate({ to: '/auth/sign-in' })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (asked === 'unset') {
|
|
67
|
+
return (
|
|
68
|
+
<AuthFrame title={t('auth.firstRun', 'Set up this app')}>
|
|
69
|
+
<p className="text-sm" role="alert">
|
|
70
|
+
{t('auth.firstRunCodeUnset')}
|
|
71
|
+
</p>
|
|
72
|
+
</AuthFrame>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
return (
|
|
76
|
+
<AuthFrame title={t('auth.firstRun', 'Set up this app')} description={t('auth.firstRunDescription', 'Nobody owns this app yet. The account made here becomes its owner and first administrator.')}>
|
|
77
|
+
<form className="grid gap-4" noValidate onSubmit={submit}>
|
|
78
|
+
{box('name', t('auth.name', 'Name'), { autoComplete: 'name', onChange: (event) => setName(event.target.value), type: 'text', value: name })}
|
|
79
|
+
{box('email', t('auth.email', 'Email'), { autoComplete: 'email', onChange: (event) => setEmail(event.target.value), type: 'email', value: email })}
|
|
80
|
+
{box('password', t('auth.password', 'Password'), {
|
|
81
|
+
autoComplete: 'new-password',
|
|
82
|
+
description: t('auth.passwordRule', 'At least {count} characters.')!.replace('{count}', String(bounds.min)),
|
|
83
|
+
onChange: (event) => setPassword(event.target.value),
|
|
84
|
+
type: 'password',
|
|
85
|
+
value: password,
|
|
86
|
+
})}
|
|
87
|
+
{box('confirmation', t('auth.confirmPassword', 'Confirm the password'), {
|
|
88
|
+
autoComplete: 'new-password',
|
|
89
|
+
onChange: (event) => setConfirmation(event.target.value),
|
|
90
|
+
type: 'password',
|
|
91
|
+
value: confirmation,
|
|
92
|
+
})}
|
|
93
|
+
{asked === 'required'
|
|
94
|
+
? box('code', t('auth.setupCode', 'Setup code'), {
|
|
95
|
+
autoComplete: 'off',
|
|
96
|
+
description: t('auth.setupCodeDescription', 'The SETUP_CODE this server was started with.'),
|
|
97
|
+
onChange: (event) => setCode(event.target.value),
|
|
98
|
+
type: 'password',
|
|
99
|
+
value: code,
|
|
100
|
+
})
|
|
101
|
+
: null}
|
|
102
|
+
{/* `role="alert"` so a refusal is spoken when it appears; nothing moves the focus to it. */}
|
|
103
|
+
{refusal && (
|
|
104
|
+
<p className="text-destructive text-sm" role="alert">
|
|
105
|
+
{refusal}
|
|
106
|
+
</p>
|
|
107
|
+
)}
|
|
108
|
+
<Button disabled={pending} type="submit">
|
|
109
|
+
{pending ? t('auth.firstRunPending', 'Setting up…') : t('auth.firstRunSubmit', 'Set up the app')}
|
|
110
|
+
</Button>
|
|
111
|
+
</form>
|
|
112
|
+
</AuthFrame>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
@@ -19,7 +19,7 @@ export const Route = createFileRoute('/auth/forgot-password')({
|
|
|
19
19
|
function ForgotPassword() {
|
|
20
20
|
const { t } = useCopy()
|
|
21
21
|
const search = Route.useSearch()
|
|
22
|
-
const { signedIn, home } = Route.useRouteContext()
|
|
22
|
+
const { signedIn, home, screens } = Route.useRouteContext()
|
|
23
23
|
const refused = useRefusal()
|
|
24
24
|
const [email, setEmail] = useState('')
|
|
25
25
|
const [left, setLeft] = useState(false)
|
|
@@ -47,7 +47,7 @@ function ForgotPassword() {
|
|
|
47
47
|
setSent(true)
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
if (signedIn) return <AlreadySignedIn redirect={search.redirect} home={home} />
|
|
50
|
+
if (signedIn) return <AlreadySignedIn redirect={search.redirect} home={home} screens={screens} />
|
|
51
51
|
if (sent) {
|
|
52
52
|
return (
|
|
53
53
|
<AuthFrame
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
// Trades the
|
|
1
|
+
// Trades the backoffice's one-time impersonation token for the session through the plugin's own
|
|
2
2
|
// verify, then lands on the first screen the impersonated person may enter. The access report is
|
|
3
3
|
// read with only the session the verify set, so the landing grants nothing. A spent, expired or
|
|
4
4
|
// unknown token sets nothing and lands on sign-in.
|
|
5
5
|
import { createFileRoute } from '@tanstack/react-router'
|
|
6
6
|
import type {} from '@tanstack/start-client-core'
|
|
7
7
|
|
|
8
|
-
const firstScreen = (targets: unknown, listed: readonly string[],
|
|
8
|
+
const firstScreen = (targets: unknown, listed: readonly string[], backoffice: string) => {
|
|
9
9
|
const screens = targets as Record<string, { capability?: string; client?: { path: string } } | undefined>
|
|
10
|
-
const name = listed.find((one) => one !==
|
|
10
|
+
const name = listed.find((one) => one !== backoffice && screens[one]?.capability === 'screen')
|
|
11
11
|
return name ? screens[name]!.client!.path : '/unauthorized'
|
|
12
12
|
}
|
|
13
13
|
|
|
@@ -15,7 +15,7 @@ export const Route = createFileRoute('/auth/handoff')({
|
|
|
15
15
|
server: {
|
|
16
16
|
handlers: {
|
|
17
17
|
GET: async ({ request }) => {
|
|
18
|
-
const { asas, tenantURL,
|
|
18
|
+
const { asas, tenantURL, BACKOFFICE_SCREEN } = await import('asas')
|
|
19
19
|
// The deployment's own origin, never the request's, whose host a caller writes.
|
|
20
20
|
const own = new URL(tenantURL).origin
|
|
21
21
|
const verified = await asas.handler(
|
|
@@ -30,7 +30,7 @@ export const Route = createFileRoute('/auth/handoff')({
|
|
|
30
30
|
const report = verified.ok
|
|
31
31
|
? ((await (await asas.handler(new Request(new URL('/api/access', own), { headers: { cookie: session } }))).json().catch(() => ({}))) as { screens?: string[] })
|
|
32
32
|
: undefined
|
|
33
|
-
const headers = new Headers({ location: report ? firstScreen(asas.targets, report.screens ?? [],
|
|
33
|
+
const headers = new Headers({ location: report ? firstScreen(asas.targets, report.screens ?? [], BACKOFFICE_SCREEN) : '/auth/sign-in' })
|
|
34
34
|
for (const cookie of cookies) headers.append('set-cookie', cookie)
|
|
35
35
|
return new Response(null, { status: 302, headers })
|
|
36
36
|
},
|
|
@@ -1,109 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Link, createFileRoute } from '@tanstack/react-router'
|
|
3
|
-
import { Button } from '@/components/ui/button'
|
|
4
|
-
import { authArm } from '../api.ts'
|
|
1
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
5
2
|
import { documentHead } from '../copy.ts'
|
|
6
|
-
import {
|
|
7
|
-
import { useCopy } from './__root.tsx'
|
|
3
|
+
import { ChoosePassword, passwordGate, tokenSearch } from '../choose-password.tsx'
|
|
8
4
|
|
|
5
|
+
// Where a forgotten password's link lands. A first password, chosen from a sign-up's or an added person's link, has a
|
|
6
|
+
// page of its own, `/auth/set-password`, with the same form.
|
|
9
7
|
export const Route = createFileRoute('/auth/reset-password')({
|
|
10
8
|
head: ({ match }) => documentHead(match.context, (t) => t('auth.resetPassword')),
|
|
11
|
-
validateSearch:
|
|
12
|
-
|
|
13
|
-
...(typeof search.token === 'string' && search.token ? { token: search.token } : {}),
|
|
14
|
-
}),
|
|
15
|
-
beforeLoad: () => authGate(),
|
|
9
|
+
validateSearch: tokenSearch,
|
|
10
|
+
beforeLoad: ({ search }: { search: { token?: string } }) => passwordGate(search),
|
|
16
11
|
component: ResetPassword,
|
|
17
12
|
})
|
|
18
13
|
|
|
19
14
|
function ResetPassword() {
|
|
20
|
-
|
|
21
|
-
const { token, redirect } = Route.useSearch()
|
|
22
|
-
const { password: bounds, signedIn, home } = Route.useRouteContext()
|
|
23
|
-
const refused = useRefusal()
|
|
24
|
-
const [password, setPassword] = useState('')
|
|
25
|
-
const [confirmation, setConfirmation] = useState('')
|
|
26
|
-
const [pending, setPending] = useState(false)
|
|
27
|
-
const [done, setDone] = useState(false)
|
|
28
|
-
const [refusal, setRefusal] = useState<string | null>(null)
|
|
29
|
-
const [left, setLeft] = useState<ReadonlySet<string>>(() => new Set())
|
|
30
|
-
const wrong = {
|
|
31
|
-
password: () =>
|
|
32
|
-
!password ? t('auth.required', 'This field is required.')
|
|
33
|
-
: password.length < bounds.min ? t('auth.passwordTooShort', 'Password too short')
|
|
34
|
-
: password.length > bounds.max ? t('auth.passwordTooLong', 'Password too long')
|
|
35
|
-
: undefined,
|
|
36
|
-
confirmation: () => (!confirmation ? t('auth.required', 'This field is required.') : confirmation === password ? undefined : t('auth.passwordsDiffer', 'The two passwords are not the same.')),
|
|
37
|
-
}
|
|
38
|
-
const errorOf = (box: keyof typeof wrong) => (left.has(box) ? wrong[box]() : undefined)
|
|
39
|
-
const box = (id: keyof typeof wrong, label: string | undefined, props: ComponentProps<'input'> & { description?: string }) => (
|
|
40
|
-
<AuthBox id={id} label={label} error={errorOf(id)} onLeave={() => setLeft((already) => new Set(already).add(id))} {...props} />
|
|
41
|
-
)
|
|
42
|
-
const signIn = (
|
|
43
|
-
<p className="mt-4 text-center text-sm">
|
|
44
|
-
<Link className="text-primary underline underline-offset-4" to="/auth/sign-in">
|
|
45
|
-
{t('auth.signIn', 'Sign in')}
|
|
46
|
-
</Link>
|
|
47
|
-
</p>
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
const submit = async (event: React.FormEvent) => {
|
|
51
|
-
event.preventDefault()
|
|
52
|
-
setLeft(new Set(Object.keys(wrong)))
|
|
53
|
-
if (Object.values(wrong).some((check) => check())) return
|
|
54
|
-
setPending(true)
|
|
55
|
-
setRefusal(null)
|
|
56
|
-
const answered = await authArm('reset-password', { newPassword: password, token })
|
|
57
|
-
setPending(false)
|
|
58
|
-
if (!answered.ok) return setRefusal(refused(answered, t('auth.resetFailed', 'That reset did not work.')))
|
|
59
|
-
setDone(true)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (signedIn) return <AlreadySignedIn redirect={redirect} home={home} />
|
|
63
|
-
if (!token) {
|
|
64
|
-
return (
|
|
65
|
-
<AuthFrame title={t('auth.resetPassword', 'Reset password')} description={t('auth.tokenInvalid', 'Token is either invalid or has expired.')}>
|
|
66
|
-
<p className="text-center text-sm">
|
|
67
|
-
<Link className="text-primary underline underline-offset-4" to="/auth/forgot-password">
|
|
68
|
-
{t('auth.forgotPassword', 'Forgot password')}
|
|
69
|
-
</Link>
|
|
70
|
-
</p>
|
|
71
|
-
</AuthFrame>
|
|
72
|
-
)
|
|
73
|
-
}
|
|
74
|
-
if (done) {
|
|
75
|
-
return (
|
|
76
|
-
<AuthFrame title={t('auth.resetPassword', 'Reset password')} description={t('auth.passwordReset', 'Password reset successfully.')}>
|
|
77
|
-
{signIn}
|
|
78
|
-
</AuthFrame>
|
|
79
|
-
)
|
|
80
|
-
}
|
|
81
|
-
return (
|
|
82
|
-
<AuthFrame title={t('auth.resetPassword', 'Reset password')}>
|
|
83
|
-
<form className="grid gap-4" noValidate onSubmit={submit}>
|
|
84
|
-
{box('password', t('auth.newPassword', 'New password'), {
|
|
85
|
-
autoComplete: 'new-password',
|
|
86
|
-
description: t('auth.passwordRule', 'At least {count} characters.')!.replace('{count}', String(bounds.min)),
|
|
87
|
-
onChange: (event) => setPassword(event.target.value),
|
|
88
|
-
type: 'password',
|
|
89
|
-
value: password,
|
|
90
|
-
})}
|
|
91
|
-
{box('confirmation', t('auth.confirmPassword', 'Confirm the password'), {
|
|
92
|
-
autoComplete: 'new-password',
|
|
93
|
-
onChange: (event) => setConfirmation(event.target.value),
|
|
94
|
-
type: 'password',
|
|
95
|
-
value: confirmation,
|
|
96
|
-
})}
|
|
97
|
-
{refusal && (
|
|
98
|
-
<p className="text-destructive text-sm" role="alert">
|
|
99
|
-
{refusal}
|
|
100
|
-
</p>
|
|
101
|
-
)}
|
|
102
|
-
<Button disabled={pending} type="submit">
|
|
103
|
-
{t('auth.resetPassword', 'Reset password')}
|
|
104
|
-
</Button>
|
|
105
|
-
</form>
|
|
106
|
-
{signIn}
|
|
107
|
-
</AuthFrame>
|
|
108
|
-
)
|
|
15
|
+
return <ChoosePassword {...Route.useSearch()} {...Route.useRouteContext()} first={false} />
|
|
109
16
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
import { documentHead } from '../copy.ts'
|
|
3
|
+
import { ChoosePassword, passwordGate, tokenSearch } from '../choose-password.tsx'
|
|
4
|
+
|
|
5
|
+
// Where a sign-up's or an added person's link lands: a brand-new account chooses its first password here, under a
|
|
6
|
+
// title that says so, and never reads that it is resetting one.
|
|
7
|
+
export const Route = createFileRoute('/auth/set-password')({
|
|
8
|
+
head: ({ match }) => documentHead(match.context, (t) => t('auth.setPassword')),
|
|
9
|
+
validateSearch: tokenSearch,
|
|
10
|
+
beforeLoad: ({ search }: { search: { token?: string } }) => passwordGate(search),
|
|
11
|
+
component: SetPassword,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
function SetPassword() {
|
|
15
|
+
return <ChoosePassword {...Route.useSearch()} {...Route.useRouteContext()} first />
|
|
16
|
+
}
|
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
import { useState, type ComponentProps } from 'react'
|
|
2
|
-
import { Link, createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
2
|
+
import { Link, createFileRoute, redirect, useNavigate } from '@tanstack/react-router'
|
|
3
|
+
import { organizations, signUp } from 'asas/organizations'
|
|
3
4
|
import { Button } from '@/components/ui/button'
|
|
4
5
|
import { authArm } from '../api.ts'
|
|
5
6
|
import { documentHead } from '../copy.ts'
|
|
6
7
|
import { AuthBox, AuthFrame, authGate, gate, notAnEmail, redirectSearch, useRefusal } from '../auth-frame.tsx'
|
|
8
|
+
import { firstRun } from '../first-run.ts'
|
|
7
9
|
import { useCopy } from './__root.tsx'
|
|
8
|
-
import {
|
|
10
|
+
import { landing } from '../redirect.ts'
|
|
9
11
|
|
|
10
12
|
// The password has no length rule here because the provider's sign-in has none: a wrong
|
|
11
13
|
// password of any length is the one refusal it answers.
|
|
12
14
|
|
|
13
15
|
export const Route = createFileRoute('/auth/sign-in')({
|
|
14
16
|
head: ({ match }) => documentHead(match.context, (t) => t('auth.signIn')),
|
|
15
|
-
// The router hands `validateSearch` whatever the URL carried; `
|
|
17
|
+
// The router hands `validateSearch` whatever the URL carried; `landing` decides what is honoured.
|
|
16
18
|
validateSearch: redirectSearch,
|
|
17
|
-
|
|
19
|
+
// Nobody signs in to an app of one organization before its owner has set it up.
|
|
20
|
+
beforeLoad: async ({ search }: { search: { redirect?: string } }) => {
|
|
21
|
+
if ((await firstRun()).open) throw redirect({ href: '/auth/first-run' })
|
|
22
|
+
return gate({ search })
|
|
23
|
+
},
|
|
18
24
|
component: SignIn,
|
|
19
25
|
})
|
|
20
26
|
|
|
27
|
+
/** Whether a visitor with no invitation may make an account here, so the page offers one. */
|
|
28
|
+
const offersSignUp = organizations === 'many' || signUp === 'open'
|
|
29
|
+
|
|
21
30
|
function SignIn() {
|
|
22
31
|
const navigate = useNavigate()
|
|
23
32
|
const { t } = useCopy()
|
|
@@ -26,7 +35,8 @@ function SignIn() {
|
|
|
26
35
|
const [email, setEmail] = useState('')
|
|
27
36
|
const [password, setPassword] = useState('')
|
|
28
37
|
const [pending, setPending] = useState(false)
|
|
29
|
-
|
|
38
|
+
// The answer, not its sentence, so a language switch says it again in the language chosen.
|
|
39
|
+
const [refusal, setRefusal] = useState<Parameters<typeof refused>[0] | null>(null)
|
|
30
40
|
const [left, setLeft] = useState<ReadonlySet<string>>(() => new Set())
|
|
31
41
|
const required = t('auth.required', 'This field is required.')
|
|
32
42
|
const wrong = {
|
|
@@ -48,17 +58,17 @@ function SignIn() {
|
|
|
48
58
|
// The project's own sentence for the provider's code, never its English `message`, since copy carries every locale.
|
|
49
59
|
if (!answered.ok) {
|
|
50
60
|
setPending(false)
|
|
51
|
-
return setRefusal(
|
|
61
|
+
return setRefusal(answered)
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
setPending(false)
|
|
55
65
|
|
|
56
66
|
// The first screen this actor enters is read only once the session exists.
|
|
57
|
-
await navigate({
|
|
67
|
+
await navigate({ href: landing({ asked: search.redirect, screens: (await authGate()).screens }) })
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
return (
|
|
61
|
-
<AuthFrame title={t('auth.signIn', 'Sign in')} description={t('auth.signInDescription', 'Sign in
|
|
71
|
+
<AuthFrame title={t('auth.signIn', 'Sign in')} description={t('auth.signInDescription', 'Welcome back. Sign in with your email and password.')}>
|
|
62
72
|
<form className="grid gap-4" noValidate onSubmit={submit}>
|
|
63
73
|
{box('email', t('auth.email', 'Email'), { autoComplete: 'email', onChange: (event) => setEmail(event.target.value), type: 'email', value: email })}
|
|
64
74
|
{box('password', t('auth.password', 'Password'), { autoComplete: 'current-password', onChange: (event) => setPassword(event.target.value), type: 'password', value: password })}
|
|
@@ -68,19 +78,21 @@ function SignIn() {
|
|
|
68
78
|
{/* `role="alert"` so a refusal is spoken when it appears: nothing moves the focus to it. */}
|
|
69
79
|
{refusal && (
|
|
70
80
|
<p className="text-destructive text-sm" role="alert">
|
|
71
|
-
{refusal}
|
|
81
|
+
{refused(refusal, t('auth.failed', 'That sign-in did not work.'))}
|
|
72
82
|
</p>
|
|
73
83
|
)}
|
|
74
84
|
<Button disabled={pending} type="submit">
|
|
75
85
|
{pending ? t('auth.signInPending', 'Signing in…') : t('auth.signIn', 'Sign in')}
|
|
76
86
|
</Button>
|
|
77
87
|
</form>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
{
|
|
82
|
-
|
|
83
|
-
|
|
88
|
+
{offersSignUp ? (
|
|
89
|
+
<p className="mt-4 text-center text-sm text-muted-foreground">
|
|
90
|
+
{t('auth.noAccount', 'No account yet?')}{' '}
|
|
91
|
+
<Link className="text-primary underline underline-offset-4" to="/auth/sign-up" search={{ redirect: search.redirect }}>
|
|
92
|
+
{t('auth.signUp', 'Sign up')}
|
|
93
|
+
</Link>
|
|
94
|
+
</p>
|
|
95
|
+
) : null}
|
|
84
96
|
</AuthFrame>
|
|
85
97
|
)
|
|
86
98
|
}
|
|
@@ -1,25 +1,31 @@
|
|
|
1
1
|
import { useState, type ComponentProps } from 'react'
|
|
2
|
-
import { Link, createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
2
|
+
import { Link, createFileRoute, redirect, useNavigate } from '@tanstack/react-router'
|
|
3
|
+
import { organizations, signUp, verifyEmail } from 'asas/organizations'
|
|
3
4
|
import { Button } from '@/components/ui/button'
|
|
4
5
|
import { authArm } from '../api.ts'
|
|
5
6
|
import { documentHead } from '../copy.ts'
|
|
6
7
|
import { AuthBox, AuthFrame, authGate, gate, notAnEmail, redirectSearch, useRefusal } from '../auth-frame.tsx'
|
|
7
|
-
import {
|
|
8
|
+
import { firstRun } from '../first-run.ts'
|
|
9
|
+
import { landing, wanted } from '../redirect.ts'
|
|
8
10
|
import { useCopy } from './__root.tsx'
|
|
9
11
|
|
|
10
12
|
export const Route = createFileRoute('/auth/sign-up')({
|
|
11
13
|
head: ({ match }) => documentHead(match.context, (t) => t('auth.signUp')),
|
|
12
14
|
validateSearch: redirectSearch,
|
|
13
|
-
|
|
15
|
+
// An app of one organization that has none yet takes no account before its owner's.
|
|
16
|
+
beforeLoad: async ({ search }: { search: { redirect?: string } }) => {
|
|
17
|
+
if ((await firstRun()).open) throw redirect({ href: '/auth/first-run' })
|
|
18
|
+
return gate({ search })
|
|
19
|
+
},
|
|
14
20
|
component: SignUp,
|
|
15
21
|
})
|
|
16
22
|
|
|
17
23
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
24
|
+
* Whether a new account proves its address before it signs in, so the sign-up answers with no session: with many
|
|
25
|
+
* organizations always, where the person then founds or joins one, and with one where an open sign-up says so. Such a
|
|
26
|
+
* sign-up asks no password: it is chosen once, on the page the mailed link opens, and the server keeps none typed here.
|
|
20
27
|
*/
|
|
21
|
-
const
|
|
22
|
-
`${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'organization'}-${Math.random().toString(36).slice(2).padEnd(6, '0').slice(0, 6)}`
|
|
28
|
+
const verifies = signUp === 'open' && verifyEmail
|
|
23
29
|
|
|
24
30
|
function SignUp() {
|
|
25
31
|
const navigate = useNavigate()
|
|
@@ -29,23 +35,24 @@ function SignUp() {
|
|
|
29
35
|
const { password: bounds } = Route.useRouteContext()
|
|
30
36
|
// The redirect is a link anyone can write; a crafted one only makes an account with no organization,
|
|
31
37
|
// which reads nothing until an organization invites it.
|
|
32
|
-
const
|
|
38
|
+
const asked = wanted({ asked: search.redirect, home: '/' })
|
|
39
|
+
const invited = asked.startsWith('/auth/accept-invitation?')
|
|
40
|
+
// The invitation's id, which a sign-up by invitation carries: holding it is what lets it through.
|
|
41
|
+
const invitationId = invited ? new URLSearchParams(asked.slice(asked.indexOf('?'))).get('id') ?? undefined : undefined
|
|
33
42
|
const [name, setName] = useState('')
|
|
34
|
-
const [organization, setOrganization] = useState('')
|
|
35
43
|
const [email, setEmail] = useState('')
|
|
36
44
|
const [password, setPassword] = useState('')
|
|
37
45
|
const [confirmation, setConfirmation] = useState('')
|
|
38
46
|
const [pending, setPending] = useState(false)
|
|
39
47
|
const [refusal, setRefusal] = useState<string | null>(null)
|
|
40
|
-
// The address this submit already signed up. A second sign-up with the same email is refused, so a
|
|
41
|
-
// retry after the organization call failed must skip it. An address and not a flag: a corrected typo signs up again.
|
|
42
|
-
const [signedUpAs, setSignedUpAs] = useState<string | null>(null)
|
|
43
48
|
const [left, setLeft] = useState<ReadonlySet<string>>(() => new Set())
|
|
49
|
+
const [sent, setSent] = useState<string | null>(null)
|
|
44
50
|
const required = t('auth.required', 'This field is required.')
|
|
45
|
-
const
|
|
51
|
+
const named = {
|
|
46
52
|
name: () => (name ? undefined : required),
|
|
47
|
-
organization: () => (invited || organization ? undefined : required),
|
|
48
53
|
email: () => (!email ? required : notAnEmail(email) ? t('auth.invalidEmail', 'Invalid email') : undefined),
|
|
54
|
+
}
|
|
55
|
+
const chosen = {
|
|
49
56
|
password: () =>
|
|
50
57
|
!password ? required
|
|
51
58
|
: password.length < bounds.min ? t('auth.passwordTooShort', 'Password too short')
|
|
@@ -53,7 +60,8 @@ function SignUp() {
|
|
|
53
60
|
: undefined,
|
|
54
61
|
confirmation: () => (!confirmation ? required : confirmation === password ? undefined : t('auth.passwordsDiffer', 'The two passwords are not the same.')),
|
|
55
62
|
}
|
|
56
|
-
const
|
|
63
|
+
const wrong: Partial<Record<'name' | 'email' | 'password' | 'confirmation', () => string | undefined>> = verifies ? named : { ...named, ...chosen }
|
|
64
|
+
const errorOf = (box: keyof typeof wrong) => (left.has(box) ? wrong[box]?.() : undefined)
|
|
57
65
|
const box = (id: keyof typeof wrong, label: string | undefined, props: ComponentProps<'input'> & { description?: string }) => (
|
|
58
66
|
<AuthBox id={id} label={label} error={errorOf(id)} onLeave={() => setLeft((already) => new Set(already).add(id))} {...props} />
|
|
59
67
|
)
|
|
@@ -64,39 +72,57 @@ function SignUp() {
|
|
|
64
72
|
if (Object.values(wrong).some((check) => check())) return
|
|
65
73
|
setPending(true)
|
|
66
74
|
setRefusal(null)
|
|
67
|
-
|
|
68
|
-
const made = await authArm('sign-up/email', { name, email, password })
|
|
69
|
-
if (!made.ok) {
|
|
70
|
-
setPending(false)
|
|
71
|
-
return setRefusal(refused(made, t('auth.signUpFailed', 'That sign-up did not work.')))
|
|
72
|
-
}
|
|
73
|
-
setSignedUpAs(email)
|
|
74
|
-
}
|
|
75
|
-
// A refusal here leaves an account that reads nothing, so the sentence names the organization;
|
|
76
|
-
// `signedUpAs` holds a retry to this call.
|
|
77
|
-
const created = invited ? undefined : await authArm('organization/create', { name: organization, slug: slugOf(organization) })
|
|
75
|
+
const made = await authArm('sign-up/email', { name, email, ...(!verifies && { password }), ...(invitationId && { invitationId }) })
|
|
78
76
|
setPending(false)
|
|
79
|
-
if (
|
|
80
|
-
|
|
77
|
+
if (!made.ok) return setRefusal(refused(made, t('auth.signUpFailed', 'That sign-up did not work.')))
|
|
78
|
+
// An account that proves its address first has no session yet, invited or not, so it is told to open its mail.
|
|
79
|
+
if (verifies) return setSent(email)
|
|
80
|
+
// The server accepted the invitation the sign-up carried, so its page would only say it is spent.
|
|
81
|
+
await navigate({ href: landing({ asked: invited ? undefined : search.redirect, screens: (await authGate()).screens }) })
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
const toSignIn = (
|
|
85
|
+
<p className="mt-4 text-center text-sm text-muted-foreground">
|
|
86
|
+
{t('auth.haveAccount', 'Already have an account?')}{' '}
|
|
87
|
+
<Link className="text-primary underline underline-offset-4" to="/auth/sign-in" search={{ redirect: search.redirect }}>
|
|
88
|
+
{t('auth.signIn', 'Sign in')}
|
|
89
|
+
</Link>
|
|
90
|
+
</p>
|
|
91
|
+
)
|
|
92
|
+
// By invitation, nobody without one is offered a form the server would refuse.
|
|
93
|
+
if (organizations === 'one' && signUp === 'invite' && !invitationId) {
|
|
94
|
+
return (
|
|
95
|
+
<AuthFrame title={t('auth.signUp', 'Sign up')} description={t('auth.inviteOnly', 'Accounts here are made by invitation. Open the link of the invitation sent to you, or sign in.')}>
|
|
96
|
+
{toSignIn}
|
|
97
|
+
</AuthFrame>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
if (sent) {
|
|
101
|
+
return (
|
|
102
|
+
<AuthFrame
|
|
103
|
+
title={t('auth.verifySent', 'Check your email')}
|
|
104
|
+
description={<span className="[overflow-wrap:anywhere]">{t('auth.verifySentDescription', 'We sent a link to {email}. Open it to confirm your address and choose your password, then sign in. It lasts an hour.')!.split(/(\{email\})/).map((part, at) => (part === '{email}' ? <bdi key={at}>{sent}</bdi> : part))}</span>}
|
|
105
|
+
>
|
|
106
|
+
{toSignIn}
|
|
107
|
+
</AuthFrame>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
83
110
|
return (
|
|
84
111
|
<AuthFrame
|
|
85
112
|
title={t('auth.signUp', 'Sign up')}
|
|
86
|
-
description={invited ? t('auth.signUpInvitedDescription', 'Make an account with the email address the invitation was sent to.') : t('auth.signUpDescription', 'Make an account
|
|
113
|
+
description={invited ? t('auth.signUpInvitedDescription', 'Make an account with the email address the invitation was sent to.') : t('auth.signUpDescription', 'Make an account.')}
|
|
87
114
|
>
|
|
88
115
|
<form className="grid gap-4" noValidate onSubmit={submit}>
|
|
89
116
|
{box('name', t('auth.name', 'Name'), { autoComplete: 'name', onChange: (event) => setName(event.target.value), type: 'text', value: name })}
|
|
90
|
-
{invited ? null : box('organization', t('auth.organization', 'Organization'), { autoComplete: 'organization', onChange: (event) => setOrganization(event.target.value), type: 'text', value: organization })}
|
|
91
117
|
{box('email', t('auth.email', 'Email'), { autoComplete: 'email', onChange: (event) => setEmail(event.target.value), type: 'email', value: email })}
|
|
92
|
-
{box('password', t('auth.password', 'Password'), {
|
|
118
|
+
{verifies ? null : box('password', t('auth.password', 'Password'), {
|
|
93
119
|
autoComplete: 'new-password',
|
|
94
120
|
description: t('auth.passwordRule', 'At least {count} characters.')!.replace('{count}', String(bounds.min)),
|
|
95
121
|
onChange: (event) => setPassword(event.target.value),
|
|
96
122
|
type: 'password',
|
|
97
123
|
value: password,
|
|
98
124
|
})}
|
|
99
|
-
{box('confirmation', t('auth.confirmPassword', 'Confirm the password'), {
|
|
125
|
+
{verifies ? null : box('confirmation', t('auth.confirmPassword', 'Confirm the password'), {
|
|
100
126
|
autoComplete: 'new-password',
|
|
101
127
|
onChange: (event) => setConfirmation(event.target.value),
|
|
102
128
|
type: 'password',
|
|
@@ -111,12 +137,7 @@ function SignUp() {
|
|
|
111
137
|
{pending ? t('auth.signUpPending', 'Signing up…') : t('auth.signUp', 'Sign up')}
|
|
112
138
|
</Button>
|
|
113
139
|
</form>
|
|
114
|
-
|
|
115
|
-
{t('auth.haveAccount', 'Already have an account?')}{' '}
|
|
116
|
-
<Link className="text-primary underline underline-offset-4" to="/auth/sign-in" search={{ redirect: search.redirect }}>
|
|
117
|
-
{t('auth.signIn', 'Sign in')}
|
|
118
|
-
</Link>
|
|
119
|
-
</p>
|
|
140
|
+
{toSignIn}
|
|
120
141
|
</AuthFrame>
|
|
121
142
|
)
|
|
122
143
|
}
|