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,888 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { notFound, redirect, useMatches, useRouter } from '@tanstack/react-router'
|
|
3
|
+
import { createIsomorphicFn, createServerFn, createServerOnlyFn } from '@tanstack/react-start'
|
|
4
|
+
import { Suspense, lazy, useEffect, useState, type ComponentType, type ReactElement, type ReactNode } from 'react'
|
|
5
|
+
import { holds, navOf, referencesIn, type AccessReport, type ClientKayan, type LoaderTools, type Row, type ScreenClient } from 'mithaq/admin'
|
|
6
|
+
import { ScreenContext, useScreen, type ScreenState } from 'mithaq/admin/react'
|
|
7
|
+
import { actingAs, arm, authArm, caused, clientOf, readsOf, takesKey } from './api.ts'
|
|
8
|
+
import { clampedHref, routedHref } from './links.ts'
|
|
9
|
+
import { mergedHeaders } from './request-headers.ts'
|
|
10
|
+
import { nameIn } from 'asas/organizations'
|
|
11
|
+
import { documentHead, localeOf, locales, translator, type Locale } from './copy.ts'
|
|
12
|
+
import { FieldValue } from 'mithaq-admin/field-value.tsx'
|
|
13
|
+
import type { FormClient, FormProps } from 'mithaq-admin/form-types.tsx'
|
|
14
|
+
import type { Page } from 'mithaq-admin/list-types.tsx'
|
|
15
|
+
import { QueryRefused, findOf, listRaw, prefixOf, readQuery, rulesOf, scopedTo, withList, type RawQuery } from 'mithaq-admin/list-query.ts'
|
|
16
|
+
import { newTitleOf, titleOf } from 'mithaq-admin/copy.ts'
|
|
17
|
+
import { createsIn } from 'mithaq-admin/field-values.ts'
|
|
18
|
+
import { NavLinks } from 'mithaq-admin/nav-links.tsx'
|
|
19
|
+
import { NodeButton, OwnButton, pageButtonOf } from 'mithaq-admin/node-buttons.tsx'
|
|
20
|
+
import { formIn, listIn, nodesOf, readsAsValues, relatedIn, scopeOf, type Draw } from 'mithaq-admin/node-tree.ts'
|
|
21
|
+
import { frameOf, pageOf } from 'mithaq-admin/nodes.tsx'
|
|
22
|
+
import { RecordFrame, holdFocus } from 'mithaq-admin/record-frame.tsx'
|
|
23
|
+
import { rowTitle } from 'mithaq-admin/row-title.tsx'
|
|
24
|
+
import { refOf, type Refs, type TreePage } from 'mithaq-admin/screen-refs.ts'
|
|
25
|
+
import { idsOf } from 'mithaq-admin/relationship-control.tsx'
|
|
26
|
+
import { ScreenForm } from './screen-form.tsx'
|
|
27
|
+
import { ScreenList, askedFor, readsDrafts, rowReadsDrafts, rowsKey } from './screen-list.tsx'
|
|
28
|
+
import { screenNamed } from './screen-names.ts'
|
|
29
|
+
import { slide } from './slide.ts'
|
|
30
|
+
import { Toaster } from 'mithaq-admin/toast.tsx'
|
|
31
|
+
import { useCopy } from './routes/__root.tsx'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The built asas, on the server alone. The module cache serves one build for
|
|
35
|
+
* every call, since `asas` stays out of the server bundle; a changed `asas` takes a restart.
|
|
36
|
+
*/
|
|
37
|
+
export const getAsas = createServerOnlyFn(async () => (await import('asas')).asas)
|
|
38
|
+
|
|
39
|
+
type InProcess = Record<string, { operations: Record<string, (input: object, options: object) => Promise<unknown>> }>
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Each kayan's operations in process. In the backoffice app a read of a kayan asks `tenant: 'all'`,
|
|
43
|
+
* which the runner grants backoffice staff there alone, so staff read every organization's rows; a
|
|
44
|
+
* global is one organization's row and asks for none, and a write never asks.
|
|
45
|
+
*/
|
|
46
|
+
const inProcess = async (asas: Awaited<ReturnType<typeof getAsas>>): Promise<InProcess> => {
|
|
47
|
+
const kayan = asas.kayan as unknown as InProcess
|
|
48
|
+
if (!(await import('asas')).onBackoffice()) return kayan
|
|
49
|
+
const reads = new Set(asas.operations.filter((one) => one.effect === 'read' && !one.single).map((one) => `${one.kayan}/${one.name}`))
|
|
50
|
+
return Object.fromEntries(Object.entries(kayan).map(([name, one]) => [name, {
|
|
51
|
+
operations: Object.fromEntries(Object.entries(one.operations).map(([operation, run]) =>
|
|
52
|
+
[operation, reads.has(`${name}/${operation}`) ? (input: object, options: object) => run(input, { ...options, tenant: 'all' }) : run])),
|
|
53
|
+
}]))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One screen's generated glob: each file its references name, keyed by the path a reference prints, loaded only when called. */
|
|
57
|
+
export type Files = Readonly<Record<string, () => Promise<unknown>>>
|
|
58
|
+
|
|
59
|
+
type Drawn = ComponentType<Record<string, unknown>>
|
|
60
|
+
const loaded = new Map<string, Drawn>()
|
|
61
|
+
const drawn = new Map<string, Drawn>()
|
|
62
|
+
|
|
63
|
+
const load = async (files: Files, reference: string) => {
|
|
64
|
+
const at = reference.indexOf('#')
|
|
65
|
+
const exported = ((await files[reference.slice(0, at)]?.()) as Record<string, Drawn> | undefined)?.[reference.slice(at + 1)]
|
|
66
|
+
if (exported) loaded.set(reference, exported)
|
|
67
|
+
return exported
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One `lazy` per reference. A loaded file is handed over as a thenable that
|
|
71
|
+
// resolves at once, which React reads without suspending; an unloaded one
|
|
72
|
+
// (hydration before any loader ran) suspends.
|
|
73
|
+
const drawnOf = (files: Files, reference: string) => {
|
|
74
|
+
let found = drawn.get(reference)
|
|
75
|
+
if (!found) {
|
|
76
|
+
found = lazy(() => {
|
|
77
|
+
const ready = loaded.get(reference)
|
|
78
|
+
return ready
|
|
79
|
+
? ({ then: (resolve: (module: { default: Drawn }) => void) => resolve({ default: ready }) } as unknown as Promise<{ default: Drawn }>)
|
|
80
|
+
: load(files, reference).then((exported) => ({ default: exported ?? (() => null) }))
|
|
81
|
+
})
|
|
82
|
+
drawn.set(reference, found)
|
|
83
|
+
}
|
|
84
|
+
return found
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const refsOf = (files: Files, screen: ScreenClient): Refs =>
|
|
88
|
+
Object.fromEntries(
|
|
89
|
+
referencesIn(screen)
|
|
90
|
+
.filter((reference) => Object.hasOwn(files, reference.slice(0, reference.indexOf('#'))))
|
|
91
|
+
.map((reference) => [reference, drawnOf(files, reference)]),
|
|
92
|
+
) as Refs
|
|
93
|
+
|
|
94
|
+
const preload = (files: Files, ...parts: unknown[]) => Promise.all(referencesIn(parts).map((reference) => loaded.get(reference) ?? load(files, reference)))
|
|
95
|
+
|
|
96
|
+
/** This build's screens by name: what a request's screen name is checked against before anything is read by it. */
|
|
97
|
+
const screensOf = (targets: unknown) =>
|
|
98
|
+
Object.fromEntries(Object.entries(targets as Record<string, { capability?: string } | undefined>).filter(([, target]) => target?.capability === 'screen'))
|
|
99
|
+
|
|
100
|
+
const segments = (path: string) => path.split('/').filter(Boolean)
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The page a path asks for, a kayan's or the screen's own, longest first and a
|
|
104
|
+
* static segment before a parameter, so `/courses/new` is the create page, not
|
|
105
|
+
* an edit of a row called `new`.
|
|
106
|
+
*/
|
|
107
|
+
export const matchPage = (screen: ScreenClient, path: string) => {
|
|
108
|
+
const asked = segments(path)
|
|
109
|
+
const pages = [
|
|
110
|
+
...Object.entries(screen.pages).map(([name, page]) => ({ kayan: undefined, name, page, parts: segments(page.path) })),
|
|
111
|
+
...Object.values(screen.kayan).flatMap((kayan) => Object.entries(kayan.pages).map(([name, page]) => ({ kayan: kayan.name as string | undefined, name, page, parts: segments(page.path) }))),
|
|
112
|
+
]
|
|
113
|
+
.sort((a, b) => b.parts.length - a.parts.length || a.parts.filter((one) => one.startsWith(':')).length - b.parts.filter((one) => one.startsWith(':')).length)
|
|
114
|
+
for (const one of pages) {
|
|
115
|
+
if (one.parts.length !== asked.length) continue
|
|
116
|
+
const params: Record<string, string> = {}
|
|
117
|
+
if (one.parts.every((part, at) => (part.startsWith(':') ? ((params[part.slice(1)] = asked[at]!), true) : part === asked[at]))) {
|
|
118
|
+
return { kayan: one.kayan, page: one.name, params }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
type Actor = {
|
|
125
|
+
readonly id?: string
|
|
126
|
+
readonly name?: string | null
|
|
127
|
+
readonly email?: string | null
|
|
128
|
+
readonly impersonatedBy?: unknown
|
|
129
|
+
readonly activeOrganizationId?: string | null
|
|
130
|
+
readonly organizations?: readonly { readonly id: string; readonly name: string; readonly slug?: string; readonly names?: unknown }[]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// `'unauthorized'` is answered before the projection is read, so a refused screen sends no kayan, field, row or page;
|
|
134
|
+
// what is returned is serialized into the document, so projection and report are cut to what the actor may read.
|
|
135
|
+
const resolveScreen = async (asked: unknown) => {
|
|
136
|
+
const [{ getRequest, setResponseHeader }, asas] = await Promise.all([import('@tanstack/react-start/server'), getAsas()])
|
|
137
|
+
const name = screenNamed(screensOf(asas.targets), asked)
|
|
138
|
+
const request = getRequest()
|
|
139
|
+
const ask = (path: string) => asas.handler(new Request(new URL(path, request.url), { headers: request.headers }))
|
|
140
|
+
await slide({ strategies: asas.auth.strategies, headers: request.headers, setResponseHeader })
|
|
141
|
+
// Off the strategy loop, not the provider's session route: in process, so
|
|
142
|
+
// no request counts against its rate limit.
|
|
143
|
+
const actor = (await asas.auth.actor(request.headers)) as Actor | null
|
|
144
|
+
if (!actor?.email) return undefined
|
|
145
|
+
const report = (await (await ask('/api/access')).json()) as AccessReport
|
|
146
|
+
if (!report.screens?.includes(name)) return 'unauthorized' as const
|
|
147
|
+
const full = (asas.targets as unknown as Record<string, { client?: ScreenClient } | undefined>)[name]!.client!
|
|
148
|
+
const kayan = Object.fromEntries(
|
|
149
|
+
Object.entries(full.kayan)
|
|
150
|
+
.filter(([one]) => report.collections?.[one]?.read === true)
|
|
151
|
+
.map(([one, value]) => [one, { ...value, fields: value.fields.filter((field) => report.collections?.[one]?.fields?.[field.name]?.read === true) }]),
|
|
152
|
+
)
|
|
153
|
+
const { onBackoffice, backofficeEntry, BACKOFFICE_SCREEN } = await import('asas')
|
|
154
|
+
const backoffice = backofficeEntry(actor)
|
|
155
|
+
// The other screens of this app the actor may enter. The backoffice's own name
|
|
156
|
+
// is left out of a tenant app's list, so the one link to it is `backofficeEntry`'s alone.
|
|
157
|
+
const served = screensOf(asas.targets) as Record<string, { client: ScreenClient }>
|
|
158
|
+
const siblings = (report.screens ?? [])
|
|
159
|
+
.filter((one) => one !== name && Object.hasOwn(served, one) && (one === BACKOFFICE_SCREEN) === onBackoffice())
|
|
160
|
+
.map((one) => ({ name: one, path: served[one]!.client.path }))
|
|
161
|
+
const others = [...siblings, ...(backoffice ? [{ name: BACKOFFICE_SCREEN, path: backoffice }] : [])]
|
|
162
|
+
return {
|
|
163
|
+
asas,
|
|
164
|
+
request,
|
|
165
|
+
name,
|
|
166
|
+
report,
|
|
167
|
+
// Whole, so a page of a kayan the cut leaves out is told from a path naming no page.
|
|
168
|
+
full,
|
|
169
|
+
user: {
|
|
170
|
+
...(actor.id ? { id: actor.id } : {}),
|
|
171
|
+
name: actor.name ?? actor.email,
|
|
172
|
+
email: actor.email,
|
|
173
|
+
// Only the fact; the id of the person behind the session stays on the server.
|
|
174
|
+
...(actor.impersonatedBy ? { impersonating: true } : {}),
|
|
175
|
+
...(others.length ? { screens: others } : {}),
|
|
176
|
+
...(actor.organizations && {
|
|
177
|
+
// Every locale's, because the frame is read before the viewer's locale is known here; the shell picks one.
|
|
178
|
+
organizations: actor.organizations.map(({ id, name, slug, names }) => ({ id, name, slug, ...(typeof names === 'string' && { names }) })),
|
|
179
|
+
activeOrganizationId: actor.activeOrganizationId ?? null,
|
|
180
|
+
}),
|
|
181
|
+
},
|
|
182
|
+
access: {
|
|
183
|
+
// So `holds` answers a `screen.<name>` key in the browser as on the server.
|
|
184
|
+
...(report.screens === undefined ? {} : { screens: report.screens }),
|
|
185
|
+
collections: Object.fromEntries(Object.keys(kayan).map((one) => [one, report.collections![one]])),
|
|
186
|
+
// For a key neither a screen's nor an operation's, such as `user.ban`.
|
|
187
|
+
...(report.permissions === undefined ? {} : { permissions: report.permissions }),
|
|
188
|
+
} satisfies AccessReport,
|
|
189
|
+
// The nav is cut from the whole report, before the report is cut to the screen's kayan.
|
|
190
|
+
screen: { ...full, kayan, nav: navOf(full, report) } as ScreenClient,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The layout route's answer: who, what they may do, and the screen. */
|
|
195
|
+
export const loadScreen = createServerFn({ method: 'GET', strict: { output: false } })
|
|
196
|
+
// Checked against this build's screens in `resolveScreen`.
|
|
197
|
+
.validator((screen: unknown) => screen)
|
|
198
|
+
.handler(async ({ data }) => {
|
|
199
|
+
const found = await resolveScreen(data)
|
|
200
|
+
return found === 'unauthorized' ? found : found ? { name: found.name, user: found.user, access: found.access, screen: found.screen } : null
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* What `/unauthorized` offers a signed-in actor: every screen the report lists for it, `null` without a session.
|
|
205
|
+
* `from` is only matched against this build's screens and pages, so nothing a caller writes is said back.
|
|
206
|
+
*/
|
|
207
|
+
export const loadEnterable = createServerFn({ method: 'GET' })
|
|
208
|
+
.validator((asked?: { from?: unknown; locale?: unknown }) => ({
|
|
209
|
+
from: typeof asked?.from === 'string' ? asked.from : undefined,
|
|
210
|
+
locale: localeOf(typeof asked?.locale === 'string' ? asked.locale : undefined),
|
|
211
|
+
}))
|
|
212
|
+
.handler(async ({ data }): Promise<{
|
|
213
|
+
screens: { name: string; path: string }[]
|
|
214
|
+
refused?: { page: string; screen: { name: string; path: string } }
|
|
215
|
+
email?: string
|
|
216
|
+
organizations?: { id: string; name: string; slug?: string }[]
|
|
217
|
+
others?: { id: string; name: string; slug?: string }[]
|
|
218
|
+
impersonating?: string
|
|
219
|
+
} | null> => {
|
|
220
|
+
const [{ getRequest }, asas, { onBackoffice, BACKOFFICE_SCREEN }] = await Promise.all([import('@tanstack/react-start/server'), getAsas(), import('asas')])
|
|
221
|
+
const request = getRequest()
|
|
222
|
+
const actor = (await asas.auth.actor(request.headers)) as Actor | null
|
|
223
|
+
if (!actor?.email) return null
|
|
224
|
+
const report = (await (await asas.handler(new Request(new URL('/api/access', request.url), { headers: request.headers }))).json()) as AccessReport
|
|
225
|
+
const screens = screensOf(asas.targets) as Record<string, { client: ScreenClient }>
|
|
226
|
+
const entered = (report.screens ?? [])
|
|
227
|
+
.filter((name) => Object.hasOwn(screens, name) && (name === BACKOFFICE_SCREEN) === onBackoffice())
|
|
228
|
+
.map((name) => ({ name, path: screens[name]!.client.path }))
|
|
229
|
+
const impersonating = actor.impersonatedBy ? { impersonating: actor.name ?? actor.email } : {}
|
|
230
|
+
if (entered.length) {
|
|
231
|
+
const { from, locale } = data
|
|
232
|
+
const home = from === undefined ? undefined : entered.find((one) => from === one.path || from.startsWith(`${one.path}/`))
|
|
233
|
+
const match = home && matchPage(screens[home.name]!.client, from!.slice(home.path.length))
|
|
234
|
+
if (!home || !match) return { screens: entered, ...impersonating }
|
|
235
|
+
const page = nameOfPage({ screen: screens[home.name]!.client, name: home.name, match, copy: { t: translator(locale), locale } })
|
|
236
|
+
return { screens: entered, refused: { page, screen: home }, ...impersonating }
|
|
237
|
+
}
|
|
238
|
+
const memberships = actor.organizations ?? []
|
|
239
|
+
const active = memberships.find((one) => one.id === actor.activeOrganizationId)
|
|
240
|
+
// Named here, where the locale is known: the page draws what it is handed, and a name the viewer cannot read
|
|
241
|
+
// in the default locale would be no name to them.
|
|
242
|
+
const named = ({ id, name, slug, names }: NonNullable<Actor['organizations']>[number]) => ({ id, name: nameIn({ name, names }, data.locale), slug })
|
|
243
|
+
return {
|
|
244
|
+
screens: entered,
|
|
245
|
+
email: actor.email,
|
|
246
|
+
organizations: (active ? [active] : memberships).map(named),
|
|
247
|
+
others: onBackoffice() ? [] : memberships.filter((one) => one !== active).map(named),
|
|
248
|
+
...impersonating,
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
export { askedFor }
|
|
253
|
+
|
|
254
|
+
const holdsForm = (page: TreePage) => formIn(nodesOf(page)) !== undefined
|
|
255
|
+
|
|
256
|
+
const relatedOf = (screen: ScreenClient, kayan: ClientKayan) => Object.values(screen.kayan).filter((one) => kayan.fields.some((field) => field.relationTo === one.name))
|
|
257
|
+
|
|
258
|
+
const scopeTo = (node: { where?: string }, id: unknown) => ({ [node.where!]: { equals: id } })
|
|
259
|
+
|
|
260
|
+
/** One page's answer: which page the path asked for, the row a form draws, the rows a list draws, and what this actor may write. */
|
|
261
|
+
export const loadPage = createServerFn({ method: 'GET', strict: { output: false } })
|
|
262
|
+
.validator((asked: { screen: unknown; path: string; query: Record<string, unknown> }) => ({
|
|
263
|
+
screen: asked.screen,
|
|
264
|
+
path: String(asked.path),
|
|
265
|
+
query: askedFor(asked.query),
|
|
266
|
+
}))
|
|
267
|
+
.handler(async ({ data }) => {
|
|
268
|
+
const found = await resolveScreen(data.screen)
|
|
269
|
+
if (!found) return 'signed out' as const
|
|
270
|
+
if (found === 'unauthorized') return found
|
|
271
|
+
const { asas, screen } = found
|
|
272
|
+
const match = matchPage(screen, data.path)
|
|
273
|
+
// A page of a kayan the actor may not read is refused like a page whose key
|
|
274
|
+
// it does not hold, so one role meets one denial page.
|
|
275
|
+
if (!match) return matchPage(found.full, data.path) ? ('unauthorized' as const) : null
|
|
276
|
+
// A page of the screen's own reads nothing, but one naming a key is served
|
|
277
|
+
// only to an actor the report says holds it; its nav entry is only chrome.
|
|
278
|
+
if (match.kayan === undefined) {
|
|
279
|
+
const permission = screen.pages[match.page]?.permission
|
|
280
|
+
if (permission !== undefined && !holds(found.report, permission)) return 'unauthorized' as const
|
|
281
|
+
return { name: found.name, user: found.user, access: found.access, screen, kayan: undefined, page: match.page, value: null, first: undefined, related: {}, refused: [], creates: false, operations: {}, writable: {}, drafts: undefined }
|
|
282
|
+
}
|
|
283
|
+
const kayan = screen.kayan[match.kayan]
|
|
284
|
+
if (!kayan) return null
|
|
285
|
+
const page = kayan.pages[match.page]!
|
|
286
|
+
const tree = 'page' in page ? page : undefined
|
|
287
|
+
const actor = (await asas.auth.actor(found.request.headers)) ?? undefined
|
|
288
|
+
const operations = (await inProcess(asas)) as unknown as Record<string, { operations: Record<string, (input: object, options: { actor?: unknown; draft?: boolean }) => Promise<unknown>> } | undefined>
|
|
289
|
+
const globals = asas.global as unknown as typeof operations
|
|
290
|
+
const readRow = async (id: string, draft: boolean) =>
|
|
291
|
+
(await operations[kayan.name]!.operations.findByID!({ id }, { actor, ...(draft && { draft }) }).catch((thrown: unknown) => {
|
|
292
|
+
if (!caused(thrown)) throw thrown
|
|
293
|
+
return null
|
|
294
|
+
})) as Row | null
|
|
295
|
+
const report = found.access.collections?.[kayan.name]
|
|
296
|
+
// A create page with no field to write is refused like a page whose key is
|
|
297
|
+
// not held, not drawn as an empty form.
|
|
298
|
+
if (match.page === 'create' && !createsIn({ kayan, report })) return 'unauthorized' as const
|
|
299
|
+
// A kayan that keeps drafts, for an actor holding `findVersions` (the key a
|
|
300
|
+
// draft read and a draft save ask): the actor edits the latest draft.
|
|
301
|
+
// Without it, or where it leaves this row out, the actor reads the row and saves there.
|
|
302
|
+
const drafting = await rowReadsDrafts({
|
|
303
|
+
kayan,
|
|
304
|
+
operations: report?.operations,
|
|
305
|
+
id: kayan.single ? undefined : match.params.id,
|
|
306
|
+
findVersions: (input) => operations[kayan.name]!.operations.findVersions!(input, { actor }).catch((thrown: unknown) => {
|
|
307
|
+
if (!caused(thrown)) throw thrown
|
|
308
|
+
return null
|
|
309
|
+
}) as Promise<{ docs: readonly unknown[] } | null>,
|
|
310
|
+
})
|
|
311
|
+
let value: Row | null = null
|
|
312
|
+
if (kayan.single ? !tree || holdsForm(tree) : match.params.id !== undefined) {
|
|
313
|
+
if (kayan.single) value = (await globals[kayan.name]!.operations.findOne!({}, { actor })) as Row | null
|
|
314
|
+
else if (match.params.id) {
|
|
315
|
+
value = await readRow(match.params.id, drafting)
|
|
316
|
+
if (!value) return null
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// A create page reads Draft from the start, since no published copy exists.
|
|
320
|
+
// A plain read finds only a published row, which tells draft from changed.
|
|
321
|
+
let drafts: FormProps['drafts']
|
|
322
|
+
if (drafting && tree && holdsForm(tree)) {
|
|
323
|
+
const published = value ? await readRow(String(value.id), false) : null
|
|
324
|
+
drafts = {
|
|
325
|
+
status: !published ? 'draft' : value!._status === 'draft' ? 'changed' : 'published',
|
|
326
|
+
...(kayan.drafts!.autosave && { autosave: kayan.drafts!.autosave.interval }),
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// The list this page draws, or the one it opens over. A URL the list does not
|
|
330
|
+
// offer is refused before `find` is asked anything, and the page draws the refusal.
|
|
331
|
+
const list = kayan.pages.list && 'page' in kayan.pages.list ? kayan.pages.list : undefined
|
|
332
|
+
const listPage = tree && listIn(nodesOf(tree)) ? tree : tree?.mode ? list : undefined
|
|
333
|
+
let first: { input: Record<string, unknown>; rows: Page } | undefined
|
|
334
|
+
const node = listPage && listIn(nodesOf(listPage))
|
|
335
|
+
if (node) {
|
|
336
|
+
try {
|
|
337
|
+
const input = findOf({
|
|
338
|
+
kayan,
|
|
339
|
+
query: readQuery(data.query, rulesOf({ kayan, node, maxLimit: screen.maxLimit, context: { client: relatedOf(screen, kayan), locales } })),
|
|
340
|
+
node,
|
|
341
|
+
locales,
|
|
342
|
+
client: relatedOf(screen, kayan),
|
|
343
|
+
})
|
|
344
|
+
first = { input, rows: (await operations[kayan.name]!.operations.find!(input, { actor, ...(drafting && { draft: true }) })) as Page }
|
|
345
|
+
} catch (thrown) {
|
|
346
|
+
if (!(thrown instanceof QueryRefused)) throw thrown
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// Each list of another kayan the page draws over its row. A kayan the screen
|
|
350
|
+
// does not cross (one the actor may not read) is read nothing, and so is a URL the list does not offer.
|
|
351
|
+
const related: Record<string, { input: Record<string, unknown>; rows: Page }> = {}
|
|
352
|
+
// Lists whose read the server refused, which the page says it cannot show rather than drawing nothing.
|
|
353
|
+
const refused: string[] = []
|
|
354
|
+
for (const one of tree && value ? relatedIn(nodesOf(tree), scopeOf({ values: {}, record: value, access: found.access, kayan })) : []) {
|
|
355
|
+
const target = screen.kayan[one.of!]
|
|
356
|
+
if (!target) continue
|
|
357
|
+
try {
|
|
358
|
+
const input = scopedTo(findOf({
|
|
359
|
+
kayan: target,
|
|
360
|
+
query: readQuery(listRaw(data.query, prefixOf(one)), rulesOf({ kayan: target, node: one, maxLimit: screen.maxLimit, context: { client: relatedOf(screen, target), locales } })),
|
|
361
|
+
node: one,
|
|
362
|
+
locales,
|
|
363
|
+
client: relatedOf(screen, target),
|
|
364
|
+
}), scopeTo(one, value!.id))
|
|
365
|
+
const draft = readsDrafts(target, found.access.collections?.[target.name]?.operations)
|
|
366
|
+
related[prefixOf(one)] = { input, rows: (await operations[target.name]!.operations.find!(input, { actor, ...(draft && { draft }) })) as Page }
|
|
367
|
+
} catch (thrown) {
|
|
368
|
+
if (!(thrown instanceof QueryRefused) && !caused(thrown)) throw thrown
|
|
369
|
+
if ((thrown as { status?: unknown }).status === 403) refused.push(prefixOf(one))
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const own = (name: string) => (asas.operations as readonly { kayan: string; name: string; input: Parameters<typeof takesKey>[0] }[]).find((one) => one.kayan === kayan.name && one.name === name)
|
|
373
|
+
const update = own('update')
|
|
374
|
+
// The row a relationship title points at, read as this actor; one the actor
|
|
375
|
+
// may not read names nothing. Only its id and title field go into the page's
|
|
376
|
+
// data, which is serialized into the document.
|
|
377
|
+
const titleField = kayan.fields.find((one) => one.name === kayan.useAsTitle && one.kind === 'relationship')
|
|
378
|
+
const titledKayan = titleField ? screen.kayan[titleField.relationTo ?? ''] : undefined
|
|
379
|
+
const titledId = value && titledKayan ? idsOf(value[titleField!.name])[0] : undefined
|
|
380
|
+
const titledRow = titledId === undefined ? undefined : ((await operations[titledKayan!.name]?.operations.findByID?.({ id: titledId }, { actor }).catch((thrown: unknown) => {
|
|
381
|
+
if (!caused(thrown)) throw thrown
|
|
382
|
+
return null
|
|
383
|
+
})) as Row | null | undefined)
|
|
384
|
+
const titledField = titledKayan?.useAsTitle
|
|
385
|
+
const titledBy = titledRow && titledField ? ({ id: titledRow.id, [titledField]: titledRow[titledField] } as Row) : undefined
|
|
386
|
+
return {
|
|
387
|
+
name: found.name,
|
|
388
|
+
user: found.user,
|
|
389
|
+
access: found.access,
|
|
390
|
+
screen,
|
|
391
|
+
kayan: kayan.name,
|
|
392
|
+
page: match.page,
|
|
393
|
+
value,
|
|
394
|
+
first,
|
|
395
|
+
related,
|
|
396
|
+
refused,
|
|
397
|
+
creates: createsIn({ kayan, report }),
|
|
398
|
+
operations: report?.operations ?? {},
|
|
399
|
+
writable: report?.fields ?? {},
|
|
400
|
+
drafts,
|
|
401
|
+
...(titledBy && { titledBy }),
|
|
402
|
+
// Whether the kayan's update takes the `expectedUpdatedAt` step's key, which the form then sends.
|
|
403
|
+
...(update && takesKey(update.input, 'expectedUpdatedAt') && { expectedUpdatedAt: true }),
|
|
404
|
+
// And which of delete and restore do, for the Discards and the Versions tab's restore.
|
|
405
|
+
expectedUpdatedAtOn: ['delete', 'restoreVersion'].filter((name) => { const one = own(name); return !!one && takesKey(one.input, 'expectedUpdatedAt') }),
|
|
406
|
+
}
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
type ScreenData = Exclude<Awaited<ReturnType<typeof loadScreen>>, 'unauthorized' | null>
|
|
410
|
+
type PageData = Exclude<Awaited<ReturnType<typeof loadPage>>, 'signed out' | 'unauthorized' | null>
|
|
411
|
+
|
|
412
|
+
const pathOf = (href: string) => href.split(/[?#]/)[0]!
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* A screen's frame route's loader: resolves the session, access report and screen, and preloads the providers' files.
|
|
416
|
+
* It is not `beforeLoad` because route context must be serializable and the projection's `rules` are `unknown`.
|
|
417
|
+
*/
|
|
418
|
+
export const frameLoader = async ({ screen, files, href }: { screen: string; files: Files; href: string }) => {
|
|
419
|
+
const found = await loadScreen({ data: screen })
|
|
420
|
+
if (!found) throw redirect({ to: '/auth/sign-in', search: { redirect: href } })
|
|
421
|
+
if (found === 'unauthorized') throw redirect({ to: '/unauthorized', search: { from: pathOf(href) } })
|
|
422
|
+
await preload(files, found.screen.providers)
|
|
423
|
+
return found
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Server: in process as this request's actor, so the first HTML has what a loader reads. Browser: the page's `fetch`.
|
|
427
|
+
// Only the browser's client is held to the served read's cap; in process a `limit` of 0 is allowed.
|
|
428
|
+
const toolsOf = createIsomorphicFn()
|
|
429
|
+
.server(async ({ access, maxLimit }: Pick<LoaderTools, 'access' | 'maxLimit'>): Promise<LoaderTools> => {
|
|
430
|
+
const [{ getRequest }, asas] = await Promise.all([import('@tanstack/react-start/server'), getAsas()])
|
|
431
|
+
const request = getRequest()
|
|
432
|
+
const actor = (await asas.auth.actor(request.headers)) ?? undefined
|
|
433
|
+
const kayan = (await inProcess(asas)) as unknown as Parameters<typeof actingAs>[0]
|
|
434
|
+
return {
|
|
435
|
+
client: actingAs(kayan, actor),
|
|
436
|
+
request: (path, init) => asas.handler(new Request(new URL(path, request.url), { ...init, headers: mergedHeaders(request.headers, init?.headers) })),
|
|
437
|
+
access,
|
|
438
|
+
maxLimit,
|
|
439
|
+
}
|
|
440
|
+
})
|
|
441
|
+
.client(async ({ access, maxLimit }: Pick<LoaderTools, 'access' | 'maxLimit'>): Promise<LoaderTools> => ({ client: clientOf(arm), request: fetch, access, maxLimit }))
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* A screen page route's loader: the page `path` names and only the files it draws. A page naming a `permission`
|
|
445
|
+
* is served only to an actor holding it; a page whose file exports a `loader` gets its return as `loaderData`.
|
|
446
|
+
*/
|
|
447
|
+
export const pageLoader = async ({
|
|
448
|
+
screen,
|
|
449
|
+
files,
|
|
450
|
+
path,
|
|
451
|
+
query,
|
|
452
|
+
href,
|
|
453
|
+
}: {
|
|
454
|
+
screen: string
|
|
455
|
+
files: Files
|
|
456
|
+
path: string
|
|
457
|
+
query: Record<string, unknown>
|
|
458
|
+
href: string
|
|
459
|
+
}) => {
|
|
460
|
+
const found = await loadPage({ data: { screen, path, query } })
|
|
461
|
+
if (found === 'signed out') throw redirect({ to: '/auth/sign-in', search: { redirect: href } })
|
|
462
|
+
if (found === 'unauthorized') throw redirect({ to: '/unauthorized', search: { from: pathOf(href) } })
|
|
463
|
+
if (!found) throw notFound()
|
|
464
|
+
// A list asked for a page past its last is sent to its last, replacing the
|
|
465
|
+
// entry the URL made, so back does not land on it again.
|
|
466
|
+
const clamped = found.first && clampedHref({ href, page: found.first.rows.page, totalPages: found.first.rows.totalPages })
|
|
467
|
+
if (clamped) throw redirect({ href: clamped, replace: true })
|
|
468
|
+
await preload(files, ...partsOf(found))
|
|
469
|
+
if (found.kayan === undefined) {
|
|
470
|
+
const component = found.screen.pages[found.page]?.component
|
|
471
|
+
const module = component ? ((await files[component.slice(0, component.indexOf('#'))]?.()) as { loader?: unknown } | undefined) : undefined
|
|
472
|
+
if (typeof module?.loader === 'function') {
|
|
473
|
+
return { ...found, loaderData: (await (module.loader as (tools: LoaderTools) => Promise<unknown>)(await toolsOf({ access: found.access, maxLimit: found.screen.maxLimit }))) as unknown }
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return found
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const nameOfPage = ({ screen, name, match, row, titledBy, copy }: {
|
|
480
|
+
screen: ScreenClient
|
|
481
|
+
name: string
|
|
482
|
+
match: { kayan?: string; page: string }
|
|
483
|
+
row?: Row | null
|
|
484
|
+
titledBy?: Row | null
|
|
485
|
+
copy: { t: (key: string, fallback?: string) => string | undefined; locale: Locale }
|
|
486
|
+
}): string => {
|
|
487
|
+
if (match.kayan === undefined) return screen.nav.find((entry) => !entry.kayan && entry.page === match.page)?.label?.[copy.locale] ?? copy.t(`screens.${name}`, name) ?? name
|
|
488
|
+
const kayan = screen.kayan[match.kayan]!
|
|
489
|
+
const page = kayan.pages[match.page]
|
|
490
|
+
// A list page draws every row, whether a tree's list node or a component drawing it whole.
|
|
491
|
+
const many = match.page === 'list' || (!!page && 'page' in page && listIn(nodesOf(page)) !== undefined)
|
|
492
|
+
if (match.page === 'create') return newTitleOf({ kayan, copy })
|
|
493
|
+
const field = kayan.fields.find((one) => one.name === kayan.useAsTitle)
|
|
494
|
+
const target = field?.kind === 'relationship' ? screen.kayan[field.relationTo ?? ''] : undefined
|
|
495
|
+
const titled = many ? undefined : target ? titledBy && rowTitle({ kayan: target, row: titledBy, locale: copy.locale }) : row && rowTitle({ kayan, row, locale: copy.locale })
|
|
496
|
+
return titled || titleOf({ kayan, copy, many })
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** A page's route `head`: the tab names the page, then the app. A route that drew its not-found has no data and says the page was not found. */
|
|
500
|
+
export const pageHead = (data: PageData | undefined, context: { locale: Locale; app: string }) =>
|
|
501
|
+
documentHead(context, (t) =>
|
|
502
|
+
data ? nameOfPage({ screen: data.screen, name: data.name, match: data, row: data.value, titledBy: 'titledBy' in data ? data.titledBy : undefined, copy: { t, locale: context.locale } }) : t('admin.notFoundTitle', 'Page not found'))
|
|
503
|
+
|
|
504
|
+
const partsOf = (found: PageData): unknown[] => {
|
|
505
|
+
if (found.kayan === undefined) return [found.screen.pages[found.page]]
|
|
506
|
+
const kayan = found.screen.kayan[found.kayan]!
|
|
507
|
+
const page = kayan.pages[found.page]!
|
|
508
|
+
return [page, 'page' in page && page.mode ? kayan.pages.list : undefined, Object.values(found.screen.kayan).map((one) => one.fields)]
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const useScreenState = (data: ScreenData): ScreenState => {
|
|
512
|
+
const { dir, locale, t } = useCopy()
|
|
513
|
+
return { t, locale, dir, screen: data.screen, access: data.access, user: data.user, client: clientOf(arm) }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The bar that says the session is an impersonation, with the way out. Stopping
|
|
518
|
+
* is a sign-out: the staff member's own session lives in the backoffice app under
|
|
519
|
+
* that app's cookie, and the sign-out answers where they go next.
|
|
520
|
+
*/
|
|
521
|
+
export function ImpersonationBar({ name }: { name: string }): ReactElement {
|
|
522
|
+
const { t } = useCopy()
|
|
523
|
+
return (
|
|
524
|
+
<div role="alert" data-impersonating="" className="flex flex-wrap items-center justify-between gap-2 bg-amber-500 px-4 py-2 text-sm font-medium text-black">
|
|
525
|
+
<span>
|
|
526
|
+
{t('admin.impersonating', 'You are impersonating')} <strong>{name}</strong>.
|
|
527
|
+
</span>
|
|
528
|
+
<button
|
|
529
|
+
type="button"
|
|
530
|
+
className="rounded-md border border-black/40 px-3 py-1 font-semibold"
|
|
531
|
+
// `replace`, so Back does not return to the page signed out of.
|
|
532
|
+
onClick={() => void authArm('sign-out').then((answered) => window.location.replace(String(answered.body?.url ?? '/auth/sign-in')))}
|
|
533
|
+
>
|
|
534
|
+
{t('admin.stopImpersonating', 'Stop impersonating')}
|
|
535
|
+
</button>
|
|
536
|
+
</div>
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function ScreenBar({ user }: { user: ScreenData['user'] }): ReactElement | null {
|
|
541
|
+
const { t } = useCopy()
|
|
542
|
+
const [failed, setFailed] = useState(false)
|
|
543
|
+
useEffect(() => {
|
|
544
|
+
const show = (event: Event) => setFailed((event as CustomEvent<boolean>).detail)
|
|
545
|
+
window.addEventListener('mithaq:after-commit', show)
|
|
546
|
+
return () => window.removeEventListener('mithaq:after-commit', show)
|
|
547
|
+
}, [])
|
|
548
|
+
return (
|
|
549
|
+
<>
|
|
550
|
+
{'impersonating' in user && user.impersonating ? <ImpersonationBar name={user.name} /> : null}
|
|
551
|
+
{failed ? (
|
|
552
|
+
<p role="status" className="rounded-md border border-amber-500/50 px-3 py-2 text-sm">
|
|
553
|
+
{t('admin.afterCommitFailed', 'Saved, but a follow-up action failed, so something this save should have caused, such as an email or a notification, may be missing. Please contact an administrator.')}
|
|
554
|
+
</p>
|
|
555
|
+
) : null}
|
|
556
|
+
</>
|
|
557
|
+
)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** The screen's own frame: its providers, outermost first, around the frame's bar and the page. */
|
|
561
|
+
export function ScreenFrame({ files, data, children }: { files: Files; data: ScreenData; children: ReactNode }): ReactElement {
|
|
562
|
+
const { t } = useCopy()
|
|
563
|
+
const refs = refsOf(files, data.screen)
|
|
564
|
+
// One per frame: made per request on the server, so no row one actor read
|
|
565
|
+
// reaches another's render; held for the frame's life in the browser.
|
|
566
|
+
const [queries] = useState(() => new QueryClient())
|
|
567
|
+
// A hydrating page has had no loader run in the browser, so every file the
|
|
568
|
+
// frame and page draw is asked for at once, off the answer the page's loader
|
|
569
|
+
// sent, not one round trip per file as React reaches it.
|
|
570
|
+
const page = useMatches({ select: (matches) => matches.at(-1)?.loaderData as PageData | undefined })
|
|
571
|
+
useState(() => typeof window !== 'undefined' && void preload(files, data.screen.providers, ...(page?.screen ? partsOf(page) : [])))
|
|
572
|
+
// A link inside the screen is followed by the router, so a tap reads the
|
|
573
|
+
// next page's data instead of loading a whole document.
|
|
574
|
+
const router = useRouter()
|
|
575
|
+
const base = data.screen.path
|
|
576
|
+
useEffect(() => {
|
|
577
|
+
const follow = (event: MouseEvent) => {
|
|
578
|
+
const href = routedHref({ event, origin: window.location.origin, base })
|
|
579
|
+
if (href === undefined) return
|
|
580
|
+
event.preventDefault()
|
|
581
|
+
void router.navigate({ href })
|
|
582
|
+
}
|
|
583
|
+
document.addEventListener('click', follow)
|
|
584
|
+
return () => document.removeEventListener('click', follow)
|
|
585
|
+
}, [router, base])
|
|
586
|
+
// Tab stays inside an open dialog, alert dialog or sheet, as in a native modal.
|
|
587
|
+
useEffect(() => holdFocus(document.body), [])
|
|
588
|
+
// A component that changed what the page reads, with no kayan operation to
|
|
589
|
+
// say so, asks for a re-read of the lists and the page's loader without a
|
|
590
|
+
// reload, so the scroll stays.
|
|
591
|
+
useEffect(() => {
|
|
592
|
+
const again = () => void Promise.all([queries.invalidateQueries(), router.invalidate()])
|
|
593
|
+
window.addEventListener('mithaq:refresh', again)
|
|
594
|
+
return () => window.removeEventListener('mithaq:refresh', again)
|
|
595
|
+
}, [queries, router])
|
|
596
|
+
// Suspends only while a hydrating page's files load.
|
|
597
|
+
return (
|
|
598
|
+
<QueryClientProvider client={queries}>
|
|
599
|
+
<ScreenContext value={useScreenState(data)}>
|
|
600
|
+
<Suspense fallback={null}>
|
|
601
|
+
{data.screen.providers.reduceRight<ReactElement>(
|
|
602
|
+
(inner, path) => {
|
|
603
|
+
const Provider = refOf({ screen: { refs }, place: 'provider', path })!
|
|
604
|
+
return <Provider>{inner}</Provider>
|
|
605
|
+
},
|
|
606
|
+
<>
|
|
607
|
+
<ScreenBar user={data.user} />
|
|
608
|
+
{children}
|
|
609
|
+
{/* The one place every write's toast is drawn, kept across the screen's pages. */}
|
|
610
|
+
<Toaster closeLabel={t('admin.close', 'Close') ?? 'Close'} />
|
|
611
|
+
</>,
|
|
612
|
+
)}
|
|
613
|
+
</Suspense>
|
|
614
|
+
</ScreenContext>
|
|
615
|
+
</QueryClientProvider>
|
|
616
|
+
)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
type PageViewProps = {
|
|
620
|
+
files: Files
|
|
621
|
+
data: PageData & { loaderData?: unknown }
|
|
622
|
+
query: RawQuery
|
|
623
|
+
onQuery: (search: Record<string, string>) => void
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** One page, inside what the host provides for it: the screen, and on a kayan's page that kayan and the row, which `useKayan()` and `useRecord()` read. */
|
|
627
|
+
export function ScreenPageView(props: PageViewProps): ReactElement {
|
|
628
|
+
const { data } = props
|
|
629
|
+
const state = useScreenState(data)
|
|
630
|
+
// So a shell marks its home link on the home page alone.
|
|
631
|
+
const own = data.screen.pages[data.page]?.path ?? '/'
|
|
632
|
+
return (
|
|
633
|
+
<ScreenContext value={data.kayan === undefined ? { ...state, path: own === '/' ? data.screen.path : `${data.screen.path}${own}` } : { ...state, kayan: data.screen.kayan[data.kayan], record: data.value }}>
|
|
634
|
+
<PageBody {...props} />
|
|
635
|
+
</ScreenContext>
|
|
636
|
+
)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* A screen page route's not-found, drawn under the screen's frame in the shell
|
|
641
|
+
* its pages use, so the nav stays and the way back is the screen's home. The
|
|
642
|
+
* shell is the first page tree's, which is every page's in this template.
|
|
643
|
+
*/
|
|
644
|
+
export function ScreenNotFound({ files, screen: name }: { files: Files; screen: string }): ReactElement {
|
|
645
|
+
const { t } = useCopy()
|
|
646
|
+
const screen = useScreen()
|
|
647
|
+
const refs = refsOf(files, screen)
|
|
648
|
+
// A page that opens over its list is drawn inside the list's shell, so it names none to draw here.
|
|
649
|
+
const framed = Object.values(screen.kayan).flatMap((kayan) => Object.values(kayan.pages).flatMap((page) => ('page' in page && !page.mode ? [{ kayan, page }] : [])))[0]
|
|
650
|
+
const back = (t('auth.backTo', 'Back to {screen}') ?? 'Back to {screen}').replace('{screen}', () => t(`screens.${name}`, name) ?? name)
|
|
651
|
+
const regions = {
|
|
652
|
+
header: <h1 className="text-2xl font-bold tracking-tight">{t('admin.notFoundTitle', 'Page not found')}</h1>,
|
|
653
|
+
nav: <nav aria-label={t('admin.navigation', 'Navigation')}><NavLinks /></nav>,
|
|
654
|
+
main: (
|
|
655
|
+
<div className="flex flex-col items-start gap-4">
|
|
656
|
+
<p className="text-muted-foreground text-sm">{t('admin.notFound', 'That page does not exist.')}</p>
|
|
657
|
+
<a className="text-foreground text-sm underline underline-offset-4" href={screen.path}>{back}</a>
|
|
658
|
+
</div>
|
|
659
|
+
),
|
|
660
|
+
}
|
|
661
|
+
if (!framed) return <main className="p-6">{regions.header}{regions.main}</main>
|
|
662
|
+
return <>{frameOf({ screen: { refs, ...framed }, t, path: framed.page.page.shell, regions })}</>
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function PageBody({ files, data, query, onQuery }: PageViewProps): ReactElement | null {
|
|
666
|
+
const refs = refsOf(files, data.screen)
|
|
667
|
+
const router = useRouter()
|
|
668
|
+
const queries = useQueryClient()
|
|
669
|
+
const { locale, t } = useCopy()
|
|
670
|
+
if (data.kayan === undefined) {
|
|
671
|
+
const Page = refOf({ screen: { refs }, place: 'page', path: data.screen.pages[data.page]?.component })
|
|
672
|
+
return Page ? <Page loaderData={data.loaderData} /> : null
|
|
673
|
+
}
|
|
674
|
+
const kayan = data.screen.kayan[data.kayan]!
|
|
675
|
+
const declared = kayan.pages[data.page]!
|
|
676
|
+
if ('component' in declared) {
|
|
677
|
+
const Whole = refOf({ screen: { refs }, place: 'component', path: declared.component })
|
|
678
|
+
return Whole ? <Whole page={declared} /> : null
|
|
679
|
+
}
|
|
680
|
+
const page = declared
|
|
681
|
+
const base = data.screen.path
|
|
682
|
+
const hrefOf = (target: ClientKayan, pageName: string | undefined, id?: string) => {
|
|
683
|
+
const path = pageName === undefined ? undefined : target.pages[pageName]?.path
|
|
684
|
+
return path ? `${base}${id ? path.replace(':id', encodeURIComponent(id)) : path}` : undefined
|
|
685
|
+
}
|
|
686
|
+
// A row opens its kayan's edit page, else the one page whose path draws a
|
|
687
|
+
// row (how the backoffice opens a record it only reads).
|
|
688
|
+
const rowPage = (target: ClientKayan) => (target.pages.edit ? 'edit' : Object.keys(target.pages).find((one) => target.pages[one]!.path.includes(':id')))
|
|
689
|
+
const related = relatedOf(data.screen, kayan)
|
|
690
|
+
const go = (href: string | undefined) => void (href ? router.navigate({ href }) : router.invalidate())
|
|
691
|
+
const written = <Written,>(write: Promise<Written>, name = kayan.name) =>
|
|
692
|
+
write.then((out) => {
|
|
693
|
+
void queries.invalidateQueries({ queryKey: rowsKey(name) })
|
|
694
|
+
return out
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
// A list of another kayan on this page: the rows whose `where` holds the row
|
|
698
|
+
// the page draws. The screen crosses only the kayan the actor may read, so one
|
|
699
|
+
// it may not is refused here, as is one whose read the server refused.
|
|
700
|
+
const relatedList = (target: TreePage, node: Parameters<NonNullable<Draw['list']>>[0]['node'], index: number, own?: ReactElement) => {
|
|
701
|
+
const listed = data.screen.kayan[node.of!]
|
|
702
|
+
// Both look the same to the viewer: rows it cannot see.
|
|
703
|
+
if (!listed || data.refused.includes(prefixOf(node))) {
|
|
704
|
+
return (
|
|
705
|
+
<p key={index} role="alert" data-mithaq-refused-list={node.of} className="rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive">
|
|
706
|
+
{t('admin.cannotSee', 'You can\'t see this')}
|
|
707
|
+
</p>
|
|
708
|
+
)
|
|
709
|
+
}
|
|
710
|
+
const id = data.value?.id
|
|
711
|
+
if (id === undefined) return null
|
|
712
|
+
const prefix = prefixOf(node)
|
|
713
|
+
const report = data.access.collections?.[listed.name]
|
|
714
|
+
const targets = relatedOf(data.screen, listed)
|
|
715
|
+
return (
|
|
716
|
+
<ScreenList
|
|
717
|
+
key={index}
|
|
718
|
+
props={{
|
|
719
|
+
kayan: listed,
|
|
720
|
+
node,
|
|
721
|
+
client: targets,
|
|
722
|
+
api: readsOf([listed, ...targets]),
|
|
723
|
+
creates: createsIn({ kayan: listed, report }),
|
|
724
|
+
operations: report?.operations ?? {},
|
|
725
|
+
writes: (field) => report?.fields?.[field]?.update === true,
|
|
726
|
+
access: data.access,
|
|
727
|
+
t,
|
|
728
|
+
locale,
|
|
729
|
+
locales,
|
|
730
|
+
href: (row) => hrefOf(listed, rowPage(listed), row) ?? '#',
|
|
731
|
+
newHref: '#',
|
|
732
|
+
screen: { refs, kayan: listed, page: target },
|
|
733
|
+
edit: {
|
|
734
|
+
client: {
|
|
735
|
+
create: (input) => written(arm[listed.name]!.create!(input), listed.name),
|
|
736
|
+
update: (input) => written(arm[listed.name]!.update!(input), listed.name),
|
|
737
|
+
delete: (input) => written(arm[listed.name]!.delete!(input), listed.name),
|
|
738
|
+
},
|
|
739
|
+
writes: (field, on) => report?.fields?.[field]?.[on] === true,
|
|
740
|
+
prefill: { [node.where!]: id },
|
|
741
|
+
},
|
|
742
|
+
}}
|
|
743
|
+
raw={listRaw(query, prefix)}
|
|
744
|
+
onQuery={(search) => onQuery(withList({ raw: query, prefix, written: search }))}
|
|
745
|
+
first={data.related[prefix]}
|
|
746
|
+
scope={scopeTo(node, id)}
|
|
747
|
+
find={(input, options) => arm[listed.name]!.find!(input, options) as Promise<Page>}
|
|
748
|
+
run={(operation, input) => arm[listed.name]![operation]!(input)}
|
|
749
|
+
own={own}
|
|
750
|
+
/>
|
|
751
|
+
)
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const draw = (target: TreePage): Draw => {
|
|
755
|
+
return {
|
|
756
|
+
kayan,
|
|
757
|
+
copy: { t, locale },
|
|
758
|
+
issues: {},
|
|
759
|
+
// A page drawn here holds no form, so a `when` reads the row and the report alone.
|
|
760
|
+
scope: scopeOf({ values: {}, record: data.value, access: data.access, kayan }),
|
|
761
|
+
// A leaf is the row's value drawn by its cell, read and never edited.
|
|
762
|
+
control: (leaf) => {
|
|
763
|
+
const field = kayan.fields.find((one) => one.name === (typeof leaf === 'string' ? leaf : leaf.field))
|
|
764
|
+
return field ? (
|
|
765
|
+
<FieldValue
|
|
766
|
+
key={field.name}
|
|
767
|
+
kayan={kayan}
|
|
768
|
+
field={field}
|
|
769
|
+
leaf={typeof leaf === 'string' ? undefined : leaf}
|
|
770
|
+
row={data.value}
|
|
771
|
+
copy={{ t, locale }}
|
|
772
|
+
api={readsOf(related)}
|
|
773
|
+
kayans={related}
|
|
774
|
+
screen={{ refs, kayan, page: target }}
|
|
775
|
+
/>
|
|
776
|
+
) : null
|
|
777
|
+
},
|
|
778
|
+
component: (path, index) => {
|
|
779
|
+
const Component = refOf({ screen: { refs }, place: 'component', path })
|
|
780
|
+
return Component ? <Component key={index} page={target} /> : null
|
|
781
|
+
},
|
|
782
|
+
layout: ({ path, regions, index }) => frameOf({ screen: { refs, kayan, page: target }, t, path, regions, key: index }),
|
|
783
|
+
// A list node naming a component is drawn by it, which reads through
|
|
784
|
+
// `useList()` what the registry's list is handed.
|
|
785
|
+
list: ({ node, index, component }) => {
|
|
786
|
+
const Own = component ? refOf({ screen: { refs }, place: 'component', path: component }) : undefined
|
|
787
|
+
if (node.of) return relatedList(target, node, index, Own ? <Own page={target} /> : undefined)
|
|
788
|
+
return (
|
|
789
|
+
<ScreenList
|
|
790
|
+
key={index}
|
|
791
|
+
props={{
|
|
792
|
+
kayan,
|
|
793
|
+
client: related,
|
|
794
|
+
api: readsOf([kayan, ...related]),
|
|
795
|
+
creates: data.creates && !!kayan.pages.create,
|
|
796
|
+
operations: data.operations,
|
|
797
|
+
writes: (field) => data.writable[field]?.update === true,
|
|
798
|
+
access: data.access,
|
|
799
|
+
t,
|
|
800
|
+
locale,
|
|
801
|
+
locales,
|
|
802
|
+
href: (id) => hrefOf(kayan, rowPage(kayan), id) ?? '#',
|
|
803
|
+
newHref: hrefOf(kayan, 'create') ?? '#',
|
|
804
|
+
screen: { refs, kayan, page: target },
|
|
805
|
+
}}
|
|
806
|
+
raw={query}
|
|
807
|
+
onQuery={onQuery}
|
|
808
|
+
first={data.first}
|
|
809
|
+
find={(input, options) => arm[kayan.name]!.find!(input, options) as Promise<Page>}
|
|
810
|
+
run={(operation, input) => arm[kayan.name]![operation]!(input)}
|
|
811
|
+
own={Own ? <Own page={target} /> : undefined}
|
|
812
|
+
/>
|
|
813
|
+
)
|
|
814
|
+
},
|
|
815
|
+
nav: () => <NavLinks />,
|
|
816
|
+
button: (node, index) => {
|
|
817
|
+
const button = pageButtonOf({ kayan, node, allowed: data.operations })
|
|
818
|
+
if (!button) return null
|
|
819
|
+
const call = async (input: Row) => {
|
|
820
|
+
await written(arm[kayan.name]![button.name]!({ ...input, ...(data.value?.id !== undefined && { id: data.value.id }) }))
|
|
821
|
+
await router.invalidate()
|
|
822
|
+
}
|
|
823
|
+
// A project's button component is handed a run that asks the confirm
|
|
824
|
+
// first; the registry's own button asks it before calling.
|
|
825
|
+
const Own = node.component ? refOf({ screen: { refs }, place: 'button', path: node.component }) : undefined
|
|
826
|
+
return Own ? (
|
|
827
|
+
<OwnButton key={index} Own={Own} operation={button} busy={false} copy={{ t, locale }} call={call} />
|
|
828
|
+
) : (
|
|
829
|
+
<NodeButton key={index} node={node} copy={{ t, locale }} busy={false} run={() => void call({})} />
|
|
830
|
+
)
|
|
831
|
+
},
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
const drawn = (target: TreePage) => pageOf({ refs, kayan, page: target }, draw(target))
|
|
835
|
+
if (readsAsValues({ page, value: data.value, operations: data.operations })) return <>{drawn(page)}</>
|
|
836
|
+
|
|
837
|
+
const listHref = hrefOf(kayan, 'list')
|
|
838
|
+
const client: FormClient = kayan.single
|
|
839
|
+
? {
|
|
840
|
+
create: ({ id: _id, ...input }) => arm[kayan.name]!.update!(input),
|
|
841
|
+
update: ({ id: _id, ...input }) => arm[kayan.name]!.update!(input),
|
|
842
|
+
delete: () => Promise.reject(new Error(`"${kayan.name}" is one row and declares no delete.`)),
|
|
843
|
+
}
|
|
844
|
+
: {
|
|
845
|
+
create: (input, options) => written(arm[kayan.name]!.create!(input, options)),
|
|
846
|
+
update: (input, options) => written(arm[kayan.name]!.update!(input, options)),
|
|
847
|
+
delete: (input) => written(arm[kayan.name]!.delete!(input)),
|
|
848
|
+
run: (operation, input) => written(arm[kayan.name]![operation]!(input)),
|
|
849
|
+
findByID: (input) => arm[kayan.name]!.findByID!(input),
|
|
850
|
+
}
|
|
851
|
+
const form = (
|
|
852
|
+
<ScreenForm
|
|
853
|
+
kayan={kayan}
|
|
854
|
+
screen={{ refs, kayan, page }}
|
|
855
|
+
client={client}
|
|
856
|
+
value={data.value}
|
|
857
|
+
t={t}
|
|
858
|
+
locale={locale}
|
|
859
|
+
locales={locales}
|
|
860
|
+
writes={(field, on) => data.writable[field]?.[on] === true}
|
|
861
|
+
kayans={related}
|
|
862
|
+
api={readsOf(related)}
|
|
863
|
+
operations={data.operations}
|
|
864
|
+
related={draw(page).list}
|
|
865
|
+
// A create opened over its list closes back to it once it lands, the new row in the list, as Payload's drawer does;
|
|
866
|
+
// one whose kayan autosaves its drafts moves to the row's page instead, since its first autosave is a create too.
|
|
867
|
+
edit={(id) => (page.mode && listHref && !data.drafts?.autosave ? listHref : hrefOf(kayan, 'edit', id))}
|
|
868
|
+
list={listHref}
|
|
869
|
+
drafts={data.drafts}
|
|
870
|
+
expectedUpdatedAt={data.expectedUpdatedAt}
|
|
871
|
+
expectedUpdatedAtOn={data.expectedUpdatedAtOn}
|
|
872
|
+
/>
|
|
873
|
+
)
|
|
874
|
+
const list = kayan.pages.list
|
|
875
|
+
if (page.mode && list && 'page' in list) {
|
|
876
|
+
return (
|
|
877
|
+
<>
|
|
878
|
+
{drawn(list)}
|
|
879
|
+
{/* Named for a screen reader by the page's title, a create as new. */}
|
|
880
|
+
<RecordFrame mode={page.mode} title={(data.value ? titleOf : newTitleOf)({ kayan, copy: { t, locale } })} closeLabel={t('admin.close', 'Close') ?? 'Close'} onClose={() => go(listHref)}>
|
|
881
|
+
{form}
|
|
882
|
+
</RecordFrame>
|
|
883
|
+
</>
|
|
884
|
+
)
|
|
885
|
+
}
|
|
886
|
+
return form
|
|
887
|
+
}
|
|
888
|
+
// mithaq-admin 0.0.0 da2e742262e16a7f9f8dab050c2a4201517478795101af68736907464a0d52ce
|