create-mithaq 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (348) hide show
  1. package/LICENSES/openstatus-time-picker.txt +21 -0
  2. package/index.js +457 -63
  3. package/package.json +1 -1
  4. package/template/apps/{asas-platform → asas-backoffice}/package.json +5 -3
  5. package/template/apps/{asas-platform → asas-backoffice}/src/api.ts +1 -1
  6. package/template/apps/{asas-platform → asas-backoffice}/src/auth-frame.tsx +12 -12
  7. package/template/apps/asas-backoffice/src/components.ts +35 -0
  8. package/template/apps/asas-backoffice/src/first-user.ts +14 -0
  9. package/template/apps/{asas-platform → asas-backoffice}/src/links.ts +1 -1
  10. package/template/apps/{asas-platform → asas-backoffice}/src/locale-switch.tsx +7 -5
  11. package/template/apps/asas-backoffice/src/no-organization.tsx +5 -0
  12. package/template/apps/asas-backoffice/src/redirect.ts +1 -0
  13. package/template/apps/{asas-platform → asas-backoffice}/src/request-headers.ts +1 -1
  14. package/template/apps/asas-backoffice/src/routeTree.gen.ts +206 -0
  15. package/template/apps/asas-backoffice/src/router.tsx +18 -0
  16. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/files.ts +10 -0
  17. package/template/apps/{asas-platform/src/routes/(screens)/platform → asas-backoffice/src/routes/(screens)/backoffice}/screen.json +426 -64
  18. package/template/apps/{asas-platform/src/routes/(screens)/platform → asas-backoffice/src/routes/(screens)/backoffice}/types.ts +18 -0
  19. package/template/apps/{asas-platform/src/routes/(screens)/platform.$.tsx → asas-backoffice/src/routes/(screens)/backoffice.$.tsx} +4 -4
  20. package/template/apps/{asas-platform/src/routes/(screens)/platform.index.tsx → asas-backoffice/src/routes/(screens)/backoffice.index.tsx} +3 -3
  21. package/template/apps/{asas-platform/src/routes/(screens)/platform.tsx → asas-backoffice/src/routes/(screens)/backoffice.tsx} +3 -3
  22. package/template/apps/{asas-platform → asas-backoffice}/src/routes/__root.tsx +2 -2
  23. package/template/apps/asas-backoffice/src/routes/api/$.ts +34 -0
  24. package/template/apps/asas-backoffice/src/routes/auth.create-first-user.tsx +113 -0
  25. package/template/apps/{asas-platform → asas-backoffice}/src/routes/auth.sign-in.tsx +9 -4
  26. package/template/apps/{asas-platform → asas-backoffice}/src/routes/unauthorized.tsx +24 -12
  27. package/template/apps/{asas-platform → asas-backoffice}/src/screen-form.tsx +5 -3
  28. package/template/apps/{asas-platform → asas-backoffice}/src/screen-list.tsx +6 -5
  29. package/template/apps/{asas-platform → asas-backoffice}/src/screen-names.ts +1 -1
  30. package/template/apps/{asas-platform → asas-backoffice}/src/screens.tsx +46 -26
  31. package/template/apps/asas-backoffice/src/server.ts +30 -0
  32. package/template/apps/{asas-platform → asas-backoffice}/src/slide.ts +1 -1
  33. package/template/apps/{asas-platform → asas-backoffice}/src/start.ts +1 -1
  34. package/template/apps/{asas-platform → asas-backoffice}/src/styles.css +9 -7
  35. package/template/apps/{asas-platform → asas-backoffice}/tsconfig.json +2 -2
  36. package/template/apps/{asas-platform → asas-backoffice}/vite.config.ts +7 -1
  37. package/template/apps/asas-tanstack/package.json +4 -2
  38. package/template/apps/asas-tanstack/src/api.ts +1 -1
  39. package/template/apps/asas-tanstack/src/auth-frame.tsx +12 -12
  40. package/template/apps/asas-tanstack/src/choose-password.tsx +150 -0
  41. package/template/apps/asas-tanstack/src/components.ts +35 -0
  42. package/template/apps/asas-tanstack/src/copy.ts +416 -79
  43. package/template/apps/asas-tanstack/src/first-run.ts +16 -0
  44. package/template/apps/asas-tanstack/src/links.ts +1 -1
  45. package/template/apps/asas-tanstack/src/locale-switch.tsx +7 -5
  46. package/template/apps/asas-tanstack/src/no-organization.tsx +108 -0
  47. package/template/apps/asas-tanstack/src/redirect.ts +20 -1
  48. package/template/apps/asas-tanstack/src/request-headers.ts +1 -1
  49. package/template/apps/asas-tanstack/src/routeTree.gen.ts +42 -0
  50. package/template/apps/asas-tanstack/src/router.tsx +12 -1
  51. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +9 -2
  52. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +475 -116
  53. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +4 -2
  54. package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +7 -2
  55. package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +83 -319
  56. package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +3 -1
  57. package/template/apps/asas-tanstack/src/routes/__root.tsx +1 -1
  58. package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +14 -7
  59. package/template/apps/asas-tanstack/src/routes/auth.first-run.tsx +114 -0
  60. package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +2 -2
  61. package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +5 -5
  62. package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +7 -100
  63. package/template/apps/asas-tanstack/src/routes/auth.set-password.tsx +16 -0
  64. package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +27 -15
  65. package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +59 -38
  66. package/template/apps/asas-tanstack/src/routes/index.tsx +18 -14
  67. package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +24 -12
  68. package/template/apps/asas-tanstack/src/screen-form.tsx +5 -3
  69. package/template/apps/asas-tanstack/src/screen-list.tsx +6 -5
  70. package/template/apps/asas-tanstack/src/screen-names.ts +1 -1
  71. package/template/apps/asas-tanstack/src/screens.tsx +46 -26
  72. package/template/apps/asas-tanstack/src/server.ts +10 -0
  73. package/template/apps/asas-tanstack/src/slide.ts +1 -1
  74. package/template/apps/asas-tanstack/src/styles.css +7 -3
  75. package/template/apps/asas-tanstack/tsconfig.json +2 -2
  76. package/template/apps/asas-tanstack/vite.config.ts +7 -1
  77. package/template/package.json +3 -3
  78. package/template/templates/asas/.claude/skills/add-field/SKILL.md +27 -0
  79. package/template/templates/asas/.claude/skills/add-kayan/SKILL.md +26 -0
  80. package/template/templates/asas/.claude/skills/eject-admin-piece/SKILL.md +28 -0
  81. package/template/templates/asas/.claude/skills/upgrade-mithaq/SKILL.md +35 -0
  82. package/template/templates/asas/.env.example +22 -5
  83. package/template/templates/asas/AGENTS.md +101 -0
  84. package/template/templates/asas/CLAUDE.md +1 -0
  85. package/template/templates/asas/README.md +1056 -0
  86. package/template/templates/asas/package.json +19 -4
  87. package/template/templates/asas/src/asas.ts +81 -56
  88. package/template/templates/asas/src/{platform-http.ts → backoffice-http.ts} +48 -27
  89. package/template/templates/asas/src/dev-setup.ts +112 -0
  90. package/template/templates/asas/src/local-database.ts +22 -0
  91. package/template/templates/asas/src/messages/account.json +129 -103
  92. package/template/templates/asas/src/messages/auditLog.json +223 -105
  93. package/template/templates/asas/src/messages/backofficeLog.json +132 -0
  94. package/template/templates/asas/src/messages/courses.json +256 -214
  95. package/template/templates/asas/src/messages/enrollments.json +45 -27
  96. package/template/templates/asas/src/messages/invitation.json +72 -56
  97. package/template/templates/asas/src/messages/localUsers.json +34 -28
  98. package/template/templates/asas/src/messages/media.json +31 -21
  99. package/template/templates/asas/src/messages/member.json +59 -39
  100. package/template/templates/asas/src/messages/memberRoles.json +37 -31
  101. package/template/templates/asas/src/messages/mithaq.json +206 -162
  102. package/template/templates/asas/src/messages/organization.json +63 -41
  103. package/template/templates/asas/src/messages/organizationRole.json +59 -47
  104. package/template/templates/asas/src/messages/roles.json +59 -45
  105. package/template/templates/asas/src/messages/session.json +99 -79
  106. package/template/templates/asas/src/messages/sessions.json +110 -80
  107. package/template/templates/asas/src/messages/settings.json +51 -53
  108. package/template/templates/asas/src/messages/tags.json +31 -21
  109. package/template/templates/asas/src/messages/user.json +109 -87
  110. package/template/templates/asas/src/messages/verification.json +59 -47
  111. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/migration.sql +1 -0
  112. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/snapshot.json +3881 -0
  113. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/migration.sql +1 -0
  114. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/snapshot.json +3894 -0
  115. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/migration.sql +1 -0
  116. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/snapshot.json +3907 -0
  117. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/migration.sql +1 -0
  118. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/snapshot.json +3907 -0
  119. package/template/templates/asas/src/migrations/20261015042444_organization_names/migration.sql +1 -0
  120. package/template/templates/asas/src/migrations/20261015042444_organization_names/snapshot.json +3920 -0
  121. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/migration.sql +1 -0
  122. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/snapshot.json +3933 -0
  123. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/migration.sql +15 -0
  124. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/snapshot.json +4113 -0
  125. package/template/templates/asas/src/migrations/20261015095752_portal/migration.sql +2 -0
  126. package/template/templates/asas/src/migrations/20261015095752_portal/snapshot.json +4113 -0
  127. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/migration.sql +9 -0
  128. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/snapshot.json +4302 -0
  129. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/migration.sql +27 -0
  130. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/snapshot.json +4289 -0
  131. package/template/templates/asas/src/migrations/schema.ts +1 -0
  132. package/template/templates/asas/src/mithaq.ts +4 -406
  133. package/template/templates/asas/src/organizations.ts +3 -0
  134. package/template/templates/asas/src/seed.ts +147 -45
  135. package/template/templates/asas/src/{ui-platform → ui-backoffice}/home.tsx +9 -9
  136. package/template/templates/asas/src/ui-backoffice/organization-actions.tsx +121 -0
  137. package/template/templates/asas/src/{ui-platform → ui-backoffice}/record-page.tsx +4 -4
  138. package/template/templates/asas/src/{ui-platform → ui-backoffice}/role-cells.tsx +13 -6
  139. package/template/templates/asas/src/{ui-platform → ui-backoffice}/user-actions.tsx +46 -48
  140. package/template/templates/asas/src/ui-dashboard/dashboard.tsx +10 -4
  141. package/template/templates/asas/src/ui-dashboard/home.tsx +73 -0
  142. package/template/templates/asas/src/ui-dashboard/members-card.tsx +147 -0
  143. package/template/templates/asas/src/ui-dashboard/members.tsx +637 -438
  144. package/template/templates/asas/src/ui-dashboard/versions.tsx +22 -8
  145. package/template/templates/asas/src/ui-portal/courses-home.tsx +96 -0
  146. package/template/templates/asas/src/ui-portal/home.tsx +9 -13
  147. package/template/templates/features/audit-log/audit-log/audit-log.ts +284 -0
  148. package/template/templates/features/audit-log/backoffice-log/backoffice-log.ts +67 -0
  149. package/template/templates/features/audit-log/index.ts +4 -0
  150. package/template/templates/features/audit-log/package.json +13 -0
  151. package/template/templates/features/auth/index.ts +11 -0
  152. package/template/templates/{asas/src → features/auth}/local-users/local-users.ts +7 -7
  153. package/template/templates/{auth → features/auth}/package.json +2 -3
  154. package/template/templates/{asas/src/kayan → features}/auth/user/account.access.ts +2 -2
  155. package/template/templates/{asas/src/kayan → features}/auth/user/account.ts +2 -2
  156. package/template/templates/{asas/src/kayan → features}/auth/user/invitation.access.ts +1 -1
  157. package/template/templates/{asas/src/kayan → features}/auth/user/invitation.ts +3 -3
  158. package/template/templates/{asas/src/kayan → features}/auth/user/member.access.ts +4 -4
  159. package/template/templates/{asas/src/kayan → features}/auth/user/member.screens.ts +5 -5
  160. package/template/templates/{asas/src/kayan → features}/auth/user/member.ts +4 -3
  161. package/template/templates/{asas/src/kayan → features}/auth/user/organization.access.ts +6 -6
  162. package/template/templates/{asas/src/kayan → features}/auth/user/organization.screens.ts +6 -6
  163. package/template/templates/{asas/src/kayan → features}/auth/user/organization.ts +2 -1
  164. package/template/templates/{asas/src/kayan → features}/auth/user/organizationRole.access.ts +2 -2
  165. package/template/templates/{asas/src/kayan → features}/auth/user/organizationRole.ts +3 -3
  166. package/template/templates/{asas/src/kayan → features}/auth/user/session.access.ts +2 -2
  167. package/template/templates/{asas/src/kayan → features}/auth/user/session.ts +2 -2
  168. package/template/templates/{asas/src/kayan → features}/auth/user/user.access.ts +8 -7
  169. package/template/templates/{asas/src/kayan → features}/auth/user/user.screens.ts +9 -9
  170. package/template/templates/{asas/src/kayan → features}/auth/user/user.ts +2 -2
  171. package/template/templates/{asas/src/kayan → features}/auth/user/verification.access.ts +2 -2
  172. package/template/templates/{asas/src/kayan → features}/auth/user/verification.ts +2 -2
  173. package/template/templates/{asas/src → features/courses}/courses/courses.ts +122 -133
  174. package/template/templates/features/courses/courses/cover-reads.ts +33 -0
  175. package/template/templates/features/courses/courses/ui/archived-badge.tsx +9 -0
  176. package/template/templates/{asas/src → features/courses}/courses/ui/fee-cell.tsx +1 -1
  177. package/template/templates/{asas/src → features/courses}/courses/ui/status-cell.tsx +5 -3
  178. package/template/templates/features/courses/courses/ui/unpublish-button.tsx +15 -0
  179. package/template/templates/features/courses/domain/archive.ts +39 -0
  180. package/template/templates/features/courses/domain/on-offer.ts +6 -0
  181. package/template/templates/features/courses/enrollments/enrollments.ts +309 -0
  182. package/template/templates/{asas/src → features/courses}/enrollments/ui/session-cell.tsx +17 -7
  183. package/template/templates/{asas/src → features/courses/enrollments}/ui/session-labels.tsx +3 -3
  184. package/template/templates/features/courses/index.ts +8 -0
  185. package/template/templates/features/courses/package.json +24 -0
  186. package/template/templates/features/courses/portal/ui/catalogue.tsx +86 -0
  187. package/template/templates/features/courses/portal/ui/course.tsx +176 -0
  188. package/template/templates/features/courses/portal/ui/enrollments.tsx +170 -0
  189. package/template/templates/features/courses/portal/ui/parts.tsx +95 -0
  190. package/template/templates/features/courses/portal/ui/reads.ts +76 -0
  191. package/template/templates/features/courses/portal/ui/rules.ts +87 -0
  192. package/template/templates/features/courses/portal/ui/shell.tsx +194 -0
  193. package/template/templates/features/courses/sessions/seats.ts +42 -0
  194. package/template/templates/{asas/src → features/courses}/sessions/sessions.ts +45 -14
  195. package/template/templates/features/media/index.ts +10 -0
  196. package/template/templates/{asas/src → features/media}/media/media.ts +34 -40
  197. package/template/templates/{asas/src → features/media}/media/ui/preview.tsx +2 -2
  198. package/template/templates/features/media/package.json +19 -0
  199. package/template/templates/features/roles/index.ts +4 -0
  200. package/template/templates/{asas/src → features/roles}/member-roles/member-roles.ts +15 -15
  201. package/template/templates/features/roles/package.json +21 -0
  202. package/template/templates/{asas/src → features/roles}/roles/roles.ts +23 -23
  203. package/template/templates/{asas/src → features/roles}/roles/ui/role-form.tsx +65 -24
  204. package/template/templates/{asas/src → features/roles}/roles/ui/roles.tsx +49 -25
  205. package/template/templates/features/settings/index.ts +3 -0
  206. package/template/templates/features/settings/package.json +13 -0
  207. package/template/templates/{asas/src → features/settings}/settings/settings.ts +12 -17
  208. package/template/templates/features/tags/index.ts +3 -0
  209. package/template/templates/features/tags/package.json +14 -0
  210. package/template/templates/{asas/src → features/tags}/tags/tags.ts +15 -15
  211. package/template/templates/mithaq/package.json +33 -0
  212. package/template/templates/mithaq/src/mail/index.ts +32 -0
  213. package/template/templates/mithaq/src/mail/mailers.ts +36 -0
  214. package/template/templates/{asas/src/audit-log/sink.ts → mithaq/src/mithaq/audit-sink.ts} +31 -9
  215. package/template/templates/{auth/src/platform-roles.ts → mithaq/src/mithaq/auth/backoffice-roles.ts} +44 -23
  216. package/template/templates/mithaq/src/mithaq/auth/index.ts +1859 -0
  217. package/template/templates/mithaq/src/mithaq/auth/organizations.ts +80 -0
  218. package/template/templates/{asas/src → mithaq/src/mithaq}/pages.ts +16 -7
  219. package/template/templates/mithaq/src/mithaq/rich-text-nodes.ts +19 -0
  220. package/template/templates/mithaq/src/mithaq.ts +593 -0
  221. package/template/templates/mithaq/src/roles/default-member-roles.ts +29 -0
  222. package/template/templates/{asas → mithaq}/src/roles/grants.ts +1 -1
  223. package/template/templates/mithaq/src/roles/member-access.ts +11 -0
  224. package/template/templates/{asas/src → mithaq/src/roles}/statements.ts +6 -4
  225. package/template/templates/mithaq/src/roles/system-roles.ts +16 -0
  226. package/template/templates/{asas/src → mithaq/src/roles}/tenant-access.ts +96 -57
  227. package/template/templates/{asas/src → mithaq/src/roles}/tenant-roles.ts +84 -25
  228. package/template/templates/{asas → mithaq}/src/steps/expected-updated-at.ts +2 -2
  229. package/template/templates/{asas → mithaq}/src/storage/index.ts +3 -1
  230. package/template/templates/mithaq/src/ui/activity-page.tsx +33 -0
  231. package/template/templates/mithaq/src/ui/activity.tsx +527 -0
  232. package/template/templates/{asas → mithaq}/src/ui/app-shell.tsx +27 -19
  233. package/template/templates/mithaq/src/ui/rich-text-nodes.tsx +33 -0
  234. package/template/templates/{asas → mithaq}/src/ui/screen-nav.tsx +1 -1
  235. package/template/templates/{asas → mithaq}/src/ui/top-shell.tsx +8 -8
  236. package/template/apps/asas-platform/src/components/mithaq/cells.tsx +0 -112
  237. package/template/apps/asas-platform/src/components/mithaq/controls.tsx +0 -650
  238. package/template/apps/asas-platform/src/components/mithaq/field-shell.tsx +0 -53
  239. package/template/apps/asas-platform/src/components/mithaq/filter-operators.ts +0 -108
  240. package/template/apps/asas-platform/src/components/mithaq/form.tsx +0 -1467
  241. package/template/apps/asas-platform/src/components/mithaq/list-columns.tsx +0 -108
  242. package/template/apps/asas-platform/src/components/mithaq/list-filters.tsx +0 -368
  243. package/template/apps/asas-platform/src/components/mithaq/list-pagination.tsx +0 -153
  244. package/template/apps/asas-platform/src/components/mithaq/list-query.ts +0 -432
  245. package/template/apps/asas-platform/src/components/mithaq/list-selection.tsx +0 -74
  246. package/template/apps/asas-platform/src/components/mithaq/list.tsx +0 -865
  247. package/template/apps/asas-platform/src/components/mithaq/nav.tsx +0 -871
  248. package/template/apps/asas-platform/src/components/mithaq/nodes.tsx +0 -1024
  249. package/template/apps/asas-platform/src/components/mithaq/relationship-cell.tsx +0 -127
  250. package/template/apps/asas-platform/src/components/mithaq/relationship-control.tsx +0 -400
  251. package/template/apps/asas-platform/src/components/mithaq/rich-text-nodes.ts +0 -6
  252. package/template/apps/asas-platform/src/components/mithaq/rich-text.tsx +0 -1083
  253. package/template/apps/asas-platform/src/components/mithaq/upload.tsx +0 -686
  254. package/template/apps/asas-platform/src/components/ui/drawer.tsx +0 -216
  255. package/template/apps/asas-platform/src/redirect.ts +0 -1
  256. package/template/apps/asas-platform/src/routeTree.gen.ts +0 -181
  257. package/template/apps/asas-platform/src/router.tsx +0 -7
  258. package/template/apps/asas-platform/src/routes/(screens)/platform/files.ts +0 -4
  259. package/template/apps/asas-platform/src/routes/api/$.ts +0 -30
  260. package/template/apps/asas-platform/src/server.ts +0 -20
  261. package/template/apps/asas-tanstack/src/components/mithaq/cells.tsx +0 -112
  262. package/template/apps/asas-tanstack/src/components/mithaq/controls.tsx +0 -650
  263. package/template/apps/asas-tanstack/src/components/mithaq/field-shell.tsx +0 -53
  264. package/template/apps/asas-tanstack/src/components/mithaq/filter-operators.ts +0 -108
  265. package/template/apps/asas-tanstack/src/components/mithaq/form.tsx +0 -1467
  266. package/template/apps/asas-tanstack/src/components/mithaq/list-columns.tsx +0 -108
  267. package/template/apps/asas-tanstack/src/components/mithaq/list-filters.tsx +0 -368
  268. package/template/apps/asas-tanstack/src/components/mithaq/list-pagination.tsx +0 -153
  269. package/template/apps/asas-tanstack/src/components/mithaq/list-query.ts +0 -432
  270. package/template/apps/asas-tanstack/src/components/mithaq/list-selection.tsx +0 -74
  271. package/template/apps/asas-tanstack/src/components/mithaq/list.tsx +0 -865
  272. package/template/apps/asas-tanstack/src/components/mithaq/nav.tsx +0 -871
  273. package/template/apps/asas-tanstack/src/components/mithaq/nodes.tsx +0 -1024
  274. package/template/apps/asas-tanstack/src/components/mithaq/relationship-cell.tsx +0 -127
  275. package/template/apps/asas-tanstack/src/components/mithaq/relationship-control.tsx +0 -400
  276. package/template/apps/asas-tanstack/src/components/mithaq/rich-text-nodes.ts +0 -6
  277. package/template/apps/asas-tanstack/src/components/mithaq/rich-text.tsx +0 -1083
  278. package/template/apps/asas-tanstack/src/components/mithaq/upload.tsx +0 -686
  279. package/template/apps/asas-tanstack/src/components/ui/drawer.tsx +0 -215
  280. package/template/templates/asas/src/audit-log/audit-log.ts +0 -187
  281. package/template/templates/asas/src/audit-log/ui/activity.tsx +0 -243
  282. package/template/templates/asas/src/courses/ui/archive-button.tsx +0 -25
  283. package/template/templates/asas/src/courses/ui/no-courses-on-offer.tsx +0 -11
  284. package/template/templates/asas/src/courses/ui/on-offer.ts +0 -6
  285. package/template/templates/asas/src/enrollments/enrollments.ts +0 -137
  286. package/template/templates/asas/src/rich-text-nodes.ts +0 -11
  287. package/template/templates/asas/src/roles.ts +0 -16
  288. package/template/templates/asas/src/ui/rich-text-nodes.tsx +0 -15
  289. package/template/templates/asas/src/ui-platform/organization-actions.tsx +0 -114
  290. package/template/templates/auth/src/index.ts +0 -690
  291. /package/template/apps/{asas-platform → asas-backoffice}/components.json +0 -0
  292. /package/template/apps/{asas-platform → asas-backoffice}/gitignore +0 -0
  293. /package/template/apps/{asas-platform → asas-backoffice}/public/favicon.svg +0 -0
  294. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/accordion.tsx +0 -0
  295. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/alert-dialog.tsx +0 -0
  296. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/avatar.tsx +0 -0
  297. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/badge.tsx +0 -0
  298. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/breadcrumb.tsx +0 -0
  299. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/button-group.tsx +0 -0
  300. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/button.tsx +0 -0
  301. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/calendar.tsx +0 -0
  302. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/card.tsx +0 -0
  303. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/chart.tsx +0 -0
  304. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/checkbox.tsx +0 -0
  305. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/collapsible.tsx +0 -0
  306. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/combobox.tsx +0 -0
  307. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/dialog.tsx +0 -0
  308. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/direction.tsx +0 -0
  309. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/directional-icon.tsx +0 -0
  310. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/dropdown-menu.tsx +0 -0
  311. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/field.tsx +0 -0
  312. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input-group.tsx +0 -0
  313. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input-otp.tsx +0 -0
  314. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input.tsx +0 -0
  315. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/label.tsx +0 -0
  316. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/popover.tsx +0 -0
  317. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/radio-group.tsx +0 -0
  318. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/resizable.tsx +0 -0
  319. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/scroll-area.tsx +0 -0
  320. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/select.tsx +0 -0
  321. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/separator.tsx +0 -0
  322. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/sheet.tsx +0 -0
  323. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/sidebar.tsx +0 -0
  324. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/skeleton.tsx +0 -0
  325. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/slider.tsx +0 -0
  326. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/spinner.tsx +0 -0
  327. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/switch.tsx +0 -0
  328. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/table.tsx +0 -0
  329. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/tabs.tsx +0 -0
  330. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/textarea.tsx +0 -0
  331. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/toggle-group.tsx +0 -0
  332. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/toggle.tsx +0 -0
  333. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/tooltip.tsx +0 -0
  334. /package/template/apps/{asas-platform → asas-backoffice}/src/copy.ts +0 -0
  335. /package/template/apps/{asas-platform → asas-backoffice}/src/hooks/use-mobile.ts +0 -0
  336. /package/template/apps/{asas-platform → asas-backoffice}/src/lib/utils.ts +0 -0
  337. /package/template/apps/{asas-platform → asas-backoffice}/src/theme.ts +0 -0
  338. /package/template/templates/{asas/src/kayan → features}/auth/user/index.ts +0 -0
  339. /package/template/templates/{asas/src → features/courses}/courses/ui/no-courses.tsx +0 -0
  340. /package/template/templates/{asas → mithaq}/components.json +0 -0
  341. /package/template/templates/{asas → mithaq}/src/storage/defaults.ts +0 -0
  342. /package/template/templates/{asas → mithaq}/src/storage/local.ts +0 -0
  343. /package/template/templates/{asas → mithaq}/src/storage/r2.ts +0 -0
  344. /package/template/templates/{asas → mithaq}/src/storage/s3.ts +0 -0
  345. /package/template/templates/{asas → mithaq}/src/storage/uploadthing.ts +0 -0
  346. /package/template/templates/{asas → mithaq}/src/ui/kpi-card.tsx +0 -0
  347. /package/template/templates/{asas → mithaq}/src/ui/record-page.tsx +0 -0
  348. /package/template/templates/{asas → mithaq}/src/ui/table-page.tsx +0 -0
