create-mithaq 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (348) hide show
  1. package/LICENSES/openstatus-time-picker.txt +21 -0
  2. package/index.js +457 -63
  3. package/package.json +1 -1
  4. package/template/apps/{asas-platform → asas-backoffice}/package.json +5 -3
  5. package/template/apps/{asas-platform → asas-backoffice}/src/api.ts +1 -1
  6. package/template/apps/{asas-platform → asas-backoffice}/src/auth-frame.tsx +12 -12
  7. package/template/apps/asas-backoffice/src/components.ts +35 -0
  8. package/template/apps/asas-backoffice/src/first-user.ts +14 -0
  9. package/template/apps/{asas-platform → asas-backoffice}/src/links.ts +1 -1
  10. package/template/apps/{asas-platform → asas-backoffice}/src/locale-switch.tsx +7 -5
  11. package/template/apps/asas-backoffice/src/no-organization.tsx +5 -0
  12. package/template/apps/asas-backoffice/src/redirect.ts +1 -0
  13. package/template/apps/{asas-platform → asas-backoffice}/src/request-headers.ts +1 -1
  14. package/template/apps/asas-backoffice/src/routeTree.gen.ts +206 -0
  15. package/template/apps/asas-backoffice/src/router.tsx +18 -0
  16. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/files.ts +10 -0
  17. package/template/apps/{asas-platform/src/routes/(screens)/platform → asas-backoffice/src/routes/(screens)/backoffice}/screen.json +426 -64
  18. package/template/apps/{asas-platform/src/routes/(screens)/platform → asas-backoffice/src/routes/(screens)/backoffice}/types.ts +18 -0
  19. package/template/apps/{asas-platform/src/routes/(screens)/platform.$.tsx → asas-backoffice/src/routes/(screens)/backoffice.$.tsx} +4 -4
  20. package/template/apps/{asas-platform/src/routes/(screens)/platform.index.tsx → asas-backoffice/src/routes/(screens)/backoffice.index.tsx} +3 -3
  21. package/template/apps/{asas-platform/src/routes/(screens)/platform.tsx → asas-backoffice/src/routes/(screens)/backoffice.tsx} +3 -3
  22. package/template/apps/{asas-platform → asas-backoffice}/src/routes/__root.tsx +2 -2
  23. package/template/apps/asas-backoffice/src/routes/api/$.ts +34 -0
  24. package/template/apps/asas-backoffice/src/routes/auth.create-first-user.tsx +113 -0
  25. package/template/apps/{asas-platform → asas-backoffice}/src/routes/auth.sign-in.tsx +9 -4
  26. package/template/apps/{asas-platform → asas-backoffice}/src/routes/unauthorized.tsx +24 -12
  27. package/template/apps/{asas-platform → asas-backoffice}/src/screen-form.tsx +5 -3
  28. package/template/apps/{asas-platform → asas-backoffice}/src/screen-list.tsx +6 -5
  29. package/template/apps/{asas-platform → asas-backoffice}/src/screen-names.ts +1 -1
  30. package/template/apps/{asas-platform → asas-backoffice}/src/screens.tsx +46 -26
  31. package/template/apps/asas-backoffice/src/server.ts +30 -0
  32. package/template/apps/{asas-platform → asas-backoffice}/src/slide.ts +1 -1
  33. package/template/apps/{asas-platform → asas-backoffice}/src/start.ts +1 -1
  34. package/template/apps/{asas-platform → asas-backoffice}/src/styles.css +9 -7
  35. package/template/apps/{asas-platform → asas-backoffice}/tsconfig.json +2 -2
  36. package/template/apps/{asas-platform → asas-backoffice}/vite.config.ts +7 -1
  37. package/template/apps/asas-tanstack/package.json +4 -2
  38. package/template/apps/asas-tanstack/src/api.ts +1 -1
  39. package/template/apps/asas-tanstack/src/auth-frame.tsx +12 -12
  40. package/template/apps/asas-tanstack/src/choose-password.tsx +150 -0
  41. package/template/apps/asas-tanstack/src/components.ts +35 -0
  42. package/template/apps/asas-tanstack/src/copy.ts +416 -79
  43. package/template/apps/asas-tanstack/src/first-run.ts +16 -0
  44. package/template/apps/asas-tanstack/src/links.ts +1 -1
  45. package/template/apps/asas-tanstack/src/locale-switch.tsx +7 -5
  46. package/template/apps/asas-tanstack/src/no-organization.tsx +108 -0
  47. package/template/apps/asas-tanstack/src/redirect.ts +20 -1
  48. package/template/apps/asas-tanstack/src/request-headers.ts +1 -1
  49. package/template/apps/asas-tanstack/src/routeTree.gen.ts +42 -0
  50. package/template/apps/asas-tanstack/src/router.tsx +12 -1
  51. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +9 -2
  52. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +475 -116
  53. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +4 -2
  54. package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +7 -2
  55. package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +83 -319
  56. package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +3 -1
  57. package/template/apps/asas-tanstack/src/routes/__root.tsx +1 -1
  58. package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +14 -7
  59. package/template/apps/asas-tanstack/src/routes/auth.first-run.tsx +114 -0
  60. package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +2 -2
  61. package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +5 -5
  62. package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +7 -100
  63. package/template/apps/asas-tanstack/src/routes/auth.set-password.tsx +16 -0
  64. package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +27 -15
  65. package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +59 -38
  66. package/template/apps/asas-tanstack/src/routes/index.tsx +18 -14
  67. package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +24 -12
  68. package/template/apps/asas-tanstack/src/screen-form.tsx +5 -3
  69. package/template/apps/asas-tanstack/src/screen-list.tsx +6 -5
  70. package/template/apps/asas-tanstack/src/screen-names.ts +1 -1
  71. package/template/apps/asas-tanstack/src/screens.tsx +46 -26
  72. package/template/apps/asas-tanstack/src/server.ts +10 -0
  73. package/template/apps/asas-tanstack/src/slide.ts +1 -1
  74. package/template/apps/asas-tanstack/src/styles.css +7 -3
  75. package/template/apps/asas-tanstack/tsconfig.json +2 -2
  76. package/template/apps/asas-tanstack/vite.config.ts +7 -1
  77. package/template/package.json +3 -3
  78. package/template/templates/asas/.claude/skills/add-field/SKILL.md +27 -0
  79. package/template/templates/asas/.claude/skills/add-kayan/SKILL.md +26 -0
  80. package/template/templates/asas/.claude/skills/eject-admin-piece/SKILL.md +28 -0
  81. package/template/templates/asas/.claude/skills/upgrade-mithaq/SKILL.md +35 -0
  82. package/template/templates/asas/.env.example +22 -5
  83. package/template/templates/asas/AGENTS.md +101 -0
  84. package/template/templates/asas/CLAUDE.md +1 -0
  85. package/template/templates/asas/README.md +1056 -0
  86. package/template/templates/asas/package.json +19 -4
  87. package/template/templates/asas/src/asas.ts +81 -56
  88. package/template/templates/asas/src/{platform-http.ts → backoffice-http.ts} +48 -27
  89. package/template/templates/asas/src/dev-setup.ts +112 -0
  90. package/template/templates/asas/src/local-database.ts +22 -0
  91. package/template/templates/asas/src/messages/account.json +129 -103
  92. package/template/templates/asas/src/messages/auditLog.json +223 -105
  93. package/template/templates/asas/src/messages/backofficeLog.json +132 -0
  94. package/template/templates/asas/src/messages/courses.json +256 -214
  95. package/template/templates/asas/src/messages/enrollments.json +45 -27
  96. package/template/templates/asas/src/messages/invitation.json +72 -56
  97. package/template/templates/asas/src/messages/localUsers.json +34 -28
  98. package/template/templates/asas/src/messages/media.json +31 -21
  99. package/template/templates/asas/src/messages/member.json +59 -39
  100. package/template/templates/asas/src/messages/memberRoles.json +37 -31
  101. package/template/templates/asas/src/messages/mithaq.json +206 -162
  102. package/template/templates/asas/src/messages/organization.json +63 -41
  103. package/template/templates/asas/src/messages/organizationRole.json +59 -47
  104. package/template/templates/asas/src/messages/roles.json +59 -45
  105. package/template/templates/asas/src/messages/session.json +99 -79
  106. package/template/templates/asas/src/messages/sessions.json +110 -80
  107. package/template/templates/asas/src/messages/settings.json +51 -53
  108. package/template/templates/asas/src/messages/tags.json +31 -21
  109. package/template/templates/asas/src/messages/user.json +109 -87
  110. package/template/templates/asas/src/messages/verification.json +59 -47
  111. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/migration.sql +1 -0
  112. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/snapshot.json +3881 -0
  113. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/migration.sql +1 -0
  114. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/snapshot.json +3894 -0
  115. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/migration.sql +1 -0
  116. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/snapshot.json +3907 -0
  117. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/migration.sql +1 -0
  118. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/snapshot.json +3907 -0
  119. package/template/templates/asas/src/migrations/20261015042444_organization_names/migration.sql +1 -0
  120. package/template/templates/asas/src/migrations/20261015042444_organization_names/snapshot.json +3920 -0
  121. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/migration.sql +1 -0
  122. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/snapshot.json +3933 -0
  123. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/migration.sql +15 -0
  124. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/snapshot.json +4113 -0
  125. package/template/templates/asas/src/migrations/20261015095752_portal/migration.sql +2 -0
  126. package/template/templates/asas/src/migrations/20261015095752_portal/snapshot.json +4113 -0
  127. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/migration.sql +9 -0
  128. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/snapshot.json +4302 -0
  129. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/migration.sql +27 -0
  130. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/snapshot.json +4289 -0
  131. package/template/templates/asas/src/migrations/schema.ts +1 -0
  132. package/template/templates/asas/src/mithaq.ts +4 -406
  133. package/template/templates/asas/src/organizations.ts +3 -0
  134. package/template/templates/asas/src/seed.ts +147 -45
  135. package/template/templates/asas/src/{ui-platform → ui-backoffice}/home.tsx +9 -9
  136. package/template/templates/asas/src/ui-backoffice/organization-actions.tsx +121 -0
  137. package/template/templates/asas/src/{ui-platform → ui-backoffice}/record-page.tsx +4 -4
  138. package/template/templates/asas/src/{ui-platform → ui-backoffice}/role-cells.tsx +13 -6
  139. package/template/templates/asas/src/{ui-platform → ui-backoffice}/user-actions.tsx +46 -48
  140. package/template/templates/asas/src/ui-dashboard/dashboard.tsx +10 -4
  141. package/template/templates/asas/src/ui-dashboard/home.tsx +73 -0
  142. package/template/templates/asas/src/ui-dashboard/members-card.tsx +147 -0
  143. package/template/templates/asas/src/ui-dashboard/members.tsx +637 -438
  144. package/template/templates/asas/src/ui-dashboard/versions.tsx +22 -8
  145. package/template/templates/asas/src/ui-portal/courses-home.tsx +96 -0
  146. package/template/templates/asas/src/ui-portal/home.tsx +9 -13
  147. package/template/templates/features/audit-log/audit-log/audit-log.ts +284 -0
  148. package/template/templates/features/audit-log/backoffice-log/backoffice-log.ts +67 -0
  149. package/template/templates/features/audit-log/index.ts +4 -0
  150. package/template/templates/features/audit-log/package.json +13 -0
  151. package/template/templates/features/auth/index.ts +11 -0
  152. package/template/templates/{asas/src → features/auth}/local-users/local-users.ts +7 -7
  153. package/template/templates/{auth → features/auth}/package.json +2 -3
  154. package/template/templates/{asas/src/kayan → features}/auth/user/account.access.ts +2 -2
  155. package/template/templates/{asas/src/kayan → features}/auth/user/account.ts +2 -2
  156. package/template/templates/{asas/src/kayan → features}/auth/user/invitation.access.ts +1 -1
  157. package/template/templates/{asas/src/kayan → features}/auth/user/invitation.ts +3 -3
  158. package/template/templates/{asas/src/kayan → features}/auth/user/member.access.ts +4 -4
  159. package/template/templates/{asas/src/kayan → features}/auth/user/member.screens.ts +5 -5
  160. package/template/templates/{asas/src/kayan → features}/auth/user/member.ts +4 -3
  161. package/template/templates/{asas/src/kayan → features}/auth/user/organization.access.ts +6 -6
  162. package/template/templates/{asas/src/kayan → features}/auth/user/organization.screens.ts +6 -6
  163. package/template/templates/{asas/src/kayan → features}/auth/user/organization.ts +2 -1
  164. package/template/templates/{asas/src/kayan → features}/auth/user/organizationRole.access.ts +2 -2
  165. package/template/templates/{asas/src/kayan → features}/auth/user/organizationRole.ts +3 -3
  166. package/template/templates/{asas/src/kayan → features}/auth/user/session.access.ts +2 -2
  167. package/template/templates/{asas/src/kayan → features}/auth/user/session.ts +2 -2
  168. package/template/templates/{asas/src/kayan → features}/auth/user/user.access.ts +8 -7
  169. package/template/templates/{asas/src/kayan → features}/auth/user/user.screens.ts +9 -9
  170. package/template/templates/{asas/src/kayan → features}/auth/user/user.ts +2 -2
  171. package/template/templates/{asas/src/kayan → features}/auth/user/verification.access.ts +2 -2
  172. package/template/templates/{asas/src/kayan → features}/auth/user/verification.ts +2 -2
  173. package/template/templates/{asas/src → features/courses}/courses/courses.ts +122 -133
  174. package/template/templates/features/courses/courses/cover-reads.ts +33 -0
  175. package/template/templates/features/courses/courses/ui/archived-badge.tsx +9 -0
  176. package/template/templates/{asas/src → features/courses}/courses/ui/fee-cell.tsx +1 -1
  177. package/template/templates/{asas/src → features/courses}/courses/ui/status-cell.tsx +5 -3
  178. package/template/templates/features/courses/courses/ui/unpublish-button.tsx +15 -0
  179. package/template/templates/features/courses/domain/archive.ts +39 -0
  180. package/template/templates/features/courses/domain/on-offer.ts +6 -0
  181. package/template/templates/features/courses/enrollments/enrollments.ts +309 -0
  182. package/template/templates/{asas/src → features/courses}/enrollments/ui/session-cell.tsx +17 -7
  183. package/template/templates/{asas/src → features/courses/enrollments}/ui/session-labels.tsx +3 -3
  184. package/template/templates/features/courses/index.ts +8 -0
  185. package/template/templates/features/courses/package.json +24 -0
  186. package/template/templates/features/courses/portal/ui/catalogue.tsx +86 -0
  187. package/template/templates/features/courses/portal/ui/course.tsx +176 -0
  188. package/template/templates/features/courses/portal/ui/enrollments.tsx +170 -0
  189. package/template/templates/features/courses/portal/ui/parts.tsx +95 -0
  190. package/template/templates/features/courses/portal/ui/reads.ts +76 -0
  191. package/template/templates/features/courses/portal/ui/rules.ts +87 -0
  192. package/template/templates/features/courses/portal/ui/shell.tsx +194 -0
  193. package/template/templates/features/courses/sessions/seats.ts +42 -0
  194. package/template/templates/{asas/src → features/courses}/sessions/sessions.ts +45 -14
  195. package/template/templates/features/media/index.ts +10 -0
  196. package/template/templates/{asas/src → features/media}/media/media.ts +34 -40
  197. package/template/templates/{asas/src → features/media}/media/ui/preview.tsx +2 -2
  198. package/template/templates/features/media/package.json +19 -0
  199. package/template/templates/features/roles/index.ts +4 -0
  200. package/template/templates/{asas/src → features/roles}/member-roles/member-roles.ts +15 -15
  201. package/template/templates/features/roles/package.json +21 -0
  202. package/template/templates/{asas/src → features/roles}/roles/roles.ts +23 -23
  203. package/template/templates/{asas/src → features/roles}/roles/ui/role-form.tsx +65 -24
  204. package/template/templates/{asas/src → features/roles}/roles/ui/roles.tsx +49 -25
  205. package/template/templates/features/settings/index.ts +3 -0
  206. package/template/templates/features/settings/package.json +13 -0
  207. package/template/templates/{asas/src → features/settings}/settings/settings.ts +12 -17
  208. package/template/templates/features/tags/index.ts +3 -0
  209. package/template/templates/features/tags/package.json +14 -0
  210. package/template/templates/{asas/src → features/tags}/tags/tags.ts +15 -15
  211. package/template/templates/mithaq/package.json +33 -0
  212. package/template/templates/mithaq/src/mail/index.ts +32 -0
  213. package/template/templates/mithaq/src/mail/mailers.ts +36 -0
  214. package/template/templates/{asas/src/audit-log/sink.ts → mithaq/src/mithaq/audit-sink.ts} +31 -9
  215. package/template/templates/{auth/src/platform-roles.ts → mithaq/src/mithaq/auth/backoffice-roles.ts} +44 -23
  216. package/template/templates/mithaq/src/mithaq/auth/index.ts +1859 -0
  217. package/template/templates/mithaq/src/mithaq/auth/organizations.ts +80 -0
  218. package/template/templates/{asas/src → mithaq/src/mithaq}/pages.ts +16 -7
  219. package/template/templates/mithaq/src/mithaq/rich-text-nodes.ts +19 -0
  220. package/template/templates/mithaq/src/mithaq.ts +593 -0
  221. package/template/templates/mithaq/src/roles/default-member-roles.ts +29 -0
  222. package/template/templates/{asas → mithaq}/src/roles/grants.ts +1 -1
  223. package/template/templates/mithaq/src/roles/member-access.ts +11 -0
  224. package/template/templates/{asas/src → mithaq/src/roles}/statements.ts +6 -4
  225. package/template/templates/mithaq/src/roles/system-roles.ts +16 -0
  226. package/template/templates/{asas/src → mithaq/src/roles}/tenant-access.ts +96 -57
  227. package/template/templates/{asas/src → mithaq/src/roles}/tenant-roles.ts +84 -25
  228. package/template/templates/{asas → mithaq}/src/steps/expected-updated-at.ts +2 -2
  229. package/template/templates/{asas → mithaq}/src/storage/index.ts +3 -1
  230. package/template/templates/mithaq/src/ui/activity-page.tsx +33 -0
  231. package/template/templates/mithaq/src/ui/activity.tsx +527 -0
  232. package/template/templates/{asas → mithaq}/src/ui/app-shell.tsx +27 -19
  233. package/template/templates/mithaq/src/ui/rich-text-nodes.tsx +33 -0
  234. package/template/templates/{asas → mithaq}/src/ui/screen-nav.tsx +1 -1
  235. package/template/templates/{asas → mithaq}/src/ui/top-shell.tsx +8 -8
  236. package/template/apps/asas-platform/src/components/mithaq/cells.tsx +0 -112
  237. package/template/apps/asas-platform/src/components/mithaq/controls.tsx +0 -650
  238. package/template/apps/asas-platform/src/components/mithaq/field-shell.tsx +0 -53
  239. package/template/apps/asas-platform/src/components/mithaq/filter-operators.ts +0 -108
  240. package/template/apps/asas-platform/src/components/mithaq/form.tsx +0 -1467
  241. package/template/apps/asas-platform/src/components/mithaq/list-columns.tsx +0 -108
  242. package/template/apps/asas-platform/src/components/mithaq/list-filters.tsx +0 -368
  243. package/template/apps/asas-platform/src/components/mithaq/list-pagination.tsx +0 -153
  244. package/template/apps/asas-platform/src/components/mithaq/list-query.ts +0 -432
  245. package/template/apps/asas-platform/src/components/mithaq/list-selection.tsx +0 -74
  246. package/template/apps/asas-platform/src/components/mithaq/list.tsx +0 -865
  247. package/template/apps/asas-platform/src/components/mithaq/nav.tsx +0 -871
  248. package/template/apps/asas-platform/src/components/mithaq/nodes.tsx +0 -1024
  249. package/template/apps/asas-platform/src/components/mithaq/relationship-cell.tsx +0 -127
  250. package/template/apps/asas-platform/src/components/mithaq/relationship-control.tsx +0 -400
  251. package/template/apps/asas-platform/src/components/mithaq/rich-text-nodes.ts +0 -6
  252. package/template/apps/asas-platform/src/components/mithaq/rich-text.tsx +0 -1083
  253. package/template/apps/asas-platform/src/components/mithaq/upload.tsx +0 -686
  254. package/template/apps/asas-platform/src/components/ui/drawer.tsx +0 -216
  255. package/template/apps/asas-platform/src/redirect.ts +0 -1
  256. package/template/apps/asas-platform/src/routeTree.gen.ts +0 -181
  257. package/template/apps/asas-platform/src/router.tsx +0 -7
  258. package/template/apps/asas-platform/src/routes/(screens)/platform/files.ts +0 -4
  259. package/template/apps/asas-platform/src/routes/api/$.ts +0 -30
  260. package/template/apps/asas-platform/src/server.ts +0 -20
  261. package/template/apps/asas-tanstack/src/components/mithaq/cells.tsx +0 -112
  262. package/template/apps/asas-tanstack/src/components/mithaq/controls.tsx +0 -650
  263. package/template/apps/asas-tanstack/src/components/mithaq/field-shell.tsx +0 -53
  264. package/template/apps/asas-tanstack/src/components/mithaq/filter-operators.ts +0 -108
  265. package/template/apps/asas-tanstack/src/components/mithaq/form.tsx +0 -1467
  266. package/template/apps/asas-tanstack/src/components/mithaq/list-columns.tsx +0 -108
  267. package/template/apps/asas-tanstack/src/components/mithaq/list-filters.tsx +0 -368
  268. package/template/apps/asas-tanstack/src/components/mithaq/list-pagination.tsx +0 -153
  269. package/template/apps/asas-tanstack/src/components/mithaq/list-query.ts +0 -432
  270. package/template/apps/asas-tanstack/src/components/mithaq/list-selection.tsx +0 -74
  271. package/template/apps/asas-tanstack/src/components/mithaq/list.tsx +0 -865
  272. package/template/apps/asas-tanstack/src/components/mithaq/nav.tsx +0 -871
  273. package/template/apps/asas-tanstack/src/components/mithaq/nodes.tsx +0 -1024
  274. package/template/apps/asas-tanstack/src/components/mithaq/relationship-cell.tsx +0 -127
  275. package/template/apps/asas-tanstack/src/components/mithaq/relationship-control.tsx +0 -400
  276. package/template/apps/asas-tanstack/src/components/mithaq/rich-text-nodes.ts +0 -6
  277. package/template/apps/asas-tanstack/src/components/mithaq/rich-text.tsx +0 -1083
  278. package/template/apps/asas-tanstack/src/components/mithaq/upload.tsx +0 -686
  279. package/template/apps/asas-tanstack/src/components/ui/drawer.tsx +0 -215
  280. package/template/templates/asas/src/audit-log/audit-log.ts +0 -187
  281. package/template/templates/asas/src/audit-log/ui/activity.tsx +0 -243
  282. package/template/templates/asas/src/courses/ui/archive-button.tsx +0 -25
  283. package/template/templates/asas/src/courses/ui/no-courses-on-offer.tsx +0 -11
  284. package/template/templates/asas/src/courses/ui/on-offer.ts +0 -6
  285. package/template/templates/asas/src/enrollments/enrollments.ts +0 -137
  286. package/template/templates/asas/src/rich-text-nodes.ts +0 -11
  287. package/template/templates/asas/src/roles.ts +0 -16
  288. package/template/templates/asas/src/ui/rich-text-nodes.tsx +0 -15
  289. package/template/templates/asas/src/ui-platform/organization-actions.tsx +0 -114
  290. package/template/templates/auth/src/index.ts +0 -690
  291. /package/template/apps/{asas-platform → asas-backoffice}/components.json +0 -0
  292. /package/template/apps/{asas-platform → asas-backoffice}/gitignore +0 -0
  293. /package/template/apps/{asas-platform → asas-backoffice}/public/favicon.svg +0 -0
  294. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/accordion.tsx +0 -0
  295. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/alert-dialog.tsx +0 -0
  296. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/avatar.tsx +0 -0
  297. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/badge.tsx +0 -0
  298. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/breadcrumb.tsx +0 -0
  299. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/button-group.tsx +0 -0
  300. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/button.tsx +0 -0
  301. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/calendar.tsx +0 -0
  302. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/card.tsx +0 -0
  303. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/chart.tsx +0 -0
  304. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/checkbox.tsx +0 -0
  305. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/collapsible.tsx +0 -0
  306. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/combobox.tsx +0 -0
  307. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/dialog.tsx +0 -0
  308. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/direction.tsx +0 -0
  309. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/directional-icon.tsx +0 -0
  310. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/dropdown-menu.tsx +0 -0
  311. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/field.tsx +0 -0
  312. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input-group.tsx +0 -0
  313. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input-otp.tsx +0 -0
  314. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input.tsx +0 -0
  315. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/label.tsx +0 -0
  316. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/popover.tsx +0 -0
  317. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/radio-group.tsx +0 -0
  318. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/resizable.tsx +0 -0
  319. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/scroll-area.tsx +0 -0
  320. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/select.tsx +0 -0
  321. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/separator.tsx +0 -0
  322. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/sheet.tsx +0 -0
  323. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/sidebar.tsx +0 -0
  324. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/skeleton.tsx +0 -0
  325. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/slider.tsx +0 -0
  326. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/spinner.tsx +0 -0
  327. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/switch.tsx +0 -0
  328. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/table.tsx +0 -0
  329. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/tabs.tsx +0 -0
  330. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/textarea.tsx +0 -0
  331. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/toggle-group.tsx +0 -0
  332. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/toggle.tsx +0 -0
  333. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/tooltip.tsx +0 -0
  334. /package/template/apps/{asas-platform → asas-backoffice}/src/copy.ts +0 -0
  335. /package/template/apps/{asas-platform → asas-backoffice}/src/hooks/use-mobile.ts +0 -0
  336. /package/template/apps/{asas-platform → asas-backoffice}/src/lib/utils.ts +0 -0
  337. /package/template/apps/{asas-platform → asas-backoffice}/src/theme.ts +0 -0
  338. /package/template/templates/{asas/src/kayan → features}/auth/user/index.ts +0 -0
  339. /package/template/templates/{asas/src → features/courses}/courses/ui/no-courses.tsx +0 -0
  340. /package/template/templates/{asas → mithaq}/components.json +0 -0
  341. /package/template/templates/{asas → mithaq}/src/storage/defaults.ts +0 -0
  342. /package/template/templates/{asas → mithaq}/src/storage/local.ts +0 -0
  343. /package/template/templates/{asas → mithaq}/src/storage/r2.ts +0 -0
  344. /package/template/templates/{asas → mithaq}/src/storage/s3.ts +0 -0
  345. /package/template/templates/{asas → mithaq}/src/storage/uploadthing.ts +0 -0
  346. /package/template/templates/{asas → mithaq}/src/ui/kpi-card.tsx +0 -0
  347. /package/template/templates/{asas → mithaq}/src/ui/record-page.tsx +0 -0
  348. /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 platform role, only for 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 { platformRoles } from '@asas/auth/platform-roles'
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 platform roles and the plugin's own `user`, which is none of them. */
28
- const roleNames = ['user', ...Object.keys(platformRoles)] as const
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
- ['', 'platform.banUntilLifted', 'Until it is lifted'],
33
- ['86400', 'platform.banDay', 'One day'],
34
- ['604800', 'platform.banWeek', 'Seven days'],
35
- ['2592000', 'platform.banMonth', 'Thirty days'],
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: ['platform.refusedBanned', 'This person is banned, so they cannot be impersonated.'],
46
+ BANNED_USER: ['backoffice.refusedBanned', 'This person is banned, so they cannot be impersonated.'],
46
47
  }
