create-mithaq 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/LICENSES/payload.txt +22 -0
- package/LICENSES/ponytail.txt +21 -0
- package/index.js +127 -0
- package/package.json +19 -4
- package/template/apps/asas-platform/components.json +25 -0
- package/template/apps/asas-platform/gitignore +3 -0
- package/template/apps/asas-platform/package.json +55 -0
- package/template/apps/asas-platform/public/favicon.svg +1 -0
- package/template/apps/asas-platform/src/api.ts +147 -0
- package/template/apps/asas-platform/src/auth-frame.tsx +150 -0
- package/template/apps/asas-platform/src/components/mithaq/cells.tsx +112 -0
- package/template/apps/asas-platform/src/components/mithaq/controls.tsx +650 -0
- package/template/apps/asas-platform/src/components/mithaq/field-shell.tsx +53 -0
- package/template/apps/asas-platform/src/components/mithaq/filter-operators.ts +108 -0
- package/template/apps/asas-platform/src/components/mithaq/form.tsx +1467 -0
- package/template/apps/asas-platform/src/components/mithaq/list-columns.tsx +108 -0
- package/template/apps/asas-platform/src/components/mithaq/list-filters.tsx +368 -0
- package/template/apps/asas-platform/src/components/mithaq/list-pagination.tsx +153 -0
- package/template/apps/asas-platform/src/components/mithaq/list-query.ts +432 -0
- package/template/apps/asas-platform/src/components/mithaq/list-selection.tsx +74 -0
- package/template/apps/asas-platform/src/components/mithaq/list.tsx +865 -0
- package/template/apps/asas-platform/src/components/mithaq/nav.tsx +871 -0
- package/template/apps/asas-platform/src/components/mithaq/nodes.tsx +1024 -0
- package/template/apps/asas-platform/src/components/mithaq/relationship-cell.tsx +127 -0
- package/template/apps/asas-platform/src/components/mithaq/relationship-control.tsx +400 -0
- package/template/apps/asas-platform/src/components/mithaq/rich-text-nodes.ts +6 -0
- package/template/apps/asas-platform/src/components/mithaq/rich-text.tsx +1083 -0
- package/template/apps/asas-platform/src/components/mithaq/upload.tsx +686 -0
- package/template/apps/asas-platform/src/components/ui/accordion.tsx +74 -0
- package/template/apps/asas-platform/src/components/ui/alert-dialog.tsx +185 -0
- package/template/apps/asas-platform/src/components/ui/avatar.tsx +106 -0
- package/template/apps/asas-platform/src/components/ui/badge.tsx +48 -0
- package/template/apps/asas-platform/src/components/ui/breadcrumb.tsx +124 -0
- package/template/apps/asas-platform/src/components/ui/button-group.tsx +87 -0
- package/template/apps/asas-platform/src/components/ui/button.tsx +55 -0
- package/template/apps/asas-platform/src/components/ui/calendar.tsx +223 -0
- package/template/apps/asas-platform/src/components/ui/card.tsx +102 -0
- package/template/apps/asas-platform/src/components/ui/chart.tsx +371 -0
- package/template/apps/asas-platform/src/components/ui/checkbox.tsx +28 -0
- package/template/apps/asas-platform/src/components/ui/collapsible.tsx +19 -0
- package/template/apps/asas-platform/src/components/ui/combobox.tsx +308 -0
- package/template/apps/asas-platform/src/components/ui/dialog.tsx +158 -0
- package/template/apps/asas-platform/src/components/ui/direction.tsx +6 -0
- package/template/apps/asas-platform/src/components/ui/directional-icon.tsx +20 -0
- package/template/apps/asas-platform/src/components/ui/drawer.tsx +216 -0
- package/template/apps/asas-platform/src/components/ui/dropdown-menu.tsx +267 -0
- package/template/apps/asas-platform/src/components/ui/field.tsx +236 -0
- package/template/apps/asas-platform/src/components/ui/input-group.tsx +155 -0
- package/template/apps/asas-platform/src/components/ui/input-otp.tsx +84 -0
- package/template/apps/asas-platform/src/components/ui/input.tsx +19 -0
- package/template/apps/asas-platform/src/components/ui/label.tsx +19 -0
- package/template/apps/asas-platform/src/components/ui/popover.tsx +90 -0
- package/template/apps/asas-platform/src/components/ui/radio-group.tsx +37 -0
- package/template/apps/asas-platform/src/components/ui/resizable.tsx +47 -0
- package/template/apps/asas-platform/src/components/ui/scroll-area.tsx +54 -0
- package/template/apps/asas-platform/src/components/ui/select.tsx +204 -0
- package/template/apps/asas-platform/src/components/ui/separator.tsx +24 -0
- package/template/apps/asas-platform/src/components/ui/sheet.tsx +139 -0
- package/template/apps/asas-platform/src/components/ui/sidebar.tsx +714 -0
- package/template/apps/asas-platform/src/components/ui/skeleton.tsx +13 -0
- package/template/apps/asas-platform/src/components/ui/slider.tsx +55 -0
- package/template/apps/asas-platform/src/components/ui/spinner.tsx +10 -0
- package/template/apps/asas-platform/src/components/ui/switch.tsx +29 -0
- package/template/apps/asas-platform/src/components/ui/table.tsx +115 -0
- package/template/apps/asas-platform/src/components/ui/tabs.tsx +79 -0
- package/template/apps/asas-platform/src/components/ui/textarea.tsx +17 -0
- package/template/apps/asas-platform/src/components/ui/toggle-group.tsx +89 -0
- package/template/apps/asas-platform/src/components/ui/toggle.tsx +44 -0
- package/template/apps/asas-platform/src/components/ui/tooltip.tsx +63 -0
- package/template/apps/asas-platform/src/copy.ts +1 -0
- package/template/apps/asas-platform/src/hooks/use-mobile.ts +19 -0
- package/template/apps/asas-platform/src/lib/utils.ts +1 -0
- package/template/apps/asas-platform/src/links.ts +38 -0
- package/template/apps/asas-platform/src/locale-switch.tsx +26 -0
- package/template/apps/asas-platform/src/redirect.ts +1 -0
- package/template/apps/asas-platform/src/request-headers.ts +11 -0
- package/template/apps/asas-platform/src/routeTree.gen.ts +181 -0
- package/template/apps/asas-platform/src/router.tsx +7 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform/files.ts +4 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform/screen.json +3609 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform/types.ts +145 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform.$.tsx +17 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform.index.tsx +10 -0
- package/template/apps/asas-platform/src/routes/(screens)/platform.tsx +13 -0
- package/template/apps/asas-platform/src/routes/__root.tsx +95 -0
- package/template/apps/asas-platform/src/routes/api/$.ts +30 -0
- package/template/apps/asas-platform/src/routes/auth.sign-in.tsx +72 -0
- package/template/apps/asas-platform/src/routes/unauthorized.tsx +122 -0
- package/template/apps/asas-platform/src/screen-form.tsx +34 -0
- package/template/apps/asas-platform/src/screen-list.tsx +139 -0
- package/template/apps/asas-platform/src/screen-names.ts +10 -0
- package/template/apps/asas-platform/src/screens.tsx +868 -0
- package/template/apps/asas-platform/src/server.ts +20 -0
- package/template/apps/asas-platform/src/slide.ts +22 -0
- package/template/apps/asas-platform/src/start.ts +42 -0
- package/template/apps/asas-platform/src/styles.css +200 -0
- package/template/apps/asas-platform/src/theme.ts +2 -0
- package/template/apps/asas-platform/tsconfig.json +19 -0
- package/template/apps/asas-platform/vite.config.ts +26 -0
- package/template/apps/asas-tanstack/components.json +25 -0
- package/template/apps/asas-tanstack/gitignore +3 -0
- package/template/apps/asas-tanstack/package.json +56 -0
- package/template/apps/asas-tanstack/public/favicon.svg +1 -0
- package/template/apps/asas-tanstack/src/api.ts +147 -0
- package/template/apps/asas-tanstack/src/auth-frame.tsx +150 -0
- package/template/apps/asas-tanstack/src/components/mithaq/cells.tsx +112 -0
- package/template/apps/asas-tanstack/src/components/mithaq/controls.tsx +650 -0
- package/template/apps/asas-tanstack/src/components/mithaq/field-shell.tsx +53 -0
- package/template/apps/asas-tanstack/src/components/mithaq/filter-operators.ts +108 -0
- package/template/apps/asas-tanstack/src/components/mithaq/form.tsx +1467 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-columns.tsx +108 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-filters.tsx +368 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-pagination.tsx +153 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-query.ts +432 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list-selection.tsx +74 -0
- package/template/apps/asas-tanstack/src/components/mithaq/list.tsx +865 -0
- package/template/apps/asas-tanstack/src/components/mithaq/nav.tsx +871 -0
- package/template/apps/asas-tanstack/src/components/mithaq/nodes.tsx +1024 -0
- package/template/apps/asas-tanstack/src/components/mithaq/relationship-cell.tsx +127 -0
- package/template/apps/asas-tanstack/src/components/mithaq/relationship-control.tsx +400 -0
- package/template/apps/asas-tanstack/src/components/mithaq/rich-text-nodes.ts +6 -0
- package/template/apps/asas-tanstack/src/components/mithaq/rich-text.tsx +1083 -0
- package/template/apps/asas-tanstack/src/components/mithaq/upload.tsx +686 -0
- package/template/apps/asas-tanstack/src/components/ui/accordion.tsx +10 -0
- package/template/apps/asas-tanstack/src/components/ui/alert-dialog.tsx +173 -0
- package/template/apps/asas-tanstack/src/components/ui/avatar.tsx +104 -0
- package/template/apps/asas-tanstack/src/components/ui/badge.tsx +48 -0
- package/template/apps/asas-tanstack/src/components/ui/breadcrumb.tsx +125 -0
- package/template/apps/asas-tanstack/src/components/ui/button-group.tsx +87 -0
- package/template/apps/asas-tanstack/src/components/ui/button.tsx +56 -0
- package/template/apps/asas-tanstack/src/components/ui/calendar.tsx +223 -0
- package/template/apps/asas-tanstack/src/components/ui/card.tsx +94 -0
- package/template/apps/asas-tanstack/src/components/ui/chart.tsx +369 -0
- package/template/apps/asas-tanstack/src/components/ui/checkbox.tsx +27 -0
- package/template/apps/asas-tanstack/src/components/ui/collapsible.tsx +19 -0
- package/template/apps/asas-tanstack/src/components/ui/combobox.tsx +300 -0
- package/template/apps/asas-tanstack/src/components/ui/dialog.tsx +149 -0
- package/template/apps/asas-tanstack/src/components/ui/direction.tsx +5 -0
- package/template/apps/asas-tanstack/src/components/ui/directional-icon.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/drawer.tsx +215 -0
- package/template/apps/asas-tanstack/src/components/ui/dropdown-menu.tsx +267 -0
- package/template/apps/asas-tanstack/src/components/ui/field.tsx +225 -0
- package/template/apps/asas-tanstack/src/components/ui/input-group.tsx +147 -0
- package/template/apps/asas-tanstack/src/components/ui/input-otp.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/input.tsx +20 -0
- package/template/apps/asas-tanstack/src/components/ui/label.tsx +18 -0
- package/template/apps/asas-tanstack/src/components/ui/popover.tsx +50 -0
- package/template/apps/asas-tanstack/src/components/ui/radio-group.tsx +10 -0
- package/template/apps/asas-tanstack/src/components/ui/resizable.tsx +47 -0
- package/template/apps/asas-tanstack/src/components/ui/scroll-area.tsx +52 -0
- package/template/apps/asas-tanstack/src/components/ui/select.tsx +190 -0
- package/template/apps/asas-tanstack/src/components/ui/separator.tsx +23 -0
- package/template/apps/asas-tanstack/src/components/ui/sheet.tsx +127 -0
- package/template/apps/asas-tanstack/src/components/ui/sidebar.tsx +716 -0
- package/template/apps/asas-tanstack/src/components/ui/skeleton.tsx +13 -0
- package/template/apps/asas-tanstack/src/components/ui/slider.tsx +13 -0
- package/template/apps/asas-tanstack/src/components/ui/spinner.tsx +11 -0
- package/template/apps/asas-tanstack/src/components/ui/switch.tsx +8 -0
- package/template/apps/asas-tanstack/src/components/ui/table.tsx +119 -0
- package/template/apps/asas-tanstack/src/components/ui/tabs.tsx +80 -0
- package/template/apps/asas-tanstack/src/components/ui/textarea.tsx +18 -0
- package/template/apps/asas-tanstack/src/components/ui/toggle-group.tsx +8 -0
- package/template/apps/asas-tanstack/src/components/ui/toggle.tsx +44 -0
- package/template/apps/asas-tanstack/src/components/ui/tooltip.tsx +68 -0
- package/template/apps/asas-tanstack/src/copy.ts +870 -0
- package/template/apps/asas-tanstack/src/hooks/use-mobile.ts +19 -0
- package/template/apps/asas-tanstack/src/lib/utils.ts +6 -0
- package/template/apps/asas-tanstack/src/links.ts +38 -0
- package/template/apps/asas-tanstack/src/locale-switch.tsx +26 -0
- package/template/apps/asas-tanstack/src/quiet-abort.ts +25 -0
- package/template/apps/asas-tanstack/src/redirect.ts +20 -0
- package/template/apps/asas-tanstack/src/request-headers.ts +11 -0
- package/template/apps/asas-tanstack/src/routeTree.gen.ts +382 -0
- package/template/apps/asas-tanstack/src/router.tsx +7 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +4 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +3883 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +158 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.$.tsx +17 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.index.tsx +10 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.tsx +13 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +4 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +1800 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +92 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.$.tsx +17 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.index.tsx +10 -0
- package/template/apps/asas-tanstack/src/routes/(screens)/portal.tsx +13 -0
- package/template/apps/asas-tanstack/src/routes/__root.tsx +99 -0
- package/template/apps/asas-tanstack/src/routes/api/$.ts +11 -0
- package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +101 -0
- package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +94 -0
- package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +39 -0
- package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +109 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +86 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +122 -0
- package/template/apps/asas-tanstack/src/routes/index.tsx +142 -0
- package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +122 -0
- package/template/apps/asas-tanstack/src/screen-form.tsx +34 -0
- package/template/apps/asas-tanstack/src/screen-list.tsx +139 -0
- package/template/apps/asas-tanstack/src/screen-names.ts +10 -0
- package/template/apps/asas-tanstack/src/screens.tsx +868 -0
- package/template/apps/asas-tanstack/src/server.ts +16 -0
- package/template/apps/asas-tanstack/src/slide.ts +22 -0
- package/template/apps/asas-tanstack/src/start.ts +57 -0
- package/template/apps/asas-tanstack/src/styles.css +159 -0
- package/template/apps/asas-tanstack/src/theme.ts +5 -0
- package/template/apps/asas-tanstack/tsconfig.json +19 -0
- package/template/apps/asas-tanstack/vite.config.ts +28 -0
- package/template/package.json +16 -0
- package/template/templates/asas/.env.example +16 -0
- package/template/templates/asas/components.json +22 -0
- package/template/templates/asas/mithaq.hints.json +25 -0
- package/template/templates/asas/package.json +38 -0
- package/template/templates/asas/src/asas.ts +93 -0
- package/template/templates/asas/src/audit-log/audit-log.ts +187 -0
- package/template/templates/asas/src/audit-log/sink.ts +89 -0
- package/template/templates/asas/src/audit-log/ui/activity.tsx +243 -0
- package/template/templates/asas/src/courses/courses.ts +612 -0
- package/template/templates/asas/src/courses/ui/archive-button.tsx +25 -0
- package/template/templates/asas/src/courses/ui/fee-cell.tsx +19 -0
- package/template/templates/asas/src/courses/ui/no-courses-on-offer.tsx +11 -0
- package/template/templates/asas/src/courses/ui/no-courses.tsx +13 -0
- package/template/templates/asas/src/courses/ui/on-offer.ts +6 -0
- package/template/templates/asas/src/courses/ui/status-cell.tsx +31 -0
- package/template/templates/asas/src/enrollments/enrollments.ts +137 -0
- package/template/templates/asas/src/enrollments/ui/session-cell.tsx +65 -0
- package/template/templates/asas/src/kayan/auth/user/account.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/account.ts +31 -0
- package/template/templates/asas/src/kayan/auth/user/index.ts +18 -0
- package/template/templates/asas/src/kayan/auth/user/invitation.access.ts +4 -0
- package/template/templates/asas/src/kayan/auth/user/invitation.ts +27 -0
- package/template/templates/asas/src/kayan/auth/user/member.access.ts +10 -0
- package/template/templates/asas/src/kayan/auth/user/member.screens.ts +37 -0
- package/template/templates/asas/src/kayan/auth/user/member.ts +25 -0
- package/template/templates/asas/src/kayan/auth/user/organization.access.ts +20 -0
- package/template/templates/asas/src/kayan/auth/user/organization.screens.ts +37 -0
- package/template/templates/asas/src/kayan/auth/user/organization.ts +24 -0
- package/template/templates/asas/src/kayan/auth/user/organizationRole.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/organizationRole.ts +25 -0
- package/template/templates/asas/src/kayan/auth/user/session.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/session.ts +28 -0
- package/template/templates/asas/src/kayan/auth/user/user.access.ts +29 -0
- package/template/templates/asas/src/kayan/auth/user/user.screens.ts +45 -0
- package/template/templates/asas/src/kayan/auth/user/user.ts +31 -0
- package/template/templates/asas/src/kayan/auth/user/verification.access.ts +8 -0
- package/template/templates/asas/src/kayan/auth/user/verification.ts +23 -0
- package/template/templates/asas/src/local-users/local-users.ts +143 -0
- package/template/templates/asas/src/media/media.ts +137 -0
- package/template/templates/asas/src/media/ui/preview.tsx +143 -0
- package/template/templates/asas/src/member-roles/member-roles.ts +207 -0
- package/template/templates/asas/src/messages/account.json +106 -0
- package/template/templates/asas/src/messages/auditLog.json +186 -0
- package/template/templates/asas/src/messages/courses.json +270 -0
- package/template/templates/asas/src/messages/enrollments.json +42 -0
- package/template/templates/asas/src/messages/invitation.json +66 -0
- package/template/templates/asas/src/messages/localUsers.json +42 -0
- package/template/templates/asas/src/messages/media.json +66 -0
- package/template/templates/asas/src/messages/member.json +42 -0
- package/template/templates/asas/src/messages/memberRoles.json +42 -0
- package/template/templates/asas/src/messages/mithaq.json +170 -0
- package/template/templates/asas/src/messages/organization.json +50 -0
- package/template/templates/asas/src/messages/organizationRole.json +50 -0
- package/template/templates/asas/src/messages/roles.json +58 -0
- package/template/templates/asas/src/messages/session.json +82 -0
- package/template/templates/asas/src/messages/sessions.json +106 -0
- package/template/templates/asas/src/messages/settings.json +66 -0
- package/template/templates/asas/src/messages/tags.json +38 -0
- package/template/templates/asas/src/messages/user.json +90 -0
- package/template/templates/asas/src/messages/verification.json +50 -0
- package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/migration.sql +177 -0
- package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/snapshot.json +2169 -0
- package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/snapshot.json +2182 -0
- package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/migration.sql +20 -0
- package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/snapshot.json +2409 -0
- package/template/templates/asas/src/migrations/20260922082945_military_ironclad/migration.sql +15 -0
- package/template/templates/asas/src/migrations/20260922082945_military_ironclad/snapshot.json +2627 -0
- package/template/templates/asas/src/migrations/20260922112630_bitter_chat/migration.sql +5 -0
- package/template/templates/asas/src/migrations/20260922112630_bitter_chat/snapshot.json +2691 -0
- package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/migration.sql +8 -0
- package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/snapshot.json +2795 -0
- package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/migration.sql +3 -0
- package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/snapshot.json +2834 -0
- package/template/templates/asas/src/migrations/20260927212525_bent_prowler/migration.sql +9 -0
- package/template/templates/asas/src/migrations/20260927212525_bent_prowler/snapshot.json +2870 -0
- package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/migration.sql +1 -0
- package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/snapshot.json +2881 -0
- package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/migration.sql +15 -0
- package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/snapshot.json +3073 -0
- package/template/templates/asas/src/migrations/20260929024407_grey_morlun/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20260929024407_grey_morlun/snapshot.json +3098 -0
- package/template/templates/asas/src/migrations/20260929130530_bored_jackal/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20260929130530_bored_jackal/snapshot.json +3124 -0
- package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/migration.sql +32 -0
- package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/snapshot.json +3324 -0
- package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/migration.sql +2 -0
- package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/snapshot.json +3350 -0
- package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/migration.sql +21 -0
- package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/migration.sql +8 -0
- package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/migration.sql +18 -0
- package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/migration.sql +18 -0
- package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/snapshot.json +3635 -0
- package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/migration.sql +11 -0
- package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/snapshot.json +3667 -0
- package/template/templates/asas/src/migrations/20261012000752_organization_ownership/migration.sql +48 -0
- package/template/templates/asas/src/migrations/20261012000752_organization_ownership/snapshot.json +3891 -0
- package/template/templates/asas/src/migrations/drizzle.config.ts +11 -0
- package/template/templates/asas/src/migrations/schema.ts +29 -0
- package/template/templates/asas/src/mithaq.ts +406 -0
- package/template/templates/asas/src/pages.ts +74 -0
- package/template/templates/asas/src/platform-http.ts +96 -0
- package/template/templates/asas/src/rich-text-nodes.ts +11 -0
- package/template/templates/asas/src/roles/grants.ts +47 -0
- package/template/templates/asas/src/roles/roles.ts +184 -0
- package/template/templates/asas/src/roles/ui/role-form.tsx +226 -0
- package/template/templates/asas/src/roles/ui/roles.tsx +166 -0
- package/template/templates/asas/src/roles.ts +16 -0
- package/template/templates/asas/src/seed.ts +325 -0
- package/template/templates/asas/src/sessions/sessions.ts +155 -0
- package/template/templates/asas/src/settings/settings.ts +69 -0
- package/template/templates/asas/src/statements.ts +11 -0
- package/template/templates/asas/src/steps/expected-updated-at.ts +41 -0
- package/template/templates/asas/src/storage/defaults.ts +156 -0
- package/template/templates/asas/src/storage/index.ts +95 -0
- package/template/templates/asas/src/storage/local.ts +143 -0
- package/template/templates/asas/src/storage/r2.ts +5 -0
- package/template/templates/asas/src/storage/s3.ts +91 -0
- package/template/templates/asas/src/storage/uploadthing.ts +59 -0
- package/template/templates/asas/src/tags/tags.ts +80 -0
- package/template/templates/asas/src/tenant-access.ts +221 -0
- package/template/templates/asas/src/tenant-roles.ts +262 -0
- package/template/templates/asas/src/ui/app-shell.tsx +260 -0
- package/template/templates/asas/src/ui/kpi-card.tsx +25 -0
- package/template/templates/asas/src/ui/record-page.tsx +50 -0
- package/template/templates/asas/src/ui/rich-text-nodes.tsx +15 -0
- package/template/templates/asas/src/ui/screen-nav.tsx +28 -0
- package/template/templates/asas/src/ui/session-labels.tsx +36 -0
- package/template/templates/asas/src/ui/table-page.tsx +9 -0
- package/template/templates/asas/src/ui/top-shell.tsx +60 -0
- package/template/templates/asas/src/ui-dashboard/dashboard.tsx +294 -0
- package/template/templates/asas/src/ui-dashboard/members.tsx +630 -0
- package/template/templates/asas/src/ui-dashboard/theme-provider.tsx +10 -0
- package/template/templates/asas/src/ui-dashboard/versions.tsx +432 -0
- package/template/templates/asas/src/ui-platform/home.tsx +63 -0
- package/template/templates/asas/src/ui-platform/organization-actions.tsx +114 -0
- package/template/templates/asas/src/ui-platform/record-page.tsx +30 -0
- package/template/templates/asas/src/ui-platform/role-cells.tsx +52 -0
- package/template/templates/asas/src/ui-platform/user-actions.tsx +251 -0
- package/template/templates/asas/src/ui-portal/home.tsx +77 -0
- package/template/templates/asas/src/ui-portal/page.tsx +11 -0
- package/template/templates/auth/package.json +14 -0
- package/template/templates/auth/src/index.ts +690 -0
- package/template/templates/auth/src/platform-roles.ts +87 -0
- package/README.md +0 -3
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { actorPermissions, count, defaultFields, find, findOne, invalid, type Ctx, type Held } from 'mithaq'
|
|
2
|
+
import { defineKayan, rows, organization } from '../mithaq.ts'
|
|
3
|
+
import { platformClosed } from '../tenant-access.ts'
|
|
4
|
+
import { auditLogName, type Change } from './sink.ts'
|
|
5
|
+
|
|
6
|
+
/** The kayan whose `find` this actor holds, which are the kayan whose entries it reads. */
|
|
7
|
+
const heldKayans = (held: Exclude<Held, true | undefined>) =>
|
|
8
|
+
[...held].filter((key) => key.endsWith('.find') && !key.includes('.fields.')).map((key) => key.slice(0, -'.find'.length))
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The read the log's rows are ANDed with, so it narrows and never widens: an
|
|
12
|
+
* entry of a kayan the actor may not list is no entry it reads. An actor
|
|
13
|
+
* nothing grades reads none, which is the closed direction.
|
|
14
|
+
*/
|
|
15
|
+
const readableKayans = ({ user }: { user: unknown }) => {
|
|
16
|
+
const held = actorPermissions(user)
|
|
17
|
+
if (held === true) return true
|
|
18
|
+
const kayans = held ? heldKayans(held) : []
|
|
19
|
+
return kayans.length ? { kayan: { in: kayans } } : false
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether this actor reads one column of the kayan an entry names. The access
|
|
24
|
+
* target grades every column of this project by its key.
|
|
25
|
+
*/
|
|
26
|
+
const readsField = (held: Held, kayan: string, field: string) => held === true || held?.has(`${kayan}.fields.${field}`) === true
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether this actor reads the provider's people, by the key of their `find`.
|
|
30
|
+
* A command's operating-system user is the one thing in the log that names a
|
|
31
|
+
* person no row of the people does, so only such a reader sees or asks for it.
|
|
32
|
+
*/
|
|
33
|
+
const readsPeople = (held: Held) => held === true || held?.has('user.find') === true
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What the sink writes for a command: the command, then the operating-system
|
|
37
|
+
* user in parentheses. The command is the shortest part before ` (`, so a user
|
|
38
|
+
* holding a parenthesis is never left in it.
|
|
39
|
+
*/
|
|
40
|
+
const commandActor = /^(.+?) \(.+\)$/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The step after the read: each entry keeps the changes of the columns the
|
|
44
|
+
* actor may read and drops the rest whole, so a column it may not read shows
|
|
45
|
+
* neither value nor that it changed. The server does it rather than the page,
|
|
46
|
+
* because the log is read over HTTP as well as drawn.
|
|
47
|
+
*/
|
|
48
|
+
const visibleChanges = (ctx: Ctx): Ctx => {
|
|
49
|
+
const held = actorPermissions(ctx.actor)
|
|
50
|
+
const trim = (entry: unknown) => {
|
|
51
|
+
if (!entry || typeof entry !== 'object' || typeof (entry as { changes?: unknown }).changes !== 'string') return entry
|
|
52
|
+
const { kayan, changes } = entry as { kayan: string; changes: string }
|
|
53
|
+
const all = JSON.parse(changes) as Record<string, Change>
|
|
54
|
+
const kept = Object.fromEntries(Object.entries(all).filter(([field]) => readsField(held, kayan, field)))
|
|
55
|
+
// A command's actor is the command alone to a reader of no people, its operating-system user blanked.
|
|
56
|
+
const actor = (entry as { actor?: unknown }).actor
|
|
57
|
+
const named = !readsPeople(held) && typeof actor === 'string' ? { actor: commandActor.exec(actor)?.[1] ?? actor } : {}
|
|
58
|
+
return { ...entry, ...named, changes: JSON.stringify(kept) }
|
|
59
|
+
}
|
|
60
|
+
const data: unknown = ctx.data
|
|
61
|
+
const page = data && typeof data === 'object' && Array.isArray((data as { docs?: unknown }).docs) ? (data as { docs: unknown[] }) : undefined
|
|
62
|
+
return { ...ctx, data: (page ? { ...page, docs: page.docs.map(trim) } : trim(data)) as Record<string, unknown> }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The columns the log is read by. Nothing else filters or orders it, `changes`
|
|
67
|
+
* above all: a `contains` over its text would read a value out of a column the
|
|
68
|
+
* reader holds no key for, before `visibleChanges` trims the page. The
|
|
69
|
+
* organization is core's own filter and `impersonatedBy` names staff.
|
|
70
|
+
*/
|
|
71
|
+
const queryable = new Set(['kayan', 'record', 'createdAt', 'operation', 'actor'])
|
|
72
|
+
|
|
73
|
+
const namedIn = (where: unknown): string[] => {
|
|
74
|
+
if (!where || typeof where !== 'object') return []
|
|
75
|
+
return Object.entries(where).flatMap(([key, value]) =>
|
|
76
|
+
(key === 'and' || key === 'or') && Array.isArray(value) ? value.flatMap(namedIn) : [key])
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The step before the read: a `where` or a `sort` naming any column but those
|
|
81
|
+
* is refused 400, before the store is asked, on every read the log declares.
|
|
82
|
+
* The runner's own checks still run after it on what it lets through.
|
|
83
|
+
*/
|
|
84
|
+
const queriedByQueryable = (ctx: Ctx): Ctx => {
|
|
85
|
+
const { where, sort } = ctx.data as { where?: unknown; sort?: unknown }
|
|
86
|
+
const field = namedIn(where).find((name) => !queryable.has(name))
|
|
87
|
+
if (field !== undefined) {
|
|
88
|
+
throw invalid({ message: `Kayan "auditLog" is filtered by ${[...queryable].join(', ')} alone, and not by "${field}".`, key: 'error.invalid.where', params: { kayan: 'auditLog', field } })
|
|
89
|
+
}
|
|
90
|
+
// A reader of no people names an actor in no filter and no sort: a pattern, a range, an order or
|
|
91
|
+
// even an equality over the string would let it confirm a guessed operating-system user of a
|
|
92
|
+
// command's actor.
|
|
93
|
+
if (!readsPeople(actorPermissions(ctx.actor)) && (namedIn(where).includes('actor') || (typeof sort === 'string' && sort.replace(/^-/, '') === 'actor'))) {
|
|
94
|
+
throw invalid({ message: 'Kayan "auditLog" is neither filtered nor sorted by "actor" for a reader of no people.', key: 'error.invalid.where', params: { kayan: 'auditLog', field: 'actor' } })
|
|
95
|
+
}
|
|
96
|
+
if (sort !== undefined && !(typeof sort === 'string' && queryable.has(sort.replace(/^-/, '')))) {
|
|
97
|
+
throw invalid({ message: `Kayan "auditLog" is sorted by ${[...queryable].join(', ')} alone.`, key: 'error.invalid.sort_field', params: { kayan: 'auditLog', field: String(sort) } })
|
|
98
|
+
}
|
|
99
|
+
return ctx
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The audit log's rows: an organization reads its own entries, and nothing writes
|
|
104
|
+
* one through the log, a caller of any role included, since the service
|
|
105
|
+
* target's sink writes each entry through the store inside the write it
|
|
106
|
+
* records. Every platform role is closed: the platform lists no entry, and a
|
|
107
|
+
* staff member's own impersonated writes are the entries it would read.
|
|
108
|
+
*/
|
|
109
|
+
const auditLogAccess = rows({
|
|
110
|
+
'*': { create: false, read: true, update: false, delete: false },
|
|
111
|
+
...platformClosed,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The audit log: one row per row a write changed, written by the service
|
|
116
|
+
* target's audit sink inside that write's own transaction and by nothing else.
|
|
117
|
+
* It declares no operation that writes, so no caller creates, edits or deletes
|
|
118
|
+
* an entry, and each of its rows answers `false` for every write besides.
|
|
119
|
+
*
|
|
120
|
+
* It is its organization's own, and a record's entries are read on that record's edit
|
|
121
|
+
* page. The dashboard lists it for its access report alone, which is what the
|
|
122
|
+
* edit pages' panel is drawn by, and draws no page of it. The platform lists
|
|
123
|
+
* none of it and reads none of it.
|
|
124
|
+
*/
|
|
125
|
+
export const auditLog = defineKayan({
|
|
126
|
+
name: auditLogName,
|
|
127
|
+
dbName: 'audit_log',
|
|
128
|
+
messages: {
|
|
129
|
+
singular: { en: 'Audit entry', ar: 'سجل تدقيق' },
|
|
130
|
+
plural: { en: 'Audit log', ar: 'سجل التدقيق' },
|
|
131
|
+
// What the activity panel draws beside the field labels.
|
|
132
|
+
activity: { en: 'Activity', ar: 'النشاط' },
|
|
133
|
+
loading: { en: 'Loading activity…', ar: 'جارٍ تحميل النشاط…' },
|
|
134
|
+
empty: { en: 'Nothing has changed here yet.', ar: 'لم يتغير شيء هنا بعد.' },
|
|
135
|
+
failed: { en: 'The activity could not be read.', ar: 'تعذرت قراءة النشاط.' },
|
|
136
|
+
more: { en: 'Show older', ar: 'عرض الأقدم' },
|
|
137
|
+
system: { en: 'The system', ar: 'النظام' },
|
|
138
|
+
event: { en: '{action} by {actor}', ar: '{action} بواسطة {actor}' },
|
|
139
|
+
// Staff writing as a member: the real person first, as the member they wrote as.
|
|
140
|
+
impersonated: { en: '{staff} as {member}', ar: '{staff} بصفة {member}' },
|
|
141
|
+
// One per operation a tenant kayan writes through, which is every entry the sink records.
|
|
142
|
+
action: {
|
|
143
|
+
create: { en: 'Created', ar: 'أُنشئ' },
|
|
144
|
+
update: { en: 'Updated', ar: 'حُدِّث' },
|
|
145
|
+
delete: { en: 'Deleted', ar: 'حُذف' },
|
|
146
|
+
updateMany: { en: 'Updated', ar: 'حُدِّث' },
|
|
147
|
+
deleteMany: { en: 'Deleted', ar: 'حُذف' },
|
|
148
|
+
restoreVersion: { en: 'Restored a version', ar: 'استُعيد إصدار' },
|
|
149
|
+
archive: { en: 'Archived', ar: 'أُرشف' },
|
|
150
|
+
archiveMany: { en: 'Archived', ar: 'أُرشف' },
|
|
151
|
+
},
|
|
152
|
+
someone: { en: 'Someone', ar: 'شخص ما' },
|
|
153
|
+
// A command line write, named for a reader of the people: the command and
|
|
154
|
+
// the operating-system user who ran it.
|
|
155
|
+
command: { en: 'the command {command} ({user})', ar: 'الأمر {command} ({user})' },
|
|
156
|
+
// The viewer's own entry, which needs no read of the people to name. Its
|
|
157
|
+
// sentence is its own, since Arabic joins the person to "by" in one word,
|
|
158
|
+
// "بواسطتك", and not as "بواسطة أنت".
|
|
159
|
+
you: { en: 'You', ar: 'أنت' },
|
|
160
|
+
eventYou: { en: '{action} by You', ar: '{action} بواسطتك' },
|
|
161
|
+
none: { en: 'No field you may read changed.', ar: 'لم يتغير حقل يمكنك قراءته.' },
|
|
162
|
+
},
|
|
163
|
+
access: { ...auditLogAccess, read: readableKayans },
|
|
164
|
+
targets: {
|
|
165
|
+
dashboard: {},
|
|
166
|
+
service: {
|
|
167
|
+
operations: {
|
|
168
|
+
find: { steps: [queriedByQueryable, find, visibleChanges] },
|
|
169
|
+
findByID: { steps: [queriedByQueryable, findOne, visibleChanges] },
|
|
170
|
+
count: { steps: [queriedByQueryable, count] },
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
fields: {
|
|
175
|
+
id: defaultFields.id,
|
|
176
|
+
createdAt: defaultFields.createdAt,
|
|
177
|
+
organization,
|
|
178
|
+
kayan: { kind: 'text', label: { en: 'Kayan', ar: 'الكيان' }, rules: { required: true, maxLength: 60 } },
|
|
179
|
+
record: { kind: 'text', label: { en: 'Record', ar: 'السجل' }, rules: { required: true, index: true } },
|
|
180
|
+
operation: { kind: 'text', label: { en: 'Operation', ar: 'العملية' }, rules: { required: true, maxLength: 60 } },
|
|
181
|
+
actor: { kind: 'text', label: { en: 'Actor', ar: 'المنفذ' } },
|
|
182
|
+
impersonatedBy: { kind: 'text', label: { en: 'Impersonated by', ar: 'انتحل الصفة' } },
|
|
183
|
+
// Never an order, which the runner refuses by name for every read, and never
|
|
184
|
+
// a filter or a search, which the dashboard offers only where a field opts in.
|
|
185
|
+
changes: { kind: 'text', label: { en: 'Changes', ar: 'التغييرات' }, rules: { required: true }, targets: { dashboard: { sortable: false } } },
|
|
186
|
+
},
|
|
187
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { AuditHandler } from 'mithaq'
|
|
2
|
+
import { same } from 'mithaq/admin'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The kayan this sink writes, which `./audit-log.ts` declares under the same
|
|
6
|
+
* name. A file of its own, apart from the kayan's, because the service target
|
|
7
|
+
* that holds this sink is built in `../mithaq.ts` and the kayan imports its
|
|
8
|
+
* `defineKayan` from there, so one file holding both would be a cycle.
|
|
9
|
+
*/
|
|
10
|
+
export const auditLogName = 'auditLog'
|
|
11
|
+
|
|
12
|
+
/** One changed column: what it held before the write and what it holds after. */
|
|
13
|
+
export type Change = { from: unknown; to: unknown }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What the Activity panel draws as a dash: absent, `null`, an empty string or
|
|
17
|
+
* an empty list. A column that is one of these before and after changed
|
|
18
|
+
* nothing a reader could see.
|
|
19
|
+
*/
|
|
20
|
+
export const empty = (value: unknown) => value === undefined || value === null || value === '' || (Array.isArray(value) && !value.length)
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The columns whose value differs, each with its value on both sides and
|
|
24
|
+
* `null` for a side that held none. A column empty on both sides is no change,
|
|
25
|
+
* so a create lists only what it set and a delete only what the row held.
|
|
26
|
+
*/
|
|
27
|
+
export const changesOf = (before: Readonly<Record<string, unknown>>, after: Readonly<Record<string, unknown>>) => {
|
|
28
|
+
const changes: Record<string, Change> = {}
|
|
29
|
+
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
30
|
+
if (!same(before[key], after[key]) && !(empty(before[key]) && empty(after[key]))) changes[key] = { from: before[key] ?? null, to: after[key] ?? null }
|
|
31
|
+
}
|
|
32
|
+
return changes
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const rowOf = (value: unknown): Readonly<Record<string, unknown>> | undefined =>
|
|
36
|
+
value && typeof value === 'object' && !Array.isArray(value) && 'id' in value ? (value as Record<string, unknown>) : undefined
|
|
37
|
+
|
|
38
|
+
const stringOf = (value: unknown) => (typeof value === 'string' ? value : null)
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The service target's audit sink: one `auditLog` row per row a write changed,
|
|
42
|
+
* written through the store and the transaction the write itself runs in, so
|
|
43
|
+
* the entry commits with the change or not at all. It goes to the store and
|
|
44
|
+
* not through an operation, because the log declares none that writes: this is
|
|
45
|
+
* the only thing that writes it.
|
|
46
|
+
*
|
|
47
|
+
* A bulk runs every row through the single-row write of its effect, which
|
|
48
|
+
* audits that row, and then audits itself with the page of rows as its
|
|
49
|
+
* result; that second call names no row and is passed over here. An update
|
|
50
|
+
* that moved nothing but the columns the runner bumps on every write declines
|
|
51
|
+
* its own row. A delete of a kayan with no soft-delete field removes the row,
|
|
52
|
+
* so its change is every column to nothing; a soft delete hands back the row it
|
|
53
|
+
* marked, and its change is the mark.
|
|
54
|
+
*
|
|
55
|
+
* The actor is the person the write ran as, and `impersonatedBy` the real one
|
|
56
|
+
* behind an impersonated session. `mithaq rbac assign` hands the sink no person
|
|
57
|
+
* but the command and the operating-system user, and the entry names both. The
|
|
58
|
+
* organization is the one the row belongs to, which is the active one inside a
|
|
59
|
+
* managed write and the one the log is read in; a shared kayan writes no entry.
|
|
60
|
+
*/
|
|
61
|
+
export const auditSink: AuditHandler = async ({ effect, kayan, operation, dataBefore, dataAfter, store, tx, user }) => {
|
|
62
|
+
// A shared kayan writes no entry: the log is read in one organization alone,
|
|
63
|
+
// so an entry of none is one nobody reads, and the shared kayan are the
|
|
64
|
+
// provider's generated auth tables and local auth's accounts, whose rows hold
|
|
65
|
+
// tokens and hashes a log should never keep.
|
|
66
|
+
const owner = kayan.tenant
|
|
67
|
+
if (effect === 'read' || !store || kayan.name === auditLogName || !owner) return
|
|
68
|
+
const before = dataBefore ?? {}
|
|
69
|
+
const after = rowOf(dataAfter)
|
|
70
|
+
const record = after?.id ?? dataBefore?.id
|
|
71
|
+
if (record === undefined || record === null) return
|
|
72
|
+
const removed = effect === 'delete' && !kayan.softDelete
|
|
73
|
+
const changes = changesOf(before, removed ? {} : (after ?? {}))
|
|
74
|
+
if (effect === 'update' && Object.keys(changes).every((name) => kayan.fields[name]?.rules?.nowOnUpdate)) return
|
|
75
|
+
const actor = user && typeof user === 'object' ? (user as { id?: unknown; impersonatedBy?: unknown; command?: unknown; osUser?: unknown }) : undefined
|
|
76
|
+
await store.insert({ tx, kayan: auditLogName, data: {
|
|
77
|
+
createdAt: new Date(),
|
|
78
|
+
organization: stringOf(after?.[owner] ?? before[owner]),
|
|
79
|
+
kayan: kayan.name,
|
|
80
|
+
record: String(record),
|
|
81
|
+
operation,
|
|
82
|
+
actor: stringOf(actor?.id) ?? (typeof actor?.command === 'string' ? `${actor.command} (${String(actor.osUser)})` : null),
|
|
83
|
+
impersonatedBy: stringOf(actor?.impersonatedBy),
|
|
84
|
+
// JSON in a text column, because the language ships no json kind. The log
|
|
85
|
+
// refuses any filter or sort on it, since a `contains` over the text would
|
|
86
|
+
// read a value its reader may not.
|
|
87
|
+
changes: JSON.stringify(changes),
|
|
88
|
+
} })
|
|
89
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
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. The page holds only the fields this viewer reads
|
|
4
|
+
// as well, so a change the server let through for a column the page left out is
|
|
5
|
+
// not drawn either.
|
|
6
|
+
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
7
|
+
import type { ClientField, ClientKayan, ComponentProps, Row } from 'mithaq/admin'
|
|
8
|
+
import { useAccess, useClient, useKayan, useLocale, useRecord, useScreen, useUser } from 'mithaq/admin/react'
|
|
9
|
+
import { empty } from '../sink.ts'
|
|
10
|
+
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
|
11
|
+
import { Button } from '@/components/ui/button'
|
|
12
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
13
|
+
import { cellFor } from '@/components/mithaq/cells.tsx'
|
|
14
|
+
import { formatDate, labelFor, operationLabelFor, type FindApi, type FoundPage, type Translate } from '@/components/mithaq/controls.tsx'
|
|
15
|
+
import { useRelationshipTitles, type RelationshipTitles } from '@/components/mithaq/relationship-cell.tsx'
|
|
16
|
+
|
|
17
|
+
/** One entry as the log's `find` answers it. */
|
|
18
|
+
export type Entry = {
|
|
19
|
+
id: string
|
|
20
|
+
createdAt: string | Date
|
|
21
|
+
operation: string
|
|
22
|
+
actor?: string | null
|
|
23
|
+
impersonatedBy?: string | null
|
|
24
|
+
changes: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const limit = 10
|
|
28
|
+
|
|
29
|
+
/** The columns every kayan of this project carries and no person edits, which the panel leaves out. */
|
|
30
|
+
const technical = new Set(['id', 'organization', 'createdAt', 'updatedAt'])
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Who a write ran as, as a relationship to the provider's people, so the panel
|
|
34
|
+
* names them through the same read a relationship cell does: the active
|
|
35
|
+
* organization's members by their name, and anyone that read does not answer as
|
|
36
|
+
* someone.
|
|
37
|
+
*/
|
|
38
|
+
const actorField = { name: 'actor', kind: 'relationship', relationTo: 'user', label: {}, rules: {}, component: 'relationship', cell: 'relationship' } as unknown as ClientField
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What the sink writes for a command: the command, then the operating-system user
|
|
42
|
+
* in parentheses. A user's id holds no space, so no person is read as one.
|
|
43
|
+
*/
|
|
44
|
+
const commandOf = /^(.+?) \((.+)\)$/
|
|
45
|
+
|
|
46
|
+
const changesOf = (entry: Entry): [string, { from: unknown; to: unknown }][] => {
|
|
47
|
+
try {
|
|
48
|
+
return Object.entries(JSON.parse(entry.changes) as Record<string, { from: unknown; to: unknown }>)
|
|
49
|
+
} catch {
|
|
50
|
+
return []
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
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]]
|
|
55
|
+
|
|
56
|
+
/** How long ago, in the viewer's own locale, in the largest unit that is at least one. */
|
|
57
|
+
export const relative = ({ at, locale, now = Date.now() }: { at: Date; locale: string; now?: number }) => {
|
|
58
|
+
const seconds = Math.round((at.getTime() - now) / 1000)
|
|
59
|
+
const [unit, size] = units.find(([, size]) => Math.abs(seconds) >= size) ?? units.at(-1)!
|
|
60
|
+
return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(Math.round(seconds / size), unit)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type EntriesProps = {
|
|
64
|
+
entries: readonly Entry[]
|
|
65
|
+
/** The viewer's own id, whom an entry that names them calls "You", without a read of the people. */
|
|
66
|
+
viewer?: string
|
|
67
|
+
/** Whether the viewer reads the provider's people, which is what lets a command's operating-system user be named. */
|
|
68
|
+
people?: boolean
|
|
69
|
+
kayan: ClientKayan
|
|
70
|
+
titles?: RelationshipTitles
|
|
71
|
+
t: Translate
|
|
72
|
+
locale: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The entries drawn, with nothing read: what the panel shows once a page has come back. */
|
|
76
|
+
export function ActivityEntries({ entries, kayan, titles, t, locale, viewer, people }: EntriesProps) {
|
|
77
|
+
const copy = { t, locale }
|
|
78
|
+
// A person the title read did not name, one the reader may not read among
|
|
79
|
+
// them, is someone, never the raw id.
|
|
80
|
+
const named = (id: string | null | undefined) => {
|
|
81
|
+
if (!id) return t('auditLog.system', 'The system') ?? 'The system'
|
|
82
|
+
if (id === viewer) return t('auditLog.you', 'You') ?? 'You'
|
|
83
|
+
// A command's actor, which the sink writes as one string, and which no
|
|
84
|
+
// people read names: said in the viewer's locale to a reader of the
|
|
85
|
+
// people, with each part isolated so the Arabic sentence keeps the
|
|
86
|
+
// command's own direction.
|
|
87
|
+
const command = people ? commandOf.exec(id) : null
|
|
88
|
+
if (command) return (t('auditLog.command', 'the command {command} ({user})') ?? '').replace('{command}', () => `\u2068${command[1]}\u2069`).replace('{user}', () => `\u2068${command[2]}\u2069`)
|
|
89
|
+
const title = titles?.get(`user:${id}`)
|
|
90
|
+
return typeof title === 'string' ? title : (t('auditLog.someone', 'Someone') ?? 'Someone')
|
|
91
|
+
}
|
|
92
|
+
const drawn = (field: ClientField, value: unknown): ReactNode =>
|
|
93
|
+
empty(value) ? '—' : cellFor(field)({ kayan, field, value, t, locale, titles })
|
|
94
|
+
return (
|
|
95
|
+
<ol className="grid gap-4 text-sm">
|
|
96
|
+
{entries.map((entry) => {
|
|
97
|
+
const at = new Date(entry.createdAt)
|
|
98
|
+
// Every operation has its words in the log's own scope, in the past
|
|
99
|
+
// tense the sentence reads in, and one a project adds before it
|
|
100
|
+
// writes them falls back to the operation's own label.
|
|
101
|
+
const action = t(`auditLog.action.${entry.operation}`, operationLabelFor({ kayan, operation: { name: entry.operation }, copy })) ?? entry.operation
|
|
102
|
+
// Staff writing inside a member's session is named first, as the member.
|
|
103
|
+
const actor = entry.impersonatedBy
|
|
104
|
+
? (t('auditLog.impersonated', '{staff} as {member}') ?? '{staff} as {member}').replace('{staff}', () => named(entry.impersonatedBy)).replace('{member}', () => named(entry.actor))
|
|
105
|
+
: named(entry.actor)
|
|
106
|
+
const shown = changesOf(entry).flatMap(([name, change]) => {
|
|
107
|
+
const field = technical.has(name) ? undefined : kayan.fields.find((one) => one.name === name)
|
|
108
|
+
return field ? [{ field, change }] : []
|
|
109
|
+
})
|
|
110
|
+
return (
|
|
111
|
+
<li key={entry.id} data-entry={entry.operation} className="grid grid-cols-[2rem_minmax(0,1fr)] gap-3">
|
|
112
|
+
<Avatar className="mt-0.5 size-8">
|
|
113
|
+
<AvatarFallback>{(named(entry.impersonatedBy ?? entry.actor).trim()[0] ?? '?').toUpperCase()}</AvatarFallback>
|
|
114
|
+
</Avatar>
|
|
115
|
+
<div className="min-w-0">
|
|
116
|
+
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
|
117
|
+
<p data-event className="font-medium">
|
|
118
|
+
{(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)}
|
|
119
|
+
</p>
|
|
120
|
+
<time dateTime={at.toISOString()} className="text-muted-foreground shrink-0 text-xs">
|
|
121
|
+
{relative({ at, locale })}
|
|
122
|
+
</time>
|
|
123
|
+
</div>
|
|
124
|
+
<p className="text-muted-foreground text-xs">
|
|
125
|
+
{formatDate({ value: at, locale })}
|
|
126
|
+
</p>
|
|
127
|
+
{shown.length ? (
|
|
128
|
+
<ul className="text-muted-foreground mt-1.5 grid gap-1">
|
|
129
|
+
{shown.map(({ field, change }) => (
|
|
130
|
+
<li key={field.name} data-change={field.name} className="break-words">
|
|
131
|
+
<span className="text-foreground">{labelFor({ kayan, field, copy })}</span>
|
|
132
|
+
{': '}
|
|
133
|
+
<span data-from>{drawn(field, change.from)}</span>
|
|
134
|
+
<span aria-hidden className="mx-1 inline-block rtl:rotate-180">→</span>
|
|
135
|
+
<span data-to>{drawn(field, change.to)}</span>
|
|
136
|
+
</li>
|
|
137
|
+
))}
|
|
138
|
+
</ul>
|
|
139
|
+
) : (
|
|
140
|
+
<p className="text-muted-foreground mt-1.5">{t('auditLog.none', 'No field you may read changed.')}</p>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
</li>
|
|
144
|
+
)
|
|
145
|
+
})}
|
|
146
|
+
</ol>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The panel a page names as a component node. It reads the row the page
|
|
152
|
+
* draws and the kayan it belongs to off the screen, so one file serves every
|
|
153
|
+
* kayan's edit page.
|
|
154
|
+
*/
|
|
155
|
+
export function Activity(_: ComponentProps) {
|
|
156
|
+
const { t, locale } = useLocale()
|
|
157
|
+
const kayan = useKayan()
|
|
158
|
+
const record = useRecord()
|
|
159
|
+
const client = useClient()
|
|
160
|
+
const screen = useScreen()
|
|
161
|
+
const viewer = useUser().id
|
|
162
|
+
const people = useAccess().collections?.user?.read === true
|
|
163
|
+
const id = record?.id === undefined ? undefined : String(record.id)
|
|
164
|
+
// The row as this page last drew it: a save moves its `updatedAt`, and the
|
|
165
|
+
// panel reads its entries again from the first page, the new one on top.
|
|
166
|
+
const seen = `${id}@${String(record?.updatedAt ?? '')}`
|
|
167
|
+
// What has been read, and how many pages were asked for, each held with the
|
|
168
|
+
// row it is for, so a page that moves to another row, or the same row
|
|
169
|
+
// saved, starts at its first page rather than drawing the last one's.
|
|
170
|
+
const [read, setRead] = useState<{ seen: string; docs: Entry[]; more: boolean } | { seen: string; failed: true }>()
|
|
171
|
+
const [asked, setAsked] = useState({ seen, page: 1 })
|
|
172
|
+
const page = asked.seen === seen ? asked.page : 1
|
|
173
|
+
|
|
174
|
+
// The client is left out of the dependencies: the host makes a new one on
|
|
175
|
+
// every render of the page, and each posts to the same route, so following it
|
|
176
|
+
// would read the same page again on every render.
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (!id) return
|
|
179
|
+
let live = true
|
|
180
|
+
const where = { and: [{ kayan: { equals: kayan.name } }, { record: { equals: id } }] }
|
|
181
|
+
;(client({ kayan: 'auditLog', operation: 'find', input: { where, sort: '-createdAt', limit, page, depth: 0 } }) as Promise<FoundPage>).then(
|
|
182
|
+
(found) => {
|
|
183
|
+
if (!live) return
|
|
184
|
+
// The pages before this one, as far as they go, and this one after
|
|
185
|
+
// them, less any entry already drawn: a write that lands between two
|
|
186
|
+
// pages moves every older entry down one, so the next page opens on
|
|
187
|
+
// the one the last page ended with.
|
|
188
|
+
setRead((held) => {
|
|
189
|
+
const before = held?.seen === seen && 'docs' in held ? held.docs.slice(0, (page - 1) * limit) : []
|
|
190
|
+
const drawn = new Set(before.map((entry) => entry.id))
|
|
191
|
+
return { seen, docs: [...before, ...(found.docs as Entry[]).filter((entry) => !drawn.has(entry.id))], more: found.hasNextPage }
|
|
192
|
+
})
|
|
193
|
+
},
|
|
194
|
+
() => live && setRead({ seen, failed: true }),
|
|
195
|
+
)
|
|
196
|
+
return () => {
|
|
197
|
+
live = false
|
|
198
|
+
}
|
|
199
|
+
}, [kayan.name, seen, page])
|
|
200
|
+
|
|
201
|
+
// Every kayan this screen crosses, read through the screen's own client, so
|
|
202
|
+
// a relationship's value and the actor are named the way a list names them.
|
|
203
|
+
const kayans = useMemo(() => Object.values(screen.kayan), [screen])
|
|
204
|
+
const api = useMemo<FindApi>(
|
|
205
|
+
() => Object.fromEntries(kayans.map((one) => [one.name, { find: (input: Row) => client({ kayan: one.name, operation: 'find', input }) as Promise<FoundPage> }])),
|
|
206
|
+
[kayans],
|
|
207
|
+
)
|
|
208
|
+
const shown = read?.seen === seen ? read : undefined
|
|
209
|
+
const entries = shown && 'docs' in shown ? shown.docs : []
|
|
210
|
+
// Each entry's actor, both people of an impersonation, and every value a
|
|
211
|
+
// change holds, as rows the title read groups by the kayan each points at.
|
|
212
|
+
const docs = entries.flatMap((entry) => [
|
|
213
|
+
{ actor: [entry.actor, entry.impersonatedBy].filter(Boolean) },
|
|
214
|
+
...changesOf(entry).flatMap(([name, change]) => [{ [name]: change.from }, { [name]: change.to }]),
|
|
215
|
+
])
|
|
216
|
+
const titles = useRelationshipTitles({ fields: [...kayan.fields, actorField], docs, context: { api, client: kayans, locale } })
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<Card data-panel="activity">
|
|
220
|
+
<CardHeader>
|
|
221
|
+
<CardTitle>{t('auditLog.activity', 'Activity')}</CardTitle>
|
|
222
|
+
</CardHeader>
|
|
223
|
+
<CardContent>
|
|
224
|
+
{shown === undefined ? (
|
|
225
|
+
<p className="text-muted-foreground text-sm">{t('auditLog.loading', 'Loading activity…')}</p>
|
|
226
|
+
) : 'failed' in shown ? (
|
|
227
|
+
<p className="text-destructive text-sm">{t('auditLog.failed', 'The activity could not be read.')}</p>
|
|
228
|
+
) : !entries.length ? (
|
|
229
|
+
<p className="text-muted-foreground text-sm">{t('auditLog.empty', 'Nothing has changed here yet.')}</p>
|
|
230
|
+
) : (
|
|
231
|
+
<div className="grid gap-4">
|
|
232
|
+
<ActivityEntries entries={entries} kayan={kayan} titles={titles} t={t} locale={locale} viewer={viewer} people={people} />
|
|
233
|
+
{shown.more ? (
|
|
234
|
+
<Button type="button" variant="outline" size="sm" onClick={() => setAsked({ seen, page: page + 1 })}>
|
|
235
|
+
{t('auditLog.more', 'Show older')}
|
|
236
|
+
</Button>
|
|
237
|
+
) : null}
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
</CardContent>
|
|
241
|
+
</Card>
|
|
242
|
+
)
|
|
243
|
+
}
|