@@ -1,35 +1,45 @@
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'
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'
7
13
  import { useAccess, useClient, useLocale, useUser } from 'mithaq/admin/react'
8
14
  import type { asas } from '../asas.ts'
9
15
  import { authArm } from '@/api.ts'
10
16
  import { refusalKey } from '@/copy.ts'
17
+ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
11
18
  import { Badge } from '@/components/ui/badge'
12
- import { Button } from '@/components/ui/button'
19
+ import { Button, buttonVariants } from '@/components/ui/button'
20
+ import { ButtonGroup } from '@/components/ui/button-group'
21
+ import { Card, CardContent } from '@/components/ui/card'
13
22
  import { Checkbox } from '@/components/ui/checkbox'
23
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
14
24
  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'
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'
21
39
 
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. */
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. */
33
43
  export type MembersData = { members: Member[]; roles: Role[]; invites?: Invite[] }
34
44
 
35
45
  /**
@@ -38,24 +48,20 @@ export type MembersData = { members: Member[]; roles: Role[]; invites?: Invite[]
38
48
  */
39
49
  const invitesAsGovernance = (role: unknown) => typeof role === 'string' && role.split(',').some((name) => name === 'owner' || name === 'admin')
40
50
 
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
- }
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
+ })
46
60
 
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: [] },
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),
59
65
  // Asked only of a viewer who may invite: one holding the key, whose
