create-mithaq 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/LICENSES/openstatus-time-picker.txt +21 -0
- package/LICENSES/payload.txt +22 -0
- package/LICENSES/ponytail.txt +21 -0
- package/index.js +521 -0
- package/package.json +19 -4
- package/template/apps/asas-backoffice/components.json +25 -0
- package/template/apps/asas-backoffice/gitignore +3 -0
- package/template/apps/asas-backoffice/package.json +57 -0
- package/template/apps/asas-backoffice/public/favicon.svg +1 -0
- package/template/apps/asas-backoffice/src/api.ts +147 -0
- package/template/apps/asas-backoffice/src/auth-frame.tsx +150 -0
- package/template/apps/asas-backoffice/src/components/ui/accordion.tsx +74 -0
- package/template/apps/asas-backoffice/src/components/ui/alert-dialog.tsx +185 -0
- package/template/apps/asas-backoffice/src/components/ui/avatar.tsx +106 -0
- package/template/apps/asas-backoffice/src/components/ui/badge.tsx +48 -0
- package/template/apps/asas-backoffice/src/components/ui/breadcrumb.tsx +124 -0
- package/template/apps/asas-backoffice/src/components/ui/button-group.tsx +87 -0
- package/template/apps/asas-backoffice/src/components/ui/button.tsx +55 -0
- package/template/apps/asas-backoffice/src/components/ui/calendar.tsx +223 -0
- package/template/apps/asas-backoffice/src/components/ui/card.tsx +102 -0
- package/template/apps/asas-backoffice/src/components/ui/chart.tsx +371 -0
- package/template/apps/asas-backoffice/src/components/ui/checkbox.tsx +28 -0
- package/template/apps/asas-backoffice/src/components/ui/collapsible.tsx +19 -0
- package/template/apps/asas-backoffice/src/components/ui/combobox.tsx +308 -0
- package/template/apps/asas-backoffice/src/components/ui/dialog.tsx +158 -0
- package/template/apps/asas-backoffice/src/components/ui/direction.tsx +6 -0
- package/template/apps/asas-backoffice/src/components/ui/directional-icon.tsx +20 -0
- package/template/apps/asas-backoffice/src/components/ui/dropdown-menu.tsx +267 -0
- package/template/apps/asas-backoffice/src/components/ui/field.tsx +236 -0
- package/template/apps/asas-backoffice/src/components/ui/input-group.tsx +155 -0
- package/template/apps/asas-backoffice/src/components/ui/input-otp.tsx +84 -0
- package/template/apps/asas-backoffice/src/components/ui/input.tsx +19 -0
- package/template/apps/asas-backoffice/src/components/ui/label.tsx +19 -0
- package/template/apps/asas-backoffice/src/components/ui/popover.tsx +90 -0
- package/template/apps/asas-backoffice/src/components/ui/radio-group.tsx +37 -0
- package/template/apps/asas-backoffice/src/components/ui/resizable.tsx +47 -0
- package/template/apps/asas-backoffice/src/components/ui/scroll-area.tsx +54 -0
- package/template/apps/asas-backoffice/src/components/ui/select.tsx +204 -0
- package/template/apps/asas-backoffice/src/components/ui/separator.tsx +24 -0
- package/template/apps/asas-backoffice/src/components/ui/sheet.tsx +139 -0
- package/template/apps/asas-backoffice/src/components/ui/sidebar.tsx +714 -0
- package/template/apps/asas-backoffice/src/components/ui/skeleton.tsx +13 -0
- package/template/apps/asas-backoffice/src/components/ui/slider.tsx +55 -0
- package/template/apps/asas-backoffice/src/components/ui/spinner.tsx +10 -0
- package/template/apps/asas-backoffice/src/components/ui/switch.tsx +29 -0
- package/template/apps/asas-backoffice/src/components/ui/table.tsx +115 -0
- package/template/apps/asas-backoffice/src/components/ui/tabs.tsx +79 -0
- package/template/apps/asas-backoffice/src/components/ui/textarea.tsx +17 -0
- package/template/apps/asas-backoffice/src/components/ui/toggle-group.tsx +89 -0
- package/template/apps/asas-backoffice/src/components/ui/toggle.tsx +44 -0
- package/template/apps/asas-backoffice/src/components/ui/tooltip.tsx +63 -0
- package/template/apps/asas-backoffice/src/components.ts +35 -0
- package/template/apps/asas-backoffice/src/copy.ts +1 -0
- package/template/apps/asas-backoffice/src/first-user.ts +14 -0
- package/template/apps/asas-backoffice/src/hooks/use-mobile.ts +19 -0
- package/template/apps/asas-backoffice/src/lib/utils.ts +1 -0
- package/template/apps/asas-backoffice/src/links.ts +38 -0
- package/template/apps/asas-backoffice/src/locale-switch.tsx +28 -0
- 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-backoffice/src/request-headers.ts +11 -0
- 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-backoffice/src/routes/(screens)/backoffice/screen.json +3971 -0
- package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/types.ts +163 -0
- package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.$.tsx +17 -0
- package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.index.tsx +10 -0
- package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.tsx +13 -0
- package/template/apps/asas-backoffice/src/routes/__root.tsx +95 -0
- 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-backoffice/src/routes/auth.sign-in.tsx +77 -0
- package/template/apps/asas-backoffice/src/routes/unauthorized.tsx +134 -0
- package/template/apps/asas-backoffice/src/screen-form.tsx +36 -0
- package/template/apps/asas-backoffice/src/screen-list.tsx +140 -0
- package/template/apps/asas-backoffice/src/screen-names.ts +10 -0
- package/template/apps/asas-backoffice/src/screens.tsx +888 -0
- package/template/apps/asas-backoffice/src/server.ts +30 -0
- package/template/apps/asas-backoffice/src/slide.ts +22 -0
- package/template/apps/asas-backoffice/src/start.ts +42 -0
- package/template/apps/asas-backoffice/src/styles.css +202 -0
- package/template/apps/asas-backoffice/src/theme.ts +2 -0
- package/template/apps/asas-backoffice/tsconfig.json +19 -0
- package/template/apps/asas-backoffice/vite.config.ts +32 -0
- package/template/apps/asas-tanstack/components.json +25 -0
- package/template/apps/asas-tanstack/gitignore +3 -0
- package/template/apps/asas-tanstack/package.json +58 -0
- package/template/apps/asas-tanstack/public/favicon.svg +1 -0
- package/template/apps/asas-tanstack/src/api.ts +147 -0
- package/template/apps/asas-tanstack/src/auth-frame.tsx +150 -0
- package/template/apps/asas-tanstack/src/choose-password.tsx +150 -0
- package/template/apps/asas-tanstack/src/components/ui/accordion.tsx +10 -0
- package/template/apps/asas-tanstack/src/components/ui/alert-dialog.tsx +173 -0
- package/template/apps/asas-tanstack/src/components/ui/avatar.tsx +104 -0
- package/template/apps/asas-tanstack/src/components/ui/badge.tsx +48 -0
- package/template/apps/asas-tanstack/src/components/ui/breadcrumb.tsx +125 -0
- package/template/apps/asas-tanstack/src/components/ui/button-group.tsx +87 -0
- package/template/apps/asas-tanstack/src/components/ui/button.tsx +56 -0
- package/template/apps/asas-tanstack/src/components/ui/calendar.tsx +223 -0
- package/template/apps/asas-tanstack/src/components/ui/card.tsx +94 -0
- package/template/apps/asas-tanstack/src/components/ui/chart.tsx +369 -0
- package/template/apps/asas-tanstack/src/components/ui/checkbox.tsx +27 -0
- package/template/apps/asas-tanstack/src/components/ui/collapsible.tsx +19 -0
- package/template/apps/asas-tanstack/src/components/ui/combobox.tsx +300 -0
- package/template/apps/asas-tanstack/src/components/ui/dialog.tsx +149 -0
- package/template/apps/asas-tanstack/src/components/ui/direction.tsx +5 -0
- package/template/apps/asas-tanstack/src/components/ui/directional-icon.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/dropdown-menu.tsx +267 -0
- package/template/apps/asas-tanstack/src/components/ui/field.tsx +225 -0
- package/template/apps/asas-tanstack/src/components/ui/input-group.tsx +147 -0
- package/template/apps/asas-tanstack/src/components/ui/input-otp.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/input.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/label.tsx +18 -0
- package/template/apps/asas-tanstack/src/components/ui/popover.tsx +50 -0
- package/template/apps/asas-tanstack/src/components/ui/radio-group.tsx +10 -0
- package/template/apps/asas-tanstack/src/components/ui/resizable.tsx +47 -0
- package/template/apps/asas-tanstack/src/components/ui/scroll-area.tsx +52 -0
- package/template/apps/asas-tanstack/src/components/ui/select.tsx +190 -0
- package/template/apps/asas-tanstack/src/components/ui/separator.tsx +23 -0
- package/template/apps/asas-tanstack/src/components/ui/sheet.tsx +127 -0
- package/template/apps/asas-tanstack/src/components/ui/sidebar.tsx +716 -0
- package/template/apps/asas-tanstack/src/components/ui/skeleton.tsx +13 -0
- package/template/apps/asas-tanstack/src/components/ui/slider.tsx +13 -0
- package/template/apps/asas-tanstack/src/components/ui/spinner.tsx +11 -0
- package/template/apps/asas-tanstack/src/components/ui/switch.tsx +8 -0
- package/template/apps/asas-tanstack/src/components/ui/table.tsx +119 -0
- package/template/apps/asas-tanstack/src/components/ui/tabs.tsx +80 -0
- package/template/apps/asas-tanstack/src/components/ui/textarea.tsx +18 -0
- package/template/apps/asas-tanstack/src/components/ui/toggle-group.tsx +8 -0
- package/template/apps/asas-tanstack/src/components/ui/toggle.tsx +44 -0
- package/template/apps/asas-tanstack/src/components/ui/tooltip.tsx +68 -0
- package/template/apps/asas-tanstack/src/components.ts +35 -0
- package/template/apps/asas-tanstack/src/copy.ts +1207 -0
- package/template/apps/asas-tanstack/src/first-run.ts +16 -0
- package/template/apps/asas-tanstack/src/hooks/use-mobile.ts +19 -0
- package/template/apps/asas-tanstack/src/lib/utils.ts +6 -0
- package/template/apps/asas-tanstack/src/links.ts +38 -0
- package/template/apps/asas-tanstack/src/locale-switch.tsx +28 -0
- package/template/apps/asas-tanstack/src/no-organization.tsx +108 -0
- package/template/apps/asas-tanstack/src/quiet-abort.ts +25 -0
- package/template/apps/asas-tanstack/src/redirect.ts +39 -0
- package/template/apps/asas-tanstack/src/request-headers.ts +11 -0
- package/template/apps/asas-tanstack/src/routeTree.gen.ts +424 -0
- package/template/apps/asas-tanstack/src/router.tsx +18 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +11 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +4242 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +160 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.$.tsx +17 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.index.tsx +10 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.tsx +13 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +9 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +1564 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +94 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.$.tsx +17 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.index.tsx +10 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.tsx +13 -0
- package/template/apps/asas-tanstack/src/routes/__root.tsx +99 -0
- package/template/apps/asas-tanstack/src/routes/api/$.ts +11 -0
- package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +108 -0
- package/template/apps/asas-tanstack/src/routes/auth.first-run.tsx +114 -0
- package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +94 -0
- package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +39 -0
- package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +16 -0
- package/template/apps/asas-tanstack/src/routes/auth.set-password.tsx +16 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +98 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +143 -0
- package/template/apps/asas-tanstack/src/routes/index.tsx +146 -0
- package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +134 -0
- package/template/apps/asas-tanstack/src/screen-form.tsx +36 -0
- package/template/apps/asas-tanstack/src/screen-list.tsx +140 -0
- package/template/apps/asas-tanstack/src/screen-names.ts +10 -0
- package/template/apps/asas-tanstack/src/screens.tsx +888 -0
- package/template/apps/asas-tanstack/src/server.ts +26 -0
- package/template/apps/asas-tanstack/src/slide.ts +22 -0
- package/template/apps/asas-tanstack/src/start.ts +57 -0
- package/template/apps/asas-tanstack/src/styles.css +163 -0
- package/template/apps/asas-tanstack/src/theme.ts +5 -0
- package/template/apps/asas-tanstack/tsconfig.json +19 -0
- package/template/apps/asas-tanstack/vite.config.ts +34 -0
- package/template/package.json +16 -0
- 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 +33 -0
- package/template/templates/asas/AGENTS.md +101 -0
- package/template/templates/asas/CLAUDE.md +1 -0
- package/template/templates/asas/README.md +1056 -0
- package/template/templates/asas/mithaq.hints.json +25 -0
- package/template/templates/asas/package.json +53 -0
- package/template/templates/asas/src/asas.ts +118 -0
- package/template/templates/asas/src/backoffice-http.ts +117 -0
- 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 +132 -0
- package/template/templates/asas/src/messages/auditLog.json +304 -0
- package/template/templates/asas/src/messages/backofficeLog.json +132 -0
- package/template/templates/asas/src/messages/courses.json +312 -0
- package/template/templates/asas/src/messages/enrollments.json +60 -0
- package/template/templates/asas/src/messages/invitation.json +82 -0
- package/template/templates/asas/src/messages/localUsers.json +48 -0
- package/template/templates/asas/src/messages/media.json +76 -0
- package/template/templates/asas/src/messages/member.json +62 -0
- package/template/templates/asas/src/messages/memberRoles.json +48 -0
- package/template/templates/asas/src/messages/mithaq.json +214 -0
- package/template/templates/asas/src/messages/organization.json +72 -0
- package/template/templates/asas/src/messages/organizationRole.json +62 -0
- package/template/templates/asas/src/messages/roles.json +72 -0
- package/template/templates/asas/src/messages/session.json +102 -0
- package/template/templates/asas/src/messages/sessions.json +136 -0
- package/template/templates/asas/src/messages/settings.json +64 -0
- package/template/templates/asas/src/messages/tags.json +48 -0
- package/template/templates/asas/src/messages/user.json +112 -0
- package/template/templates/asas/src/messages/verification.json +62 -0
- package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/migration.sql +177 -0
- package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/snapshot.json +2169 -0
- package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/snapshot.json +2182 -0
- package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/migration.sql +20 -0
- package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/snapshot.json +2409 -0
- package/template/templates/asas/src/migrations/20260922082945_military_ironclad/migration.sql +15 -0
- package/template/templates/asas/src/migrations/20260922082945_military_ironclad/snapshot.json +2627 -0
- package/template/templates/asas/src/migrations/20260922112630_bitter_chat/migration.sql +5 -0
- package/template/templates/asas/src/migrations/20260922112630_bitter_chat/snapshot.json +2691 -0
- package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/migration.sql +8 -0
- package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/snapshot.json +2795 -0
- package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/migration.sql +3 -0
- package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/snapshot.json +2834 -0
- package/template/templates/asas/src/migrations/20260927212525_bent_prowler/migration.sql +9 -0
- package/template/templates/asas/src/migrations/20260927212525_bent_prowler/snapshot.json +2870 -0
- package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/snapshot.json +2881 -0
- package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/migration.sql +15 -0
- package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/snapshot.json +3073 -0
- package/template/templates/asas/src/migrations/20260929024407_grey_morlun/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20260929024407_grey_morlun/snapshot.json +3098 -0
- package/template/templates/asas/src/migrations/20260929130530_bored_jackal/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20260929130530_bored_jackal/snapshot.json +3124 -0
- package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/migration.sql +32 -0
- package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/snapshot.json +3324 -0
- package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/migration.sql +2 -0
- package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/snapshot.json +3350 -0
- package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/migration.sql +8 -0
- package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/migration.sql +18 -0
- package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/migration.sql +18 -0
- package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/migration.sql +11 -0
- package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/snapshot.json +3667 -0
- package/template/templates/asas/src/migrations/20261012000752_organization_ownership/migration.sql +48 -0
- package/template/templates/asas/src/migrations/20261012000752_organization_ownership/snapshot.json +3891 -0
- 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/drizzle.config.ts +11 -0
- package/template/templates/asas/src/migrations/schema.ts +30 -0
- package/template/templates/asas/src/mithaq.ts +4 -0
- package/template/templates/asas/src/organizations.ts +3 -0
- package/template/templates/asas/src/seed.ts +427 -0
- package/template/templates/asas/src/ui-backoffice/home.tsx +63 -0
- package/template/templates/asas/src/ui-backoffice/organization-actions.tsx +121 -0
- package/template/templates/asas/src/ui-backoffice/record-page.tsx +30 -0
- package/template/templates/asas/src/ui-backoffice/role-cells.tsx +59 -0
- package/template/templates/asas/src/ui-backoffice/user-actions.tsx +249 -0
- package/template/templates/asas/src/ui-dashboard/dashboard.tsx +300 -0
- 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 +829 -0
- package/template/templates/asas/src/ui-dashboard/theme-provider.tsx +10 -0
- package/template/templates/asas/src/ui-dashboard/versions.tsx +446 -0
- package/template/templates/asas/src/ui-portal/courses-home.tsx +96 -0
- package/template/templates/asas/src/ui-portal/home.tsx +73 -0
- package/template/templates/asas/src/ui-portal/page.tsx +11 -0
- 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/features/auth/local-users/local-users.ts +143 -0
- package/template/templates/features/auth/package.json +13 -0
- package/template/templates/features/auth/user/account.access.ts +8 -0
- package/template/templates/features/auth/user/account.ts +31 -0
- package/template/templates/features/auth/user/index.ts +18 -0
- package/template/templates/features/auth/user/invitation.access.ts +4 -0
- package/template/templates/features/auth/user/invitation.ts +27 -0
- package/template/templates/features/auth/user/member.access.ts +10 -0
- package/template/templates/features/auth/user/member.screens.ts +37 -0
- package/template/templates/features/auth/user/member.ts +26 -0
- package/template/templates/features/auth/user/organization.access.ts +20 -0
- package/template/templates/features/auth/user/organization.screens.ts +37 -0
- package/template/templates/features/auth/user/organization.ts +25 -0
- package/template/templates/features/auth/user/organizationRole.access.ts +8 -0
- package/template/templates/features/auth/user/organizationRole.ts +25 -0
- package/template/templates/features/auth/user/session.access.ts +8 -0
- package/template/templates/features/auth/user/session.ts +28 -0
- package/template/templates/features/auth/user/user.access.ts +30 -0
- package/template/templates/features/auth/user/user.screens.ts +45 -0
- package/template/templates/features/auth/user/user.ts +31 -0
- package/template/templates/features/auth/user/verification.access.ts +8 -0
- package/template/templates/features/auth/user/verification.ts +23 -0
- package/template/templates/features/courses/courses/courses.ts +601 -0
- 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/features/courses/courses/ui/fee-cell.tsx +19 -0
- package/template/templates/features/courses/courses/ui/no-courses.tsx +13 -0
- package/template/templates/features/courses/courses/ui/status-cell.tsx +33 -0
- 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/features/courses/enrollments/ui/session-cell.tsx +75 -0
- package/template/templates/features/courses/enrollments/ui/session-labels.tsx +36 -0
- 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/features/courses/sessions/sessions.ts +186 -0
- package/template/templates/features/media/index.ts +10 -0
- package/template/templates/features/media/media/media.ts +131 -0
- package/template/templates/features/media/media/ui/preview.tsx +143 -0
- package/template/templates/features/media/package.json +19 -0
- package/template/templates/features/roles/index.ts +4 -0
- package/template/templates/features/roles/member-roles/member-roles.ts +207 -0
- package/template/templates/features/roles/package.json +21 -0
- package/template/templates/features/roles/roles/roles.ts +184 -0
- package/template/templates/features/roles/roles/ui/role-form.tsx +267 -0
- package/template/templates/features/roles/roles/ui/roles.tsx +190 -0
- package/template/templates/features/settings/index.ts +3 -0
- package/template/templates/features/settings/package.json +13 -0
- package/template/templates/features/settings/settings/settings.ts +64 -0
- package/template/templates/features/tags/index.ts +3 -0
- package/template/templates/features/tags/package.json +14 -0
- package/template/templates/features/tags/tags/tags.ts +80 -0
- package/template/templates/mithaq/components.json +22 -0
- 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/mithaq/src/mithaq/audit-sink.ts +111 -0
- package/template/templates/mithaq/src/mithaq/auth/backoffice-roles.ts +108 -0
- 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/mithaq/src/mithaq/pages.ts +83 -0
- 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/mithaq/src/roles/grants.ts +47 -0
- package/template/templates/mithaq/src/roles/member-access.ts +11 -0
- package/template/templates/mithaq/src/roles/statements.ts +13 -0
- package/template/templates/mithaq/src/roles/system-roles.ts +16 -0
- package/template/templates/mithaq/src/roles/tenant-access.ts +260 -0
- package/template/templates/mithaq/src/roles/tenant-roles.ts +321 -0
- package/template/templates/mithaq/src/steps/expected-updated-at.ts +41 -0
- package/template/templates/mithaq/src/storage/defaults.ts +156 -0
- package/template/templates/mithaq/src/storage/index.ts +97 -0
- package/template/templates/mithaq/src/storage/local.ts +143 -0
- package/template/templates/mithaq/src/storage/r2.ts +5 -0
- package/template/templates/mithaq/src/storage/s3.ts +91 -0
- package/template/templates/mithaq/src/storage/uploadthing.ts +59 -0
- package/template/templates/mithaq/src/ui/activity-page.tsx +33 -0
- package/template/templates/mithaq/src/ui/activity.tsx +527 -0
- package/template/templates/mithaq/src/ui/app-shell.tsx +268 -0
- package/template/templates/mithaq/src/ui/kpi-card.tsx +25 -0
- package/template/templates/mithaq/src/ui/record-page.tsx +50 -0
- package/template/templates/mithaq/src/ui/rich-text-nodes.tsx +33 -0
- package/template/templates/mithaq/src/ui/screen-nav.tsx +28 -0
- package/template/templates/mithaq/src/ui/table-page.tsx +9 -0
- package/template/templates/mithaq/src/ui/top-shell.tsx +60 -0
- package/README.md +0 -3
|
@@ -0,0 +1,829 @@
|
|
|
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'
|
|
13
|
+
import { useAccess, useClient, useLocale, useUser } from 'mithaq/admin/react'
|
|
14
|
+
import type { asas } from '../asas.ts'
|
|
15
|
+
import { authArm } from '@/api.ts'
|
|
16
|
+
import { refusalKey } from '@/copy.ts'
|
|
17
|
+
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
|
|
18
|
+
import { Badge } from '@/components/ui/badge'
|
|
19
|
+
import { Button, buttonVariants } from '@/components/ui/button'
|
|
20
|
+
import { ButtonGroup } from '@/components/ui/button-group'
|
|
21
|
+
import { Card, CardContent } from '@/components/ui/card'
|
|
22
|
+
import { Checkbox } from '@/components/ui/checkbox'
|
|
23
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
|
24
|
+
import { Input } from '@/components/ui/input'
|
|
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'
|
|
39
|
+
|
|
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. */
|
|
43
|
+
export type MembersData = { members: Member[]; roles: Role[]; invites?: Invite[] }
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether a governance role lets its member invite: `owner` and `admin` hold `invitation.create`, and `member` holds nothing but reading the role list,
|
|
47
|
+
* so the provider refuses a `member` the invitation whatever keys it holds.
|
|
48
|
+
*/
|
|
49
|
+
const invitesAsGovernance = (role: unknown) => typeof role === 'string' && role.split(',').some((name) => name === 'owner' || name === 'admin')
|
|
50
|
+
|
|
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
|
+
})
|
|
60
|
+
|
|
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),
|
|
65
|
+
// Asked only of a viewer who may invite: one holding the key, whose
|
|
66
|
+
// governance role the provider lets invite.
|
|
67
|
+
holds(access, 'invitation.create')
|
|
68
|
+
? request('/api/auth/user/organization/get-active-member-role')
|
|
69
|
+
.then(async (response) => response.ok && invitesAsGovernance(((await response.json()) as { role?: unknown }).role))
|
|
70
|
+
.then((invites) =>
|
|
71
|
+
invites
|
|
72
|
+
? request('/api/auth/user/organization/list-invitations').then(async (response) => {
|
|
73
|
+
if (!response.ok) throw new Error(`list-invitations answered ${response.status}`)
|
|
74
|
+
return (await response.json()) as { id: string; email: string; role?: string | null; status: string; expiresAt: string; inviterId?: string }[]
|
|
75
|
+
})
|
|
76
|
+
: undefined,
|
|
77
|
+
)
|
|
78
|
+
: undefined,
|
|
79
|
+
])
|
|
80
|
+
const names = new Map(members.map((member) => [member.userId, member.name]))
|
|
81
|
+
return {
|
|
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))) }),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether this is Vite's development server, which has no mailer, read so the
|
|
92
|
+
* registry's own typecheck, which knows no Vite, reads it too.
|
|
93
|
+
*/
|
|
94
|
+
const development = (import.meta as { env?: { DEV?: boolean } }).env?.DEV === true
|
|
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
|
+
|
|
206
|
+
const sameRoles = (a: ReadonlySet<string>, b: ReadonlySet<string>) => a.size === b.size && [...a].every((name) => b.has(name))
|
|
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
|
+
|
|
398
|
+
export function Members({ loaderData }: PageProps<MembersData>) {
|
|
399
|
+
const { locale, t } = useLocale()
|
|
400
|
+
const client = useClient<typeof asas.router>()
|
|
401
|
+
const access = useAccess()
|
|
402
|
+
const me = useUser().email
|
|
403
|
+
const assignments = access.collections?.memberRoles
|
|
404
|
+
const roles = loaderData?.roles ?? []
|
|
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('')
|
|
413
|
+
const [editing, setEditing] = useState<Member>()
|
|
414
|
+
const [removing, setRemoving] = useState<Member>()
|
|
415
|
+
const [busy, setBusy] = useState(false)
|
|
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.
|
|
425
|
+
const edits = assignments?.create === true || assignments?.delete === true
|
|
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.
|
|
428
|
+
const removes = holds(access, 'member.delete')
|
|
429
|
+
const cancels = holds(access, 'invitation.cancel')
|
|
430
|
+
const invites = loaderData?.invites !== undefined
|
|
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')
|
|
434
|
+
const [inviting, setInviting] = useState(false)
|
|
435
|
+
const [email, setEmail] = useState('')
|
|
436
|
+
/** Whether the email box has been left or submitted, after which it says what is wrong with it. */
|
|
437
|
+
const [emailLeft, setEmailLeft] = useState(false)
|
|
438
|
+
const [inviteRefusal, setInviteRefusal] = useState<string>()
|
|
439
|
+
const inviteSheet = useRef<HTMLDialogElement>(null)
|
|
440
|
+
const emailBox = useRef<HTMLInputElement>(null)
|
|
441
|
+
const inviteTitle = useId()
|
|
442
|
+
const emailId = useId()
|
|
443
|
+
const refused = (answered: { status: number; body: Record<string, unknown> | null }, fallback: string | undefined) => {
|
|
444
|
+
const key = refusedKey(answered)
|
|
445
|
+
return (key && t(key)) || fallback || ''
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* What is wrong with the address typed, said once the box is left and on
|
|
449
|
+
* every submit: nothing typed, or what the browser's own `type="email"`
|
|
450
|
+
* refuses, so no second rule for an address is written here.
|
|
451
|
+
*/
|
|
452
|
+
const emailError = !emailLeft
|
|
453
|
+
? undefined
|
|
454
|
+
: !email.trim()
|
|
455
|
+
? t('auth.required', 'This field is required.')
|
|
456
|
+
: emailBox.current?.validity.typeMismatch
|
|
457
|
+
? t('auth.invalidEmail', 'Invalid email')
|
|
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])
|
|
461
|
+
/**
|
|
462
|
+
* One invitation, as a member: the role the provider gives on accepting, so
|
|
463
|
+
* the roles of this page are given after, here, like any member's.
|
|
464
|
+
*/
|
|
465
|
+
const invite = async () => {
|
|
466
|
+
setEmailLeft(true)
|
|
467
|
+
if (!email.trim() || emailBox.current?.validity.typeMismatch) return emailBox.current?.focus()
|
|
468
|
+
setInviting(true)
|
|
469
|
+
setInviteRefusal(undefined)
|
|
470
|
+
const answered = await authArm('organization/invite-member', { email: email.trim(), role: 'member' }).finally(() => setInviting(false))
|
|
471
|
+
// The provider's sentence is English alone, so the refusal is said in the viewer's locale, by its code.
|
|
472
|
+
if (!answered.ok) return setInviteRefusal(refused(answered, t('dashboard.inviteRefused', 'The server refused that invitation.')))
|
|
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))
|
|
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))
|
|
488
|
+
}
|
|
489
|
+
/** One pending invitation withdrawn, through the provider's own route. */
|
|
490
|
+
const cancel = async (one: Invite) => {
|
|
491
|
+
const answered = await authArm('organization/cancel-invitation', { invitationId: one.id })
|
|
492
|
+
if (!answered.ok) return void toast.error(refused(answered, t('dashboard.inviteCancelRefused', 'The server refused to cancel that invitation.')))
|
|
493
|
+
setPending((all) => all.filter((other) => other.id !== one.id))
|
|
494
|
+
toast.success(t('dashboard.inviteCancelled', 'Invitation cancelled') ?? 'Invitation cancelled')
|
|
495
|
+
}
|
|
496
|
+
/** The link the invitee opens, for the inviter to hand over where the mail does not reach, in every environment. */
|
|
497
|
+
const copyLink = async (one: Invite) => {
|
|
498
|
+
await navigator.clipboard.writeText(new URL(`/auth/accept-invitation?id=${encodeURIComponent(one.id)}`, window.location.origin).href)
|
|
499
|
+
toast.success(t('dashboard.inviteCopied', 'Link copied') ?? 'Link copied')
|
|
500
|
+
}
|
|
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))
|
|
511
|
+
}
|
|
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.
|
|
513
|
+
useEffect(() => {
|
|
514
|
+
const asked = new URLSearchParams(window.location.search).get('member')
|
|
515
|
+
const member = members.find((one) => one.userId === asked)
|
|
516
|
+
if (member && edits) setEditing(member)
|
|
517
|
+
}, [])
|
|
518
|
+
const labelOf = (name: string) => roles.find((role) => role.name === name)?.label?.[locale] ?? name
|
|
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)))
|
|
521
|
+
/**
|
|
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.
|
|
524
|
+
*/
|
|
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
|
+
})
|
|
530
|
+
/**
|
|
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.
|
|
534
|
+
*/
|
|
535
|
+
const remove = async (member: Member) => {
|
|
536
|
+
setBusy(true)
|
|
537
|
+
let held = member.roles
|
|
538
|
+
try {
|
|
539
|
+
for (const one of held) {
|
|
540
|
+
await client({ kayan: 'memberRoles', operation: 'delete', input: { id: one.id } })
|
|
541
|
+
held = held.filter((row) => row !== one)
|
|
542
|
+
}
|
|
543
|
+
} catch (thrown) {
|
|
544
|
+
const key = (thrown as { data?: { key?: unknown } }).data?.key
|
|
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.') || '')
|
|
548
|
+
}
|
|
549
|
+
const answered = await authArm('organization/remove-member', { memberIdOrEmail: member.email }).finally(() => setBusy(false))
|
|
550
|
+
if (!answered.ok) {
|
|
551
|
+
hold(member, held)
|
|
552
|
+
return void toast.error(refused(answered, t('dashboard.removeRefused', 'The server refused to remove that member.')))
|
|
553
|
+
}
|
|
554
|
+
setMembers((all) => all.filter((one) => one.userId !== member.userId))
|
|
555
|
+
toast.success((t('dashboard.memberRemoved', '{name} removed') ?? '{name} removed').replace('{name}', () => member.name))
|
|
556
|
+
}
|
|
557
|
+
|
|
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">
|
|
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.')}
|
|
583
|
+
</p>
|
|
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
|
+
)
|
|
590
|
+
|
|
591
|
+
return (
|
|
592
|
+
<AppShell
|
|
593
|
+
regions={{
|
|
594
|
+
header: (
|
|
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}
|
|
620
|
+
</div>
|
|
621
|
+
),
|
|
622
|
+
main: (
|
|
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. */}
|
|
733
|
+
{invites ? (
|
|
734
|
+
<dialog
|
|
735
|
+
ref={inviteSheet}
|
|
736
|
+
aria-labelledby={inviteTitle}
|
|
737
|
+
data-invite-sheet=""
|
|
738
|
+
className="bg-background text-foreground m-auto w-[min(28rem,calc(100%-2rem))] rounded-lg border p-0 shadow-lg backdrop:bg-black/80"
|
|
739
|
+
onClose={() => {
|
|
740
|
+
setEmail('')
|
|
741
|
+
setEmailLeft(false)
|
|
742
|
+
setInviteRefusal(undefined)
|
|
743
|
+
}}
|
|
744
|
+
>
|
|
745
|
+
<form
|
|
746
|
+
noValidate
|
|
747
|
+
className="grid gap-4 p-6 text-sm"
|
|
748
|
+
onSubmit={(event) => {
|
|
749
|
+
event.preventDefault()
|
|
750
|
+
void invite()
|
|
751
|
+
}}
|
|
752
|
+
>
|
|
753
|
+
<header className="grid gap-1.5">
|
|
754
|
+
<h2 id={inviteTitle} className="text-base font-medium">{t('dashboard.inviteTitle', 'Invite someone')}</h2>
|
|
755
|
+
<p className="text-muted-foreground">{t('dashboard.inviteAbout', 'They join this organization as a member once they accept. Give them roles here after that.')}</p>
|
|
756
|
+
</header>
|
|
757
|
+
{noMailer}
|
|
758
|
+
<FieldShell id={emailId} label={t('dashboard.membersEmail', 'Email') ?? 'Email'} error={emailError} onBlur={() => setEmailLeft(true)}>
|
|
759
|
+
<Input
|
|
760
|
+
ref={emailBox}
|
|
761
|
+
id={emailId}
|
|
762
|
+
name="email"
|
|
763
|
+
type="email"
|
|
764
|
+
dir="ltr"
|
|
765
|
+
autoComplete="off"
|
|
766
|
+
disabled={inviting}
|
|
767
|
+
value={email}
|
|
768
|
+
aria-required
|
|
769
|
+
aria-invalid={emailError ? true : undefined}
|
|
770
|
+
aria-describedby={describedBy({ id: emailId, error: emailError })}
|
|
771
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
772
|
+
/>
|
|
773
|
+
</FieldShell>
|
|
774
|
+
{inviteRefusal ? <p role="alert" className="text-destructive">{inviteRefusal}</p> : null}
|
|
775
|
+
<footer className="flex justify-end gap-2">
|
|
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>
|
|
778
|
+
</footer>
|
|
779
|
+
</form>
|
|
780
|
+
</dialog>
|
|
781
|
+
) : null}
|
|
782
|
+
{adds ? (
|
|
783
|
+
<dialog
|
|
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)}
|
|
789
|
+
>
|
|
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>
|
|
797
|
+
</footer>
|
|
798
|
+
</div>
|
|
799
|
+
</dialog>
|
|
800
|
+
) : null}
|
|
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>
|
|
825
|
+
),
|
|
826
|
+
}}
|
|
827
|
+
/>
|
|
828
|
+
)
|
|
829
|
+
}
|