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,527 @@
|
|
|
1
|
+
// The entries are read in the browser, as the viewer, from the log's own `find`,
|
|
2
|
+
// which answers only this tenant's entries of kayan the viewer lists and only the
|
|
3
|
+
// changes of columns it reads, and narrows every search of a change to the kayan
|
|
4
|
+
// the viewer may be told of. The page holds only the fields this viewer reads as
|
|
5
|
+
// well, so a change the server let through for a column the page left out is not
|
|
6
|
+
// drawn either.
|
|
7
|
+
import { useContext, useEffect, useId, useMemo, useState, type ReactNode } from 'react'
|
|
8
|
+
import type { ClientField, ClientKayan, ComponentProps, Row } from 'mithaq/admin'
|
|
9
|
+
import { FormContext, useAccess, useClient, useKayan, useLocale, useRecord, useScreen, useUser } from 'mithaq/admin/react'
|
|
10
|
+
import { empty, type Change } from '../mithaq/audit-sink.ts'
|
|
11
|
+
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
|
12
|
+
import { Button } from '@/components/ui/button'
|
|
13
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
14
|
+
import { Label } from '@/components/ui/label'
|
|
15
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
16
|
+
import { cellFor, useComponents } from 'mithaq-admin/components.tsx'
|
|
17
|
+
import { DatePicker } from 'mithaq-admin/date-control.tsx'
|
|
18
|
+
import { missingRow, type FindApi, type FoundPage, type RelationshipTitles } from 'mithaq-admin/control-props.ts'
|
|
19
|
+
import { labelFor, operationLabelFor, titleOf, type Translate } from 'mithaq-admin/copy.ts'
|
|
20
|
+
import { formatDate } from 'mithaq-admin/format.tsx'
|
|
21
|
+
import { Pagination, type PageInfo } from 'mithaq-admin/list-pagination.tsx'
|
|
22
|
+
import { Search } from 'mithaq-admin/list-toolbar.tsx'
|
|
23
|
+
import { rowKey, useRelationshipTitles } from 'mithaq-admin/relationship-cell.tsx'
|
|
24
|
+
import { RelationshipControl } from 'mithaq-admin/relationship-control.tsx'
|
|
25
|
+
import { rowTitle } from 'mithaq-admin/row-title.tsx'
|
|
26
|
+
|
|
27
|
+
/** One entry as the log's `find` answers it. */
|
|
28
|
+
export type Entry = {
|
|
29
|
+
id: string
|
|
30
|
+
createdAt: string | Date
|
|
31
|
+
kayan?: string
|
|
32
|
+
record?: string
|
|
33
|
+
operation: string
|
|
34
|
+
actor?: string | null
|
|
35
|
+
impersonatedBy?: string | null
|
|
36
|
+
changes: Readonly<Record<string, Change>>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** How many entries the record page's side column draws: the last few, the rest on the Activity page. */
|
|
40
|
+
const latest = 5
|
|
41
|
+
|
|
42
|
+
/** The columns every kayan of this project carries and no person edits, which the panel leaves out. */
|
|
43
|
+
const technical = new Set(['id', 'organization', 'createdAt', 'updatedAt'])
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Who a write ran as, as a relationship to the provider's people, so the panel
|
|
47
|
+
* names them through the same read a relationship cell does: the active
|
|
48
|
+
* organization's members by their name, and anyone that read does not answer as
|
|
49
|
+
* someone.
|
|
50
|
+
*/
|
|
51
|
+
const actorField = { name: 'actor', kind: 'relationship', relationTo: 'user', label: {}, rules: {}, component: 'relationship', cell: 'relationship' } as unknown as ClientField
|
|
52
|
+
|
|
53
|
+
/** A record of one kayan, as a relationship to it, which titles the record each entry of the Activity page names. */
|
|
54
|
+
const recordField = (kayan: string) => ({ name: `record:${kayan}`, kind: 'relationship', relationTo: kayan, label: {}, rules: {} }) as unknown as ClientField
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What the sink writes for a command: the command, then the operating-system user
|
|
58
|
+
* in parentheses. A user's id holds no space, so no person is read as one.
|
|
59
|
+
*/
|
|
60
|
+
const commandOf = /^(.+?) \((.+)\)$/
|
|
61
|
+
|
|
62
|
+
const changesOf = (entry: Entry): [string, Change][] => Object.entries(entry.changes ?? {})
|
|
63
|
+
|
|
64
|
+
const units: [Intl.RelativeTimeFormatUnit, number][] = [['year', 31_536_000], ['month', 2_592_000], ['week', 604_800], ['day', 86_400], ['hour', 3_600], ['minute', 60], ['second', 1]]
|
|
65
|
+
|
|
66
|
+
/** How long ago, in the viewer's own locale, in the largest unit that is at least one. */
|
|
67
|
+
export const relative = ({ at, locale, now = Date.now() }: { at: Date; locale: string; now?: number }) => {
|
|
68
|
+
const seconds = Math.round((at.getTime() - now) / 1000)
|
|
69
|
+
const [unit, size] = units.find(([, size]) => Math.abs(seconds) >= size) ?? units.at(-1)!
|
|
70
|
+
return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(Math.round(seconds / size), unit)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type EntriesProps = {
|
|
74
|
+
entries: readonly Entry[]
|
|
75
|
+
/** The viewer's own id, whom an entry that names them calls "You", without a read of the people. */
|
|
76
|
+
viewer?: string
|
|
77
|
+
/** Whether the viewer reads the provider's people, which is what lets a command's operating-system user be named. */
|
|
78
|
+
people?: boolean
|
|
79
|
+
/** The kayan every entry is of, where one record's entries are drawn. */
|
|
80
|
+
kayan?: ClientKayan
|
|
81
|
+
/** Every kayan the screen draws, which an entry of any kayan is drawn by where no one kayan is given. */
|
|
82
|
+
kayans?: Readonly<Record<string, ClientKayan | undefined>>
|
|
83
|
+
/** What names the record an entry is of, on a page of many records' entries. */
|
|
84
|
+
record?: (entry: Entry, kayan: ClientKayan | undefined) => ReactNode
|
|
85
|
+
titles?: RelationshipTitles
|
|
86
|
+
t: Translate
|
|
87
|
+
locale: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The entries drawn, with nothing read: what the panel shows once a page has come back. */
|
|
91
|
+
export function ActivityEntries({ entries, kayan: one, kayans, record, titles, t, locale, viewer, people }: EntriesProps) {
|
|
92
|
+
const copy = { t, locale }
|
|
93
|
+
const { cells } = useComponents()
|
|
94
|
+
// A person the title read did not name, one the reader may not read among
|
|
95
|
+
// them, is someone, never the raw id.
|
|
96
|
+
const named = (id: string | null | undefined) => {
|
|
97
|
+
if (!id) return t('auditLog.system', 'The system') ?? 'The system'
|
|
98
|
+
if (id === viewer) return t('auditLog.you', 'You') ?? 'You'
|
|
99
|
+
// A command's actor, which the sink writes as one string, and which no
|
|
100
|
+
// people read names: said in the viewer's locale to a reader of the
|
|
101
|
+
// people, with each part isolated so the Arabic sentence keeps the
|
|
102
|
+
// command's own direction.
|
|
103
|
+
const command = people ? commandOf.exec(id) : null
|
|
104
|
+
if (command) return (t('auditLog.command', 'the command {command} ({user})') ?? '').replace('{command}', () => `${command[1]}`).replace('{user}', () => `${command[2]}`)
|
|
105
|
+
const title = titles?.get(`user:${id}`)
|
|
106
|
+
return typeof title === 'string' ? title : (t('auditLog.someone', 'Someone') ?? 'Someone')
|
|
107
|
+
}
|
|
108
|
+
return (
|
|
109
|
+
<ol className="grid gap-4 text-sm">
|
|
110
|
+
{entries.map((entry) => {
|
|
111
|
+
const kayan = one ?? kayans?.[entry.kayan ?? '']
|
|
112
|
+
// A cut change holds JSON text, which no cell of the field's kind reads.
|
|
113
|
+
const drawn = (field: ClientField, value: unknown, change: Change): ReactNode =>
|
|
114
|
+
empty(value) ? '—' : change.truncated
|
|
115
|
+
? <code dir="ltr" className="break-all font-mono">{String(value)}</code>
|
|
116
|
+
: cellFor({ cells, field })({ kayan: kayan!, field, value, t, locale, titles })
|
|
117
|
+
const at = new Date(entry.createdAt)
|
|
118
|
+
// Every operation has its words in the log's own scope, in the past
|
|
119
|
+
// tense the sentence reads in, and one a project adds before it
|
|
120
|
+
// writes them falls back to the operation's own label.
|
|
121
|
+
const action = t(`auditLog.action.${entry.operation}`, kayan ? operationLabelFor({ kayan, operation: { name: entry.operation }, copy }) : entry.operation) ?? entry.operation
|
|
122
|
+
// Staff writing inside a member's session is named first, as the member.
|
|
123
|
+
const actor = entry.impersonatedBy
|
|
124
|
+
? (t('auditLog.impersonated', '{staff} as {member}') ?? '{staff} as {member}').replace('{staff}', () => named(entry.impersonatedBy)).replace('{member}', () => named(entry.actor))
|
|
125
|
+
: named(entry.actor)
|
|
126
|
+
// A create says what the person set: a field left empty, or a box left off, is nothing they did.
|
|
127
|
+
const made = entry.operation === 'create'
|
|
128
|
+
// In the order the kayan declares its fields: jsonb keeps its keys in an order of its own.
|
|
129
|
+
const shown = (kayan?.fields ?? []).flatMap((field) => {
|
|
130
|
+
const change = technical.has(field.name) || !Object.hasOwn(entry.changes ?? {}, field.name) ? undefined : entry.changes[field.name]
|
|
131
|
+
return change && !(made && (empty(change.to) || change.to === false)) ? [{ field, change }] : []
|
|
132
|
+
})
|
|
133
|
+
return (
|
|
134
|
+
<li key={entry.id} data-entry={entry.operation} className="grid grid-cols-[2rem_minmax(0,1fr)] gap-3">
|
|
135
|
+
<Avatar className="mt-0.5 size-8">
|
|
136
|
+
<AvatarFallback>{(named(entry.impersonatedBy ?? entry.actor).trim()[0] ?? '?').toUpperCase()}</AvatarFallback>
|
|
137
|
+
</Avatar>
|
|
138
|
+
<div className="min-w-0">
|
|
139
|
+
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
|
140
|
+
<p data-event className="font-medium">
|
|
141
|
+
{(viewer && entry.actor === viewer && !entry.impersonatedBy ? (t('auditLog.eventYou', '{action} by You') ?? '{action} by You') : (t('auditLog.event', '{action} by {actor}') ?? '{action} by {actor}')).replace('{action}', () => action).replace('{actor}', () => actor)}
|
|
142
|
+
</p>
|
|
143
|
+
<time dateTime={at.toISOString()} className="text-muted-foreground shrink-0 text-xs">
|
|
144
|
+
{relative({ at, locale })}
|
|
145
|
+
</time>
|
|
146
|
+
</div>
|
|
147
|
+
{record ? <p data-record className="text-xs">{record(entry, kayan)}</p> : null}
|
|
148
|
+
<p className="text-muted-foreground text-xs">
|
|
149
|
+
{formatDate({ value: at, locale })}
|
|
150
|
+
</p>
|
|
151
|
+
{shown.length ? (
|
|
152
|
+
<ul className="text-muted-foreground mt-1.5 grid gap-1">
|
|
153
|
+
{shown.map(({ field, change }) => (
|
|
154
|
+
<li key={field.name} data-change={field.name} className="break-words">
|
|
155
|
+
<span className="text-foreground">{labelFor({ kayan: kayan!, field, copy })}</span>
|
|
156
|
+
{': '}
|
|
157
|
+
{made ? null : (
|
|
158
|
+
<>
|
|
159
|
+
<span data-from>{drawn(field, change.from, change)}</span>
|
|
160
|
+
<span aria-hidden className="mx-1 inline-block rtl:rotate-180">→</span>
|
|
161
|
+
</>
|
|
162
|
+
)}
|
|
163
|
+
<span data-to>{drawn(field, change.to, change)}</span>
|
|
164
|
+
{change.truncated ? <span data-truncated className="block">{t('auditLog.truncated', 'Too long to keep whole; its start is shown.')}</span> : null}
|
|
165
|
+
</li>
|
|
166
|
+
))}
|
|
167
|
+
</ul>
|
|
168
|
+
) : (
|
|
169
|
+
<p className="text-muted-foreground mt-1.5">{t('auditLog.none', 'No field you may read changed.')}</p>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
</li>
|
|
173
|
+
)
|
|
174
|
+
})}
|
|
175
|
+
</ol>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The five actions the Activity filters by, each the `where` it reads the log by. */
|
|
180
|
+
export const actionWheres = {
|
|
181
|
+
created: { operation: { equals: 'create' } },
|
|
182
|
+
updated: { operation: { in: ['update', 'updateMany'] } },
|
|
183
|
+
deleted: { operation: { in: ['delete', 'deleteMany'] } },
|
|
184
|
+
published: { operation: { equals: 'publish' } },
|
|
185
|
+
restored: { operation: { equals: 'restoreVersion' } },
|
|
186
|
+
} as const
|
|
187
|
+
export type ActionName = keyof typeof actionWheres
|
|
188
|
+
|
|
189
|
+
/** What the Activity is filtered by, every key a string a link can carry. */
|
|
190
|
+
export type ActivityFilters = {
|
|
191
|
+
kayan?: string
|
|
192
|
+
record?: string
|
|
193
|
+
/** Text any changed value holds. */
|
|
194
|
+
search?: string
|
|
195
|
+
/** The person who wrote. */
|
|
196
|
+
actor?: string
|
|
197
|
+
/** The column that changed. */
|
|
198
|
+
field?: string
|
|
199
|
+
action?: string
|
|
200
|
+
/** The first and last day, as a date box writes them, each read in the viewer's own time zone. */
|
|
201
|
+
from?: string
|
|
202
|
+
to?: string
|
|
203
|
+
}
|
|
204
|
+
export type ActivityQuery = ActivityFilters & { page: number; limit: number }
|
|
205
|
+
|
|
206
|
+
const filterKeys = ['kayan', 'record', 'search', 'actor', 'field', 'action', 'from', 'to'] as const
|
|
207
|
+
|
|
208
|
+
/** The start of a day in the viewer's own time zone, and of the day after it. */
|
|
209
|
+
const dayStart = (day: string, after = 0) => {
|
|
210
|
+
const at = new Date(`${day}T00:00:00`)
|
|
211
|
+
at.setDate(at.getDate() + after)
|
|
212
|
+
return Number.isNaN(at.getTime()) ? undefined : at.toISOString()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The log's `where` for these filters, or nothing where none is set. */
|
|
216
|
+
export const whereOf = (filters: ActivityFilters) => {
|
|
217
|
+
const from = filters.from ? dayStart(filters.from) : undefined
|
|
218
|
+
const to = filters.to ? dayStart(filters.to, 1) : undefined
|
|
219
|
+
const parts = [
|
|
220
|
+
filters.kayan && { kayan: { equals: filters.kayan } },
|
|
221
|
+
filters.record && { record: { equals: filters.record } },
|
|
222
|
+
filters.search?.trim() && { changes: { contains: filters.search.trim() } },
|
|
223
|
+
filters.actor && { actor: { equals: filters.actor } },
|
|
224
|
+
filters.field && { [`changes.${filters.field}`]: { exists: true } },
|
|
225
|
+
filters.action && Object.hasOwn(actionWheres, filters.action) && actionWheres[filters.action as ActionName],
|
|
226
|
+
from && { createdAt: { greater_than_equal: from } },
|
|
227
|
+
to && { createdAt: { less_than: to } },
|
|
228
|
+
].filter((part) => !!part)
|
|
229
|
+
return parts.length ? { and: parts } : undefined
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The filters a link carries, each the key it is named by, and anything else left out. A person
|
|
234
|
+
* is left out for a reader of no people, whose read of the log refuses any filter by one.
|
|
235
|
+
*/
|
|
236
|
+
export const filtersOf = ({ search, people }: { search: string; people: boolean }): ActivityQuery => {
|
|
237
|
+
const params = new URLSearchParams(search)
|
|
238
|
+
if (!people) params.delete('actor')
|
|
239
|
+
const page = Number.parseInt(params.get('page') ?? '', 10)
|
|
240
|
+
const limit = Number.parseInt(params.get('limit') ?? '', 10)
|
|
241
|
+
return {
|
|
242
|
+
...Object.fromEntries(filterKeys.flatMap((key) => (params.get(key) ? [[key, params.get(key)!]] : []))),
|
|
243
|
+
page: page > 0 ? page : 1,
|
|
244
|
+
limit: limit > 0 ? limit : 10,
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Every kayan this screen draws, keyed by name, and the typed client's read of each for the titles. */
|
|
249
|
+
const useReads = () => {
|
|
250
|
+
const screen = useScreen()
|
|
251
|
+
const client = useClient()
|
|
252
|
+
const kayans = useMemo(() => Object.values(screen.kayan), [screen])
|
|
253
|
+
// The client is left out of the dependencies: the host makes a new one on
|
|
254
|
+
// every render of the page, and each posts to the same route.
|
|
255
|
+
const api = useMemo<FindApi>(
|
|
256
|
+
() => Object.fromEntries(kayans.map((one) => [one.name, { find: (input: Row) => client({ kayan: one.name, operation: 'find', input }) as Promise<FoundPage> }])),
|
|
257
|
+
[kayans],
|
|
258
|
+
)
|
|
259
|
+
return { kayans, api }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Every person and value an entry names, as rows the title read groups by the kayan each points at. */
|
|
263
|
+
const namedIn = (entries: readonly Entry[]) =>
|
|
264
|
+
entries.flatMap((entry) => [
|
|
265
|
+
{ actor: [entry.actor, entry.impersonatedBy].filter(Boolean) },
|
|
266
|
+
...changesOf(entry).flatMap(([name, change]) => [{ [name]: change.from }, { [name]: change.to }]),
|
|
267
|
+
])
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The panel a page names as a component node: a record's last few entries. It
|
|
271
|
+
* reads the row the page draws and the kayan it belongs to off the screen, so
|
|
272
|
+
* one file serves every kayan's edit page, and links the rest on the Activity
|
|
273
|
+
* page, filtered to this record.
|
|
274
|
+
*/
|
|
275
|
+
export function Activity(_: ComponentProps) {
|
|
276
|
+
const { t, locale } = useLocale()
|
|
277
|
+
const kayan = useKayan()
|
|
278
|
+
const record = useRecord()
|
|
279
|
+
const client = useClient()
|
|
280
|
+
const screen = useScreen()
|
|
281
|
+
const viewer = useUser().id
|
|
282
|
+
const people = useAccess().collections?.user?.read === true
|
|
283
|
+
const id = record?.id === undefined ? undefined : String(record.id)
|
|
284
|
+
// The row as this page last drew it, and every save the form beside it
|
|
285
|
+
// landed: a save, a draft, a publish or an autosave writes an entry, and the
|
|
286
|
+
// panel reads its entries again. A draft save moves no `updatedAt` of the row
|
|
287
|
+
// the page holds, so the count is what says it landed.
|
|
288
|
+
const saves = useContext(FormContext)?.saves ?? 0
|
|
289
|
+
const seen = `${id}@${String(record?.updatedAt ?? '')}@${saves}`
|
|
290
|
+
const [read, setRead] = useState<{ seen: string; docs: Entry[]; more: boolean } | { seen: string; failed: true }>()
|
|
291
|
+
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
if (!id) return
|
|
294
|
+
let live = true
|
|
295
|
+
const where = { and: [{ kayan: { equals: kayan.name } }, { record: { equals: id } }] }
|
|
296
|
+
;(client({ kayan: 'auditLog', operation: 'find', input: { where, sort: '-createdAt', limit: latest, page: 1, depth: 0 } }) as Promise<FoundPage>).then(
|
|
297
|
+
(found) => live && setRead({ seen, docs: found.docs as Entry[], more: found.hasNextPage }),
|
|
298
|
+
() => live && setRead({ seen, failed: true }),
|
|
299
|
+
)
|
|
300
|
+
return () => {
|
|
301
|
+
live = false
|
|
302
|
+
}
|
|
303
|
+
}, [kayan.name, seen])
|
|
304
|
+
|
|
305
|
+
const { kayans, api } = useReads()
|
|
306
|
+
const shown = read?.seen === seen ? read : undefined
|
|
307
|
+
const entries = shown && 'docs' in shown ? shown.docs : []
|
|
308
|
+
const titles = useRelationshipTitles({ fields: [...kayan.fields, actorField], docs: namedIn(entries), context: { api, client: kayans, locale } })
|
|
309
|
+
// The Activity page, where the screen has one, which the rest of this record's entries are on.
|
|
310
|
+
const page = screen.pages.activity?.path
|
|
311
|
+
const all = page && id ? `${screen.path}${page}?${new URLSearchParams({ kayan: kayan.name, record: id })}` : undefined
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<Card data-panel="activity">
|
|
315
|
+
<CardHeader>
|
|
316
|
+
<CardTitle>{t('auditLog.activity', 'Activity')}</CardTitle>
|
|
317
|
+
</CardHeader>
|
|
318
|
+
<CardContent>
|
|
319
|
+
{shown === undefined ? (
|
|
320
|
+
<p className="text-muted-foreground text-sm">{t('auditLog.loading', 'Loading activity…')}</p>
|
|
321
|
+
) : 'failed' in shown ? (
|
|
322
|
+
<p className="text-destructive text-sm">{t('auditLog.failed', 'The activity could not be read.')}</p>
|
|
323
|
+
) : !entries.length ? (
|
|
324
|
+
<p className="text-muted-foreground text-sm">{t('auditLog.empty', 'Nothing has changed here yet.')}</p>
|
|
325
|
+
) : (
|
|
326
|
+
<div className="grid gap-4">
|
|
327
|
+
<ActivityEntries entries={entries} kayan={kayan} titles={titles} t={t} locale={locale} viewer={viewer} people={people} />
|
|
328
|
+
{shown.more && all ? (
|
|
329
|
+
<a data-all-activity href={all} className="text-foreground text-sm underline underline-offset-4 max-sm:inline-flex max-sm:min-h-11 max-sm:items-center">
|
|
330
|
+
{t('auditLog.all', 'All activity')}
|
|
331
|
+
</a>
|
|
332
|
+
) : null}
|
|
333
|
+
</div>
|
|
334
|
+
)}
|
|
335
|
+
</CardContent>
|
|
336
|
+
</Card>
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** One filter's label above its box, the label naming the box for a screen reader. */
|
|
341
|
+
function Labelled({ id, label, children }: { id: string; label: string; children: ReactNode }) {
|
|
342
|
+
return (
|
|
343
|
+
<div className="grid min-w-0 gap-1.5">
|
|
344
|
+
<Label htmlFor={id} className="text-xs">{label}</Label>
|
|
345
|
+
{children}
|
|
346
|
+
</div>
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** A choice among `options`, or none: shadcn's select, its trigger naming what is chosen in words. */
|
|
351
|
+
function Choice({ id, value, options, any, onChange }: { id: string; value: string | undefined; options: readonly { value: string; label: string }[]; any: string; onChange: (value: string | undefined) => void }) {
|
|
352
|
+
return (
|
|
353
|
+
<Select value={value ?? ''} onValueChange={(next: string | null) => onChange(next || undefined)}>
|
|
354
|
+
<SelectTrigger id={id} className="w-full max-sm:data-[size=default]:h-11">
|
|
355
|
+
<SelectValue>{options.find((one) => one.value === value)?.label ?? any}</SelectValue>
|
|
356
|
+
</SelectTrigger>
|
|
357
|
+
<SelectContent>
|
|
358
|
+
<SelectItem value="">{any}</SelectItem>
|
|
359
|
+
{options.map((one) => (
|
|
360
|
+
<SelectItem key={one.value} value={one.value}>{one.label}</SelectItem>
|
|
361
|
+
))}
|
|
362
|
+
</SelectContent>
|
|
363
|
+
</Select>
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The Activity: entries newest first, searched, filtered and paged on the
|
|
369
|
+
* server. On one record (`kayan` and `record` given) it filters by person,
|
|
370
|
+
* column, action and day; across the organization by the kayan too, and each
|
|
371
|
+
* entry names its record with a link to it. It is read-only: nothing here
|
|
372
|
+
* writes, and the log declares no write a caller could run.
|
|
373
|
+
*/
|
|
374
|
+
export function ActivityLog({ kayan: fixed, record, query, onQuery, refresh = 0 }: {
|
|
375
|
+
kayan?: ClientKayan
|
|
376
|
+
record?: string
|
|
377
|
+
query: ActivityQuery
|
|
378
|
+
onQuery: (query: ActivityQuery) => void
|
|
379
|
+
/** A count that reads the page again when it moves, such as the saves of the form beside it. */
|
|
380
|
+
refresh?: number
|
|
381
|
+
}) {
|
|
382
|
+
const { t, locale } = useLocale()
|
|
383
|
+
const client = useClient()
|
|
384
|
+
const screen = useScreen()
|
|
385
|
+
const viewer = useUser().id
|
|
386
|
+
const access = useAccess()
|
|
387
|
+
const people = access.collections?.user?.read === true
|
|
388
|
+
const copy = { t, locale }
|
|
389
|
+
const { kayans, api } = useReads()
|
|
390
|
+
const ids = { person: useId(), kayan: useId(), field: useId(), action: useId(), from: useId(), to: useId() }
|
|
391
|
+
const filters = fixed ? { ...query, kayan: fixed.name, record } : query
|
|
392
|
+
const asked = JSON.stringify([whereOf(filters), query.page, query.limit, refresh])
|
|
393
|
+
const [read, setRead] = useState<{ asked: string; page: FoundPage & PageInfo } | { asked: string; failed: true }>()
|
|
394
|
+
useEffect(() => {
|
|
395
|
+
let live = true
|
|
396
|
+
;(client({ kayan: 'auditLog', operation: 'find', input: { where: whereOf(filters), sort: '-createdAt', limit: query.limit, page: query.page, depth: 0 } }) as Promise<FoundPage & PageInfo>).then(
|
|
397
|
+
(page) => live && setRead({ asked, page }),
|
|
398
|
+
() => live && setRead({ asked, failed: true }),
|
|
399
|
+
)
|
|
400
|
+
return () => {
|
|
401
|
+
live = false
|
|
402
|
+
}
|
|
403
|
+
}, [asked])
|
|
404
|
+
|
|
405
|
+
const shown = read?.asked === asked ? read : undefined
|
|
406
|
+
const entries = shown && 'page' in shown ? (shown.page.docs as Entry[]) : []
|
|
407
|
+
// A filter changed reads from the first page again, as a list's does.
|
|
408
|
+
const refine = (next: ActivityFilters) => onQuery({ ...query, ...next, page: 1 })
|
|
409
|
+
const any = t('auditLog.any', 'Any') ?? 'Any'
|
|
410
|
+
const kayan = fixed ?? screen.kayan[query.kayan ?? '']
|
|
411
|
+
// The kayan the viewer lists, which are the kayan whose entries it reads.
|
|
412
|
+
const listed = screen.nav.flatMap((entry) => (entry.kayan && screen.kayan[entry.kayan] ? [screen.kayan[entry.kayan]!] : []))
|
|
413
|
+
const fields = kayan ? kayan.fields.filter((field) => !technical.has(field.name) && field.kind !== 'join') : []
|
|
414
|
+
const filtered = filterKeys.some((key) => key !== 'kayan' && key !== 'record' ? query[key] : !fixed && query[key])
|
|
415
|
+
// Each record an entry names, titled by its own kayan's read.
|
|
416
|
+
const recordFields = [...new Set(entries.map((entry) => entry.kayan ?? ''))].filter((name) => screen.kayan[name]).map(recordField)
|
|
417
|
+
const titles = useRelationshipTitles({
|
|
418
|
+
fields: [...(kayan?.fields ?? []), ...(fixed ? [] : Object.values(screen.kayan).flatMap((one) => one?.fields ?? [])), actorField, ...recordFields],
|
|
419
|
+
docs: [...namedIn(entries), ...(fixed ? [] : entries.map((entry) => ({ [`record:${entry.kayan}`]: entry.record })))],
|
|
420
|
+
context: { api, client: kayans, locale },
|
|
421
|
+
})
|
|
422
|
+
const recordOf = (entry: Entry, of: ClientKayan | undefined) => {
|
|
423
|
+
if (!of || !entry.record) return null
|
|
424
|
+
const row = titles.get(rowKey(of.name, entry.record))
|
|
425
|
+
const name = row && typeof row === 'object' && !(row instanceof Error) ? rowTitle({ kayan: of, row: row as Row, locale }) ?? titleOf({ kayan: of, copy }) : row === true ? missingRow({ t, gone: true }) : titleOf({ kayan: of, copy })
|
|
426
|
+
const edit = of.pages.edit?.path
|
|
427
|
+
const said = `${titleOf({ kayan: of, copy })}: ${name}`
|
|
428
|
+
// A record gone, or one with no page to open, is named and not linked.
|
|
429
|
+
return edit && row !== true ? <a href={`${screen.path}${edit.replace(':id', encodeURIComponent(entry.record))}`} className="text-foreground underline underline-offset-4">{said}</a> : <span className="text-foreground">{said}</span>
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return (
|
|
433
|
+
<section data-activity-log className="grid min-w-0 gap-4">
|
|
434
|
+
<div role="group" aria-label={t('auditLog.filters', 'Filter the activity') ?? 'Filter the activity'} className="grid gap-3">
|
|
435
|
+
<Search value={query.search} fields={[t('auditLog.searchChanges', 'changed values') ?? 'changed values']} locale={locale} t={t} onSearch={(search) => refine({ search })} />
|
|
436
|
+
{/* The server searches a change's text only in the kayan whose every column the reader reads,
|
|
437
|
+
and a reader graded column by column may read some kayan in part: the page holds no list of
|
|
438
|
+
the columns it does not read, so it says so rather than guessing which. */}
|
|
439
|
+
{Array.isArray(access.permissions) ? <p data-search-limited className="text-muted-foreground text-xs">{t('auditLog.searchLimited', 'The search covers the records whose every field you may read.')}</p> : null}
|
|
440
|
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
441
|
+
{people && screen.kayan.auditLog ? (
|
|
442
|
+
<Labelled id={ids.person} label={t('auditLog.person', 'Person') ?? 'Person'}>
|
|
443
|
+
<RelationshipControl
|
|
444
|
+
id={ids.person}
|
|
445
|
+
field={actorField}
|
|
446
|
+
kayan={screen.kayan.auditLog}
|
|
447
|
+
client={kayans}
|
|
448
|
+
api={api}
|
|
449
|
+
value={query.actor ?? null}
|
|
450
|
+
values={{}}
|
|
451
|
+
setValue={() => {}}
|
|
452
|
+
onChange={(actor) => refine({ actor: typeof actor === 'string' ? actor : undefined })}
|
|
453
|
+
readOnly={false}
|
|
454
|
+
t={t}
|
|
455
|
+
locale={locale}
|
|
456
|
+
/>
|
|
457
|
+
</Labelled>
|
|
458
|
+
) : null}
|
|
459
|
+
{fixed ? null : (
|
|
460
|
+
<Labelled id={ids.kayan} label={t('auditLog.type', 'Record type') ?? 'Record type'}>
|
|
461
|
+
<Choice id={ids.kayan} value={query.kayan} any={any} options={listed.map((one) => ({ value: one.name, label: titleOf({ kayan: one, copy, many: true }) }))} onChange={(next) => refine({ kayan: next, field: undefined, record: undefined })} />
|
|
462
|
+
</Labelled>
|
|
463
|
+
)}
|
|
464
|
+
<Labelled id={ids.field} label={t('auditLog.field', 'Field') ?? 'Field'}>
|
|
465
|
+
<Choice id={ids.field} value={query.field} any={any} options={fields.map((field) => ({ value: field.name, label: labelFor({ kayan: kayan!, field, copy }) }))} onChange={(field) => refine({ field })} />
|
|
466
|
+
</Labelled>
|
|
467
|
+
<Labelled id={ids.action} label={t('auditLog.actionFilter', 'Action') ?? 'Action'}>
|
|
468
|
+
<Choice id={ids.action} value={query.action} any={any} options={Object.keys(actionWheres).map((name) => ({ value: name, label: t(`auditLog.filter.${name}`, name) ?? name }))} onChange={(action) => refine({ action })} />
|
|
469
|
+
</Labelled>
|
|
470
|
+
{(['from', 'to'] as const).map((edge) => (
|
|
471
|
+
<Labelled key={edge} id={ids[edge]} label={t(`auditLog.${edge}`, edge) ?? edge}>
|
|
472
|
+
<DatePicker
|
|
473
|
+
id={ids[edge]}
|
|
474
|
+
field={{ name: edge, kind: 'date', label: { [locale]: t(`auditLog.${edge}`, edge) ?? edge }, rules: {} } as unknown as ClientField}
|
|
475
|
+
kayan={screen.kayan.auditLog!}
|
|
476
|
+
value={query[edge] ?? null}
|
|
477
|
+
values={{}}
|
|
478
|
+
setValue={() => {}}
|
|
479
|
+
onChange={(day) => refine({ [edge]: typeof day === 'string' ? day : undefined })}
|
|
480
|
+
readOnly={false}
|
|
481
|
+
t={t}
|
|
482
|
+
locale={locale}
|
|
483
|
+
/>
|
|
484
|
+
</Labelled>
|
|
485
|
+
))}
|
|
486
|
+
</div>
|
|
487
|
+
{filtered ? (
|
|
488
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
489
|
+
{!fixed && query.record ? <p className="text-muted-foreground text-sm">{t('auditLog.oneRecord', 'One record\'s activity.')}</p> : null}
|
|
490
|
+
<Button type="button" variant="outline" size="sm" className="max-sm:h-11" onClick={() => onQuery({ page: 1, limit: query.limit })}>
|
|
491
|
+
{t('auditLog.clear', 'Clear filters')}
|
|
492
|
+
</Button>
|
|
493
|
+
</div>
|
|
494
|
+
) : null}
|
|
495
|
+
</div>
|
|
496
|
+
{/* One short line a screen reader hears as a read lands, never the whole list. */}
|
|
497
|
+
<p aria-live="polite" className={`text-sm ${shown && 'failed' in shown ? 'text-destructive' : 'text-muted-foreground'}`}>
|
|
498
|
+
{shown === undefined
|
|
499
|
+
? t('auditLog.loading', 'Loading activity…')
|
|
500
|
+
: 'failed' in shown
|
|
501
|
+
? t('auditLog.failed', 'The activity could not be read.')
|
|
502
|
+
: !entries.length
|
|
503
|
+
? filtered ? t('auditLog.nothing', 'No activity matches these filters.') : t('auditLog.empty', 'Nothing has changed here yet.')
|
|
504
|
+
: (t('auditLog.found', 'Entries found: {count}') ?? 'Entries found: {count}').replace('{count}', () => shown.page.totalDocs.toLocaleString(locale))}
|
|
505
|
+
</p>
|
|
506
|
+
{entries.length ? <ActivityEntries entries={entries} kayan={fixed} kayans={screen.kayan} record={fixed ? undefined : recordOf} titles={titles} t={t} locale={locale} viewer={viewer} people={people} /> : null}
|
|
507
|
+
{shown && 'page' in shown && shown.page.totalDocs > 0 ? (
|
|
508
|
+
<Pagination page={shown.page} t={t} locale={locale} onPage={(page) => onQuery({ ...query, page })} onLimit={(limit) => onQuery({ ...query, limit, page: 1 })} />
|
|
509
|
+
) : null}
|
|
510
|
+
</section>
|
|
511
|
+
)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** The Activity tab of a record's edit page: the record's whole history, beside its Versions. */
|
|
515
|
+
export function ActivityTab(_: ComponentProps) {
|
|
516
|
+
const kayan = useKayan()
|
|
517
|
+
const record = useRecord()
|
|
518
|
+
const saves = useContext(FormContext)?.saves ?? 0
|
|
519
|
+
const [query, setQuery] = useState<ActivityQuery>({ page: 1, limit: 10 })
|
|
520
|
+
return record?.id === undefined ? null : (
|
|
521
|
+
<Card>
|
|
522
|
+
<CardContent>
|
|
523
|
+
<ActivityLog kayan={kayan} record={String(record.id)} query={query} onQuery={setQuery} refresh={saves} />
|
|
524
|
+
</CardContent>
|
|
525
|
+
</Card>
|
|
526
|
+
)
|
|
527
|
+
}
|