60
66
  // governance role the provider lets invite.
61
67
  holds(access, 'invitation.create')
@@ -65,32 +71,19 @@ export const loader = async ({ access, client, request, maxLimit }: LoaderTools)
65
71
  invites
66
72
  ? request('/api/auth/user/organization/list-invitations').then(async (response) => {
67
73
  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 }[]
74
+ return (await response.json()) as { id: string; email: string; role?: string | null; status: string; expiresAt: string; inviterId?: string }[]
69
75
  })
70
76
  : undefined,
71
77
  )
72
78
  : undefined,
73
79
  ])
80
+ const names = new Map(members.map((member) => [member.userId, member.name]))
74
81
  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) })) }),
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))) }),
94
87
  }
95
88
  }
96
89
 
@@ -100,8 +93,308 @@ export const loader = async ({ access, client, request, maxLimit }: LoaderTools)
100
93
  */
101
94
  const development = (import.meta as { env?: { DEV?: boolean } }).env?.DEV === true
102
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
+
103
206
  const sameRoles = (a: ReadonlySet<string>, b: ReadonlySet<string>) => a.size === b.size && [...a].every((name) => b.has(name))
104
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
+
105
398
  export function Members({ loaderData }: PageProps<MembersData>) {
106
399
  const { locale, t } = useLocale()
107
400
  const client = useClient<typeof asas.router>()
@@ -110,27 +403,34 @@ export function Members({ loaderData }: PageProps<MembersData>) {
110
403
  const assignments = access.collections?.memberRoles
111
404
  const roles = loaderData?.roles ?? []
112
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('')
113
413
  const [editing, setEditing] = useState<Member>()
114
- const [chosen, setChosen] = useState<ReadonlySet<string>>(new Set())
414
+ const [removing, setRemoving] = useState<Member>()
115
415
  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.
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.
123
425
  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.
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.
127
428
  const removes = holds(access, 'member.delete')
128
429
  const cancels = holds(access, 'invitation.cancel')
129
- const opens = edits || removes
130
430
  const invites = loaderData?.invites !== undefined
131
- const [pending, setPending] = useState(loaderData?.invites ?? [])
132
- const [pendingRefusal, setPendingRefusal] = useState<string>()
133
- const [copied, setCopied] = useState<string>()
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')
134
434
  const [inviting, setInviting] = useState(false)
135
435
  const [email, setEmail] = useState('')
136
436
  /** Whether the email box has been left or submitted, after which it says what is wrong with it. */
@@ -140,13 +440,9 @@ export function Members({ loaderData }: PageProps<MembersData>) {
140
440
  const emailBox = useRef<HTMLInputElement>(null)
141
441
  const inviteTitle = useId()
142
442
  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
443
  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
444
+ const key = refusedKey(answered)
445
+ return (key && t(key)) || fallback || ''
150
446
  }
151
447
  /**
152
448
  * What is wrong with the address typed, said once the box is left and on
@@ -160,6 +456,8 @@ export function Members({ loaderData }: PageProps<MembersData>) {
160
456
  : emailBox.current?.validity.typeMismatch
161
457
  ? t('auth.invalidEmail', 'Invalid email')
162
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])
163
461
  /**
164
462
  * One invitation, as a member: the role the provider gives on accepting, so
165
463
  * the roles of this page are given after, here, like any member's.
@@ -172,87 +470,71 @@ export function Members({ loaderData }: PageProps<MembersData>) {
172
470
  const answered = await authArm('organization/invite-member', { email: email.trim(), role: 'member' }).finally(() => setInviting(false))
173
471
  // The provider's sentence is English alone, so the refusal is said in the viewer's locale, by its code.
174
472
  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) }])
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))
177
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))
178
488
  }
179
489
  /** One pending invitation withdrawn, through the provider's own route. */
180
490
  const cancel = async (one: Invite) => {
181
- setPendingRefusal(undefined)
182
491
  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.')))
492
+ if (!answered.ok) return void toast.error(refused(answered, t('dashboard.inviteCancelRefused', 'The server refused to cancel that invitation.')))
184
493
  setPending((all) => all.filter((other) => other.id !== one.id))
494
+ toast.success(t('dashboard.inviteCancelled', 'Invitation cancelled') ?? 'Invitation cancelled')
185
495
  }
186
- /** The link the invitee opens, which development hands over by hand since no mail goes out. */
496
+ /** The link the invitee opens, for the inviter to hand over where the mail does not reach, in every environment. */
187
497
  const copyLink = async (one: Invite) => {
188
498
  await navigator.clipboard.writeText(new URL(`/auth/accept-invitation?id=${encodeURIComponent(one.id)}`, window.location.origin).href)
189
- setCopied(one.id)
499
+ toast.success(t('dashboard.inviteCopied', 'Link copied') ?? 'Link copied')
190
500
  }
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()
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))
198
511
  }
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.
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.
200
513
  useEffect(() => {
201
514
  const asked = new URLSearchParams(window.location.search).get('member')
202
515
  const member = members.find((one) => one.userId === asked)
203
- if (member && opens) open(member)
516
+ if (member && edits) setEditing(member)
204
517
  }, [])