47
48
 
48
- const usePlatformRoleLabel = () => {
49
+ const useBackofficeRoleLabel = () => {
49
50
  const { locale, t } = useLocale()
50
51
  return (one: string) =>
51
- Object.hasOwn(platformRoles, one) ? platformRoles[one as keyof typeof platformRoles].label[locale === 'ar' ? 'ar' : 'en'] : t('platform.noRole', 'No platform role')
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 platform roles as badges, each by its label, which a record page draws where the column holds the slugs. */
55
- export function PlatformRole({ value }: Pick<CellProps, 'value'>) {
56
- const labelOf = usePlatformRoleLabel()
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(platformRoles, one.trim()))
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('platform.setRole', 'Set role'), ban: t('platform.ban', 'Ban'), unban: t('platform.unban', 'Unban'), impersonate: t('platform.impersonate', 'Impersonate') }
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 = usePlatformRoleLabel()
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('platform.refused', 'The platform refused that action.'))
118
+ return setRefusal(known ? t(known[0], known[1]) : t('backoffice.refused', 'The backoffice refused that action.'))
119
119
  }
120
- // The platform answers a one-time link into the tenant app, which trades it for the impersonated
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
- setDone(landed[action])
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('platform.pickRole', 'Pick another platform role for {name}.') : t('platform.confirmRole', 'Give {name} the platform role "{role}"?')),
135
- ban: fill(t('platform.confirmBan', 'Ban {name}? They are signed out of both apps and cannot sign in until the ban ends.')),
136
- unban: fill(t('platform.confirmUnban', 'Lift the ban on {name}? They can sign in again.')),
137
- impersonate: fill(t('platform.confirmImpersonate', 'Impersonate {name}? You act as them in the tenant app until you stop.')),
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('platform.roleSet', '{name} now holds the platform role "{role}".')),
141
- ban: fill(t('platform.banned', '{name} is banned.')),
142
- unban: fill(t('platform.unbanned', 'The ban on {name} is lifted.')),
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
- {/* Polite, and drawn before and after the action lands, so the sentence it lands with is read out. */}
150
- <AlertDialogDescription aria-live="polite">{done ?? said}</AlertDialogDescription>
150
+ <AlertDialogDescription>{said}</AlertDialogDescription>
151
151
  </AlertDialogHeader>
