create-mithaq 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (357) hide show
  1. package/LICENSE +22 -0
  2. package/LICENSES/payload.txt +22 -0
  3. package/LICENSES/ponytail.txt +21 -0
  4. package/index.js +127 -0
  5. package/package.json +19 -4
  6. package/template/apps/asas-platform/components.json +25 -0
  7. package/template/apps/asas-platform/gitignore +3 -0
  8. package/template/apps/asas-platform/package.json +55 -0
  9. package/template/apps/asas-platform/public/favicon.svg +1 -0
  10. package/template/apps/asas-platform/src/api.ts +147 -0
  11. package/template/apps/asas-platform/src/auth-frame.tsx +150 -0
  12. package/template/apps/asas-platform/src/components/mithaq/cells.tsx +112 -0
  13. package/template/apps/asas-platform/src/components/mithaq/controls.tsx +650 -0
  14. package/template/apps/asas-platform/src/components/mithaq/field-shell.tsx +53 -0
  15. package/template/apps/asas-platform/src/components/mithaq/filter-operators.ts +108 -0
  16. package/template/apps/asas-platform/src/components/mithaq/form.tsx +1467 -0
  17. package/template/apps/asas-platform/src/components/mithaq/list-columns.tsx +108 -0
  18. package/template/apps/asas-platform/src/components/mithaq/list-filters.tsx +368 -0
  19. package/template/apps/asas-platform/src/components/mithaq/list-pagination.tsx +153 -0
  20. package/template/apps/asas-platform/src/components/mithaq/list-query.ts +432 -0
  21. package/template/apps/asas-platform/src/components/mithaq/list-selection.tsx +74 -0
  22. package/template/apps/asas-platform/src/components/mithaq/list.tsx +865 -0
  23. package/template/apps/asas-platform/src/components/mithaq/nav.tsx +871 -0
  24. package/template/apps/asas-platform/src/components/mithaq/nodes.tsx +1024 -0
  25. package/template/apps/asas-platform/src/components/mithaq/relationship-cell.tsx +127 -0
  26. package/template/apps/asas-platform/src/components/mithaq/relationship-control.tsx +400 -0
  27. package/template/apps/asas-platform/src/components/mithaq/rich-text-nodes.ts +6 -0
  28. package/template/apps/asas-platform/src/components/mithaq/rich-text.tsx +1083 -0
  29. package/template/apps/asas-platform/src/components/mithaq/upload.tsx +686 -0
  30. package/template/apps/asas-platform/src/components/ui/accordion.tsx +74 -0
  31. package/template/apps/asas-platform/src/components/ui/alert-dialog.tsx +185 -0
  32. package/template/apps/asas-platform/src/components/ui/avatar.tsx +106 -0
  33. package/template/apps/asas-platform/src/components/ui/badge.tsx +48 -0
  34. package/template/apps/asas-platform/src/components/ui/breadcrumb.tsx +124 -0
  35. package/template/apps/asas-platform/src/components/ui/button-group.tsx +87 -0
  36. package/template/apps/asas-platform/src/components/ui/button.tsx +55 -0
  37. package/template/apps/asas-platform/src/components/ui/calendar.tsx +223 -0
  38. package/template/apps/asas-platform/src/components/ui/card.tsx +102 -0
  39. package/template/apps/asas-platform/src/components/ui/chart.tsx +371 -0
  40. package/template/apps/asas-platform/src/components/ui/checkbox.tsx +28 -0
  41. package/template/apps/asas-platform/src/components/ui/collapsible.tsx +19 -0
  42. package/template/apps/asas-platform/src/components/ui/combobox.tsx +308 -0
  43. package/template/apps/asas-platform/src/components/ui/dialog.tsx +158 -0
  44. package/template/apps/asas-platform/src/components/ui/direction.tsx +6 -0
  45. package/template/apps/asas-platform/src/components/ui/directional-icon.tsx +20 -0
  46. package/template/apps/asas-platform/src/components/ui/drawer.tsx +216 -0
  47. package/template/apps/asas-platform/src/components/ui/dropdown-menu.tsx +267 -0
  48. package/template/apps/asas-platform/src/components/ui/field.tsx +236 -0
  49. package/template/apps/asas-platform/src/components/ui/input-group.tsx +155 -0
  50. package/template/apps/asas-platform/src/components/ui/input-otp.tsx +84 -0
  51. package/template/apps/asas-platform/src/components/ui/input.tsx +19 -0
  52. package/template/apps/asas-platform/src/components/ui/label.tsx +19 -0
  53. package/template/apps/asas-platform/src/components/ui/popover.tsx +90 -0
  54. package/template/apps/asas-platform/src/components/ui/radio-group.tsx +37 -0
  55. package/template/apps/asas-platform/src/components/ui/resizable.tsx +47 -0
  56. package/template/apps/asas-platform/src/components/ui/scroll-area.tsx +54 -0
  57. package/template/apps/asas-platform/src/components/ui/select.tsx +204 -0
  58. package/template/apps/asas-platform/src/components/ui/separator.tsx +24 -0
  59. package/template/apps/asas-platform/src/components/ui/sheet.tsx +139 -0
  60. package/template/apps/asas-platform/src/components/ui/sidebar.tsx +714 -0
  61. package/template/apps/asas-platform/src/components/ui/skeleton.tsx +13 -0
  62. package/template/apps/asas-platform/src/components/ui/slider.tsx +55 -0
  63. package/template/apps/asas-platform/src/components/ui/spinner.tsx +10 -0
  64. package/template/apps/asas-platform/src/components/ui/switch.tsx +29 -0
  65. package/template/apps/asas-platform/src/components/ui/table.tsx +115 -0
  66. package/template/apps/asas-platform/src/components/ui/tabs.tsx +79 -0
  67. package/template/apps/asas-platform/src/components/ui/textarea.tsx +17 -0
  68. package/template/apps/asas-platform/src/components/ui/toggle-group.tsx +89 -0
  69. package/template/apps/asas-platform/src/components/ui/toggle.tsx +44 -0
  70. package/template/apps/asas-platform/src/components/ui/tooltip.tsx +63 -0
  71. package/template/apps/asas-platform/src/copy.ts +1 -0
  72. package/template/apps/asas-platform/src/hooks/use-mobile.ts +19 -0
  73. package/template/apps/asas-platform/src/lib/utils.ts +1 -0
  74. package/template/apps/asas-platform/src/links.ts +38 -0
  75. package/template/apps/asas-platform/src/locale-switch.tsx +26 -0
  76. package/template/apps/asas-platform/src/redirect.ts +1 -0
  77. package/template/apps/asas-platform/src/request-headers.ts +11 -0
  78. package/template/apps/asas-platform/src/routeTree.gen.ts +181 -0
  79. package/template/apps/asas-platform/src/router.tsx +7 -0
  80. package/template/apps/asas-platform/src/routes/(screens)/platform/files.ts +4 -0
  81. package/template/apps/asas-platform/src/routes/(screens)/platform/screen.json +3609 -0
  82. package/template/apps/asas-platform/src/routes/(screens)/platform/types.ts +145 -0
  83. package/template/apps/asas-platform/src/routes/(screens)/platform.$.tsx +17 -0
  84. package/template/apps/asas-platform/src/routes/(screens)/platform.index.tsx +10 -0
  85. package/template/apps/asas-platform/src/routes/(screens)/platform.tsx +13 -0
  86. package/template/apps/asas-platform/src/routes/__root.tsx +95 -0
  87. package/template/apps/asas-platform/src/routes/api/$.ts +30 -0
  88. package/template/apps/asas-platform/src/routes/auth.sign-in.tsx +72 -0
  89. package/template/apps/asas-platform/src/routes/unauthorized.tsx +122 -0
  90. package/template/apps/asas-platform/src/screen-form.tsx +34 -0
  91. package/template/apps/asas-platform/src/screen-list.tsx +139 -0
  92. package/template/apps/asas-platform/src/screen-names.ts +10 -0
  93. package/template/apps/asas-platform/src/screens.tsx +868 -0
  94. package/template/apps/asas-platform/src/server.ts +20 -0
  95. package/template/apps/asas-platform/src/slide.ts +22 -0
  96. package/template/apps/asas-platform/src/start.ts +42 -0
  97. package/template/apps/asas-platform/src/styles.css +200 -0
  98. package/template/apps/asas-platform/src/theme.ts +2 -0
  99. package/template/apps/asas-platform/tsconfig.json +19 -0
  100. package/template/apps/asas-platform/vite.config.ts +26 -0
  101. package/template/apps/asas-tanstack/components.json +25 -0
  102. package/template/apps/asas-tanstack/gitignore +3 -0
  103. package/template/apps/asas-tanstack/package.json +56 -0
  104. package/template/apps/asas-tanstack/public/favicon.svg +1 -0
  105. package/template/apps/asas-tanstack/src/api.ts +147 -0
  106. package/template/apps/asas-tanstack/src/auth-frame.tsx +150 -0
  107. package/template/apps/asas-tanstack/src/components/mithaq/cells.tsx +112 -0
  108. package/template/apps/asas-tanstack/src/components/mithaq/controls.tsx +650 -0
  109. package/template/apps/asas-tanstack/src/components/mithaq/field-shell.tsx +53 -0
  110. package/template/apps/asas-tanstack/src/components/mithaq/filter-operators.ts +108 -0
  111. package/template/apps/asas-tanstack/src/components/mithaq/form.tsx +1467 -0
  112. package/template/apps/asas-tanstack/src/components/mithaq/list-columns.tsx +108 -0
  113. package/template/apps/asas-tanstack/src/components/mithaq/list-filters.tsx +368 -0
  114. package/template/apps/asas-tanstack/src/components/mithaq/list-pagination.tsx +153 -0
  115. package/template/apps/asas-tanstack/src/components/mithaq/list-query.ts +432 -0
  116. package/template/apps/asas-tanstack/src/components/mithaq/list-selection.tsx +74 -0
  117. package/template/apps/asas-tanstack/src/components/mithaq/list.tsx +865 -0
  118. package/template/apps/asas-tanstack/src/components/mithaq/nav.tsx +871 -0
  119. package/template/apps/asas-tanstack/src/components/mithaq/nodes.tsx +1024 -0
  120. package/template/apps/asas-tanstack/src/components/mithaq/relationship-cell.tsx +127 -0
  121. package/template/apps/asas-tanstack/src/components/mithaq/relationship-control.tsx +400 -0
  122. package/template/apps/asas-tanstack/src/components/mithaq/rich-text-nodes.ts +6 -0
  123. package/template/apps/asas-tanstack/src/components/mithaq/rich-text.tsx +1083 -0
  124. package/template/apps/asas-tanstack/src/components/mithaq/upload.tsx +686 -0
  125. package/template/apps/asas-tanstack/src/components/ui/accordion.tsx +10 -0
  126. package/template/apps/asas-tanstack/src/components/ui/alert-dialog.tsx +173 -0
  127. package/template/apps/asas-tanstack/src/components/ui/avatar.tsx +104 -0
  128. package/template/apps/asas-tanstack/src/components/ui/badge.tsx +48 -0
  129. package/template/apps/asas-tanstack/src/components/ui/breadcrumb.tsx +125 -0
  130. package/template/apps/asas-tanstack/src/components/ui/button-group.tsx +87 -0
  131. package/template/apps/asas-tanstack/src/components/ui/button.tsx +56 -0
  132. package/template/apps/asas-tanstack/src/components/ui/calendar.tsx +223 -0
  133. package/template/apps/asas-tanstack/src/components/ui/card.tsx +94 -0
  134. package/template/apps/asas-tanstack/src/components/ui/chart.tsx +369 -0
  135. package/template/apps/asas-tanstack/src/components/ui/checkbox.tsx +27 -0
  136. package/template/apps/asas-tanstack/src/components/ui/collapsible.tsx +19 -0
  137. package/template/apps/asas-tanstack/src/components/ui/combobox.tsx +300 -0
  138. package/template/apps/asas-tanstack/src/components/ui/dialog.tsx +149 -0
  139. package/template/apps/asas-tanstack/src/components/ui/direction.tsx +5 -0
  140. package/template/apps/asas-tanstack/src/components/ui/directional-icon.tsx +20 -0
  141. package/template/apps/asas-tanstack/src/components/ui/drawer.tsx +215 -0
  142. package/template/apps/asas-tanstack/src/components/ui/dropdown-menu.tsx +267 -0
  143. package/template/apps/asas-tanstack/src/components/ui/field.tsx +225 -0
  144. package/template/apps/asas-tanstack/src/components/ui/input-group.tsx +147 -0
  145. package/template/apps/asas-tanstack/src/components/ui/input-otp.tsx +20 -0
  146. package/template/apps/asas-tanstack/src/components/ui/input.tsx +20 -0
  147. package/template/apps/asas-tanstack/src/components/ui/label.tsx +18 -0
  148. package/template/apps/asas-tanstack/src/components/ui/popover.tsx +50 -0
  149. package/template/apps/asas-tanstack/src/components/ui/radio-group.tsx +10 -0
  150. package/template/apps/asas-tanstack/src/components/ui/resizable.tsx +47 -0
  151. package/template/apps/asas-tanstack/src/components/ui/scroll-area.tsx +52 -0
  152. package/template/apps/asas-tanstack/src/components/ui/select.tsx +190 -0
  153. package/template/apps/asas-tanstack/src/components/ui/separator.tsx +23 -0
  154. package/template/apps/asas-tanstack/src/components/ui/sheet.tsx +127 -0
  155. package/template/apps/asas-tanstack/src/components/ui/sidebar.tsx +716 -0
  156. package/template/apps/asas-tanstack/src/components/ui/skeleton.tsx +13 -0
  157. package/template/apps/asas-tanstack/src/components/ui/slider.tsx +13 -0
  158. package/template/apps/asas-tanstack/src/components/ui/spinner.tsx +11 -0
  159. package/template/apps/asas-tanstack/src/components/ui/switch.tsx +8 -0
  160. package/template/apps/asas-tanstack/src/components/ui/table.tsx +119 -0
  161. package/template/apps/asas-tanstack/src/components/ui/tabs.tsx +80 -0
  162. package/template/apps/asas-tanstack/src/components/ui/textarea.tsx +18 -0
  163. package/template/apps/asas-tanstack/src/components/ui/toggle-group.tsx +8 -0
  164. package/template/apps/asas-tanstack/src/components/ui/toggle.tsx +44 -0
  165. package/template/apps/asas-tanstack/src/components/ui/tooltip.tsx +68 -0
  166. package/template/apps/asas-tanstack/src/copy.ts +870 -0
  167. package/template/apps/asas-tanstack/src/hooks/use-mobile.ts +19 -0
  168. package/template/apps/asas-tanstack/src/lib/utils.ts +6 -0
  169. package/template/apps/asas-tanstack/src/links.ts +38 -0
  170. package/template/apps/asas-tanstack/src/locale-switch.tsx +26 -0
  171. package/template/apps/asas-tanstack/src/quiet-abort.ts +25 -0
  172. package/template/apps/asas-tanstack/src/redirect.ts +20 -0
  173. package/template/apps/asas-tanstack/src/request-headers.ts +11 -0
  174. package/template/apps/asas-tanstack/src/routeTree.gen.ts +382 -0
  175. package/template/apps/asas-tanstack/src/router.tsx +7 -0
  176. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +4 -0
  177. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +3883 -0
  178. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +158 -0
  179. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.$.tsx +17 -0
  180. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.index.tsx +10 -0
  181. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.tsx +13 -0
  182. package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +4 -0
  183. package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +1800 -0
  184. package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +92 -0
  185. package/template/apps/asas-tanstack/src/routes/(screens)/portal.$.tsx +17 -0
  186. package/template/apps/asas-tanstack/src/routes/(screens)/portal.index.tsx +10 -0
  187. package/template/apps/asas-tanstack/src/routes/(screens)/portal.tsx +13 -0
  188. package/template/apps/asas-tanstack/src/routes/__root.tsx +99 -0
  189. package/template/apps/asas-tanstack/src/routes/api/$.ts +11 -0
  190. package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +101 -0
  191. package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +94 -0
  192. package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +39 -0
  193. package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +109 -0
  194. package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +86 -0
  195. package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +122 -0
  196. package/template/apps/asas-tanstack/src/routes/index.tsx +142 -0
  197. package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +122 -0
  198. package/template/apps/asas-tanstack/src/screen-form.tsx +34 -0
  199. package/template/apps/asas-tanstack/src/screen-list.tsx +139 -0
  200. package/template/apps/asas-tanstack/src/screen-names.ts +10 -0
  201. package/template/apps/asas-tanstack/src/screens.tsx +868 -0
  202. package/template/apps/asas-tanstack/src/server.ts +16 -0
  203. package/template/apps/asas-tanstack/src/slide.ts +22 -0
  204. package/template/apps/asas-tanstack/src/start.ts +57 -0
  205. package/template/apps/asas-tanstack/src/styles.css +159 -0
  206. package/template/apps/asas-tanstack/src/theme.ts +5 -0
  207. package/template/apps/asas-tanstack/tsconfig.json +19 -0
  208. package/template/apps/asas-tanstack/vite.config.ts +28 -0
  209. package/template/package.json +16 -0
  210. package/template/templates/asas/.env.example +16 -0
  211. package/template/templates/asas/components.json +22 -0
  212. package/template/templates/asas/mithaq.hints.json +25 -0
  213. package/template/templates/asas/package.json +38 -0
  214. package/template/templates/asas/src/asas.ts +93 -0
  215. package/template/templates/asas/src/audit-log/audit-log.ts +187 -0
  216. package/template/templates/asas/src/audit-log/sink.ts +89 -0
  217. package/template/templates/asas/src/audit-log/ui/activity.tsx +243 -0
  218. package/template/templates/asas/src/courses/courses.ts +612 -0
  219. package/template/templates/asas/src/courses/ui/archive-button.tsx +25 -0
  220. package/template/templates/asas/src/courses/ui/fee-cell.tsx +19 -0
  221. package/template/templates/asas/src/courses/ui/no-courses-on-offer.tsx +11 -0
  222. package/template/templates/asas/src/courses/ui/no-courses.tsx +13 -0
  223. package/template/templates/asas/src/courses/ui/on-offer.ts +6 -0
  224. package/template/templates/asas/src/courses/ui/status-cell.tsx +31 -0
  225. package/template/templates/asas/src/enrollments/enrollments.ts +137 -0
  226. package/template/templates/asas/src/enrollments/ui/session-cell.tsx +65 -0
  227. package/template/templates/asas/src/kayan/auth/user/account.access.ts +8 -0
  228. package/template/templates/asas/src/kayan/auth/user/account.ts +31 -0
  229. package/template/templates/asas/src/kayan/auth/user/index.ts +18 -0
  230. package/template/templates/asas/src/kayan/auth/user/invitation.access.ts +4 -0
  231. package/template/templates/asas/src/kayan/auth/user/invitation.ts +27 -0
  232. package/template/templates/asas/src/kayan/auth/user/member.access.ts +10 -0
  233. package/template/templates/asas/src/kayan/auth/user/member.screens.ts +37 -0
  234. package/template/templates/asas/src/kayan/auth/user/member.ts +25 -0
  235. package/template/templates/asas/src/kayan/auth/user/organization.access.ts +20 -0
  236. package/template/templates/asas/src/kayan/auth/user/organization.screens.ts +37 -0
  237. package/template/templates/asas/src/kayan/auth/user/organization.ts +24 -0
  238. package/template/templates/asas/src/kayan/auth/user/organizationRole.access.ts +8 -0
  239. package/template/templates/asas/src/kayan/auth/user/organizationRole.ts +25 -0
  240. package/template/templates/asas/src/kayan/auth/user/session.access.ts +8 -0
  241. package/template/templates/asas/src/kayan/auth/user/session.ts +28 -0
  242. package/template/templates/asas/src/kayan/auth/user/user.access.ts +29 -0
  243. package/template/templates/asas/src/kayan/auth/user/user.screens.ts +45 -0
  244. package/template/templates/asas/src/kayan/auth/user/user.ts +31 -0
  245. package/template/templates/asas/src/kayan/auth/user/verification.access.ts +8 -0
  246. package/template/templates/asas/src/kayan/auth/user/verification.ts +23 -0
  247. package/template/templates/asas/src/local-users/local-users.ts +143 -0
  248. package/template/templates/asas/src/media/media.ts +137 -0
  249. package/template/templates/asas/src/media/ui/preview.tsx +143 -0
  250. package/template/templates/asas/src/member-roles/member-roles.ts +207 -0
  251. package/template/templates/asas/src/messages/account.json +106 -0
  252. package/template/templates/asas/src/messages/auditLog.json +186 -0
  253. package/template/templates/asas/src/messages/courses.json +270 -0
  254. package/template/templates/asas/src/messages/enrollments.json +42 -0
  255. package/template/templates/asas/src/messages/invitation.json +66 -0
  256. package/template/templates/asas/src/messages/localUsers.json +42 -0
  257. package/template/templates/asas/src/messages/media.json +66 -0
  258. package/template/templates/asas/src/messages/member.json +42 -0
  259. package/template/templates/asas/src/messages/memberRoles.json +42 -0
  260. package/template/templates/asas/src/messages/mithaq.json +170 -0
  261. package/template/templates/asas/src/messages/organization.json +50 -0
  262. package/template/templates/asas/src/messages/organizationRole.json +50 -0
  263. package/template/templates/asas/src/messages/roles.json +58 -0
  264. package/template/templates/asas/src/messages/session.json +82 -0
  265. package/template/templates/asas/src/messages/sessions.json +106 -0
  266. package/template/templates/asas/src/messages/settings.json +66 -0
  267. package/template/templates/asas/src/messages/tags.json +38 -0
  268. package/template/templates/asas/src/messages/user.json +90 -0
  269. package/template/templates/asas/src/messages/verification.json +50 -0
  270. package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/migration.sql +177 -0
  271. package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/snapshot.json +2169 -0
  272. package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/migration.sql +1 -0
  273. package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/snapshot.json +2182 -0
  274. package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/migration.sql +20 -0
  275. package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/snapshot.json +2409 -0
  276. package/template/templates/asas/src/migrations/20260922082945_military_ironclad/migration.sql +15 -0
  277. package/template/templates/asas/src/migrations/20260922082945_military_ironclad/snapshot.json +2627 -0
  278. package/template/templates/asas/src/migrations/20260922112630_bitter_chat/migration.sql +5 -0
  279. package/template/templates/asas/src/migrations/20260922112630_bitter_chat/snapshot.json +2691 -0
  280. package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/migration.sql +8 -0
  281. package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/snapshot.json +2795 -0
  282. package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/migration.sql +3 -0
  283. package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/snapshot.json +2834 -0
  284. package/template/templates/asas/src/migrations/20260927212525_bent_prowler/migration.sql +9 -0
  285. package/template/templates/asas/src/migrations/20260927212525_bent_prowler/snapshot.json +2870 -0
  286. package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/migration.sql +1 -0
  287. package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/snapshot.json +2881 -0
  288. package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/migration.sql +15 -0
  289. package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/snapshot.json +3073 -0
  290. package/template/templates/asas/src/migrations/20260929024407_grey_morlun/migration.sql +21 -0
  291. package/template/templates/asas/src/migrations/20260929024407_grey_morlun/snapshot.json +3098 -0
  292. package/template/templates/asas/src/migrations/20260929130530_bored_jackal/migration.sql +21 -0
  293. package/template/templates/asas/src/migrations/20260929130530_bored_jackal/snapshot.json +3124 -0
  294. package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/migration.sql +32 -0
  295. package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/snapshot.json +3324 -0
  296. package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/migration.sql +2 -0
  297. package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/snapshot.json +3350 -0
  298. package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/migration.sql +21 -0
  299. package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/snapshot.json +3635 -0
  300. package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/migration.sql +8 -0
  301. package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/snapshot.json +3635 -0
  302. package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/migration.sql +18 -0
  303. package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/snapshot.json +3635 -0
  304. package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/migration.sql +18 -0
  305. package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/snapshot.json +3635 -0
  306. package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/migration.sql +11 -0
  307. package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/snapshot.json +3667 -0
  308. package/template/templates/asas/src/migrations/20261012000752_organization_ownership/migration.sql +48 -0
  309. package/template/templates/asas/src/migrations/20261012000752_organization_ownership/snapshot.json +3891 -0
  310. package/template/templates/asas/src/migrations/drizzle.config.ts +11 -0
  311. package/template/templates/asas/src/migrations/schema.ts +29 -0
  312. package/template/templates/asas/src/mithaq.ts +406 -0
  313. package/template/templates/asas/src/pages.ts +74 -0
  314. package/template/templates/asas/src/platform-http.ts +96 -0
  315. package/template/templates/asas/src/rich-text-nodes.ts +11 -0
  316. package/template/templates/asas/src/roles/grants.ts +47 -0
  317. package/template/templates/asas/src/roles/roles.ts +184 -0
  318. package/template/templates/asas/src/roles/ui/role-form.tsx +226 -0
  319. package/template/templates/asas/src/roles/ui/roles.tsx +166 -0
  320. package/template/templates/asas/src/roles.ts +16 -0
  321. package/template/templates/asas/src/seed.ts +325 -0
  322. package/template/templates/asas/src/sessions/sessions.ts +155 -0
  323. package/template/templates/asas/src/settings/settings.ts +69 -0
  324. package/template/templates/asas/src/statements.ts +11 -0
  325. package/template/templates/asas/src/steps/expected-updated-at.ts +41 -0
  326. package/template/templates/asas/src/storage/defaults.ts +156 -0
  327. package/template/templates/asas/src/storage/index.ts +95 -0
  328. package/template/templates/asas/src/storage/local.ts +143 -0
  329. package/template/templates/asas/src/storage/r2.ts +5 -0
  330. package/template/templates/asas/src/storage/s3.ts +91 -0
  331. package/template/templates/asas/src/storage/uploadthing.ts +59 -0
  332. package/template/templates/asas/src/tags/tags.ts +80 -0
  333. package/template/templates/asas/src/tenant-access.ts +221 -0
  334. package/template/templates/asas/src/tenant-roles.ts +262 -0
  335. package/template/templates/asas/src/ui/app-shell.tsx +260 -0
  336. package/template/templates/asas/src/ui/kpi-card.tsx +25 -0
  337. package/template/templates/asas/src/ui/record-page.tsx +50 -0
  338. package/template/templates/asas/src/ui/rich-text-nodes.tsx +15 -0
  339. package/template/templates/asas/src/ui/screen-nav.tsx +28 -0
  340. package/template/templates/asas/src/ui/session-labels.tsx +36 -0
  341. package/template/templates/asas/src/ui/table-page.tsx +9 -0
  342. package/template/templates/asas/src/ui/top-shell.tsx +60 -0
  343. package/template/templates/asas/src/ui-dashboard/dashboard.tsx +294 -0
  344. package/template/templates/asas/src/ui-dashboard/members.tsx +630 -0
  345. package/template/templates/asas/src/ui-dashboard/theme-provider.tsx +10 -0
  346. package/template/templates/asas/src/ui-dashboard/versions.tsx +432 -0
  347. package/template/templates/asas/src/ui-platform/home.tsx +63 -0
  348. package/template/templates/asas/src/ui-platform/organization-actions.tsx +114 -0
  349. package/template/templates/asas/src/ui-platform/record-page.tsx +30 -0
  350. package/template/templates/asas/src/ui-platform/role-cells.tsx +52 -0
  351. package/template/templates/asas/src/ui-platform/user-actions.tsx +251 -0
  352. package/template/templates/asas/src/ui-portal/home.tsx +77 -0
  353. package/template/templates/asas/src/ui-portal/page.tsx +11 -0
  354. package/template/templates/auth/package.json +14 -0
  355. package/template/templates/auth/src/index.ts +690 -0
  356. package/template/templates/auth/src/platform-roles.ts +87 -0
  357. package/README.md +0 -3
