create-mithaq 0.1.1 → 0.2.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/LICENSES/openstatus-time-picker.txt +21 -0
- package/index.js +457 -63
- package/package.json +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/package.json +5 -3
- package/template/apps/{asas-platform → asas-backoffice}/src/api.ts +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/src/auth-frame.tsx +12 -12
- package/template/apps/asas-backoffice/src/components.ts +35 -0
- package/template/apps/asas-backoffice/src/first-user.ts +14 -0
- package/template/apps/{asas-platform → asas-backoffice}/src/links.ts +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/src/locale-switch.tsx +7 -5
- 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-platform → asas-backoffice}/src/request-headers.ts +1 -1
- 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-platform/src/routes/(screens)/platform → asas-backoffice/src/routes/(screens)/backoffice}/screen.json +426 -64
- package/template/apps/{asas-platform/src/routes/(screens)/platform → asas-backoffice/src/routes/(screens)/backoffice}/types.ts +18 -0
- package/template/apps/{asas-platform/src/routes/(screens)/platform.$.tsx → asas-backoffice/src/routes/(screens)/backoffice.$.tsx} +4 -4
- package/template/apps/{asas-platform/src/routes/(screens)/platform.index.tsx → asas-backoffice/src/routes/(screens)/backoffice.index.tsx} +3 -3
- package/template/apps/{asas-platform/src/routes/(screens)/platform.tsx → asas-backoffice/src/routes/(screens)/backoffice.tsx} +3 -3
- package/template/apps/{asas-platform → asas-backoffice}/src/routes/__root.tsx +2 -2
- 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-platform → asas-backoffice}/src/routes/auth.sign-in.tsx +9 -4
- package/template/apps/{asas-platform → asas-backoffice}/src/routes/unauthorized.tsx +24 -12
- package/template/apps/{asas-platform → asas-backoffice}/src/screen-form.tsx +5 -3
- package/template/apps/{asas-platform → asas-backoffice}/src/screen-list.tsx +6 -5
- package/template/apps/{asas-platform → asas-backoffice}/src/screen-names.ts +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/src/screens.tsx +46 -26
- package/template/apps/asas-backoffice/src/server.ts +30 -0
- package/template/apps/{asas-platform → asas-backoffice}/src/slide.ts +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/src/start.ts +1 -1
- package/template/apps/{asas-platform → asas-backoffice}/src/styles.css +9 -7
- package/template/apps/{asas-platform → asas-backoffice}/tsconfig.json +2 -2
- package/template/apps/{asas-platform → asas-backoffice}/vite.config.ts +7 -1
- package/template/apps/asas-tanstack/package.json +4 -2
- package/template/apps/asas-tanstack/src/api.ts +1 -1
- package/template/apps/asas-tanstack/src/auth-frame.tsx +12 -12
- package/template/apps/asas-tanstack/src/choose-password.tsx +150 -0
- package/template/apps/asas-tanstack/src/components.ts +35 -0
- package/template/apps/asas-tanstack/src/copy.ts +416 -79
- package/template/apps/asas-tanstack/src/first-run.ts +16 -0
- package/template/apps/asas-tanstack/src/links.ts +1 -1
- package/template/apps/asas-tanstack/src/locale-switch.tsx +7 -5
- package/template/apps/asas-tanstack/src/no-organization.tsx +108 -0
- package/template/apps/asas-tanstack/src/redirect.ts +20 -1
- package/template/apps/asas-tanstack/src/request-headers.ts +1 -1
- package/template/apps/asas-tanstack/src/routeTree.gen.ts +42 -0
- package/template/apps/asas-tanstack/src/router.tsx +12 -1
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +9 -2
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +475 -116
- package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +4 -2
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +7 -2
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +83 -319
- package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +3 -1
- package/template/apps/asas-tanstack/src/routes/__root.tsx +1 -1
- package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +14 -7
- package/template/apps/asas-tanstack/src/routes/auth.first-run.tsx +114 -0
- package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +2 -2
- package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +5 -5
- package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +7 -100
- package/template/apps/asas-tanstack/src/routes/auth.set-password.tsx +16 -0
- package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +27 -15
- package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +59 -38
- package/template/apps/asas-tanstack/src/routes/index.tsx +18 -14
- package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +24 -12
- package/template/apps/asas-tanstack/src/screen-form.tsx +5 -3
- package/template/apps/asas-tanstack/src/screen-list.tsx +6 -5
- package/template/apps/asas-tanstack/src/screen-names.ts +1 -1
- package/template/apps/asas-tanstack/src/screens.tsx +46 -26
- package/template/apps/asas-tanstack/src/server.ts +10 -0
- package/template/apps/asas-tanstack/src/slide.ts +1 -1
- package/template/apps/asas-tanstack/src/styles.css +7 -3
- package/template/apps/asas-tanstack/tsconfig.json +2 -2
- package/template/apps/asas-tanstack/vite.config.ts +7 -1
- package/template/package.json +3 -3
- 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 +22 -5
- package/template/templates/asas/AGENTS.md +101 -0
- package/template/templates/asas/CLAUDE.md +1 -0
- package/template/templates/asas/README.md +1094 -0
- package/template/templates/asas/package.json +19 -4
- package/template/templates/asas/src/asas.ts +81 -56
- package/template/templates/asas/src/{platform-http.ts → backoffice-http.ts} +48 -27
- 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 +129 -103
- package/template/templates/asas/src/messages/auditLog.json +223 -105
- package/template/templates/asas/src/messages/backofficeLog.json +132 -0
- package/template/templates/asas/src/messages/courses.json +256 -214
- package/template/templates/asas/src/messages/enrollments.json +45 -27
- package/template/templates/asas/src/messages/invitation.json +72 -56
- package/template/templates/asas/src/messages/localUsers.json +34 -28
- package/template/templates/asas/src/messages/media.json +31 -21
- package/template/templates/asas/src/messages/member.json +59 -39
- package/template/templates/asas/src/messages/memberRoles.json +37 -31
- package/template/templates/asas/src/messages/mithaq.json +206 -162
- package/template/templates/asas/src/messages/organization.json +63 -41
- package/template/templates/asas/src/messages/organizationRole.json +59 -47
- package/template/templates/asas/src/messages/roles.json +59 -45
- package/template/templates/asas/src/messages/session.json +99 -79
- package/template/templates/asas/src/messages/sessions.json +110 -80
- package/template/templates/asas/src/messages/settings.json +51 -53
- package/template/templates/asas/src/messages/tags.json +31 -21
- package/template/templates/asas/src/messages/user.json +109 -87
- package/template/templates/asas/src/messages/verification.json +59 -47
- 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/schema.ts +1 -0
- package/template/templates/asas/src/mithaq.ts +4 -406
- package/template/templates/asas/src/organizations.ts +3 -0
- package/template/templates/asas/src/seed.ts +147 -45
- package/template/templates/asas/src/{ui-platform → ui-backoffice}/home.tsx +9 -9
- package/template/templates/asas/src/ui-backoffice/organization-actions.tsx +121 -0
- package/template/templates/asas/src/{ui-platform → ui-backoffice}/record-page.tsx +4 -4
- package/template/templates/asas/src/{ui-platform → ui-backoffice}/role-cells.tsx +13 -6
- package/template/templates/asas/src/{ui-platform → ui-backoffice}/user-actions.tsx +46 -48
- package/template/templates/asas/src/ui-dashboard/dashboard.tsx +10 -4
- 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 +637 -438
- package/template/templates/asas/src/ui-dashboard/versions.tsx +22 -8
- package/template/templates/asas/src/ui-portal/courses-home.tsx +96 -0
- package/template/templates/asas/src/ui-portal/home.tsx +9 -13
- 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/{asas/src → features/auth}/local-users/local-users.ts +7 -7
- package/template/templates/{auth → features/auth}/package.json +2 -3
- package/template/templates/{asas/src/kayan → features}/auth/user/account.access.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/account.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/invitation.access.ts +1 -1
- package/template/templates/{asas/src/kayan → features}/auth/user/invitation.ts +3 -3
- package/template/templates/{asas/src/kayan → features}/auth/user/member.access.ts +4 -4
- package/template/templates/{asas/src/kayan → features}/auth/user/member.screens.ts +5 -5
- package/template/templates/{asas/src/kayan → features}/auth/user/member.ts +4 -3
- package/template/templates/{asas/src/kayan → features}/auth/user/organization.access.ts +6 -6
- package/template/templates/{asas/src/kayan → features}/auth/user/organization.screens.ts +6 -6
- package/template/templates/{asas/src/kayan → features}/auth/user/organization.ts +2 -1
- package/template/templates/{asas/src/kayan → features}/auth/user/organizationRole.access.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/organizationRole.ts +3 -3
- package/template/templates/{asas/src/kayan → features}/auth/user/session.access.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/session.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/user.access.ts +8 -7
- package/template/templates/{asas/src/kayan → features}/auth/user/user.screens.ts +9 -9
- package/template/templates/{asas/src/kayan → features}/auth/user/user.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/verification.access.ts +2 -2
- package/template/templates/{asas/src/kayan → features}/auth/user/verification.ts +2 -2
- package/template/templates/{asas/src → features/courses}/courses/courses.ts +122 -133
- 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/{asas/src → features/courses}/courses/ui/fee-cell.tsx +1 -1
- package/template/templates/{asas/src → features/courses}/courses/ui/status-cell.tsx +5 -3
- 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/{asas/src → features/courses}/enrollments/ui/session-cell.tsx +17 -7
- package/template/templates/{asas/src → features/courses/enrollments}/ui/session-labels.tsx +3 -3
- 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/{asas/src → features/courses}/sessions/sessions.ts +45 -14
- package/template/templates/features/media/index.ts +10 -0
- package/template/templates/{asas/src → features/media}/media/media.ts +34 -40
- package/template/templates/{asas/src → features/media}/media/ui/preview.tsx +2 -2
- package/template/templates/features/media/package.json +19 -0
- package/template/templates/features/roles/index.ts +4 -0
- package/template/templates/{asas/src → features/roles}/member-roles/member-roles.ts +15 -15
- package/template/templates/features/roles/package.json +21 -0
- package/template/templates/{asas/src → features/roles}/roles/roles.ts +23 -23
- package/template/templates/{asas/src → features/roles}/roles/ui/role-form.tsx +65 -24
- package/template/templates/{asas/src → features/roles}/roles/ui/roles.tsx +49 -25
- package/template/templates/features/settings/index.ts +3 -0
- package/template/templates/features/settings/package.json +13 -0
- package/template/templates/{asas/src → features/settings}/settings/settings.ts +12 -17
- package/template/templates/features/tags/index.ts +3 -0
- package/template/templates/features/tags/package.json +14 -0
- package/template/templates/{asas/src → features/tags}/tags/tags.ts +15 -15
- 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/{asas/src/audit-log/sink.ts → mithaq/src/mithaq/audit-sink.ts} +31 -9
- package/template/templates/{auth/src/platform-roles.ts → mithaq/src/mithaq/auth/backoffice-roles.ts} +44 -23
- 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/{asas/src → mithaq/src/mithaq}/pages.ts +16 -7
- 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/{asas → mithaq}/src/roles/grants.ts +1 -1
- package/template/templates/mithaq/src/roles/member-access.ts +11 -0
- package/template/templates/{asas/src → mithaq/src/roles}/statements.ts +6 -4
- package/template/templates/mithaq/src/roles/system-roles.ts +16 -0
- package/template/templates/{asas/src → mithaq/src/roles}/tenant-access.ts +96 -57
- package/template/templates/{asas/src → mithaq/src/roles}/tenant-roles.ts +84 -25
- package/template/templates/{asas → mithaq}/src/steps/expected-updated-at.ts +2 -2
- package/template/templates/{asas → mithaq}/src/storage/index.ts +3 -1
- package/template/templates/mithaq/src/ui/activity-page.tsx +33 -0
- package/template/templates/mithaq/src/ui/activity.tsx +527 -0
- package/template/templates/{asas → mithaq}/src/ui/app-shell.tsx +27 -19
- package/template/templates/mithaq/src/ui/rich-text-nodes.tsx +33 -0
- package/template/templates/{asas → mithaq}/src/ui/screen-nav.tsx +1 -1
- package/template/templates/{asas → mithaq}/src/ui/top-shell.tsx +8 -8
- package/template/apps/asas-platform/src/components/mithaq/cells.tsx +0 -112
- package/template/apps/asas-platform/src/components/mithaq/controls.tsx +0 -650
- package/template/apps/asas-platform/src/components/mithaq/field-shell.tsx +0 -53
- package/template/apps/asas-platform/src/components/mithaq/filter-operators.ts +0 -108
- package/template/apps/asas-platform/src/components/mithaq/form.tsx +0 -1467
- package/template/apps/asas-platform/src/components/mithaq/list-columns.tsx +0 -108
- package/template/apps/asas-platform/src/components/mithaq/list-filters.tsx +0 -368
- package/template/apps/asas-platform/src/components/mithaq/list-pagination.tsx +0 -153
- package/template/apps/asas-platform/src/components/mithaq/list-query.ts +0 -432
- package/template/apps/asas-platform/src/components/mithaq/list-selection.tsx +0 -74
- package/template/apps/asas-platform/src/components/mithaq/list.tsx +0 -865
- package/template/apps/asas-platform/src/components/mithaq/nav.tsx +0 -871
- package/template/apps/asas-platform/src/components/mithaq/nodes.tsx +0 -1024
- package/template/apps/asas-platform/src/components/mithaq/relationship-cell.tsx +0 -127
- package/template/apps/asas-platform/src/components/mithaq/relationship-control.tsx +0 -400
- package/template/apps/asas-platform/src/components/mithaq/rich-text-nodes.ts +0 -6
- package/template/apps/asas-platform/src/components/mithaq/rich-text.tsx +0 -1083
- package/template/apps/asas-platform/src/components/mithaq/upload.tsx +0 -686
- package/template/apps/asas-platform/src/components/ui/drawer.tsx +0 -216
- package/template/apps/asas-platform/src/redirect.ts +0 -1
- package/template/apps/asas-platform/src/routeTree.gen.ts +0 -181
- package/template/apps/asas-platform/src/router.tsx +0 -7
- package/template/apps/asas-platform/src/routes/(screens)/platform/files.ts +0 -4
- package/template/apps/asas-platform/src/routes/api/$.ts +0 -30
- package/template/apps/asas-platform/src/server.ts +0 -20
- package/template/apps/asas-tanstack/src/components/mithaq/cells.tsx +0 -112
- package/template/apps/asas-tanstack/src/components/mithaq/controls.tsx +0 -650
- package/template/apps/asas-tanstack/src/components/mithaq/field-shell.tsx +0 -53
- package/template/apps/asas-tanstack/src/components/mithaq/filter-operators.ts +0 -108
- package/template/apps/asas-tanstack/src/components/mithaq/form.tsx +0 -1467
- package/template/apps/asas-tanstack/src/components/mithaq/list-columns.tsx +0 -108
- package/template/apps/asas-tanstack/src/components/mithaq/list-filters.tsx +0 -368
- package/template/apps/asas-tanstack/src/components/mithaq/list-pagination.tsx +0 -153
- package/template/apps/asas-tanstack/src/components/mithaq/list-query.ts +0 -432
- package/template/apps/asas-tanstack/src/components/mithaq/list-selection.tsx +0 -74
- package/template/apps/asas-tanstack/src/components/mithaq/list.tsx +0 -865
- package/template/apps/asas-tanstack/src/components/mithaq/nav.tsx +0 -871
- package/template/apps/asas-tanstack/src/components/mithaq/nodes.tsx +0 -1024
- package/template/apps/asas-tanstack/src/components/mithaq/relationship-cell.tsx +0 -127
- package/template/apps/asas-tanstack/src/components/mithaq/relationship-control.tsx +0 -400
- package/template/apps/asas-tanstack/src/components/mithaq/rich-text-nodes.ts +0 -6
- package/template/apps/asas-tanstack/src/components/mithaq/rich-text.tsx +0 -1083
- package/template/apps/asas-tanstack/src/components/mithaq/upload.tsx +0 -686
- package/template/apps/asas-tanstack/src/components/ui/drawer.tsx +0 -215
- package/template/templates/asas/src/audit-log/audit-log.ts +0 -187
- package/template/templates/asas/src/audit-log/ui/activity.tsx +0 -243
- package/template/templates/asas/src/courses/ui/archive-button.tsx +0 -25
- package/template/templates/asas/src/courses/ui/no-courses-on-offer.tsx +0 -11
- package/template/templates/asas/src/courses/ui/on-offer.ts +0 -6
- package/template/templates/asas/src/enrollments/enrollments.ts +0 -137
- package/template/templates/asas/src/rich-text-nodes.ts +0 -11
- package/template/templates/asas/src/roles.ts +0 -16
- package/template/templates/asas/src/ui/rich-text-nodes.tsx +0 -15
- package/template/templates/asas/src/ui-platform/organization-actions.tsx +0 -114
- package/template/templates/auth/src/index.ts +0 -690
- /package/template/apps/{asas-platform → asas-backoffice}/components.json +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/gitignore +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/public/favicon.svg +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/accordion.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/alert-dialog.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/avatar.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/badge.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/breadcrumb.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/button-group.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/button.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/calendar.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/card.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/chart.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/checkbox.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/collapsible.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/combobox.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/dialog.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/direction.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/directional-icon.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/dropdown-menu.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/field.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input-group.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input-otp.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/label.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/popover.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/radio-group.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/resizable.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/scroll-area.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/select.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/separator.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/sheet.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/sidebar.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/skeleton.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/slider.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/spinner.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/switch.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/table.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/tabs.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/textarea.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/toggle-group.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/toggle.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/tooltip.tsx +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/copy.ts +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/hooks/use-mobile.ts +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/lib/utils.ts +0 -0
- /package/template/apps/{asas-platform → asas-backoffice}/src/theme.ts +0 -0
- /package/template/templates/{asas/src/kayan → features}/auth/user/index.ts +0 -0
- /package/template/templates/{asas/src → features/courses}/courses/ui/no-courses.tsx +0 -0
- /package/template/templates/{asas → mithaq}/components.json +0 -0
- /package/template/templates/{asas → mithaq}/src/storage/defaults.ts +0 -0
- /package/template/templates/{asas → mithaq}/src/storage/local.ts +0 -0
- /package/template/templates/{asas → mithaq}/src/storage/r2.ts +0 -0
- /package/template/templates/{asas → mithaq}/src/storage/s3.ts +0 -0
- /package/template/templates/{asas → mithaq}/src/storage/uploadthing.ts +0 -0
- /package/template/templates/{asas → mithaq}/src/ui/kpi-card.tsx +0 -0
- /package/template/templates/{asas → mithaq}/src/ui/record-page.tsx +0 -0
- /package/template/templates/{asas → mithaq}/src/ui/table-page.tsx +0 -0
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Each action is the admin plugin's own route, drawn only where this actor holds its key
|
|
2
2
|
// (`user.set-role`, `user.ban` or `user.impersonate`) and refused by the provider without it. None is
|
|
3
|
-
// drawn on the viewer's own row, and on a staff member's, a person holding a
|
|
3
|
+
// drawn on the viewer's own row, and on a staff member's, a person holding a backoffice role, only for a
|
|
4
4
|
// super admin; the server refuses both whatever is drawn. An action that lands asks the page behind
|
|
5
5
|
// it to be read again through `mithaq:refresh`, so the page keeps its scroll.
|
|
6
6
|
import { useState, type ReactNode } from 'react'
|
|
7
7
|
import { holds, type CellProps, type MenuProps, type Row } from 'mithaq/admin'
|
|
8
8
|
import { useAccess, useLocale, useRecord, useUser } from 'mithaq/admin/react'
|
|
9
|
-
import {
|
|
9
|
+
import { backofficeRoles } from '@asas/mithaq/auth/backoffice-roles'
|
|
10
10
|
import { authArm } from '@/api.ts'
|
|
11
|
+
import { toast } from 'mithaq-admin/toast.tsx'
|
|
11
12
|
import {
|
|
12
13
|
AlertDialog,
|
|
13
14
|
AlertDialogAction,
|
|
@@ -24,15 +25,15 @@ import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
|
|
24
25
|
import { Input } from '@/components/ui/input'
|
|
25
26
|
import { Label } from '@/components/ui/label'
|
|
26
27
|
|
|
27
|
-
/** The names `set-role` accepts: the
|
|
28
|
-
const roleNames = ['user', ...Object.keys(
|
|
28
|
+
/** The names `set-role` accepts: the backoffice roles and the plugin's own `user`, which is none of them. */
|
|
29
|
+
const roleNames = ['user', ...Object.keys(backofficeRoles)] as const
|
|
29
30
|
|
|
30
31
|
/** How long a ban lasts, in the seconds the plugin's `banExpiresIn` takes, or until it is lifted. */
|
|
31
32
|
const banEnds = [
|
|
32
|
-
['', '
|
|
33
|
-
['86400', '
|
|
34
|
-
['604800', '
|
|
35
|
-
['2592000', '
|
|
33
|
+
['', 'backoffice.banUntilLifted', 'Until it is lifted'],
|
|
34
|
+
['86400', 'backoffice.banDay', 'One day'],
|
|
35
|
+
['604800', 'backoffice.banWeek', 'Seven days'],
|
|
36
|
+
['2592000', 'backoffice.banMonth', 'Thirty days'],
|
|
36
37
|
] as const
|
|
37
38
|
|
|
38
39
|
type Action = 'set-role' | 'ban' | 'unban' | 'impersonate'
|
|
@@ -42,18 +43,18 @@ const choiceClass =
|
|
|
42
43
|
'h-10 w-full rounded-none border border-transparent border-b-input bg-transparent px-0 text-sm outline-none focus-visible:border-b-ring [&>option]:bg-popover [&>option]:text-popover-foreground'
|
|
43
44
|
|
|
44
45
|
const refusalsByCode: Record<string, [key: string, fallback: string]> = {
|
|
45
|
-
BANNED_USER: ['
|
|
46
|
+
BANNED_USER: ['backoffice.refusedBanned', 'This person is banned, so they cannot be impersonated.'],
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
const
|
|
49
|
+
const useBackofficeRoleLabel = () => {
|
|
49
50
|
const { locale, t } = useLocale()
|
|
50
51
|
return (one: string) =>
|
|
51
|
-
Object.hasOwn(
|
|
52
|
+
Object.hasOwn(backofficeRoles, one) ? backofficeRoles[one as keyof typeof backofficeRoles].label[locale === 'ar' ? 'ar' : 'en'] : t('backoffice.noRole', 'No backoffice role')
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
/** A person's
|
|
55
|
-
export function
|
|
56
|
-
const labelOf =
|
|
55
|
+
/** A person's backoffice roles as badges, each by its label, which a record page draws where the column holds the slugs. */
|
|
56
|
+
export function BackofficeRole({ value }: Pick<CellProps, 'value'>) {
|
|
57
|
+
const labelOf = useBackofficeRoleLabel()
|
|
57
58
|
const role = typeof value === 'string' && value ? value : 'user'
|
|
58
59
|
return (
|
|
59
60
|
<span className="inline-flex flex-wrap gap-1">
|
|
@@ -71,7 +72,7 @@ const usePerson = (row: Row) => {
|
|
|
71
72
|
const viewer = useUser()
|
|
72
73
|
const role = typeof row.role === 'string' && row.role ? row.role : 'user'
|
|
73
74
|
const own = typeof row.email === 'string' && row.email === viewer.email
|
|
74
|
-
const staff = role.split(',').some((one) => Object.hasOwn(
|
|
75
|
+
const staff = role.split(',').some((one) => Object.hasOwn(backofficeRoles, one.trim()))
|
|
75
76
|
// None on the viewer's own row, and on a staff member's only for a super
|
|
76
77
|
// admin; the server refuses both whatever is drawn.
|
|
77
78
|
const acts = !own && (!staff || access.permissions === '*')
|
|
@@ -86,19 +87,18 @@ const usePerson = (row: Row) => {
|
|
|
86
87
|
|
|
87
88
|
const useNames = () => {
|
|
88
89
|
const { t } = useLocale()
|
|
89
|
-
return { 'set-role': t('
|
|
90
|
+
return { 'set-role': t('backoffice.setRole', 'Set role'), ban: t('backoffice.ban', 'Ban'), unban: t('backoffice.unban', 'Unban'), impersonate: t('backoffice.impersonate', 'Impersonate') }
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
function PersonDialog({ row, action, onClose }: { row: Row; action: Action; onClose: () => void }) {
|
|
93
94
|
const { t } = useLocale()
|
|
94
95
|
const names = useNames()
|
|
95
|
-
const labelOf =
|
|
96
|
+
const labelOf = useBackofficeRoleLabel()
|
|
96
97
|
const { role } = usePerson(row)
|
|
97
98
|
const [chosen, setChosen] = useState(role)
|
|
98
99
|
const [reason, setReason] = useState('')
|
|
99
100
|
const [ends, setEnds] = useState('')
|
|
100
101
|
const [refusal, setRefusal] = useState<string>()
|
|
101
|
-
const [done, setDone] = useState<string>()
|
|
102
102
|
const [busy, setBusy] = useState(false)
|
|
103
103
|
const userId = String(row.id ?? '')
|
|
104
104
|
const name = String(row.name || row.email || '')
|
|
@@ -115,43 +115,43 @@ function PersonDialog({ row, action, onClose }: { row: Row; action: Action; onCl
|
|
|
115
115
|
// refusal in the viewer's locale; the detail stays in the answer's body.
|
|
116
116
|
if (!answered.ok) {
|
|
117
117
|
const known = refusalsByCode[String(answered.body?.code)]
|
|
118
|
-
return setRefusal(known ? t(known[0], known[1]) : t('
|
|
118
|
+
return setRefusal(known ? t(known[0], known[1]) : t('backoffice.refused', 'The backoffice refused that action.'))
|
|
119
119
|
}
|
|
120
|
-
// The
|
|
120
|
+
// The backoffice answers a one-time link into the tenant app, which trades it for the impersonated
|
|
121
121
|
// session under its own cookie.
|
|
122
122
|
if (action === 'impersonate') {
|
|
123
123
|
onClose()
|
|
124
124
|
return window.location.assign(String(answered.body?.url))
|
|
125
125
|
}
|
|
126
126
|
window.dispatchEvent(new Event('mithaq:refresh'))
|
|
127
|
-
|
|
127
|
+
toast.success(landed[action])
|
|
128
|
+
onClose()
|
|
128
129
|
}
|
|
129
130
|
// Functions, so a name holding `$&` is put in as written and never read as a pattern.
|
|
130
131
|
const fill = (text: string | undefined) => (text ?? '').replace('{name}', () => name).replace('{role}', () => labelOf(chosen) ?? chosen)
|
|
131
132
|
const said = {
|
|
132
133
|
// Asked only once another role is picked: the dialog opens on the role
|
|
133
134
|
// the person holds, which there is nothing to give.
|
|
134
|
-
'set-role': fill(chosen === role ? t('
|
|
135
|
-
ban: fill(t('
|
|
136
|
-
unban: fill(t('
|
|
137
|
-
impersonate: fill(t('
|
|
135
|
+
'set-role': fill(chosen === role ? t('backoffice.pickRole', 'Pick another backoffice role for {name}.') : t('backoffice.confirmRole', 'Give {name} the backoffice role "{role}"?')),
|
|
136
|
+
ban: fill(t('backoffice.confirmBan', 'Ban {name}? They are signed out of both apps and cannot sign in until the ban ends.')),
|
|
137
|
+
unban: fill(t('backoffice.confirmUnban', 'Lift the ban on {name}? They can sign in again.')),
|
|
138
|
+
impersonate: fill(t('backoffice.confirmImpersonate', 'Impersonate {name}? You act as them in the tenant app until you stop.')),
|
|
138
139
|
}[action]
|
|
139
140
|
const landed = {
|
|
140
|
-
'set-role': fill(t('
|
|
141
|
-
ban: fill(t('
|
|
142
|
-
unban: fill(t('
|
|
141
|
+
'set-role': fill(t('backoffice.roleSet', '{name} now holds the backoffice role "{role}".')),
|
|
142
|
+
ban: fill(t('backoffice.banned', '{name} is banned.')),
|
|
143
|
+
unban: fill(t('backoffice.unbanned', 'The ban on {name} is lifted.')),
|
|
143
144
|
}
|
|
144
145
|
return (
|
|
145
146
|
<AlertDialog open onOpenChange={(open) => !open && onClose()}>
|
|
146
147
|
<AlertDialogContent data-person={userId}>
|
|
147
148
|
<AlertDialogHeader>
|
|
148
149
|
<AlertDialogTitle>{names[action]}</AlertDialogTitle>
|
|
149
|
-
{
|
|
150
|
-
<AlertDialogDescription aria-live="polite">{done ?? said}</AlertDialogDescription>
|
|
150
|
+
<AlertDialogDescription>{said}</AlertDialogDescription>
|
|
151
151
|
</AlertDialogHeader>
|
|
152
|
-
{
|
|
152
|
+
{action === 'set-role' ? (
|
|
153
153
|
<div className="grid gap-2">
|
|
154
|
-
<Label htmlFor={`role-${userId}`}>{t('
|
|
154
|
+
<Label htmlFor={`role-${userId}`}>{t('backoffice.role', 'Backoffice role')}</Label>
|
|
155
155
|
<select
|
|
156
156
|
id={`role-${userId}`}
|
|
157
157
|
data-role="chosen"
|
|
@@ -167,20 +167,20 @@ function PersonDialog({ row, action, onClose }: { row: Row; action: Action; onCl
|
|
|
167
167
|
</select>
|
|
168
168
|
</div>
|
|
169
169
|
) : null}
|
|
170
|
-
{
|
|
170
|
+
{action === 'ban' ? (
|
|
171
171
|
<div className="grid gap-3">
|
|
172
172
|
<div className="grid gap-2">
|
|
173
|
-
<Label htmlFor={`ban-reason-${userId}`}>{t('
|
|
173
|
+
<Label htmlFor={`ban-reason-${userId}`}>{t('backoffice.banReason', 'Reason')}</Label>
|
|
174
174
|
{/* Required, and said so while it is empty, since Ban stays off until a reason is given. */}
|
|
175
175
|
<Input id={`ban-reason-${userId}`} data-ban="reason" required aria-required maxLength={200} aria-describedby={reason.trim() ? undefined : `ban-reason-${userId}-needed`} value={reason} onChange={(event) => setReason(event.target.value)} />
|
|
176
176
|
{reason.trim() ? null : (
|
|
177
177
|
<p id={`ban-reason-${userId}-needed`} className="text-muted-foreground text-sm">
|
|
178
|
-
{t('
|
|
178
|
+
{t('backoffice.banReasonNeeded', 'A ban needs a reason.')}
|
|
179
179
|
</p>
|
|
180
180
|
)}
|
|
181
181
|
</div>
|
|
182
182
|
<div className="grid gap-2">
|
|
183
|
-
<Label htmlFor={`ban-ends-${userId}`}>{t('
|
|
183
|
+
<Label htmlFor={`ban-ends-${userId}`}>{t('backoffice.banEnds', 'Ban lasts')}</Label>
|
|
184
184
|
<select
|
|
185
185
|
id={`ban-ends-${userId}`}
|
|
186
186
|
data-ban="ends"
|
|
@@ -197,23 +197,21 @@ function PersonDialog({ row, action, onClose }: { row: Row; action: Action; onCl
|
|
|
197
197
|
</div>
|
|
198
198
|
</div>
|
|
199
199
|
) : null}
|
|
200
|
-
{refusal
|
|
200
|
+
{refusal ? (
|
|
201
201
|
<p role="alert" className="text-sm text-destructive">
|
|
202
202
|
{refusal}
|
|
203
203
|
</p>
|
|
204
204
|
) : null}
|
|
205
205
|
<AlertDialogFooter className="max-sm:[&_button]:h-11">
|
|
206
|
-
<AlertDialogCancel data-button="close">{
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
</AlertDialogAction>
|
|
216
|
-
)}
|
|
206
|
+
<AlertDialogCancel data-button="close">{t('admin.cancel', 'Cancel')}</AlertDialogCancel>
|
|
207
|
+
<AlertDialogAction
|
|
208
|
+
data-button="confirm"
|
|
209
|
+
variant={action === 'ban' ? 'destructive' : 'default'}
|
|
210
|
+
disabled={busy || (action === 'ban' && !reason.trim()) || (action === 'set-role' && chosen === role)}
|
|
211
|
+
onClick={() => void send()}
|
|
212
|
+
>
|
|
213
|
+
{names[action]}
|
|
214
|
+
</AlertDialogAction>
|
|
217
215
|
</AlertDialogFooter>
|
|
218
216
|
</AlertDialogContent>
|
|
219
217
|
</AlertDialog>
|
|
@@ -12,9 +12,10 @@ import { Button } from '@/components/ui/button'
|
|
|
12
12
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
13
13
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart'
|
|
14
14
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
|
15
|
-
import { counted, formatDate } from '
|
|
16
|
-
import { AppShell } from '
|
|
17
|
-
import { KpiCard } from '
|
|
15
|
+
import { counted, formatDate } from 'mithaq-admin/format.tsx'
|
|
16
|
+
import { AppShell } from '@asas/mithaq/ui/app-shell.tsx'
|
|
17
|
+
import { KpiCard } from '@asas/mithaq/ui/kpi-card.tsx'
|
|
18
|
+
import { MembersCard, membersFigures, type MembersFigures } from './members-card.tsx'
|
|
18
19
|
|
|
19
20
|
const ranges = [7, 30, 90] as const
|
|
20
21
|
type Range = (typeof ranges)[number]
|
|
@@ -51,6 +52,8 @@ export type Figures = {
|
|
|
51
52
|
perDay?: Record<string, number>
|
|
52
53
|
/** The next sessions to start, each beside its course's title, where the actor reads sessions. */
|
|
53
54
|
upcoming?: { id: string; course: string; startsAt: string; venue?: string; capacity?: number }[]
|
|
55
|
+
/** Who works here and the roles they hold, where the actor may list members. */
|
|
56
|
+
members?: MembersFigures
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
/** How many sessions the home's table lists, which is a card's worth. */
|
|
@@ -78,7 +81,7 @@ export const loader = async (tools: LoaderTools): Promise<Figures> => {
|
|
|
78
81
|
// row where the actor holds `findVersions`, so the card and the list count one
|
|
79
82
|
// set, a course nobody has published yet among it.
|
|
80
83
|
const draft = access.collections?.courses?.operations?.findVersions === true
|
|
81
|
-
const [rows, enrollments, recent, next] = await Promise.all([
|
|
84
|
+
const [rows, enrollments, recent, next, members] = await Promise.all([
|
|
82
85
|
readAll({ tools, kayan: 'courses', draft }),
|
|
83
86
|
reads ? (client({ kayan: 'enrollments', operation: 'count', input: {} }) as Promise<{ totalDocs: number }>) : undefined,
|
|
84
87
|
reads ? readAll({ tools, kayan: 'enrollments', where: { createdAt: { greater_than_equal: since } } }) : undefined,
|
|
@@ -89,6 +92,7 @@ export const loader = async (tools: LoaderTools): Promise<Figures> => {
|
|
|
89
92
|
input: { where: { startsAt: { greater_than_equal: new Date().toISOString() } }, sort: 'startsAt', limit: Math.min(upcomingLimit, tools.maxLimit), depth: 0 },
|
|
90
93
|
}) as Promise<{ docs: Row[] }>)
|
|
91
94
|
: undefined,
|
|
95
|
+
membersFigures(tools),
|
|
92
96
|
])
|
|
93
97
|
const titles = new Map(rows.map((course) => [String(course.id), String(course.title ?? course.id)]))
|
|
94
98
|
const fees: Record<string, number> = {}
|
|
@@ -104,6 +108,7 @@ export const loader = async (tools: LoaderTools): Promise<Figures> => {
|
|
|
104
108
|
perDay[key] = (perDay[key] ?? 0) + 1
|
|
105
109
|
}
|
|
106
110
|
return {
|
|
111
|
+
...(members && { members }),
|
|
107
112
|
courses: rows.length,
|
|
108
113
|
open: open.length,
|
|
109
114
|
...(readsFees && { fees: Object.entries(fees) }),
|
|
@@ -210,6 +215,7 @@ export function Dashboard({ loaderData: figures }: PageProps<Figures>) {
|
|
|
210
215
|
/>
|
|
211
216
|
</>
|
|
212
217
|
) : null}
|
|
218
|
+
{figures?.members ? <MembersCard figures={figures.members} href={`${screen.path}/members`} /> : null}
|
|
213
219
|
</div>
|
|
214
220
|
{reads ? (
|
|
215
221
|
<Card data-chart-card="enrollments">
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// What `/dashboard` itself draws where the courses add-on, whose home counts
|
|
2
|
+
// its catalogue, is not there: a figure card for each list the nav offers the
|
|
3
|
+
// viewer, with how many rows of it they may read and a link to it, and with the
|
|
4
|
+
// roles add-on who works here and the roles they hold. A list
|
|
5
|
+
// whose count the viewer may not read, or whose count failed, shows a dash.
|
|
6
|
+
import type { LoaderTools, PageProps } from 'mithaq/admin'
|
|
7
|
+
import { useLocale, useScreen } from 'mithaq/admin/react'
|
|
8
|
+
import { AppShell, iconOf } from '@asas/mithaq/ui/app-shell.tsx'
|
|
9
|
+
import { KpiCard } from '@asas/mithaq/ui/kpi-card.tsx'
|
|
10
|
+
import { navLabel } from '@asas/mithaq/ui/screen-nav.tsx'
|
|
11
|
+
// add-on: roles
|
|
12
|
+
import { MembersCard, membersFigures } from './members-card.tsx'
|
|
13
|
+
// end add-on: roles
|
|
14
|
+
|
|
15
|
+
/** How many rows of each kayan the viewer may count, read as the viewer, keyed by its kayan. */
|
|
16
|
+
const countsOf = async ({ client, access }: LoaderTools): Promise<Record<string, number | undefined>> =>
|
|
17
|
+
Object.fromEntries(
|
|
18
|
+
await Promise.all(
|
|
19
|
+
Object.entries(access.collections ?? {})
|
|
20
|
+
.filter(([, one]) => one?.operations?.count === true)
|
|
21
|
+
.map(async ([kayan]) => [kayan, await (client({ kayan, operation: 'count', input: {} }) as Promise<{ totalDocs: number }>).then((found) => found.totalDocs).catch(() => undefined)] as const),
|
|
22
|
+
),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
export const loader = async (tools: LoaderTools) => ({
|
|
26
|
+
counts: await countsOf(tools),
|
|
27
|
+
// add-on: roles
|
|
28
|
+
// Who works here, where the viewer may list members.
|
|
29
|
+
members: await membersFigures(tools),
|
|
30
|
+
// end add-on: roles
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
export function Home({ loaderData }: PageProps<Awaited<ReturnType<typeof loader>>>) {
|
|
34
|
+
const counts = loaderData?.counts
|
|
35
|
+
const { locale, t } = useLocale()
|
|
36
|
+
const screen = useScreen()
|
|
37
|
+
// add-on: roles
|
|
38
|
+
const members = loaderData?.members ? <MembersCard figures={loaderData.members} href={`${screen.path}/members`} /> : null
|
|
39
|
+
// end add-on: roles
|
|
40
|
+
// without add-on: roles
|
|
41
|
+
// const members = null
|
|
42
|
+
// end without add-on: roles
|
|
43
|
+
return (
|
|
44
|
+
<AppShell
|
|
45
|
+
regions={{
|
|
46
|
+
header: <h1>{t('dashboard.dashboard', 'Dashboard')}</h1>,
|
|
47
|
+
main: (
|
|
48
|
+
<section data-dashboard="dashboard" className="@container grid gap-4">
|
|
49
|
+
<p className="text-muted-foreground">{t('dashboard.dashboardAboutCatalogue', 'What this organization holds.')}</p>
|
|
50
|
+
<div className="grid grid-cols-2 items-start gap-4 @2xl:grid-cols-4">
|
|
51
|
+
{screen.nav.flatMap((entry) => {
|
|
52
|
+
if (!entry.kayan) return []
|
|
53
|
+
const Icon = iconOf(entry.path)
|
|
54
|
+
const counted = counts?.[entry.kayan]
|
|
55
|
+
return [
|
|
56
|
+
<KpiCard
|
|
57
|
+
key={entry.kayan}
|
|
58
|
+
kpi={entry.kayan}
|
|
59
|
+
icon={<Icon className="size-4" />}
|
|
60
|
+
title={navLabel({ entry, screen, locale, t })}
|
|
61
|
+
value={counted === undefined ? '—' : new Intl.NumberFormat(locale).format(counted)}
|
|
62
|
+
footer={<a href={`${screen.path}${entry.path}`} className="text-foreground inline-flex items-center hover:underline max-sm:min-h-11">{t('portal.open', 'Open the list')}</a>}
|
|
63
|
+
/>,
|
|
64
|
+
]
|
|
65
|
+
})}
|
|
66
|
+
{members}
|
|
67
|
+
</div>
|
|
68
|
+
</section>
|
|
69
|
+
),
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// What the Members page and the dashboard's Members card both read: the members of the viewer's organization with their
|
|
2
|
+
// roles, and the roles a member may hold, read as the viewer. Its own module, so the dashboard's home carries the card
|
|
3
|
+
// and not the page.
|
|
4
|
+
import { Users } from 'lucide-react'
|
|
5
|
+
import { holds, type LoaderTools } from 'mithaq/admin'
|
|
6
|
+
import { useLocale } from 'mithaq/admin/react'
|
|
7
|
+
import { isOwner, OWNER_ROLE } from '@asas/mithaq/roles/grants.ts'
|
|
8
|
+
import { tenantRoles } from '@asas/mithaq/roles/tenant-roles.ts'
|
|
9
|
+
import { KpiCard } from '@asas/mithaq/ui/kpi-card.tsx'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One member as the page draws it: each role assignment by its row, whether they own the organization, which holds
|
|
13
|
+
* `administrator` whatever the rows say, and when they were last seen, where the viewer may read it.
|
|
14
|
+
*/
|
|
15
|
+
export type Member = { userId: string; name: string; email: string; joined: string; lastSeen?: string; owner: boolean; roles: { id: string; role: string }[] }
|
|
16
|
+
/**
|
|
17
|
+
* One role the drawer offers: its label in every locale, whether this project ships it, and its keys, which a fixed role's
|
|
18
|
+
* code always says and a custom role's row says where the viewer may read them.
|
|
19
|
+
*/
|
|
20
|
+
export type Role = { name: string; label?: Readonly<Record<string, string>>; fixed: boolean; permissions?: readonly string[] }
|
|
21
|
+
/** An ISO timestamp, or `''` for a value that does not parse: `toISOString()` throws on an invalid date, and a row with no readable date is still worth drawing. */
|
|
22
|
+
export const isoOf = (value: unknown): string => {
|
|
23
|
+
const when = new Date(value as string)
|
|
24
|
+
return value === null || value === undefined || Number.isNaN(when.getTime()) ? '' : when.toISOString()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Every row of a read that answers a page at a time, read until the total is reached: an organization outgrows any one
|
|
29
|
+
* page, and a page that drew the first hundred would leave the rest out without a word.
|
|
30
|
+
*
|
|
31
|
+
* The pages are offsets, so a row added or removed between two reads shifts the next page: a row then comes back twice,
|
|
32
|
+
* and is kept once by `key`, or is missed until the page loads again.
|
|
33
|
+
*/
|
|
34
|
+
// ponytail: every member is read and drawn at once, by offset; page the table itself, by a cursor, once an organization's size makes the page slow.
|
|
35
|
+
export const everyPage = async <Row,>({ read, key }: { read: (offset: number) => Promise<{ rows: Row[]; total: number }>; key: (row: Row) => string }): Promise<Row[]> => {
|
|
36
|
+
const rows = new Map<string, Row>()
|
|
37
|
+
for (let offset = 0; ; ) {
|
|
38
|
+
const page = await read(offset)
|
|
39
|
+
for (const row of page.rows) if (!rows.has(key(row))) rows.set(key(row), row)
|
|
40
|
+
offset += page.rows.length
|
|
41
|
+
if (!page.rows.length || offset >= page.total) return [...rows.values()]
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The members with their roles, and the roles a member may hold: what the page and the dashboard's card both read. */
|
|
46
|
+
export const readMembers = async ({ access, client, request, maxLimit }: LoaderTools): Promise<{ members: Member[]; roles: Role[] }> => {
|
|
47
|
+
const [members, assignments, roles] = await Promise.all([
|
|
48
|
+
everyPage({
|
|
49
|
+
key: (member) => member.userId,
|
|
50
|
+
read: async (offset: number) => {
|
|
51
|
+
// In id order, which no change to a member's other columns reorders.
|
|
52
|
+
const response = await request(`/api/auth/user/organization/list-members?limit=${maxLimit}&offset=${offset}&sortBy=id`)
|
|
53
|
+
if (!response.ok) throw new Error(`list-members answered ${response.status}`)
|
|
54
|
+
const page = (await response.json()) as { members: { userId: string; role: string; createdAt: string; lastSeenAt?: string | null; user: { name: string; email: string } }[]; total: number }
|
|
55
|
+
return { rows: page.members, total: page.total }
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
// The page is served on `member.find`, so a viewer may list the members
|
|
59
|
+
// and not read who holds which role.
|
|
60
|
+
holds(access, 'memberRoles.find')
|
|
61
|
+
? everyPage({
|
|
62
|
+
key: (row) => row.id,
|
|
63
|
+
read: async (offset: number) => {
|
|
64
|
+
const page = (await client({ kayan: 'memberRoles', operation: 'find', input: { limit: maxLimit, page: Math.ceil(offset / maxLimit) + 1, depth: 0 } })) as { docs: { id: string; user: string; role: string }[]; totalDocs: number }
|
|
65
|
+
return { rows: page.docs, total: page.totalDocs }
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
: [],
|
|
69
|
+
// Asked only of a viewer the report lets read roles, so the page still
|
|
70
|
+
// draws for one who may read the assignments and not the roles.
|
|
71
|
+
holds(access, 'roles.find')
|
|
72
|
+
? (client({ kayan: 'roles', operation: 'find', input: { limit: maxLimit, depth: 0, sort: 'name' } }) as Promise<{ docs: { name: string; label?: Record<string, string> | null; permissions?: unknown }[] }>)
|
|
73
|
+
: { docs: [] },
|
|
74
|
+
])
|
|
75
|
+
return {
|
|
76
|
+
members: [...members].sort((a, b) => a.createdAt.localeCompare(b.createdAt)).map((member) => ({
|
|
77
|
+
userId: member.userId,
|
|
78
|
+
name: member.user.name,
|
|
79
|
+
email: member.user.email,
|
|
80
|
+
joined: isoOf(member.createdAt),
|
|
81
|
+
// Answered only to a viewer holding the column's key; the server leaves it out for anyone else.
|
|
82
|
+
...(member.lastSeenAt && { lastSeen: isoOf(member.lastSeenAt) }),
|
|
83
|
+
owner: isOwner(member.role),
|
|
84
|
+
roles: assignments.filter((row) => row.user === member.userId).map((row) => ({ id: String(row.id), role: row.role })),
|
|
85
|
+
})),
|
|
86
|
+
roles: [
|
|
87
|
+
...Object.entries(tenantRoles).map(([name, role]): Role => ({ name, label: role.label, fixed: true, permissions: role.permissions === '*' ? ['*'] : role.permissions })),
|
|
88
|
+
...roles.docs.map((row): Role => ({
|
|
89
|
+
name: row.name,
|
|
90
|
+
...(row.label && { label: row.label }),
|
|
91
|
+
fixed: false,
|
|
92
|
+
...(Array.isArray(row.permissions) && { permissions: row.permissions as string[] }),
|
|
93
|
+
})),
|
|
94
|
+
],
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The dashboard's Members card: how many work here and, to a reader of the assignments, how many hold each role. */
|
|
99
|
+
export type MembersFigures = { total: number; held?: { role: string; label?: Readonly<Record<string, string>>; count: number }[] }
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The card's figures, read as the viewer, and nothing for a viewer who may not list the members or whose read failed,
|
|
103
|
+
* so the home still draws. An owner holds `administrator` whatever the rows say, as the server reads them.
|
|
104
|
+
*/
|
|
105
|
+
export const membersFigures = async (tools: LoaderTools): Promise<MembersFigures | undefined> => {
|
|
106
|
+
if (!holds(tools.access, 'member.find')) return undefined
|
|
107
|
+
const read = await readMembers(tools).catch(() => undefined)
|
|
108
|
+
if (!read) return undefined
|
|
109
|
+
if (!holds(tools.access, 'memberRoles.find')) return { total: read.members.length }
|
|
110
|
+
const counts = new Map<string, number>()
|
|
111
|
+
for (const member of read.members) {
|
|
112
|
+
for (const role of new Set([...member.roles.map((one) => one.role), ...(member.owner ? [OWNER_ROLE] : [])])) counts.set(role, (counts.get(role) ?? 0) + 1)
|
|
113
|
+
}
|
|
114
|
+
const order = read.roles.map((role) => role.name)
|
|
115
|
+
return {
|
|
116
|
+
total: read.members.length,
|
|
117
|
+
held: [...counts]
|
|
118
|
+
.sort(([a], [b]) => (order.indexOf(a) >>> 0) - (order.indexOf(b) >>> 0))
|
|
119
|
+
.map(([role, count]) => {
|
|
120
|
+
const label = read.roles.find((one) => one.name === role)?.label
|
|
121
|
+
return { role, ...(label && { label }), count }
|
|
122
|
+
}),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function MembersCard({ figures, href }: { figures: MembersFigures; href: string }) {
|
|
127
|
+
const { locale, t } = useLocale()
|
|
128
|
+
const number = new Intl.NumberFormat(locale)
|
|
129
|
+
return (
|
|
130
|
+
<KpiCard
|
|
131
|
+
kpi="members"
|
|
132
|
+
icon={<Users className="size-4" />}
|
|
133
|
+
title={t('dashboard.members', 'Members')}
|
|
134
|
+
value={number.format(figures.total)}
|
|
135
|
+
footer={
|
|
136
|
+
<span className="grid gap-1">
|
|
137
|
+
{figures.held?.length ? (
|
|
138
|
+
<span data-members-held="">
|
|
139
|
+
{figures.held.map((one) => `${number.format(one.count)} ${one.label?.[locale] ?? one.role}`).join(' · ')}
|
|
140
|
+
</span>
|
|
141
|
+
) : null}
|
|
142
|
+
<a href={href} className="text-foreground inline-flex items-center hover:underline max-sm:min-h-11">{t('portal.open', 'Open the list')}</a>
|
|
143
|
+
</span>
|
|
144
|
+
}
|
|
145
|
+
/>
|
|
146
|
+
)
|
|
147
|
+
}
|