create-mithaq 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +1094 -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
@@ -3,8 +3,9 @@ import { Link, createFileRoute, useNavigate } from '@tanstack/react-router'
3
3
  import { Button } from '@/components/ui/button'
4
4
  import { authArm } from '../api.ts'
5
5
  import { documentHead } from '../copy.ts'
6
- import { AuthFrame, authGate, useRefusal } from '../auth-frame.tsx'
6
+ import { AuthFrame, authGate, useHomeLabel, useRefusal } from '../auth-frame.tsx'
7
7
  import { useCopy } from './__root.tsx'
8
+ import { landing } from '../redirect.ts'
8
9
 
9
10
  // Accepting is a POST the person presses, so following a link alone joins nobody to anything.
10
11
  // The provider decides whether the invitation is theirs.
@@ -17,11 +18,12 @@ export const Route = createFileRoute('/auth/accept-invitation')({
17
18
  })
18
19
 
19
20
  function AcceptInvitation() {
20
- const { t } = useCopy()
21
+ const { t, locale } = useCopy()
21
22
  const navigate = useNavigate()
22
23
  const refused = useRefusal()
23
24
  const { id } = Route.useSearch()
24
- const { signedIn } = Route.useRouteContext()
25
+ const { signedIn, home } = Route.useRouteContext()
26
+ const homeLabel = useHomeLabel()
25
27
  const [organization, setOrganization] = useState<string>()
26
28
  const [gone, setGone] = useState<string>()
27
29
  const [pending, setPending] = useState(false)
@@ -32,13 +34,13 @@ function AcceptInvitation() {
32
34
  if (!signedIn || !id) return
33
35
  fetch(`/api/auth/user/organization/get-invitation?id=${encodeURIComponent(id)}`)
34
36
  .then(async (response) => {
35
- const body = (await response.json().catch(() => null)) as { organizationName?: string; code?: unknown } | null
36
- if (response.ok) return setOrganization(body?.organizationName ?? '')
37
+ const body = (await response.json().catch(() => null)) as { organizationName?: string; organizationNames?: Record<string, string>; code?: unknown } | null
38
+ if (response.ok) return setOrganization(body?.organizationNames?.[locale] || body?.organizationName || '')
37
39
  // Better Auth answers a missing, spent or expired invitation 400 with no code.
38
40
  setGone(refused({ status: response.status, body }, t('auth.invitationGone', 'This invitation has expired, was cancelled or was already accepted. Ask whoever invited you to send a new one.')))
39
41
  })
40
42
  .catch(() => setGone(t('auth.requestFailed', 'That request did not work.')))
41
- }, [signedIn, id])
43
+ }, [signedIn, id, locale])
42
44
 
43
45
  const accept = async () => {
44
46
  setPending(true)
@@ -49,7 +51,7 @@ function AcceptInvitation() {
49
51
  return setRefusal(refused(answered, t('auth.invitationAcceptFailed', 'That invitation could not be accepted.')))
50
52
  }
51
53
  // The provider made the organization active as it accepted, so the first screen is read as a member.
52
- await navigate({ to: (await authGate()).home.path })
54
+ await navigate({ href: landing({ asked: undefined, screens: (await authGate()).screens }) })
53
55
  }
54
56
 
55
57
  if (!id) return <AuthFrame title={title} description={t('auth.invitationMissing', 'This link names no invitation.')}>{null}</AuthFrame>