@@ -0,0 +1,630 @@
1
+ // The dashboard's one page that belongs to no kayan: who works in the viewer's organization and the roles each holds, and the one place a member's roles are changed.
2
+ // The `loader` reads as the viewer: members from the provider's `list-members` (the active organization only), assignments from `memberRoles`, and the roles the sheet offers: the fixed ones from their code and the organization's own from `roles`, never a platform role.
3
+ // Invitations, cancellations and removals go through the provider's own routes, which the auth template gates by `invitation.create`, `invitation.cancel` and `member.delete`.
4
+ import { useEffect, useId, useRef, useState } from 'react'
5
+ import { Lock } from 'lucide-react'
6
+ import { holds, type LoaderTools, type PageProps } from 'mithaq/admin'
7
+ import { useAccess, useClient, useLocale, useUser } from 'mithaq/admin/react'
8
+ import type { asas } from '../asas.ts'
9
+ import { authArm } from '@/api.ts'
10
+ import { refusalKey } from '@/copy.ts'
11
+ import { Badge } from '@/components/ui/badge'
12
+ import { Button } from '@/components/ui/button'
13
+ import { Checkbox } from '@/components/ui/checkbox'
14
+ import { Input } from '@/components/ui/input'
15
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
16
+ import { formatDate } from '@/components/mithaq/controls.tsx'
17
+ import { FieldShell, describedBy } from '@/components/mithaq/field-shell.tsx'
18
+ import { grantable, isOwner, managesRoles, OWNER_ROLE } from '../roles/grants.ts'
19
+ import { tenantRoles } from '../tenant-roles.ts'
20
+ import { AppShell } from '../ui/app-shell.tsx'
21
+
22
+ /**
23
+ * One member as the page draws it: each role assignment by its row, and whether they own the organization, which holds `administrator` whatever the rows say.
24
+ */
25
+ export type Member = { userId: string; name: string; email: string; joined: string; owner: boolean; roles: { id: string; role: string }[] }
26
+ /**
27
+ * One role the sheet offers: its label in every locale, whether this project ships it, and its keys, which a fixed role's
28
+ * code always says and a custom role's row says where the viewer may read them.
29
+ */
30
+ export type Role = { name: string; label?: Readonly<Record<string, string>>; fixed: boolean; permissions?: readonly string[] }
31
+ export type Invite = { id: string; email: string; expiresAt: string }
32
+ /** `invites` is there only for a viewer who may invite, and lists what is pending. */
33
+ export type MembersData = { members: Member[]; roles: Role[]; invites?: Invite[] }
34
+
35
+ /**
36
+ * Whether a governance role lets its member invite: `owner` and `admin` hold `invitation.create`, and `member` holds nothing but reading the role list,
37
+ * so the provider refuses a `member` the invitation whatever keys it holds.
38
+ */
39
+ const invitesAsGovernance = (role: unknown) => typeof role === 'string' && role.split(',').some((name) => name === 'owner' || name === 'admin')
40
+
41
+ /** 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. */
42
+ const isoOf = (value: string): string => {
43
+ const when = new Date(value)
44
+ return Number.isNaN(when.getTime()) ? '' : when.toISOString()
45
+ }
46
+
47
+ export const loader = async ({ access, client, request, maxLimit }: LoaderTools): Promise<MembersData> => {
48
+ const [listed, assigned, roles, invited] = await Promise.all([
49
+ request('/api/auth/user/organization/list-members?limit=100').then(async (response) => {
50
+ if (!response.ok) throw new Error(`list-members answered ${response.status}`)
51
+ return (await response.json()) as { members: { userId: string; role: string; createdAt: string; user: { name: string; email: string } }[] }
52
+ }),
53
+ client({ kayan: 'memberRoles', operation: 'find', input: { limit: maxLimit, depth: 0 } }) as Promise<{ docs: { id: string; user: string; role: string }[] }>,
54
+ // Asked only of a viewer the report lets read roles, so the page still
55
+ // draws for one who may read the assignments and not the roles.
56
+ holds(access, 'roles.find')
57
+ ? (client({ kayan: 'roles', operation: 'find', input: { limit: maxLimit, depth: 0, sort: 'name' } }) as Promise<{ docs: { name: string; label?: Record<string, string> | null; permissions?: unknown }[] }>)
58
+ : { docs: [] },
59
+ // Asked only of a viewer who may invite: one holding the key, whose
60
+ // governance role the provider lets invite.
61
+ holds(access, 'invitation.create')
62
+ ? request('/api/auth/user/organization/get-active-member-role')
63
+ .then(async (response) => response.ok && invitesAsGovernance(((await response.json()) as { role?: unknown }).role))
64
+ .then((invites) =>
65
+ invites
66
+ ? request('/api/auth/user/organization/list-invitations').then(async (response) => {
67
+ if (!response.ok) throw new Error(`list-invitations answered ${response.status}`)
68
+ return (await response.json()) as { id: string; email: string; status: string; expiresAt: string }[]
69
+ })
70
+ : undefined,
71
+ )
72
+ : undefined,
73
+ ])
74
+ return {
75
+ members: listed.members.map((member) => ({
76
+ userId: member.userId,
77
+ name: member.user.name,
78
+ email: member.user.email,
79
+ joined: isoOf(member.createdAt),
80
+ owner: isOwner(member.role),
81
+ roles: assigned.docs.filter((row) => row.user === member.userId).map((row) => ({ id: String(row.id), role: row.role })),
82
+ })),
83
+ roles: [
84
+ ...Object.entries(tenantRoles).map(([name, role]): Role => ({ name, label: role.label, fixed: true, permissions: role.permissions === '*' ? ['*'] : role.permissions })),
85
+ ...roles.docs.map((row): Role => ({
86
+ name: row.name,
87
+ ...(row.label && { label: row.label }),
88
+ fixed: false,
89
+ ...(Array.isArray(row.permissions) && { permissions: row.permissions as string[] }),
90
+ })),
91
+ ],
92
+ // Pending and not expired: the provider accepts neither of the others.
93
+ ...(invited && { invites: invited.filter((one) => one.status === 'pending' && new Date(one.expiresAt).getTime() > Date.now()).map((one) => ({ id: one.id, email: one.email, expiresAt: isoOf(one.expiresAt) })) }),
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Whether this is Vite's development server, which has no mailer, read so the
99
+ * registry's own typecheck, which knows no Vite, reads it too.
100
+ */
101
+ const development = (import.meta as { env?: { DEV?: boolean } }).env?.DEV === true
102
+
103
+ const sameRoles = (a: ReadonlySet<string>, b: ReadonlySet<string>) => a.size === b.size && [...a].every((name) => b.has(name))
104
+
105
+ export function Members({ loaderData }: PageProps<MembersData>) {
106
+ const { locale, t } = useLocale()
107
+ const client = useClient<typeof asas.router>()
108
+ const access = useAccess()
109
+ const me = useUser().email
110
+ const assignments = access.collections?.memberRoles
111
+ const roles = loaderData?.roles ?? []
112
+ const [members, setMembers] = useState(loaderData?.members ?? [])
113
+ const [editing, setEditing] = useState<Member>()
114
+ const [chosen, setChosen] = useState<ReadonlySet<string>>(new Set())
115
+ const [busy, setBusy] = useState(false)
116
+ const [refusal, setRefusal] = useState<string>()
117
+ /** Whether the sheet asks to remove the member it shows, the second of the two presses a removal takes. */
118
+ const [confirming, setConfirming] = useState(false)
119
+ const sheet = useRef<HTMLDialogElement>(null)
120
+ const title = useId()
121
+ // A member's roles are changed where the report lets this actor add or
122
+ // remove an assignment; the guard on each write still decides every role.
123
+ const edits = assignments?.create === true || assignments?.delete === true
124
+ // A member is removed, and an invitation cancelled, by whoever holds the
125
+ // key the auth template gates the provider's route by; the provider's own
126
+ // governance role still decides, and its refusal is worded below.
127
+ const removes = holds(access, 'member.delete')
128
+ const cancels = holds(access, 'invitation.cancel')
129
+ const opens = edits || removes
130
+ const invites = loaderData?.invites !== undefined
131
+ const [pending, setPending] = useState(loaderData?.invites ?? [])
132
+ const [pendingRefusal, setPendingRefusal] = useState<string>()
133
+ const [copied, setCopied] = useState<string>()
134
+ const [inviting, setInviting] = useState(false)
135
+ const [email, setEmail] = useState('')
136
+ /** Whether the email box has been left or submitted, after which it says what is wrong with it. */
137
+ const [emailLeft, setEmailLeft] = useState(false)
138
+ const [inviteRefusal, setInviteRefusal] = useState<string>()
139
+ const inviteSheet = useRef<HTMLDialogElement>(null)
140
+ const emailBox = useRef<HTMLInputElement>(null)
141
+ const inviteTitle = useId()
142
+ const emailId = useId()
143
+ /**
144
+ * This app's sentence for a refusal of the provider, by its code, or for one of the template's own, whose code is a copy key, or `fallback`.
145
+ */
146
+ const refused = (answered: { status: number; body: Record<string, unknown> | null }, fallback: string | undefined) => {
147
+ const code = answered.body?.code
148
+ const key = refusalKey(answered.status, code) ?? (typeof code === 'string' && code.startsWith('error.') ? code : undefined)
149
+ return (key && t(key)) || fallback
150
+ }
151
+ /**
152
+ * What is wrong with the address typed, said once the box is left and on
153
+ * every submit: nothing typed, or what the browser's own `type="email"`
154
+ * refuses, so no second rule for an address is written here.
155
+ */
156
+ const emailError = !emailLeft
157
+ ? undefined
158
+ : !email.trim()
159
+ ? t('auth.required', 'This field is required.')
160
+ : emailBox.current?.validity.typeMismatch
161
+ ? t('auth.invalidEmail', 'Invalid email')
162
+ : undefined
163
+ /**
164
+ * One invitation, as a member: the role the provider gives on accepting, so
165
+ * the roles of this page are given after, here, like any member's.
166
+ */
167
+ const invite = async () => {
168
+ setEmailLeft(true)
169
+ if (!email.trim() || emailBox.current?.validity.typeMismatch) return emailBox.current?.focus()
170
+ setInviting(true)
171
+ setInviteRefusal(undefined)
172
+ const answered = await authArm('organization/invite-member', { email: email.trim(), role: 'member' }).finally(() => setInviting(false))
173
+ // The provider's sentence is English alone, so the refusal is said in the viewer's locale, by its code.
174
+ if (!answered.ok) return setInviteRefusal(refused(answered, t('dashboard.inviteRefused', 'The server refused that invitation.')))
175
+ const made = answered.body as { id: string; email: string; expiresAt: string }
176
+ setPending((all) => [...all.filter((one) => one.id !== made.id), { id: made.id, email: made.email, expiresAt: isoOf(made.expiresAt) }])
177
+ inviteSheet.current?.close()
178
+ }
179
+ /** One pending invitation withdrawn, through the provider's own route. */
180
+ const cancel = async (one: Invite) => {
181
+ setPendingRefusal(undefined)
182
+ const answered = await authArm('organization/cancel-invitation', { invitationId: one.id })
183
+ if (!answered.ok) return setPendingRefusal(refused(answered, t('dashboard.inviteCancelRefused', 'The server refused to cancel that invitation.')))
184
+ setPending((all) => all.filter((other) => other.id !== one.id))
185
+ }
186
+ /** The link the invitee opens, which development hands over by hand since no mail goes out. */
187
+ const copyLink = async (one: Invite) => {
188
+ await navigator.clipboard.writeText(new URL(`/auth/accept-invitation?id=${encodeURIComponent(one.id)}`, window.location.origin).href)
189
+ setCopied(one.id)
190
+ }
191
+
192
+ const open = (member: Member) => {
193
+ setEditing(member)
194
+ setChosen(new Set(member.roles.map((one) => one.role)))
195
+ setRefusal(undefined)
196
+ setConfirming(false)
197
+ sheet.current?.showModal()
198
+ }
199
+ // A link to one member's roles, `?member=<id>`, opens the sheet on them, as a role's delete links each member who still holds it.
200
+ useEffect(() => {
201
+ const asked = new URLSearchParams(window.location.search).get('member')
202
+ const member = members.find((one) => one.userId === asked)
203
+ if (member && opens) open(member)
204
+ }, [])
205
+ const labelOf = (name: string) => roles.find((role) => role.name === name)?.label?.[locale] ?? name
206
+ const changed = editing !== undefined && !sameRoles(chosen, new Set(editing.roles.map((one) => one.role)))
207
+
208
+ /**
209
+ * Writes the ticked roles as `memberRoles` rows through that kayan's own operations, so its guards judge each one: removals first, then additions, stopping at the first refusal.
210
+ * Whatever landed before it stays shown, and only the roles the sheet lists are touched.
211
+ */
212
+ const save = async () => {
213
+ if (!editing) return
214
+ setBusy(true)
215
+ setRefusal(undefined)
216
+ let held = editing.roles
217
+ try {
218
+ for (const one of held.filter((row) => roles.some((role) => role.name === row.role) && !chosen.has(row.role))) {
219
+ await client({ kayan: 'memberRoles', operation: 'delete', input: { id: one.id } })
220
+ held = held.filter((row) => row !== one)
221
+ }
222
+ for (const role of [...chosen].filter((name) => !held.some((row) => row.role === name))) {
223
+ const row = await client({ kayan: 'memberRoles', operation: 'create', input: { user: editing.userId, role } })
224
+ held = [...held, { id: String(row.id), role }]
225
+ }
226
+ sheet.current?.close()
227
+ } catch (thrown) {
228
+ const key = (thrown as { data?: { key?: unknown } }).data?.key
229
+ setRefusal((typeof key === 'string' && t(key)) || t('dashboard.membersRefused', 'The server refused that change.'))
230
+ } finally {
231
+ setMembers((all) => all.map((one) => (one.userId === editing.userId ? { ...one, roles: held } : one)))
232
+ setEditing((one) => one && { ...one, roles: held })
233
+ setBusy(false)
234
+ }
235
+ }
236
+ /**
237
+ * Takes the member out: each role they hold first, through the assignment kayan's own delete and so under its guards, then the membership through the provider's route, which refuses a member still holding a role.
238
+ * A refused role stops it, and the sheet shows what was taken before it.
239
+ */
240
+ const remove = async () => {
241
+ if (!editing) return
242
+ /**
243
+ * What a removal that stopped leaves the member holding, drawn in the
244
+ * table, the sheet and its boxes alike, so the boxes tick only what is
245
+ * still held and Save writes nothing taken back.
246
+ */
247
+ const kept = (held: Member['roles']) => {
248
+ setMembers((all) => all.map((one) => (one.userId === editing.userId ? { ...one, roles: held } : one)))
249
+ setEditing((one) => one && { ...one, roles: held })
250
+ setChosen(new Set(held.map((row) => row.role)))
251
+ }
252
+ setBusy(true)
253
+ setRefusal(undefined)
254
+ setConfirming(false)
255
+ let held = editing.roles
256
+ try {
257
+ for (const one of held) {
258
+ await client({ kayan: 'memberRoles', operation: 'delete', input: { id: one.id } })
259
+ held = held.filter((row) => row !== one)
260
+ }
261
+ } catch (thrown) {
262
+ const key = (thrown as { data?: { key?: unknown } }).data?.key
263
+ kept(held)
264
+ setRefusal((typeof key === 'string' && t(key)) || t('dashboard.removeRefused', 'The server refused to remove that member.'))
265
+ return setBusy(false)
266
+ }
267
+ const answered = await authArm('organization/remove-member', { memberIdOrEmail: editing.email }).finally(() => setBusy(false))
268
+ if (!answered.ok) {
269
+ kept(held)
270
+ return setRefusal(refused(answered, t('dashboard.removeRefused', 'The server refused to remove that member.')))
271
+ }
272
+ setMembers((all) => all.filter((one) => one.userId !== editing.userId))
273
+ sheet.current?.close()
274
+ }
275
+
276
+ const lockOf = (role: Role, member: Member | undefined) => {
277
+ const assigned = member?.roles.some((row) => row.role === role.name) === true
278
+ // The role that lets the viewer manage roles is not taken from themselves, which the server refuses too.
279
+ const own = assigned && member?.email === me && role.permissions !== undefined && managesRoles(role.permissions)
280
+ // The owner holds administrator whatever the rows say, so the box is
281
+ // ticked and locked, and the server refuses removing the row anyway.
282
+ const owned = role.name === OWNER_ROLE && member?.owner === true
283
+ // Unticking removes an assignment and ticking adds one, so each needs its
284
+ // own operation, and either needs a role the viewer may hand over.
285
+ const locked = own || owned || !grantable(access, role.permissions) || (assigned ? assignments?.delete !== true : assignments?.create !== true)
286
+ return { assigned, own, owned, locked }
287
+ }
288
+ /**
289
+ * Whether the viewer may remove the member shown: one who may remove members, never themselves, and only where every role the member holds is one the sheet lets them take,
290
+ * the rule the server's guards keep on each of those deletes.
291
+ */
292
+ const removable =
293
+ removes && editing !== undefined && editing.email !== me && editing.roles.every((row) => {
294
+ const role = roles.find((one) => one.name === row.role)
295
+ return role !== undefined && !lockOf(role, editing).locked
296
+ })
297
+
298
+ const choice = (role: Role) => {
299
+ const { own, owned, locked } = lockOf(role, editing)
300
+ const disabled = busy || locked
301
+ const keys = role.permissions
302
+ return (
303
+ // Only the box and the name are dimmed when the box is locked: the
304
+ // sentence saying why stays at the muted text's own contrast.
305
+ <label key={role.name} data-role={role.name} data-disabled={disabled} className="hover:bg-muted/70 flex items-start gap-3 rounded-md px-2 py-2">
306
+ <Checkbox
307
+ className="mt-0.5"
308
+ checked={owned || chosen.has(role.name)}
309
+ disabled={disabled}
310
+ onCheckedChange={(on) => setChosen((now) => (on ? new Set([...now, role.name]) : new Set([...now].filter((name) => name !== role.name))))}
311
+ />
312
+ <span className="grid gap-0.5">
313
+ <span className={`flex items-center gap-1.5 font-medium${disabled ? ' opacity-50' : ''}`}>
314
+ {role.fixed ? <Lock className="text-muted-foreground size-3.5" aria-hidden="true" /> : null}
315
+ {role.label?.[locale] ?? role.name}
316
+ </span>
317
+ {owned ? (
318
+ <span data-owner="" className="text-muted-foreground text-xs">{t('dashboard.membersOwnerRole', 'The owner of the organization always holds this role.')}</span>
319
+ ) : own ? (
320
+ <span data-own="" className="text-muted-foreground text-xs">{t('dashboard.membersOwnRole', 'This role lets you manage roles, so only another member who may manage roles takes it from you.')}</span>
321
+ ) : null}
322
+ {keys ? (
323
+ <span className="text-muted-foreground text-xs">
324
+ {keys.includes('*')
325
+ ? t('dashboard.everyPermission', 'Every permission')
326
+ : (t('dashboard.membersPermissions', '{count} permissions') ?? '').replace('{count}', String(keys.length))}
327
+ </span>
328
+ ) : null}
329
+ </span>
330
+ </label>
331
+ )
332
+ }
333
+ const group = (fixed: boolean, heading: string | undefined) => {
334
+ const listed = roles.filter((role) => role.fixed === fixed)
335
+ return listed.length ? (
336
+ <section className="grid gap-1">
337
+ <h3 className="text-muted-foreground px-2 text-xs font-medium">{heading}</h3>
338
+ {listed.map(choice)}
339
+ </section>
340
+ ) : null
341
+ }
342
+ // Development has no mailer, so the dialog and the pending list say how an
343
+ // invitation reaches anyone there.
344
+ const noMailer = development ? (
345
+ <p data-no-mailer="" className="text-muted-foreground rounded-md border px-3 py-2">
346
+ {t('dashboard.inviteNoMailer', 'No mailer is configured, so no email is sent. In development the link is written to the server log, and each pending invitation has a link to copy.')}
347
+ </p>
348
+ ) : null
349
+
350
+ return (
351
+ <AppShell
352
+ regions={{
353
+ header: (
354
+ <div className="flex w-full min-w-0 items-center justify-between gap-2">
355
+ <h1>{t('dashboard.members', 'Members')}</h1>
356
+ {invites ? (
357
+ <Button type="button" size="sm" className="max-sm:h-11" data-invite="" onClick={() => inviteSheet.current?.showModal()}>
358
+ {t('dashboard.invite', 'Invite')}
359
+ </Button>
360
+ ) : null}
361
+ </div>
362
+ ),
363
+ main: (
364
+ <div data-members="" className="grid gap-4">
365
+ <p className="text-muted-foreground">{t('dashboard.membersAbout', 'Who works in this organization, and the role each holds.')}</p>
366
+ <div className="overflow-hidden rounded-lg border">
367
+ <Table>
368
+ <TableHeader className="bg-muted/50">
369
+ <TableRow>
370
+ <TableHead scope="col">{t('dashboard.membersName', 'Name')}</TableHead>
371
+ <TableHead scope="col" className="max-sm:hidden">{t('dashboard.membersEmail', 'Email')}</TableHead>
372
+ <TableHead scope="col">{t('dashboard.membersRoles', 'Roles')}</TableHead>
373
+ <TableHead scope="col" className="max-lg:hidden">{t('dashboard.membersJoined', 'Joined')}</TableHead>
374
+ {opens ? (
375
+ <TableHead scope="col" className="max-lg:hidden">
376
+ <span className="sr-only">{t('dashboard.membersActions', 'Actions')}</span>
377
+ </TableHead>
378
+ ) : null}
379
+ </TableRow>
380
+ </TableHeader>
381
+ <TableBody>
382
+ {members.length === 0 ? (
383
+ <TableRow>
384
+ <TableCell colSpan={opens ? 5 : 4} className="h-24 text-center text-muted-foreground">
385
+ {t('dashboard.membersNone', 'No one works in this organization yet.')}
386
+ </TableCell>
387
+ </TableRow>
388
+ ) : (
389
+ members.map((member) => (
390
+ // The whole row opens the sheet; the button is the same for the
391
+ // keyboard. Below a laptop's width the email folds under the name and the
392
+ // day joined and the button drop, so the row fits a tablet's width as well
393
+ // as a phone's, and the name is the button.
394
+ <TableRow key={member.userId} data-member={member.userId} className={opens ? 'cursor-pointer' : undefined} onClick={opens ? () => open(member) : undefined}>
395
+ <TableCell className="font-medium">
396
+ {opens ? (
397
+ <>
398
+ <span className="max-lg:hidden">{member.name}</span>
399
+ <button type="button" data-open-member={member.userId} aria-label={`${t('dashboard.membersEdit', 'Edit roles')}: ${member.name}`} className="min-h-11 min-w-11 text-start underline underline-offset-4 lg:hidden" onClick={(event) => {
400
+ event.stopPropagation()
401
+ open(member)
402
+ }}>
403
+ {member.name}
404
+ </button>
405
+ </>
406
+ ) : member.name}
407
+ <span className="text-muted-foreground block text-xs font-normal sm:hidden">{member.email}</span>
408
+ </TableCell>
409
+ <TableCell className="max-sm:hidden">{member.email}</TableCell>
410
+ <TableCell>
411
+ <div className="flex flex-wrap gap-1">
412
+ {member.roles.map((one) => (
413
+ <Badge key={one.id} variant="secondary">
414
+ {labelOf(one.role)}
415
+ </Badge>
416
+ ))}
417
+ </div>
418
+ </TableCell>
419
+ {/* The day in UTC, so the server's render and the browser's agree on it. */}
420
+ <TableCell className="whitespace-nowrap max-lg:hidden">{member.joined ? formatDate({ value: member.joined, locale, day: true }) : null}</TableCell>
421
+ {opens ? (
422
+ <TableCell className="text-end max-lg:hidden">
423
+ {/* Named for its member, as the phone's button is, so five rows are not five buttons of one name. */}
424
+ <Button
425
+ type="button"
426
+ size="sm"
427
+ variant="outline"
428
+ data-edit-roles={member.userId}
429
+ aria-label={`${t('dashboard.membersEdit', 'Edit roles')}: ${member.name}`}
430
+ onClick={(event) => {
431
+ event.stopPropagation()
432
+ open(member)
433
+ }}
434
+ >
435
+ {t('dashboard.membersEdit', 'Edit roles')}
436
+ </Button>
437
+ </TableCell>
438
+ ) : null}
439
+ </TableRow>
440
+ ))
441
+ )}
442
+ </TableBody>
443
+ </Table>
444
+ </div>
445
+ {invites && pending.length ? (
446
+ <section data-pending="" className="grid gap-2">
447
+ <h2 className="text-base font-medium">{t('dashboard.invitesPending', 'Invitations not yet accepted')}</h2>
448
+ {noMailer}
449
+ <div className="overflow-hidden rounded-lg border">
450
+ <Table>
451
+ <TableHeader className="bg-muted/50">
452
+ <TableRow>
453
+ <TableHead scope="col">{t('dashboard.membersEmail', 'Email')}</TableHead>
454
+ <TableHead scope="col">{t('dashboard.inviteExpires', 'Expires')}</TableHead>
455
+ {cancels || development ? (
456
+ <TableHead scope="col">
457
+ <span className="sr-only">{t('dashboard.membersActions', 'Actions')}</span>
458
+ </TableHead>
459
+ ) : null}
460
+ </TableRow>
461
+ </TableHeader>
462
+ <TableBody>
463
+ {pending.map((one) => (
464
+ <TableRow key={one.id} data-invite-row={one.email}>
465
+ <TableCell className="break-all">{one.email}</TableCell>
466
+ <TableCell className="whitespace-nowrap">{one.expiresAt ? formatDate({ value: one.expiresAt, locale, day: true }) : null}</TableCell>
467
+ {cancels || development ? (
468
+ <TableCell>
469
+ <div className="flex flex-wrap justify-end gap-2">
470
+ {development ? (
471
+ <Button type="button" size="sm" variant="outline" className="max-sm:h-11" data-copy-invite={one.id} aria-label={`${t('dashboard.inviteCopy', 'Copy link')}: ${one.email}`} onClick={() => void copyLink(one)}>
472
+ {copied === one.id ? t('dashboard.inviteCopied', 'Link copied') : t('dashboard.inviteCopy', 'Copy link')}
473
+ </Button>
474
+ ) : null}
475
+ {cancels ? (
476
+ <Button type="button" size="sm" variant="outline" className="max-sm:h-11" data-cancel-invite={one.id} aria-label={`${t('dashboard.inviteCancel', 'Cancel invitation')}: ${one.email}`} onClick={() => void cancel(one)}>
477
+ {t('dashboard.inviteCancel', 'Cancel invitation')}
478
+ </Button>
479
+ ) : null}
480
+ </div>
481
+ </TableCell>
482
+ ) : null}
483
+ </TableRow>
484
+ ))}
485
+ </TableBody>
486
+ </Table>
487
+ </div>
488
+ {pendingRefusal ? (
489
+ <p role="alert" className="text-destructive text-sm">
490
+ {pendingRefusal}
491
+ </p>
492
+ ) : null}
493
+ </section>
494
+ ) : null}
495
+ {/* The invitation asks in a modal `<dialog>` for the reason the
496
+ roles sheet below gives. Closing it, however it is closed,
497
+ empties it, so the next invitation starts from nothing. */}
498
+ {invites ? (
499
+ <dialog
500
+ ref={inviteSheet}
501
+ aria-labelledby={inviteTitle}
502
+ data-invite-sheet=""
503
+ className="bg-background text-foreground m-auto w-[min(28rem,calc(100%-2rem))] rounded-lg border p-0 shadow-lg backdrop:bg-black/80"
504
+ onClose={() => {
505
+ setEmail('')
506
+ setEmailLeft(false)
507
+ setInviteRefusal(undefined)
508
+ }}
509
+ >
510
+ <form
511
+ noValidate
512
+ className="grid gap-4 p-6 text-sm"
513
+ onSubmit={(event) => {
514
+ event.preventDefault()
515
+ void invite()
516
+ }}
517
+ >
518
+ <header className="grid gap-1.5">
519
+ <h2 id={inviteTitle} className="text-base font-medium">{t('dashboard.inviteTitle', 'Invite someone')}</h2>
520
+ <p className="text-muted-foreground">{t('dashboard.inviteAbout', 'They join this organization as a member once they accept. Give them roles here after that.')}</p>
521
+ </header>
522
+ {noMailer}
523
+ <FieldShell id={emailId} label={t('dashboard.membersEmail', 'Email') ?? 'Email'} error={emailError} onBlur={() => setEmailLeft(true)}>
524
+ <Input
525
+ ref={emailBox}
526
+ id={emailId}
527
+ name="email"
528
+ type="email"
529
+ dir="ltr"
530
+ autoComplete="off"
531
+ disabled={inviting}
532
+ value={email}
533
+ aria-required
534
+ aria-invalid={emailError ? true : undefined}
535
+ aria-describedby={describedBy({ id: emailId, error: emailError })}
536
+ onChange={(event) => setEmail(event.target.value)}
537
+ />
538
+ </FieldShell>
539
+ {inviteRefusal ? (
540
+ <p role="alert" className="text-destructive">
541
+ {inviteRefusal}
542
+ </p>
543
+ ) : null}
544
+ <footer className="flex justify-end gap-2">
545
+ <Button type="button" variant="outline" onClick={() => inviteSheet.current?.close()}>
546
+ {t('admin.cancel', 'Cancel')}
547
+ </Button>
548
+ <Button type="submit" disabled={inviting}>
549
+ {t('dashboard.inviteSend', 'Send invitation')}
550
+ </Button>
551
+ </footer>
552
+ </form>
553
+ </dialog>
554
+ ) : null}
555
+ {/* The side sheet is the platform's own modal `<dialog>` rather
556
+ than shadcn's sheet, whose popup is a portal a server render
557
+ draws nothing of: a closed dialog is in the
558
+ document and hidden, so the first paint carries every word
559
+ the sheet shows, which the boot's Arabic walk reads. It traps
560
+ focus and closes on Escape by itself. A removal asks inside it
561
+ rather than in a dialog of its own, which would open under the
562
+ sheet's top layer. */}
563
+ {opens ? (
564
+ <dialog
565
+ ref={sheet}
566
+ aria-labelledby={title}
567
+ data-member-sheet={editing?.userId ?? ''}
568
+ className="bg-background text-foreground m-0 ms-auto h-dvh max-h-dvh w-3/4 max-w-sm border-s p-0 shadow-lg backdrop:bg-black/80"
569
+ >
570
+ <form
571
+ className="flex h-full flex-col text-sm"
572
+ onSubmit={(event) => {
573
+ event.preventDefault()
574
+ void save()
575
+ }}
576
+ >
577
+ <header className="grid gap-1.5 p-6">
578
+ <h2 id={title} className="text-base font-medium">
579
+ {t('dashboard.membersEdit', 'Edit roles')}
580
+ </h2>
581
+ <p className="text-muted-foreground">{editing ? `${editing.name} · ${editing.email}` : null}</p>
582
+ </header>
583
+ <div className="grid flex-1 content-start gap-4 overflow-y-auto px-6">
584
+ {roles.length === 0 ? <p className="text-muted-foreground">{t('dashboard.membersNoRoles', 'There is no role you may read to assign.')}</p> : null}
585
+ {group(true, t('dashboard.membersFixed', 'Fixed roles'))}
586
+ {group(false, t('dashboard.membersCustom', 'Custom roles'))}
587
+ {refusal ? (
588
+ <p role="alert" className="text-destructive">
589
+ {refusal}
590
+ </p>
591
+ ) : null}
592
+ {removable ? (
593
+ <section data-remove-member="" className="grid gap-2 border-t pt-4">
594
+ {confirming ? (
595
+ <>
596
+ <p>{t('dashboard.removeAbout', 'They lose access to this organization and every role they hold in it. Inviting them again does not give the roles back.')}</p>
597
+ <div className="flex flex-wrap gap-2">
598
+ <Button type="button" variant="destructive" data-button="confirm-remove" disabled={busy} onClick={() => void remove()}>
599
+ {t('dashboard.removeConfirm', 'Remove for good')}
600
+ </Button>
601
+ <Button type="button" variant="outline" onClick={() => setConfirming(false)}>
602
+ {t('dashboard.removeKeep', 'Keep this member')}
603
+ </Button>
604
+ </div>
605
+ </>
606
+ ) : (
607
+ <Button type="button" variant="outline" className="text-destructive justify-self-start" data-button="remove-member" disabled={busy} onClick={() => setConfirming(true)}>
608
+ {t('dashboard.removeMember', 'Remove from organization')}
609
+ </Button>
610
+ )}
611
+ </section>
612
+ ) : null}
613
+ </div>
614
+ <footer className="flex justify-end gap-2 p-6">
615
+ <Button type="button" variant="outline" onClick={() => sheet.current?.close()}>
616
+ {t('admin.cancel', 'Cancel')}
617
+ </Button>
618
+ <Button type="submit" disabled={busy || !changed}>
619
+ {t('admin.save', 'Save')}
620
+ </Button>
621
+ </footer>
622
+ </form>
623
+ </dialog>
624
+ ) : null}
625
+ </div>
626
+ ),
627
+ }}
628
+ />
629
+ )
630
+ }
@@ -0,0 +1,10 @@
1
+ // The dashboard screen's one provider, folded around its shell: it marks the
2
+ // subtree the screen draws, so a stylesheet can scope to it.
3
+ import type { ReactNode } from 'react'
4
+ import type { ProviderProps } from 'mithaq/admin'
5
+
6
+ export const ThemeProvider = ({ children }: ProviderProps<ReactNode>) => (
7
+ <div data-provider="theme" className="contents">
8
+ {children}
9
+ </div>
10
+ )