create-mithaq 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (396) hide show
  1. package/LICENSE +22 -0
  2. package/LICENSES/openstatus-time-picker.txt +21 -0
  3. package/LICENSES/payload.txt +22 -0
  4. package/LICENSES/ponytail.txt +21 -0
  5. package/index.js +521 -0
  6. package/package.json +19 -4
  7. package/template/apps/asas-backoffice/components.json +25 -0
  8. package/template/apps/asas-backoffice/gitignore +3 -0
  9. package/template/apps/asas-backoffice/package.json +57 -0
  10. package/template/apps/asas-backoffice/public/favicon.svg +1 -0
  11. package/template/apps/asas-backoffice/src/api.ts +147 -0
  12. package/template/apps/asas-backoffice/src/auth-frame.tsx +150 -0
  13. package/template/apps/asas-backoffice/src/components/ui/accordion.tsx +74 -0
  14. package/template/apps/asas-backoffice/src/components/ui/alert-dialog.tsx +185 -0
  15. package/template/apps/asas-backoffice/src/components/ui/avatar.tsx +106 -0
  16. package/template/apps/asas-backoffice/src/components/ui/badge.tsx +48 -0
  17. package/template/apps/asas-backoffice/src/components/ui/breadcrumb.tsx +124 -0
  18. package/template/apps/asas-backoffice/src/components/ui/button-group.tsx +87 -0
  19. package/template/apps/asas-backoffice/src/components/ui/button.tsx +55 -0
  20. package/template/apps/asas-backoffice/src/components/ui/calendar.tsx +223 -0
  21. package/template/apps/asas-backoffice/src/components/ui/card.tsx +102 -0
  22. package/template/apps/asas-backoffice/src/components/ui/chart.tsx +371 -0
  23. package/template/apps/asas-backoffice/src/components/ui/checkbox.tsx +28 -0
  24. package/template/apps/asas-backoffice/src/components/ui/collapsible.tsx +19 -0
  25. package/template/apps/asas-backoffice/src/components/ui/combobox.tsx +308 -0
  26. package/template/apps/asas-backoffice/src/components/ui/dialog.tsx +158 -0
  27. package/template/apps/asas-backoffice/src/components/ui/direction.tsx +6 -0
  28. package/template/apps/asas-backoffice/src/components/ui/directional-icon.tsx +20 -0
  29. package/template/apps/asas-backoffice/src/components/ui/dropdown-menu.tsx +267 -0
  30. package/template/apps/asas-backoffice/src/components/ui/field.tsx +236 -0
  31. package/template/apps/asas-backoffice/src/components/ui/input-group.tsx +155 -0
  32. package/template/apps/asas-backoffice/src/components/ui/input-otp.tsx +84 -0
  33. package/template/apps/asas-backoffice/src/components/ui/input.tsx +19 -0
  34. package/template/apps/asas-backoffice/src/components/ui/label.tsx +19 -0
  35. package/template/apps/asas-backoffice/src/components/ui/popover.tsx +90 -0
  36. package/template/apps/asas-backoffice/src/components/ui/radio-group.tsx +37 -0
  37. package/template/apps/asas-backoffice/src/components/ui/resizable.tsx +47 -0
  38. package/template/apps/asas-backoffice/src/components/ui/scroll-area.tsx +54 -0
  39. package/template/apps/asas-backoffice/src/components/ui/select.tsx +204 -0
  40. package/template/apps/asas-backoffice/src/components/ui/separator.tsx +24 -0
  41. package/template/apps/asas-backoffice/src/components/ui/sheet.tsx +139 -0
  42. package/template/apps/asas-backoffice/src/components/ui/sidebar.tsx +714 -0
  43. package/template/apps/asas-backoffice/src/components/ui/skeleton.tsx +13 -0
  44. package/template/apps/asas-backoffice/src/components/ui/slider.tsx +55 -0
  45. package/template/apps/asas-backoffice/src/components/ui/spinner.tsx +10 -0
  46. package/template/apps/asas-backoffice/src/components/ui/switch.tsx +29 -0
  47. package/template/apps/asas-backoffice/src/components/ui/table.tsx +115 -0
  48. package/template/apps/asas-backoffice/src/components/ui/tabs.tsx +79 -0
  49. package/template/apps/asas-backoffice/src/components/ui/textarea.tsx +17 -0
  50. package/template/apps/asas-backoffice/src/components/ui/toggle-group.tsx +89 -0
  51. package/template/apps/asas-backoffice/src/components/ui/toggle.tsx +44 -0
  52. package/template/apps/asas-backoffice/src/components/ui/tooltip.tsx +63 -0
  53. package/template/apps/asas-backoffice/src/components.ts +35 -0
  54. package/template/apps/asas-backoffice/src/copy.ts +1 -0
  55. package/template/apps/asas-backoffice/src/first-user.ts +14 -0
  56. package/template/apps/asas-backoffice/src/hooks/use-mobile.ts +19 -0
  57. package/template/apps/asas-backoffice/src/lib/utils.ts +1 -0
  58. package/template/apps/asas-backoffice/src/links.ts +38 -0
  59. package/template/apps/asas-backoffice/src/locale-switch.tsx +28 -0
  60. package/template/apps/asas-backoffice/src/no-organization.tsx +5 -0
  61. package/template/apps/asas-backoffice/src/redirect.ts +1 -0
  62. package/template/apps/asas-backoffice/src/request-headers.ts +11 -0
  63. package/template/apps/asas-backoffice/src/routeTree.gen.ts +206 -0
  64. package/template/apps/asas-backoffice/src/router.tsx +18 -0
  65. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/files.ts +10 -0
  66. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/screen.json +3971 -0
  67. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/types.ts +163 -0
  68. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.$.tsx +17 -0
  69. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.index.tsx +10 -0
  70. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.tsx +13 -0
  71. package/template/apps/asas-backoffice/src/routes/__root.tsx +95 -0
  72. package/template/apps/asas-backoffice/src/routes/api/$.ts +34 -0
  73. package/template/apps/asas-backoffice/src/routes/auth.create-first-user.tsx +113 -0
  74. package/template/apps/asas-backoffice/src/routes/auth.sign-in.tsx +77 -0
  75. package/template/apps/asas-backoffice/src/routes/unauthorized.tsx +134 -0
  76. package/template/apps/asas-backoffice/src/screen-form.tsx +36 -0
  77. package/template/apps/asas-backoffice/src/screen-list.tsx +140 -0
  78. package/template/apps/asas-backoffice/src/screen-names.ts +10 -0
  79. package/template/apps/asas-backoffice/src/screens.tsx +888 -0
  80. package/template/apps/asas-backoffice/src/server.ts +30 -0
  81. package/template/apps/asas-backoffice/src/slide.ts +22 -0
  82. package/template/apps/asas-backoffice/src/start.ts +42 -0
  83. package/template/apps/asas-backoffice/src/styles.css +202 -0
  84. package/template/apps/asas-backoffice/src/theme.ts +2 -0
  85. package/template/apps/asas-backoffice/tsconfig.json +19 -0
  86. package/template/apps/asas-backoffice/vite.config.ts +32 -0
  87. package/template/apps/asas-tanstack/components.json +25 -0
  88. package/template/apps/asas-tanstack/gitignore +3 -0
  89. package/template/apps/asas-tanstack/package.json +58 -0
  90. package/template/apps/asas-tanstack/public/favicon.svg +1 -0
  91. package/template/apps/asas-tanstack/src/api.ts +147 -0
  92. package/template/apps/asas-tanstack/src/auth-frame.tsx +150 -0
  93. package/template/apps/asas-tanstack/src/choose-password.tsx +150 -0
  94. package/template/apps/asas-tanstack/src/components/ui/accordion.tsx +10 -0
  95. package/template/apps/asas-tanstack/src/components/ui/alert-dialog.tsx +173 -0
  96. package/template/apps/asas-tanstack/src/components/ui/avatar.tsx +104 -0
  97. package/template/apps/asas-tanstack/src/components/ui/badge.tsx +48 -0
  98. package/template/apps/asas-tanstack/src/components/ui/breadcrumb.tsx +125 -0
  99. package/template/apps/asas-tanstack/src/components/ui/button-group.tsx +87 -0
  100. package/template/apps/asas-tanstack/src/components/ui/button.tsx +56 -0
  101. package/template/apps/asas-tanstack/src/components/ui/calendar.tsx +223 -0
  102. package/template/apps/asas-tanstack/src/components/ui/card.tsx +94 -0
  103. package/template/apps/asas-tanstack/src/components/ui/chart.tsx +369 -0
  104. package/template/apps/asas-tanstack/src/components/ui/checkbox.tsx +27 -0
  105. package/template/apps/asas-tanstack/src/components/ui/collapsible.tsx +19 -0
  106. package/template/apps/asas-tanstack/src/components/ui/combobox.tsx +300 -0
  107. package/template/apps/asas-tanstack/src/components/ui/dialog.tsx +149 -0
  108. package/template/apps/asas-tanstack/src/components/ui/direction.tsx +5 -0
  109. package/template/apps/asas-tanstack/src/components/ui/directional-icon.tsx +20 -0
  110. package/template/apps/asas-tanstack/src/components/ui/dropdown-menu.tsx +267 -0
  111. package/template/apps/asas-tanstack/src/components/ui/field.tsx +225 -0
  112. package/template/apps/asas-tanstack/src/components/ui/input-group.tsx +147 -0
  113. package/template/apps/asas-tanstack/src/components/ui/input-otp.tsx +20 -0
  114. package/template/apps/asas-tanstack/src/components/ui/input.tsx +20 -0
  115. package/template/apps/asas-tanstack/src/components/ui/label.tsx +18 -0
  116. package/template/apps/asas-tanstack/src/components/ui/popover.tsx +50 -0
  117. package/template/apps/asas-tanstack/src/components/ui/radio-group.tsx +10 -0
  118. package/template/apps/asas-tanstack/src/components/ui/resizable.tsx +47 -0
  119. package/template/apps/asas-tanstack/src/components/ui/scroll-area.tsx +52 -0
  120. package/template/apps/asas-tanstack/src/components/ui/select.tsx +190 -0
  121. package/template/apps/asas-tanstack/src/components/ui/separator.tsx +23 -0
  122. package/template/apps/asas-tanstack/src/components/ui/sheet.tsx +127 -0
  123. package/template/apps/asas-tanstack/src/components/ui/sidebar.tsx +716 -0
  124. package/template/apps/asas-tanstack/src/components/ui/skeleton.tsx +13 -0
  125. package/template/apps/asas-tanstack/src/components/ui/slider.tsx +13 -0
  126. package/template/apps/asas-tanstack/src/components/ui/spinner.tsx +11 -0
  127. package/template/apps/asas-tanstack/src/components/ui/switch.tsx +8 -0
  128. package/template/apps/asas-tanstack/src/components/ui/table.tsx +119 -0
  129. package/template/apps/asas-tanstack/src/components/ui/tabs.tsx +80 -0
  130. package/template/apps/asas-tanstack/src/components/ui/textarea.tsx +18 -0
  131. package/template/apps/asas-tanstack/src/components/ui/toggle-group.tsx +8 -0
  132. package/template/apps/asas-tanstack/src/components/ui/toggle.tsx +44 -0
  133. package/template/apps/asas-tanstack/src/components/ui/tooltip.tsx +68 -0
  134. package/template/apps/asas-tanstack/src/components.ts +35 -0
  135. package/template/apps/asas-tanstack/src/copy.ts +1207 -0
  136. package/template/apps/asas-tanstack/src/first-run.ts +16 -0
  137. package/template/apps/asas-tanstack/src/hooks/use-mobile.ts +19 -0
  138. package/template/apps/asas-tanstack/src/lib/utils.ts +6 -0
  139. package/template/apps/asas-tanstack/src/links.ts +38 -0
  140. package/template/apps/asas-tanstack/src/locale-switch.tsx +28 -0
  141. package/template/apps/asas-tanstack/src/no-organization.tsx +108 -0
  142. package/template/apps/asas-tanstack/src/quiet-abort.ts +25 -0
  143. package/template/apps/asas-tanstack/src/redirect.ts +39 -0
  144. package/template/apps/asas-tanstack/src/request-headers.ts +11 -0
  145. package/template/apps/asas-tanstack/src/routeTree.gen.ts +424 -0
  146. package/template/apps/asas-tanstack/src/router.tsx +18 -0
  147. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +11 -0
  148. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +4242 -0
  149. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +160 -0
  150. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.$.tsx +17 -0
  151. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.index.tsx +10 -0
  152. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.tsx +13 -0
  153. package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +9 -0
  154. package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +1564 -0
  155. package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +94 -0
  156. package/template/apps/asas-tanstack/src/routes/(screens)/portal.$.tsx +17 -0
  157. package/template/apps/asas-tanstack/src/routes/(screens)/portal.index.tsx +10 -0
  158. package/template/apps/asas-tanstack/src/routes/(screens)/portal.tsx +13 -0
  159. package/template/apps/asas-tanstack/src/routes/__root.tsx +99 -0
  160. package/template/apps/asas-tanstack/src/routes/api/$.ts +11 -0
  161. package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +108 -0
  162. package/template/apps/asas-tanstack/src/routes/auth.first-run.tsx +114 -0
  163. package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +94 -0
  164. package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +39 -0
  165. package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +16 -0
  166. package/template/apps/asas-tanstack/src/routes/auth.set-password.tsx +16 -0
  167. package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +98 -0
  168. package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +143 -0
  169. package/template/apps/asas-tanstack/src/routes/index.tsx +146 -0
  170. package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +134 -0
  171. package/template/apps/asas-tanstack/src/screen-form.tsx +36 -0
  172. package/template/apps/asas-tanstack/src/screen-list.tsx +140 -0
  173. package/template/apps/asas-tanstack/src/screen-names.ts +10 -0
  174. package/template/apps/asas-tanstack/src/screens.tsx +888 -0
  175. package/template/apps/asas-tanstack/src/server.ts +26 -0
  176. package/template/apps/asas-tanstack/src/slide.ts +22 -0
  177. package/template/apps/asas-tanstack/src/start.ts +57 -0
  178. package/template/apps/asas-tanstack/src/styles.css +163 -0
  179. package/template/apps/asas-tanstack/src/theme.ts +5 -0
  180. package/template/apps/asas-tanstack/tsconfig.json +19 -0
  181. package/template/apps/asas-tanstack/vite.config.ts +34 -0
  182. package/template/package.json +16 -0
  183. package/template/templates/asas/.claude/skills/add-field/SKILL.md +27 -0
  184. package/template/templates/asas/.claude/skills/add-kayan/SKILL.md +26 -0
  185. package/template/templates/asas/.claude/skills/eject-admin-piece/SKILL.md +28 -0
  186. package/template/templates/asas/.claude/skills/upgrade-mithaq/SKILL.md +35 -0
  187. package/template/templates/asas/.env.example +33 -0
  188. package/template/templates/asas/AGENTS.md +101 -0
  189. package/template/templates/asas/CLAUDE.md +1 -0
  190. package/template/templates/asas/README.md +1056 -0
  191. package/template/templates/asas/mithaq.hints.json +25 -0
  192. package/template/templates/asas/package.json +53 -0
  193. package/template/templates/asas/src/asas.ts +118 -0
  194. package/template/templates/asas/src/backoffice-http.ts +117 -0
  195. package/template/templates/asas/src/dev-setup.ts +112 -0
  196. package/template/templates/asas/src/local-database.ts +22 -0
  197. package/template/templates/asas/src/messages/account.json +132 -0
  198. package/template/templates/asas/src/messages/auditLog.json +304 -0
  199. package/template/templates/asas/src/messages/backofficeLog.json +132 -0
  200. package/template/templates/asas/src/messages/courses.json +312 -0
  201. package/template/templates/asas/src/messages/enrollments.json +60 -0
  202. package/template/templates/asas/src/messages/invitation.json +82 -0
  203. package/template/templates/asas/src/messages/localUsers.json +48 -0
  204. package/template/templates/asas/src/messages/media.json +76 -0
  205. package/template/templates/asas/src/messages/member.json +62 -0
  206. package/template/templates/asas/src/messages/memberRoles.json +48 -0
  207. package/template/templates/asas/src/messages/mithaq.json +214 -0
  208. package/template/templates/asas/src/messages/organization.json +72 -0
  209. package/template/templates/asas/src/messages/organizationRole.json +62 -0
  210. package/template/templates/asas/src/messages/roles.json +72 -0
  211. package/template/templates/asas/src/messages/session.json +102 -0
  212. package/template/templates/asas/src/messages/sessions.json +136 -0
  213. package/template/templates/asas/src/messages/settings.json +64 -0
  214. package/template/templates/asas/src/messages/tags.json +48 -0
  215. package/template/templates/asas/src/messages/user.json +112 -0
  216. package/template/templates/asas/src/messages/verification.json +62 -0
  217. package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/migration.sql +177 -0
  218. package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/snapshot.json +2169 -0
  219. package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/migration.sql +1 -0
  220. package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/snapshot.json +2182 -0
  221. package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/migration.sql +20 -0
  222. package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/snapshot.json +2409 -0
  223. package/template/templates/asas/src/migrations/20260922082945_military_ironclad/migration.sql +15 -0
  224. package/template/templates/asas/src/migrations/20260922082945_military_ironclad/snapshot.json +2627 -0
  225. package/template/templates/asas/src/migrations/20260922112630_bitter_chat/migration.sql +5 -0
  226. package/template/templates/asas/src/migrations/20260922112630_bitter_chat/snapshot.json +2691 -0
  227. package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/migration.sql +8 -0
  228. package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/snapshot.json +2795 -0
  229. package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/migration.sql +3 -0
  230. package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/snapshot.json +2834 -0
  231. package/template/templates/asas/src/migrations/20260927212525_bent_prowler/migration.sql +9 -0
  232. package/template/templates/asas/src/migrations/20260927212525_bent_prowler/snapshot.json +2870 -0
  233. package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/migration.sql +1 -0
  234. package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/snapshot.json +2881 -0
  235. package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/migration.sql +15 -0
  236. package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/snapshot.json +3073 -0
  237. package/template/templates/asas/src/migrations/20260929024407_grey_morlun/migration.sql +21 -0
  238. package/template/templates/asas/src/migrations/20260929024407_grey_morlun/snapshot.json +3098 -0
  239. package/template/templates/asas/src/migrations/20260929130530_bored_jackal/migration.sql +21 -0
  240. package/template/templates/asas/src/migrations/20260929130530_bored_jackal/snapshot.json +3124 -0
  241. package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/migration.sql +32 -0
  242. package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/snapshot.json +3324 -0
  243. package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/migration.sql +2 -0
  244. package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/snapshot.json +3350 -0
  245. package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/migration.sql +21 -0
  246. package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/snapshot.json +3635 -0
  247. package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/migration.sql +8 -0
  248. package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/snapshot.json +3635 -0
  249. package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/migration.sql +18 -0
  250. package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/snapshot.json +3635 -0
  251. package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/migration.sql +18 -0
  252. package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/snapshot.json +3635 -0
  253. package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/migration.sql +11 -0
  254. package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/snapshot.json +3667 -0
  255. package/template/templates/asas/src/migrations/20261012000752_organization_ownership/migration.sql +48 -0
  256. package/template/templates/asas/src/migrations/20261012000752_organization_ownership/snapshot.json +3891 -0
  257. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/migration.sql +1 -0
  258. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/snapshot.json +3881 -0
  259. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/migration.sql +1 -0
  260. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/snapshot.json +3894 -0
  261. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/migration.sql +1 -0
  262. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/snapshot.json +3907 -0
  263. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/migration.sql +1 -0
  264. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/snapshot.json +3907 -0
  265. package/template/templates/asas/src/migrations/20261015042444_organization_names/migration.sql +1 -0
  266. package/template/templates/asas/src/migrations/20261015042444_organization_names/snapshot.json +3920 -0
  267. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/migration.sql +1 -0
  268. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/snapshot.json +3933 -0
  269. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/migration.sql +15 -0
  270. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/snapshot.json +4113 -0
  271. package/template/templates/asas/src/migrations/20261015095752_portal/migration.sql +2 -0
  272. package/template/templates/asas/src/migrations/20261015095752_portal/snapshot.json +4113 -0
  273. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/migration.sql +9 -0
  274. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/snapshot.json +4302 -0
  275. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/migration.sql +27 -0
  276. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/snapshot.json +4289 -0
  277. package/template/templates/asas/src/migrations/drizzle.config.ts +11 -0
  278. package/template/templates/asas/src/migrations/schema.ts +30 -0
  279. package/template/templates/asas/src/mithaq.ts +4 -0
  280. package/template/templates/asas/src/organizations.ts +3 -0
  281. package/template/templates/asas/src/seed.ts +427 -0
  282. package/template/templates/asas/src/ui-backoffice/home.tsx +63 -0
  283. package/template/templates/asas/src/ui-backoffice/organization-actions.tsx +121 -0
  284. package/template/templates/asas/src/ui-backoffice/record-page.tsx +30 -0
  285. package/template/templates/asas/src/ui-backoffice/role-cells.tsx +59 -0
  286. package/template/templates/asas/src/ui-backoffice/user-actions.tsx +249 -0
  287. package/template/templates/asas/src/ui-dashboard/dashboard.tsx +300 -0
  288. package/template/templates/asas/src/ui-dashboard/home.tsx +73 -0
  289. package/template/templates/asas/src/ui-dashboard/members-card.tsx +147 -0
  290. package/template/templates/asas/src/ui-dashboard/members.tsx +829 -0
  291. package/template/templates/asas/src/ui-dashboard/theme-provider.tsx +10 -0
  292. package/template/templates/asas/src/ui-dashboard/versions.tsx +446 -0
  293. package/template/templates/asas/src/ui-portal/courses-home.tsx +96 -0
  294. package/template/templates/asas/src/ui-portal/home.tsx +73 -0
  295. package/template/templates/asas/src/ui-portal/page.tsx +11 -0
  296. package/template/templates/features/audit-log/audit-log/audit-log.ts +284 -0
  297. package/template/templates/features/audit-log/backoffice-log/backoffice-log.ts +67 -0
  298. package/template/templates/features/audit-log/index.ts +4 -0
  299. package/template/templates/features/audit-log/package.json +13 -0
  300. package/template/templates/features/auth/index.ts +11 -0
  301. package/template/templates/features/auth/local-users/local-users.ts +143 -0
  302. package/template/templates/features/auth/package.json +13 -0
  303. package/template/templates/features/auth/user/account.access.ts +8 -0
  304. package/template/templates/features/auth/user/account.ts +31 -0
  305. package/template/templates/features/auth/user/index.ts +18 -0
  306. package/template/templates/features/auth/user/invitation.access.ts +4 -0
  307. package/template/templates/features/auth/user/invitation.ts +27 -0
  308. package/template/templates/features/auth/user/member.access.ts +10 -0
  309. package/template/templates/features/auth/user/member.screens.ts +37 -0
  310. package/template/templates/features/auth/user/member.ts +26 -0
  311. package/template/templates/features/auth/user/organization.access.ts +20 -0
  312. package/template/templates/features/auth/user/organization.screens.ts +37 -0
  313. package/template/templates/features/auth/user/organization.ts +25 -0
  314. package/template/templates/features/auth/user/organizationRole.access.ts +8 -0
  315. package/template/templates/features/auth/user/organizationRole.ts +25 -0
  316. package/template/templates/features/auth/user/session.access.ts +8 -0
  317. package/template/templates/features/auth/user/session.ts +28 -0
  318. package/template/templates/features/auth/user/user.access.ts +30 -0
  319. package/template/templates/features/auth/user/user.screens.ts +45 -0
  320. package/template/templates/features/auth/user/user.ts +31 -0
  321. package/template/templates/features/auth/user/verification.access.ts +8 -0
  322. package/template/templates/features/auth/user/verification.ts +23 -0
  323. package/template/templates/features/courses/courses/courses.ts +601 -0
  324. package/template/templates/features/courses/courses/cover-reads.ts +33 -0
  325. package/template/templates/features/courses/courses/ui/archived-badge.tsx +9 -0
  326. package/template/templates/features/courses/courses/ui/fee-cell.tsx +19 -0
  327. package/template/templates/features/courses/courses/ui/no-courses.tsx +13 -0
  328. package/template/templates/features/courses/courses/ui/status-cell.tsx +33 -0
  329. package/template/templates/features/courses/courses/ui/unpublish-button.tsx +15 -0
  330. package/template/templates/features/courses/domain/archive.ts +39 -0
  331. package/template/templates/features/courses/domain/on-offer.ts +6 -0
  332. package/template/templates/features/courses/enrollments/enrollments.ts +309 -0
  333. package/template/templates/features/courses/enrollments/ui/session-cell.tsx +75 -0
  334. package/template/templates/features/courses/enrollments/ui/session-labels.tsx +36 -0
  335. package/template/templates/features/courses/index.ts +8 -0
  336. package/template/templates/features/courses/package.json +24 -0
  337. package/template/templates/features/courses/portal/ui/catalogue.tsx +86 -0
  338. package/template/templates/features/courses/portal/ui/course.tsx +176 -0
  339. package/template/templates/features/courses/portal/ui/enrollments.tsx +170 -0
  340. package/template/templates/features/courses/portal/ui/parts.tsx +95 -0
  341. package/template/templates/features/courses/portal/ui/reads.ts +76 -0
  342. package/template/templates/features/courses/portal/ui/rules.ts +87 -0
  343. package/template/templates/features/courses/portal/ui/shell.tsx +194 -0
  344. package/template/templates/features/courses/sessions/seats.ts +42 -0
  345. package/template/templates/features/courses/sessions/sessions.ts +186 -0
  346. package/template/templates/features/media/index.ts +10 -0
  347. package/template/templates/features/media/media/media.ts +131 -0
  348. package/template/templates/features/media/media/ui/preview.tsx +143 -0
  349. package/template/templates/features/media/package.json +19 -0
  350. package/template/templates/features/roles/index.ts +4 -0
  351. package/template/templates/features/roles/member-roles/member-roles.ts +207 -0
  352. package/template/templates/features/roles/package.json +21 -0
  353. package/template/templates/features/roles/roles/roles.ts +184 -0
  354. package/template/templates/features/roles/roles/ui/role-form.tsx +267 -0
  355. package/template/templates/features/roles/roles/ui/roles.tsx +190 -0
  356. package/template/templates/features/settings/index.ts +3 -0
  357. package/template/templates/features/settings/package.json +13 -0
  358. package/template/templates/features/settings/settings/settings.ts +64 -0
  359. package/template/templates/features/tags/index.ts +3 -0
  360. package/template/templates/features/tags/package.json +14 -0
  361. package/template/templates/features/tags/tags/tags.ts +80 -0
  362. package/template/templates/mithaq/components.json +22 -0
  363. package/template/templates/mithaq/package.json +33 -0
  364. package/template/templates/mithaq/src/mail/index.ts +32 -0
  365. package/template/templates/mithaq/src/mail/mailers.ts +36 -0
  366. package/template/templates/mithaq/src/mithaq/audit-sink.ts +111 -0
  367. package/template/templates/mithaq/src/mithaq/auth/backoffice-roles.ts +108 -0
  368. package/template/templates/mithaq/src/mithaq/auth/index.ts +1859 -0
  369. package/template/templates/mithaq/src/mithaq/auth/organizations.ts +80 -0
  370. package/template/templates/mithaq/src/mithaq/pages.ts +83 -0
  371. package/template/templates/mithaq/src/mithaq/rich-text-nodes.ts +19 -0
  372. package/template/templates/mithaq/src/mithaq.ts +593 -0
  373. package/template/templates/mithaq/src/roles/default-member-roles.ts +29 -0
  374. package/template/templates/mithaq/src/roles/grants.ts +47 -0
  375. package/template/templates/mithaq/src/roles/member-access.ts +11 -0
  376. package/template/templates/mithaq/src/roles/statements.ts +13 -0
  377. package/template/templates/mithaq/src/roles/system-roles.ts +16 -0
  378. package/template/templates/mithaq/src/roles/tenant-access.ts +260 -0
  379. package/template/templates/mithaq/src/roles/tenant-roles.ts +321 -0
  380. package/template/templates/mithaq/src/steps/expected-updated-at.ts +41 -0
  381. package/template/templates/mithaq/src/storage/defaults.ts +156 -0
  382. package/template/templates/mithaq/src/storage/index.ts +97 -0
  383. package/template/templates/mithaq/src/storage/local.ts +143 -0
  384. package/template/templates/mithaq/src/storage/r2.ts +5 -0
  385. package/template/templates/mithaq/src/storage/s3.ts +91 -0
  386. package/template/templates/mithaq/src/storage/uploadthing.ts +59 -0
  387. package/template/templates/mithaq/src/ui/activity-page.tsx +33 -0
  388. package/template/templates/mithaq/src/ui/activity.tsx +527 -0
  389. package/template/templates/mithaq/src/ui/app-shell.tsx +268 -0
  390. package/template/templates/mithaq/src/ui/kpi-card.tsx +25 -0
  391. package/template/templates/mithaq/src/ui/record-page.tsx +50 -0
  392. package/template/templates/mithaq/src/ui/rich-text-nodes.tsx +33 -0
  393. package/template/templates/mithaq/src/ui/screen-nav.tsx +28 -0
  394. package/template/templates/mithaq/src/ui/table-page.tsx +9 -0
  395. package/template/templates/mithaq/src/ui/top-shell.tsx +60 -0
  396. package/README.md +0 -3
