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,143 @@
|
|
|
1
|
+
// What the `media` kayan draws of its own files: the image beside its edit
|
|
2
|
+
// form, the list's thumbnail and size cells, and the page a new image is made
|
|
3
|
+
// on. Each reads the row's computed `url` and `thumbnailURL`, so a file is
|
|
4
|
+
// fetched through the file route and the row's access decides. A new file is
|
|
5
|
+
// made in the registry's upload dialog, which sends the bytes before the row
|
|
6
|
+
// is made.
|
|
7
|
+
import { useRef, useState } from 'react'
|
|
8
|
+
import { Download, Upload } from 'lucide-react'
|
|
9
|
+
import type { CellProps, ComponentProps, Row } from 'mithaq/admin'
|
|
10
|
+
import { useAccess, useClient, useLocale, useRecord, useScreen } from 'mithaq/admin/react'
|
|
11
|
+
import { locales } from '@/copy.ts'
|
|
12
|
+
import { Button, buttonVariants } from '@/components/ui/button'
|
|
13
|
+
import { FileThumb, FileValue, UploadDialog, sizeOf } from 'mithaq-admin/upload.tsx'
|
|
14
|
+
import { AppShell } from '@asas/mithaq/ui/app-shell.tsx'
|
|
15
|
+
|
|
16
|
+
/** The `filesize` column: the size as a person reads it, never the raw bytes, in the upload dialog's own units. */
|
|
17
|
+
export function FileSize({ value }: CellProps) {
|
|
18
|
+
const { locale } = useLocale()
|
|
19
|
+
return typeof value === 'number' ? <span className="whitespace-nowrap">{sizeOf(value, locale)}</span> : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The `filename` column: the image's thumbnail before its name. The name
|
|
24
|
+
* keeps at least seven rem and wraps onto a second line before it is cut,
|
|
25
|
+
* between words where it has them and anywhere only where it must, so
|
|
26
|
+
* on a phone the table gives the column room for it rather than squeezing it
|
|
27
|
+
* to "co…" beside the thumbnail.
|
|
28
|
+
*/
|
|
29
|
+
export function FileName({ value, row }: CellProps) {
|
|
30
|
+
const { t, locale } = useLocale()
|
|
31
|
+
const name = String(value ?? '')
|
|
32
|
+
return (
|
|
33
|
+
<span className="inline-flex min-w-0 items-center gap-2">
|
|
34
|
+
<FileThumb row={row} t={t} locale={locale} size="xs" />
|
|
35
|
+
<span className="line-clamp-2 min-w-28 [overflow-wrap:anywhere]" dir="auto" title={name}>{name}</span>
|
|
36
|
+
</span>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const useEditHref = () => {
|
|
41
|
+
const { path, kayan } = useScreen()
|
|
42
|
+
return (id: unknown) => `${path}${kayan.media?.pages.edit?.path.replace(':id', encodeURIComponent(String(id)))}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The upload dialog over this kayan, which opens the new image's page once it is made. */
|
|
46
|
+
function NewImageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
|
47
|
+
const { t, locale } = useLocale()
|
|
48
|
+
const { kayan } = useScreen()
|
|
49
|
+
const client = useClient()
|
|
50
|
+
const editHref = useEditHref()
|
|
51
|
+
// The dialog closes itself once the row is made, and that close is no
|
|
52
|
+
// Cancel: the new image's page is already opening.
|
|
53
|
+
const done = useRef(false)
|
|
54
|
+
return kayan.media ? (
|
|
55
|
+
<UploadDialog
|
|
56
|
+
open={open}
|
|
57
|
+
onOpenChange={(next) => done.current || onOpenChange(next)}
|
|
58
|
+
target={kayan.media}
|
|
59
|
+
client={client}
|
|
60
|
+
t={t}
|
|
61
|
+
locale={locale}
|
|
62
|
+
locales={locales}
|
|
63
|
+
onDone={(row: Row) => {
|
|
64
|
+
done.current = true
|
|
65
|
+
window.location.assign(editHref(row.id))
|
|
66
|
+
}}
|
|
67
|
+
/>
|
|
68
|
+
) : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The image beside its edit form, and above it on a phone, where the record
|
|
73
|
+
* layout puts a side marked `data-lead` first: the image itself, its size, a
|
|
74
|
+
* link that downloads it, and Replace file. A file never changes once it is
|
|
75
|
+
* made, so Replace file uploads a new image and opens it, and this one stays,
|
|
76
|
+
* with whatever points at it, until it is deleted.
|
|
77
|
+
*/
|
|
78
|
+
export function MediaPreview() {
|
|
79
|
+
const { t, locale } = useLocale()
|
|
80
|
+
const access = useAccess()
|
|
81
|
+
const row = useRecord()
|
|
82
|
+
const [replacing, setReplacing] = useState(false)
|
|
83
|
+
if (!row) return null
|
|
84
|
+
const creates = access.collections?.media?.create === true
|
|
85
|
+
const url = typeof row.url === 'string' ? row.url : undefined
|
|
86
|
+
return (
|
|
87
|
+
<div data-media-preview="" data-lead="" className="grid justify-items-start gap-3">
|
|
88
|
+
<FileValue row={row} t={t} locale={locale} />
|
|
89
|
+
{typeof row.filesize === 'number' ? <p className="text-muted-foreground text-sm">{sizeOf(row.filesize, locale)}</p> : null}
|
|
90
|
+
<div className="flex flex-wrap gap-2">
|
|
91
|
+
{url ? (
|
|
92
|
+
<a href={url} download={typeof row.filename === 'string' ? row.filename : ''} data-media-download="" className={buttonVariants({ variant: 'outline', size: 'sm' })}>
|
|
93
|
+
<Download className="size-4" aria-hidden />
|
|
94
|
+
{t('media.download', 'Download')}
|
|
95
|
+
</a>
|
|
96
|
+
) : null}
|
|
97
|
+
{creates ? (
|
|
98
|
+
<Button type="button" variant="outline" size="sm" data-media-replace="" onClick={() => setReplacing(true)}>
|
|
99
|
+
<Upload className="size-4" aria-hidden />
|
|
100
|
+
{t('media.replace', 'Replace file')}
|
|
101
|
+
</Button>
|
|
102
|
+
) : null}
|
|
103
|
+
</div>
|
|
104
|
+
{creates ? <p className="text-muted-foreground text-xs">{t('media.replaceAbout', 'Replace file uploads a new image and opens it. This one stays until you delete it.')}</p> : null}
|
|
105
|
+
<NewImageDialog open={replacing} onOpenChange={setReplacing} />
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The New page, `/media/new`, which the list's New button opens: the upload
|
|
112
|
+
* dialog, open at once, and the page under it, which opens it again. Closing
|
|
113
|
+
* it goes back to the list.
|
|
114
|
+
*/
|
|
115
|
+
export function NewImage(_props: ComponentProps) {
|
|
116
|
+
const { t } = useLocale()
|
|
117
|
+
const { path, kayan } = useScreen()
|
|
118
|
+
const [open, setOpen] = useState(true)
|
|
119
|
+
const list = `${path}${kayan.media?.pages.list?.path ?? ''}`
|
|
120
|
+
return (
|
|
121
|
+
<AppShell
|
|
122
|
+
regions={{
|
|
123
|
+
header: <h1>{t('media.new', 'New image')}</h1>,
|
|
124
|
+
main: (
|
|
125
|
+
<div data-media-new="" className="grid justify-items-start gap-4">
|
|
126
|
+
<p className="text-muted-foreground">{t('media.newAbout', 'Choose an image and describe it. It is uploaded before it is saved.')}</p>
|
|
127
|
+
<Button type="button" onClick={() => setOpen(true)}>
|
|
128
|
+
<Upload className="size-4" aria-hidden />
|
|
129
|
+
{t('admin.upload.new', 'Upload new')}
|
|
130
|
+
</Button>
|
|
131
|
+
<NewImageDialog
|
|
132
|
+
open={open}
|
|
133
|
+
onOpenChange={(next) => {
|
|
134
|
+
setOpen(next)
|
|
135
|
+
if (!next) window.location.assign(list)
|
|
136
|
+
}}
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
),
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@asas/media",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.ts"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@asas/mithaq": "*",
|
|
11
|
+
"mithaq": "*",
|
|
12
|
+
"mithaq-admin": "*"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/react": "^19.3.0",
|
|
16
|
+
"lucide-react": "^1.16.0",
|
|
17
|
+
"react": "^19.3.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { sql } from 'mithaq/drizzle'
|
|
2
|
+
import type { NodePgDatabase } from 'mithaq/drizzle/node-postgres'
|
|
3
|
+
import { assignableRole, conflict, defaultFields, defaultOperations, denied, type Ctx, type WriteTools } from 'mithaq'
|
|
4
|
+
import { defineKayan, backofficeUi, rows, organization } from '@asas/mithaq'
|
|
5
|
+
import { backofficeList, backofficeRecord } from '@asas/mithaq/mithaq/pages.ts'
|
|
6
|
+
import { tenantRoles } from '@asas/mithaq/roles/system-roles.ts'
|
|
7
|
+
import { backofficeReads } from '@asas/mithaq/roles/tenant-access.ts'
|
|
8
|
+
import { managesRoles, OWNER, OWNER_ROLE } from '@asas/mithaq/roles/grants.ts'
|
|
9
|
+
|
|
10
|
+
const role = { field: 'role', cell: backofficeUi('role-cells.tsx#RoleName') } as const
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Nobody takes from themselves the role that lets them manage roles: their own
|
|
14
|
+
* `administrator`, or any role holding a key that manages roles. Another member
|
|
15
|
+
* holding the same keys removes it.
|
|
16
|
+
*
|
|
17
|
+
* A custom role's keys are read as the actor, through the role kayan's own
|
|
18
|
+
* access, so a role the actor may not read is refused rather than taken off
|
|
19
|
+
* unchecked. It guards a member's own row; two administrators removing each
|
|
20
|
+
* other at once are `managerRemains`' to refuse.
|
|
21
|
+
*/
|
|
22
|
+
const keepsOwnManager = async (ctx: Ctx, tools: WriteTools): Promise<Ctx> => {
|
|
23
|
+
const before = ctx.dataBefore
|
|
24
|
+
const me = (ctx.actor as { id?: unknown } | undefined)?.id
|
|
25
|
+
if (!before || me === undefined || before.user !== me) return ctx
|
|
26
|
+
const after = { ...before, ...(ctx.data as Record<string, unknown>) }
|
|
27
|
+
if (tools.kind === 'update' && after.user === before.user && after.role === before.role) return ctx
|
|
28
|
+
const role = String(before.role)
|
|
29
|
+
const roles = (tools.kayans as { roles: { find(input: object): Promise<{ docs: { permissions?: unknown }[] }> } }).roles
|
|
30
|
+
const keys = Object.hasOwn(tenantRoles, role)
|
|
31
|
+
? tenantRoles[role as keyof typeof tenantRoles].permissions
|
|
32
|
+
: (await roles.find({ where: { name: { equals: role } } })).docs.flatMap((row) => (Array.isArray(row.permissions) ? (row.permissions as string[]) : []))
|
|
33
|
+
if (!managesRoles(keys)) return ctx
|
|
34
|
+
throw denied({
|
|
35
|
+
message: `Forbidden: role "${role}" is what lets this member manage roles, so it is not taken from themselves. Another member who may manage roles removes it.`,
|
|
36
|
+
key: 'error.forbidden.own_role',
|
|
37
|
+
params: { kayan: ctx.kayan.name, role },
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The write's transaction, which is drizzle's here. */
|
|
42
|
+
const txOf = (ctx: Ctx) => ctx.tx as NodePgDatabase
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How many assignments of one tenant name one role, counted inside the write's
|
|
46
|
+
* own transaction rather than read as the actor: a number leaves this function
|
|
47
|
+
* and no row does, so a role's author needs no `memberRoles` key to learn
|
|
48
|
+
* whether a name is still carried.
|
|
49
|
+
*/
|
|
50
|
+
export const assignedCount = async ({ ctx, tenantId, role }: { ctx: Ctx; tenantId: unknown; role: string }): Promise<number> => {
|
|
51
|
+
const [found] = (
|
|
52
|
+
await txOf(ctx).execute<{ total: number }>(sql`select count(*)::int as total from member_roles where organization_id = ${String(tenantId)} and role = ${role}`)
|
|
53
|
+
).rows
|
|
54
|
+
return found?.total ?? 0
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What a write changes about who manages roles: an assignment removed, or
|
|
59
|
+
* moved to another member or role, or a custom role's keys, which are `[]`
|
|
60
|
+
* when it is deleted.
|
|
61
|
+
*/
|
|
62
|
+
type Change = { assignment: Readonly<Record<string, unknown>>; becomes?: { user: unknown; role: unknown } } | { role: string; keys: readonly string[] }
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* **Every tenant keeps a member who may manage roles**: one holding a role whose
|
|
66
|
+
* keys `managesRoles` answers for, or the organization's owner, who always holds
|
|
67
|
+
* `administrator`. A write that would leave none is refused 409
|
|
68
|
+
* `error.conflict.last_manager`. Removing the owner's own `administrator` row
|
|
69
|
+
* changes nothing the owner holds, so it is refused too, 403
|
|
70
|
+
* `error.forbidden.owner_role`.
|
|
71
|
+
*
|
|
72
|
+
* The check runs under `pg_advisory_xact_lock` on the tenant, held to the end of
|
|
73
|
+
* the write's transaction, and every guarded write takes the same lock first, so
|
|
74
|
+
* two administrators removing each other at once are serialized and the second
|
|
75
|
+
* reads the first's removal. `FOR UPDATE` on the manager assignments would not
|
|
76
|
+
* do: which assignments manage roles also depends on the roles table, and a role
|
|
77
|
+
* losing its keys touches no assignment row.
|
|
78
|
+
*
|
|
79
|
+
* Rows are read through the store rather than as the actor, because the answer
|
|
80
|
+
* is about every member of the tenant and the actor may read few of them;
|
|
81
|
+
* nothing read leaves this function but the refusal.
|
|
82
|
+
*/
|
|
83
|
+
export const managerRemains = async ({ ctx, tenantId, change }: { ctx: Ctx; tenantId: unknown; change: Change }): Promise<void> => {
|
|
84
|
+
const tx = txOf(ctx)
|
|
85
|
+
const tenant = String(tenantId)
|
|
86
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('mithaq.managers'), hashtext(${tenant}))`)
|
|
87
|
+
const owners = (
|
|
88
|
+
await tx.execute<{ user: string }>(
|
|
89
|
+
sql`select user_id as "user" from member where organization_id = ${tenant} and ${OWNER} = any(string_to_array(role, ','))`,
|
|
90
|
+
)
|
|
91
|
+
).rows.map((row) => row.user)
|
|
92
|
+
if ('assignment' in change) {
|
|
93
|
+
const { assignment, becomes } = change
|
|
94
|
+
if (assignment.role === OWNER_ROLE && owners.includes(String(assignment.user)) && (becomes?.role !== assignment.role || becomes.user !== assignment.user)) {
|
|
95
|
+
throw denied({
|
|
96
|
+
message: `Forbidden: the organization's owner always holds "${OWNER_ROLE}", so the row saying so is not taken from them.`,
|
|
97
|
+
key: 'error.forbidden.owner_role',
|
|
98
|
+
params: { kayan: ctx.kayan.name, role: OWNER_ROLE },
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (owners.length) return
|
|
103
|
+
const assigned = (await tx.execute<{ id: string; user: string; role: string }>(sql`select id, user_id as "user", role from member_roles where organization_id = ${tenant}`)).rows
|
|
104
|
+
const custom = new Map<string, readonly string[]>(
|
|
105
|
+
(await tx.execute<{ name: string; permissions: string[] }>(sql`select name, permissions from roles where organization_id = ${tenant}`)).rows.map((row) => [row.name, row.permissions] as const),
|
|
106
|
+
)
|
|
107
|
+
if ('keys' in change) custom.set(change.role, change.keys)
|
|
108
|
+
const keysOf = (role: string) => (Object.hasOwn(tenantRoles, role) ? tenantRoles[role as keyof typeof tenantRoles].permissions : (custom.get(role) ?? []))
|
|
109
|
+
const after = assigned.flatMap((row) => {
|
|
110
|
+
if (!('assignment' in change) || row.id !== change.assignment.id) return [row]
|
|
111
|
+
return change.becomes ? [{ ...row, role: String(change.becomes.role) }] : []
|
|
112
|
+
})
|
|
113
|
+
if (after.some((row) => managesRoles(keysOf(row.role)))) return
|
|
114
|
+
throw conflict({
|
|
115
|
+
message: `This write would leave the tenant with no member who may manage roles, so it is refused. Give another member such a role first.`,
|
|
116
|
+
key: 'error.conflict.last_manager',
|
|
117
|
+
params: { kayan: ctx.kayan.name },
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `managerRemains` for an assignment's update or delete; an update that neither moves the row nor changes its role changes nothing. */
|
|
122
|
+
const keepsAManager = async (ctx: Ctx, tools: WriteTools): Promise<Ctx> => {
|
|
123
|
+
const before = ctx.dataBefore
|
|
124
|
+
if (!before) return ctx
|
|
125
|
+
const after = tools.kind === 'update' ? { ...before, ...(ctx.data as Record<string, unknown>) } : undefined
|
|
126
|
+
if (after && after.user === before.user && after.role === before.role) return ctx
|
|
127
|
+
await managerRemains({ ctx, tenantId: before.organization, change: { assignment: before, ...(after && { becomes: { user: after.user, role: after.role } }) } })
|
|
128
|
+
return ctx
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Who holds which of this project's roles, per tenant: one row per member per
|
|
133
|
+
* role. It exists so that Better Auth's own `member.role` never carries a
|
|
134
|
+
* business role name beside its governance one: a role name holding a comma would
|
|
135
|
+
* be accepted by `create-role` and could never be assigned again, because that
|
|
136
|
+
* column is comma-delimited.
|
|
137
|
+
*
|
|
138
|
+
* The access target reads it straight from the store while the actor is being
|
|
139
|
+
* made, filtered on this tenant and this member, so another tenant's row is
|
|
140
|
+
* never in what comes back.
|
|
141
|
+
*/
|
|
142
|
+
export const memberRoles = defineKayan({
|
|
143
|
+
name: 'memberRoles',
|
|
144
|
+
dbName: 'member_roles',
|
|
145
|
+
targets: {
|
|
146
|
+
// Every organization's rows on the backoffice, read-only, and each row's
|
|
147
|
+
// record. An assignment is looked for by its person, is titled by them, and
|
|
148
|
+
// reads its role by the role's label, never its slug.
|
|
149
|
+
backoffice: {
|
|
150
|
+
useAsTitle: 'user',
|
|
151
|
+
pages: {
|
|
152
|
+
...backofficeList('/member-roles', { columns: ['user', role, 'organization'], filters: ['organization', 'user', 'role'], search: ['user.name', 'user.email'] }),
|
|
153
|
+
...backofficeRecord({ path: '/member-roles/:id', main: [{ section: 'assignment', label: { en: 'Assignment', ar: 'الإسناد' }, fields: ['user', role] }] }),
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
// No dashboard page and so no nav entry: a member's roles are changed on the
|
|
157
|
+
// Members page's sheet, which writes these rows through the operations below,
|
|
158
|
+
// guards and all.
|
|
159
|
+
service: {
|
|
160
|
+
operations: {
|
|
161
|
+
...defaultOperations,
|
|
162
|
+
// The gate in front of the store step: the runner has parsed by then, so
|
|
163
|
+
// the column is a string, and the write has not happened, so a refused
|
|
164
|
+
// assignment leaves no row. Without it a custom role holding
|
|
165
|
+
// `memberRoles.create` assigns itself `administrator`. It is handed the
|
|
166
|
+
// tenant roles alone, so a backoffice role such as `support` reads as an
|
|
167
|
+
// unknown custom role and is refused 400. `'roles'` is the kayan it reads
|
|
168
|
+
// the tenant's custom roles from.
|
|
169
|
+
create: { steps: ({ steps }) => [assignableRole(tenantRoles, 'roles'), steps.insert] },
|
|
170
|
+
update: { steps: ({ steps }) => [assignableRole(tenantRoles, 'roles'), keepsOwnManager, keepsAManager, steps.update] },
|
|
171
|
+
// Removing an assignment is measured the way handing it out is. Without it
|
|
172
|
+
// an actor delegated `memberRoles.delete` removes the tenant's only
|
|
173
|
+
// administrator row. The guard reads the row the store holds, because a
|
|
174
|
+
// delete names no column.
|
|
175
|
+
delete: { steps: ({ steps }) => [assignableRole(tenantRoles, 'roles'), keepsOwnManager, keepsAManager, steps.remove] },
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
// Its own rows, since the inherited table would close the instructor's
|
|
180
|
+
// delete here. Every backoffice role reads the assignments of every tenant in
|
|
181
|
+
// the backoffice app and changes none; in the tenant app `assignedRoles` omits
|
|
182
|
+
// the backoffice slugs.
|
|
183
|
+
access: rows({
|
|
184
|
+
'*': { create: true, read: true, update: true, delete: true },
|
|
185
|
+
...backofficeReads,
|
|
186
|
+
}),
|
|
187
|
+
/** A member holds a role once per tenant. A second assignment answers 409 `error.conflict.unique`. */
|
|
188
|
+
indexes: [{ within: 'organization', unique: [['user', 'role']] }],
|
|
189
|
+
fields: {
|
|
190
|
+
...defaultFields,
|
|
191
|
+
organization,
|
|
192
|
+
user: {
|
|
193
|
+
kind: 'relationship',
|
|
194
|
+
label: { ar: 'العضو', en: 'Member' },
|
|
195
|
+
relationTo: 'user',
|
|
196
|
+
// The assignment goes with the member: a deleted user holds no role, and
|
|
197
|
+
// `restrict`, which a required relationship takes by default, would leave a
|
|
198
|
+
// row nothing can reach standing in the way of the delete.
|
|
199
|
+
rules: { required: true, index: true, onDelete: 'cascade' },
|
|
200
|
+
},
|
|
201
|
+
role: {
|
|
202
|
+
kind: 'text',
|
|
203
|
+
label: { ar: 'الدور', en: 'Role' },
|
|
204
|
+
rules: { required: true, maxLength: 60, index: true },
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@asas/roles",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.ts",
|
|
8
|
+
"./roles/ui/*": "./roles/ui/*"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@asas/auth": "*",
|
|
12
|
+
"@asas/mithaq": "*",
|
|
13
|
+
"mithaq": "*",
|
|
14
|
+
"mithaq-admin": "*"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/react": "^19.3.0",
|
|
18
|
+
"lucide-react": "^1.16.0",
|
|
19
|
+
"react": "^19.3.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { actorRoles, conflict, defaultFields, denied, folderOf, grantedPermissions, writableRoleName, defaultOperations, type Ctx, type WriteTools } from 'mithaq'
|
|
2
|
+
import { user as users } from '@asas/auth'
|
|
3
|
+
import { assignedCount, managerRemains, memberRoles } from '../member-roles/member-roles.ts'
|
|
4
|
+
import { managesRoles } from '@asas/mithaq/roles/grants.ts'
|
|
5
|
+
import { defineKayan, rows, organization } from '@asas/mithaq'
|
|
6
|
+
import { appPage, backofficeList, backofficeRecord } from '@asas/mithaq/mithaq/pages.ts'
|
|
7
|
+
import { systemRoles } from '@asas/mithaq/roles/system-roles.ts'
|
|
8
|
+
import { backofficeReads } from '@asas/mithaq/roles/tenant-access.ts'
|
|
9
|
+
|
|
10
|
+
const rolesUi = folderOf(new URL('./ui/', import.meta.url))
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A tenant's own roles, each with a flat list of the catalog keys it grants. The roles this project ships are code, `systemRoles`, and never a row here;
|
|
14
|
+
* a granter may not hand over a key it does not itself hold.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* A tenant's role never holds `screen.backoffice`, which only a backoffice role holds: a row naming it would be a tenant claiming the backoffice.
|
|
18
|
+
* It is refused before the granter check, so a root granter holding `'*'` is refused too.
|
|
19
|
+
*/
|
|
20
|
+
const BACKOFFICE_KEY = 'screen.backoffice'
|
|
21
|
+
const tenantKeysOnly = (ctx: Ctx): Ctx => {
|
|
22
|
+
const written = (ctx.data as Record<string, unknown>).permissions
|
|
23
|
+
if (!Array.isArray(written) || !written.includes(BACKOFFICE_KEY)) return ctx
|
|
24
|
+
throw denied({
|
|
25
|
+
message: `Forbidden: a "${ctx.kayan.name}" row is a tenant's role, and "${BACKOFFICE_KEY}" is held only by a backoffice role.`,
|
|
26
|
+
key: 'error.forbidden.backoffice_key',
|
|
27
|
+
params: { kayan: ctx.kayan.name, permissions: [BACKOFFICE_KEY] },
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const assignments = (tools: WriteTools, role: string) =>
|
|
32
|
+
tools.of(memberRoles).find({ where: { role: { equals: role } }, limit: 100 })
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A role a member holds is neither deleted nor renamed, and no role takes a name an assignment of its tenant already names: either would leave a `memberRoles` row naming a role that is gone, armed later by a role with that name. Both refuse 409.
|
|
36
|
+
* Holders are read as the actor, and an actor who may not read them is refused, because a write nobody could check does not go ahead. Assignments are counted through the store, so a role's author needs no `memberRoles` key.
|
|
37
|
+
*/
|
|
38
|
+
const unheld = async (ctx: Ctx, tools: WriteTools): Promise<Ctx> => {
|
|
39
|
+
const before = ctx.dataBefore
|
|
40
|
+
const name = before?.name
|
|
41
|
+
const renamed = (ctx.data as { name?: unknown }).name
|
|
42
|
+
const arriving = tools.kind === 'insert' || (tools.kind === 'update' && renamed !== name) ? renamed : undefined
|
|
43
|
+
// The runner fills the organization on a create, and it is the active one.
|
|
44
|
+
const tenantId = before?.organization ?? (ctx.data as { organization?: unknown }).organization
|
|
45
|
+
if (typeof arriving === 'string') {
|
|
46
|
+
const standing = await assignedCount({ ctx, tenantId, role: arriving })
|
|
47
|
+
if (standing) {
|
|
48
|
+
throw conflict({
|
|
49
|
+
message: `Role "${arriving}" is named by ${standing} assignment(s) of this tenant that outlived their role, so no role takes that name until they are removed.`,
|
|
50
|
+
key: 'error.conflict.role_assigned',
|
|
51
|
+
params: { kayan: ctx.kayan.name, role: arriving, count: standing },
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (typeof name !== 'string' || (tools.kind === 'update' && (renamed === undefined || renamed === name))) return ctx
|
|
56
|
+
const held = await assignments(tools, name)
|
|
57
|
+
if (!held.totalDocs) return ctx
|
|
58
|
+
const ids = held.docs.map((row) => String(row.user))
|
|
59
|
+
const named = await tools.of(users).find({ where: { id: { in: ids } }, limit: 100 }).catch(() => ({ docs: [] as { id?: unknown; name?: unknown }[] }))
|
|
60
|
+
const members = ids.map((id) => ({ id, name: String(named.docs.find((one) => one.id === id)?.name ?? id) }))
|
|
61
|
+
throw conflict({
|
|
62
|
+
message: `Role "${name}" is held by ${held.totalDocs} member(s), so it is not ${tools.kind === 'remove' ? 'deleted' : 'renamed'} until none holds it.`,
|
|
63
|
+
key: 'error.conflict.role_held',
|
|
64
|
+
params: { kayan: ctx.kayan.name, role: name, count: held.totalDocs, members },
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const keysOf = (value: unknown): readonly string[] => (Array.isArray(value) ? (value as string[]) : [])
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Nobody edits a role they hold so it stops letting them manage roles. The actor's roles are the ones the access target resolved for this tenant,
|
|
72
|
+
* off the trusted snapshot; another member who may manage roles edits it.
|
|
73
|
+
*/
|
|
74
|
+
const keepsOwnRole = (ctx: Ctx): Ctx => {
|
|
75
|
+
const before = ctx.dataBefore
|
|
76
|
+
const keys = (ctx.data as { permissions?: unknown }).permissions
|
|
77
|
+
if (!before || keys === undefined || !managesRoles(keysOf(before.permissions)) || managesRoles(keysOf(keys))) return ctx
|
|
78
|
+
// The roles an assignment gives, not `assignedRoles`: the owner's added
|
|
79
|
+
// `administrator` is a fixed role, which no update reaches.
|
|
80
|
+
if (!actorRoles(ctx.actor)?.includes(String(before.name))) return ctx
|
|
81
|
+
throw denied({
|
|
82
|
+
message: `Forbidden: role "${String(before.name)}" is what lets this member manage roles, so they do not take its role-managing keys from it. Another member who may manage roles changes it.`,
|
|
83
|
+
key: 'error.forbidden.own_role',
|
|
84
|
+
params: { kayan: ctx.kayan.name, role: String(before.name) },
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A role that lets its holders manage roles loses that only while another member still may, whether its keys change or it is deleted. */
|
|
89
|
+
const keepsManagers = async (ctx: Ctx, tools: WriteTools): Promise<Ctx> => {
|
|
90
|
+
const before = ctx.dataBefore
|
|
91
|
+
if (!before || !managesRoles(keysOf(before.permissions))) return ctx
|
|
92
|
+
const keys = tools.kind === 'remove' ? [] : keysOf((ctx.data as { permissions?: unknown }).permissions ?? before.permissions)
|
|
93
|
+
if (managesRoles(keys)) return ctx
|
|
94
|
+
await managerRemains({ ctx, tenantId: before.organization, change: { role: String(before.name), keys } })
|
|
95
|
+
return ctx
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const rolesAccess = rows({
|
|
99
|
+
'*': { create: true, read: true, update: true, delete: true },
|
|
100
|
+
/**
|
|
101
|
+
* Every backoffice role reads the role rows of every tenant, and writes none. A
|
|
102
|
+
* tenant that lost its administrator is repaired through `mithaq rbac assign`,
|
|
103
|
+
* never from the backoffice app.
|
|
104
|
+
*/
|
|
105
|
+
...backofficeReads,
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
export const roles = defineKayan({
|
|
109
|
+
name: 'roles',
|
|
110
|
+
targets: {
|
|
111
|
+
/** Every organization's rows on the backoffice, read-only. */
|
|
112
|
+
backoffice: {
|
|
113
|
+
useAsTitle: 'label',
|
|
114
|
+
pages: {
|
|
115
|
+
// A role by its label, never its slug, and its keys grouped as the
|
|
116
|
+
// dashboard draws a fixed role's.
|
|
117
|
+
...backofficeList('/roles', { columns: ['label', 'organization'], filters: ['organization'], search: ['label'] }),
|
|
118
|
+
...backofficeRecord({
|
|
119
|
+
path: '/roles/:id',
|
|
120
|
+
main: [{ section: 'role', label: { en: 'Role', ar: 'الدور' }, fields: ['label', { field: 'permissions', cell: rolesUi('roles.tsx#Permissions') }] }],
|
|
121
|
+
}),
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
service: {
|
|
125
|
+
operations: {
|
|
126
|
+
...defaultOperations,
|
|
127
|
+
// The shipped lists with the guard in front of the store step: the runner
|
|
128
|
+
// has parsed by then, so the column is already a list of strings, and the
|
|
129
|
+
// write has not happened, so a refused grant leaves no row behind.
|
|
130
|
+
create: { steps: ({ steps }) => [tenantKeysOnly, grantedPermissions, writableRoleName(systemRoles), unheld, steps.insert] },
|
|
131
|
+
update: { steps: ({ steps }) => [tenantKeysOnly, grantedPermissions, writableRoleName(systemRoles), unheld, keepsOwnRole, keepsManagers, steps.update] },
|
|
132
|
+
delete: { steps: ({ steps }) => [grantedPermissions, unheld, keepsManagers, steps.remove] },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
/**
|
|
136
|
+
* The fixed roles list first, from their code, each with a lock and its keys; a custom role opens its form, where its keys are picked as badges, and its delete names the members who still hold it.
|
|
137
|
+
*/
|
|
138
|
+
dashboard: {
|
|
139
|
+
useAsTitle: 'label',
|
|
140
|
+
description: {
|
|
141
|
+
en: 'What each role in this organization may see and do.',
|
|
142
|
+
ar: 'ما يمكن لكل دور في هذه المؤسسة أن يراه ويفعله.',
|
|
143
|
+
},
|
|
144
|
+
pages: {
|
|
145
|
+
list: { path: '/roles', page: appPage([{ layout: 'table', main: [{ list: { columns: ['label', 'name', 'permissions'], limit: 100 }, component: rolesUi('roles.tsx#RolesTable') }] }]) },
|
|
146
|
+
create: { path: '/roles/new', page: appPage([{ layout: 'record', main: [{ form: ['name', 'label', 'permissions'], component: rolesUi('role-form.tsx#RoleForm') }] }]) },
|
|
147
|
+
edit: {
|
|
148
|
+
path: '/roles/:id',
|
|
149
|
+
page: appPage([
|
|
150
|
+
{
|
|
151
|
+
layout: 'record',
|
|
152
|
+
main: [{ form: ['name', 'label', 'permissions'], component: rolesUi('role-form.tsx#RoleForm') }],
|
|
153
|
+
danger: [{ button: 'delete', label: { en: 'Delete', ar: 'حذف' }, component: rolesUi('roles.tsx#DeleteRole') }],
|
|
154
|
+
},
|
|
155
|
+
]),
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
access: rolesAccess,
|
|
161
|
+
fields: {
|
|
162
|
+
...defaultFields,
|
|
163
|
+
organization,
|
|
164
|
+
// Unique within the organization: two rows of one name would each grant their keys, so
|
|
165
|
+
// deleting the one meant to revoke would leave the other granting.
|
|
166
|
+
name: {
|
|
167
|
+
kind: 'text',
|
|
168
|
+
label: { en: 'Name', ar: 'الاسم' },
|
|
169
|
+
// Trimmed, as the label below is, so neither is spaces alone.
|
|
170
|
+
rules: { required: true, unique: true, trim: true, maxLength: 60, index: true },
|
|
171
|
+
},
|
|
172
|
+
/** What a person reads a role by, everywhere; the name is the slug an assignment names. */
|
|
173
|
+
label: {
|
|
174
|
+
kind: 'text',
|
|
175
|
+
label: { en: 'Label', ar: 'التسمية' },
|
|
176
|
+
rules: { required: true, localized: true, trim: true, maxLength: 60 },
|
|
177
|
+
},
|
|
178
|
+
permissions: {
|
|
179
|
+
kind: 'textArray',
|
|
180
|
+
label: { en: 'Permissions', ar: 'الصلاحيات' },
|
|
181
|
+
rules: { required: true },
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
})
|