205
518
  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
-
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)))
208
521
  /**
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.
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.
211
524
  */
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
- }
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
+ })
236
530
  /**
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.
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.
239
534
  */
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
- }
535
+ const remove = async (member: Member) => {
252
536
  setBusy(true)
253
- setRefusal(undefined)
254
- setConfirming(false)
255
- let held = editing.roles
537
+ let held = member.roles
256
538
  try {
257
539
  for (const one of held) {
258
540
  await client({ kayan: 'memberRoles', operation: 'delete', input: { id: one.id } })
@@ -260,241 +542,194 @@ export function Members({ loaderData }: PageProps<MembersData>) {
260
542
  }
261
543
  } catch (thrown) {
262
544
  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)
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.') || '')
266
548
  }
267
- const answered = await authArm('organization/remove-member', { memberIdOrEmail: editing.email }).finally(() => setBusy(false))
549
+ const answered = await authArm('organization/remove-member', { memberIdOrEmail: member.email }).finally(() => setBusy(false))
268
550
  if (!answered.ok) {
269
- kept(held)
270
- return setRefusal(refused(answered, t('dashboard.removeRefused', 'The server refused to remove that member.')))
551
+ hold(member, held)
552
+ return void toast.error(refused(answered, t('dashboard.removeRefused', 'The server refused to remove that member.')))
271
553
  }
272
- setMembers((all) => all.filter((one) => one.userId !== editing.userId))
273
- sheet.current?.close()
554
+ setMembers((all) => all.filter((one) => one.userId !== member.userId))
555
+ toast.success((t('dashboard.memberRemoved', '{name} removed') ?? '{name} removed').replace('{name}', () => member.name))
274
556
  }