152
- {done ? null : action === 'set-role' ? (
152
+ {action === 'set-role' ? (
153
153
  <div className="grid gap-2">
154
- <Label htmlFor={`role-${userId}`}>{t('platform.role', 'Platform role')}</Label>
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
- {!done && action === 'ban' ? (
170
+ {action === 'ban' ? (
171
171
  <div className="grid gap-3">
172
172
  <div className="grid gap-2">
173
- <Label htmlFor={`ban-reason-${userId}`}>{t('platform.banReason', 'Reason')}</Label>
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('platform.banReasonNeeded', 'A ban needs a reason.')}
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('platform.banEnds', 'Ban lasts')}</Label>
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 && !done ? (
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">{done ? t('admin.close', 'Close') : t('admin.cancel', 'Cancel')}</AlertDialogCancel>
207
- {done ? null : (
208
- <AlertDialogAction
209
- data-button="confirm"
210
- variant={action === 'ban' ? 'destructive' : 'default'}
211
- disabled={busy || (action === 'ban' && !reason.trim()) || (action === 'set-role' && chosen === role)}
212
- onClick={() => void send()}
213
- >
214
- {names[action]}
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 '@/components/mithaq/controls.tsx'
16
- import { AppShell } from '../ui/app-shell.tsx'
17
- import { KpiCard } from '../ui/kpi-card.tsx'
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
+ }