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,829 @@
1
+ // The dashboard's one page that belongs to no kayan: who works in the viewer's organization, the roles each holds and
2
+ // the invitations not yet accepted, drawn on the list table every kayan's list is drawn on, and the one place a
3
+ // member's roles are changed.
4
+ // The `loader` reads as the viewer: members from the provider's `list-members` (the active organization only, gated by
5
+ // `member.find`, each one's last seen only under `member.fields.lastSeenAt`), assignments from `memberRoles`, and the
6
+ // roles the drawer offers: the fixed ones from their code and the organization's own from `roles`, never a backoffice role.
7
+ // Invitations, cancellations, removals and password links go through the provider's own routes and the auth template's,
8
+ // which gate them by `invitation.create`, `invitation.cancel`, `member.delete` and `member.create`. Every button here
9
+ // only reflects those gates: the server keeps each one whatever the page draws.
10
+ import { useEffect, useId, useRef, useState, type ReactNode, type RefObject } from 'react'
11
+ import { ChevronDown, Lock, MoreHorizontal } from 'lucide-react'
12
+ import { holds, type AccessReport, type CellProps, type ClientField, type ClientKayan, type LoaderTools, type PageProps } from 'mithaq/admin'
13
+ import { useAccess, useClient, useLocale, useUser } from 'mithaq/admin/react'
14
+ import type { asas } from '../asas.ts'
15
+ import { authArm } from '@/api.ts'
16
+ import { refusalKey } from '@/copy.ts'
17
+ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
18
+ import { Badge } from '@/components/ui/badge'
19
+ import { Button, buttonVariants } from '@/components/ui/button'
20
+ import { ButtonGroup } from '@/components/ui/button-group'
21
+ import { Card, CardContent } from '@/components/ui/card'
22
+ import { Checkbox } from '@/components/ui/checkbox'
23
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
24
+ import { Input } from '@/components/ui/input'
25
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
26
+ import { formatDate, noValue } from 'mithaq-admin/format.tsx'
27
+ import { FieldShell, describedBy } from 'mithaq-admin/field-shell.tsx'
28
+ import { ListMessage } from 'mithaq-admin/list-header.tsx'
29
+ import { ListTable } from 'mithaq-admin/list-table.tsx'
30
+ import { Search } from 'mithaq-admin/list-toolbar.tsx'
31
+ import { DocumentDrawer } from 'mithaq-admin/record-frame.tsx'
32
+ import type { ScreenPage } from 'mithaq-admin/screen-refs.ts'
33
+ import { toast } from 'mithaq-admin/toast.tsx'
34
+ import { organizations } from '@asas/mithaq/auth/organizations'
35
+ import { grantable, managesRoles, OWNER_ROLE } from '@asas/mithaq/roles/grants.ts'
36
+ import { AppShell } from '@asas/mithaq/ui/app-shell.tsx'
37
+ import { Grants, PermissionBadges } from '@asas/roles/roles/ui/role-form.tsx'
38
+ import { isoOf, readMembers, type Member, type Role } from './members-card.tsx'
39
+
40
+ /** One invitation not yet accepted: its governance role, who sent it where they are still a member, and whether it ran out. */
41
+ export type Invite = { id: string; email: string; role: string; inviter?: string; expiresAt: string; expired: boolean }
42
+ /** `invites` is there only for a viewer who may invite, and lists what is pending, run out or not. */
43
+ export type MembersData = { members: Member[]; roles: Role[]; invites?: Invite[] }
44
+
45
+ /**
46
+ * Whether a governance role lets its member invite: `owner` and `admin` hold `invitation.create`, and `member` holds nothing but reading the role list,
47
+ * so the provider refuses a `member` the invitation whatever keys it holds.
48
+ */
49
+ const invitesAsGovernance = (role: unknown) => typeof role === 'string' && role.split(',').some((name) => name === 'owner' || name === 'admin')
50
+
51
+ /** An invitation as the provider answers it and as the page holds it. */
52
+ const inviteOf = (one: { id: string; email: string; role?: string | null; expiresAt: string }, inviter?: string): Invite => ({
53
+ id: one.id,
54
+ email: one.email,
55
+ role: one.role || 'member',
56
+ ...(inviter !== undefined && { inviter }),
57
+ expiresAt: isoOf(one.expiresAt),
58
+ expired: !(new Date(one.expiresAt).getTime() > Date.now()),
59
+ })
60
+
61
+ export const loader = async (tools: LoaderTools): Promise<MembersData> => {
62
+ const { access, request } = tools
63
+ const [{ members, roles }, invited] = await Promise.all([
64
+ readMembers(tools),
65
+ // Asked only of a viewer who may invite: one holding the key, whose
66
+ // governance role the provider lets invite.
67
+ holds(access, 'invitation.create')
68
+ ? request('/api/auth/user/organization/get-active-member-role')
69
+ .then(async (response) => response.ok && invitesAsGovernance(((await response.json()) as { role?: unknown }).role))
70
+ .then((invites) =>
71
+ invites
72
+ ? request('/api/auth/user/organization/list-invitations').then(async (response) => {
73
+ if (!response.ok) throw new Error(`list-invitations answered ${response.status}`)
74
+ return (await response.json()) as { id: string; email: string; role?: string | null; status: string; expiresAt: string; inviterId?: string }[]
75
+ })
76
+ : undefined,
77
+ )
78
+ : undefined,
79
+ ])
80
+ const names = new Map(members.map((member) => [member.userId, member.name]))
81
+ return {
82
+ members,
83
+ roles,
84
+ // Pending, run out or not: one run out is drawn greyed and sent again from its row. The provider accepts neither
85
+ // an accepted, a rejected nor a cancelled one, so those are gone.
86
+ ...(invited && { invites: invited.filter((one) => one.status === 'pending').map((one) => inviteOf(one, one.inviterId === undefined ? undefined : names.get(one.inviterId))) }),
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Whether this is Vite's development server, which has no mailer, read so the
92
+ * registry's own typecheck, which knows no Vite, reads it too.
93
+ */
94
+ const development = (import.meta as { env?: { DEV?: boolean } }).env?.DEV === true
95
+
96
+ /** 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. */
97
+ const refusedKey = (answered: { status: number; body: Record<string, unknown> | null }) => {
98
+ const code = answered.body?.code
99
+ return refusalKey(answered.status, code) ?? (typeof code === 'string' && code.startsWith('error.') ? code : undefined)
100
+ }
101
+
102
+ /**
103
+ * "Add person", the Members page's first action with one organization: a name, an address and a role the viewer may
104
+ * hand over, sent to the server's `add-person`, which makes the account, the membership and the role in one request and
105
+ * mails the link that chooses the password. Someone who already has a proved account is invited instead. The page draws
106
+ * the button that opens it.
107
+ */
108
+ export function AddPerson({ sheet, roles, onInvited }: { sheet: RefObject<HTMLDialogElement | null>; roles: Role[]; onInvited: (invite: Invite) => void }) {
109
+ const { locale, t } = useLocale()
110
+ const nameId = useId()
111
+ const emailId = useId()
112
+ const roleId = useId()
113
+ const [name, setName] = useState('')
114
+ const [email, setEmail] = useState('')
115
+ const [role, setRole] = useState('')
116
+ const [busy, setBusy] = useState(false)
117
+ const [refusal, setRefusal] = useState<string>()
118
+ // Answered only where no mailer is configured: the administrator hands it over.
119
+ const [link, setLink] = useState<string>()
120
+ const add = async () => {
121
+ if (!name.trim() || !email.trim()) return setRefusal(t('auth.required', 'This field is required.'))
122
+ setBusy(true)
123
+ setRefusal(undefined)
124
+ const answered = await authArm('organization/add-person', { name: name.trim(), email: email.trim(), ...(role && { role }) }).finally(() => setBusy(false))
125
+ if (!answered.ok) {
126
+ const key = refusedKey(answered)
127
+ return setRefusal((key && t(key)) || t('dashboard.addPersonRefused', 'The server refused to add that person.'))
128
+ }
129
+ const body = answered.body as { added?: boolean; link?: string; invited?: boolean; invitation?: { id: string; email: string; role?: string; expiresAt: string } }
130
+ if (body.invited && body.invitation) {
131
+ onInvited(inviteOf(body.invitation))
132
+ toast.success((t('dashboard.inviteSent', 'Invitation sent to {email}') ?? 'Invitation sent to {email}').replace('{email}', () => body.invitation!.email))
133
+ return sheet.current?.close()
134
+ }
135
+ if (body.link) {
136
+ toast.success((t('dashboard.personAddedLink', '{name} added. Hand them the link below.') ?? '{name} added. Hand them the link below.').replace('{name}', () => name.trim()))
137
+ return setLink(body.link)
138
+ }
139
+ toast.success((t('dashboard.personAdded', '{name} added. A link to choose a password was sent to {email}.') ?? '{name} added. A link to choose a password was sent to {email}.').replace('{name}', () => name.trim()).replace('{email}', () => email.trim()))
140
+ sheet.current?.close()
141
+ // The new member and their role are read again in place, so the toast that says so stays.
142
+ window.dispatchEvent(new Event('mithaq:refresh'))
143
+ }
144
+ return (
145
+ <dialog
146
+ ref={sheet}
147
+ data-add-person-sheet=""
148
+ aria-label={t('dashboard.addPersonTitle', 'Add a person')}
149
+ className="bg-background text-foreground m-auto w-[min(28rem,calc(100%-2rem))] rounded-lg border p-0 shadow-lg backdrop:bg-black/80"
150
+ onClose={() => {
151
+ if (link) window.dispatchEvent(new Event('mithaq:refresh'))
152
+ setName('')
153
+ setEmail('')
154
+ setRole('')
155
+ setLink(undefined)
156
+ setRefusal(undefined)
157
+ }}
158
+ >
159
+ <form noValidate className="grid gap-4 p-6 text-sm" onSubmit={(event) => { event.preventDefault(); void add() }}>
160
+ <header className="grid gap-1.5">
161
+ <h2 className="text-base font-medium">{t('dashboard.addPersonTitle', 'Add a person')}</h2>
162
+ <p className="text-muted-foreground">{t('dashboard.addPersonAbout', 'They get an account here and a link by email to choose their password. Someone who already has an account is sent an invitation instead.')}</p>
163
+ </header>
164
+ {link ? (
165
+ <HandedLink link={link} said={t('dashboard.addPersonLink', 'No mailer is configured. Hand this link to them alone: it chooses their password and lasts 48 hours.')} />
166
+ ) : (
167
+ <>
168
+ <FieldShell id={nameId} label={t('dashboard.membersName', 'Name') ?? 'Name'}>
169
+ <Input id={nameId} value={name} maxLength={100} autoComplete="off" disabled={busy} onChange={(event) => setName(event.target.value)} />
170
+ </FieldShell>
171
+ <FieldShell id={emailId} label={t('dashboard.membersEmail', 'Email') ?? 'Email'}>
172
+ <Input id={emailId} type="email" dir="ltr" value={email} autoComplete="off" disabled={busy} onChange={(event) => setEmail(event.target.value)} />
173
+ </FieldShell>
174
+ <FieldShell id={roleId} label={t('dashboard.addPersonRole', 'Role') ?? 'Role'}>
175
+ <select id={roleId} value={role} disabled={busy} className="border-input bg-background h-9 rounded-md border px-3 max-sm:h-11" onChange={(event) => setRole(event.target.value)}>
176
+ <option value="">{t('dashboard.addPersonNoRole', 'No role yet')}</option>
177
+ {roles.map((one) => <option key={one.name} value={one.name}>{one.label?.[locale] ?? one.name}</option>)}
178
+ </select>
179
+ </FieldShell>
180
+ </>
181
+ )}
182
+ {refusal ? <p role="alert" className="text-destructive">{refusal}</p> : null}
183
+ <footer className="flex justify-end gap-2">
184
+ <Button type="button" variant="outline" className="max-sm:h-11" onClick={() => sheet.current?.close()}>{link ? t('admin.close', 'Close') : t('admin.cancel', 'Cancel')}</Button>
185
+ {link ? null : <Button type="submit" className="max-sm:h-11" disabled={busy}>{t('dashboard.addPersonSubmit', 'Add')}</Button>}
186
+ </footer>
187
+ </form>
188
+ </dialog>
189
+ )
190
+ }
191
+
192
+ /** A link answered where no mailer is configured, for the administrator to hand over, in a box that selects itself. */
193
+ function HandedLink({ link, said }: { link: string; said: ReactNode }) {
194
+ const { t } = useLocale()
195
+ return (
196
+ <div data-handed-link="" className="grid gap-2">
197
+ <p>{said}</p>
198
+ <Input readOnly dir="ltr" value={link} aria-label={t('dashboard.link', 'Link')} onFocus={(event) => event.currentTarget.select()} />
199
+ <Button type="button" variant="outline" className="max-sm:h-11" onClick={() => void navigator.clipboard.writeText(link).then(() => toast.success(t('dashboard.inviteCopied', 'Link copied') ?? 'Link copied'))}>
200
+ {t('dashboard.inviteCopy', 'Copy link')}
201
+ </Button>
202
+ </div>
203
+ )
204
+ }
205
+
206
+ const sameRoles = (a: ReadonlySet<string>, b: ReadonlySet<string>) => a.size === b.size && [...a].every((name) => b.has(name))
207
+
208
+ /**
209
+ * Whether the viewer may tick or untick `role` for `member`, and why not. Unticking removes an assignment and ticking adds
210
+ * one, so each needs its own operation, and either needs a role the viewer may hand over. The server's guards decide
211
+ * every write whatever this answers.
212
+ */
213
+ const lockOf = ({ access, me, member, role }: { access: AccessReport; me: string | undefined; member: Member; role: Role }) => {
214
+ const assignments = access.collections?.memberRoles
215
+ const assigned = member.roles.some((row) => row.role === role.name)
216
+ // The role that lets the viewer manage roles is not taken from themselves, which the server refuses too.
217
+ const own = assigned && member.email === me && role.permissions !== undefined && managesRoles(role.permissions)
218
+ // The owner holds administrator whatever the rows say, so the box is ticked and locked, and the server refuses
219
+ // removing the row anyway.
220
+ const owned = role.name === OWNER_ROLE && member.owner
221
+ const locked = own || owned || !grantable(access, role.permissions) || (assigned ? assignments?.delete !== true : assignments?.create !== true)
222
+ return { own, owned, locked }
223
+ }
224
+
225
+ /**
226
+ * Edit roles: Payload's document drawer over the page, listing every role with what it grants, grouped by screen and
227
+ * kayan and closed behind its count as the Roles page draws it, each with a box that gives or takes it. A box the viewer may not change is disabled, with the reason where
228
+ * there is one. Saving writes `memberRoles` rows through that kayan's own operations, so its guards judge each one:
229
+ * removals first, then additions, stopping at the first refusal, and whatever landed before it stays shown.
230
+ */
231
+ function RolesDrawer({ member, roles, onClose, onHeld }: { member: Member; roles: Role[]; onClose: () => void; onHeld: (held: Member['roles']) => void }) {
232
+ const { locale, t } = useLocale()
233
+ const client = useClient<typeof asas.router>()
234
+ const access = useAccess()
235
+ const me = useUser().email
236
+ const [chosen, setChosen] = useState<ReadonlySet<string>>(new Set(member.roles.map((one) => one.role)))
237
+ const [busy, setBusy] = useState(false)
238
+ const [refusal, setRefusal] = useState<string>()
239
+ const changed = !sameRoles(chosen, new Set(member.roles.map((one) => one.role)))
240
+ const save = async () => {
241
+ setBusy(true)
242
+ setRefusal(undefined)
243
+ let held = member.roles
244
+ try {
245
+ for (const one of held.filter((row) => roles.some((role) => role.name === row.role) && !chosen.has(row.role))) {
246
+ await client({ kayan: 'memberRoles', operation: 'delete', input: { id: one.id } })
247
+ held = held.filter((row) => row !== one)
248
+ }
249
+ for (const role of [...chosen].filter((name) => !held.some((row) => row.role === name))) {
250
+ const row = await client({ kayan: 'memberRoles', operation: 'create', input: { user: member.userId, role } })
251
+ held = [...held, { id: String(row.id), role }]
252
+ }
253
+ toast.success((t('dashboard.rolesSaved', 'Roles saved for {name}') ?? 'Roles saved for {name}').replace('{name}', () => member.name))
254
+ onHeld(held)
255
+ onClose()
256
+ } catch (thrown) {
257
+ const key = (thrown as { data?: { key?: unknown } }).data?.key
258
+ setRefusal((typeof key === 'string' && t(key)) || t('dashboard.membersRefused', 'The server refused that change.'))
259
+ onHeld(held)
260
+ } finally {
261
+ setBusy(false)
262
+ }
263
+ }
264
+ const choice = (role: Role) => {
265
+ const { own, owned, locked } = lockOf({ access, me, member, role })
266
+ const disabled = busy || locked
267
+ return (
268
+ // Only the box and the name are dimmed when the box is locked: the sentence saying why and what the role grants
269
+ // stay at their own contrast.
270
+ <div key={role.name} data-role={role.name} data-disabled={disabled} className="grid gap-2 rounded-md border p-3">
271
+ <label className="flex items-start gap-3">
272
+ <Checkbox
273
+ className="mt-0.5"
274
+ checked={owned || chosen.has(role.name)}
275
+ disabled={disabled}
276
+ onCheckedChange={(on) => setChosen((now) => (on ? new Set([...now, role.name]) : new Set([...now].filter((name) => name !== role.name))))}
277
+ />
278
+ <span className="grid gap-0.5">
279
+ <span className={`flex items-center gap-1.5 font-medium${disabled ? ' opacity-50' : ''}`}>
280
+ {role.fixed ? <Lock className="text-muted-foreground size-3.5" aria-hidden="true" /> : null}
281
+ {role.label?.[locale] ?? role.name}
282
+ </span>
283
+ {owned ? (
284
+ <span data-owner="" className="text-muted-foreground text-xs">{t('dashboard.membersOwnerRole', 'The owner of the organization always holds this role.')}</span>
285
+ ) : own ? (
286
+ <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>
287
+ ) : grantable(access, role.permissions) ? null : (
288
+ <span data-locked="" className="text-muted-foreground text-xs">{t('dashboard.membersNotGrantable', 'It grants keys you do not hold, so only someone who holds them gives or takes it.')}</span>
289
+ )}
290
+ </span>
291
+ </label>
292
+ {role.permissions ? <div className="ps-7"><Grants named={role.label?.[locale] ?? role.name} held={role.permissions}><PermissionBadges held={role.permissions} /></Grants></div> : null}
293
+ </div>
294
+ )
295
+ }
296
+ const group = (fixed: boolean, heading: string | undefined) => {
297
+ const listed = roles.filter((role) => role.fixed === fixed)
298
+ return listed.length ? (
299
+ <section className="grid gap-2">
300
+ <h3 className="text-muted-foreground text-xs font-medium">{heading}</h3>
301
+ {listed.map(choice)}
302
+ </section>
303
+ ) : null
304
+ }
305
+ return (
306
+ <DocumentDrawer title={`${t('dashboard.membersEdit', 'Edit roles')}: ${member.name}`} edit={member.userId} closeLabel={t('admin.close', 'Close') ?? 'Close'} onClose={onClose}>
307
+ <form
308
+ data-member-sheet={member.userId}
309
+ className="mx-auto grid w-full max-w-3xl gap-4 text-sm"
310
+ onSubmit={(event) => {
311
+ event.preventDefault()
312
+ void save()
313
+ }}
314
+ >
315
+ <p className="text-muted-foreground"><bdi>{member.name}</bdi> · <bdi dir="ltr">{member.email}</bdi></p>
316
+ {roles.length === 0 ? <p className="text-muted-foreground">{t('dashboard.membersNoRoles', 'There is no role you may read to assign.')}</p> : null}
317
+ {group(true, t('dashboard.membersFixed', 'Fixed roles'))}
318
+ {group(false, t('dashboard.membersCustom', 'Custom roles'))}
319
+ {refusal ? <p role="alert" className="text-destructive">{refusal}</p> : null}
320
+ <footer className="bg-background sticky bottom-0 flex justify-end gap-2 border-t py-3">
321
+ <Button type="button" variant="outline" className="max-sm:h-11" onClick={onClose}>{t('admin.cancel', 'Cancel')}</Button>
322
+ <Button type="submit" className="max-sm:h-11" disabled={busy || !changed}>{t('admin.save', 'Save')}</Button>
323
+ </footer>
324
+ </form>
325
+ </DocumentDrawer>
326
+ )
327
+ }
328
+
329
+ /** A member's name with their address under it, which the first column of the list draws. */
330
+ function PersonCell({ row }: CellProps) {
331
+ return (
332
+ <span className="grid">
333
+ <span className="font-medium">{String(row.name ?? '')}</span>
334
+ <bdi dir="ltr" className="text-muted-foreground text-xs">{String(row.email ?? '')}</bdi>
335
+ </span>
336
+ )
337
+ }
338
+
339
+ /** A member's roles, each by its label, as badges. */
340
+ function RolesCell({ value }: CellProps) {
341
+ const labels = Array.isArray(value) ? (value as string[]) : []
342
+ return labels.length ? <div className="flex flex-wrap gap-1">{labels.map((label) => <Badge key={label} variant="secondary">{label}</Badge>)}</div> : noValue
343
+ }
344
+
345
+ /** A day, in UTC so the server's render and the browser's agree on it, and a dash where there is none. */
346
+ function DayCell({ value }: CellProps) {
347
+ const { locale } = useLocale()
348
+ return (typeof value === 'string' && value && formatDate({ value, locale, day: true })) || noValue
349
+ }
350
+
351
+ /** One column of an invitation, greyed once it has run out. */
352
+ function InviteCell({ value, row }: CellProps) {
353
+ const text = typeof value === 'string' && value ? value : undefined
354
+ return <span className={row.expired ? 'text-muted-foreground' : undefined}>{text ?? noValue}</span>
355
+ }
356
+
357
+ /** When an invitation runs out, or that it has, greyed. */
358
+ function ExpiresCell({ value, row }: CellProps) {
359
+ const { locale, t } = useLocale()
360
+ const day = typeof value === 'string' && value ? formatDate({ value, locale, day: true }) : undefined
361
+ return row.expired ? (
362
+ <span data-expired="" className="text-muted-foreground">{t('dashboard.inviteExpired', 'Expired')}{day ? ` · ${day}` : ''}</span>
363
+ ) : (day ?? noValue)
364
+ }
365
+
366
+ /** A column of one of the page's tables, drawn by one of the cells above or a shipped one, named in the viewer's locale. */
367
+ const columnOf = ({ name, label, cell, locale }: { name: string; label: string | undefined; cell: string; locale: string }): ClientField => ({
368
+ name,
369
+ kind: 'text',
370
+ label: { [locale]: label ?? name },
371
+ rules: {},
372
+ component: 'text',
373
+ cell,
374
+ sortable: false,
375
+ })
376
+
377
+ /** The page's tables as the list table takes them: the cells by name, under a kayan of their own that no server knows. */
378
+ const tableOf = (name: string): ScreenPage => ({
379
+ refs: { person: PersonCell, roles: RolesCell, day: DayCell, invite: InviteCell, expires: ExpiresCell } as ScreenPage['refs'],
380
+ kayan: { name, single: false, fields: [], operations: [], pages: {} } as ClientKayan,
381
+ page: { path: '', page: { shell: '' } },
382
+ })
383
+ const memberTable = tableOf('members')
384
+ const inviteTable = tableOf('invitations')
385
+
386
+ /** A row's menu, as every list draws one, named for the row. */
387
+ function RowActions({ id, label, children }: { id: string; label: string; children: ReactNode }) {
388
+ return (
389
+ <DropdownMenu>
390
+ <DropdownMenuTrigger data-row-menu={id} aria-label={label} className={buttonVariants({ variant: 'ghost', size: 'icon-sm', className: 'max-sm:size-11' })}>
391
+ <MoreHorizontal className="size-4" aria-hidden="true" />
392
+ </DropdownMenuTrigger>
393
+ <DropdownMenuContent align="end" className="w-56">{children}</DropdownMenuContent>
394
+ </DropdownMenu>
395
+ )
396
+ }
397
+
398
+ export function Members({ loaderData }: PageProps<MembersData>) {
399
+ const { locale, t } = useLocale()
400
+ const client = useClient<typeof asas.router>()
401
+ const access = useAccess()
402
+ const me = useUser().email
403
+ const assignments = access.collections?.memberRoles
404
+ const roles = loaderData?.roles ?? []
405
+ const [members, setMembers] = useState(loaderData?.members ?? [])
406
+ const [pending, setPending] = useState(loaderData?.invites ?? [])
407
+ // A page read again in place, after a person is added, hands the new lists.
408
+ useEffect(() => setMembers(loaderData?.members ?? []), [loaderData?.members])
409
+ useEffect(() => setPending(loaderData?.invites ?? []), [loaderData?.invites])
410
+ const [tab, setTab] = useState('members')
411
+ const [search, setSearch] = useState<string>()
412
+ const [roleShown, setRoleShown] = useState('')
413
+ const [editing, setEditing] = useState<Member>()
414
+ const [removing, setRemoving] = useState<Member>()
415
+ const [busy, setBusy] = useState(false)
416
+ const [handed, setHanded] = useState<{ name: string; link: string }>()
417
+ const handedSheet = useRef<HTMLDialogElement>(null)
418
+ const addSheet = useRef<HTMLDialogElement>(null)
419
+ const table = useRef<HTMLTableElement>(null)
420
+ const inviteTableRef = useRef<HTMLTableElement>(null)
421
+ const openHint = useId()
422
+ const roleFilter = useId()
423
+ // A member's roles are changed where the report lets this actor add or remove an assignment; the guard on each
424
+ // write still decides every role.
425
+ const edits = assignments?.create === true || assignments?.delete === true
426
+ // A member is removed, and an invitation cancelled, by whoever holds the key the auth template gates the provider's
427
+ // route by; the provider's own governance role still decides, and its refusal is worded below.
428
+ const removes = holds(access, 'member.delete')
429
+ const cancels = holds(access, 'invitation.cancel')
430
+ const invites = loaderData?.invites !== undefined
431
+ // With one organization an administrator adds people directly, and sends one a new password link the same way.
432
+ const adds = organizations === 'one' && holds(access, 'member.create')
433
+ const seesLastSeen = holds(access, 'member.fields.lastSeenAt')
434
+ const [inviting, setInviting] = useState(false)
435
+ const [email, setEmail] = useState('')
436
+ /** Whether the email box has been left or submitted, after which it says what is wrong with it. */
437
+ const [emailLeft, setEmailLeft] = useState(false)
438
+ const [inviteRefusal, setInviteRefusal] = useState<string>()
439
+ const inviteSheet = useRef<HTMLDialogElement>(null)
440
+ const emailBox = useRef<HTMLInputElement>(null)
441
+ const inviteTitle = useId()
442
+ const emailId = useId()
443
+ const refused = (answered: { status: number; body: Record<string, unknown> | null }, fallback: string | undefined) => {
444
+ const key = refusedKey(answered)
445
+ return (key && t(key)) || fallback || ''
446
+ }
447
+ /**
448
+ * What is wrong with the address typed, said once the box is left and on
449
+ * every submit: nothing typed, or what the browser's own `type="email"`
450
+ * refuses, so no second rule for an address is written here.
451
+ */
452
+ const emailError = !emailLeft
453
+ ? undefined
454
+ : !email.trim()
455
+ ? t('auth.required', 'This field is required.')
456
+ : emailBox.current?.validity.typeMismatch
457
+ ? t('auth.invalidEmail', 'Invalid email')
458
+ : undefined
459
+ /** An invitation listed, or listed again in its place once sent again. */
460
+ const listInvite = (made: Invite) => setPending((all) => [...all.filter((one) => one.id !== made.id), made])
461
+ /**
462
+ * One invitation, as a member: the role the provider gives on accepting, so
463
+ * the roles of this page are given after, here, like any member's.
464
+ */
465
+ const invite = async () => {
466
+ setEmailLeft(true)
467
+ if (!email.trim() || emailBox.current?.validity.typeMismatch) return emailBox.current?.focus()
468
+ setInviting(true)
469
+ setInviteRefusal(undefined)
470
+ const answered = await authArm('organization/invite-member', { email: email.trim(), role: 'member' }).finally(() => setInviting(false))
471
+ // The provider's sentence is English alone, so the refusal is said in the viewer's locale, by its code.
472
+ if (!answered.ok) return setInviteRefusal(refused(answered, t('dashboard.inviteRefused', 'The server refused that invitation.')))
473
+ const made = answered.body as { id: string; email: string; role?: string; expiresAt: string }
474
+ listInvite(inviteOf(made, members.find((one) => one.email === me)?.name))
475
+ toast.success((t('dashboard.inviteSent', 'Invitation sent to {email}') ?? 'Invitation sent to {email}').replace('{email}', () => made.email))
476
+ inviteSheet.current?.close()
477
+ // Where its link is copied from.
478
+ setTab('invitations')
479
+ }
480
+ /** One pending invitation mailed again with a new expiry, through the provider's own route. */
481
+ const resend = async (one: Invite) => {
482
+ const answered = await authArm('organization/invite-member', { email: one.email, role: one.role, resend: true })
483
+ if (!answered.ok) return void toast.error(refused(answered, t('dashboard.inviteRefused', 'The server refused that invitation.')))
484
+ // One run out may come back as a new invitation, which takes its place.
485
+ const made = inviteOf(answered.body as { id: string; email: string; role?: string; expiresAt: string }, one.inviter)
486
+ setPending((all) => [...all.filter((other) => other.id !== one.id && other.id !== made.id), made])
487
+ toast.success((t('dashboard.inviteResent', 'Invitation sent again to {email}') ?? 'Invitation sent again to {email}').replace('{email}', () => one.email))
488
+ }
489
+ /** One pending invitation withdrawn, through the provider's own route. */
490
+ const cancel = async (one: Invite) => {
491
+ const answered = await authArm('organization/cancel-invitation', { invitationId: one.id })
492
+ if (!answered.ok) return void toast.error(refused(answered, t('dashboard.inviteCancelRefused', 'The server refused to cancel that invitation.')))
493
+ setPending((all) => all.filter((other) => other.id !== one.id))
494
+ toast.success(t('dashboard.inviteCancelled', 'Invitation cancelled') ?? 'Invitation cancelled')
495
+ }
496
+ /** The link the invitee opens, for the inviter to hand over where the mail does not reach, in every environment. */
497
+ const copyLink = async (one: Invite) => {
498
+ await navigator.clipboard.writeText(new URL(`/auth/accept-invitation?id=${encodeURIComponent(one.id)}`, window.location.origin).href)
499
+ toast.success(t('dashboard.inviteCopied', 'Link copied') ?? 'Link copied')
500
+ }
501
+ /** A new link to choose a password, mailed to the member, or answered to hand over where no mailer is configured. */
502
+ const sendLink = async (member: Member) => {
503
+ const answered = await authArm('organization/send-password-link', { userId: member.userId })
504
+ if (!answered.ok) return void toast.error(refused(answered, t('dashboard.passwordLinkRefused', 'The server refused to send that link.')))
505
+ const link = (answered.body as { link?: unknown } | null)?.link
506
+ if (typeof link === 'string') {
507
+ setHanded({ name: member.name, link })
508
+ return handedSheet.current?.showModal()
509
+ }
510
+ toast.success((t('dashboard.passwordLinkSent', 'A link to choose a password was sent to {email}') ?? 'A link to choose a password was sent to {email}').replace('{email}', () => member.email))
511
+ }
512
+ // A link to one member's roles, `?member=<id>`, opens the drawer on them, as a role's delete links each member who still holds it.
513
+ useEffect(() => {
514
+ const asked = new URLSearchParams(window.location.search).get('member')
515
+ const member = members.find((one) => one.userId === asked)
516
+ if (member && edits) setEditing(member)
517
+ }, [])
518
+ const labelOf = (name: string) => roles.find((role) => role.name === name)?.label?.[locale] ?? name
519
+ /** What a removal or a save leaves the member holding, drawn in the table at once. */
520
+ const hold = (member: Member, held: Member['roles']) => setMembers((all) => all.map((one) => (one.userId === member.userId ? { ...one, roles: held } : one)))
521
+ /**
522
+ * Whether the viewer may remove the member: one who may remove members, never themselves, and only where every role
523
+ * the member holds is one they may take, the rule the server's guards keep on each of those deletes.
524
+ */
525
+ const removable = (member: Member) =>
526
+ removes && member.email !== me && member.roles.every((row) => {
527
+ const role = roles.find((one) => one.name === row.role)
528
+ return role !== undefined && !lockOf({ access, me, member, role }).locked
529
+ })
530
+ /**
531
+ * Takes the member out: each role they hold first, through the assignment kayan's own delete and so under its guards,
532
+ * then the membership through the provider's route, which refuses a member still holding a role. A refused role
533
+ * stops it, and the table shows what was taken before it.
534
+ */
535
+ const remove = async (member: Member) => {
536
+ setBusy(true)
537
+ let held = member.roles
538
+ try {
539
+ for (const one of held) {
540
+ await client({ kayan: 'memberRoles', operation: 'delete', input: { id: one.id } })
541
+ held = held.filter((row) => row !== one)
542
+ }
543
+ } catch (thrown) {
544
+ const key = (thrown as { data?: { key?: unknown } }).data?.key
545
+ hold(member, held)
546
+ setBusy(false)
547
+ return void toast.error((typeof key === 'string' && t(key)) || t('dashboard.removeRefused', 'The server refused to remove that member.') || '')
548
+ }
549
+ const answered = await authArm('organization/remove-member', { memberIdOrEmail: member.email }).finally(() => setBusy(false))
550
+ if (!answered.ok) {
551
+ hold(member, held)
552
+ return void toast.error(refused(answered, t('dashboard.removeRefused', 'The server refused to remove that member.')))
553
+ }
554
+ setMembers((all) => all.filter((one) => one.userId !== member.userId))
555
+ toast.success((t('dashboard.memberRemoved', '{name} removed') ?? '{name} removed').replace('{name}', () => member.name))
556
+ }
557
+
558
+ const term = search?.toLocaleLowerCase(locale)
559
+ const shown = members.filter(
560
+ (member) =>
561
+ (!roleShown || member.roles.some((one) => one.role === roleShown) || (roleShown === OWNER_ROLE && member.owner)) &&
562
+ (!term || `${member.name}\n${member.email}`.toLocaleLowerCase(locale).includes(term)),
563
+ )
564
+ const rowActionsLabel = (name: string) => (t('admin.rowActionsFor', 'Actions for {name}') ?? 'Actions for {name}').replace('{name}', () => `⁨${name}⁩`)
565
+ const memberColumns = [
566
+ columnOf({ name: 'person', label: t('dashboard.membersName', 'Name'), cell: 'person', locale }),
567
+ columnOf({ name: 'roles', label: t('dashboard.membersRoles', 'Roles'), cell: 'roles', locale }),
568
+ columnOf({ name: 'joined', label: t('dashboard.membersJoined', 'Joined'), cell: 'day', locale }),
569
+ ...(seesLastSeen ? [columnOf({ name: 'lastSeen', label: t('member.lastSeenAt', 'Last seen'), cell: 'day', locale })] : []),
570
+ ]
571
+ const inviteColumns = [
572
+ columnOf({ name: 'email', label: t('dashboard.membersEmail', 'Email'), cell: 'invite', locale }),
573
+ columnOf({ name: 'role', label: t('dashboard.addPersonRole', 'Role'), cell: 'invite', locale }),
574
+ columnOf({ name: 'inviter', label: t('dashboard.inviteSentBy', 'Sent by'), cell: 'invite', locale }),
575
+ columnOf({ name: 'expiresAt', label: t('dashboard.inviteExpires', 'Expires'), cell: 'expires', locale }),
576
+ ]
577
+ const cellsOf = (columns: ClientField[]) => new Map(columns.map((column) => [column.name, column.cell]))
578
+ const filtered = Boolean(term || roleShown)
579
+ // Development has no mailer, so the page says how an invitation or a password link reaches anyone there.
580
+ const noMailer = development && (invites || adds) ? (
581
+ <p data-no-mailer="" role="note" className="text-muted-foreground rounded-md border border-dashed px-3 py-2 text-sm">
582
+ {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.')}
583
+ </p>
584
+ ) : null
585
+ const inviteButton = (primary: boolean) => (
586
+ <Button type="button" size="sm" variant={primary ? 'default' : 'outline'} className="max-sm:h-11" data-invite="" onClick={() => inviteSheet.current?.showModal()}>
587
+ {t('dashboard.invite', 'Invite')}
588
+ </Button>
589
+ )
590
+
591
+ return (
592
+ <AppShell
593
+ regions={{
594
+ header: (
595
+ <div className="flex min-w-0 flex-1 items-center justify-between gap-2">
596
+ <h1 className="truncate">
597
+ {t('dashboard.members', 'Members')} <span className="text-muted-foreground">· {new Intl.NumberFormat(locale).format(members.length)}</span>
598
+ </h1>
599
+ {adds ? (
600
+ // Add person first, and inviting by a link to copy beside it, for someone who already has an account
601
+ // elsewhere or whom the mail does not reach.
602
+ <ButtonGroup aria-label={t('dashboard.addPerson', 'Add person')}>
603
+ <Button type="button" size="sm" className="max-sm:h-11" data-add-person="" onClick={() => addSheet.current?.showModal()}>
604
+ {t('dashboard.addPerson', 'Add person')}
605
+ </Button>
606
+ {invites ? (
607
+ <DropdownMenu>
608
+ <DropdownMenuTrigger data-invite-menu="" aria-label={t('dashboard.inviteByLink', 'Invite by link')} className={buttonVariants({ size: 'sm', className: 'border-s border-s-primary-foreground/30 max-sm:h-11' })}>
609
+ <ChevronDown className="size-4" aria-hidden="true" />
610
+ </DropdownMenuTrigger>
611
+ <DropdownMenuContent align="end" className="w-56">
612
+ <DropdownMenuItem data-invite="" className="max-sm:min-h-11" onClick={() => inviteSheet.current?.showModal()}>
613
+ {t('dashboard.inviteByLink', 'Invite by link')}
614
+ </DropdownMenuItem>
615
+ </DropdownMenuContent>
616
+ </DropdownMenu>
617
+ ) : null}
618
+ </ButtonGroup>
619
+ ) : invites ? inviteButton(true) : null}
620
+ </div>
621
+ ),
622
+ main: (
623
+ <section data-members="" className="flex w-full min-w-0 flex-1 flex-col gap-4">
624
+ <p className="text-muted-foreground text-sm">{t('dashboard.membersAbout', 'Who works in this organization, and the role each holds.')}</p>
625
+ {noMailer}
626
+ <Tabs value={tab} onValueChange={(next) => setTab(String(next))}>
627
+ {invites ? (
628
+ <TabsList variant="line" className="max-sm:min-h-12">
629
+ <TabsTrigger value="members" data-members-tab="members" className="flex-none max-sm:min-h-11">{t('dashboard.members', 'Members')}</TabsTrigger>
630
+ <TabsTrigger value="invitations" data-members-tab="invitations" className="flex-none max-sm:min-h-11">
631
+ {(t('dashboard.invitationsTab', 'Invitations ({count})') ?? 'Invitations ({count})').replace('{count}', () => new Intl.NumberFormat(locale).format(pending.length))}
632
+ </TabsTrigger>
633
+ </TabsList>
634
+ ) : null}
635
+ {/* Both stay in the first paint, so a server render carries every word and every row of either. */}
636
+ <TabsContent value="members" keepMounted className="data-[hidden]:hidden">
637
+ <Card>
638
+ <CardContent className="grid gap-4">
639
+ <div className="flex flex-col gap-2 lg:flex-row lg:items-center">
640
+ <Search value={search} fields={[t('dashboard.membersName', 'Name') ?? 'Name', t('dashboard.membersEmail', 'Email') ?? 'Email']} locale={locale} t={t} onSearch={setSearch} />
641
+ <label htmlFor={roleFilter} className="sr-only">{t('dashboard.membersRoleFilter', 'Role')}</label>
642
+ <select id={roleFilter} data-role-filter="" value={roleShown} className="border-input bg-background h-9 rounded-md border px-3 text-sm max-sm:h-11 lg:w-56" onChange={(event) => setRoleShown(event.target.value)}>
643
+ <option value="">{t('dashboard.membersEveryRole', 'Every role')}</option>
644
+ {roles.map((one) => <option key={one.name} value={one.name}>{one.label?.[locale] ?? one.name}</option>)}
645
+ </select>
646
+ </div>
647
+ <ListTable
648
+ kayan={memberTable.kayan}
649
+ screen={memberTable}
650
+ copy={{ t, locale }}
651
+ shown={memberColumns}
652
+ cellOf={cellsOf(memberColumns)}
653
+ titles={new Map()}
654
+ docs={shown.map((member) => ({ id: member.userId, name: member.name, email: member.email, person: member.name, roles: [...new Set([...member.roles.map((one) => labelOf(one.role)), ...(member.owner && !member.roles.some((one) => one.role === OWNER_ROLE) ? [labelOf(OWNER_ROLE)] : [])])], joined: member.joined, lastSeen: member.lastSeen }))}
655
+ onSort={() => {}}
656
+ href={() => '#'}
657
+ openHint={openHint}
658
+ openRow={() => {}}
659
+ table={table}
660
+ unreadable={false}
661
+ actions={(row) => {
662
+ const member = members.find((one) => one.userId === row.id)
663
+ if (!member) return null
664
+ const linkable = adds && member.email !== me
665
+ const takes = removable(member)
666
+ return edits || linkable || takes ? (
667
+ <RowActions id={member.userId} label={rowActionsLabel(member.name)}>
668
+ {edits ? <DropdownMenuItem data-row-action="edit-roles" className="max-sm:min-h-11" onClick={() => setEditing(member)}>{t('dashboard.membersEdit', 'Edit roles')}</DropdownMenuItem> : null}
669
+ {linkable ? <DropdownMenuItem data-row-action="password-link" className="max-sm:min-h-11" onClick={() => void sendLink(member)}>{t('dashboard.passwordLink', 'Send a new password link')}</DropdownMenuItem> : null}
670
+ {takes ? <DropdownMenuItem data-row-action="remove" variant="destructive" className="max-sm:min-h-11" disabled={busy} onClick={() => setRemoving(member)}>{t('dashboard.removeMember', 'Remove from organization')}</DropdownMenuItem> : null}
671
+ </RowActions>
672
+ ) : null
673
+ }}
674
+ >
675
+ <ListMessage
676
+ count={shown.length}
677
+ filtered={filtered}
678
+ kayan={memberTable.kayan}
679
+ empty={t('dashboard.membersNone', 'No one works in this organization yet.')}
680
+ onClear={() => {
681
+ setSearch(undefined)
682
+ setRoleShown('')
683
+ }}
684
+ t={t}
685
+ unreadable={false}
686
+ />
687
+ </ListTable>
688
+ </CardContent>
689
+ </Card>
690
+ </TabsContent>
691
+ {invites ? (
692
+ <TabsContent value="invitations" keepMounted className="data-[hidden]:hidden">
693
+ <Card>
694
+ <CardContent className="grid gap-4">
695
+ <p className="text-muted-foreground text-sm">{t('dashboard.inviteLinkOwn', 'A copied link lets whoever holds it join as the invited address: hand it to that person alone.')}</p>
696
+ <ListTable
697
+ kayan={inviteTable.kayan}
698
+ screen={inviteTable}
699
+ copy={{ t, locale }}
700
+ shown={inviteColumns}
701
+ cellOf={cellsOf(inviteColumns)}
702
+ titles={new Map()}
703
+ docs={pending.map((one) => ({ id: one.id, email: one.email, role: t(`backoffice.membership.${one.role}`) ?? one.role, inviter: one.inviter, expiresAt: one.expiresAt, expired: one.expired }))}
704
+ onSort={() => {}}
705
+ href={() => '#'}
706
+ openHint={openHint}
707
+ openRow={() => {}}
708
+ table={inviteTableRef}
709
+ unreadable={false}
710
+ actions={(row) => {
711
+ const one = pending.find((invited) => invited.id === row.id)
712
+ return one ? (
713
+ <RowActions id={one.id} label={rowActionsLabel(one.email)}>
714
+ {/* A link run out joins nobody, so it is sent again rather than copied. */}
715
+ {one.expired ? null : <DropdownMenuItem data-row-action="copy-link" className="max-sm:min-h-11" onClick={() => void copyLink(one)}>{t('dashboard.inviteCopy', 'Copy link')}</DropdownMenuItem>}
716
+ <DropdownMenuItem data-row-action="resend" className="max-sm:min-h-11" onClick={() => void resend(one)}>{t('dashboard.inviteResend', 'Resend')}</DropdownMenuItem>
717
+ {cancels ? <DropdownMenuItem data-row-action="cancel" variant="destructive" className="max-sm:min-h-11" onClick={() => void cancel(one)}>{t('dashboard.inviteCancel', 'Cancel invitation')}</DropdownMenuItem> : null}
718
+ </RowActions>
719
+ ) : null
720
+ }}
721
+ >
722
+ <ListMessage count={pending.length} filtered={false} kayan={inviteTable.kayan} empty={t('dashboard.invitesNone', 'No invitation is waiting to be accepted.')} onClear={() => {}} t={t} unreadable={false} />
723
+ </ListTable>
724
+ </CardContent>
725
+ </Card>
726
+ </TabsContent>
727
+ ) : null}
728
+ </Tabs>
729
+ <span id={openHint} hidden />
730
+ {adds ? <AddPerson sheet={addSheet} roles={roles.filter((role) => assignments?.create === true && grantable(access, role.permissions))} onInvited={(made) => (listInvite(made), setTab('invitations'))} /> : null}
731
+ {/* The invitation asks in a modal `<dialog>`: a closed dialog is in the first paint, which carries every
732
+ word it shows. Closing it, however it is closed, empties it, so the next invitation starts from nothing. */}
733
+ {invites ? (
734
+ <dialog
735
+ ref={inviteSheet}
736
+ aria-labelledby={inviteTitle}
737
+ data-invite-sheet=""
738
+ className="bg-background text-foreground m-auto w-[min(28rem,calc(100%-2rem))] rounded-lg border p-0 shadow-lg backdrop:bg-black/80"
739
+ onClose={() => {
740
+ setEmail('')
741
+ setEmailLeft(false)
742
+ setInviteRefusal(undefined)
743
+ }}
744
+ >
745
+ <form
746
+ noValidate
747
+ className="grid gap-4 p-6 text-sm"
748
+ onSubmit={(event) => {
749
+ event.preventDefault()
750
+ void invite()
751
+ }}
752
+ >
753
+ <header className="grid gap-1.5">
754
+ <h2 id={inviteTitle} className="text-base font-medium">{t('dashboard.inviteTitle', 'Invite someone')}</h2>
755
+ <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>
756
+ </header>
757
+ {noMailer}
758
+ <FieldShell id={emailId} label={t('dashboard.membersEmail', 'Email') ?? 'Email'} error={emailError} onBlur={() => setEmailLeft(true)}>
759
+ <Input
760
+ ref={emailBox}
761
+ id={emailId}
762
+ name="email"
763
+ type="email"
764
+ dir="ltr"
765
+ autoComplete="off"
766
+ disabled={inviting}
767
+ value={email}
768
+ aria-required
769
+ aria-invalid={emailError ? true : undefined}
770
+ aria-describedby={describedBy({ id: emailId, error: emailError })}
771
+ onChange={(event) => setEmail(event.target.value)}
772
+ />
773
+ </FieldShell>
774
+ {inviteRefusal ? <p role="alert" className="text-destructive">{inviteRefusal}</p> : null}
775
+ <footer className="flex justify-end gap-2">
776
+ <Button type="button" variant="outline" className="max-sm:h-11" onClick={() => inviteSheet.current?.close()}>{t('admin.cancel', 'Cancel')}</Button>
777
+ <Button type="submit" className="max-sm:h-11" disabled={inviting}>{t('dashboard.inviteSend', 'Send invitation')}</Button>
778
+ </footer>
779
+ </form>
780
+ </dialog>
781
+ ) : null}
782
+ {adds ? (
783
+ <dialog
784
+ ref={handedSheet}
785
+ data-password-link-sheet=""
786
+ aria-label={t('dashboard.passwordLink', 'Send a new password link')}
787
+ className="bg-background text-foreground m-auto w-[min(28rem,calc(100%-2rem))] rounded-lg border p-0 shadow-lg backdrop:bg-black/80"
788
+ onClose={() => setHanded(undefined)}
789
+ >
790
+ <div className="grid gap-4 p-6 text-sm">
791
+ <h2 className="text-base font-medium">{t('dashboard.passwordLink', 'Send a new password link')}</h2>
792
+ {handed ? (
793
+ <HandedLink link={handed.link} said={(t('dashboard.passwordLinkHanded', 'No mailer is configured. Hand this link to {name} alone: it chooses their password and lasts 48 hours.') ?? '').replace('{name}', () => handed.name)} />
794
+ ) : null}
795
+ <footer className="flex justify-end">
796
+ <Button type="button" variant="outline" className="max-sm:h-11" onClick={() => handedSheet.current?.close()}>{t('admin.close', 'Close')}</Button>
797
+ </footer>
798
+ </div>
799
+ </dialog>
800
+ ) : null}
801
+ {editing ? <RolesDrawer member={editing} roles={roles} onClose={() => setEditing(undefined)} onHeld={(held) => hold(editing, held)} /> : null}
802
+ <AlertDialog open={removing !== undefined} onOpenChange={(open: boolean) => (open ? undefined : setRemoving(undefined))}>
803
+ <AlertDialogContent data-remove-member="">
804
+ <AlertDialogHeader>
805
+ <AlertDialogTitle>{(t('dashboard.removeTitle', 'Remove {name}?') ?? 'Remove {name}?').replace('{name}', () => removing?.name ?? '')}</AlertDialogTitle>
806
+ <AlertDialogDescription>{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.')}</AlertDialogDescription>
807
+ </AlertDialogHeader>
808
+ <AlertDialogFooter className="max-sm:[&_button]:h-11">
809
+ <AlertDialogCancel>{t('dashboard.removeKeep', 'Keep this member')}</AlertDialogCancel>
810
+ <AlertDialogAction
811
+ data-button="confirm-remove"
812
+ variant="destructive"
813
+ onClick={() => {
814
+ const member = removing
815
+ setRemoving(undefined)
816
+ if (member) void remove(member)
817
+ }}
818
+ >
819
+ {t('dashboard.removeConfirm', 'Remove for good')}
820
+ </AlertDialogAction>
821
+ </AlertDialogFooter>
822
+ </AlertDialogContent>
823
+ </AlertDialog>
824
+ </section>
825
+ ),
826
+ }}
827
+ />
828
+ )
829
+ }