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,267 @@
|
|
|
1
|
+
// Draws a custom role's slug, its label in every locale and its keys as badges
|
|
2
|
+
// in place of the form's leaves. The form still holds, checks and saves the
|
|
3
|
+
// values; this reads and writes them through `useForm()`, and reads each box's
|
|
4
|
+
// refusal through `useKayanField`, so a box says it is required, says what is
|
|
5
|
+
// wrong under itself once left or refused, and marks itself invalid.
|
|
6
|
+
import type { ComponentProps as ElementProps, ReactNode } from 'react'
|
|
7
|
+
import { ChevronDown } from 'lucide-react'
|
|
8
|
+
import type { ComponentProps } from 'mithaq/admin'
|
|
9
|
+
import { useAccess, useForm, useKayan, useLocale, useScreen } from 'mithaq/admin/react'
|
|
10
|
+
import { Badge } from '@/components/ui/badge'
|
|
11
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
12
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
|
13
|
+
import { Input } from '@/components/ui/input'
|
|
14
|
+
import { ariaRequired } from 'mithaq-admin/control-props.ts'
|
|
15
|
+
import { FieldShell, describedBy } from 'mithaq-admin/field-shell.tsx'
|
|
16
|
+
import { useKayanField } from 'mithaq-admin/form-context.tsx'
|
|
17
|
+
import { grantable } from '@asas/mithaq/roles/grants.ts'
|
|
18
|
+
import { projectStatements } from '@asas/mithaq/roles/statements.ts'
|
|
19
|
+
|
|
20
|
+
/** The key a tenant's role may never hold, which the role kayan refuses by name, so no badge offers it. */
|
|
21
|
+
const BACKOFFICE_KEY = 'screen.backoffice'
|
|
22
|
+
|
|
23
|
+
type Key = { key: string; label: string }
|
|
24
|
+
type Group = { name: string; label: string; operations: Key[]; fields: Key[] }
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The keys a badge may offer, grouped: the screens the viewer enters, then each
|
|
28
|
+
* kayan of this screen, its operations and then its fields, each named in the
|
|
29
|
+
* viewer's locale. The screen's own projection is the catalog here, and the
|
|
30
|
+
* server checks each key against `asas.catalog` and the granter anyway.
|
|
31
|
+
*/
|
|
32
|
+
const useGroups = (): Group[] => {
|
|
33
|
+
const screen = useScreen()
|
|
34
|
+
const access = useAccess()
|
|
35
|
+
const { locale, t } = useLocale()
|
|
36
|
+
const screens = (access.screens ?? []).map((name) => `screen.${name}`).filter((key) => key !== BACKOFFICE_KEY)
|
|
37
|
+
return [
|
|
38
|
+
{
|
|
39
|
+
name: 'screen',
|
|
40
|
+
label: t('dashboard.permissionsScreens', 'Screens') ?? 'Screens',
|
|
41
|
+
operations: screens.map((key) => ({ key, label: t(`screens.${key.slice('screen.'.length)}`) ?? key })),
|
|
42
|
+
fields: [],
|
|
43
|
+
},
|
|
44
|
+
...Object.values(screen.kayan).map((kayan) => ({
|
|
45
|
+
name: kayan.name,
|
|
46
|
+
label: t(`${kayan.name}.plural`) ?? kayan.name,
|
|
47
|
+
operations: [...kayan.operations.map((one) => one.name), ...(Object.hasOwn(projectStatements, kayan.name) ? projectStatements[kayan.name as keyof typeof projectStatements] : [])].map((name) => ({
|
|
48
|
+
key: `${kayan.name}.${name}`,
|
|
49
|
+
label: t(`${kayan.name}.operations.${name}`) ?? t(`permissions.${name}`) ?? name,
|
|
50
|
+
})),
|
|
51
|
+
// A generated auth kayan's fields carry no label of their own, and the
|
|
52
|
+
// project's copy names them.
|
|
53
|
+
fields: kayan.fields.map((field) => ({ key: `${kayan.name}.fields.${field.name}`, label: field.label?.[locale] ?? t(`${kayan.name}.${field.name}`) ?? field.name })),
|
|
54
|
+
})),
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A role's or a group's keys behind a row that names them and counts what they grant, closed at
|
|
60
|
+
* first so a page of roles reads as a list of names; `defaultOpen` where the keys are the page's
|
|
61
|
+
* work. A closed panel is `hidden="until-found"` once the page runs, so it is in the first paint
|
|
62
|
+
* and a find on the page opens it. `named` names a row that draws no label of its own, so a reader
|
|
63
|
+
* of the screen hears whose keys it opens. Every permission has nothing to open, so it is said.
|
|
64
|
+
*/
|
|
65
|
+
export function Grants({ label, named, held, heading, defaultOpen, children, ...root }: { label?: ReactNode; named?: string; held: readonly string[]; heading?: boolean; defaultOpen?: boolean; children: ReactNode } & ElementProps<'div'>) {
|
|
66
|
+
const { locale, t } = useLocale()
|
|
67
|
+
const count = held.length === 1
|
|
68
|
+
? t('dashboard.permissionsCountOne', '1 permission')
|
|
69
|
+
: (t('dashboard.permissionsCount', '{count} permissions') ?? '{count} permissions').replace('{count}', () => new Intl.NumberFormat(locale).format(held.length))
|
|
70
|
+
if (held.includes('*')) {
|
|
71
|
+
return (
|
|
72
|
+
<div {...root} className="flex items-center gap-2">
|
|
73
|
+
{/* Where a chevron stands on every other row, so the names line up. */}
|
|
74
|
+
<span aria-hidden="true" className="size-4 shrink-0" />
|
|
75
|
+
{label}
|
|
76
|
+
<span className="text-muted-foreground text-xs">{t('dashboard.everyPermission', 'Every permission')}</span>
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
const trigger = (
|
|
81
|
+
<CollapsibleTrigger aria-label={named && `${named}: ${count}`} className="group flex w-full cursor-pointer items-center gap-2 text-start max-sm:min-h-11">
|
|
82
|
+
<ChevronDown aria-hidden="true" className="text-muted-foreground size-4 shrink-0 transition-transform group-data-[panel-open]:rotate-180" />
|
|
83
|
+
{label}
|
|
84
|
+
<span className="text-muted-foreground text-xs font-normal">{count}</span>
|
|
85
|
+
</CollapsibleTrigger>
|
|
86
|
+
)
|
|
87
|
+
return (
|
|
88
|
+
<Collapsible {...root} defaultOpen={defaultOpen}>
|
|
89
|
+
{heading ? <h3 className="text-sm font-medium">{trigger}</h3> : trigger}
|
|
90
|
+
<CollapsibleContent hiddenUntilFound className="ps-6 pt-2">{children}</CollapsibleContent>
|
|
91
|
+
</Collapsible>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The keys a role holds as badges, grouped by screen and kayan, each group behind its count: closed where they are
|
|
97
|
+
* read, open in the editor, whose work they are. With `onChange`
|
|
98
|
+
* each badge is a toggle, a key the viewer may not hand over disabled; without it
|
|
99
|
+
* only the keys held are drawn. A key no group names stays, under "Other", so a
|
|
100
|
+
* save never drops one.
|
|
101
|
+
*/
|
|
102
|
+
export function PermissionBadges({ held, onChange, busy }: { held: readonly string[]; onChange?: (keys: string[]) => void; busy?: boolean }) {
|
|
103
|
+
const access = useAccess()
|
|
104
|
+
const { t } = useLocale()
|
|
105
|
+
// Read only, every screen the role enters is named, whichever the viewer
|
|
106
|
+
// enters: the backoffice reads a tenant's role, which holds `screen.dashboard`.
|
|
107
|
+
const groups = useGroups().map((group) =>
|
|
108
|
+
group.name === 'screen' && !onChange
|
|
109
|
+
? { ...group, operations: held.filter((key) => key.startsWith('screen.')).map((key) => ({ key, label: t(`screens.${key.slice('screen.'.length)}`) ?? key })) }
|
|
110
|
+
: group,
|
|
111
|
+
)
|
|
112
|
+
const named = new Set(groups.flatMap((group) => [...group.operations, ...group.fields].map((one) => one.key)))
|
|
113
|
+
const other = held.filter((key) => !named.has(key))
|
|
114
|
+
/**
|
|
115
|
+
* A key no group names, which is a kayan this screen does not draw or a
|
|
116
|
+
* column the viewer may not read, still by its words where the copy has
|
|
117
|
+
* them, `<kayan>.<field>` or the operation's, and by the key only where it
|
|
118
|
+
* has none.
|
|
119
|
+
*/
|
|
120
|
+
const otherLabel = (key: string) => {
|
|
121
|
+
const [kayan, second, field] = key.split('.')
|
|
122
|
+
return (second === 'fields' && field ? t(`${kayan}.${field}`) : second ? (t(`${kayan}.operations.${second}`) ?? t(`permissions.${second}`)) : undefined) ?? key
|
|
123
|
+
}
|
|
124
|
+
const toggle = (key: string) => onChange?.(held.includes(key) ? held.filter((one) => one !== key) : [...held, key])
|
|
125
|
+
const badges = (keys: Key[]) => {
|
|
126
|
+
const shown = onChange ? keys : keys.filter((one) => held.includes(one.key))
|
|
127
|
+
return shown.length ? (
|
|
128
|
+
// On a phone each chip is a 44 pixel target, with room between them, so
|
|
129
|
+
// a thumb does not take the key beside the one it meant.
|
|
130
|
+
<div className="flex flex-wrap gap-1.5 max-sm:gap-2">
|
|
131
|
+
{shown.map(({ key, label }) => {
|
|
132
|
+
const on = held.includes(key)
|
|
133
|
+
if (!onChange) return <Badge key={key} variant="outline" data-key={key}>{label}</Badge>
|
|
134
|
+
const disabled = busy === true || !grantable(access, [key])
|
|
135
|
+
return (
|
|
136
|
+
<Badge
|
|
137
|
+
key={key}
|
|
138
|
+
variant={on ? 'default' : 'outline'}
|
|
139
|
+
data-key={key}
|
|
140
|
+
data-selected={on}
|
|
141
|
+
data-disabled={disabled}
|
|
142
|
+
className="cursor-pointer data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50 max-sm:h-11 max-sm:min-w-11 max-sm:px-3"
|
|
143
|
+
render={<button type="button" aria-pressed={on} disabled={disabled} onClick={() => toggle(key)} />}
|
|
144
|
+
>
|
|
145
|
+
{label}
|
|
146
|
+
</Badge>
|
|
147
|
+
)
|
|
148
|
+
})}
|
|
149
|
+
</div>
|
|
150
|
+
) : null
|
|
151
|
+
}
|
|
152
|
+
if (held.includes('*')) return <Badge variant="outline">{t('dashboard.everyPermission', 'Every permission')}</Badge>
|
|
153
|
+
return (
|
|
154
|
+
<div data-permissions="" className="grid gap-2">
|
|
155
|
+
{groups.map((group) => {
|
|
156
|
+
const operations = badges(group.operations)
|
|
157
|
+
const fields = badges(group.fields)
|
|
158
|
+
const keys = [...group.operations, ...group.fields].map((one) => one.key)
|
|
159
|
+
return operations || fields ? (
|
|
160
|
+
<Grants key={group.name} data-group={group.name} heading defaultOpen={Boolean(onChange)} label={<span>{group.label}</span>} held={held.filter((key) => keys.includes(key))}>
|
|
161
|
+
<div className="grid gap-1.5">
|
|
162
|
+
{operations}
|
|
163
|
+
{fields ? (
|
|
164
|
+
<>
|
|
165
|
+
<p className="text-muted-foreground text-xs">{t('dashboard.permissionsFields', 'Fields')}</p>
|
|
166
|
+
{fields}
|
|
167
|
+
</>
|
|
168
|
+
) : null}
|
|
169
|
+
</div>
|
|
170
|
+
</Grants>
|
|
171
|
+
) : null
|
|
172
|
+
})}
|
|
173
|
+
{other.length ? (
|
|
174
|
+
<Grants data-group="" heading defaultOpen={Boolean(onChange)} label={<span>{t('dashboard.permissionsOther', 'Other')}</span>} held={other}>
|
|
175
|
+
{badges(other.map((key) => ({ key, label: otherLabel(key) })))}
|
|
176
|
+
</Grants>
|
|
177
|
+
) : null}
|
|
178
|
+
</div>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function RoleForm(_: ComponentProps) {
|
|
183
|
+
const { disabled: readOnly, getData, setValue } = useForm()
|
|
184
|
+
const { t } = useLocale()
|
|
185
|
+
const fields = useKayan().fields
|
|
186
|
+
const labelField = fields.find((field) => field.name === 'label')
|
|
187
|
+
const values = getData()
|
|
188
|
+
const label = (values.label ?? {}) as Record<string, string>
|
|
189
|
+
const permissions = Array.isArray(values.permissions) ? (values.permissions as string[]) : []
|
|
190
|
+
const named = { name: useKayanField('name'), label: useKayanField('label'), permissions: useKayanField('permissions') }
|
|
191
|
+
const required = (name: string) => ariaRequired(fields.find((field) => field.name === name) ?? { rules: {} })
|
|
192
|
+
/** Whether one locale's label box is the one refused: those the refusal names, or every one where it names none. */
|
|
193
|
+
const labelWrong = (locale: string) => (named.label.error && (named.label.locales?.includes(locale) ?? true) ? named.label.error : undefined)
|
|
194
|
+
const nameDescription = t('roles.name.description', 'The role\'s slug, which every assignment of it names, such as course-editor. People read the role by its label below.')
|
|
195
|
+
// Two cards, as a page's sections are drawn, so a role's page reads as the
|
|
196
|
+
// course pages do: the role's names, then its keys.
|
|
197
|
+
const section = (heading: string, body: ReactNode) => (
|
|
198
|
+
<Card className="rounded-lg">
|
|
199
|
+
<CardHeader>
|
|
200
|
+
<CardTitle>
|
|
201
|
+
<h2 className="text-base font-medium">{heading}</h2>
|
|
202
|
+
</CardTitle>
|
|
203
|
+
</CardHeader>
|
|
204
|
+
<CardContent className="grid gap-4">{body}</CardContent>
|
|
205
|
+
</Card>
|
|
206
|
+
)
|
|
207
|
+
return (
|
|
208
|
+
<div data-role-form="" className="grid gap-4">
|
|
209
|
+
{section(t('roles.singular', 'Role') ?? 'Role', (
|
|
210
|
+
<>
|
|
211
|
+
{/* The name is a slug, so the box says so and reads left to right. */}
|
|
212
|
+
<FieldShell id="roles-name" label={t('roles.name', 'Name') ?? 'Name'} description={nameDescription} error={named.name.error} onBlur={named.name.onBlur}>
|
|
213
|
+
<Input
|
|
214
|
+
id="roles-name"
|
|
215
|
+
dir="ltr"
|
|
216
|
+
aria-describedby={describedBy({ id: 'roles-name', description: nameDescription, error: named.name.error })}
|
|
217
|
+
aria-invalid={named.name.error ? true : undefined}
|
|
218
|
+
aria-required={required('name')}
|
|
219
|
+
value={String(values.name ?? '')}
|
|
220
|
+
readOnly={readOnly}
|
|
221
|
+
onChange={(event) => setValue('name', event.target.value)}
|
|
222
|
+
/>
|
|
223
|
+
</FieldShell>
|
|
224
|
+
{Object.keys(labelField?.label ?? {}).map((locale) => {
|
|
225
|
+
const id = `roles-label-${locale}`
|
|
226
|
+
const error = labelWrong(locale)
|
|
227
|
+
return (
|
|
228
|
+
<FieldShell key={locale} id={id} label={`${t('roles.label', 'Label')} (${t(`locale.${locale}`) ?? locale})`} error={error} onBlur={named.label.onBlur}>
|
|
229
|
+
<Input
|
|
230
|
+
id={id}
|
|
231
|
+
lang={locale}
|
|
232
|
+
dir="auto"
|
|
233
|
+
aria-describedby={describedBy({ id, error })}
|
|
234
|
+
aria-invalid={error ? true : undefined}
|
|
235
|
+
aria-required={required('label')}
|
|
236
|
+
value={label[locale] ?? ''}
|
|
237
|
+
readOnly={readOnly}
|
|
238
|
+
onChange={(event) => setValue('label', { ...label, [locale]: event.target.value })}
|
|
239
|
+
/>
|
|
240
|
+
</FieldShell>
|
|
241
|
+
)
|
|
242
|
+
})}
|
|
243
|
+
</>
|
|
244
|
+
))}
|
|
245
|
+
{section(t('roles.permissions', 'Permissions') ?? 'Permissions', (
|
|
246
|
+
<>
|
|
247
|
+
{/* One group for the badges, named by its section and pointing at its
|
|
248
|
+
refusal, so it marks itself as a box does. A group takes no
|
|
249
|
+
`aria-required`, so its name says it is required instead. */}
|
|
250
|
+
<div
|
|
251
|
+
role="group"
|
|
252
|
+
aria-label={t('dashboard.permissionsRequired', 'Permissions, required')}
|
|
253
|
+
aria-invalid={named.permissions.error ? true : undefined}
|
|
254
|
+
aria-describedby={named.permissions.error ? 'roles-permissions-error' : undefined}
|
|
255
|
+
>
|
|
256
|
+
<PermissionBadges held={permissions} busy={readOnly} onChange={(keys) => setValue('permissions', keys)} />
|
|
257
|
+
</div>
|
|
258
|
+
{named.permissions.error ? (
|
|
259
|
+
<p id="roles-permissions-error" role="alert" className="text-destructive text-sm">
|
|
260
|
+
{named.permissions.error}
|
|
261
|
+
</p>
|
|
262
|
+
) : null}
|
|
263
|
+
</>
|
|
264
|
+
))}
|
|
265
|
+
</div>
|
|
266
|
+
)
|
|
267
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// A fixed role is one this project ships, whose keys are code and never a row, so nothing here
|
|
2
|
+
// writes one.
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { Lock } from 'lucide-react'
|
|
5
|
+
import type { ButtonProps, CellProps, Row } from 'mithaq/admin'
|
|
6
|
+
import { useClient, useList, useLocale, useRecord, useScreen } from 'mithaq/admin/react'
|
|
7
|
+
import { tenantRoles } from '@asas/mithaq/roles/tenant-roles.ts'
|
|
8
|
+
import {
|
|
9
|
+
AlertDialog,
|
|
10
|
+
AlertDialogAction,
|
|
11
|
+
AlertDialogCancel,
|
|
12
|
+
AlertDialogContent,
|
|
13
|
+
AlertDialogDescription,
|
|
14
|
+
AlertDialogFooter,
|
|
15
|
+
AlertDialogHeader,
|
|
16
|
+
AlertDialogTitle,
|
|
17
|
+
} from '@/components/ui/alert-dialog'
|
|
18
|
+
import { Badge } from '@/components/ui/badge'
|
|
19
|
+
import { Button } from '@/components/ui/button'
|
|
20
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
|
21
|
+
import { Grants, PermissionBadges } from './role-form.tsx'
|
|
22
|
+
|
|
23
|
+
/** A role's label in the viewer's locale, and its slug only where a row carries none. */
|
|
24
|
+
export const labelOf = (row: Row | null | undefined, locale: string) => {
|
|
25
|
+
const label = row?.label as Record<string, string> | null | undefined
|
|
26
|
+
return label?.[locale] ?? String(row?.name ?? '')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The list node's component: the fixed roles first, from their code, each with a lock, then the
|
|
31
|
+
* organization's own. Each row names a role and counts what it grants, and opens to its keys and,
|
|
32
|
+
* for a custom role, a link to its page. It draws no row menu; a custom role's delete is its page's.
|
|
33
|
+
*/
|
|
34
|
+
export function RolesTable() {
|
|
35
|
+
const { page, creates, href, newHref } = useList()
|
|
36
|
+
const { locale, t } = useLocale()
|
|
37
|
+
return (
|
|
38
|
+
<div data-roles="" className="grid gap-4">
|
|
39
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
40
|
+
<p className="text-muted-foreground">{t('dashboard.rolesAbout', 'Fixed roles ship with the project and cannot be changed here. Custom roles are this organization\'s own.')}</p>
|
|
41
|
+
{creates ? (
|
|
42
|
+
<Button size="sm" className="max-sm:h-11" nativeButton={false} render={<a href={newHref} />}>
|
|
43
|
+
{t('dashboard.rolesNew', 'New role')}
|
|
44
|
+
</Button>
|
|
45
|
+
) : null}
|
|
46
|
+
</div>
|
|
47
|
+
<div className="overflow-hidden rounded-lg border">
|
|
48
|
+
<Table>
|
|
49
|
+
<TableHeader className="bg-muted/50">
|
|
50
|
+
<TableRow>
|
|
51
|
+
<TableHead scope="col">{t('roles.name', 'Name')}</TableHead>
|
|
52
|
+
<TableHead scope="col">{t('dashboard.rolesKind', 'Kind')}</TableHead>
|
|
53
|
+
</TableRow>
|
|
54
|
+
</TableHeader>
|
|
55
|
+
<TableBody>
|
|
56
|
+
{Object.entries(tenantRoles).map(([name, role]) => (
|
|
57
|
+
<TableRow key={name} data-row={name} data-fixed="true">
|
|
58
|
+
<TableCell className="whitespace-normal">
|
|
59
|
+
<Grants
|
|
60
|
+
data-fixed-role=""
|
|
61
|
+
label={
|
|
62
|
+
<span className="flex items-center gap-1.5 font-medium">
|
|
63
|
+
<Lock data-lock="" className="text-muted-foreground size-3.5" aria-hidden="true" />
|
|
64
|
+
<span>{(role.label as Readonly<Record<string, string>>)[locale] ?? name}</span>
|
|
65
|
+
</span>
|
|
66
|
+
}
|
|
67
|
+
held={role.permissions === '*' ? ['*'] : role.permissions}
|
|
68
|
+
>
|
|
69
|
+
<div className="grid gap-2">
|
|
70
|
+
<p className="text-muted-foreground text-sm">{t('dashboard.rolesFixedAbout', 'This role ships with the project. What it holds is code, so it cannot be edited or deleted here.')}</p>
|
|
71
|
+
<PermissionBadges held={role.permissions === '*' ? ['*'] : role.permissions} />
|
|
72
|
+
</div>
|
|
73
|
+
</Grants>
|
|
74
|
+
</TableCell>
|
|
75
|
+
<TableCell className="align-top">
|
|
76
|
+
<Badge variant="secondary">{t('dashboard.rolesFixed', 'Fixed')}</Badge>
|
|
77
|
+
</TableCell>
|
|
78
|
+
</TableRow>
|
|
79
|
+
))}
|
|
80
|
+
{page.docs.map((row) => {
|
|
81
|
+
const id = String(row.id)
|
|
82
|
+
const label = labelOf(row, locale)
|
|
83
|
+
const open = (
|
|
84
|
+
<a href={href(id)} className="text-primary justify-self-start text-sm underline underline-offset-4 max-sm:inline-flex max-sm:min-h-11 max-sm:items-center">
|
|
85
|
+
{(t('dashboard.rolesOpen', 'Open {role}') ?? 'Open {role}').replace('{role}', () => label)}
|
|
86
|
+
</a>
|
|
87
|
+
)
|
|
88
|
+
return (
|
|
89
|
+
<TableRow key={id} data-row={id} data-fixed="false">
|
|
90
|
+
<TableCell className="whitespace-normal">
|
|
91
|
+
{/* A role whose keys the viewer may not read comes without them, so it is named and linked and counts nothing. */}
|
|
92
|
+
{Array.isArray(row.permissions) ? (
|
|
93
|
+
<Grants label={<span className="font-medium">{label}</span>} held={row.permissions as string[]}>
|
|
94
|
+
<div className="grid gap-2">
|
|
95
|
+
{open}
|
|
96
|
+
<PermissionBadges held={row.permissions as string[]} />
|
|
97
|
+
</div>
|
|
98
|
+
</Grants>
|
|
99
|
+
) : (
|
|
100
|
+
<div className="grid gap-1">
|
|
101
|
+
<span className="font-medium">{label}</span>
|
|
102
|
+
{open}
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</TableCell>
|
|
106
|
+
<TableCell className="align-top">
|
|
107
|
+
<Badge variant="outline">{t('dashboard.rolesCustom', 'Custom')}</Badge>
|
|
108
|
+
</TableCell>
|
|
109
|
+
</TableRow>
|
|
110
|
+
)
|
|
111
|
+
})}
|
|
112
|
+
</TableBody>
|
|
113
|
+
</Table>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A role's keys where a page reads them as a value: grouped and named as a fixed role's page draws
|
|
121
|
+
* them, never a run of slugs.
|
|
122
|
+
*/
|
|
123
|
+
export function Permissions({ value }: Pick<CellProps, 'value'>) {
|
|
124
|
+
return <PermissionBadges held={Array.isArray(value) ? (value as string[]) : []} />
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A page of rows as the client answers it, read untyped since a feature never imports the asas it is built into. */
|
|
128
|
+
type Found = { docs?: Record<string, unknown>[] } | undefined
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A custom role's delete, the danger zone's button. A role any member holds is not deleted, so the
|
|
132
|
+
* button reads who holds it first: while anyone does, the dialog names them, each linking to their
|
|
133
|
+
* roles on the Members page, and offers no delete. A holder list the viewer may not read asks as any
|
|
134
|
+
* delete does, and the server refuses it there.
|
|
135
|
+
*/
|
|
136
|
+
export function DeleteRole({ run, busy }: ButtonProps) {
|
|
137
|
+
const record = useRecord()
|
|
138
|
+
const client = useClient()
|
|
139
|
+
// Read at the build's most rows one served read answers, since a page past
|
|
140
|
+
// it is a 400 the catch below would read as no holders at all.
|
|
141
|
+
const { path, maxLimit } = useScreen()
|
|
142
|
+
const { t } = useLocale()
|
|
143
|
+
const [open, setOpen] = useState(false)
|
|
144
|
+
const [holders, setHolders] = useState<{ id: string; name: string }[]>([])
|
|
145
|
+
const ask = async () => {
|
|
146
|
+
const held = await client({ kayan: 'memberRoles', operation: 'find', input: { where: { role: { equals: record?.name as string } }, limit: maxLimit, depth: 0 } }).catch(() => undefined) as Found
|
|
147
|
+
const ids = held?.docs?.map((row) => String(row.user)) ?? []
|
|
148
|
+
const named = ids.length ? await client({ kayan: 'user', operation: 'find', input: { where: { id: { in: ids } }, limit: maxLimit, depth: 0 } }).catch(() => undefined) as Found : undefined
|
|
149
|
+
// A holder whose name the viewer may not read is "a member", never a raw id, and the link still
|
|
150
|
+
// opens their roles.
|
|
151
|
+
setHolders(ids.map((id) => ({ id, name: (named?.docs?.find((one) => one.id === id)?.name as string | undefined) ?? t('dashboard.rolesHolderUnnamed', 'A member') ?? '' })))
|
|
152
|
+
setOpen(true)
|
|
153
|
+
}
|
|
154
|
+
return (
|
|
155
|
+
<AlertDialog open={open} onOpenChange={setOpen}>
|
|
156
|
+
<Button type="button" variant="destructive" data-button="delete" disabled={busy} onClick={() => void ask()}>
|
|
157
|
+
{t('admin.delete', 'Delete')}
|
|
158
|
+
</Button>
|
|
159
|
+
<AlertDialogContent>
|
|
160
|
+
<AlertDialogHeader>
|
|
161
|
+
<AlertDialogTitle>{holders.length ? t('dashboard.rolesHeldTitle', 'Members hold this role') : t('dashboard.rolesDeleteTitle', 'Delete this role?')}</AlertDialogTitle>
|
|
162
|
+
<AlertDialogDescription data-held={holders.length}>
|
|
163
|
+
{holders.length
|
|
164
|
+
? t('dashboard.rolesHeld', 'A role a member holds is not deleted. Take it from each of them on the Members page first.')
|
|
165
|
+
: t('admin.deleteWarning', 'This cannot be undone.')}
|
|
166
|
+
</AlertDialogDescription>
|
|
167
|
+
</AlertDialogHeader>
|
|
168
|
+
{holders.length ? (
|
|
169
|
+
<ul data-holders="" className="grid gap-1 text-sm">
|
|
170
|
+
{holders.map((one) => (
|
|
171
|
+
<li key={one.id}>
|
|
172
|
+
<a href={`${path}/members?member=${encodeURIComponent(one.id)}`} className="underline underline-offset-4">
|
|
173
|
+
{one.name}
|
|
174
|
+
</a>
|
|
175
|
+
</li>
|
|
176
|
+
))}
|
|
177
|
+
</ul>
|
|
178
|
+
) : null}
|
|
179
|
+
<AlertDialogFooter>
|
|
180
|
+
<AlertDialogCancel>{t('admin.cancel', 'Cancel')}</AlertDialogCancel>
|
|
181
|
+
{holders.length ? null : (
|
|
182
|
+
<AlertDialogAction variant="destructive" disabled={busy} onClick={() => void run({})}>
|
|
183
|
+
{t('roles.delete.confirm', 'Delete for good')}
|
|
184
|
+
</AlertDialogAction>
|
|
185
|
+
)}
|
|
186
|
+
</AlertDialogFooter>
|
|
187
|
+
</AlertDialogContent>
|
|
188
|
+
</AlertDialog>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { defaultFields, defaultGlobalFields, defaultGlobalOperations } from 'mithaq'
|
|
2
|
+
import { defineGlobal, organization, rows } from '@asas/mithaq'
|
|
3
|
+
import { backofficeClosed, tenantAccess } from '@asas/mithaq/roles/tenant-access.ts'
|
|
4
|
+
import { appPage } from '@asas/mithaq/mithaq/pages.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The project's settings, one row per tenant, because this global places
|
|
8
|
+
* `organization`. `asas.global.settings.operations.findOne({}, { actor })`
|
|
9
|
+
* answers the active organization's row or null, and
|
|
10
|
+
* `asas.global.settings.operations.update(data, { actor })` writes it, making it
|
|
11
|
+
* on the first call.
|
|
12
|
+
*
|
|
13
|
+
* One row per tenant is two lines here, because nothing rewrites a global's
|
|
14
|
+
* fields: the organization is unique, since it is the column that keeps the row
|
|
15
|
+
* single, and `globalType` is not unique, since every tenant's row carries the
|
|
16
|
+
* same name. `buildAsas` refuses either written the other way.
|
|
17
|
+
*/
|
|
18
|
+
export const settings = defineGlobal({
|
|
19
|
+
name: 'settings',
|
|
20
|
+
fields: {
|
|
21
|
+
...defaultFields,
|
|
22
|
+
organization: { ...organization, rules: { ...organization.rules, unique: true } },
|
|
23
|
+
// The site has no name of its own: it is the organization's, in every locale.
|
|
24
|
+
maintenance: {
|
|
25
|
+
kind: 'checkbox',
|
|
26
|
+
label: { en: 'Maintenance', ar: 'الصيانة' },
|
|
27
|
+
description: {
|
|
28
|
+
en: 'While this is on, the portal tells students it is closed for maintenance. Staff keep working.',
|
|
29
|
+
ar: 'ما دام هذا مفعلا، تخبر البوابة الطلاب بأنها مغلقة للصيانة. ويواصل الموظفون عملهم.',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
// Where a student writes to, the one address the portal shows: at its foot, and on its maintenance page.
|
|
33
|
+
contactEmail: { kind: 'email', label: { en: 'Contact email', ar: 'بريد التواصل' } },
|
|
34
|
+
// The column default is the one the table was made with, so the committed
|
|
35
|
+
// migrations still describe it.
|
|
36
|
+
globalType: { ...defaultGlobalFields.globalType, rules: { required: true, defaultValue: 'settings' } },
|
|
37
|
+
},
|
|
38
|
+
// Its own rows: the language's, and every backoffice role closed. The backoffice
|
|
39
|
+
// lists no global, since a list across tenants reads by `find` and a global
|
|
40
|
+
// declares none, so it reads none, 403 `error.forbidden.read`. A global takes
|
|
41
|
+
// read and update alone, and the other two the rows compile are never reached.
|
|
42
|
+
access: rows({ ...tenantAccess, ...backofficeClosed }) as Pick<ReturnType<typeof rows>, 'read' | 'update'>,
|
|
43
|
+
targets: {
|
|
44
|
+
service: { operations: defaultGlobalOperations },
|
|
45
|
+
// A global is one row, so its page is a custom one with no id: its form
|
|
46
|
+
// updates the row there is. Its fields in sections under its sentence, and the
|
|
47
|
+
// yes or no setting a switch with its label beside it.
|
|
48
|
+
dashboard: {
|
|
49
|
+
description: {
|
|
50
|
+
en: 'Where students reach the school, and whether the portal is open to them.',
|
|
51
|
+
ar: 'أين يتواصل الطلاب مع المدرسة، وهل البوابة مفتوحة لهم.',
|
|
52
|
+
},
|
|
53
|
+
pages: {
|
|
54
|
+
settings: {
|
|
55
|
+
path: '/settings',
|
|
56
|
+
page: appPage([{ layout: 'record', main: [{ form: [
|
|
57
|
+
{ section: 'contact', label: { en: 'Contact', ar: 'التواصل' }, fields: ['contactEmail'] },
|
|
58
|
+
{ section: 'availability', label: { en: 'Availability', ar: 'الإتاحة' }, fields: [{ field: 'maintenance', component: 'switch' }] },
|
|
59
|
+
] }] }]),
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { defaultFields } from 'mithaq'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { defineKayan, organization } from '@asas/mithaq'
|
|
4
|
+
import { appPage, danger, backofficeList, backofficeRecord } from '@asas/mithaq/mithaq/pages.ts'
|
|
5
|
+
import { expectedUpdatedAt } from '@asas/mithaq/steps/expected-updated-at.ts'
|
|
6
|
+
|
|
7
|
+
const tag = [{ section: 'tag', label: { en: 'Tag', ar: 'الوسم' }, fields: ['name'] }] as const
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A label a course may carry any number of, and which any number of courses may
|
|
11
|
+
* carry: the other end of `courses.tags`, the one many-to-many this template
|
|
12
|
+
* declares. It places `organization`, so a tag is its organization's own and
|
|
13
|
+
* the runner refuses a course pointing at another tenant's tag.
|
|
14
|
+
*/
|
|
15
|
+
export const tags = defineKayan({
|
|
16
|
+
name: 'tags',
|
|
17
|
+
messages: {
|
|
18
|
+
singular: { en: 'Tag', ar: 'وسم' },
|
|
19
|
+
plural: { en: 'Tags', ar: 'الوسوم' },
|
|
20
|
+
},
|
|
21
|
+
// What the picker on `courses.tags` shows for each row, and what its search
|
|
22
|
+
// runs a `like` over. Without it every tag is a uuid.
|
|
23
|
+
targets: {
|
|
24
|
+
// Every organization's rows on the backoffice, read-only, and each row's
|
|
25
|
+
// record, which only reads.
|
|
26
|
+
backoffice: {
|
|
27
|
+
useAsTitle: 'name',
|
|
28
|
+
pages: {
|
|
29
|
+
...backofficeList('/tags', { columns: ['name', 'organization', 'createdAt'], filters: ['organization'], search: ['name'] }),
|
|
30
|
+
...backofficeRecord({ path: '/tags/:id', main: [{ section: 'tag', label: { en: 'Tag', ar: 'الوسم' }, fields: ['name'] }] }),
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
// A create opens as a sheet over the list. The name is copy a screen shows
|
|
34
|
+
// as a label, so it is one string per locale, and every screen reads it in
|
|
35
|
+
// the viewer's.
|
|
36
|
+
dashboard: {
|
|
37
|
+
useAsTitle: 'name',
|
|
38
|
+
description: {
|
|
39
|
+
en: 'Labels the organization groups its records by.',
|
|
40
|
+
ar: 'تسميات تجمع بها المؤسسة سجلاتها.',
|
|
41
|
+
},
|
|
42
|
+
pages: {
|
|
43
|
+
list: { path: '/tags', page: appPage([{ layout: 'table', main: [{ list: { columns: ['name', 'createdAt'], search: ['name'] } }] }]) },
|
|
44
|
+
// The drawer's title says it makes a tag, so its one box is drawn
|
|
45
|
+
// bare rather than under a second heading that says "Tag" again.
|
|
46
|
+
create: { path: '/tags/new', mode: 'drawer', page: appPage([{ layout: 'record', main: [{ form: ['name'] }] }]) },
|
|
47
|
+
edit: { path: '/tags/:id', page: appPage([{ layout: 'record', main: [{ form: tag }], danger: danger() }]) },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
service: {
|
|
51
|
+
operations: ({ defaults }) => ({
|
|
52
|
+
...defaults,
|
|
53
|
+
/**
|
|
54
|
+
* The shipped update list with the `expectedUpdatedAt` step before it and
|
|
55
|
+
* its input widened by the key the step reads. A caller who sends the
|
|
56
|
+
* instant it read the tag at is refused 409 when the tag has changed
|
|
57
|
+
* since. The dashboard's edit form sends it, because the screen host
|
|
58
|
+
* reads that this input takes the key, so a second tab's stale save is
|
|
59
|
+
* the 409. The key stays optional, so a caller that sends none, a bulk
|
|
60
|
+
* update among them, saves as before.
|
|
61
|
+
*/
|
|
62
|
+
update: {
|
|
63
|
+
input: ({ update }) => update.extend({ expectedUpdatedAt: z.date().optional() }),
|
|
64
|
+
steps: ({ steps }) => [expectedUpdatedAt, steps.update],
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
fields: {
|
|
70
|
+
...defaultFields,
|
|
71
|
+
organization,
|
|
72
|
+
name: {
|
|
73
|
+
kind: 'text',
|
|
74
|
+
label: { en: 'Name', ar: 'الاسم' },
|
|
75
|
+
// Each locale trimmed before it is judged, so a name of spaces is refused
|
|
76
|
+
// as required.
|
|
77
|
+
rules: { localized: true, required: true, trim: true, maxLength: 60, index: true },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
})
|