create-mithaq 0.1.1 → 0.2.1
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 +1094 -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
|
@@ -1,35 +1,45 @@
|
|
|
1
|
-
// The dashboard's one page that belongs to no kayan: who works in the viewer's organization
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
// The dashboard's one page that belongs to no kayan: who works in the viewer's organization, the roles each holds and
|
|
2
|
+
// the invitations not yet accepted, drawn on the list table every kayan's list is drawn on, and the one place a
|
|
3
|
+
// member's roles are changed.
|
|
4
|
+
// The `loader` reads as the viewer: members from the provider's `list-members` (the active organization only, gated by
|
|
5
|
+
// `member.find`, each one's last seen only under `member.fields.lastSeenAt`), assignments from `memberRoles`, and the
|
|
6
|
+
// roles the drawer offers: the fixed ones from their code and the organization's own from `roles`, never a backoffice role.
|
|
7
|
+
// Invitations, cancellations, removals and password links go through the provider's own routes and the auth template's,
|
|
8
|
+
// which gate them by `invitation.create`, `invitation.cancel`, `member.delete` and `member.create`. Every button here
|
|
9
|
+
// only reflects those gates: the server keeps each one whatever the page draws.
|
|
10
|
+
import { useEffect, useId, useRef, useState, type ReactNode, type RefObject } from 'react'
|
|
11
|
+
import { ChevronDown, Lock, MoreHorizontal } from 'lucide-react'
|
|
12
|
+
import { holds, type AccessReport, type CellProps, type ClientField, type ClientKayan, type LoaderTools, type PageProps } from 'mithaq/admin'
|
|
7
13
|
import { useAccess, useClient, useLocale, useUser } from 'mithaq/admin/react'
|
|
8
14
|
import type { asas } from '../asas.ts'
|
|
9
15
|
import { authArm } from '@/api.ts'
|
|
10
16
|
import { refusalKey } from '@/copy.ts'
|
|
17
|
+
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
|
|
11
18
|
import { Badge } from '@/components/ui/badge'
|
|
12
|
-
import { Button } from '@/components/ui/button'
|
|
19
|
+
import { Button, buttonVariants } from '@/components/ui/button'
|
|
20
|
+
import { ButtonGroup } from '@/components/ui/button-group'
|
|
21
|
+
import { Card, CardContent } from '@/components/ui/card'
|
|
13
22
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
23
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
|
14
24
|
import { Input } from '@/components/ui/input'
|
|
15
|
-
import {
|
|
16
|
-
import { formatDate } from '
|
|
17
|
-
import { FieldShell, describedBy } from '
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
25
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
26
|
+
import { formatDate, noValue } from 'mithaq-admin/format.tsx'
|
|
27
|
+
import { FieldShell, describedBy } from 'mithaq-admin/field-shell.tsx'
|
|
28
|
+
import { ListMessage } from 'mithaq-admin/list-header.tsx'
|
|
29
|
+
import { ListTable } from 'mithaq-admin/list-table.tsx'
|
|
30
|
+
import { Search } from 'mithaq-admin/list-toolbar.tsx'
|
|
31
|
+
import { DocumentDrawer } from 'mithaq-admin/record-frame.tsx'
|
|
32
|
+
import type { ScreenPage } from 'mithaq-admin/screen-refs.ts'
|
|
33
|
+
import { toast } from 'mithaq-admin/toast.tsx'
|
|
34
|
+
import { organizations } from '@asas/mithaq/auth/organizations'
|
|
35
|
+
import { grantable, managesRoles, OWNER_ROLE } from '@asas/mithaq/roles/grants.ts'
|
|
36
|
+
import { AppShell } from '@asas/mithaq/ui/app-shell.tsx'
|
|
37
|
+
import { Grants, PermissionBadges } from '@asas/roles/roles/ui/role-form.tsx'
|
|
38
|
+
import { isoOf, readMembers, type Member, type Role } from './members-card.tsx'
|
|
21
39
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
*/
|
|
25
|
-
export type Member = { userId: string; name: string; email: string; joined: string; owner: boolean; roles: { id: string; role: string }[] }
|
|
26
|
-
/**
|
|
27
|
-
* One role the sheet offers: its label in every locale, whether this project ships it, and its keys, which a fixed role's
|
|
28
|
-
* code always says and a custom role's row says where the viewer may read them.
|
|
29
|
-
*/
|
|
30
|
-
export type Role = { name: string; label?: Readonly<Record<string, string>>; fixed: boolean; permissions?: readonly string[] }
|
|
31
|
-
export type Invite = { id: string; email: string; expiresAt: string }
|
|
32
|
-
/** `invites` is there only for a viewer who may invite, and lists what is pending. */
|
|
40
|
+
/** One invitation not yet accepted: its governance role, who sent it where they are still a member, and whether it ran out. */
|
|
41
|
+
export type Invite = { id: string; email: string; role: string; inviter?: string; expiresAt: string; expired: boolean }
|
|
42
|
+
/** `invites` is there only for a viewer who may invite, and lists what is pending, run out or not. */
|
|
33
43
|
export type MembersData = { members: Member[]; roles: Role[]; invites?: Invite[] }
|
|
34
44
|
|
|
35
45
|
/**
|
|
@@ -38,24 +48,20 @@ export type MembersData = { members: Member[]; roles: Role[]; invites?: Invite[]
|
|
|
38
48
|
*/
|
|
39
49
|
const invitesAsGovernance = (role: unknown) => typeof role === 'string' && role.split(',').some((name) => name === 'owner' || name === 'admin')
|
|
40
50
|
|
|
41
|
-
/** An
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
51
|
+
/** An invitation as the provider answers it and as the page holds it. */
|
|
52
|
+
const inviteOf = (one: { id: string; email: string; role?: string | null; expiresAt: string }, inviter?: string): Invite => ({
|
|
53
|
+
id: one.id,
|
|
54
|
+
email: one.email,
|
|
55
|
+
role: one.role || 'member',
|
|
56
|
+
...(inviter !== undefined && { inviter }),
|
|
57
|
+
expiresAt: isoOf(one.expiresAt),
|
|
58
|
+
expired: !(new Date(one.expiresAt).getTime() > Date.now()),
|
|
59
|
+
})
|
|
46
60
|
|
|
47
|
-
export const loader = async (
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return (await response.json()) as { members: { userId: string; role: string; createdAt: string; user: { name: string; email: string } }[] }
|
|
52
|
-
}),
|
|
53
|
-
client({ kayan: 'memberRoles', operation: 'find', input: { limit: maxLimit, depth: 0 } }) as Promise<{ docs: { id: string; user: string; role: string }[] }>,
|
|
54
|
-
// Asked only of a viewer the report lets read roles, so the page still
|
|
55
|
-
// draws for one who may read the assignments and not the roles.
|
|
56
|
-
holds(access, 'roles.find')
|
|
57
|
-
? (client({ kayan: 'roles', operation: 'find', input: { limit: maxLimit, depth: 0, sort: 'name' } }) as Promise<{ docs: { name: string; label?: Record<string, string> | null; permissions?: unknown }[] }>)
|
|
58
|
-
: { docs: [] },
|
|
61
|
+
export const loader = async (tools: LoaderTools): Promise<MembersData> => {
|
|
62
|
+
const { access, request } = tools
|
|
63
|
+
const [{ members, roles }, invited] = await Promise.all([
|
|
64
|
+
readMembers(tools),
|
|
59
65
|
// Asked only of a viewer who may invite: one holding the key, whose
|
|
60
66
|
// governance role the provider lets invite.
|
|
61
67
|
holds(access, 'invitation.create')
|
|
@@ -65,32 +71,19 @@ export const loader = async ({ access, client, request, maxLimit }: LoaderTools)
|
|
|
65
71
|
invites
|
|
66
72
|
? request('/api/auth/user/organization/list-invitations').then(async (response) => {
|
|
67
73
|
if (!response.ok) throw new Error(`list-invitations answered ${response.status}`)
|
|
68
|
-
return (await response.json()) as { id: string; email: string; status: string; expiresAt: string }[]
|
|
74
|
+
return (await response.json()) as { id: string; email: string; role?: string | null; status: string; expiresAt: string; inviterId?: string }[]
|
|
69
75
|
})
|
|
70
76
|
: undefined,
|
|
71
77
|
)
|
|
72
78
|
: undefined,
|
|
73
79
|
])
|
|
80
|
+
const names = new Map(members.map((member) => [member.userId, member.name]))
|
|
74
81
|
return {
|
|
75
|
-
members
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
owner: isOwner(member.role),
|
|
81
|
-
roles: assigned.docs.filter((row) => row.user === member.userId).map((row) => ({ id: String(row.id), role: row.role })),
|
|
82
|
-
})),
|
|
83
|
-
roles: [
|
|
84
|
-
...Object.entries(tenantRoles).map(([name, role]): Role => ({ name, label: role.label, fixed: true, permissions: role.permissions === '*' ? ['*'] : role.permissions })),
|
|
85
|
-
...roles.docs.map((row): Role => ({
|
|
86
|
-
name: row.name,
|
|
87
|
-
...(row.label && { label: row.label }),
|
|
88
|
-
fixed: false,
|
|
89
|
-
...(Array.isArray(row.permissions) && { permissions: row.permissions as string[] }),
|
|
90
|
-
})),
|
|
91
|
-
],
|
|
92
|
-
// Pending and not expired: the provider accepts neither of the others.
|
|
93
|
-
...(invited && { invites: invited.filter((one) => one.status === 'pending' && new Date(one.expiresAt).getTime() > Date.now()).map((one) => ({ id: one.id, email: one.email, expiresAt: isoOf(one.expiresAt) })) }),
|
|
82
|
+
members,
|
|
83
|
+
roles,
|
|
84
|
+
// Pending, run out or not: one run out is drawn greyed and sent again from its row. The provider accepts neither
|
|
85
|
+
// an accepted, a rejected nor a cancelled one, so those are gone.
|
|
86
|
+
...(invited && { invites: invited.filter((one) => one.status === 'pending').map((one) => inviteOf(one, one.inviterId === undefined ? undefined : names.get(one.inviterId))) }),
|
|
94
87
|
}
|
|
95
88
|
}
|
|
96
89
|
|
|
@@ -100,8 +93,308 @@ export const loader = async ({ access, client, request, maxLimit }: LoaderTools)
|
|
|
100
93
|
*/
|
|
101
94
|
const development = (import.meta as { env?: { DEV?: boolean } }).env?.DEV === true
|
|
102
95
|
|
|
96
|
+
/** This app's sentence for a refusal of the provider, by its code, or for one of the template's own, whose code is a copy key. */
|
|
97
|
+
const refusedKey = (answered: { status: number; body: Record<string, unknown> | null }) => {
|
|
98
|
+
const code = answered.body?.code
|
|
99
|
+
return refusalKey(answered.status, code) ?? (typeof code === 'string' && code.startsWith('error.') ? code : undefined)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* "Add person", the Members page's first action with one organization: a name, an address and a role the viewer may
|
|
104
|
+
* hand over, sent to the server's `add-person`, which makes the account, the membership and the role in one request and
|
|
105
|
+
* mails the link that chooses the password. Someone who already has a proved account is invited instead. The page draws
|
|
106
|
+
* the button that opens it.
|
|
107
|
+
*/
|
|
108
|
+
export function AddPerson({ sheet, roles, onInvited }: { sheet: RefObject<HTMLDialogElement | null>; roles: Role[]; onInvited: (invite: Invite) => void }) {
|
|
109
|
+
const { locale, t } = useLocale()
|
|
110
|
+
const nameId = useId()
|
|
111
|
+
const emailId = useId()
|
|
112
|
+
const roleId = useId()
|
|
113
|
+
const [name, setName] = useState('')
|
|
114
|
+
const [email, setEmail] = useState('')
|
|
115
|
+
const [role, setRole] = useState('')
|
|
116
|
+
const [busy, setBusy] = useState(false)
|
|
117
|
+
const [refusal, setRefusal] = useState<string>()
|
|
118
|
+
// Answered only where no mailer is configured: the administrator hands it over.
|
|
119
|
+
const [link, setLink] = useState<string>()
|
|
120
|
+
const add = async () => {
|
|
121
|
+
if (!name.trim() || !email.trim()) return setRefusal(t('auth.required', 'This field is required.'))
|
|
122
|
+
setBusy(true)
|
|
123
|
+
setRefusal(undefined)
|
|
124
|
+
const answered = await authArm('organization/add-person', { name: name.trim(), email: email.trim(), ...(role && { role }) }).finally(() => setBusy(false))
|
|
125
|
+
if (!answered.ok) {
|
|
126
|
+
const key = refusedKey(answered)
|
|
127
|
+
return setRefusal((key && t(key)) || t('dashboard.addPersonRefused', 'The server refused to add that person.'))
|
|
128
|
+
}
|
|
129
|
+
const body = answered.body as { added?: boolean; link?: string; invited?: boolean; invitation?: { id: string; email: string; role?: string; expiresAt: string } }
|
|
130
|
+
if (body.invited && body.invitation) {
|
|
131
|
+
onInvited(inviteOf(body.invitation))
|
|
132
|
+
toast.success((t('dashboard.inviteSent', 'Invitation sent to {email}') ?? 'Invitation sent to {email}').replace('{email}', () => body.invitation!.email))
|
|
133
|
+
return sheet.current?.close()
|
|
134
|
+
}
|
|
135
|
+
if (body.link) {
|
|
136
|
+
toast.success((t('dashboard.personAddedLink', '{name} added. Hand them the link below.') ?? '{name} added. Hand them the link below.').replace('{name}', () => name.trim()))
|
|
137
|
+
return setLink(body.link)
|
|
138
|
+
}
|
|
139
|
+
toast.success((t('dashboard.personAdded', '{name} added. A link to choose a password was sent to {email}.') ?? '{name} added. A link to choose a password was sent to {email}.').replace('{name}', () => name.trim()).replace('{email}', () => email.trim()))
|
|
140
|
+
sheet.current?.close()
|
|
141
|
+
// The new member and their role are read again in place, so the toast that says so stays.
|
|
142
|
+
window.dispatchEvent(new Event('mithaq:refresh'))
|
|
143
|
+
}
|
|
144
|
+
return (
|
|
145
|
+
<dialog
|
|
146
|
+
ref={sheet}
|
|
147
|
+
data-add-person-sheet=""
|
|
148
|
+
aria-label={t('dashboard.addPersonTitle', 'Add a person')}
|
|
149
|
+
className="bg-background text-foreground m-auto w-[min(28rem,calc(100%-2rem))] rounded-lg border p-0 shadow-lg backdrop:bg-black/80"
|
|
150
|
+
onClose={() => {
|
|
151
|
+
if (link) window.dispatchEvent(new Event('mithaq:refresh'))
|
|
152
|
+
setName('')
|
|
153
|
+
setEmail('')
|
|
154
|
+
setRole('')
|
|
155
|
+
setLink(undefined)
|
|
156
|
+
setRefusal(undefined)
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
<form noValidate className="grid gap-4 p-6 text-sm" onSubmit={(event) => { event.preventDefault(); void add() }}>
|
|
160
|
+
<header className="grid gap-1.5">
|
|
161
|
+
<h2 className="text-base font-medium">{t('dashboard.addPersonTitle', 'Add a person')}</h2>
|
|
162
|
+
<p className="text-muted-foreground">{t('dashboard.addPersonAbout', 'They get an account here and a link by email to choose their password. Someone who already has an account is sent an invitation instead.')}</p>
|
|
163
|
+
</header>
|
|
164
|
+
{link ? (
|
|
165
|
+
<HandedLink link={link} said={t('dashboard.addPersonLink', 'No mailer is configured. Hand this link to them alone: it chooses their password and lasts 48 hours.')} />
|
|
166
|
+
) : (
|
|
167
|
+
<>
|
|
168
|
+
<FieldShell id={nameId} label={t('dashboard.membersName', 'Name') ?? 'Name'}>
|
|
169
|
+
<Input id={nameId} value={name} maxLength={100} autoComplete="off" disabled={busy} onChange={(event) => setName(event.target.value)} />
|
|
170
|
+
</FieldShell>
|
|
171
|
+
<FieldShell id={emailId} label={t('dashboard.membersEmail', 'Email') ?? 'Email'}>
|
|
172
|
+
<Input id={emailId} type="email" dir="ltr" value={email} autoComplete="off" disabled={busy} onChange={(event) => setEmail(event.target.value)} />
|
|
173
|
+
</FieldShell>
|
|
174
|
+
<FieldShell id={roleId} label={t('dashboard.addPersonRole', 'Role') ?? 'Role'}>
|
|
175
|
+
<select id={roleId} value={role} disabled={busy} className="border-input bg-background h-9 rounded-md border px-3 max-sm:h-11" onChange={(event) => setRole(event.target.value)}>
|
|
176
|
+
<option value="">{t('dashboard.addPersonNoRole', 'No role yet')}</option>
|
|
177
|
+
{roles.map((one) => <option key={one.name} value={one.name}>{one.label?.[locale] ?? one.name}</option>)}
|
|
178
|
+
</select>
|
|
179
|
+
</FieldShell>
|
|
180
|
+
</>
|
|
181
|
+
)}
|
|
182
|
+
{refusal ? <p role="alert" className="text-destructive">{refusal}</p> : null}
|
|
183
|
+
<footer className="flex justify-end gap-2">
|
|
184
|
+
<Button type="button" variant="outline" className="max-sm:h-11" onClick={() => sheet.current?.close()}>{link ? t('admin.close', 'Close') : t('admin.cancel', 'Cancel')}</Button>
|
|
185
|
+
{link ? null : <Button type="submit" className="max-sm:h-11" disabled={busy}>{t('dashboard.addPersonSubmit', 'Add')}</Button>}
|
|
186
|
+
</footer>
|
|
187
|
+
</form>
|
|
188
|
+
</dialog>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A link answered where no mailer is configured, for the administrator to hand over, in a box that selects itself. */
|
|
193
|
+
function HandedLink({ link, said }: { link: string; said: ReactNode }) {
|
|
194
|
+
const { t } = useLocale()
|
|
195
|
+
return (
|
|
196
|
+
<div data-handed-link="" className="grid gap-2">
|
|
197
|
+
<p>{said}</p>
|
|
198
|
+
<Input readOnly dir="ltr" value={link} aria-label={t('dashboard.link', 'Link')} onFocus={(event) => event.currentTarget.select()} />
|
|
199
|
+
<Button type="button" variant="outline" className="max-sm:h-11" onClick={() => void navigator.clipboard.writeText(link).then(() => toast.success(t('dashboard.inviteCopied', 'Link copied') ?? 'Link copied'))}>
|
|
200
|
+
{t('dashboard.inviteCopy', 'Copy link')}
|
|
201
|
+
</Button>
|
|
202
|
+
</div>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
103
206
|
const sameRoles = (a: ReadonlySet<string>, b: ReadonlySet<string>) => a.size === b.size && [...a].every((name) => b.has(name))
|
|
104
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Whether the viewer may tick or untick `role` for `member`, and why not. Unticking removes an assignment and ticking adds
|
|
210
|
+
* one, so each needs its own operation, and either needs a role the viewer may hand over. The server's guards decide
|
|
211
|
+
* every write whatever this answers.
|
|
212
|
+
*/
|
|
213
|
+
const lockOf = ({ access, me, member, role }: { access: AccessReport; me: string | undefined; member: Member; role: Role }) => {
|
|
214
|
+
const assignments = access.collections?.memberRoles
|
|
215
|
+
const assigned = member.roles.some((row) => row.role === role.name)
|
|
216
|
+
// The role that lets the viewer manage roles is not taken from themselves, which the server refuses too.
|
|
217
|
+
const own = assigned && member.email === me && role.permissions !== undefined && managesRoles(role.permissions)
|
|
218
|
+
// The owner holds administrator whatever the rows say, so the box is ticked and locked, and the server refuses
|
|
219
|
+
// removing the row anyway.
|
|
220
|
+
const owned = role.name === OWNER_ROLE && member.owner
|
|
221
|
+
const locked = own || owned || !grantable(access, role.permissions) || (assigned ? assignments?.delete !== true : assignments?.create !== true)
|
|
222
|
+
return { own, owned, locked }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Edit roles: Payload's document drawer over the page, listing every role with what it grants, grouped by screen and
|
|
227
|
+
* kayan and closed behind its count as the Roles page draws it, each with a box that gives or takes it. A box the viewer may not change is disabled, with the reason where
|
|
228
|
+
* there is one. Saving writes `memberRoles` rows through that kayan's own operations, so its guards judge each one:
|
|
229
|
+
* removals first, then additions, stopping at the first refusal, and whatever landed before it stays shown.
|
|
230
|
+
*/
|
|
231
|
+
function RolesDrawer({ member, roles, onClose, onHeld }: { member: Member; roles: Role[]; onClose: () => void; onHeld: (held: Member['roles']) => void }) {
|
|
232
|
+
const { locale, t } = useLocale()
|
|
233
|
+
const client = useClient<typeof asas.router>()
|
|
234
|
+
const access = useAccess()
|
|
235
|
+
const me = useUser().email
|
|
236
|
+
const [chosen, setChosen] = useState<ReadonlySet<string>>(new Set(member.roles.map((one) => one.role)))
|
|
237
|
+
const [busy, setBusy] = useState(false)
|
|
238
|
+
const [refusal, setRefusal] = useState<string>()
|
|
239
|
+
const changed = !sameRoles(chosen, new Set(member.roles.map((one) => one.role)))
|
|
240
|
+
const save = async () => {
|
|
241
|
+
setBusy(true)
|
|
242
|
+
setRefusal(undefined)
|
|
243
|
+
let held = member.roles
|
|
244
|
+
try {
|
|
245
|
+
for (const one of held.filter((row) => roles.some((role) => role.name === row.role) && !chosen.has(row.role))) {
|
|
246
|
+
await client({ kayan: 'memberRoles', operation: 'delete', input: { id: one.id } })
|
|
247
|
+
held = held.filter((row) => row !== one)
|
|
248
|
+
}
|
|
249
|
+
for (const role of [...chosen].filter((name) => !held.some((row) => row.role === name))) {
|
|
250
|
+
const row = await client({ kayan: 'memberRoles', operation: 'create', input: { user: member.userId, role } })
|
|
251
|
+
held = [...held, { id: String(row.id), role }]
|
|
252
|
+
}
|
|
253
|
+
toast.success((t('dashboard.rolesSaved', 'Roles saved for {name}') ?? 'Roles saved for {name}').replace('{name}', () => member.name))
|
|
254
|
+
onHeld(held)
|
|
255
|
+
onClose()
|
|
256
|
+
} catch (thrown) {
|
|
257
|
+
const key = (thrown as { data?: { key?: unknown } }).data?.key
|
|
258
|
+
setRefusal((typeof key === 'string' && t(key)) || t('dashboard.membersRefused', 'The server refused that change.'))
|
|
259
|
+
onHeld(held)
|
|
260
|
+
} finally {
|
|
261
|
+
setBusy(false)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const choice = (role: Role) => {
|
|
265
|
+
const { own, owned, locked } = lockOf({ access, me, member, role })
|
|
266
|
+
const disabled = busy || locked
|
|
267
|
+
return (
|
|
268
|
+
// Only the box and the name are dimmed when the box is locked: the sentence saying why and what the role grants
|
|
269
|
+
// stay at their own contrast.
|
|
270
|
+
<div key={role.name} data-role={role.name} data-disabled={disabled} className="grid gap-2 rounded-md border p-3">
|
|
271
|
+
<label className="flex items-start gap-3">
|
|
272
|
+
<Checkbox
|
|
273
|
+
className="mt-0.5"
|
|
274
|
+
checked={owned || chosen.has(role.name)}
|
|
275
|
+
disabled={disabled}
|
|
276
|
+
onCheckedChange={(on) => setChosen((now) => (on ? new Set([...now, role.name]) : new Set([...now].filter((name) => name !== role.name))))}
|
|
277
|
+
/>
|
|
278
|
+
<span className="grid gap-0.5">
|
|
279
|
+
<span className={`flex items-center gap-1.5 font-medium${disabled ? ' opacity-50' : ''}`}>
|
|
280
|
+
{role.fixed ? <Lock className="text-muted-foreground size-3.5" aria-hidden="true" /> : null}
|
|
281
|
+
{role.label?.[locale] ?? role.name}
|
|
282
|
+
</span>
|
|
283
|
+
{owned ? (
|
|
284
|
+
<span data-owner="" className="text-muted-foreground text-xs">{t('dashboard.membersOwnerRole', 'The owner of the organization always holds this role.')}</span>
|
|
285
|
+
) : own ? (
|
|
286
|
+
<span data-own="" className="text-muted-foreground text-xs">{t('dashboard.membersOwnRole', 'This role lets you manage roles, so only another member who may manage roles takes it from you.')}</span>
|
|
287
|
+
) : grantable(access, role.permissions) ? null : (
|
|
288
|
+
<span data-locked="" className="text-muted-foreground text-xs">{t('dashboard.membersNotGrantable', 'It grants keys you do not hold, so only someone who holds them gives or takes it.')}</span>
|
|
289
|
+
)}
|
|
290
|
+
</span>
|
|
291
|
+
</label>
|
|
292
|
+
{role.permissions ? <div className="ps-7"><Grants named={role.label?.[locale] ?? role.name} held={role.permissions}><PermissionBadges held={role.permissions} /></Grants></div> : null}
|
|
293
|
+
</div>
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
const group = (fixed: boolean, heading: string | undefined) => {
|
|
297
|
+
const listed = roles.filter((role) => role.fixed === fixed)
|
|
298
|
+
return listed.length ? (
|
|
299
|
+
<section className="grid gap-2">
|
|
300
|
+
<h3 className="text-muted-foreground text-xs font-medium">{heading}</h3>
|
|
301
|
+
{listed.map(choice)}
|
|
302
|
+
</section>
|
|
303
|
+
) : null
|
|
304
|
+
}
|
|
305
|
+
return (
|
|
306
|
+
<DocumentDrawer title={`${t('dashboard.membersEdit', 'Edit roles')}: ${member.name}`} edit={member.userId} closeLabel={t('admin.close', 'Close') ?? 'Close'} onClose={onClose}>
|
|
307
|
+
<form
|
|
308
|
+
data-member-sheet={member.userId}
|
|
309
|
+
className="mx-auto grid w-full max-w-3xl gap-4 text-sm"
|
|
310
|
+
onSubmit={(event) => {
|
|
311
|
+
event.preventDefault()
|
|
312
|
+
void save()
|
|
313
|
+
}}
|
|
314
|
+
>
|
|
315
|
+
<p className="text-muted-foreground"><bdi>{member.name}</bdi> · <bdi dir="ltr">{member.email}</bdi></p>
|
|
316
|
+
{roles.length === 0 ? <p className="text-muted-foreground">{t('dashboard.membersNoRoles', 'There is no role you may read to assign.')}</p> : null}
|
|
317
|
+
{group(true, t('dashboard.membersFixed', 'Fixed roles'))}
|
|
318
|
+
{group(false, t('dashboard.membersCustom', 'Custom roles'))}
|
|
319
|
+
{refusal ? <p role="alert" className="text-destructive">{refusal}</p> : null}
|
|
320
|
+
<footer className="bg-background sticky bottom-0 flex justify-end gap-2 border-t py-3">
|
|
321
|
+
<Button type="button" variant="outline" className="max-sm:h-11" onClick={onClose}>{t('admin.cancel', 'Cancel')}</Button>
|
|
322
|
+
<Button type="submit" className="max-sm:h-11" disabled={busy || !changed}>{t('admin.save', 'Save')}</Button>
|
|
323
|
+
</footer>
|
|
324
|
+
</form>
|
|
325
|
+
</DocumentDrawer>
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** A member's name with their address under it, which the first column of the list draws. */
|
|
330
|
+
function PersonCell({ row }: CellProps) {
|
|
331
|
+
return (
|
|
332
|
+
<span className="grid">
|
|
333
|
+
<span className="font-medium">{String(row.name ?? '')}</span>
|
|
334
|
+
<bdi dir="ltr" className="text-muted-foreground text-xs">{String(row.email ?? '')}</bdi>
|
|
335
|
+
</span>
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** A member's roles, each by its label, as badges. */
|
|
340
|
+
function RolesCell({ value }: CellProps) {
|
|
341
|
+
const labels = Array.isArray(value) ? (value as string[]) : []
|
|
342
|
+
return labels.length ? <div className="flex flex-wrap gap-1">{labels.map((label) => <Badge key={label} variant="secondary">{label}</Badge>)}</div> : noValue
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** A day, in UTC so the server's render and the browser's agree on it, and a dash where there is none. */
|
|
346
|
+
function DayCell({ value }: CellProps) {
|
|
347
|
+
const { locale } = useLocale()
|
|
348
|
+
return (typeof value === 'string' && value && formatDate({ value, locale, day: true })) || noValue
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** One column of an invitation, greyed once it has run out. */
|
|
352
|
+
function InviteCell({ value, row }: CellProps) {
|
|
353
|
+
const text = typeof value === 'string' && value ? value : undefined
|
|
354
|
+
return <span className={row.expired ? 'text-muted-foreground' : undefined}>{text ?? noValue}</span>
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** When an invitation runs out, or that it has, greyed. */
|
|
358
|
+
function ExpiresCell({ value, row }: CellProps) {
|
|
359
|
+
const { locale, t } = useLocale()
|
|
360
|
+
const day = typeof value === 'string' && value ? formatDate({ value, locale, day: true }) : undefined
|
|
361
|
+
return row.expired ? (
|
|
362
|
+
<span data-expired="" className="text-muted-foreground">{t('dashboard.inviteExpired', 'Expired')}{day ? ` · ${day}` : ''}</span>
|
|
363
|
+
) : (day ?? noValue)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** A column of one of the page's tables, drawn by one of the cells above or a shipped one, named in the viewer's locale. */
|
|
367
|
+
const columnOf = ({ name, label, cell, locale }: { name: string; label: string | undefined; cell: string; locale: string }): ClientField => ({
|
|
368
|
+
name,
|
|
369
|
+
kind: 'text',
|
|
370
|
+
label: { [locale]: label ?? name },
|
|
371
|
+
rules: {},
|
|
372
|
+
component: 'text',
|
|
373
|
+
cell,
|
|
374
|
+
sortable: false,
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
/** The page's tables as the list table takes them: the cells by name, under a kayan of their own that no server knows. */
|
|
378
|
+
const tableOf = (name: string): ScreenPage => ({
|
|
379
|
+
refs: { person: PersonCell, roles: RolesCell, day: DayCell, invite: InviteCell, expires: ExpiresCell } as ScreenPage['refs'],
|
|
380
|
+
kayan: { name, single: false, fields: [], operations: [], pages: {} } as ClientKayan,
|
|
381
|
+
page: { path: '', page: { shell: '' } },
|
|
382
|
+
})
|
|
383
|
+
const memberTable = tableOf('members')
|
|
384
|
+
const inviteTable = tableOf('invitations')
|
|
385
|
+
|
|
386
|
+
/** A row's menu, as every list draws one, named for the row. */
|
|
387
|
+
function RowActions({ id, label, children }: { id: string; label: string; children: ReactNode }) {
|
|
388
|
+
return (
|
|
389
|
+
<DropdownMenu>
|
|
390
|
+
<DropdownMenuTrigger data-row-menu={id} aria-label={label} className={buttonVariants({ variant: 'ghost', size: 'icon-sm', className: 'max-sm:size-11' })}>
|
|
391
|
+
<MoreHorizontal className="size-4" aria-hidden="true" />
|
|
392
|
+
</DropdownMenuTrigger>
|
|
393
|
+
<DropdownMenuContent align="end" className="w-56">{children}</DropdownMenuContent>
|
|
394
|
+
</DropdownMenu>
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
|
|
105
398
|
export function Members({ loaderData }: PageProps<MembersData>) {
|
|
106
399
|
const { locale, t } = useLocale()
|
|
107
400
|
const client = useClient<typeof asas.router>()
|
|
@@ -110,27 +403,34 @@ export function Members({ loaderData }: PageProps<MembersData>) {
|
|
|
110
403
|
const assignments = access.collections?.memberRoles
|
|
111
404
|
const roles = loaderData?.roles ?? []
|
|
112
405
|
const [members, setMembers] = useState(loaderData?.members ?? [])
|
|
406
|
+
const [pending, setPending] = useState(loaderData?.invites ?? [])
|
|
407
|
+
// A page read again in place, after a person is added, hands the new lists.
|
|
408
|
+
useEffect(() => setMembers(loaderData?.members ?? []), [loaderData?.members])
|
|
409
|
+
useEffect(() => setPending(loaderData?.invites ?? []), [loaderData?.invites])
|
|
410
|
+
const [tab, setTab] = useState('members')
|
|
411
|
+
const [search, setSearch] = useState<string>()
|
|
412
|
+
const [roleShown, setRoleShown] = useState('')
|
|
113
413
|
const [editing, setEditing] = useState<Member>()
|
|
114
|
-
const [
|
|
414
|
+
const [removing, setRemoving] = useState<Member>()
|
|
115
415
|
const [busy, setBusy] = useState(false)
|
|
116
|
-
const [
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
416
|
+
const [handed, setHanded] = useState<{ name: string; link: string }>()
|
|
417
|
+
const handedSheet = useRef<HTMLDialogElement>(null)
|
|
418
|
+
const addSheet = useRef<HTMLDialogElement>(null)
|
|
419
|
+
const table = useRef<HTMLTableElement>(null)
|
|
420
|
+
const inviteTableRef = useRef<HTMLTableElement>(null)
|
|
421
|
+
const openHint = useId()
|
|
422
|
+
const roleFilter = useId()
|
|
423
|
+
// A member's roles are changed where the report lets this actor add or remove an assignment; the guard on each
|
|
424
|
+
// write still decides every role.
|
|
123
425
|
const edits = assignments?.create === true || assignments?.delete === true
|
|
124
|
-
// A member is removed, and an invitation cancelled, by whoever holds the
|
|
125
|
-
//
|
|
126
|
-
// governance role still decides, and its refusal is worded below.
|
|
426
|
+
// A member is removed, and an invitation cancelled, by whoever holds the key the auth template gates the provider's
|
|
427
|
+
// route by; the provider's own governance role still decides, and its refusal is worded below.
|
|
127
428
|
const removes = holds(access, 'member.delete')
|
|
128
429
|
const cancels = holds(access, 'invitation.cancel')
|
|
129
|
-
const opens = edits || removes
|
|
130
430
|
const invites = loaderData?.invites !== undefined
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
const
|
|
431
|
+
// With one organization an administrator adds people directly, and sends one a new password link the same way.
|
|
432
|
+
const adds = organizations === 'one' && holds(access, 'member.create')
|
|
433
|
+
const seesLastSeen = holds(access, 'member.fields.lastSeenAt')
|
|
134
434
|
const [inviting, setInviting] = useState(false)
|
|
135
435
|
const [email, setEmail] = useState('')
|
|
136
436
|
/** Whether the email box has been left or submitted, after which it says what is wrong with it. */
|
|
@@ -140,13 +440,9 @@ export function Members({ loaderData }: PageProps<MembersData>) {
|
|
|
140
440
|
const emailBox = useRef<HTMLInputElement>(null)
|
|
141
441
|
const inviteTitle = useId()
|
|
142
442
|
const emailId = useId()
|
|
143
|
-
/**
|
|
144
|
-
* This app's sentence for a refusal of the provider, by its code, or for one of the template's own, whose code is a copy key, or `fallback`.
|
|
145
|
-
*/
|
|
146
443
|
const refused = (answered: { status: number; body: Record<string, unknown> | null }, fallback: string | undefined) => {
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
return (key && t(key)) || fallback
|
|
444
|
+
const key = refusedKey(answered)
|
|
445
|
+
return (key && t(key)) || fallback || ''
|
|
150
446
|
}
|
|
151
447
|
/**
|
|
152
448
|
* What is wrong with the address typed, said once the box is left and on
|
|
@@ -160,6 +456,8 @@ export function Members({ loaderData }: PageProps<MembersData>) {
|
|
|
160
456
|
: emailBox.current?.validity.typeMismatch
|
|
161
457
|
? t('auth.invalidEmail', 'Invalid email')
|
|
162
458
|
: undefined
|
|
459
|
+
/** An invitation listed, or listed again in its place once sent again. */
|
|
460
|
+
const listInvite = (made: Invite) => setPending((all) => [...all.filter((one) => one.id !== made.id), made])
|
|
163
461
|
/**
|
|
164
462
|
* One invitation, as a member: the role the provider gives on accepting, so
|
|
165
463
|
* the roles of this page are given after, here, like any member's.
|
|
@@ -172,87 +470,71 @@ export function Members({ loaderData }: PageProps<MembersData>) {
|
|
|
172
470
|
const answered = await authArm('organization/invite-member', { email: email.trim(), role: 'member' }).finally(() => setInviting(false))
|
|
173
471
|
// The provider's sentence is English alone, so the refusal is said in the viewer's locale, by its code.
|
|
174
472
|
if (!answered.ok) return setInviteRefusal(refused(answered, t('dashboard.inviteRefused', 'The server refused that invitation.')))
|
|
175
|
-
const made = answered.body as { id: string; email: string; expiresAt: string }
|
|
176
|
-
|
|
473
|
+
const made = answered.body as { id: string; email: string; role?: string; expiresAt: string }
|
|
474
|
+
listInvite(inviteOf(made, members.find((one) => one.email === me)?.name))
|
|
475
|
+
toast.success((t('dashboard.inviteSent', 'Invitation sent to {email}') ?? 'Invitation sent to {email}').replace('{email}', () => made.email))
|
|
177
476
|
inviteSheet.current?.close()
|
|
477
|
+
// Where its link is copied from.
|
|
478
|
+
setTab('invitations')
|
|
479
|
+
}
|
|
480
|
+
/** One pending invitation mailed again with a new expiry, through the provider's own route. */
|
|
481
|
+
const resend = async (one: Invite) => {
|
|
482
|
+
const answered = await authArm('organization/invite-member', { email: one.email, role: one.role, resend: true })
|
|
483
|
+
if (!answered.ok) return void toast.error(refused(answered, t('dashboard.inviteRefused', 'The server refused that invitation.')))
|
|
484
|
+
// One run out may come back as a new invitation, which takes its place.
|
|
485
|
+
const made = inviteOf(answered.body as { id: string; email: string; role?: string; expiresAt: string }, one.inviter)
|
|
486
|
+
setPending((all) => [...all.filter((other) => other.id !== one.id && other.id !== made.id), made])
|
|
487
|
+
toast.success((t('dashboard.inviteResent', 'Invitation sent again to {email}') ?? 'Invitation sent again to {email}').replace('{email}', () => one.email))
|
|
178
488
|
}
|
|
179
489
|
/** One pending invitation withdrawn, through the provider's own route. */
|
|
180
490
|
const cancel = async (one: Invite) => {
|
|
181
|
-
setPendingRefusal(undefined)
|
|
182
491
|
const answered = await authArm('organization/cancel-invitation', { invitationId: one.id })
|
|
183
|
-
if (!answered.ok) return
|
|
492
|
+
if (!answered.ok) return void toast.error(refused(answered, t('dashboard.inviteCancelRefused', 'The server refused to cancel that invitation.')))
|
|
184
493
|
setPending((all) => all.filter((other) => other.id !== one.id))
|
|
494
|
+
toast.success(t('dashboard.inviteCancelled', 'Invitation cancelled') ?? 'Invitation cancelled')
|
|
185
495
|
}
|
|
186
|
-
/** The link the invitee opens,
|
|
496
|
+
/** The link the invitee opens, for the inviter to hand over where the mail does not reach, in every environment. */
|
|
187
497
|
const copyLink = async (one: Invite) => {
|
|
188
498
|
await navigator.clipboard.writeText(new URL(`/auth/accept-invitation?id=${encodeURIComponent(one.id)}`, window.location.origin).href)
|
|
189
|
-
|
|
499
|
+
toast.success(t('dashboard.inviteCopied', 'Link copied') ?? 'Link copied')
|
|
190
500
|
}
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
501
|
+
/** A new link to choose a password, mailed to the member, or answered to hand over where no mailer is configured. */
|
|
502
|
+
const sendLink = async (member: Member) => {
|
|
503
|
+
const answered = await authArm('organization/send-password-link', { userId: member.userId })
|
|
504
|
+
if (!answered.ok) return void toast.error(refused(answered, t('dashboard.passwordLinkRefused', 'The server refused to send that link.')))
|
|
505
|
+
const link = (answered.body as { link?: unknown } | null)?.link
|
|
506
|
+
if (typeof link === 'string') {
|
|
507
|
+
setHanded({ name: member.name, link })
|
|
508
|
+
return handedSheet.current?.showModal()
|
|
509
|
+
}
|
|
510
|
+
toast.success((t('dashboard.passwordLinkSent', 'A link to choose a password was sent to {email}') ?? 'A link to choose a password was sent to {email}').replace('{email}', () => member.email))
|
|
198
511
|
}
|
|
199
|
-
// A link to one member's roles, `?member=<id>`, opens the
|
|
512
|
+
// A link to one member's roles, `?member=<id>`, opens the drawer on them, as a role's delete links each member who still holds it.
|
|
200
513
|
useEffect(() => {
|
|
201
514
|
const asked = new URLSearchParams(window.location.search).get('member')
|
|
202
515
|
const member = members.find((one) => one.userId === asked)
|
|
203
|
-
if (member &&
|
|
516
|
+
if (member && edits) setEditing(member)
|
|
204
517
|
}, [])
|
|
205
518
|
const labelOf = (name: string) => roles.find((role) => role.name === name)?.label?.[locale] ?? name
|
|
206
|
-
|
|
207
|
-
|
|
519
|
+
/** What a removal or a save leaves the member holding, drawn in the table at once. */
|
|
520
|
+
const hold = (member: Member, held: Member['roles']) => setMembers((all) => all.map((one) => (one.userId === member.userId ? { ...one, roles: held } : one)))
|
|
208
521
|
/**
|
|
209
|
-
*
|
|
210
|
-
*
|
|
522
|
+
* Whether the viewer may remove the member: one who may remove members, never themselves, and only where every role
|
|
523
|
+
* the member holds is one they may take, the rule the server's guards keep on each of those deletes.
|
|
211
524
|
*/
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
try {
|
|
218
|
-
for (const one of held.filter((row) => roles.some((role) => role.name === row.role) && !chosen.has(row.role))) {
|
|
219
|
-
await client({ kayan: 'memberRoles', operation: 'delete', input: { id: one.id } })
|
|
220
|
-
held = held.filter((row) => row !== one)
|
|
221
|
-
}
|
|
222
|
-
for (const role of [...chosen].filter((name) => !held.some((row) => row.role === name))) {
|
|
223
|
-
const row = await client({ kayan: 'memberRoles', operation: 'create', input: { user: editing.userId, role } })
|
|
224
|
-
held = [...held, { id: String(row.id), role }]
|
|
225
|
-
}
|
|
226
|
-
sheet.current?.close()
|
|
227
|
-
} catch (thrown) {
|
|
228
|
-
const key = (thrown as { data?: { key?: unknown } }).data?.key
|
|
229
|
-
setRefusal((typeof key === 'string' && t(key)) || t('dashboard.membersRefused', 'The server refused that change.'))
|
|
230
|
-
} finally {
|
|
231
|
-
setMembers((all) => all.map((one) => (one.userId === editing.userId ? { ...one, roles: held } : one)))
|
|
232
|
-
setEditing((one) => one && { ...one, roles: held })
|
|
233
|
-
setBusy(false)
|
|
234
|
-
}
|
|
235
|
-
}
|
|
525
|
+
const removable = (member: Member) =>
|
|
526
|
+
removes && member.email !== me && member.roles.every((row) => {
|
|
527
|
+
const role = roles.find((one) => one.name === row.role)
|
|
528
|
+
return role !== undefined && !lockOf({ access, me, member, role }).locked
|
|
529
|
+
})
|
|
236
530
|
/**
|
|
237
|
-
* Takes the member out: each role they hold first, through the assignment kayan's own delete and so under its guards,
|
|
238
|
-
*
|
|
531
|
+
* Takes the member out: each role they hold first, through the assignment kayan's own delete and so under its guards,
|
|
532
|
+
* then the membership through the provider's route, which refuses a member still holding a role. A refused role
|
|
533
|
+
* stops it, and the table shows what was taken before it.
|
|
239
534
|
*/
|
|
240
|
-
const remove = async () => {
|
|
241
|
-
if (!editing) return
|
|
242
|
-
/**
|
|
243
|
-
* What a removal that stopped leaves the member holding, drawn in the
|
|
244
|
-
* table, the sheet and its boxes alike, so the boxes tick only what is
|
|
245
|
-
* still held and Save writes nothing taken back.
|
|
246
|
-
*/
|
|
247
|
-
const kept = (held: Member['roles']) => {
|
|
248
|
-
setMembers((all) => all.map((one) => (one.userId === editing.userId ? { ...one, roles: held } : one)))
|
|
249
|
-
setEditing((one) => one && { ...one, roles: held })
|
|
250
|
-
setChosen(new Set(held.map((row) => row.role)))
|
|
251
|
-
}
|
|
535
|
+
const remove = async (member: Member) => {
|
|
252
536
|
setBusy(true)
|
|
253
|
-
|
|
254
|
-
setConfirming(false)
|
|
255
|
-
let held = editing.roles
|
|
537
|
+
let held = member.roles
|
|
256
538
|
try {
|
|
257
539
|
for (const one of held) {
|
|
258
540
|
await client({ kayan: 'memberRoles', operation: 'delete', input: { id: one.id } })
|
|
@@ -260,241 +542,194 @@ export function Members({ loaderData }: PageProps<MembersData>) {
|
|
|
260
542
|
}
|
|
261
543
|
} catch (thrown) {
|
|
262
544
|
const key = (thrown as { data?: { key?: unknown } }).data?.key
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
return
|
|
545
|
+
hold(member, held)
|
|
546
|
+
setBusy(false)
|
|
547
|
+
return void toast.error((typeof key === 'string' && t(key)) || t('dashboard.removeRefused', 'The server refused to remove that member.') || '')
|
|
266
548
|
}
|
|
267
|
-
const answered = await authArm('organization/remove-member', { memberIdOrEmail:
|
|
549
|
+
const answered = await authArm('organization/remove-member', { memberIdOrEmail: member.email }).finally(() => setBusy(false))
|
|
268
550
|
if (!answered.ok) {
|
|
269
|
-
|
|
270
|
-
return
|
|
551
|
+
hold(member, held)
|
|
552
|
+
return void toast.error(refused(answered, t('dashboard.removeRefused', 'The server refused to remove that member.')))
|
|
271
553
|
}
|
|
272
|
-
setMembers((all) => all.filter((one) => one.userId !==
|
|
273
|
-
|
|
554
|
+
setMembers((all) => all.filter((one) => one.userId !== member.userId))
|
|
555
|
+
toast.success((t('dashboard.memberRemoved', '{name} removed') ?? '{name} removed').replace('{name}', () => member.name))
|
|
274
556
|
}
|
|
275
557
|
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
const disabled = busy || locked
|
|
301
|
-
const keys = role.permissions
|
|
302
|
-
return (
|
|
303
|
-
// Only the box and the name are dimmed when the box is locked: the
|
|
304
|
-
// sentence saying why stays at the muted text's own contrast.
|
|
305
|
-
<label key={role.name} data-role={role.name} data-disabled={disabled} className="hover:bg-muted/70 flex items-start gap-3 rounded-md px-2 py-2">
|
|
306
|
-
<Checkbox
|
|
307
|
-
className="mt-0.5"
|
|
308
|
-
checked={owned || chosen.has(role.name)}
|
|
309
|
-
disabled={disabled}
|
|
310
|
-
onCheckedChange={(on) => setChosen((now) => (on ? new Set([...now, role.name]) : new Set([...now].filter((name) => name !== role.name))))}
|
|
311
|
-
/>
|
|
312
|
-
<span className="grid gap-0.5">
|
|
313
|
-
<span className={`flex items-center gap-1.5 font-medium${disabled ? ' opacity-50' : ''}`}>
|
|
314
|
-
{role.fixed ? <Lock className="text-muted-foreground size-3.5" aria-hidden="true" /> : null}
|
|
315
|
-
{role.label?.[locale] ?? role.name}
|
|
316
|
-
</span>
|
|
317
|
-
{owned ? (
|
|
318
|
-
<span data-owner="" className="text-muted-foreground text-xs">{t('dashboard.membersOwnerRole', 'The owner of the organization always holds this role.')}</span>
|
|
319
|
-
) : own ? (
|
|
320
|
-
<span data-own="" className="text-muted-foreground text-xs">{t('dashboard.membersOwnRole', 'This role lets you manage roles, so only another member who may manage roles takes it from you.')}</span>
|
|
321
|
-
) : null}
|
|
322
|
-
{keys ? (
|
|
323
|
-
<span className="text-muted-foreground text-xs">
|
|
324
|
-
{keys.includes('*')
|
|
325
|
-
? t('dashboard.everyPermission', 'Every permission')
|
|
326
|
-
: (t('dashboard.membersPermissions', '{count} permissions') ?? '').replace('{count}', String(keys.length))}
|
|
327
|
-
</span>
|
|
328
|
-
) : null}
|
|
329
|
-
</span>
|
|
330
|
-
</label>
|
|
331
|
-
)
|
|
332
|
-
}
|
|
333
|
-
const group = (fixed: boolean, heading: string | undefined) => {
|
|
334
|
-
const listed = roles.filter((role) => role.fixed === fixed)
|
|
335
|
-
return listed.length ? (
|
|
336
|
-
<section className="grid gap-1">
|
|
337
|
-
<h3 className="text-muted-foreground px-2 text-xs font-medium">{heading}</h3>
|
|
338
|
-
{listed.map(choice)}
|
|
339
|
-
</section>
|
|
340
|
-
) : null
|
|
341
|
-
}
|
|
342
|
-
// Development has no mailer, so the dialog and the pending list say how an
|
|
343
|
-
// invitation reaches anyone there.
|
|
344
|
-
const noMailer = development ? (
|
|
345
|
-
<p data-no-mailer="" className="text-muted-foreground rounded-md border px-3 py-2">
|
|
558
|
+
const term = search?.toLocaleLowerCase(locale)
|
|
559
|
+
const shown = members.filter(
|
|
560
|
+
(member) =>
|
|
561
|
+
(!roleShown || member.roles.some((one) => one.role === roleShown) || (roleShown === OWNER_ROLE && member.owner)) &&
|
|
562
|
+
(!term || `${member.name}\n${member.email}`.toLocaleLowerCase(locale).includes(term)),
|
|
563
|
+
)
|
|
564
|
+
const rowActionsLabel = (name: string) => (t('admin.rowActionsFor', 'Actions for {name}') ?? 'Actions for {name}').replace('{name}', () => `${name}`)
|
|
565
|
+
const memberColumns = [
|
|
566
|
+
columnOf({ name: 'person', label: t('dashboard.membersName', 'Name'), cell: 'person', locale }),
|
|
567
|
+
columnOf({ name: 'roles', label: t('dashboard.membersRoles', 'Roles'), cell: 'roles', locale }),
|
|
568
|
+
columnOf({ name: 'joined', label: t('dashboard.membersJoined', 'Joined'), cell: 'day', locale }),
|
|
569
|
+
...(seesLastSeen ? [columnOf({ name: 'lastSeen', label: t('member.lastSeenAt', 'Last seen'), cell: 'day', locale })] : []),
|
|
570
|
+
]
|
|
571
|
+
const inviteColumns = [
|
|
572
|
+
columnOf({ name: 'email', label: t('dashboard.membersEmail', 'Email'), cell: 'invite', locale }),
|
|
573
|
+
columnOf({ name: 'role', label: t('dashboard.addPersonRole', 'Role'), cell: 'invite', locale }),
|
|
574
|
+
columnOf({ name: 'inviter', label: t('dashboard.inviteSentBy', 'Sent by'), cell: 'invite', locale }),
|
|
575
|
+
columnOf({ name: 'expiresAt', label: t('dashboard.inviteExpires', 'Expires'), cell: 'expires', locale }),
|
|
576
|
+
]
|
|
577
|
+
const cellsOf = (columns: ClientField[]) => new Map(columns.map((column) => [column.name, column.cell]))
|
|
578
|
+
const filtered = Boolean(term || roleShown)
|
|
579
|
+
// Development has no mailer, so the page says how an invitation or a password link reaches anyone there.
|
|
580
|
+
const noMailer = development && (invites || adds) ? (
|
|
581
|
+
<p data-no-mailer="" role="note" className="text-muted-foreground rounded-md border border-dashed px-3 py-2 text-sm">
|
|
346
582
|
{t('dashboard.inviteNoMailer', 'No mailer is configured, so no email is sent. In development the link is written to the server log, and each pending invitation has a link to copy.')}
|
|
347
583
|
</p>
|
|
348
584
|
) : null
|
|
585
|
+
const inviteButton = (primary: boolean) => (
|
|
586
|
+
<Button type="button" size="sm" variant={primary ? 'default' : 'outline'} className="max-sm:h-11" data-invite="" onClick={() => inviteSheet.current?.showModal()}>
|
|
587
|
+
{t('dashboard.invite', 'Invite')}
|
|
588
|
+
</Button>
|
|
589
|
+
)
|
|
349
590
|
|
|
350
591
|
return (
|
|
351
592
|
<AppShell
|
|
352
593
|
regions={{
|
|
353
594
|
header: (
|
|
354
|
-
<div className="flex
|
|
355
|
-
<h1
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
595
|
+
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
|
|
596
|
+
<h1 className="truncate">
|
|
597
|
+
{t('dashboard.members', 'Members')} <span className="text-muted-foreground">· {new Intl.NumberFormat(locale).format(members.length)}</span>
|
|
598
|
+
</h1>
|
|
599
|
+
{adds ? (
|
|
600
|
+
// Add person first, and inviting by a link to copy beside it, for someone who already has an account
|
|
601
|
+
// elsewhere or whom the mail does not reach.
|
|
602
|
+
<ButtonGroup aria-label={t('dashboard.addPerson', 'Add person')}>
|
|
603
|
+
<Button type="button" size="sm" className="max-sm:h-11" data-add-person="" onClick={() => addSheet.current?.showModal()}>
|
|
604
|
+
{t('dashboard.addPerson', 'Add person')}
|
|
605
|
+
</Button>
|
|
606
|
+
{invites ? (
|
|
607
|
+
<DropdownMenu>
|
|
608
|
+
<DropdownMenuTrigger data-invite-menu="" aria-label={t('dashboard.inviteByLink', 'Invite by link')} className={buttonVariants({ size: 'sm', className: 'border-s border-s-primary-foreground/30 max-sm:h-11' })}>
|
|
609
|
+
<ChevronDown className="size-4" aria-hidden="true" />
|
|
610
|
+
</DropdownMenuTrigger>
|
|
611
|
+
<DropdownMenuContent align="end" className="w-56">
|
|
612
|
+
<DropdownMenuItem data-invite="" className="max-sm:min-h-11" onClick={() => inviteSheet.current?.showModal()}>
|
|
613
|
+
{t('dashboard.inviteByLink', 'Invite by link')}
|
|
614
|
+
</DropdownMenuItem>
|
|
615
|
+
</DropdownMenuContent>
|
|
616
|
+
</DropdownMenu>
|
|
617
|
+
) : null}
|
|
618
|
+
</ButtonGroup>
|
|
619
|
+
) : invites ? inviteButton(true) : null}
|
|
361
620
|
</div>
|
|
362
621
|
),
|
|
363
622
|
main: (
|
|
364
|
-
<
|
|
365
|
-
<p className="text-muted-foreground">{t('dashboard.membersAbout', 'Who works in this organization, and the role each holds.')}</p>
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
{
|
|
386
|
-
</
|
|
387
|
-
</
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
{
|
|
420
|
-
|
|
421
|
-
{
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
) : null}
|
|
475
|
-
{cancels ? (
|
|
476
|
-
<Button type="button" size="sm" variant="outline" className="max-sm:h-11" data-cancel-invite={one.id} aria-label={`${t('dashboard.inviteCancel', 'Cancel invitation')}: ${one.email}`} onClick={() => void cancel(one)}>
|
|
477
|
-
{t('dashboard.inviteCancel', 'Cancel invitation')}
|
|
478
|
-
</Button>
|
|
479
|
-
) : null}
|
|
480
|
-
</div>
|
|
481
|
-
</TableCell>
|
|
482
|
-
) : null}
|
|
483
|
-
</TableRow>
|
|
484
|
-
))}
|
|
485
|
-
</TableBody>
|
|
486
|
-
</Table>
|
|
487
|
-
</div>
|
|
488
|
-
{pendingRefusal ? (
|
|
489
|
-
<p role="alert" className="text-destructive text-sm">
|
|
490
|
-
{pendingRefusal}
|
|
491
|
-
</p>
|
|
492
|
-
) : null}
|
|
493
|
-
</section>
|
|
494
|
-
) : null}
|
|
495
|
-
{/* The invitation asks in a modal `<dialog>` for the reason the
|
|
496
|
-
roles sheet below gives. Closing it, however it is closed,
|
|
497
|
-
empties it, so the next invitation starts from nothing. */}
|
|
623
|
+
<section data-members="" className="flex w-full min-w-0 flex-1 flex-col gap-4">
|
|
624
|
+
<p className="text-muted-foreground text-sm">{t('dashboard.membersAbout', 'Who works in this organization, and the role each holds.')}</p>
|
|
625
|
+
{noMailer}
|
|
626
|
+
<Tabs value={tab} onValueChange={(next) => setTab(String(next))}>
|
|
627
|
+
{invites ? (
|
|
628
|
+
<TabsList variant="line" className="max-sm:min-h-12">
|
|
629
|
+
<TabsTrigger value="members" data-members-tab="members" className="flex-none max-sm:min-h-11">{t('dashboard.members', 'Members')}</TabsTrigger>
|
|
630
|
+
<TabsTrigger value="invitations" data-members-tab="invitations" className="flex-none max-sm:min-h-11">
|
|
631
|
+
{(t('dashboard.invitationsTab', 'Invitations ({count})') ?? 'Invitations ({count})').replace('{count}', () => new Intl.NumberFormat(locale).format(pending.length))}
|
|
632
|
+
</TabsTrigger>
|
|
633
|
+
</TabsList>
|
|
634
|
+
) : null}
|
|
635
|
+
{/* Both stay in the first paint, so a server render carries every word and every row of either. */}
|
|
636
|
+
<TabsContent value="members" keepMounted className="data-[hidden]:hidden">
|
|
637
|
+
<Card>
|
|
638
|
+
<CardContent className="grid gap-4">
|
|
639
|
+
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
|
640
|
+
<Search value={search} fields={[t('dashboard.membersName', 'Name') ?? 'Name', t('dashboard.membersEmail', 'Email') ?? 'Email']} locale={locale} t={t} onSearch={setSearch} />
|
|
641
|
+
<label htmlFor={roleFilter} className="sr-only">{t('dashboard.membersRoleFilter', 'Role')}</label>
|
|
642
|
+
<select id={roleFilter} data-role-filter="" value={roleShown} className="border-input bg-background h-9 rounded-md border px-3 text-sm max-sm:h-11 lg:w-56" onChange={(event) => setRoleShown(event.target.value)}>
|
|
643
|
+
<option value="">{t('dashboard.membersEveryRole', 'Every role')}</option>
|
|
644
|
+
{roles.map((one) => <option key={one.name} value={one.name}>{one.label?.[locale] ?? one.name}</option>)}
|
|
645
|
+
</select>
|
|
646
|
+
</div>
|
|
647
|
+
<ListTable
|
|
648
|
+
kayan={memberTable.kayan}
|
|
649
|
+
screen={memberTable}
|
|
650
|
+
copy={{ t, locale }}
|
|
651
|
+
shown={memberColumns}
|
|
652
|
+
cellOf={cellsOf(memberColumns)}
|
|
653
|
+
titles={new Map()}
|
|
654
|
+
docs={shown.map((member) => ({ id: member.userId, name: member.name, email: member.email, person: member.name, roles: [...new Set([...member.roles.map((one) => labelOf(one.role)), ...(member.owner && !member.roles.some((one) => one.role === OWNER_ROLE) ? [labelOf(OWNER_ROLE)] : [])])], joined: member.joined, lastSeen: member.lastSeen }))}
|
|
655
|
+
onSort={() => {}}
|
|
656
|
+
href={() => '#'}
|
|
657
|
+
openHint={openHint}
|
|
658
|
+
openRow={() => {}}
|
|
659
|
+
table={table}
|
|
660
|
+
unreadable={false}
|
|
661
|
+
actions={(row) => {
|
|
662
|
+
const member = members.find((one) => one.userId === row.id)
|
|
663
|
+
if (!member) return null
|
|
664
|
+
const linkable = adds && member.email !== me
|
|
665
|
+
const takes = removable(member)
|
|
666
|
+
return edits || linkable || takes ? (
|
|
667
|
+
<RowActions id={member.userId} label={rowActionsLabel(member.name)}>
|
|
668
|
+
{edits ? <DropdownMenuItem data-row-action="edit-roles" className="max-sm:min-h-11" onClick={() => setEditing(member)}>{t('dashboard.membersEdit', 'Edit roles')}</DropdownMenuItem> : null}
|
|
669
|
+
{linkable ? <DropdownMenuItem data-row-action="password-link" className="max-sm:min-h-11" onClick={() => void sendLink(member)}>{t('dashboard.passwordLink', 'Send a new password link')}</DropdownMenuItem> : null}
|
|
670
|
+
{takes ? <DropdownMenuItem data-row-action="remove" variant="destructive" className="max-sm:min-h-11" disabled={busy} onClick={() => setRemoving(member)}>{t('dashboard.removeMember', 'Remove from organization')}</DropdownMenuItem> : null}
|
|
671
|
+
</RowActions>
|
|
672
|
+
) : null
|
|
673
|
+
}}
|
|
674
|
+
>
|
|
675
|
+
<ListMessage
|
|
676
|
+
count={shown.length}
|
|
677
|
+
filtered={filtered}
|
|
678
|
+
kayan={memberTable.kayan}
|
|
679
|
+
empty={t('dashboard.membersNone', 'No one works in this organization yet.')}
|
|
680
|
+
onClear={() => {
|
|
681
|
+
setSearch(undefined)
|
|
682
|
+
setRoleShown('')
|
|
683
|
+
}}
|
|
684
|
+
t={t}
|
|
685
|
+
unreadable={false}
|
|
686
|
+
/>
|
|
687
|
+
</ListTable>
|
|
688
|
+
</CardContent>
|
|
689
|
+
</Card>
|
|
690
|
+
</TabsContent>
|
|
691
|
+
{invites ? (
|
|
692
|
+
<TabsContent value="invitations" keepMounted className="data-[hidden]:hidden">
|
|
693
|
+
<Card>
|
|
694
|
+
<CardContent className="grid gap-4">
|
|
695
|
+
<p className="text-muted-foreground text-sm">{t('dashboard.inviteLinkOwn', 'A copied link lets whoever holds it join as the invited address: hand it to that person alone.')}</p>
|
|
696
|
+
<ListTable
|
|
697
|
+
kayan={inviteTable.kayan}
|
|
698
|
+
screen={inviteTable}
|
|
699
|
+
copy={{ t, locale }}
|
|
700
|
+
shown={inviteColumns}
|
|
701
|
+
cellOf={cellsOf(inviteColumns)}
|
|
702
|
+
titles={new Map()}
|
|
703
|
+
docs={pending.map((one) => ({ id: one.id, email: one.email, role: t(`backoffice.membership.${one.role}`) ?? one.role, inviter: one.inviter, expiresAt: one.expiresAt, expired: one.expired }))}
|
|
704
|
+
onSort={() => {}}
|
|
705
|
+
href={() => '#'}
|
|
706
|
+
openHint={openHint}
|
|
707
|
+
openRow={() => {}}
|
|
708
|
+
table={inviteTableRef}
|
|
709
|
+
unreadable={false}
|
|
710
|
+
actions={(row) => {
|
|
711
|
+
const one = pending.find((invited) => invited.id === row.id)
|
|
712
|
+
return one ? (
|
|
713
|
+
<RowActions id={one.id} label={rowActionsLabel(one.email)}>
|
|
714
|
+
{/* A link run out joins nobody, so it is sent again rather than copied. */}
|
|
715
|
+
{one.expired ? null : <DropdownMenuItem data-row-action="copy-link" className="max-sm:min-h-11" onClick={() => void copyLink(one)}>{t('dashboard.inviteCopy', 'Copy link')}</DropdownMenuItem>}
|
|
716
|
+
<DropdownMenuItem data-row-action="resend" className="max-sm:min-h-11" onClick={() => void resend(one)}>{t('dashboard.inviteResend', 'Resend')}</DropdownMenuItem>
|
|
717
|
+
{cancels ? <DropdownMenuItem data-row-action="cancel" variant="destructive" className="max-sm:min-h-11" onClick={() => void cancel(one)}>{t('dashboard.inviteCancel', 'Cancel invitation')}</DropdownMenuItem> : null}
|
|
718
|
+
</RowActions>
|
|
719
|
+
) : null
|
|
720
|
+
}}
|
|
721
|
+
>
|
|
722
|
+
<ListMessage count={pending.length} filtered={false} kayan={inviteTable.kayan} empty={t('dashboard.invitesNone', 'No invitation is waiting to be accepted.')} onClear={() => {}} t={t} unreadable={false} />
|
|
723
|
+
</ListTable>
|
|
724
|
+
</CardContent>
|
|
725
|
+
</Card>
|
|
726
|
+
</TabsContent>
|
|
727
|
+
) : null}
|
|
728
|
+
</Tabs>
|
|
729
|
+
<span id={openHint} hidden />
|
|
730
|
+
{adds ? <AddPerson sheet={addSheet} roles={roles.filter((role) => assignments?.create === true && grantable(access, role.permissions))} onInvited={(made) => (listInvite(made), setTab('invitations'))} /> : null}
|
|
731
|
+
{/* The invitation asks in a modal `<dialog>`: a closed dialog is in the first paint, which carries every
|
|
732
|
+
word it shows. Closing it, however it is closed, empties it, so the next invitation starts from nothing. */}
|
|
498
733
|
{invites ? (
|
|
499
734
|
<dialog
|
|
500
735
|
ref={inviteSheet}
|
|
@@ -536,93 +771,57 @@ export function Members({ loaderData }: PageProps<MembersData>) {
|
|
|
536
771
|
onChange={(event) => setEmail(event.target.value)}
|
|
537
772
|
/>
|
|
538
773
|
</FieldShell>
|
|
539
|
-
{inviteRefusal ?
|
|
540
|
-
<p role="alert" className="text-destructive">
|
|
541
|
-
{inviteRefusal}
|
|
542
|
-
</p>
|
|
543
|
-
) : null}
|
|
774
|
+
{inviteRefusal ? <p role="alert" className="text-destructive">{inviteRefusal}</p> : null}
|
|
544
775
|
<footer className="flex justify-end gap-2">
|
|
545
|
-
<Button type="button" variant="outline" onClick={() => inviteSheet.current?.close()}>
|
|
546
|
-
|
|
547
|
-
</Button>
|
|
548
|
-
<Button type="submit" disabled={inviting}>
|
|
549
|
-
{t('dashboard.inviteSend', 'Send invitation')}
|
|
550
|
-
</Button>
|
|
776
|
+
<Button type="button" variant="outline" className="max-sm:h-11" onClick={() => inviteSheet.current?.close()}>{t('admin.cancel', 'Cancel')}</Button>
|
|
777
|
+
<Button type="submit" className="max-sm:h-11" disabled={inviting}>{t('dashboard.inviteSend', 'Send invitation')}</Button>
|
|
551
778
|
</footer>
|
|
552
779
|
</form>
|
|
553
780
|
</dialog>
|
|
554
781
|
) : null}
|
|
555
|
-
{
|
|
556
|
-
than shadcn's sheet, whose popup is a portal a server render
|
|
557
|
-
draws nothing of: a closed dialog is in the
|
|
558
|
-
document and hidden, so the first paint carries every word
|
|
559
|
-
the sheet shows, which the boot's Arabic walk reads. It traps
|
|
560
|
-
focus and closes on Escape by itself. A removal asks inside it
|
|
561
|
-
rather than in a dialog of its own, which would open under the
|
|
562
|
-
sheet's top layer. */}
|
|
563
|
-
{opens ? (
|
|
782
|
+
{adds ? (
|
|
564
783
|
<dialog
|
|
565
|
-
ref={
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
className="bg-background text-foreground m-
|
|
784
|
+
ref={handedSheet}
|
|
785
|
+
data-password-link-sheet=""
|
|
786
|
+
aria-label={t('dashboard.passwordLink', 'Send a new password link')}
|
|
787
|
+
className="bg-background text-foreground m-auto w-[min(28rem,calc(100%-2rem))] rounded-lg border p-0 shadow-lg backdrop:bg-black/80"
|
|
788
|
+
onClose={() => setHanded(undefined)}
|
|
569
789
|
>
|
|
570
|
-
<
|
|
571
|
-
className="
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
<header className="grid gap-1.5 p-6">
|
|
578
|
-
<h2 id={title} className="text-base font-medium">
|
|
579
|
-
{t('dashboard.membersEdit', 'Edit roles')}
|
|
580
|
-
</h2>
|
|
581
|
-
<p className="text-muted-foreground">{editing ? `${editing.name} · ${editing.email}` : null}</p>
|
|
582
|
-
</header>
|
|
583
|
-
<div className="grid flex-1 content-start gap-4 overflow-y-auto px-6">
|
|
584
|
-
{roles.length === 0 ? <p className="text-muted-foreground">{t('dashboard.membersNoRoles', 'There is no role you may read to assign.')}</p> : null}
|
|
585
|
-
{group(true, t('dashboard.membersFixed', 'Fixed roles'))}
|
|
586
|
-
{group(false, t('dashboard.membersCustom', 'Custom roles'))}
|
|
587
|
-
{refusal ? (
|
|
588
|
-
<p role="alert" className="text-destructive">
|
|
589
|
-
{refusal}
|
|
590
|
-
</p>
|
|
591
|
-
) : null}
|
|
592
|
-
{removable ? (
|
|
593
|
-
<section data-remove-member="" className="grid gap-2 border-t pt-4">
|
|
594
|
-
{confirming ? (
|
|
595
|
-
<>
|
|
596
|
-
<p>{t('dashboard.removeAbout', 'They lose access to this organization and every role they hold in it. Inviting them again does not give the roles back.')}</p>
|
|
597
|
-
<div className="flex flex-wrap gap-2">
|
|
598
|
-
<Button type="button" variant="destructive" data-button="confirm-remove" disabled={busy} onClick={() => void remove()}>
|
|
599
|
-
{t('dashboard.removeConfirm', 'Remove for good')}
|
|
600
|
-
</Button>
|
|
601
|
-
<Button type="button" variant="outline" onClick={() => setConfirming(false)}>
|
|
602
|
-
{t('dashboard.removeKeep', 'Keep this member')}
|
|
603
|
-
</Button>
|
|
604
|
-
</div>
|
|
605
|
-
</>
|
|
606
|
-
) : (
|
|
607
|
-
<Button type="button" variant="outline" className="text-destructive justify-self-start" data-button="remove-member" disabled={busy} onClick={() => setConfirming(true)}>
|
|
608
|
-
{t('dashboard.removeMember', 'Remove from organization')}
|
|
609
|
-
</Button>
|
|
610
|
-
)}
|
|
611
|
-
</section>
|
|
612
|
-
) : null}
|
|
613
|
-
</div>
|
|
614
|
-
<footer className="flex justify-end gap-2 p-6">
|
|
615
|
-
<Button type="button" variant="outline" onClick={() => sheet.current?.close()}>
|
|
616
|
-
{t('admin.cancel', 'Cancel')}
|
|
617
|
-
</Button>
|
|
618
|
-
<Button type="submit" disabled={busy || !changed}>
|
|
619
|
-
{t('admin.save', 'Save')}
|
|
620
|
-
</Button>
|
|
790
|
+
<div className="grid gap-4 p-6 text-sm">
|
|
791
|
+
<h2 className="text-base font-medium">{t('dashboard.passwordLink', 'Send a new password link')}</h2>
|
|
792
|
+
{handed ? (
|
|
793
|
+
<HandedLink link={handed.link} said={(t('dashboard.passwordLinkHanded', 'No mailer is configured. Hand this link to {name} alone: it chooses their password and lasts 48 hours.') ?? '').replace('{name}', () => handed.name)} />
|
|
794
|
+
) : null}
|
|
795
|
+
<footer className="flex justify-end">
|
|
796
|
+
<Button type="button" variant="outline" className="max-sm:h-11" onClick={() => handedSheet.current?.close()}>{t('admin.close', 'Close')}</Button>
|
|
621
797
|
</footer>
|
|
622
|
-
</
|
|
798
|
+
</div>
|
|
623
799
|
</dialog>
|
|
624
800
|
) : null}
|
|
625
|
-
|
|
801
|
+
{editing ? <RolesDrawer member={editing} roles={roles} onClose={() => setEditing(undefined)} onHeld={(held) => hold(editing, held)} /> : null}
|
|
802
|
+
<AlertDialog open={removing !== undefined} onOpenChange={(open: boolean) => (open ? undefined : setRemoving(undefined))}>
|
|
803
|
+
<AlertDialogContent data-remove-member="">
|
|
804
|
+
<AlertDialogHeader>
|
|
805
|
+
<AlertDialogTitle>{(t('dashboard.removeTitle', 'Remove {name}?') ?? 'Remove {name}?').replace('{name}', () => removing?.name ?? '')}</AlertDialogTitle>
|
|
806
|
+
<AlertDialogDescription>{t('dashboard.removeAbout', 'They lose access to this organization and every role they hold in it. Inviting them again does not give the roles back.')}</AlertDialogDescription>
|
|
807
|
+
</AlertDialogHeader>
|
|
808
|
+
<AlertDialogFooter className="max-sm:[&_button]:h-11">
|
|
809
|
+
<AlertDialogCancel>{t('dashboard.removeKeep', 'Keep this member')}</AlertDialogCancel>
|
|
810
|
+
<AlertDialogAction
|
|
811
|
+
data-button="confirm-remove"
|
|
812
|
+
variant="destructive"
|
|
813
|
+
onClick={() => {
|
|
814
|
+
const member = removing
|
|
815
|
+
setRemoving(undefined)
|
|
816
|
+
if (member) void remove(member)
|
|
817
|
+
}}
|
|
818
|
+
>
|
|
819
|
+
{t('dashboard.removeConfirm', 'Remove for good')}
|
|
820
|
+
</AlertDialogAction>
|
|
821
|
+
</AlertDialogFooter>
|
|
822
|
+
</AlertDialogContent>
|
|
823
|
+
</AlertDialog>
|
|
824
|
+
</section>
|
|
626
825
|
),
|
|
627
826
|
}}
|
|
628
827
|
/>
|