@@ -75,6 +77,11 @@ function AcceptInvitation() {
75
77
  <p role="alert" data-invitation-gone="" className="text-sm">
76
78
  {gone}
77
79
  </p>
80
+ {/* A spent invitation is no dead end for someone already in: the way to their own first screen. An anchor,
81
+ because the path is the screen's own data, not a route the router knows. */}
82
+ <Button nativeButton={false} render={<a href={home.path} data-home="" />}>
83
+ {homeLabel(home, 'marketing.openScreen')}
84
+ </Button>
78
85
  {/* The invitation may be another address's, which signing out and in again reaches. */}
79
86
  <Button type="button" variant="outline" onClick={() => void authArm('sign-out').then(() => window.location.reload())}>
80
87
  {t('admin.signOut', 'Sign out')}
@@ -0,0 +1,114 @@
1
+ import { useState, type ComponentProps } from 'react'
2
+ import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'
3
+ import { Button } from '@/components/ui/button'
4
+ import { authArm } from '../api.ts'
5
+ import { AuthBox, AuthFrame, authGate, notAnEmail, useRefusal } from '../auth-frame.tsx'
6
+ import { documentHead } from '../copy.ts'
7
+ import { firstRun } from '../first-run.ts'
8
+ import { useCopy } from './__root.tsx'
9
+
10
+ // The first owner of an app of one organization, and the organization itself: open once,
11
+ // to the holder of the setup code, and closed for good once the organization exists. The
12
+ // organization's name lives in code (`organizationName` in `organizations.ts`), so none is asked.
13
+ export const Route = createFileRoute('/auth/first-run')({
14
+ head: ({ match }) => documentHead(match.context, (t) => t('auth.firstRun')),
15
+ beforeLoad: async () => {
16
+ const state = await firstRun()
17
+ // `href`, not `to`: sign-in redirects here too, and two routes typed by each other's paths infer nothing.
18
+ if (!state.open) throw redirect({ href: '/auth/sign-in' })
19
+ return { code: state.code, password: (await authGate()).password }
20
+ },
21
+ component: FirstRun,
22
+ })
23
+
24
+ function FirstRun() {
25
+ const navigate = useNavigate()
26
+ const { t } = useCopy()
27
+ const refused = useRefusal()
28
+ const { code: asked, password: bounds } = Route.useRouteContext()
29
+ const [name, setName] = useState('')
30
+ const [email, setEmail] = useState('')
31
+ const [password, setPassword] = useState('')
32
+ const [confirmation, setConfirmation] = useState('')
33
+ const [code, setCode] = useState('')
34
+ const [pending, setPending] = useState(false)
35
+ const [refusal, setRefusal] = useState<string | null>(null)
36
+ const [left, setLeft] = useState<ReadonlySet<string>>(() => new Set())
37
+ const required = t('auth.required', 'This field is required.')
38
+ const wrong = {
39
+ name: () => (name.trim() ? undefined : required),
40
+ email: () => (!email ? required : notAnEmail(email) ? t('auth.invalidEmail', 'Invalid email') : undefined),
41
+ password: () =>
42
+ !password ? required
43
+ : password.length < bounds.min ? t('auth.passwordTooShort', 'Password too short')
44
+ : password.length > bounds.max ? t('auth.passwordTooLong', 'Password too long')
45
+ : undefined,
46
+ confirmation: () => (!confirmation ? required : confirmation === password ? undefined : t('auth.passwordsDiffer', 'The two passwords are not the same.')),
47
+ code: () => (asked !== 'required' || code ? undefined : required),
48
+ }
49
+ const errorOf = (box: keyof typeof wrong) => (left.has(box) ? wrong[box]() : undefined)
50
+ const box = (id: keyof typeof wrong, label: string | undefined, props: ComponentProps<'input'> & { description?: string }) => (
51
+ <AuthBox id={id} label={label} error={errorOf(id)} onLeave={() => setLeft((already) => new Set(already).add(id))} {...props} />
52
+ )
53
+
54
+ const submit = async (event: React.FormEvent) => {
55
+ event.preventDefault()
56
+ setLeft(new Set(Object.keys(wrong)))
57
+ if (Object.values(wrong).some((check) => check())) return
58
+ setPending(true)
59
+ setRefusal(null)
60
+ const made = await authArm('first-run', { name, email, password, ...(asked === 'required' && { code }) })
61
+ setPending(false)
62
+ if (!made.ok) return setRefusal(refused(made, t('auth.firstRunFailed', 'That owner could not be made.')))
63
+ await navigate({ to: '/auth/sign-in' })
64
+ }
65
+
66
+ if (asked === 'unset') {
67
+ return (
68
+ <AuthFrame title={t('auth.firstRun', 'Set up this app')}>
69
+ <p className="text-sm" role="alert">
70
+ {t('auth.firstRunCodeUnset')}
71
+ </p>
72
+ </AuthFrame>
73
+ )
74
+ }
75
+ return (
76
+ <AuthFrame title={t('auth.firstRun', 'Set up this app')} description={t('auth.firstRunDescription', 'Nobody owns this app yet. The account made here becomes its owner and first administrator.')}>
77
+ <form className="grid gap-4" noValidate onSubmit={submit}>
78
+ {box('name', t('auth.name', 'Name'), { autoComplete: 'name', onChange: (event) => setName(event.target.value), type: 'text', value: name })}
79
+ {box('email', t('auth.email', 'Email'), { autoComplete: 'email', onChange: (event) => setEmail(event.target.value), type: 'email', value: email })}
80
+ {box('password', t('auth.password', 'Password'), {
81
+ autoComplete: 'new-password',
82
+ description: t('auth.passwordRule', 'At least {count} characters.')!.replace('{count}', String(bounds.min)),
83
+ onChange: (event) => setPassword(event.target.value),
84
+ type: 'password',
85
+ value: password,
86
+ })}
87
+ {box('confirmation', t('auth.confirmPassword', 'Confirm the password'), {
88
+ autoComplete: 'new-password',
89
+ onChange: (event) => setConfirmation(event.target.value),
90
+ type: 'password',
91
+ value: confirmation,
92
+ })}
93
+ {asked === 'required'
94
+ ? box('code', t('auth.setupCode', 'Setup code'), {
95
+ autoComplete: 'off',
96
+ description: t('auth.setupCodeDescription', 'The SETUP_CODE this server was started with.'),
97
+ onChange: (event) => setCode(event.target.value),
98
+ type: 'password',
99
+ value: code,
100
+ })
101
+ : null}
102
+ {/* `role="alert"` so a refusal is spoken when it appears; nothing moves the focus to it. */}
103
+ {refusal && (
104
+ <p className="text-destructive text-sm" role="alert">
105
+ {refusal}
106
+ </p>
107
+ )}
108
+ <Button disabled={pending} type="submit">
109
+ {pending ? t('auth.firstRunPending', 'Setting up…') : t('auth.firstRunSubmit', 'Set up the app')}
110
+ </Button>
111
+ </form>
112
+ </AuthFrame>
113
+ )
114
+ }
@@ -19,7 +19,7 @@ export const Route = createFileRoute('/auth/forgot-password')({
19
19
  function ForgotPassword() {
20
20
  const { t } = useCopy()
21
21
  const search = Route.useSearch()
22
- const { signedIn, home } = Route.useRouteContext()
22
+ const { signedIn, home, screens } = Route.useRouteContext()
23
23
  const refused = useRefusal()
24
24
  const [email, setEmail] = useState('')
25
25
  const [left, setLeft] = useState(false)
@@ -47,7 +47,7 @@ function ForgotPassword() {
47
47
  setSent(true)
48
48
  }
49
49
 
50
- if (signedIn) return <AlreadySignedIn redirect={search.redirect} home={home} />
50
+ if (signedIn) return <AlreadySignedIn redirect={search.redirect} home={home} screens={screens} />
51
51
  if (sent) {
52
52
  return (
53
53
  <AuthFrame
@@ -1,13 +1,13 @@
1
- // Trades the platform's one-time impersonation token for the session through the plugin's own
1
+ // Trades the backoffice's one-time impersonation token for the session through the plugin's own
2
2
  // verify, then lands on the first screen the impersonated person may enter. The access report is
3
3
  // read with only the session the verify set, so the landing grants nothing. A spent, expired or
4
4
  // unknown token sets nothing and lands on sign-in.
5
5
  import { createFileRoute } from '@tanstack/react-router'
6
6
  import type {} from '@tanstack/start-client-core'
7
7
 
8
- const firstScreen = (targets: unknown, listed: readonly string[], platform: string) => {
8
+ const firstScreen = (targets: unknown, listed: readonly string[], backoffice: string) => {
9
9
  const screens = targets as Record<string, { capability?: string; client?: { path: string } } | undefined>
10
- const name = listed.find((one) => one !== platform && screens[one]?.capability === 'screen')
10
+ const name = listed.find((one) => one !== backoffice && screens[one]?.capability === 'screen')
11
11
  return name ? screens[name]!.client!.path : '/unauthorized'
12
12
  }
13
13
 
@@ -15,7 +15,7 @@ export const Route = createFileRoute('/auth/handoff')({
15
15
  server: {
16
16
  handlers: {
17
17
  GET: async ({ request }) => {
18
- const { asas, tenantURL, PLATFORM_SCREEN } = await import('asas')
18
+ const { asas, tenantURL, BACKOFFICE_SCREEN } = await import('asas')
19
19
  // The deployment's own origin, never the request's, whose host a caller writes.
20
20
  const own = new URL(tenantURL).origin
21
21
  const verified = await asas.handler(
@@ -30,7 +30,7 @@ export const Route = createFileRoute('/auth/handoff')({
30
30
  const report = verified.ok
31
31
  ? ((await (await asas.handler(new Request(new URL('/api/access', own), { headers: { cookie: session } }))).json().catch(() => ({}))) as { screens?: string[] })
32
32
  : undefined
33
- const headers = new Headers({ location: report ? firstScreen(asas.targets, report.screens ?? [], PLATFORM_SCREEN) : '/auth/sign-in' })
33
+ const headers = new Headers({ location: report ? firstScreen(asas.targets, report.screens ?? [], BACKOFFICE_SCREEN) : '/auth/sign-in' })
34
34
  for (const cookie of cookies) headers.append('set-cookie', cookie)
35
35
  return new Response(null, { status: 302, headers })
36
36
  },
@@ -1,109 +1,16 @@
1
- import { useState, type ComponentProps } from 'react'
2
- import { Link, createFileRoute } from '@tanstack/react-router'
3
- import { Button } from '@/components/ui/button'
4
- import { authArm } from '../api.ts'
1
+ import { createFileRoute } from '@tanstack/react-router'
5
2
  import { documentHead } from '../copy.ts'
6
- import { AlreadySignedIn, AuthBox, AuthFrame, authGate, redirectSearch, useRefusal } from '../auth-frame.tsx'
7
- import { useCopy } from './__root.tsx'
3
+ import { ChoosePassword, passwordGate, tokenSearch } from '../choose-password.tsx'
8
4
 
5
+ // Where a forgotten password's link lands. A first password, chosen from a sign-up's or an added person's link, has a
6
+ // page of its own, `/auth/set-password`, with the same form.
9
7
  export const Route = createFileRoute('/auth/reset-password')({
10
8
  head: ({ match }) => documentHead(match.context, (t) => t('auth.resetPassword')),
11
- validateSearch: (search: Record<string, unknown>): { redirect?: string; token?: string } => ({
12
- ...redirectSearch(search),
13
- ...(typeof search.token === 'string' && search.token ? { token: search.token } : {}),
14
- }),
15
- beforeLoad: () => authGate(),
9
+ validateSearch: tokenSearch,
10
+ beforeLoad: ({ search }: { search: { token?: string } }) => passwordGate(search),
16
11
  component: ResetPassword,
17
12
  })
18
13
 
19
14
  function ResetPassword() {
20
- const { t } = useCopy()
21
- const { token, redirect } = Route.useSearch()
22
- const { password: bounds, signedIn, home } = Route.useRouteContext()
23
- const refused = useRefusal()
24
- const [password, setPassword] = useState('')
25
- const [confirmation, setConfirmation] = useState('')
26
- const [pending, setPending] = useState(false)
27
- const [done, setDone] = useState(false)
28
- const [refusal, setRefusal] = useState<string | null>(null)
29
- const [left, setLeft] = useState<ReadonlySet<string>>(() => new Set())
30
- const wrong = {
31
- password: () =>
32
- !password ? t('auth.required', 'This field is required.')
33
- : password.length < bounds.min ? t('auth.passwordTooShort', 'Password too short')
34
- : password.length > bounds.max ? t('auth.passwordTooLong', 'Password too long')
35
- : undefined,
36
- confirmation: () => (!confirmation ? t('auth.required', 'This field is required.') : confirmation === password ? undefined : t('auth.passwordsDiffer', 'The two passwords are not the same.')),
37
- }
38
- const errorOf = (box: keyof typeof wrong) => (left.has(box) ? wrong[box]() : undefined)
39
- const box = (id: keyof typeof wrong, label: string | undefined, props: ComponentProps<'input'> & { description?: string }) => (
40
- <AuthBox id={id} label={label} error={errorOf(id)} onLeave={() => setLeft((already) => new Set(already).add(id))} {...props} />
41
- )
42
- const signIn = (
43
- <p className="mt-4 text-center text-sm">
44
- <Link className="text-primary underline underline-offset-4" to="/auth/sign-in">
45
- {t('auth.signIn', 'Sign in')}
46
- </Link>
47
- </p>
48
- )
49
-
50
- const submit = async (event: React.FormEvent) => {
51
- event.preventDefault()
52
- setLeft(new Set(Object.keys(wrong)))
53
- if (Object.values(wrong).some((check) => check())) return
54
- setPending(true)
55
- setRefusal(null)
56
- const answered = await authArm('reset-password', { newPassword: password, token })
57
- setPending(false)
58
- if (!answered.ok) return setRefusal(refused(answered, t('auth.resetFailed', 'That reset did not work.')))
59
- setDone(true)
60
- }
61
-
62
- if (signedIn) return <AlreadySignedIn redirect={redirect} home={home} />
63
- if (!token) {
64
- return (
65
- <AuthFrame title={t('auth.resetPassword', 'Reset password')} description={t('auth.tokenInvalid', 'Token is either invalid or has expired.')}>
66
- <p className="text-center text-sm">
67
- <Link className="text-primary underline underline-offset-4" to="/auth/forgot-password">
68
- {t('auth.forgotPassword', 'Forgot password')}
69
- </Link>
70
- </p>
71
- </AuthFrame>
72
- )
73
- }
74
- if (done) {
75
- return (
76
- <AuthFrame title={t('auth.resetPassword', 'Reset password')} description={t('auth.passwordReset', 'Password reset successfully.')}>
77
- {signIn}
78
- </AuthFrame>
79
- )
80
- }
81
- return (
82
- <AuthFrame title={t('auth.resetPassword', 'Reset password')}>
83
- <form className="grid gap-4" noValidate onSubmit={submit}>
84
- {box('password', t('auth.newPassword', 'New password'), {
85
- autoComplete: 'new-password',
86
- description: t('auth.passwordRule', 'At least {count} characters.')!.replace('{count}', String(bounds.min)),
87
- onChange: (event) => setPassword(event.target.value),
88
- type: 'password',
89
- value: password,
90
- })}
91
- {box('confirmation', t('auth.confirmPassword', 'Confirm the password'), {
92
- autoComplete: 'new-password',
93
- onChange: (event) => setConfirmation(event.target.value),
94
- type: 'password',
95
- value: confirmation,
96
- })}
97
- {refusal && (
98
- <p className="text-destructive text-sm" role="alert">
99
- {refusal}
100
- </p>
101
- )}
102
- <Button disabled={pending} type="submit">
103
- {t('auth.resetPassword', 'Reset password')}
104
- </Button>
105
- </form>
106
- {signIn}
107
- </AuthFrame>
108
- )
15
+ return <ChoosePassword {...Route.useSearch()} {...Route.useRouteContext()} first={false} />
109
16
  }
@@ -0,0 +1,16 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { documentHead } from '../copy.ts'
3
+ import { ChoosePassword, passwordGate, tokenSearch } from '../choose-password.tsx'
4
+
5
+ // Where a sign-up's or an added person's link lands: a brand-new account chooses its first password here, under a
6
+ // title that says so, and never reads that it is resetting one.
7
+ export const Route = createFileRoute('/auth/set-password')({
8
+ head: ({ match }) => documentHead(match.context, (t) => t('auth.setPassword')),
9
+ validateSearch: tokenSearch,
10
+ beforeLoad: ({ search }: { search: { token?: string } }) => passwordGate(search),
11
+ component: SetPassword,
12
+ })
13
+
14
+ function SetPassword() {
15
+ return <ChoosePassword {...Route.useSearch()} {...Route.useRouteContext()} first />
16
+ }
@@ -1,23 +1,32 @@
1
1
  import { useState, type ComponentProps } from 'react'
2
- import { Link, createFileRoute, useNavigate } from '@tanstack/react-router'
2
+ import { Link, createFileRoute, redirect, useNavigate } from '@tanstack/react-router'
3
+ import { organizations, signUp } from 'asas/organizations'
3
4
  import { Button } from '@/components/ui/button'
4
5
  import { authArm } from '../api.ts'
5
6
  import { documentHead } from '../copy.ts'
6
7
  import { AuthBox, AuthFrame, authGate, gate, notAnEmail, redirectSearch, useRefusal } from '../auth-frame.tsx'
8
+ import { firstRun } from '../first-run.ts'
7
9
  import { useCopy } from './__root.tsx'
8
- import { wanted } from '../redirect.ts'
10
+ import { landing } from '../redirect.ts'
9
11
 
10
12
  // The password has no length rule here because the provider's sign-in has none: a wrong
11
13
  // password of any length is the one refusal it answers.
12
14
 
13
15
  export const Route = createFileRoute('/auth/sign-in')({
14
16
  head: ({ match }) => documentHead(match.context, (t) => t('auth.signIn')),
15
- // The router hands `validateSearch` whatever the URL carried; `wanted` decides what is honoured.
17
+ // The router hands `validateSearch` whatever the URL carried; `landing` decides what is honoured.
16
18
  validateSearch: redirectSearch,
17
- beforeLoad: gate,
19
+ // Nobody signs in to an app of one organization before its owner has set it up.
20
+ beforeLoad: async ({ search }: { search: { redirect?: string } }) => {
21
+ if ((await firstRun()).open) throw redirect({ href: '/auth/first-run' })
22
+ return gate({ search })
23
+ },
18
24
  component: SignIn,
19
25
  })
20
26
 
27
+ /** Whether a visitor with no invitation may make an account here, so the page offers one. */
28
+ const offersSignUp = organizations === 'many' || signUp === 'open'
29
+
21
30
  function SignIn() {
22
31
  const navigate = useNavigate()
23
32
  const { t } = useCopy()
@@ -26,7 +35,8 @@ function SignIn() {
26
35
  const [email, setEmail] = useState('')
27
36
  const [password, setPassword] = useState('')
28
37
  const [pending, setPending] = useState(false)
29
- const [refusal, setRefusal] = useState<string | null>(null)
38
+ // The answer, not its sentence, so a language switch says it again in the language chosen.
39
+ const [refusal, setRefusal] = useState<Parameters<typeof refused>[0] | null>(null)
30
40
  const [left, setLeft] = useState<ReadonlySet<string>>(() => new Set())
31
41
  const required = t('auth.required', 'This field is required.')
32
42
  const wrong = {
@@ -48,17 +58,17 @@ function SignIn() {
48
58
  // The project's own sentence for the provider's code, never its English `message`, since copy carries every locale.
49
59
  if (!answered.ok) {
50
60
  setPending(false)
51
- return setRefusal(refused(answered, t('auth.failed', 'That sign-in did not work.')))
61
+ return setRefusal(answered)
52
62
  }
53
63
 
54
64
  setPending(false)
55
65
 
56
66
  // The first screen this actor enters is read only once the session exists.
57
- await navigate({ to: wanted({ asked: search.redirect, home: (await authGate()).home.path }) })
67
+ await navigate({ href: landing({ asked: search.redirect, screens: (await authGate()).screens }) })
58
68
  }
59
69
 
60
70
  return (
61
- <AuthFrame title={t('auth.signIn', 'Sign in')} description={t('auth.signInDescription', 'Sign in to reach the dashboard.')}>
71
+ <AuthFrame title={t('auth.signIn', 'Sign in')} description={t('auth.signInDescription', 'Welcome back. Sign in with your email and password.')}>
62
72
  <form className="grid gap-4" noValidate onSubmit={submit}>
63
73
  {box('email', t('auth.email', 'Email'), { autoComplete: 'email', onChange: (event) => setEmail(event.target.value), type: 'email', value: email })}
64
74
  {box('password', t('auth.password', 'Password'), { autoComplete: 'current-password', onChange: (event) => setPassword(event.target.value), type: 'password', value: password })}
@@ -68,19 +78,21 @@ function SignIn() {
68
78
  {/* `role="alert"` so a refusal is spoken when it appears: nothing moves the focus to it. */}
69
79
  {refusal && (
70
80
  <p className="text-destructive text-sm" role="alert">
71
- {refusal}
81
+ {refused(refusal, t('auth.failed', 'That sign-in did not work.'))}
72
82
  </p>
73
83
  )}
74
84
  <Button disabled={pending} type="submit">
75
85
  {pending ? t('auth.signInPending', 'Signing in…') : t('auth.signIn', 'Sign in')}
76
86
  </Button>
77
87
  </form>
78
- <p className="mt-4 text-center text-sm text-muted-foreground">
79
- {t('auth.noAccount', 'No account yet?')}{' '}
80
- <Link className="text-primary underline underline-offset-4" to="/auth/sign-up" search={{ redirect: search.redirect }}>
81
- {t('auth.signUp', 'Sign up')}
82
- </Link>
83
- </p>
88
+ {offersSignUp ? (
89
+ <p className="mt-4 text-center text-sm text-muted-foreground">
90
+ {t('auth.noAccount', 'No account yet?')}{' '}
91
+ <Link className="text-primary underline underline-offset-4" to="/auth/sign-up" search={{ redirect: search.redirect }}>
92
+ {t('auth.signUp', 'Sign up')}
93
+ </Link>
94
+ </p>
95
+ ) : null}
84
96
  </AuthFrame>
85
97
  )
86
98
  }
@@ -1,25 +1,31 @@
1
1
  import { useState, type ComponentProps } from 'react'
2
- import { Link, createFileRoute, useNavigate } from '@tanstack/react-router'
2
+ import { Link, createFileRoute, redirect, useNavigate } from '@tanstack/react-router'
3
+ import { organizations, signUp, verifyEmail } from 'asas/organizations'
3
4
  import { Button } from '@/components/ui/button'
4
5
  import { authArm } from '../api.ts'
5
6
  import { documentHead } from '../copy.ts'
6
7
  import { AuthBox, AuthFrame, authGate, gate, notAnEmail, redirectSearch, useRefusal } from '../auth-frame.tsx'
7
- import { wanted } from '../redirect.ts'
8
+ import { firstRun } from '../first-run.ts'
9
+ import { landing, wanted } from '../redirect.ts'
8
10
  import { useCopy } from './__root.tsx'
9
11
 
10
12
  export const Route = createFileRoute('/auth/sign-up')({
11
13
  head: ({ match }) => documentHead(match.context, (t) => t('auth.signUp')),
12
14
  validateSearch: redirectSearch,
13
- beforeLoad: gate,
15
+ // An app of one organization that has none yet takes no account before its owner's.
16
+ beforeLoad: async ({ search }: { search: { redirect?: string } }) => {
17
+ if ((await firstRun()).open) throw redirect({ href: '/auth/first-run' })
18
+ return gate({ search })
19
+ },
14
20
  component: SignUp,
15
21
  })
16
22
 
17
23
  /**
18
- * Better Auth requires a unique slug, so a random tail keeps same-named organizations apart.
19
- * Padded before the cut, so the tail is always six characters and the slug never ends in `-`.
24
+ * Whether a new account proves its address before it signs in, so the sign-up answers with no session: with many
25
+ * organizations always, where the person then founds or joins one, and with one where an open sign-up says so. Such a
26
+ * sign-up asks no password: it is chosen once, on the page the mailed link opens, and the server keeps none typed here.
20
27
  */
21
- const slugOf = (name: string) =>
22
- `${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'organization'}-${Math.random().toString(36).slice(2).padEnd(6, '0').slice(0, 6)}`
28
+ const verifies = signUp === 'open' && verifyEmail
23
29
 
24
30
  function SignUp() {
25
31
  const navigate = useNavigate()
@@ -29,23 +35,24 @@ function SignUp() {
29
35
  const { password: bounds } = Route.useRouteContext()
30
36
  // The redirect is a link anyone can write; a crafted one only makes an account with no organization,
31
37
  // which reads nothing until an organization invites it.
32
- const invited = wanted({ asked: search.redirect, home: '/' }).startsWith('/auth/accept-invitation?')
38
+ const asked = wanted({ asked: search.redirect, home: '/' })
39
+ const invited = asked.startsWith('/auth/accept-invitation?')
40
+ // The invitation's id, which a sign-up by invitation carries: holding it is what lets it through.
41
+ const invitationId = invited ? new URLSearchParams(asked.slice(asked.indexOf('?'))).get('id') ?? undefined : undefined
33
42
  const [name, setName] = useState('')
34
- const [organization, setOrganization] = useState('')
35
43
  const [email, setEmail] = useState('')
36
44
  const [password, setPassword] = useState('')
37
45
  const [confirmation, setConfirmation] = useState('')
38
46
  const [pending, setPending] = useState(false)
39
47
  const [refusal, setRefusal] = useState<string | null>(null)
40
- // The address this submit already signed up. A second sign-up with the same email is refused, so a
41
- // retry after the organization call failed must skip it. An address and not a flag: a corrected typo signs up again.
42
- const [signedUpAs, setSignedUpAs] = useState<string | null>(null)
43
48
  const [left, setLeft] = useState<ReadonlySet<string>>(() => new Set())
49
+ const [sent, setSent] = useState<string | null>(null)
44
50
  const required = t('auth.required', 'This field is required.')
45
- const wrong = {
51
+ const named = {
46
52
  name: () => (name ? undefined : required),
47
- organization: () => (invited || organization ? undefined : required),
48
53
  email: () => (!email ? required : notAnEmail(email) ? t('auth.invalidEmail', 'Invalid email') : undefined),
54
+ }
55
+ const chosen = {
49
56
  password: () =>
50
57
  !password ? required
51
58
  : password.length < bounds.min ? t('auth.passwordTooShort', 'Password too short')
@@ -53,7 +60,8 @@ function SignUp() {
53
60
  : undefined,
54
61
  confirmation: () => (!confirmation ? required : confirmation === password ? undefined : t('auth.passwordsDiffer', 'The two passwords are not the same.')),
55
62
  }
56
- const errorOf = (box: keyof typeof wrong) => (left.has(box) ? wrong[box]() : undefined)
63
+ const wrong: Partial<Record<'name' | 'email' | 'password' | 'confirmation', () => string | undefined>> = verifies ? named : { ...named, ...chosen }
64
+ const errorOf = (box: keyof typeof wrong) => (left.has(box) ? wrong[box]?.() : undefined)
57
65
  const box = (id: keyof typeof wrong, label: string | undefined, props: ComponentProps<'input'> & { description?: string }) => (
58
66
  <AuthBox id={id} label={label} error={errorOf(id)} onLeave={() => setLeft((already) => new Set(already).add(id))} {...props} />
59
67
  )
@@ -64,39 +72,57 @@ function SignUp() {
64
72
  if (Object.values(wrong).some((check) => check())) return
65
73
  setPending(true)
66
74
  setRefusal(null)
67
- if (signedUpAs !== email) {
68
- const made = await authArm('sign-up/email', { name, email, password })
69
- if (!made.ok) {
70
- setPending(false)
71
- return setRefusal(refused(made, t('auth.signUpFailed', 'That sign-up did not work.')))
72
- }
73
- setSignedUpAs(email)
74
- }
75
- // A refusal here leaves an account that reads nothing, so the sentence names the organization;
76
- // `signedUpAs` holds a retry to this call.
77
- const created = invited ? undefined : await authArm('organization/create', { name: organization, slug: slugOf(organization) })
75
+ const made = await authArm('sign-up/email', { name, email, ...(!verifies && { password }), ...(invitationId && { invitationId }) })
78
76
  setPending(false)
79
- if (created && !created.ok) return setRefusal(refused(created, t('auth.organizationFailed', 'That organization could not be created.')))
80
- await navigate({ to: wanted({ asked: search.redirect, home: (await authGate()).home.path }) })
77
+ if (!made.ok) return setRefusal(refused(made, t('auth.signUpFailed', 'That sign-up did not work.')))
78
+ // An account that proves its address first has no session yet, invited or not, so it is told to open its mail.
79
+ if (verifies) return setSent(email)
80
+ // The server accepted the invitation the sign-up carried, so its page would only say it is spent.
81
+ await navigate({ href: landing({ asked: invited ? undefined : search.redirect, screens: (await authGate()).screens }) })
81
82
  }
82
83
 
84
+ const toSignIn = (
85
+ <p className="mt-4 text-center text-sm text-muted-foreground">
86
+ {t('auth.haveAccount', 'Already have an account?')}{' '}
87
+ <Link className="text-primary underline underline-offset-4" to="/auth/sign-in" search={{ redirect: search.redirect }}>
88
+ {t('auth.signIn', 'Sign in')}
89
+ </Link>
90
+ </p>
91
+ )
92
+ // By invitation, nobody without one is offered a form the server would refuse.
93
+ if (organizations === 'one' && signUp === 'invite' && !invitationId) {
94
+ return (
95
+ <AuthFrame title={t('auth.signUp', 'Sign up')} description={t('auth.inviteOnly', 'Accounts here are made by invitation. Open the link of the invitation sent to you, or sign in.')}>
96
+ {toSignIn}
97
+ </AuthFrame>
98
+ )
99
+ }
100
+ if (sent) {
101
+ return (
102
+ <AuthFrame
103
+ title={t('auth.verifySent', 'Check your email')}
104
+ description={<span className="[overflow-wrap:anywhere]">{t('auth.verifySentDescription', 'We sent a link to {email}. Open it to confirm your address and choose your password, then sign in. It lasts an hour.')!.split(/(\{email\})/).map((part, at) => (part === '{email}' ? <bdi key={at}>{sent}</bdi> : part))}</span>}
105
+ >
106
+ {toSignIn}
107
+ </AuthFrame>
108
+ )
109
+ }
83
110
  return (
84
111
  <AuthFrame
85
112
  title={t('auth.signUp', 'Sign up')}
86
- description={invited ? t('auth.signUpInvitedDescription', 'Make an account with the email address the invitation was sent to.') : t('auth.signUpDescription', 'Make an account and its organization.')}
113
+ description={invited ? t('auth.signUpInvitedDescription', 'Make an account with the email address the invitation was sent to.') : t('auth.signUpDescription', 'Make an account.')}
87
114
  >
88
115
  <form className="grid gap-4" noValidate onSubmit={submit}>
89
116
  {box('name', t('auth.name', 'Name'), { autoComplete: 'name', onChange: (event) => setName(event.target.value), type: 'text', value: name })}
90
- {invited ? null : box('organization', t('auth.organization', 'Organization'), { autoComplete: 'organization', onChange: (event) => setOrganization(event.target.value), type: 'text', value: organization })}
91
117
  {box('email', t('auth.email', 'Email'), { autoComplete: 'email', onChange: (event) => setEmail(event.target.value), type: 'email', value: email })}
92
- {box('password', t('auth.password', 'Password'), {
118
+ {verifies ? null : box('password', t('auth.password', 'Password'), {
93
119
  autoComplete: 'new-password',
94
120
  description: t('auth.passwordRule', 'At least {count} characters.')!.replace('{count}', String(bounds.min)),
95
121
  onChange: (event) => setPassword(event.target.value),
96
122
  type: 'password',
97
123
  value: password,
98
124
  })}
99
- {box('confirmation', t('auth.confirmPassword', 'Confirm the password'), {
125
+ {verifies ? null : box('confirmation', t('auth.confirmPassword', 'Confirm the password'), {
100
126
  autoComplete: 'new-password',
101
127
  onChange: (event) => setConfirmation(event.target.value),
102
128
  type: 'password',
@@ -111,12 +137,7 @@ function SignUp() {
111
137
  {pending ? t('auth.signUpPending', 'Signing up…') : t('auth.signUp', 'Sign up')}
112
138
  </Button>
113
139
  </form>
114
- <p className="mt-4 text-center text-sm text-muted-foreground">
115
- {t('auth.haveAccount', 'Already have an account?')}{' '}
116
- <Link className="text-primary underline underline-offset-4" to="/auth/sign-in" search={{ redirect: search.redirect }}>
117
- {t('auth.signIn', 'Sign in')}
118
- </Link>
119
- </p>
140
+ {toSignIn}
120
141
  </AuthFrame>
121
142
  )
122
143
  }