275
557
 
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">
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">
346
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.')}
347
583
  </p>
348
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
+ )
349
590
 
350
591
  return (
351
592
  <AppShell
352
593
  regions={{
353
594
  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}
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}
361
620
  </div>
362
621
  ),
363
622
  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. */}
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. */}
498
733
  {invites ? (
499
734
  <dialog
500
735
  ref={inviteSheet}
@@ -536,93 +771,57 @@ export function Members({ loaderData }: PageProps<MembersData>) {
536
771
  onChange={(event) => setEmail(event.target.value)}
537
772
  />
538
773
  </FieldShell>
539
- {inviteRefusal ? (
540
- <p role="alert" className="text-destructive">
541
- {inviteRefusal}
542
- </p>
543
- ) : null}
774
+ {inviteRefusal ? <p role="alert" className="text-destructive">{inviteRefusal}</p> : null}
544
775
  <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>
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>
551
778
  </footer>
552
779
  </form>
553
780
  </dialog>
554
781
  ) : 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 ? (
782
+ {adds ? (
564
783
  <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"
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)}
569
789
  >
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>
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>
621
797
  </footer>
622
- </form>
798
+ </div>
623
799
  </dialog>
624
800
  ) : null}
625
- </div>
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>
626
825
  ),
627
826
  }}
628
827
  />