create-mithaq 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/LICENSES/payload.txt +22 -0
- package/LICENSES/ponytail.txt +21 -0
- package/index.js +127 -0
- package/package.json +19 -4
- package/template/apps/asas-platform/components.json +25 -0
- package/template/apps/asas-platform/gitignore +3 -0
- package/template/apps/asas-platform/package.json +55 -0
- package/template/apps/asas-platform/public/favicon.svg +1 -0
- package/template/apps/asas-platform/src/api.ts +147 -0
- package/template/apps/asas-platform/src/auth-frame.tsx +150 -0
- package/template/apps/asas-platform/src/components/mithaq/cells.tsx +112 -0
- package/template/apps/asas-platform/src/components/mithaq/controls.tsx +650 -0
- package/template/apps/asas-platform/src/components/mithaq/field-shell.tsx +53 -0
- package/template/apps/asas-platform/src/components/mithaq/filter-operators.ts +108 -0
- package/template/apps/asas-platform/src/components/mithaq/form.tsx +1467 -0
- package/template/apps/asas-platform/src/components/mithaq/list-columns.tsx +108 -0
- package/template/apps/asas-platform/src/components/mithaq/list-filters.tsx +368 -0
- package/template/apps/asas-platform/src/components/mithaq/list-pagination.tsx +153 -0
- package/template/apps/asas-platform/src/components/mithaq/list-query.ts +432 -0
- package/template/apps/asas-platform/src/components/mithaq/list-selection.tsx +74 -0
- package/template/apps/asas-platform/src/components/mithaq/list.tsx +865 -0
- package/template/apps/asas-platform/src/components/mithaq/nav.tsx +871 -0
- package/template/apps/asas-platform/src/components/mithaq/nodes.tsx +1024 -0
- package/template/apps/asas-platform/src/components/mithaq/relationship-cell.tsx +127 -0
- package/template/apps/asas-platform/src/components/mithaq/relationship-control.tsx +400 -0
- package/template/apps/asas-platform/src/components/mithaq/rich-text-nodes.ts +6 -0
- package/template/apps/asas-platform/src/components/mithaq/rich-text.tsx +1083 -0
- package/template/apps/asas-platform/src/components/mithaq/upload.tsx +686 -0
- package/template/apps/asas-platform/src/components/ui/accordion.tsx +74 -0
- package/template/apps/asas-platform/src/components/ui/alert-dialog.tsx +185 -0
- package/template/apps/asas-platform/src/components/ui/avatar.tsx +106 -0
- package/template/apps/asas-platform/src/components/ui/badge.tsx +48 -0
- package/template/apps/asas-platform/src/components/ui/breadcrumb.tsx +124 -0
- package/template/apps/asas-platform/src/components/ui/button-group.tsx +87 -0
- package/template/apps/asas-platform/src/components/ui/button.tsx +55 -0
- package/template/apps/asas-platform/src/components/ui/calendar.tsx +223 -0
- package/template/apps/asas-platform/src/components/ui/card.tsx +102 -0
- package/template/apps/asas-platform/src/components/ui/chart.tsx +371 -0
- package/template/apps/asas-platform/src/components/ui/checkbox.tsx +28 -0
- package/template/apps/asas-platform/src/components/ui/collapsible.tsx +19 -0
- package/template/apps/asas-platform/src/components/ui/combobox.tsx +308 -0
- package/template/apps/asas-platform/src/components/ui/dialog.tsx +158 -0
- package/template/apps/asas-platform/src/components/ui/direction.tsx +6 -0
- package/template/apps/asas-platform/src/components/ui/directional-icon.tsx +20 -0
- package/template/apps/asas-platform/src/components/ui/drawer.tsx +216 -0
- package/template/apps/asas-platform/src/components/ui/dropdown-menu.tsx +267 -0
- package/template/apps/asas-platform/src/components/ui/field.tsx +236 -0
- package/template/apps/asas-platform/src/components/ui/input-group.tsx +155 -0
- package/template/apps/asas-platform/src/components/ui/input-otp.tsx +84 -0
- package/template/apps/asas-platform/src/components/ui/input.tsx +19 -0
- package/template/apps/asas-platform/src/components/ui/label.tsx +19 -0
- package/template/apps/asas-platform/src/components/ui/popover.tsx +90 -0
- package/template/apps/asas-platform/src/components/ui/radio-group.tsx +37 -0
- package/template/apps/asas-platform/src/components/ui/resizable.tsx +47 -0
- package/template/apps/asas-platform/src/components/ui/scroll-area.tsx +54 -0
- package/template/apps/asas-platform/src/components/ui/select.tsx +204 -0
- package/template/apps/asas-platform/src/components/ui/separator.tsx +24 -0
- package/template/apps/asas-platform/src/components/ui/sheet.tsx +139 -0
- package/template/apps/asas-platform/src/components/ui/sidebar.tsx +714 -0
- package/template/apps/asas-platform/src/components/ui/skeleton.tsx +13 -0
- package/template/apps/asas-platform/src/components/ui/slider.tsx +55 -0
- package/template/apps/asas-platform/src/components/ui/spinner.tsx +10 -0
- package/template/apps/asas-platform/src/components/ui/switch.tsx +29 -0
- package/template/apps/asas-platform/src/components/ui/table.tsx +115 -0
- package/template/apps/asas-platform/src/components/ui/tabs.tsx +79 -0
- package/template/apps/asas-platform/src/components/ui/textarea.tsx +17 -0
- package/template/apps/asas-platform/src/components/ui/toggle-group.tsx +89 -0
- package/template/apps/asas-platform/src/components/ui/toggle.tsx +44 -0
- package/template/apps/asas-platform/src/components/ui/tooltip.tsx +63 -0
- package/template/apps/asas-platform/src/copy.ts +1 -0
- package/template/apps/asas-platform/src/hooks/use-mobile.ts +19 -0
- package/template/apps/asas-platform/src/lib/utils.ts +1 -0
- package/template/apps/asas-platform/src/links.ts +38 -0
- package/template/apps/asas-platform/src/locale-switch.tsx +26 -0
- package/template/apps/asas-platform/src/redirect.ts +1 -0
- package/template/apps/asas-platform/src/request-headers.ts +11 -0
- package/template/apps/asas-platform/src/routeTree.gen.ts +181 -0
- package/template/apps/asas-platform/src/router.tsx +7 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform/files.ts +4 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform/screen.json +3609 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform/types.ts +145 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform.$.tsx +17 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform.index.tsx +10 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform.tsx +13 -0
- package/template/apps/asas-platform/src/routes/__root.tsx +95 -0
- package/template/apps/asas-platform/src/routes/api/$.ts +30 -0
- package/template/apps/asas-platform/src/routes/auth.sign-in.tsx +72 -0
- package/template/apps/asas-platform/src/routes/unauthorized.tsx +122 -0
- package/template/apps/asas-platform/src/screen-form.tsx +34 -0
- package/template/apps/asas-platform/src/screen-list.tsx +139 -0
- package/template/apps/asas-platform/src/screen-names.ts +10 -0
- package/template/apps/asas-platform/src/screens.tsx +868 -0
- package/template/apps/asas-platform/src/server.ts +20 -0
- package/template/apps/asas-platform/src/slide.ts +22 -0
- package/template/apps/asas-platform/src/start.ts +42 -0
- package/template/apps/asas-platform/src/styles.css +200 -0
- package/template/apps/asas-platform/src/theme.ts +2 -0
- package/template/apps/asas-platform/tsconfig.json +19 -0
- package/template/apps/asas-platform/vite.config.ts +26 -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 +56 -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/components/mithaq/cells.tsx +112 -0
- package/template/apps/asas-tanstack/src/components/mithaq/controls.tsx +650 -0
- package/template/apps/asas-tanstack/src/components/mithaq/field-shell.tsx +53 -0
- package/template/apps/asas-tanstack/src/components/mithaq/filter-operators.ts +108 -0
- package/template/apps/asas-tanstack/src/components/mithaq/form.tsx +1467 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-columns.tsx +108 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-filters.tsx +368 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-pagination.tsx +153 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-query.ts +432 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-selection.tsx +74 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list.tsx +865 -0
- package/template/apps/asas-tanstack/src/components/mithaq/nav.tsx +871 -0
- package/template/apps/asas-tanstack/src/components/mithaq/nodes.tsx +1024 -0
- package/template/apps/asas-tanstack/src/components/mithaq/relationship-cell.tsx +127 -0
- package/template/apps/asas-tanstack/src/components/mithaq/relationship-control.tsx +400 -0
- package/template/apps/asas-tanstack/src/components/mithaq/rich-text-nodes.ts +6 -0
- package/template/apps/asas-tanstack/src/components/mithaq/rich-text.tsx +1083 -0
- package/template/apps/asas-tanstack/src/components/mithaq/upload.tsx +686 -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/drawer.tsx +215 -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/copy.ts +870 -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 +26 -0
- package/template/apps/asas-tanstack/src/quiet-abort.ts +25 -0
- package/template/apps/asas-tanstack/src/redirect.ts +20 -0
- package/template/apps/asas-tanstack/src/request-headers.ts +11 -0
- package/template/apps/asas-tanstack/src/routeTree.gen.ts +382 -0
- package/template/apps/asas-tanstack/src/router.tsx +7 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +4 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +3883 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +158 -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 +4 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +1800 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +92 -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 +101 -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 +109 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +86 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +122 -0
- package/template/apps/asas-tanstack/src/routes/index.tsx +142 -0
- package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +122 -0
- package/template/apps/asas-tanstack/src/screen-form.tsx +34 -0
- package/template/apps/asas-tanstack/src/screen-list.tsx +139 -0
- package/template/apps/asas-tanstack/src/screen-names.ts +10 -0
- package/template/apps/asas-tanstack/src/screens.tsx +868 -0
- package/template/apps/asas-tanstack/src/server.ts +16 -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 +159 -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 +28 -0
- package/template/package.json +16 -0
- package/template/templates/asas/.env.example +16 -0
- package/template/templates/asas/components.json +22 -0
- package/template/templates/asas/mithaq.hints.json +25 -0
- package/template/templates/asas/package.json +38 -0
- package/template/templates/asas/src/asas.ts +93 -0
- package/template/templates/asas/src/audit-log/audit-log.ts +187 -0
- package/template/templates/asas/src/audit-log/sink.ts +89 -0
- package/template/templates/asas/src/audit-log/ui/activity.tsx +243 -0
- package/template/templates/asas/src/courses/courses.ts +612 -0
- package/template/templates/asas/src/courses/ui/archive-button.tsx +25 -0
- package/template/templates/asas/src/courses/ui/fee-cell.tsx +19 -0
- package/template/templates/asas/src/courses/ui/no-courses-on-offer.tsx +11 -0
- package/template/templates/asas/src/courses/ui/no-courses.tsx +13 -0
- package/template/templates/asas/src/courses/ui/on-offer.ts +6 -0
- package/template/templates/asas/src/courses/ui/status-cell.tsx +31 -0
- package/template/templates/asas/src/enrollments/enrollments.ts +137 -0
- package/template/templates/asas/src/enrollments/ui/session-cell.tsx +65 -0
- package/template/templates/asas/src/kayan/auth/user/account.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/account.ts +31 -0
- package/template/templates/asas/src/kayan/auth/user/index.ts +18 -0
- package/template/templates/asas/src/kayan/auth/user/invitation.access.ts +4 -0
- package/template/templates/asas/src/kayan/auth/user/invitation.ts +27 -0
- package/template/templates/asas/src/kayan/auth/user/member.access.ts +10 -0
- package/template/templates/asas/src/kayan/auth/user/member.screens.ts +37 -0
- package/template/templates/asas/src/kayan/auth/user/member.ts +25 -0
- package/template/templates/asas/src/kayan/auth/user/organization.access.ts +20 -0
- package/template/templates/asas/src/kayan/auth/user/organization.screens.ts +37 -0
- package/template/templates/asas/src/kayan/auth/user/organization.ts +24 -0
- package/template/templates/asas/src/kayan/auth/user/organizationRole.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/organizationRole.ts +25 -0
- package/template/templates/asas/src/kayan/auth/user/session.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/session.ts +28 -0
- package/template/templates/asas/src/kayan/auth/user/user.access.ts +29 -0
- package/template/templates/asas/src/kayan/auth/user/user.screens.ts +45 -0
- package/template/templates/asas/src/kayan/auth/user/user.ts +31 -0
- package/template/templates/asas/src/kayan/auth/user/verification.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/verification.ts +23 -0
- package/template/templates/asas/src/local-users/local-users.ts +143 -0
- package/template/templates/asas/src/media/media.ts +137 -0
- package/template/templates/asas/src/media/ui/preview.tsx +143 -0
- package/template/templates/asas/src/member-roles/member-roles.ts +207 -0
- package/template/templates/asas/src/messages/account.json +106 -0
- package/template/templates/asas/src/messages/auditLog.json +186 -0
- package/template/templates/asas/src/messages/courses.json +270 -0
- package/template/templates/asas/src/messages/enrollments.json +42 -0
- package/template/templates/asas/src/messages/invitation.json +66 -0
- package/template/templates/asas/src/messages/localUsers.json +42 -0
- package/template/templates/asas/src/messages/media.json +66 -0
- package/template/templates/asas/src/messages/member.json +42 -0
- package/template/templates/asas/src/messages/memberRoles.json +42 -0
- package/template/templates/asas/src/messages/mithaq.json +170 -0
- package/template/templates/asas/src/messages/organization.json +50 -0
- package/template/templates/asas/src/messages/organizationRole.json +50 -0
- package/template/templates/asas/src/messages/roles.json +58 -0
- package/template/templates/asas/src/messages/session.json +82 -0
- package/template/templates/asas/src/messages/sessions.json +106 -0
- package/template/templates/asas/src/messages/settings.json +66 -0
- package/template/templates/asas/src/messages/tags.json +38 -0
- package/template/templates/asas/src/messages/user.json +90 -0
- package/template/templates/asas/src/messages/verification.json +50 -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/drizzle.config.ts +11 -0
- package/template/templates/asas/src/migrations/schema.ts +29 -0
- package/template/templates/asas/src/mithaq.ts +406 -0
- package/template/templates/asas/src/pages.ts +74 -0
- package/template/templates/asas/src/platform-http.ts +96 -0
- package/template/templates/asas/src/rich-text-nodes.ts +11 -0
- package/template/templates/asas/src/roles/grants.ts +47 -0
- package/template/templates/asas/src/roles/roles.ts +184 -0
- package/template/templates/asas/src/roles/ui/role-form.tsx +226 -0
- package/template/templates/asas/src/roles/ui/roles.tsx +166 -0
- package/template/templates/asas/src/roles.ts +16 -0
- package/template/templates/asas/src/seed.ts +325 -0
- package/template/templates/asas/src/sessions/sessions.ts +155 -0
- package/template/templates/asas/src/settings/settings.ts +69 -0
- package/template/templates/asas/src/statements.ts +11 -0
- package/template/templates/asas/src/steps/expected-updated-at.ts +41 -0
- package/template/templates/asas/src/storage/defaults.ts +156 -0
- package/template/templates/asas/src/storage/index.ts +95 -0
- package/template/templates/asas/src/storage/local.ts +143 -0
- package/template/templates/asas/src/storage/r2.ts +5 -0
- package/template/templates/asas/src/storage/s3.ts +91 -0
- package/template/templates/asas/src/storage/uploadthing.ts +59 -0
- package/template/templates/asas/src/tags/tags.ts +80 -0
- package/template/templates/asas/src/tenant-access.ts +221 -0
- package/template/templates/asas/src/tenant-roles.ts +262 -0
- package/template/templates/asas/src/ui/app-shell.tsx +260 -0
- package/template/templates/asas/src/ui/kpi-card.tsx +25 -0
- package/template/templates/asas/src/ui/record-page.tsx +50 -0
- package/template/templates/asas/src/ui/rich-text-nodes.tsx +15 -0
- package/template/templates/asas/src/ui/screen-nav.tsx +28 -0
- package/template/templates/asas/src/ui/session-labels.tsx +36 -0
- package/template/templates/asas/src/ui/table-page.tsx +9 -0
- package/template/templates/asas/src/ui/top-shell.tsx +60 -0
- package/template/templates/asas/src/ui-dashboard/dashboard.tsx +294 -0
- package/template/templates/asas/src/ui-dashboard/members.tsx +630 -0
- package/template/templates/asas/src/ui-dashboard/theme-provider.tsx +10 -0
- package/template/templates/asas/src/ui-dashboard/versions.tsx +432 -0
- package/template/templates/asas/src/ui-platform/home.tsx +63 -0
- package/template/templates/asas/src/ui-platform/organization-actions.tsx +114 -0
- package/template/templates/asas/src/ui-platform/record-page.tsx +30 -0
- package/template/templates/asas/src/ui-platform/role-cells.tsx +52 -0
- package/template/templates/asas/src/ui-platform/user-actions.tsx +251 -0
- package/template/templates/asas/src/ui-portal/home.tsx +77 -0
- package/template/templates/asas/src/ui-portal/page.tsx +11 -0
- package/template/templates/auth/package.json +14 -0
- package/template/templates/auth/src/index.ts +690 -0
- package/template/templates/auth/src/platform-roles.ts +87 -0
- package/README.md +0 -3
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { createHmac, randomBytes } from 'node:crypto'
|
|
3
|
+
import { BASE_ERROR_CODES, betterAuth as betterAuthServer, getCurrentAdapter } from 'better-auth'
|
|
4
|
+
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
|
5
|
+
import { APIError, createAuthMiddleware, getSessionFromCtx } from 'better-auth/api'
|
|
6
|
+
import { admin, oneTimeToken, organization } from 'better-auth/plugins'
|
|
7
|
+
import { createAccessControl } from 'better-auth/plugins/access'
|
|
8
|
+
import { adminAc, defaultStatements as adminStatements, userAc } from 'better-auth/plugins/admin/access'
|
|
9
|
+
import { defaultStatements } from 'better-auth/plugins/organization/access'
|
|
10
|
+
import { holdsPermission, requestOrganization, type Where } from 'mithaq'
|
|
11
|
+
// A module of its own so a screen can list the roles without loading the provider.
|
|
12
|
+
import { platformRoles } from './platform-roles.ts'
|
|
13
|
+
|
|
14
|
+
export { platformRoles }
|
|
15
|
+
|
|
16
|
+
// Set only by the platform app, which wraps every request it serves in `asPlatform`. Nothing a
|
|
17
|
+
// client sends sets it.
|
|
18
|
+
const platformRequest = new AsyncLocalStorage<true>()
|
|
19
|
+
/** Runs `run` as a request the platform app serves. Only the platform app calls it. */
|
|
20
|
+
export const asPlatform = <Result>(run: () => Result): Result => platformRequest.run(true, run)
|
|
21
|
+
/**
|
|
22
|
+
* Whether the current request is the platform app's, which picks the platform's Better Auth
|
|
23
|
+
* instance and its role set.
|
|
24
|
+
*/
|
|
25
|
+
export const onPlatform = () => platformRequest.getStore() === true
|
|
26
|
+
|
|
27
|
+
// A production boot needs all three: the fallbacks below are for development only, and Better
|
|
28
|
+
// Auth's refusal of its default secret never fires for the platform, whose secret is derived here.
|
|
29
|
+
const production = process.env.NODE_ENV === 'production'
|
|
30
|
+
const secret = process.env.BETTER_AUTH_SECRET ?? process.env.AUTH_SECRET
|
|
31
|
+
if (production && (!process.env.PLATFORM_URL || !process.env.BETTER_AUTH_URL || !secret)) {
|
|
32
|
+
throw new Error('A production boot needs PLATFORM_URL and BETTER_AUTH_URL, where the two apps are served, and BETTER_AUTH_SECRET, which both sign their sessions under. Set all three.')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// `NODE_ENV` is unset when a deploy runs plain `node` or `bun`, so the check above never runs and
|
|
36
|
+
// both apps would boot on a public secret. Refuse on a non-local host with no secret instead.
|
|
37
|
+
const isLocalHost = (url: string): boolean => {
|
|
38
|
+
try {
|
|
39
|
+
return ['localhost', '127.0.0.1', '::1'].includes(new URL(url).hostname)
|
|
40
|
+
} catch {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const remoteWithNoSecret = process.env.BETTER_AUTH_SECRET
|
|
45
|
+
? undefined
|
|
46
|
+
: [process.env.PLATFORM_URL, process.env.BETTER_AUTH_URL].filter((url): url is string => typeof url === 'string').find((url) => !isLocalHost(url))
|
|
47
|
+
if (remoteWithNoSecret) {
|
|
48
|
+
throw new Error(`BETTER_AUTH_SECRET must be set: ${remoteWithNoSecret} is not localhost, and this file's own secret falls back to a public default without it.`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where the platform app is served, and the platform instance's `baseURL`. Outside production it
|
|
53
|
+
* falls back to the `vite dev` port.
|
|
54
|
+
*/
|
|
55
|
+
export const platformURL = process.env.PLATFORM_URL ?? 'http://localhost:5174'
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Where the tenant app is served, the tenant instance's `baseURL` and the target of an
|
|
59
|
+
* impersonation hand-off. Outside production it falls back to the `vite dev` port.
|
|
60
|
+
*/
|
|
61
|
+
export const tenantURL = process.env.BETTER_AUTH_URL ?? 'http://localhost:5173'
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The origins the platform instance trusts beside its own `baseURL`: the `vite dev` and `vite
|
|
65
|
+
* preview` ports, outside production only.
|
|
66
|
+
*/
|
|
67
|
+
export const platformOrigins = production ? [] : ['http://localhost:4174', 'http://localhost:5174']
|
|
68
|
+
|
|
69
|
+
// Derived from the deployment secret and never equal to it, so a cookie one app signed fails
|
|
70
|
+
// verification in the other. The apps share one session table and never a session; a session
|
|
71
|
+
// crosses only through the one-time hand-off.
|
|
72
|
+
const platformSecret = createHmac('sha256', secret ?? 'mithaq-development-only').update('mithaq-platform-session').digest('base64url')
|
|
73
|
+
|
|
74
|
+
/** The platform's session cookie name, `mithaq-platform.session_token`, beside the tenant's `better-auth.session_token`. */
|
|
75
|
+
export const platformCookiePrefix = 'mithaq-platform'
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Better Auth's own actions, which no kayan declares; `buildAsas` adds them to the catalog.
|
|
79
|
+
*
|
|
80
|
+
* `member` and `user` are each a provider resource and a generated kayan, so one key opens both
|
|
81
|
+
* doors. They never meet while no auth kayan carries a tenant field. They cannot be renamed: the
|
|
82
|
+
* provider's routes ask under these exact names.
|
|
83
|
+
*/
|
|
84
|
+
export const providerStatements = { ...defaultStatements, ...adminStatements }
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
// `APIError` makes the refusal the provider's own 403 rather than the 500 an unrecognised throw
|
|
88
|
+
// becomes.
|
|
89
|
+
const gate = (key: string) => async ({ organization }: { organization?: { [key: string]: unknown; id?: unknown } } = {}) => {
|
|
90
|
+
sameOrganization(organization)
|
|
91
|
+
if (holdsPermission(key)) return
|
|
92
|
+
throw new APIError('FORBIDDEN', { message: `You do not hold the permission "${key}".` })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// `holdsPermission` answers with the caller's keys in their active organization, but a provider
|
|
96
|
+
// route that takes an `organizationId` in its body acts on that one. Refuse any other, or keys held
|
|
97
|
+
// in one organization would open another. Outside `asas.handler` nothing is resolved and the
|
|
98
|
+
// provider's own roles decide alone.
|
|
99
|
+
const sameOrganization = (organization?: { [key: string]: unknown; id?: unknown }) => {
|
|
100
|
+
const active = requestOrganization()
|
|
101
|
+
if (active === undefined || typeof organization?.id !== 'string' || organization.id === active) return
|
|
102
|
+
throw new APIError('FORBIDDEN', { message: 'Your keys are your active organization\'s, and this action is in another. Make it the active organization first.' })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The provider refuses only an `owner` invite from a non-owner, so without this an inviter could
|
|
106
|
+
// hand out `admin` with no `member.update`.
|
|
107
|
+
const invitedAs = (role: string) => {
|
|
108
|
+
if (role.split(',').every((name) => name === 'member') || holdsPermission('member.update')) return
|
|
109
|
+
throw new APIError('FORBIDDEN', { message: 'An invitation as anything but a member needs the permission "member.update".' })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The provider checks an invitation's role names trimmed but stores the value as written, and its
|
|
113
|
+
// owner checks match untrimmed, so `member, owner` could slip past an inviter who may not invite an
|
|
114
|
+
// owner. Refuse whitespace and empty names.
|
|
115
|
+
const exactRoles = (role: unknown) => {
|
|
116
|
+
if (typeof role === 'string' && role.split(',').every((name) => name !== '' && name === name.trim())) return
|
|
117
|
+
throw new APIError('BAD_REQUEST', {
|
|
118
|
+
code: 'error.invalid.member_role',
|
|
119
|
+
message: `A role value holds exact role names, comma-joined, with no whitespace around a name and no empty one: ${JSON.stringify(role)}.`,
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Both conditions are asked because either alone fails open: a deploy run with plain `node` never
|
|
124
|
+
// sets `NODE_ENV`, and an unset `BETTER_AUTH_URL` falls back to `localhost`.
|
|
125
|
+
const logsLinks = process.env.NODE_ENV === 'development' && isLocalHost(tenantURL)
|
|
126
|
+
|
|
127
|
+
// Development only: the link goes to the server's log. Anywhere else nothing is sent and nothing is
|
|
128
|
+
// logged, so the inviter hands the link over themselves.
|
|
129
|
+
const sendInvitationEmail = async ({ id, email }: { id: string; email: string }) => {
|
|
130
|
+
console.log(`[asas auth] Invitation link for "${email}": ${new URL(`/auth/accept-invitation?id=${encodeURIComponent(id)}`, tenantURL).href}`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// These options do not depend on this project's vocabulary, so `mithaq generate auth` reads the
|
|
134
|
+
// same schema the runtime builds.
|
|
135
|
+
//
|
|
136
|
+
// `beforeAddMember` is deliberately not gated. `addMember` has no HTTP route, and its other firing
|
|
137
|
+
// is inside `createOrganization`, where the creator is in no organization yet and holds no key, so
|
|
138
|
+
// gating it would make creating an organization impossible. `member.create` is left to the
|
|
139
|
+
// provider's static roles.
|
|
140
|
+
const organizationOptions = {
|
|
141
|
+
dynamicAccessControl: { enabled: true as const },
|
|
142
|
+
// An organization that was ever used holds rows that cannot all be removed (an audit log,
|
|
143
|
+
// settings, its last administrator), and every tenant key is `restrict`, so deletion is off over
|
|
144
|
+
// HTTP.
|
|
145
|
+
disableOrganizationDeletion: true,
|
|
146
|
+
// The provider compares the invited address with the signed-in one on every invitation route.
|
|
147
|
+
// `requireEmailVerificationOnInvitation` stays unset rather than `false`, so the provider asks
|
|
148
|
+
// for a verified address only if invitation ids become guessable. The template sends no mail that
|
|
149
|
+
// would verify an address, so asking for one would leave every invitation unanswerable.
|
|
150
|
+
...(logsLinks ? { sendInvitationEmail } : {}),
|
|
151
|
+
organizationHooks: {
|
|
152
|
+
beforeUpdateOrganization: gate('organization.update'),
|
|
153
|
+
beforeRemoveMember: gate('member.delete'),
|
|
154
|
+
beforeUpdateMemberRole: async ({ newRole, organization }: { newRole: string; organization: { id: string } }) => {
|
|
155
|
+
await gate('member.update')({ organization })
|
|
156
|
+
exactRoles(newRole)
|
|
157
|
+
},
|
|
158
|
+
beforeCreateInvitation: async ({ invitation, organization }: { invitation: { role: string }; organization: { id: string } }) => {
|
|
159
|
+
await gate('invitation.create')({ organization })
|
|
160
|
+
exactRoles(invitation.role)
|
|
161
|
+
invitedAs(invitation.role)
|
|
162
|
+
},
|
|
163
|
+
beforeAcceptInvitation: async ({ invitation }: { invitation: { role: string } }) => exactRoles(invitation.role),
|
|
164
|
+
beforeCancelInvitation: gate('invitation.cancel'),
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// `'*'` is the plugin's `adminAc`, every action but `impersonate-admins`, so no platform
|
|
169
|
+
// administrator mimics another.
|
|
170
|
+
const adminAccess = createAccessControl(adminStatements)
|
|
171
|
+
const adminGrant = (permissions: '*' | readonly string[]) => {
|
|
172
|
+
if (permissions === '*') return adminAc
|
|
173
|
+
const grant: Record<string, string[]> = {}
|
|
174
|
+
for (const key of permissions) {
|
|
175
|
+
const [resource, action] = [key.slice(0, key.indexOf('.')), key.slice(key.indexOf('.') + 1)]
|
|
176
|
+
const actions: readonly string[] | undefined = Object.hasOwn(adminStatements, resource) ? adminStatements[resource as keyof typeof adminStatements] : undefined
|
|
177
|
+
if (actions?.includes(action)) (grant[resource] ??= []).push(action)
|
|
178
|
+
}
|
|
179
|
+
return adminAccess.newRole(grant as Parameters<typeof adminAccess.newRole>[0])
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// `adminRoles`, not `adminUserIds`: an id list in code is a secret in the repository that no audit
|
|
183
|
+
// trail records a change to, while a column is written like any other row and can be taken back
|
|
184
|
+
// without a deploy.
|
|
185
|
+
//
|
|
186
|
+
// `roles` is the platform roles and `user` only. The plugin's gate reads `roles`, so spreading its
|
|
187
|
+
// `defaultRoles` would make `user.role = 'admin'` a platform administrator this project never
|
|
188
|
+
// declared. `user` is the plugin's `userAc`, which holds no action, and is here because `set-role`
|
|
189
|
+
// refuses a name `roles` does not hold.
|
|
190
|
+
//
|
|
191
|
+
// `adminRoles` is every platform role. None holds `impersonate-admins`, so no staff member can be
|
|
192
|
+
// impersonated. A platform role holds nothing inside a tenant.
|
|
193
|
+
const adminOptions = {
|
|
194
|
+
adminRoles: Object.keys(platformRoles),
|
|
195
|
+
roles: {
|
|
196
|
+
...Object.fromEntries(Object.entries(platformRoles).map(([slug, role]) => [slug, adminGrant(role.permissions)])),
|
|
197
|
+
user: userAc,
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The platform screen's actions are the admin plugin's own routes, not this project's operations,
|
|
202
|
+
// so the audit step never sees them. Every `/admin/` route is audited here: one JSON line per
|
|
203
|
+
// successful call naming the actor, the real person behind an impersonated session, the route, the
|
|
204
|
+
// target and the role, never a password or other body field. The template keeps no audit table, so
|
|
205
|
+
// operators must retain this output.
|
|
206
|
+
//
|
|
207
|
+
// The caller is read before the route runs because stopping an impersonation deletes the session
|
|
208
|
+
// that made the request.
|
|
209
|
+
const audited = (path: string) => path.startsWith('/admin/') || path === '/sign-out'
|
|
210
|
+
type Caller = { user: { id: string; role?: unknown }; session: Record<string, unknown> } | null
|
|
211
|
+
const callers = new WeakMap<object, Caller>()
|
|
212
|
+
/** The role a call writes, on `set-role` and `create-user` as `role` and on `update-user` and `create-user` as `data.role`. */
|
|
213
|
+
const roleOf = (body: { role?: unknown; data?: { role?: unknown } }) => body.role ?? body.data?.role
|
|
214
|
+
/** Whether a `user.role` value names a role `test` admits, read as the plugin reads it, comma-separated. */
|
|
215
|
+
const holdsRole = (role: unknown, test: (one: string) => boolean) => typeof role === 'string' && role.split(',').some((one) => test(one.trim()))
|
|
216
|
+
/** The key this project asks for each of the provider's role-writing routes. */
|
|
217
|
+
const roleWrites = new Map([
|
|
218
|
+
['/organization/create-role', 'ac.create'],
|
|
219
|
+
['/organization/update-role', 'ac.update'],
|
|
220
|
+
['/organization/delete-role', 'ac.delete'],
|
|
221
|
+
])
|
|
222
|
+
type HookContext = Parameters<Parameters<typeof createAuthMiddleware>[0]>[0]
|
|
223
|
+
const auditBefore = async (ctx: HookContext) => {
|
|
224
|
+
// The provider lets any member list invitations, invitee emails among them, and this route has no
|
|
225
|
+
// hook to gate. Only a caller who may invite reads them, in the active organization alone.
|
|
226
|
+
if (ctx.path === '/organization/list-invitations') {
|
|
227
|
+
sameOrganization({ id: (ctx.query as { organizationId?: unknown } | undefined)?.organizationId })
|
|
228
|
+
await gate('invitation.create')()
|
|
229
|
+
}
|
|
230
|
+
// A rename takes an `organizationId` in its body, but its hook is handed the new data, which
|
|
231
|
+
// carries no id, so the organization is compared here. A body naming none updates the active
|
|
232
|
+
// organization and passes.
|
|
233
|
+
if (ctx.path === '/organization/update') sameOrganization({ id: (ctx.body as { organizationId?: unknown } | undefined)?.organizationId })
|
|
234
|
+
// The provider's `organizationRole` writes take an `organizationId` in their body and ask the
|
|
235
|
+
// provider's own `ac` keys, which it answers from governance roles alone, so a governance admin
|
|
236
|
+
// of another organization could write roles there with no key. Ask this project's keys instead,
|
|
237
|
+
// in the active organization alone.
|
|
238
|
+
const roleWrite = roleWrites.get(ctx.path)
|
|
239
|
+
if (roleWrite) {
|
|
240
|
+
sameOrganization({ id: (ctx.body as { organizationId?: unknown } | undefined)?.organizationId })
|
|
241
|
+
await gate(roleWrite)()
|
|
242
|
+
}
|
|
243
|
+
if (!audited(ctx.path)) return
|
|
244
|
+
const caller: Caller = await getSessionFromCtx(ctx).catch(() => null)
|
|
245
|
+
callers.set(ctx.context, caller)
|
|
246
|
+
// Only a `super_admin` writes a role, on any route that takes one. The plugin already asks
|
|
247
|
+
// `user.set-role` of these routes, which only `'*'` holds, but a project's own platform role
|
|
248
|
+
// granted `user.update` must still write no role through `update-user`.
|
|
249
|
+
//
|
|
250
|
+
// `/admin/has-permission` is excepted: it carries `role` to ask what that role may do and writes
|
|
251
|
+
// nothing.
|
|
252
|
+
const body = (ctx.body ?? {}) as Parameters<typeof roleOf>[0] & { userId?: unknown; banReason?: unknown; data?: { banned?: unknown; banReason?: unknown } }
|
|
253
|
+
const acts = ctx.path.startsWith('/admin/') && ctx.path !== '/admin/has-permission'
|
|
254
|
+
const grants = holdsRole(caller?.user.role, (one) => one === 'super_admin')
|
|
255
|
+
if (acts && roleOf(body) !== undefined && !grants) throw new APIError('FORBIDDEN', { message: 'Only a platform administrator sets a role.' })
|
|
256
|
+
// Every route that names the person it acts on names them as `userId` in its body. Nobody acts on
|
|
257
|
+
// their own row, and only a super admin acts on a staff member's.
|
|
258
|
+
//
|
|
259
|
+
// A caller with no session is left to the provider's own 401, so the answer never says whether an
|
|
260
|
+
// id names staff.
|
|
261
|
+
if (!acts || !caller) return
|
|
262
|
+
// A ban needs a reason. The provider writes `data.banned` raw unless it is `=== true`, so a
|
|
263
|
+
// non-boolean such as `'true'` would store a ban with no reason and end no session.
|
|
264
|
+
const writes = ctx.path === '/admin/update-user' || ctx.path === '/admin/create-user'
|
|
265
|
+
if (writes && body.data && Object.hasOwn(body.data, 'banned') && typeof body.data.banned !== 'boolean') {
|
|
266
|
+
throw new APIError('BAD_REQUEST', { message: 'A ban is true or false.' })
|
|
267
|
+
}
|
|
268
|
+
const banning = ctx.path === '/admin/ban-user' ? body : writes && body.data?.banned === true ? body.data : undefined
|
|
269
|
+
if (banning && (typeof banning.banReason !== 'string' || !banning.banReason.trim())) {
|
|
270
|
+
throw new APIError('BAD_REQUEST', { message: 'A ban carries a reason.' })
|
|
271
|
+
}
|
|
272
|
+
if (body.userId === undefined) return
|
|
273
|
+
const userId = String(body.userId)
|
|
274
|
+
if (userId === caller.user.id) throw new APIError('FORBIDDEN', { message: 'Nobody acts on their own row on the platform.' })
|
|
275
|
+
// The admin plugin's own `role` column, which the core user type does not name.
|
|
276
|
+
const target = (await ctx.context.internalAdapter.findUserById(userId)) as ({ role?: string | null } & Record<string, unknown>) | null
|
|
277
|
+
if (holdsRole(target?.role, (one) => Object.hasOwn(platformRoles, one)) && !grants) {
|
|
278
|
+
throw new APIError('FORBIDDEN', { message: 'Only a platform administrator acts on a staff member.' })
|
|
279
|
+
}
|
|
280
|
+
// A role written as it already stands is answered and written nowhere, so the audit holds no line
|
|
281
|
+
// for it.
|
|
282
|
+
if (ctx.path === '/admin/set-role' && target && [body.role].flat().join(',') === (target.role ?? 'user')) return ctx.json({ user: target })
|
|
283
|
+
}
|
|
284
|
+
export const platformAudit = {
|
|
285
|
+
before: createAuthMiddleware(auditBefore),
|
|
286
|
+
after: createAuthMiddleware(async (ctx) => {
|
|
287
|
+
// `get-full-organization` also answers invitations, invitee emails among them, to any member,
|
|
288
|
+
// so they are emptied unless the caller may invite, as the list route asks.
|
|
289
|
+
const full = ctx.context.returned as { id?: unknown; invitations?: unknown } | null | undefined
|
|
290
|
+
if (ctx.path === '/organization/get-full-organization' && full && typeof full === 'object' && Array.isArray(full.invitations)) {
|
|
291
|
+
const mayInvite = await gate('invitation.create')({ organization: full }).then(() => true, () => false)
|
|
292
|
+
if (!mayInvite) return ctx.json({ ...full, invitations: [] })
|
|
293
|
+
}
|
|
294
|
+
if (!audited(ctx.path) || ctx.context.returned instanceof Error) return
|
|
295
|
+
const caller = callers.get(ctx.context)
|
|
296
|
+
// A sign-out is audited only where it ends an impersonation, which is how the tenant app stops
|
|
297
|
+
// one: the provider's own stop route would put the staff member's session back under the tenant
|
|
298
|
+
// app's cookie, which the tenant app refuses.
|
|
299
|
+
if (ctx.path === '/sign-out' && !caller?.session.impersonatedBy) return
|
|
300
|
+
const body = (ctx.body ?? {}) as { userId?: unknown; role?: unknown; data?: { role?: unknown } }
|
|
301
|
+
const query = (ctx.query ?? {}) as { id?: unknown; userId?: unknown }
|
|
302
|
+
// The person acted on: named in the body or the query, or made by the call, `create-user`'s.
|
|
303
|
+
const target = body.userId ?? query.userId ?? query.id ?? (ctx.context.returned as { user?: { id?: unknown } } | undefined)?.user?.id
|
|
304
|
+
const role = roleOf(body)
|
|
305
|
+
console.log(JSON.stringify({
|
|
306
|
+
audit: 'platform',
|
|
307
|
+
action: ctx.path,
|
|
308
|
+
actor: caller?.user.id ?? null,
|
|
309
|
+
impersonatedBy: caller?.session.impersonatedBy ?? null,
|
|
310
|
+
...(target === undefined ? {} : { target }),
|
|
311
|
+
...(role === undefined ? {} : { role }),
|
|
312
|
+
at: new Date().toISOString(),
|
|
313
|
+
}))
|
|
314
|
+
if (ctx.path === '/admin/impersonate-user' && ctx.context.newSession) return handOff(ctx)
|
|
315
|
+
// Ending an impersonation sends the staff member back to the platform, whose session is still
|
|
316
|
+
// theirs, not to the tenant's sign-in. The answer keeps the provider's `success` and the
|
|
317
|
+
// cookies it cleared.
|
|
318
|
+
if (ctx.path === '/sign-out') return ctx.json({ success: true, url: platformURL })
|
|
319
|
+
}),
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// The platform never writes the tenant app's cookie (two origins, two cookies), so an impersonation
|
|
323
|
+
// answers a one-time link into the tenant app, which trades it for the session under its own
|
|
324
|
+
// cookie.
|
|
325
|
+
//
|
|
326
|
+
// The token is stored the way the one-time-token plugin stores its own, because that plugin's
|
|
327
|
+
// `verifyOneTimeToken` consumes it: once, setting the session cookie of whichever instance served
|
|
328
|
+
// the verify. Every cookie the impersonation set is dropped from the answer, so the platform's own
|
|
329
|
+
// session is untouched.
|
|
330
|
+
const handOff = async (ctx: HookContext) => {
|
|
331
|
+
const token = randomBytes(32).toString('base64url')
|
|
332
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
333
|
+
value: ctx.context.newSession!.session.token,
|
|
334
|
+
identifier: `one-time-token:${token}`,
|
|
335
|
+
expiresAt: new Date(Date.now() + 3 * 60 * 1000),
|
|
336
|
+
})
|
|
337
|
+
ctx.context.responseHeaders?.delete('set-cookie')
|
|
338
|
+
return ctx.json({ url: new URL(`/auth/handoff?token=${token}`, tenantURL).href })
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// A session is made only for staff or for the person an impersonation mimics, which the provider
|
|
342
|
+
// makes with `impersonatedBy` set. Anyone else gets the provider's wrong-password refusal, the one
|
|
343
|
+
// its sign-in throws for an unknown address and a wrong password alike. The hook runs after the
|
|
344
|
+
// password was checked, so nobody learns which accounts exist or hold a password.
|
|
345
|
+
//
|
|
346
|
+
// Without the endpoint context there is no adapter to read the person with, so the session is
|
|
347
|
+
// refused. The read is the transaction's where one is open.
|
|
348
|
+
type SessionBefore = typeof options.databaseHooks.session.create.before
|
|
349
|
+
const staffSession = (next: SessionBefore): SessionBefore => async (session, context) => {
|
|
350
|
+
const mimics = !!(session as { impersonatedBy?: unknown }).impersonatedBy
|
|
351
|
+
const user = mimics || !context ? null : await (await getCurrentAdapter(context.context.adapter as never)).findOne<{ role?: unknown }>({ model: 'user', where: [{ field: 'id', value: session.userId }] })
|
|
352
|
+
if (!mimics && !holdsRole(user?.role, (one) => Object.hasOwn(platformRoles, one))) {
|
|
353
|
+
throw APIError.from('UNAUTHORIZED', BASE_ERROR_CODES.INVALID_EMAIL_OR_PASSWORD)
|
|
354
|
+
}
|
|
355
|
+
return next(session, context)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Called once by the provider inside `createOrganization`, with the organization and the member row
|
|
360
|
+
* it made for the caller. The template uses it to give a new organization an administrator.
|
|
361
|
+
*/
|
|
362
|
+
export type AssignFirstRole = (data: {
|
|
363
|
+
organization: { id: string } & Record<string, unknown>
|
|
364
|
+
member: { id: string } & Record<string, unknown>
|
|
365
|
+
user: { id: string } & Record<string, unknown>
|
|
366
|
+
}) => Promise<void>
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The organization hooks that need this project's store, which only the factory below holds. A
|
|
370
|
+
* `beforeRemoveMember` or `beforeAcceptInvitation` here runs after the template's own, never in its
|
|
371
|
+
* place.
|
|
372
|
+
*/
|
|
373
|
+
type Joining = { organization: { id: string } & Record<string, unknown> }
|
|
374
|
+
export type StoreHooks = {
|
|
375
|
+
afterCreateOrganization?: AssignFirstRole
|
|
376
|
+
beforeRemoveMember?: (data: Joining & { member: { userId: string } & Record<string, unknown> }) => Promise<void>
|
|
377
|
+
beforeAcceptInvitation?: (data: Joining & { invitation: { role: string } & Record<string, unknown>; user: { id: string } & Record<string, unknown> }) => Promise<void>
|
|
378
|
+
beforeAddMember?: (data: Joining & { member: { userId: string } & Record<string, unknown> }) => Promise<void>
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Parses `TRUSTED_IP_HEADER`, the header names this deployment's proxy overwrites with the real
|
|
383
|
+
* client address (`cf-connecting-ip` behind Cloudflare, `x-forwarded-for` behind a reverse proxy
|
|
384
|
+
* configured to overwrite it, comma-separated for several). It feeds Better Auth's
|
|
385
|
+
* `advanced.ipAddress.ipAddressHeaders`.
|
|
386
|
+
*
|
|
387
|
+
* `undefined` when unset, so Better Auth keeps its default of `x-forwarded-for` alone. A header is
|
|
388
|
+
* trustworthy only when the proxy overwrites it on every request; otherwise any caller picks its
|
|
389
|
+
* own rate-limit bucket.
|
|
390
|
+
*/
|
|
391
|
+
export const trustedIpHeaders = (value: string | undefined): string[] | undefined => {
|
|
392
|
+
const headers = value
|
|
393
|
+
?.split(',')
|
|
394
|
+
.map((header) => header.trim())
|
|
395
|
+
.filter(Boolean)
|
|
396
|
+
return headers?.length ? headers : undefined
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const ipAddressHeaders = trustedIpHeaders(process.env.TRUSTED_IP_HEADER)
|
|
400
|
+
|
|
401
|
+
// Where a password reset link goes. No mail sender is configured, so the link is written to the
|
|
402
|
+
// server's log in development (see `logsLinks`). Anywhere else the provider refuses every address
|
|
403
|
+
// alike before it looks one up, so no token or address is written anywhere.
|
|
404
|
+
//
|
|
405
|
+
// The link is the tenant app's reset page, not the provider's `url`, which names
|
|
406
|
+
// `/api/auth/reset-password` while this project serves the provider under `/api/auth/user`.
|
|
407
|
+
const onLocalHost = !production && isLocalHost(tenantURL)
|
|
408
|
+
const sendResetPassword = async ({ user, token }: { user: { email: string }; token: string }) => {
|
|
409
|
+
console.log(`[asas auth] Password reset link for "${user.email}": ${new URL(`/auth/reset-password?token=${encodeURIComponent(token)}`, tenantURL).href}`)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The Better Auth options, with no `database`: the strategy supplies the tables mithaq built.
|
|
414
|
+
* `mithaq generate auth` reads this module to write the auth kayan.
|
|
415
|
+
*
|
|
416
|
+
* The hooks that write rows of this project (`StoreHooks`) are a parameter of `authOptions`
|
|
417
|
+
* instead. A hook changes no table, so the schema read here is the schema the runtime builds.
|
|
418
|
+
*/
|
|
419
|
+
export const options = {
|
|
420
|
+
// A reset ends every session of the account, in both apps, since they share one session table.
|
|
421
|
+
emailAndPassword: { enabled: true as const, revokeSessionsOnPasswordReset: true, ...(logsLinks ? { sendResetPassword } : {}) },
|
|
422
|
+
// The provider rate limits only when `NODE_ENV` is `production`, so a deploy that sets none would
|
|
423
|
+
// answer every sign-in and reset request unlimited. Off a local host it limits whatever
|
|
424
|
+
// `NODE_ENV` says.
|
|
425
|
+
rateLimit: { enabled: !onLocalHost },
|
|
426
|
+
// Better Auth reads it for the links it writes into mail and the origin it checks a request
|
|
427
|
+
// against; without it every boot prints `Base URL is not set`.
|
|
428
|
+
baseURL: tenantURL,
|
|
429
|
+
// The origins a browser may carry a cookie of this project to: the template's `vite dev`, `vite
|
|
430
|
+
// preview` and Next ports, outside production only. Better Auth adds `baseURL` itself. A project
|
|
431
|
+
// that serves a mobile client adds its scheme here.
|
|
432
|
+
trustedOrigins: production ? [] : ['http://localhost:3000', 'http://localhost:4173', 'http://localhost:5173'],
|
|
433
|
+
...(ipAddressHeaders ? { advanced: { ipAddress: { ipAddressHeaders } } } : {}),
|
|
434
|
+
// How an impersonation crosses from the platform app into the tenant app (`handOff`). The plugin
|
|
435
|
+
// adds no table, and no client may mint a token over HTTP.
|
|
436
|
+
plugins: [admin(adminOptions), organization(organizationOptions), oneTimeToken({ disableClientRequest: true })],
|
|
437
|
+
hooks: platformAudit,
|
|
438
|
+
// The active organization, set where the session is made. Every row this project reads is keyed
|
|
439
|
+
// on it and the provider sets it only when an organization is created, so a user who signed in
|
|
440
|
+
// held none and was refused every read. The user's first membership becomes active on a session
|
|
441
|
+
// that names none; a session that names one is left alone.
|
|
442
|
+
//
|
|
443
|
+
// The read goes through `getCurrentAdapter`, the transaction's adapter where the provider opened
|
|
444
|
+
// one: the base adapter would take a second pool client while the transaction holds the first,
|
|
445
|
+
// and a full pool would wait on itself.
|
|
446
|
+
databaseHooks: {
|
|
447
|
+
session: {
|
|
448
|
+
create: {
|
|
449
|
+
before: async (
|
|
450
|
+
session: { userId: string; activeOrganizationId?: string | null },
|
|
451
|
+
context: { context: { adapter: { findOne: <Row>(query: { model: string; where: { field: string; value: string }[] }) => Promise<Row | null> } } } | null,
|
|
452
|
+
) => {
|
|
453
|
+
if (session.activeOrganizationId || !context) return
|
|
454
|
+
const member = await (await getCurrentAdapter(context.context.adapter as never)).findOne<{ organizationId: string }>({ model: 'member', where: [{ field: 'userId', value: session.userId }] })
|
|
455
|
+
return member ? { data: { ...session, activeOrganizationId: member.organizationId } } : undefined
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* The same options with the provider's own access control built, which is what the template builds
|
|
464
|
+
* its server from. It is a function because the roles are made from an `ac` that has to exist
|
|
465
|
+
* first.
|
|
466
|
+
*
|
|
467
|
+
* The three built-in roles are the provider's defaults over the provider's own resources and grade
|
|
468
|
+
* nothing of this project: what a member may do is the access target's answer, kept apart so a
|
|
469
|
+
* governance name never widens a business grant. One reading is deliberate and narrow: a member
|
|
470
|
+
* whose `member.role` names the owner always holds the fixed `administrator` role in that tenant,
|
|
471
|
+
* so the owner is never left without the role that manages roles. A project narrows these where its
|
|
472
|
+
* own governance differs.
|
|
473
|
+
*/
|
|
474
|
+
export const authOptions = (hooks: StoreHooks = {}) => {
|
|
475
|
+
// Only the provider's own vocabulary. Merging this project's catalog in would make the provider's
|
|
476
|
+
// `owner` hold every project key before any role row was read, and a `roles` row could then widen
|
|
477
|
+
// and never narrow. The provider gates its own actions; this project's keys are the access
|
|
478
|
+
// target's.
|
|
479
|
+
const ac = createAccessControl({ ...defaultStatements })
|
|
480
|
+
// A role's type is a subset of the statements it was made from, and these are derived at runtime,
|
|
481
|
+
// so what a role holds is only a string list here. The assertion is at the one line that crosses
|
|
482
|
+
// that boundary.
|
|
483
|
+
type Grant = Parameters<typeof ac.newRole>[0]
|
|
484
|
+
const everything: Record<string, string[]> = Object.fromEntries(
|
|
485
|
+
Object.entries(ac.statements).map(([resource, actions]) => [resource, [...(actions as readonly string[])]]),
|
|
486
|
+
)
|
|
487
|
+
const roles = {
|
|
488
|
+
owner: ac.newRole(everything as Grant),
|
|
489
|
+
admin: ac.newRole({
|
|
490
|
+
...everything,
|
|
491
|
+
organization: (everything.organization ?? []).filter((one) => one !== 'delete'),
|
|
492
|
+
} as Grant),
|
|
493
|
+
member: ac.newRole({ ac: ['read'] }),
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
options: {
|
|
497
|
+
...options,
|
|
498
|
+
plugins: [
|
|
499
|
+
// The same object as in `options`, so the schema `mithaq generate auth` reads is the schema
|
|
500
|
+
// the runtime builds. Leaving it out of the rebuilt list would drop five columns from the
|
|
501
|
+
// runtime that the generated kayan still describe.
|
|
502
|
+
admin(adminOptions),
|
|
503
|
+
oneTimeToken({ disableClientRequest: true }),
|
|
504
|
+
organization({
|
|
505
|
+
...organizationOptions,
|
|
506
|
+
organizationHooks: {
|
|
507
|
+
...organizationOptions.organizationHooks,
|
|
508
|
+
// The tenant's first assignment, which nothing inside the project can make: a role
|
|
509
|
+
// holding `memberRoles.create` has to be assigned before it can assign anything, and
|
|
510
|
+
// the caller of `createOrganization` is in no organization until that route puts it in
|
|
511
|
+
// one. So the row is written straight through the store, and the organization has a
|
|
512
|
+
// root granter from its first moment.
|
|
513
|
+
...hooks,
|
|
514
|
+
beforeRemoveMember: async (data: Parameters<NonNullable<StoreHooks['beforeRemoveMember']>>[0]) => {
|
|
515
|
+
await organizationOptions.organizationHooks.beforeRemoveMember(data)
|
|
516
|
+
await hooks.beforeRemoveMember?.(data)
|
|
517
|
+
},
|
|
518
|
+
beforeAcceptInvitation: async (data: Parameters<NonNullable<StoreHooks['beforeAcceptInvitation']>>[0]) => {
|
|
519
|
+
await organizationOptions.organizationHooks.beforeAcceptInvitation(data)
|
|
520
|
+
await hooks.beforeAcceptInvitation?.(data)
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
ac,
|
|
524
|
+
roles,
|
|
525
|
+
}),
|
|
526
|
+
],
|
|
527
|
+
},
|
|
528
|
+
roles,
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Every Better Auth instance this project runs, one key per kayan it authenticates. `mithaq
|
|
534
|
+
* generate auth` reads `options` to write that kayan's directory, and the generated kayan reads
|
|
535
|
+
* `server` back, so one record is both halves of one instance.
|
|
536
|
+
*
|
|
537
|
+
* A second population is a second key here: a second kayan, cookie name and `/api/auth/<kayan>/*`
|
|
538
|
+
* prefix. Its tables carry their own `modelName`, because one database holds them all.
|
|
539
|
+
*/
|
|
540
|
+
export const providers = {
|
|
541
|
+
user: {
|
|
542
|
+
options,
|
|
543
|
+
/**
|
|
544
|
+
* Built from the targets the language carries and the vocabulary the build derived. Neither
|
|
545
|
+
* exists where a kayan is written, so this is a factory the kayan names.
|
|
546
|
+
*/
|
|
547
|
+
server: (targets: {
|
|
548
|
+
drizzle: {
|
|
549
|
+
db: Parameters<typeof drizzleAdapter>[0]
|
|
550
|
+
tables: Record<string, unknown>
|
|
551
|
+
insert(args: { tx: unknown; kayan: string; data: Record<string, unknown> }): Promise<unknown>
|
|
552
|
+
remove(args: { tx: unknown; kayan: string; id: unknown }): Promise<unknown>
|
|
553
|
+
find(args: { tx?: unknown; kayan: string; args: { limit: number; page: number; sort: { field: string; direction: 'asc' | 'desc' }; where?: Where } }): Promise<{ docs: Record<string, unknown>[] }>
|
|
554
|
+
transaction<Result>(run: (tx: unknown) => Promise<Result>): Promise<Result>
|
|
555
|
+
count?(args: { kayan: string; args: { where?: Where } }): Promise<number>
|
|
556
|
+
}
|
|
557
|
+
}) => {
|
|
558
|
+
// The tenant's first assignment, written where the store is in hand. It goes straight through
|
|
559
|
+
// the store rather than through `asas.kayan.memberRoles.operations.create`, because a managed
|
|
560
|
+
// create is graded against the actor making it and the caller of `createOrganization` holds
|
|
561
|
+
// nothing yet. The row is `administrator`, which holds `'*'`, so every later assignment is
|
|
562
|
+
// the gated one.
|
|
563
|
+
const store = targets.drizzle
|
|
564
|
+
// The drizzle store always counts; `Store` types it optional for stores that cannot.
|
|
565
|
+
const heldRoles = (organization: string, user: string) => store.count!({ kayan: 'memberRoles', args: { where: { organization: { equals: organization }, user: { equals: user } } } })
|
|
566
|
+
const holdsRoles = () => new APIError('CONFLICT', { code: 'error.conflict.member_holds_roles', message: 'This member still holds a role in this organization. Take each role from them first.' })
|
|
567
|
+
// The roles a person holds in an organization they are not a member of, taken as they come
|
|
568
|
+
// back in by an accepted invitation or an added membership: nobody gave them anew, and the
|
|
569
|
+
// assignment guards never ran for whoever invited them back. Taken rather than refused,
|
|
570
|
+
// because no screen reaches the roles of a non-member and a refusal would shut the person out
|
|
571
|
+
// for good.
|
|
572
|
+
const clearsLeftRoles = async ({ organization, user }: { organization: string; user: string }) => {
|
|
573
|
+
// A current member left no organization: the provider runs this hook before it notices that
|
|
574
|
+
// an old invitation or an added membership names a member already, and their roles are
|
|
575
|
+
// theirs.
|
|
576
|
+
if (await store.count!({ kayan: 'member', args: { where: { organizationId: { equals: organization }, userId: { equals: user } } } })) return
|
|
577
|
+
if (!(await heldRoles(organization, user))) return
|
|
578
|
+
await store.transaction(async (tx) => {
|
|
579
|
+
const left = await store.find({ tx, kayan: 'memberRoles', args: { limit: 0, page: 1, sort: { field: 'id', direction: 'asc' }, where: { organization: { equals: organization }, user: { equals: user } } } })
|
|
580
|
+
for (const row of left.docs) await store.remove({ tx, kayan: 'memberRoles', id: row.id })
|
|
581
|
+
})
|
|
582
|
+
}
|
|
583
|
+
const built = authOptions({
|
|
584
|
+
afterCreateOrganization: async ({ organization, user: creator }) => {
|
|
585
|
+
const written = new Date()
|
|
586
|
+
await store.transaction((tx) =>
|
|
587
|
+
store.insert({
|
|
588
|
+
tx,
|
|
589
|
+
kayan: 'memberRoles',
|
|
590
|
+
data: {
|
|
591
|
+
createdAt: written,
|
|
592
|
+
role: 'administrator',
|
|
593
|
+
organization: organization.id,
|
|
594
|
+
updatedAt: written,
|
|
595
|
+
user: creator.id,
|
|
596
|
+
},
|
|
597
|
+
}),
|
|
598
|
+
)
|
|
599
|
+
},
|
|
600
|
+
// A member who still holds a role of this project in the organization is not removed. The
|
|
601
|
+
// provider deletes the member row alone and leaves this project's rows behind, which are
|
|
602
|
+
// read again the moment the person is a member again. Their roles are taken first, each
|
|
603
|
+
// through the assignment kayan's own delete, whose guards decide whether this caller may
|
|
604
|
+
// take that role. The Members page does that before removing, so a person sees this refusal
|
|
605
|
+
// only where a role was given in between or the route was called by hand.
|
|
606
|
+
//
|
|
607
|
+
// Counted outside any transaction, so a role assigned between the count and the delete is
|
|
608
|
+
// left behind; it is dormant while the person is no member and taken as they come back in
|
|
609
|
+
// (`clearsLeftRoles`).
|
|
610
|
+
beforeRemoveMember: async ({ member, organization }) => {
|
|
611
|
+
if (await heldRoles(organization.id, member.userId)) throw holdsRoles()
|
|
612
|
+
},
|
|
613
|
+
beforeAcceptInvitation: ({ user, organization }) => clearsLeftRoles({ organization: organization.id, user: user.id }),
|
|
614
|
+
beforeAddMember: ({ member, organization }) => clearsLeftRoles({ organization: organization.id, user: member.userId }),
|
|
615
|
+
}).options
|
|
616
|
+
// `transaction: true` makes the provider's own transaction helper open a real transaction
|
|
617
|
+
// wherever it asks for one: a sign-up writes its user, account and session in one, and an
|
|
618
|
+
// accepted invitation its member and the session's organization. Without it each statement
|
|
619
|
+
// runs on its own.
|
|
620
|
+
const database = drizzleAdapter(store.db, { provider: 'pg', schema: store.tables, transaction: true })
|
|
621
|
+
// Two instances over one database and one user table, one per app, so a person is one row and
|
|
622
|
+
// one password in both. The tenant app's refuses every route of the admin plugin, and its
|
|
623
|
+
// stop route, which would put a staff member's session back under the tenant app's cookie.
|
|
624
|
+
// The plugin stays in it because it refuses a banned user a session and carries
|
|
625
|
+
// `impersonatedBy` onto the session it reads.
|
|
626
|
+
const tenantOnly = Object.values(admin(adminOptions).endpoints).map((endpoint) => endpoint.path)
|
|
627
|
+
// Leaving is refused while a role is held, as a removal is above. The provider's leave route
|
|
628
|
+
// runs no hook, so it is matched by path, on both instances. A member who holds a
|
|
629
|
+
// role-managing role takes it from nobody else, so leaves only once another member has taken
|
|
630
|
+
// it from them. An owner who holds an `administrator` row never leaves and is never removed,
|
|
631
|
+
// because that row cannot be taken (`error.forbidden.owner_role`); that is stricter than the
|
|
632
|
+
// provider, which lets an owner go while another remains, and intended.
|
|
633
|
+
const keepsRoles = {
|
|
634
|
+
id: 'keeps-roles',
|
|
635
|
+
hooks: { before: [{
|
|
636
|
+
matcher: (ctx: { path?: string }) => ctx.path === '/organization/leave',
|
|
637
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
638
|
+
const organization = (ctx.body as { organizationId?: unknown } | undefined)?.organizationId
|
|
639
|
+
const caller = await getSessionFromCtx(ctx).catch(() => null)
|
|
640
|
+
if (typeof organization !== 'string' || !caller) return
|
|
641
|
+
if (await heldRoles(organization, caller.user.id)) throw holdsRoles()
|
|
642
|
+
}),
|
|
643
|
+
}] },
|
|
644
|
+
}
|
|
645
|
+
const tenant = betterAuthServer({ ...built, plugins: [...built.plugins, keepsRoles], disabledPaths: tenantOnly, database })
|
|
646
|
+
// A person who holds a seat, a withdrawn one included, is not removed: the enrollment records
|
|
647
|
+
// that they attended, and its `student` key restricts. The provider's `deleteUser` runs in no
|
|
648
|
+
// transaction, so the key alone would fail after the person's sessions and accounts were
|
|
649
|
+
// gone, leaving someone who can never sign in again. So it is refused before anything goes.
|
|
650
|
+
// The store's count reads every row, marked ones included. The id is read the way the route
|
|
651
|
+
// reads it, after its own `z.coerce.string()`, so an id sent as a list or a number is the
|
|
652
|
+
// same person here as there.
|
|
653
|
+
const seated = {
|
|
654
|
+
id: 'seated',
|
|
655
|
+
hooks: { before: [{
|
|
656
|
+
matcher: (ctx: { path?: string }) => ctx.path === '/admin/remove-user',
|
|
657
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
658
|
+
const raw = (ctx.body as { userId?: unknown } | undefined)?.userId
|
|
659
|
+
if (raw === undefined || raw === null || !(await getSessionFromCtx(ctx).catch(() => null))) return
|
|
660
|
+
// The drizzle store always counts; `Store` types it optional for stores that cannot.
|
|
661
|
+
if (await store.count!({ kayan: 'enrollments', args: { where: { student: { equals: String(raw) } } } })) {
|
|
662
|
+
throw new APIError('CONFLICT', { code: 'error.conflict.referenced', message: 'This person holds a seat in a session, a withdrawn one included, so they are not removed while an enrollment records them.' })
|
|
663
|
+
}
|
|
664
|
+
}),
|
|
665
|
+
}] },
|
|
666
|
+
}
|
|
667
|
+
// The platform app's own instance: its own cookie name, so on `localhost`, where ports do not
|
|
668
|
+
// keep cookies apart, the two apps never read each other's session; its own secret, so
|
|
669
|
+
// neither signs a cookie the other accepts; its own origin alone trusted, so a tenant page
|
|
670
|
+
// cannot post to it with the platform's cookie; no verify of a one-time token, which would
|
|
671
|
+
// turn an impersonation into a platform session; and no sign-up, since staff are people the
|
|
672
|
+
// platform already has.
|
|
673
|
+
const platform = betterAuthServer({
|
|
674
|
+
...built,
|
|
675
|
+
plugins: [...built.plugins, seated, keepsRoles],
|
|
676
|
+
baseURL: platformURL,
|
|
677
|
+
trustedOrigins: platformOrigins,
|
|
678
|
+
secret: platformSecret,
|
|
679
|
+
advanced: { ...built.advanced, cookiePrefix: platformCookiePrefix },
|
|
680
|
+
// Nor a password reset of its own: staff reset through the tenant app's page like anyone,
|
|
681
|
+
// and the reset ends their sessions here too.
|
|
682
|
+
disabledPaths: ['/one-time-token/verify', '/sign-up/email', '/request-password-reset', '/reset-password'],
|
|
683
|
+
databaseHooks: { session: { create: { before: staffSession(built.databaseHooks.session.create.before) } } },
|
|
684
|
+
database,
|
|
685
|
+
})
|
|
686
|
+
// One object for the kayan, which answers from whichever instance serves the request.
|
|
687
|
+
return new Proxy(tenant, { get: (_target, key) => Reflect.get(onPlatform() ? platform : tenant, key) })
|
|
688
|
+
},
|
|
689
|
+
},
|
|
690
|
+
}
|