@@ -0,0 +1,143 @@
1
+ // What the `media` kayan draws of its own files: the image beside its edit
2
+ // form, the list's thumbnail and size cells, and the page a new image is made
3
+ // on. Each reads the row's computed `url` and `thumbnailURL`, so a file is
4
+ // fetched through the file route and the row's access decides. A new file is
5
+ // made in the registry's upload dialog, which sends the bytes before the row
6
+ // is made.
7
+ import { useRef, useState } from 'react'
8
+ import { Download, Upload } from 'lucide-react'
9
+ import type { CellProps, ComponentProps, Row } from 'mithaq/admin'
10
+ import { useAccess, useClient, useLocale, useRecord, useScreen } from 'mithaq/admin/react'
11
+ import { locales } from '@/copy.ts'
12
+ import { Button, buttonVariants } from '@/components/ui/button'
13
+ import { FileThumb, FileValue, UploadDialog, sizeOf } from 'mithaq-admin/upload.tsx'
14
+ import { AppShell } from '@asas/mithaq/ui/app-shell.tsx'
15
+
16
+ /** The `filesize` column: the size as a person reads it, never the raw bytes, in the upload dialog's own units. */
17
+ export function FileSize({ value }: CellProps) {
18
+ const { locale } = useLocale()
19
+ return typeof value === 'number' ? <span className="whitespace-nowrap">{sizeOf(value, locale)}</span> : null
20
+ }
21
+
22
+ /**
23
+ * The `filename` column: the image's thumbnail before its name. The name
24
+ * keeps at least seven rem and wraps onto a second line before it is cut,
25
+ * between words where it has them and anywhere only where it must, so
26
+ * on a phone the table gives the column room for it rather than squeezing it
27
+ * to "co…" beside the thumbnail.
28
+ */
29
+ export function FileName({ value, row }: CellProps) {
30
+ const { t, locale } = useLocale()
31
+ const name = String(value ?? '')
32
+ return (
33
+ <span className="inline-flex min-w-0 items-center gap-2">
34
+ <FileThumb row={row} t={t} locale={locale} size="xs" />
35
+ <span className="line-clamp-2 min-w-28 [overflow-wrap:anywhere]" dir="auto" title={name}>{name}</span>
36
+ </span>
37
+ )
38
+ }
39
+
40
+ const useEditHref = () => {
41
+ const { path, kayan } = useScreen()
42
+ return (id: unknown) => `${path}${kayan.media?.pages.edit?.path.replace(':id', encodeURIComponent(String(id)))}`
43
+ }
44
+
45
+ /** The upload dialog over this kayan, which opens the new image's page once it is made. */
46
+ function NewImageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
47
+ const { t, locale } = useLocale()
48
+ const { kayan } = useScreen()
49
+ const client = useClient()
50
+ const editHref = useEditHref()
51
+ // The dialog closes itself once the row is made, and that close is no
52
+ // Cancel: the new image's page is already opening.
53
+ const done = useRef(false)
54
+ return kayan.media ? (
55
+ <UploadDialog
56
+ open={open}
57
+ onOpenChange={(next) => done.current || onOpenChange(next)}
58
+ target={kayan.media}
59
+ client={client}
60
+ t={t}
61
+ locale={locale}
62
+ locales={locales}
63
+ onDone={(row: Row) => {
64
+ done.current = true
65
+ window.location.assign(editHref(row.id))
66
+ }}
67
+ />
68
+ ) : null
69
+ }
70
+
71
+ /**
72
+ * The image beside its edit form, and above it on a phone, where the record
73
+ * layout puts a side marked `data-lead` first: the image itself, its size, a
74
+ * link that downloads it, and Replace file. A file never changes once it is
75
+ * made, so Replace file uploads a new image and opens it, and this one stays,
76
+ * with whatever points at it, until it is deleted.
77
+ */
78
+ export function MediaPreview() {
79
+ const { t, locale } = useLocale()
80
+ const access = useAccess()
81
+ const row = useRecord()
82
+ const [replacing, setReplacing] = useState(false)
83
+ if (!row) return null
84
+ const creates = access.collections?.media?.create === true
85
+ const url = typeof row.url === 'string' ? row.url : undefined
86
+ return (
87
+ <div data-media-preview="" data-lead="" className="grid justify-items-start gap-3">
88
+ <FileValue row={row} t={t} locale={locale} />
89
+ {typeof row.filesize === 'number' ? <p className="text-muted-foreground text-sm">{sizeOf(row.filesize, locale)}</p> : null}
90
+ <div className="flex flex-wrap gap-2">
91
+ {url ? (
92
+ <a href={url} download={typeof row.filename === 'string' ? row.filename : ''} data-media-download="" className={buttonVariants({ variant: 'outline', size: 'sm' })}>
93
+ <Download className="size-4" aria-hidden />
94
+ {t('media.download', 'Download')}
95
+ </a>
96
+ ) : null}
97
+ {creates ? (
98
+ <Button type="button" variant="outline" size="sm" data-media-replace="" onClick={() => setReplacing(true)}>
99
+ <Upload className="size-4" aria-hidden />
100
+ {t('media.replace', 'Replace file')}
101
+ </Button>
102
+ ) : null}
103
+ </div>
104
+ {creates ? <p className="text-muted-foreground text-xs">{t('media.replaceAbout', 'Replace file uploads a new image and opens it. This one stays until you delete it.')}</p> : null}
105
+ <NewImageDialog open={replacing} onOpenChange={setReplacing} />
106
+ </div>
107
+ )
108
+ }
109
+
110
+ /**
111
+ * The New page, `/media/new`, which the list's New button opens: the upload
112
+ * dialog, open at once, and the page under it, which opens it again. Closing
113
+ * it goes back to the list.
114
+ */
115
+ export function NewImage(_props: ComponentProps) {
116
+ const { t } = useLocale()
117
+ const { path, kayan } = useScreen()
118
+ const [open, setOpen] = useState(true)
119
+ const list = `${path}${kayan.media?.pages.list?.path ?? ''}`
120
+ return (
121
+ <AppShell
122
+ regions={{
123
+ header: <h1>{t('media.new', 'New image')}</h1>,
124
+ main: (
125
+ <div data-media-new="" className="grid justify-items-start gap-4">
126
+ <p className="text-muted-foreground">{t('media.newAbout', 'Choose an image and describe it. It is uploaded before it is saved.')}</p>
127
+ <Button type="button" onClick={() => setOpen(true)}>
128
+ <Upload className="size-4" aria-hidden />
129
+ {t('admin.upload.new', 'Upload new')}
130
+ </Button>
131
+ <NewImageDialog
132
+ open={open}
133
+ onOpenChange={(next) => {
134
+ setOpen(next)
135
+ if (!next) window.location.assign(list)
136
+ }}
137
+ />
138
+ </div>
139
+ ),
140
+ }}
141
+ />
142
+ )
143
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@asas/media",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.ts"
8
+ },
9
+ "dependencies": {
10
+ "@asas/mithaq": "*",
11
+ "mithaq": "*",
12
+ "mithaq-admin": "*"
13
+ },
14
+ "devDependencies": {
15
+ "@types/react": "^19.3.0",
16
+ "lucide-react": "^1.16.0",
17
+ "react": "^19.3.0"
18
+ }
19
+ }
@@ -0,0 +1,4 @@
1
+ import { memberRoles } from './member-roles/member-roles.ts'
2
+ import { roles } from './roles/roles.ts'
3
+
4
+ export const kayan = [roles, memberRoles] as const
@@ -0,0 +1,207 @@
1
+ import { sql } from 'mithaq/drizzle'
2
+ import type { NodePgDatabase } from 'mithaq/drizzle/node-postgres'
3
+ import { assignableRole, conflict, defaultFields, defaultOperations, denied, type Ctx, type WriteTools } from 'mithaq'
4
+ import { defineKayan, backofficeUi, rows, organization } from '@asas/mithaq'
5
+ import { backofficeList, backofficeRecord } from '@asas/mithaq/mithaq/pages.ts'
6
+ import { tenantRoles } from '@asas/mithaq/roles/system-roles.ts'
7
+ import { backofficeReads } from '@asas/mithaq/roles/tenant-access.ts'
8
+ import { managesRoles, OWNER, OWNER_ROLE } from '@asas/mithaq/roles/grants.ts'
9
+
10
+ const role = { field: 'role', cell: backofficeUi('role-cells.tsx#RoleName') } as const
11
+
12
+ /**
13
+ * Nobody takes from themselves the role that lets them manage roles: their own
14
+ * `administrator`, or any role holding a key that manages roles. Another member
15
+ * holding the same keys removes it.
16
+ *
17
+ * A custom role's keys are read as the actor, through the role kayan's own
18
+ * access, so a role the actor may not read is refused rather than taken off
19
+ * unchecked. It guards a member's own row; two administrators removing each
20
+ * other at once are `managerRemains`' to refuse.
21
+ */
22
+ const keepsOwnManager = async (ctx: Ctx, tools: WriteTools): Promise<Ctx> => {
23
+ const before = ctx.dataBefore
24
+ const me = (ctx.actor as { id?: unknown } | undefined)?.id
25
+ if (!before || me === undefined || before.user !== me) return ctx
26
+ const after = { ...before, ...(ctx.data as Record<string, unknown>) }
27
+ if (tools.kind === 'update' && after.user === before.user && after.role === before.role) return ctx
28
+ const role = String(before.role)
29
+ const roles = (tools.kayans as { roles: { find(input: object): Promise<{ docs: { permissions?: unknown }[] }> } }).roles
30
+ const keys = Object.hasOwn(tenantRoles, role)
31
+ ? tenantRoles[role as keyof typeof tenantRoles].permissions
32
+ : (await roles.find({ where: { name: { equals: role } } })).docs.flatMap((row) => (Array.isArray(row.permissions) ? (row.permissions as string[]) : []))
33
+ if (!managesRoles(keys)) return ctx
34
+ throw denied({
35
+ message: `Forbidden: role "${role}" is what lets this member manage roles, so it is not taken from themselves. Another member who may manage roles removes it.`,
36
+ key: 'error.forbidden.own_role',
37
+ params: { kayan: ctx.kayan.name, role },
38
+ })
39
+ }
40
+
41
+ /** The write's transaction, which is drizzle's here. */
42
+ const txOf = (ctx: Ctx) => ctx.tx as NodePgDatabase
43
+
44
+ /**
45
+ * How many assignments of one tenant name one role, counted inside the write's
46
+ * own transaction rather than read as the actor: a number leaves this function
47
+ * and no row does, so a role's author needs no `memberRoles` key to learn
48
+ * whether a name is still carried.
49
+ */
50
+ export const assignedCount = async ({ ctx, tenantId, role }: { ctx: Ctx; tenantId: unknown; role: string }): Promise<number> => {
51
+ const [found] = (
52
+ await txOf(ctx).execute<{ total: number }>(sql`select count(*)::int as total from member_roles where organization_id = ${String(tenantId)} and role = ${role}`)
53
+ ).rows
54
+ return found?.total ?? 0
55
+ }
56
+
57
+ /**
58
+ * What a write changes about who manages roles: an assignment removed, or
59
+ * moved to another member or role, or a custom role's keys, which are `[]`
60
+ * when it is deleted.
61
+ */
62
+ type Change = { assignment: Readonly<Record<string, unknown>>; becomes?: { user: unknown; role: unknown } } | { role: string; keys: readonly string[] }
63
+
64
+ /**
65
+ * **Every tenant keeps a member who may manage roles**: one holding a role whose
66
+ * keys `managesRoles` answers for, or the organization's owner, who always holds
67
+ * `administrator`. A write that would leave none is refused 409
68
+ * `error.conflict.last_manager`. Removing the owner's own `administrator` row
69
+ * changes nothing the owner holds, so it is refused too, 403
70
+ * `error.forbidden.owner_role`.
71
+ *
72
+ * The check runs under `pg_advisory_xact_lock` on the tenant, held to the end of
73
+ * the write's transaction, and every guarded write takes the same lock first, so
74
+ * two administrators removing each other at once are serialized and the second
75
+ * reads the first's removal. `FOR UPDATE` on the manager assignments would not
76
+ * do: which assignments manage roles also depends on the roles table, and a role
77
+ * losing its keys touches no assignment row.
78
+ *
79
+ * Rows are read through the store rather than as the actor, because the answer
80
+ * is about every member of the tenant and the actor may read few of them;
81
+ * nothing read leaves this function but the refusal.
82
+ */
83
+ export const managerRemains = async ({ ctx, tenantId, change }: { ctx: Ctx; tenantId: unknown; change: Change }): Promise<void> => {
84
+ const tx = txOf(ctx)
85
+ const tenant = String(tenantId)
86
+ await tx.execute(sql`select pg_advisory_xact_lock(hashtext('mithaq.managers'), hashtext(${tenant}))`)
87
+ const owners = (
88
+ await tx.execute<{ user: string }>(
89
+ sql`select user_id as "user" from member where organization_id = ${tenant} and ${OWNER} = any(string_to_array(role, ','))`,
90
+ )
91
+ ).rows.map((row) => row.user)
92
+ if ('assignment' in change) {
93
+ const { assignment, becomes } = change
94
+ if (assignment.role === OWNER_ROLE && owners.includes(String(assignment.user)) && (becomes?.role !== assignment.role || becomes.user !== assignment.user)) {
95
+ throw denied({
96
+ message: `Forbidden: the organization's owner always holds "${OWNER_ROLE}", so the row saying so is not taken from them.`,
97
+ key: 'error.forbidden.owner_role',
98
+ params: { kayan: ctx.kayan.name, role: OWNER_ROLE },
99
+ })
100
+ }
101
+ }
102
+ if (owners.length) return
103
+ const assigned = (await tx.execute<{ id: string; user: string; role: string }>(sql`select id, user_id as "user", role from member_roles where organization_id = ${tenant}`)).rows
104
+ const custom = new Map<string, readonly string[]>(
105
+ (await tx.execute<{ name: string; permissions: string[] }>(sql`select name, permissions from roles where organization_id = ${tenant}`)).rows.map((row) => [row.name, row.permissions] as const),
106
+ )
107
+ if ('keys' in change) custom.set(change.role, change.keys)
108
+ const keysOf = (role: string) => (Object.hasOwn(tenantRoles, role) ? tenantRoles[role as keyof typeof tenantRoles].permissions : (custom.get(role) ?? []))
109
+ const after = assigned.flatMap((row) => {
110
+ if (!('assignment' in change) || row.id !== change.assignment.id) return [row]
111
+ return change.becomes ? [{ ...row, role: String(change.becomes.role) }] : []
112
+ })
113
+ if (after.some((row) => managesRoles(keysOf(row.role)))) return
114
+ throw conflict({
115
+ message: `This write would leave the tenant with no member who may manage roles, so it is refused. Give another member such a role first.`,
116
+ key: 'error.conflict.last_manager',
117
+ params: { kayan: ctx.kayan.name },
118
+ })
119
+ }
120
+
121
+ /** `managerRemains` for an assignment's update or delete; an update that neither moves the row nor changes its role changes nothing. */
122
+ const keepsAManager = async (ctx: Ctx, tools: WriteTools): Promise<Ctx> => {
123
+ const before = ctx.dataBefore
124
+ if (!before) return ctx
125
+ const after = tools.kind === 'update' ? { ...before, ...(ctx.data as Record<string, unknown>) } : undefined
126
+ if (after && after.user === before.user && after.role === before.role) return ctx
127
+ await managerRemains({ ctx, tenantId: before.organization, change: { assignment: before, ...(after && { becomes: { user: after.user, role: after.role } }) } })
128
+ return ctx
129
+ }
130
+
131
+ /**
132
+ * Who holds which of this project's roles, per tenant: one row per member per
133
+ * role. It exists so that Better Auth's own `member.role` never carries a
134
+ * business role name beside its governance one: a role name holding a comma would
135
+ * be accepted by `create-role` and could never be assigned again, because that
136
+ * column is comma-delimited.
137
+ *
138
+ * The access target reads it straight from the store while the actor is being
139
+ * made, filtered on this tenant and this member, so another tenant's row is
140
+ * never in what comes back.
141
+ */
142
+ export const memberRoles = defineKayan({
143
+ name: 'memberRoles',
144
+ dbName: 'member_roles',
145
+ targets: {
146
+ // Every organization's rows on the backoffice, read-only, and each row's
147
+ // record. An assignment is looked for by its person, is titled by them, and
148
+ // reads its role by the role's label, never its slug.
149
+ backoffice: {
150
+ useAsTitle: 'user',
151
+ pages: {
152
+ ...backofficeList('/member-roles', { columns: ['user', role, 'organization'], filters: ['organization', 'user', 'role'], search: ['user.name', 'user.email'] }),
153
+ ...backofficeRecord({ path: '/member-roles/:id', main: [{ section: 'assignment', label: { en: 'Assignment', ar: 'الإسناد' }, fields: ['user', role] }] }),
154
+ },
155
+ },
156
+ // No dashboard page and so no nav entry: a member's roles are changed on the
157
+ // Members page's sheet, which writes these rows through the operations below,
158
+ // guards and all.
159
+ service: {
160
+ operations: {
161
+ ...defaultOperations,
162
+ // The gate in front of the store step: the runner has parsed by then, so
163
+ // the column is a string, and the write has not happened, so a refused
164
+ // assignment leaves no row. Without it a custom role holding
165
+ // `memberRoles.create` assigns itself `administrator`. It is handed the
166
+ // tenant roles alone, so a backoffice role such as `support` reads as an
167
+ // unknown custom role and is refused 400. `'roles'` is the kayan it reads
168
+ // the tenant's custom roles from.
169
+ create: { steps: ({ steps }) => [assignableRole(tenantRoles, 'roles'), steps.insert] },
170
+ update: { steps: ({ steps }) => [assignableRole(tenantRoles, 'roles'), keepsOwnManager, keepsAManager, steps.update] },
171
+ // Removing an assignment is measured the way handing it out is. Without it
172
+ // an actor delegated `memberRoles.delete` removes the tenant's only
173
+ // administrator row. The guard reads the row the store holds, because a
174
+ // delete names no column.
175
+ delete: { steps: ({ steps }) => [assignableRole(tenantRoles, 'roles'), keepsOwnManager, keepsAManager, steps.remove] },
176
+ },
177
+ },
178
+ },
179
+ // Its own rows, since the inherited table would close the instructor's
180
+ // delete here. Every backoffice role reads the assignments of every tenant in
181
+ // the backoffice app and changes none; in the tenant app `assignedRoles` omits
182
+ // the backoffice slugs.
183
+ access: rows({
184
+ '*': { create: true, read: true, update: true, delete: true },
185
+ ...backofficeReads,
186
+ }),
187
+ /** A member holds a role once per tenant. A second assignment answers 409 `error.conflict.unique`. */
188
+ indexes: [{ within: 'organization', unique: [['user', 'role']] }],
189
+ fields: {
190
+ ...defaultFields,
191
+ organization,
192
+ user: {
193
+ kind: 'relationship',
194
+ label: { ar: 'العضو', en: 'Member' },
195
+ relationTo: 'user',
196
+ // The assignment goes with the member: a deleted user holds no role, and
197
+ // `restrict`, which a required relationship takes by default, would leave a
198
+ // row nothing can reach standing in the way of the delete.
199
+ rules: { required: true, index: true, onDelete: 'cascade' },
200
+ },
201
+ role: {
202
+ kind: 'text',
203
+ label: { ar: 'الدور', en: 'Role' },
204
+ rules: { required: true, maxLength: 60, index: true },
205
+ },
206
+ },
207
+ })
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@asas/roles",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.ts",
8
+ "./roles/ui/*": "./roles/ui/*"
9
+ },
10
+ "dependencies": {
11
+ "@asas/auth": "*",
12
+ "@asas/mithaq": "*",
13
+ "mithaq": "*",
14
+ "mithaq-admin": "*"
15
+ },
16
+ "devDependencies": {
17
+ "@types/react": "^19.3.0",
18
+ "lucide-react": "^1.16.0",
19
+ "react": "^19.3.0"
20
+ }
21
+ }
@@ -0,0 +1,184 @@
1
+ import { actorRoles, conflict, defaultFields, denied, folderOf, grantedPermissions, writableRoleName, defaultOperations, type Ctx, type WriteTools } from 'mithaq'
2
+ import { user as users } from '@asas/auth'
3
+ import { assignedCount, managerRemains, memberRoles } from '../member-roles/member-roles.ts'
4
+ import { managesRoles } from '@asas/mithaq/roles/grants.ts'
5
+ import { defineKayan, rows, organization } from '@asas/mithaq'
6
+ import { appPage, backofficeList, backofficeRecord } from '@asas/mithaq/mithaq/pages.ts'
7
+ import { systemRoles } from '@asas/mithaq/roles/system-roles.ts'
8
+ import { backofficeReads } from '@asas/mithaq/roles/tenant-access.ts'
9
+
10
+ const rolesUi = folderOf(new URL('./ui/', import.meta.url))
11
+
12
+ /**
13
+ * A tenant's own roles, each with a flat list of the catalog keys it grants. The roles this project ships are code, `systemRoles`, and never a row here;
14
+ * a granter may not hand over a key it does not itself hold.
15
+ */
16
+ /**
17
+ * A tenant's role never holds `screen.backoffice`, which only a backoffice role holds: a row naming it would be a tenant claiming the backoffice.
18
+ * It is refused before the granter check, so a root granter holding `'*'` is refused too.
19
+ */
20
+ const BACKOFFICE_KEY = 'screen.backoffice'
21
+ const tenantKeysOnly = (ctx: Ctx): Ctx => {
22
+ const written = (ctx.data as Record<string, unknown>).permissions
23
+ if (!Array.isArray(written) || !written.includes(BACKOFFICE_KEY)) return ctx
24
+ throw denied({
25
+ message: `Forbidden: a "${ctx.kayan.name}" row is a tenant's role, and "${BACKOFFICE_KEY}" is held only by a backoffice role.`,
26
+ key: 'error.forbidden.backoffice_key',
27
+ params: { kayan: ctx.kayan.name, permissions: [BACKOFFICE_KEY] },
28
+ })
29
+ }
30
+
31
+ const assignments = (tools: WriteTools, role: string) =>
32
+ tools.of(memberRoles).find({ where: { role: { equals: role } }, limit: 100 })
33
+
34
+ /**
35
+ * A role a member holds is neither deleted nor renamed, and no role takes a name an assignment of its tenant already names: either would leave a `memberRoles` row naming a role that is gone, armed later by a role with that name. Both refuse 409.
36
+ * Holders are read as the actor, and an actor who may not read them is refused, because a write nobody could check does not go ahead. Assignments are counted through the store, so a role's author needs no `memberRoles` key.
37
+ */
38
+ const unheld = async (ctx: Ctx, tools: WriteTools): Promise<Ctx> => {
39
+ const before = ctx.dataBefore
40
+ const name = before?.name
41
+ const renamed = (ctx.data as { name?: unknown }).name
42
+ const arriving = tools.kind === 'insert' || (tools.kind === 'update' && renamed !== name) ? renamed : undefined
43
+ // The runner fills the organization on a create, and it is the active one.
44
+ const tenantId = before?.organization ?? (ctx.data as { organization?: unknown }).organization
45
+ if (typeof arriving === 'string') {
46
+ const standing = await assignedCount({ ctx, tenantId, role: arriving })
47
+ if (standing) {
48
+ throw conflict({
49
+ message: `Role "${arriving}" is named by ${standing} assignment(s) of this tenant that outlived their role, so no role takes that name until they are removed.`,
50
+ key: 'error.conflict.role_assigned',
51
+ params: { kayan: ctx.kayan.name, role: arriving, count: standing },
52
+ })
53
+ }
54
+ }
55
+ if (typeof name !== 'string' || (tools.kind === 'update' && (renamed === undefined || renamed === name))) return ctx
56
+ const held = await assignments(tools, name)
57
+ if (!held.totalDocs) return ctx
58
+ const ids = held.docs.map((row) => String(row.user))
59
+ const named = await tools.of(users).find({ where: { id: { in: ids } }, limit: 100 }).catch(() => ({ docs: [] as { id?: unknown; name?: unknown }[] }))
60
+ const members = ids.map((id) => ({ id, name: String(named.docs.find((one) => one.id === id)?.name ?? id) }))
61
+ throw conflict({
62
+ message: `Role "${name}" is held by ${held.totalDocs} member(s), so it is not ${tools.kind === 'remove' ? 'deleted' : 'renamed'} until none holds it.`,
63
+ key: 'error.conflict.role_held',
64
+ params: { kayan: ctx.kayan.name, role: name, count: held.totalDocs, members },
65
+ })
66
+ }
67
+
68
+ const keysOf = (value: unknown): readonly string[] => (Array.isArray(value) ? (value as string[]) : [])
69
+
70
+ /**
71
+ * Nobody edits a role they hold so it stops letting them manage roles. The actor's roles are the ones the access target resolved for this tenant,
72
+ * off the trusted snapshot; another member who may manage roles edits it.
73
+ */
74
+ const keepsOwnRole = (ctx: Ctx): Ctx => {
75
+ const before = ctx.dataBefore
76
+ const keys = (ctx.data as { permissions?: unknown }).permissions
77
+ if (!before || keys === undefined || !managesRoles(keysOf(before.permissions)) || managesRoles(keysOf(keys))) return ctx
78
+ // The roles an assignment gives, not `assignedRoles`: the owner's added
79
+ // `administrator` is a fixed role, which no update reaches.
80
+ if (!actorRoles(ctx.actor)?.includes(String(before.name))) return ctx
81
+ throw denied({
82
+ message: `Forbidden: role "${String(before.name)}" is what lets this member manage roles, so they do not take its role-managing keys from it. Another member who may manage roles changes it.`,
83
+ key: 'error.forbidden.own_role',
84
+ params: { kayan: ctx.kayan.name, role: String(before.name) },
85
+ })
86
+ }
87
+
88
+ /** A role that lets its holders manage roles loses that only while another member still may, whether its keys change or it is deleted. */
89
+ const keepsManagers = async (ctx: Ctx, tools: WriteTools): Promise<Ctx> => {
90
+ const before = ctx.dataBefore
91
+ if (!before || !managesRoles(keysOf(before.permissions))) return ctx
92
+ const keys = tools.kind === 'remove' ? [] : keysOf((ctx.data as { permissions?: unknown }).permissions ?? before.permissions)
93
+ if (managesRoles(keys)) return ctx
94
+ await managerRemains({ ctx, tenantId: before.organization, change: { role: String(before.name), keys } })
95
+ return ctx
96
+ }
97
+
98
+ const rolesAccess = rows({
99
+ '*': { create: true, read: true, update: true, delete: true },
100
+ /**
101
+ * Every backoffice role reads the role rows of every tenant, and writes none. A
102
+ * tenant that lost its administrator is repaired through `mithaq rbac assign`,
103
+ * never from the backoffice app.
104
+ */
105
+ ...backofficeReads,
106
+ })
107
+
108
+ export const roles = defineKayan({
109
+ name: 'roles',
110
+ targets: {
111
+ /** Every organization's rows on the backoffice, read-only. */
112
+ backoffice: {
113
+ useAsTitle: 'label',
114
+ pages: {
115
+ // A role by its label, never its slug, and its keys grouped as the
116
+ // dashboard draws a fixed role's.
117
+ ...backofficeList('/roles', { columns: ['label', 'organization'], filters: ['organization'], search: ['label'] }),
118
+ ...backofficeRecord({
119
+ path: '/roles/:id',
120
+ main: [{ section: 'role', label: { en: 'Role', ar: 'الدور' }, fields: ['label', { field: 'permissions', cell: rolesUi('roles.tsx#Permissions') }] }],
121
+ }),
122
+ },
123
+ },
124
+ service: {
125
+ operations: {
126
+ ...defaultOperations,
127
+ // The shipped lists with the guard in front of the store step: the runner
128
+ // has parsed by then, so the column is already a list of strings, and the
129
+ // write has not happened, so a refused grant leaves no row behind.
130
+ create: { steps: ({ steps }) => [tenantKeysOnly, grantedPermissions, writableRoleName(systemRoles), unheld, steps.insert] },
131
+ update: { steps: ({ steps }) => [tenantKeysOnly, grantedPermissions, writableRoleName(systemRoles), unheld, keepsOwnRole, keepsManagers, steps.update] },
132
+ delete: { steps: ({ steps }) => [grantedPermissions, unheld, keepsManagers, steps.remove] },
133
+ },
134
+ },
135
+ /**
136
+ * The fixed roles list first, from their code, each with a lock and its keys; a custom role opens its form, where its keys are picked as badges, and its delete names the members who still hold it.
137
+ */
138
+ dashboard: {
139
+ useAsTitle: 'label',
140
+ description: {
141
+ en: 'What each role in this organization may see and do.',
142
+ ar: 'ما يمكن لكل دور في هذه المؤسسة أن يراه ويفعله.',
143
+ },
144
+ pages: {
145
+ list: { path: '/roles', page: appPage([{ layout: 'table', main: [{ list: { columns: ['label', 'name', 'permissions'], limit: 100 }, component: rolesUi('roles.tsx#RolesTable') }] }]) },
146
+ create: { path: '/roles/new', page: appPage([{ layout: 'record', main: [{ form: ['name', 'label', 'permissions'], component: rolesUi('role-form.tsx#RoleForm') }] }]) },
147
+ edit: {
148
+ path: '/roles/:id',
149
+ page: appPage([
150
+ {
151
+ layout: 'record',
152
+ main: [{ form: ['name', 'label', 'permissions'], component: rolesUi('role-form.tsx#RoleForm') }],
153
+ danger: [{ button: 'delete', label: { en: 'Delete', ar: 'حذف' }, component: rolesUi('roles.tsx#DeleteRole') }],
154
+ },
155
+ ]),
156
+ },
157
+ },
158
+ },
159
+ },
160
+ access: rolesAccess,
161
+ fields: {
162
+ ...defaultFields,
163
+ organization,
164
+ // Unique within the organization: two rows of one name would each grant their keys, so
165
+ // deleting the one meant to revoke would leave the other granting.
166
+ name: {
167
+ kind: 'text',
168
+ label: { en: 'Name', ar: 'الاسم' },
169
+ // Trimmed, as the label below is, so neither is spaces alone.
170
+ rules: { required: true, unique: true, trim: true, maxLength: 60, index: true },
171
+ },
172
+ /** What a person reads a role by, everywhere; the name is the slug an assignment names. */
173
+ label: {
174
+ kind: 'text',
175
+ label: { en: 'Label', ar: 'التسمية' },
176
+ rules: { required: true, localized: true, trim: true, maxLength: 60 },
177
+ },
178
+ permissions: {
179
+ kind: 'textArray',
180
+ label: { en: 'Permissions', ar: 'الصلاحيات' },
181
+ rules: { required: true },
182
+ },
183
+ },
184
+ })