create-mithaq 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (396) hide show
  1. package/LICENSE +22 -0
  2. package/LICENSES/openstatus-time-picker.txt +21 -0
  3. package/LICENSES/payload.txt +22 -0
  4. package/LICENSES/ponytail.txt +21 -0
  5. package/index.js +521 -0
  6. package/package.json +19 -4
  7. package/template/apps/asas-backoffice/components.json +25 -0
  8. package/template/apps/asas-backoffice/gitignore +3 -0
  9. package/template/apps/asas-backoffice/package.json +57 -0
  10. package/template/apps/asas-backoffice/public/favicon.svg +1 -0
  11. package/template/apps/asas-backoffice/src/api.ts +147 -0
  12. package/template/apps/asas-backoffice/src/auth-frame.tsx +150 -0
  13. package/template/apps/asas-backoffice/src/components/ui/accordion.tsx +74 -0
  14. package/template/apps/asas-backoffice/src/components/ui/alert-dialog.tsx +185 -0
  15. package/template/apps/asas-backoffice/src/components/ui/avatar.tsx +106 -0
  16. package/template/apps/asas-backoffice/src/components/ui/badge.tsx +48 -0
  17. package/template/apps/asas-backoffice/src/components/ui/breadcrumb.tsx +124 -0
  18. package/template/apps/asas-backoffice/src/components/ui/button-group.tsx +87 -0
  19. package/template/apps/asas-backoffice/src/components/ui/button.tsx +55 -0
  20. package/template/apps/asas-backoffice/src/components/ui/calendar.tsx +223 -0
  21. package/template/apps/asas-backoffice/src/components/ui/card.tsx +102 -0
  22. package/template/apps/asas-backoffice/src/components/ui/chart.tsx +371 -0
  23. package/template/apps/asas-backoffice/src/components/ui/checkbox.tsx +28 -0
  24. package/template/apps/asas-backoffice/src/components/ui/collapsible.tsx +19 -0
  25. package/template/apps/asas-backoffice/src/components/ui/combobox.tsx +308 -0
  26. package/template/apps/asas-backoffice/src/components/ui/dialog.tsx +158 -0
  27. package/template/apps/asas-backoffice/src/components/ui/direction.tsx +6 -0
  28. package/template/apps/asas-backoffice/src/components/ui/directional-icon.tsx +20 -0
  29. package/template/apps/asas-backoffice/src/components/ui/dropdown-menu.tsx +267 -0
  30. package/template/apps/asas-backoffice/src/components/ui/field.tsx +236 -0
  31. package/template/apps/asas-backoffice/src/components/ui/input-group.tsx +155 -0
  32. package/template/apps/asas-backoffice/src/components/ui/input-otp.tsx +84 -0
  33. package/template/apps/asas-backoffice/src/components/ui/input.tsx +19 -0
  34. package/template/apps/asas-backoffice/src/components/ui/label.tsx +19 -0
  35. package/template/apps/asas-backoffice/src/components/ui/popover.tsx +90 -0
  36. package/template/apps/asas-backoffice/src/components/ui/radio-group.tsx +37 -0
  37. package/template/apps/asas-backoffice/src/components/ui/resizable.tsx +47 -0
  38. package/template/apps/asas-backoffice/src/components/ui/scroll-area.tsx +54 -0
  39. package/template/apps/asas-backoffice/src/components/ui/select.tsx +204 -0
  40. package/template/apps/asas-backoffice/src/components/ui/separator.tsx +24 -0
  41. package/template/apps/asas-backoffice/src/components/ui/sheet.tsx +139 -0
  42. package/template/apps/asas-backoffice/src/components/ui/sidebar.tsx +714 -0
  43. package/template/apps/asas-backoffice/src/components/ui/skeleton.tsx +13 -0
  44. package/template/apps/asas-backoffice/src/components/ui/slider.tsx +55 -0
  45. package/template/apps/asas-backoffice/src/components/ui/spinner.tsx +10 -0
  46. package/template/apps/asas-backoffice/src/components/ui/switch.tsx +29 -0
  47. package/template/apps/asas-backoffice/src/components/ui/table.tsx +115 -0
  48. package/template/apps/asas-backoffice/src/components/ui/tabs.tsx +79 -0
  49. package/template/apps/asas-backoffice/src/components/ui/textarea.tsx +17 -0
  50. package/template/apps/asas-backoffice/src/components/ui/toggle-group.tsx +89 -0
  51. package/template/apps/asas-backoffice/src/components/ui/toggle.tsx +44 -0
  52. package/template/apps/asas-backoffice/src/components/ui/tooltip.tsx +63 -0
  53. package/template/apps/asas-backoffice/src/components.ts +35 -0
  54. package/template/apps/asas-backoffice/src/copy.ts +1 -0
  55. package/template/apps/asas-backoffice/src/first-user.ts +14 -0
  56. package/template/apps/asas-backoffice/src/hooks/use-mobile.ts +19 -0
  57. package/template/apps/asas-backoffice/src/lib/utils.ts +1 -0
  58. package/template/apps/asas-backoffice/src/links.ts +38 -0
  59. package/template/apps/asas-backoffice/src/locale-switch.tsx +28 -0
  60. package/template/apps/asas-backoffice/src/no-organization.tsx +5 -0
  61. package/template/apps/asas-backoffice/src/redirect.ts +1 -0
  62. package/template/apps/asas-backoffice/src/request-headers.ts +11 -0
  63. package/template/apps/asas-backoffice/src/routeTree.gen.ts +206 -0
  64. package/template/apps/asas-backoffice/src/router.tsx +18 -0
  65. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/files.ts +10 -0
  66. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/screen.json +3971 -0
  67. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/types.ts +163 -0
  68. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.$.tsx +17 -0
  69. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.index.tsx +10 -0
  70. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.tsx +13 -0
  71. package/template/apps/asas-backoffice/src/routes/__root.tsx +95 -0
  72. package/template/apps/asas-backoffice/src/routes/api/$.ts +34 -0
  73. package/template/apps/asas-backoffice/src/routes/auth.create-first-user.tsx +113 -0
  74. package/template/apps/asas-backoffice/src/routes/auth.sign-in.tsx +77 -0
  75. package/template/apps/asas-backoffice/src/routes/unauthorized.tsx +134 -0
  76. package/template/apps/asas-backoffice/src/screen-form.tsx +36 -0
  77. package/template/apps/asas-backoffice/src/screen-list.tsx +140 -0
  78. package/template/apps/asas-backoffice/src/screen-names.ts +10 -0
  79. package/template/apps/asas-backoffice/src/screens.tsx +888 -0
  80. package/template/apps/asas-backoffice/src/server.ts +30 -0
  81. package/template/apps/asas-backoffice/src/slide.ts +22 -0
  82. package/template/apps/asas-backoffice/src/start.ts +42 -0
  83. package/template/apps/asas-backoffice/src/styles.css +202 -0
  84. package/template/apps/asas-backoffice/src/theme.ts +2 -0
  85. package/template/apps/asas-backoffice/tsconfig.json +19 -0
  86. package/template/apps/asas-backoffice/vite.config.ts +32 -0
  87. package/template/apps/asas-tanstack/components.json +25 -0
  88. package/template/apps/asas-tanstack/gitignore +3 -0
  89. package/template/apps/asas-tanstack/package.json +58 -0
  90. package/template/apps/asas-tanstack/public/favicon.svg +1 -0
  91. package/template/apps/asas-tanstack/src/api.ts +147 -0
  92. package/template/apps/asas-tanstack/src/auth-frame.tsx +150 -0
  93. package/template/apps/asas-tanstack/src/choose-password.tsx +150 -0
  94. package/template/apps/asas-tanstack/src/components/ui/accordion.tsx +10 -0
  95. package/template/apps/asas-tanstack/src/components/ui/alert-dialog.tsx +173 -0
  96. package/template/apps/asas-tanstack/src/components/ui/avatar.tsx +104 -0
  97. package/template/apps/asas-tanstack/src/components/ui/badge.tsx +48 -0
  98. package/template/apps/asas-tanstack/src/components/ui/breadcrumb.tsx +125 -0
  99. package/template/apps/asas-tanstack/src/components/ui/button-group.tsx +87 -0
  100. package/template/apps/asas-tanstack/src/components/ui/button.tsx +56 -0
  101. package/template/apps/asas-tanstack/src/components/ui/calendar.tsx +223 -0
  102. package/template/apps/asas-tanstack/src/components/ui/card.tsx +94 -0
  103. package/template/apps/asas-tanstack/src/components/ui/chart.tsx +369 -0
  104. package/template/apps/asas-tanstack/src/components/ui/checkbox.tsx +27 -0
  105. package/template/apps/asas-tanstack/src/components/ui/collapsible.tsx +19 -0
  106. package/template/apps/asas-tanstack/src/components/ui/combobox.tsx +300 -0
  107. package/template/apps/asas-tanstack/src/components/ui/dialog.tsx +149 -0
  108. package/template/apps/asas-tanstack/src/components/ui/direction.tsx +5 -0
  109. package/template/apps/asas-tanstack/src/components/ui/directional-icon.tsx +20 -0
  110. package/template/apps/asas-tanstack/src/components/ui/dropdown-menu.tsx +267 -0
  111. package/template/apps/asas-tanstack/src/components/ui/field.tsx +225 -0
  112. package/template/apps/asas-tanstack/src/components/ui/input-group.tsx +147 -0
  113. package/template/apps/asas-tanstack/src/components/ui/input-otp.tsx +20 -0
  114. package/template/apps/asas-tanstack/src/components/ui/input.tsx +20 -0
  115. package/template/apps/asas-tanstack/src/components/ui/label.tsx +18 -0
  116. package/template/apps/asas-tanstack/src/components/ui/popover.tsx +50 -0
  117. package/template/apps/asas-tanstack/src/components/ui/radio-group.tsx +10 -0
  118. package/template/apps/asas-tanstack/src/components/ui/resizable.tsx +47 -0
  119. package/template/apps/asas-tanstack/src/components/ui/scroll-area.tsx +52 -0
  120. package/template/apps/asas-tanstack/src/components/ui/select.tsx +190 -0
  121. package/template/apps/asas-tanstack/src/components/ui/separator.tsx +23 -0
  122. package/template/apps/asas-tanstack/src/components/ui/sheet.tsx +127 -0
  123. package/template/apps/asas-tanstack/src/components/ui/sidebar.tsx +716 -0
  124. package/template/apps/asas-tanstack/src/components/ui/skeleton.tsx +13 -0
  125. package/template/apps/asas-tanstack/src/components/ui/slider.tsx +13 -0
  126. package/template/apps/asas-tanstack/src/components/ui/spinner.tsx +11 -0
  127. package/template/apps/asas-tanstack/src/components/ui/switch.tsx +8 -0
  128. package/template/apps/asas-tanstack/src/components/ui/table.tsx +119 -0
  129. package/template/apps/asas-tanstack/src/components/ui/tabs.tsx +80 -0
  130. package/template/apps/asas-tanstack/src/components/ui/textarea.tsx +18 -0
  131. package/template/apps/asas-tanstack/src/components/ui/toggle-group.tsx +8 -0
  132. package/template/apps/asas-tanstack/src/components/ui/toggle.tsx +44 -0
  133. package/template/apps/asas-tanstack/src/components/ui/tooltip.tsx +68 -0
  134. package/template/apps/asas-tanstack/src/components.ts +35 -0
  135. package/template/apps/asas-tanstack/src/copy.ts +1207 -0
  136. package/template/apps/asas-tanstack/src/first-run.ts +16 -0
  137. package/template/apps/asas-tanstack/src/hooks/use-mobile.ts +19 -0
  138. package/template/apps/asas-tanstack/src/lib/utils.ts +6 -0
  139. package/template/apps/asas-tanstack/src/links.ts +38 -0
  140. package/template/apps/asas-tanstack/src/locale-switch.tsx +28 -0
  141. package/template/apps/asas-tanstack/src/no-organization.tsx +108 -0
  142. package/template/apps/asas-tanstack/src/quiet-abort.ts +25 -0
  143. package/template/apps/asas-tanstack/src/redirect.ts +39 -0
  144. package/template/apps/asas-tanstack/src/request-headers.ts +11 -0
  145. package/template/apps/asas-tanstack/src/routeTree.gen.ts +424 -0
  146. package/template/apps/asas-tanstack/src/router.tsx +18 -0
  147. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +11 -0
  148. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +4242 -0
  149. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +160 -0
  150. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.$.tsx +17 -0
  151. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.index.tsx +10 -0
  152. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.tsx +13 -0
  153. package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +9 -0
  154. package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +1564 -0
  155. package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +94 -0
  156. package/template/apps/asas-tanstack/src/routes/(screens)/portal.$.tsx +17 -0
  157. package/template/apps/asas-tanstack/src/routes/(screens)/portal.index.tsx +10 -0
  158. package/template/apps/asas-tanstack/src/routes/(screens)/portal.tsx +13 -0
  159. package/template/apps/asas-tanstack/src/routes/__root.tsx +99 -0
  160. package/template/apps/asas-tanstack/src/routes/api/$.ts +11 -0
  161. package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +108 -0
  162. package/template/apps/asas-tanstack/src/routes/auth.first-run.tsx +114 -0
  163. package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +94 -0
  164. package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +39 -0
  165. package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +16 -0
  166. package/template/apps/asas-tanstack/src/routes/auth.set-password.tsx +16 -0
  167. package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +98 -0
  168. package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +143 -0
  169. package/template/apps/asas-tanstack/src/routes/index.tsx +146 -0
  170. package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +134 -0
  171. package/template/apps/asas-tanstack/src/screen-form.tsx +36 -0
  172. package/template/apps/asas-tanstack/src/screen-list.tsx +140 -0
  173. package/template/apps/asas-tanstack/src/screen-names.ts +10 -0
  174. package/template/apps/asas-tanstack/src/screens.tsx +888 -0
  175. package/template/apps/asas-tanstack/src/server.ts +26 -0
  176. package/template/apps/asas-tanstack/src/slide.ts +22 -0
  177. package/template/apps/asas-tanstack/src/start.ts +57 -0
  178. package/template/apps/asas-tanstack/src/styles.css +163 -0
  179. package/template/apps/asas-tanstack/src/theme.ts +5 -0
  180. package/template/apps/asas-tanstack/tsconfig.json +19 -0
  181. package/template/apps/asas-tanstack/vite.config.ts +34 -0
  182. package/template/package.json +16 -0
  183. package/template/templates/asas/.claude/skills/add-field/SKILL.md +27 -0
  184. package/template/templates/asas/.claude/skills/add-kayan/SKILL.md +26 -0
  185. package/template/templates/asas/.claude/skills/eject-admin-piece/SKILL.md +28 -0
  186. package/template/templates/asas/.claude/skills/upgrade-mithaq/SKILL.md +35 -0
  187. package/template/templates/asas/.env.example +33 -0
  188. package/template/templates/asas/AGENTS.md +101 -0
  189. package/template/templates/asas/CLAUDE.md +1 -0
  190. package/template/templates/asas/README.md +1056 -0
  191. package/template/templates/asas/mithaq.hints.json +25 -0
  192. package/template/templates/asas/package.json +53 -0
  193. package/template/templates/asas/src/asas.ts +118 -0
  194. package/template/templates/asas/src/backoffice-http.ts +117 -0
  195. package/template/templates/asas/src/dev-setup.ts +112 -0
  196. package/template/templates/asas/src/local-database.ts +22 -0
  197. package/template/templates/asas/src/messages/account.json +132 -0
  198. package/template/templates/asas/src/messages/auditLog.json +304 -0
  199. package/template/templates/asas/src/messages/backofficeLog.json +132 -0
  200. package/template/templates/asas/src/messages/courses.json +312 -0
  201. package/template/templates/asas/src/messages/enrollments.json +60 -0
  202. package/template/templates/asas/src/messages/invitation.json +82 -0
  203. package/template/templates/asas/src/messages/localUsers.json +48 -0
  204. package/template/templates/asas/src/messages/media.json +76 -0
  205. package/template/templates/asas/src/messages/member.json +62 -0
  206. package/template/templates/asas/src/messages/memberRoles.json +48 -0
  207. package/template/templates/asas/src/messages/mithaq.json +214 -0
  208. package/template/templates/asas/src/messages/organization.json +72 -0
  209. package/template/templates/asas/src/messages/organizationRole.json +62 -0
  210. package/template/templates/asas/src/messages/roles.json +72 -0
  211. package/template/templates/asas/src/messages/session.json +102 -0
  212. package/template/templates/asas/src/messages/sessions.json +136 -0
  213. package/template/templates/asas/src/messages/settings.json +64 -0
  214. package/template/templates/asas/src/messages/tags.json +48 -0
  215. package/template/templates/asas/src/messages/user.json +112 -0
  216. package/template/templates/asas/src/messages/verification.json +62 -0
  217. package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/migration.sql +177 -0
  218. package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/snapshot.json +2169 -0
  219. package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/migration.sql +1 -0
  220. package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/snapshot.json +2182 -0
  221. package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/migration.sql +20 -0
  222. package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/snapshot.json +2409 -0
  223. package/template/templates/asas/src/migrations/20260922082945_military_ironclad/migration.sql +15 -0
  224. package/template/templates/asas/src/migrations/20260922082945_military_ironclad/snapshot.json +2627 -0
  225. package/template/templates/asas/src/migrations/20260922112630_bitter_chat/migration.sql +5 -0
  226. package/template/templates/asas/src/migrations/20260922112630_bitter_chat/snapshot.json +2691 -0
  227. package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/migration.sql +8 -0
  228. package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/snapshot.json +2795 -0
  229. package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/migration.sql +3 -0
  230. package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/snapshot.json +2834 -0
  231. package/template/templates/asas/src/migrations/20260927212525_bent_prowler/migration.sql +9 -0
  232. package/template/templates/asas/src/migrations/20260927212525_bent_prowler/snapshot.json +2870 -0
  233. package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/migration.sql +1 -0
  234. package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/snapshot.json +2881 -0
  235. package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/migration.sql +15 -0
  236. package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/snapshot.json +3073 -0
  237. package/template/templates/asas/src/migrations/20260929024407_grey_morlun/migration.sql +21 -0
  238. package/template/templates/asas/src/migrations/20260929024407_grey_morlun/snapshot.json +3098 -0
  239. package/template/templates/asas/src/migrations/20260929130530_bored_jackal/migration.sql +21 -0
  240. package/template/templates/asas/src/migrations/20260929130530_bored_jackal/snapshot.json +3124 -0
  241. package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/migration.sql +32 -0
  242. package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/snapshot.json +3324 -0
  243. package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/migration.sql +2 -0
  244. package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/snapshot.json +3350 -0
  245. package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/migration.sql +21 -0
  246. package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/snapshot.json +3635 -0
  247. package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/migration.sql +8 -0
  248. package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/snapshot.json +3635 -0
  249. package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/migration.sql +18 -0
  250. package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/snapshot.json +3635 -0
  251. package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/migration.sql +18 -0
  252. package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/snapshot.json +3635 -0
  253. package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/migration.sql +11 -0
  254. package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/snapshot.json +3667 -0
  255. package/template/templates/asas/src/migrations/20261012000752_organization_ownership/migration.sql +48 -0
  256. package/template/templates/asas/src/migrations/20261012000752_organization_ownership/snapshot.json +3891 -0
  257. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/migration.sql +1 -0
  258. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/snapshot.json +3881 -0
  259. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/migration.sql +1 -0
  260. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/snapshot.json +3894 -0
  261. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/migration.sql +1 -0
  262. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/snapshot.json +3907 -0
  263. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/migration.sql +1 -0
  264. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/snapshot.json +3907 -0
  265. package/template/templates/asas/src/migrations/20261015042444_organization_names/migration.sql +1 -0
  266. package/template/templates/asas/src/migrations/20261015042444_organization_names/snapshot.json +3920 -0
  267. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/migration.sql +1 -0
  268. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/snapshot.json +3933 -0
  269. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/migration.sql +15 -0
  270. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/snapshot.json +4113 -0
  271. package/template/templates/asas/src/migrations/20261015095752_portal/migration.sql +2 -0
  272. package/template/templates/asas/src/migrations/20261015095752_portal/snapshot.json +4113 -0
  273. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/migration.sql +9 -0
  274. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/snapshot.json +4302 -0
  275. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/migration.sql +27 -0
  276. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/snapshot.json +4289 -0
  277. package/template/templates/asas/src/migrations/drizzle.config.ts +11 -0
  278. package/template/templates/asas/src/migrations/schema.ts +30 -0
  279. package/template/templates/asas/src/mithaq.ts +4 -0
  280. package/template/templates/asas/src/organizations.ts +3 -0
  281. package/template/templates/asas/src/seed.ts +427 -0
  282. package/template/templates/asas/src/ui-backoffice/home.tsx +63 -0
  283. package/template/templates/asas/src/ui-backoffice/organization-actions.tsx +121 -0
  284. package/template/templates/asas/src/ui-backoffice/record-page.tsx +30 -0
  285. package/template/templates/asas/src/ui-backoffice/role-cells.tsx +59 -0
  286. package/template/templates/asas/src/ui-backoffice/user-actions.tsx +249 -0
  287. package/template/templates/asas/src/ui-dashboard/dashboard.tsx +300 -0
  288. package/template/templates/asas/src/ui-dashboard/home.tsx +73 -0
  289. package/template/templates/asas/src/ui-dashboard/members-card.tsx +147 -0
  290. package/template/templates/asas/src/ui-dashboard/members.tsx +829 -0
  291. package/template/templates/asas/src/ui-dashboard/theme-provider.tsx +10 -0
  292. package/template/templates/asas/src/ui-dashboard/versions.tsx +446 -0
  293. package/template/templates/asas/src/ui-portal/courses-home.tsx +96 -0
  294. package/template/templates/asas/src/ui-portal/home.tsx +73 -0
  295. package/template/templates/asas/src/ui-portal/page.tsx +11 -0
  296. package/template/templates/features/audit-log/audit-log/audit-log.ts +284 -0
  297. package/template/templates/features/audit-log/backoffice-log/backoffice-log.ts +67 -0
  298. package/template/templates/features/audit-log/index.ts +4 -0
  299. package/template/templates/features/audit-log/package.json +13 -0
  300. package/template/templates/features/auth/index.ts +11 -0
  301. package/template/templates/features/auth/local-users/local-users.ts +143 -0
  302. package/template/templates/features/auth/package.json +13 -0
  303. package/template/templates/features/auth/user/account.access.ts +8 -0
  304. package/template/templates/features/auth/user/account.ts +31 -0
  305. package/template/templates/features/auth/user/index.ts +18 -0
  306. package/template/templates/features/auth/user/invitation.access.ts +4 -0
  307. package/template/templates/features/auth/user/invitation.ts +27 -0
  308. package/template/templates/features/auth/user/member.access.ts +10 -0
  309. package/template/templates/features/auth/user/member.screens.ts +37 -0
  310. package/template/templates/features/auth/user/member.ts +26 -0
  311. package/template/templates/features/auth/user/organization.access.ts +20 -0
  312. package/template/templates/features/auth/user/organization.screens.ts +37 -0
  313. package/template/templates/features/auth/user/organization.ts +25 -0
  314. package/template/templates/features/auth/user/organizationRole.access.ts +8 -0
  315. package/template/templates/features/auth/user/organizationRole.ts +25 -0
  316. package/template/templates/features/auth/user/session.access.ts +8 -0
  317. package/template/templates/features/auth/user/session.ts +28 -0
  318. package/template/templates/features/auth/user/user.access.ts +30 -0
  319. package/template/templates/features/auth/user/user.screens.ts +45 -0
  320. package/template/templates/features/auth/user/user.ts +31 -0
  321. package/template/templates/features/auth/user/verification.access.ts +8 -0
  322. package/template/templates/features/auth/user/verification.ts +23 -0
  323. package/template/templates/features/courses/courses/courses.ts +601 -0
  324. package/template/templates/features/courses/courses/cover-reads.ts +33 -0
  325. package/template/templates/features/courses/courses/ui/archived-badge.tsx +9 -0
  326. package/template/templates/features/courses/courses/ui/fee-cell.tsx +19 -0
  327. package/template/templates/features/courses/courses/ui/no-courses.tsx +13 -0
  328. package/template/templates/features/courses/courses/ui/status-cell.tsx +33 -0
  329. package/template/templates/features/courses/courses/ui/unpublish-button.tsx +15 -0
  330. package/template/templates/features/courses/domain/archive.ts +39 -0
  331. package/template/templates/features/courses/domain/on-offer.ts +6 -0
  332. package/template/templates/features/courses/enrollments/enrollments.ts +309 -0
  333. package/template/templates/features/courses/enrollments/ui/session-cell.tsx +75 -0
  334. package/template/templates/features/courses/enrollments/ui/session-labels.tsx +36 -0
  335. package/template/templates/features/courses/index.ts +8 -0
  336. package/template/templates/features/courses/package.json +24 -0
  337. package/template/templates/features/courses/portal/ui/catalogue.tsx +86 -0
  338. package/template/templates/features/courses/portal/ui/course.tsx +176 -0
  339. package/template/templates/features/courses/portal/ui/enrollments.tsx +170 -0
  340. package/template/templates/features/courses/portal/ui/parts.tsx +95 -0
  341. package/template/templates/features/courses/portal/ui/reads.ts +76 -0
  342. package/template/templates/features/courses/portal/ui/rules.ts +87 -0
  343. package/template/templates/features/courses/portal/ui/shell.tsx +194 -0
  344. package/template/templates/features/courses/sessions/seats.ts +42 -0
  345. package/template/templates/features/courses/sessions/sessions.ts +186 -0
  346. package/template/templates/features/media/index.ts +10 -0
  347. package/template/templates/features/media/media/media.ts +131 -0
  348. package/template/templates/features/media/media/ui/preview.tsx +143 -0
  349. package/template/templates/features/media/package.json +19 -0
  350. package/template/templates/features/roles/index.ts +4 -0
  351. package/template/templates/features/roles/member-roles/member-roles.ts +207 -0
  352. package/template/templates/features/roles/package.json +21 -0
  353. package/template/templates/features/roles/roles/roles.ts +184 -0
  354. package/template/templates/features/roles/roles/ui/role-form.tsx +267 -0
  355. package/template/templates/features/roles/roles/ui/roles.tsx +190 -0
  356. package/template/templates/features/settings/index.ts +3 -0
  357. package/template/templates/features/settings/package.json +13 -0
  358. package/template/templates/features/settings/settings/settings.ts +64 -0
  359. package/template/templates/features/tags/index.ts +3 -0
  360. package/template/templates/features/tags/package.json +14 -0
  361. package/template/templates/features/tags/tags/tags.ts +80 -0
  362. package/template/templates/mithaq/components.json +22 -0
  363. package/template/templates/mithaq/package.json +33 -0
  364. package/template/templates/mithaq/src/mail/index.ts +32 -0
  365. package/template/templates/mithaq/src/mail/mailers.ts +36 -0
  366. package/template/templates/mithaq/src/mithaq/audit-sink.ts +111 -0
  367. package/template/templates/mithaq/src/mithaq/auth/backoffice-roles.ts +108 -0
  368. package/template/templates/mithaq/src/mithaq/auth/index.ts +1859 -0
  369. package/template/templates/mithaq/src/mithaq/auth/organizations.ts +80 -0
  370. package/template/templates/mithaq/src/mithaq/pages.ts +83 -0
  371. package/template/templates/mithaq/src/mithaq/rich-text-nodes.ts +19 -0
  372. package/template/templates/mithaq/src/mithaq.ts +593 -0
  373. package/template/templates/mithaq/src/roles/default-member-roles.ts +29 -0
  374. package/template/templates/mithaq/src/roles/grants.ts +47 -0
  375. package/template/templates/mithaq/src/roles/member-access.ts +11 -0
  376. package/template/templates/mithaq/src/roles/statements.ts +13 -0
  377. package/template/templates/mithaq/src/roles/system-roles.ts +16 -0
  378. package/template/templates/mithaq/src/roles/tenant-access.ts +260 -0
  379. package/template/templates/mithaq/src/roles/tenant-roles.ts +321 -0
  380. package/template/templates/mithaq/src/steps/expected-updated-at.ts +41 -0
  381. package/template/templates/mithaq/src/storage/defaults.ts +156 -0
  382. package/template/templates/mithaq/src/storage/index.ts +97 -0
  383. package/template/templates/mithaq/src/storage/local.ts +143 -0
  384. package/template/templates/mithaq/src/storage/r2.ts +5 -0
  385. package/template/templates/mithaq/src/storage/s3.ts +91 -0
  386. package/template/templates/mithaq/src/storage/uploadthing.ts +59 -0
  387. package/template/templates/mithaq/src/ui/activity-page.tsx +33 -0
  388. package/template/templates/mithaq/src/ui/activity.tsx +527 -0
  389. package/template/templates/mithaq/src/ui/app-shell.tsx +268 -0
  390. package/template/templates/mithaq/src/ui/kpi-card.tsx +25 -0
  391. package/template/templates/mithaq/src/ui/record-page.tsx +50 -0
  392. package/template/templates/mithaq/src/ui/rich-text-nodes.tsx +33 -0
  393. package/template/templates/mithaq/src/ui/screen-nav.tsx +28 -0
  394. package/template/templates/mithaq/src/ui/table-page.tsx +9 -0
  395. package/template/templates/mithaq/src/ui/top-shell.tsx +60 -0
  396. package/README.md +0 -3
@@ -0,0 +1,888 @@
1
+ import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query'
2
+ import { notFound, redirect, useMatches, useRouter } from '@tanstack/react-router'
3
+ import { createIsomorphicFn, createServerFn, createServerOnlyFn } from '@tanstack/react-start'
4
+ import { Suspense, lazy, useEffect, useState, type ComponentType, type ReactElement, type ReactNode } from 'react'
5
+ import { holds, navOf, referencesIn, type AccessReport, type ClientKayan, type LoaderTools, type Row, type ScreenClient } from 'mithaq/admin'
6
+ import { ScreenContext, useScreen, type ScreenState } from 'mithaq/admin/react'
7
+ import { actingAs, arm, authArm, caused, clientOf, readsOf, takesKey } from './api.ts'
8
+ import { clampedHref, routedHref } from './links.ts'
9
+ import { mergedHeaders } from './request-headers.ts'
10
+ import { nameIn } from 'asas/organizations'
11
+ import { documentHead, localeOf, locales, translator, type Locale } from './copy.ts'
12
+ import { FieldValue } from 'mithaq-admin/field-value.tsx'
13
+ import type { FormClient, FormProps } from 'mithaq-admin/form-types.tsx'
14
+ import type { Page } from 'mithaq-admin/list-types.tsx'
15
+ import { QueryRefused, findOf, listRaw, prefixOf, readQuery, rulesOf, scopedTo, withList, type RawQuery } from 'mithaq-admin/list-query.ts'
16
+ import { newTitleOf, titleOf } from 'mithaq-admin/copy.ts'
17
+ import { createsIn } from 'mithaq-admin/field-values.ts'
18
+ import { NavLinks } from 'mithaq-admin/nav-links.tsx'
19
+ import { NodeButton, OwnButton, pageButtonOf } from 'mithaq-admin/node-buttons.tsx'
20
+ import { formIn, listIn, nodesOf, readsAsValues, relatedIn, scopeOf, type Draw } from 'mithaq-admin/node-tree.ts'
21
+ import { frameOf, pageOf } from 'mithaq-admin/nodes.tsx'
22
+ import { RecordFrame, holdFocus } from 'mithaq-admin/record-frame.tsx'
23
+ import { rowTitle } from 'mithaq-admin/row-title.tsx'
24
+ import { refOf, type Refs, type TreePage } from 'mithaq-admin/screen-refs.ts'
25
+ import { idsOf } from 'mithaq-admin/relationship-control.tsx'
26
+ import { ScreenForm } from './screen-form.tsx'
27
+ import { ScreenList, askedFor, readsDrafts, rowReadsDrafts, rowsKey } from './screen-list.tsx'
28
+ import { screenNamed } from './screen-names.ts'
29
+ import { slide } from './slide.ts'
30
+ import { Toaster } from 'mithaq-admin/toast.tsx'
31
+ import { useCopy } from './routes/__root.tsx'
32
+
33
+ /**
34
+ * The built asas, on the server alone. The module cache serves one build for
35
+ * every call, since `asas` stays out of the server bundle; a changed `asas` takes a restart.
36
+ */
37
+ export const getAsas = createServerOnlyFn(async () => (await import('asas')).asas)
38
+
39
+ type InProcess = Record<string, { operations: Record<string, (input: object, options: object) => Promise<unknown>> }>
40
+
41
+ /**
42
+ * Each kayan's operations in process. In the backoffice app a read of a kayan asks `tenant: 'all'`,
43
+ * which the runner grants backoffice staff there alone, so staff read every organization's rows; a
44
+ * global is one organization's row and asks for none, and a write never asks.
45
+ */
46
+ const inProcess = async (asas: Awaited<ReturnType<typeof getAsas>>): Promise<InProcess> => {
47
+ const kayan = asas.kayan as unknown as InProcess
48
+ if (!(await import('asas')).onBackoffice()) return kayan
49
+ const reads = new Set(asas.operations.filter((one) => one.effect === 'read' && !one.single).map((one) => `${one.kayan}/${one.name}`))
50
+ return Object.fromEntries(Object.entries(kayan).map(([name, one]) => [name, {
51
+ operations: Object.fromEntries(Object.entries(one.operations).map(([operation, run]) =>
52
+ [operation, reads.has(`${name}/${operation}`) ? (input: object, options: object) => run(input, { ...options, tenant: 'all' }) : run])),
53
+ }]))
54
+ }
55
+
56
+ /** One screen's generated glob: each file its references name, keyed by the path a reference prints, loaded only when called. */
57
+ export type Files = Readonly<Record<string, () => Promise<unknown>>>
58
+
59
+ type Drawn = ComponentType<Record<string, unknown>>
60
+ const loaded = new Map<string, Drawn>()
61
+ const drawn = new Map<string, Drawn>()
62
+
63
+ const load = async (files: Files, reference: string) => {
64
+ const at = reference.indexOf('#')
65
+ const exported = ((await files[reference.slice(0, at)]?.()) as Record<string, Drawn> | undefined)?.[reference.slice(at + 1)]
66
+ if (exported) loaded.set(reference, exported)
67
+ return exported
68
+ }
69
+
70
+ // One `lazy` per reference. A loaded file is handed over as a thenable that
71
+ // resolves at once, which React reads without suspending; an unloaded one
72
+ // (hydration before any loader ran) suspends.
73
+ const drawnOf = (files: Files, reference: string) => {
74
+ let found = drawn.get(reference)
75
+ if (!found) {
76
+ found = lazy(() => {
77
+ const ready = loaded.get(reference)
78
+ return ready
79
+ ? ({ then: (resolve: (module: { default: Drawn }) => void) => resolve({ default: ready }) } as unknown as Promise<{ default: Drawn }>)
80
+ : load(files, reference).then((exported) => ({ default: exported ?? (() => null) }))
81
+ })
82
+ drawn.set(reference, found)
83
+ }
84
+ return found
85
+ }
86
+
87
+ const refsOf = (files: Files, screen: ScreenClient): Refs =>
88
+ Object.fromEntries(
89
+ referencesIn(screen)
90
+ .filter((reference) => Object.hasOwn(files, reference.slice(0, reference.indexOf('#'))))
91
+ .map((reference) => [reference, drawnOf(files, reference)]),
92
+ ) as Refs
93
+
94
+ const preload = (files: Files, ...parts: unknown[]) => Promise.all(referencesIn(parts).map((reference) => loaded.get(reference) ?? load(files, reference)))
95
+
96
+ /** This build's screens by name: what a request's screen name is checked against before anything is read by it. */
97
+ const screensOf = (targets: unknown) =>
98
+ Object.fromEntries(Object.entries(targets as Record<string, { capability?: string } | undefined>).filter(([, target]) => target?.capability === 'screen'))
99
+
100
+ const segments = (path: string) => path.split('/').filter(Boolean)
101
+
102
+ /**
103
+ * The page a path asks for, a kayan's or the screen's own, longest first and a
104
+ * static segment before a parameter, so `/courses/new` is the create page, not
105
+ * an edit of a row called `new`.
106
+ */
107
+ export const matchPage = (screen: ScreenClient, path: string) => {
108
+ const asked = segments(path)
109
+ const pages = [
110
+ ...Object.entries(screen.pages).map(([name, page]) => ({ kayan: undefined, name, page, parts: segments(page.path) })),
111
+ ...Object.values(screen.kayan).flatMap((kayan) => Object.entries(kayan.pages).map(([name, page]) => ({ kayan: kayan.name as string | undefined, name, page, parts: segments(page.path) }))),
112
+ ]
113
+ .sort((a, b) => b.parts.length - a.parts.length || a.parts.filter((one) => one.startsWith(':')).length - b.parts.filter((one) => one.startsWith(':')).length)
114
+ for (const one of pages) {
115
+ if (one.parts.length !== asked.length) continue
116
+ const params: Record<string, string> = {}
117
+ if (one.parts.every((part, at) => (part.startsWith(':') ? ((params[part.slice(1)] = asked[at]!), true) : part === asked[at]))) {
118
+ return { kayan: one.kayan, page: one.name, params }
119
+ }
120
+ }
121
+ return undefined
122
+ }
123
+
124
+ type Actor = {
125
+ readonly id?: string
126
+ readonly name?: string | null
127
+ readonly email?: string | null
128
+ readonly impersonatedBy?: unknown
129
+ readonly activeOrganizationId?: string | null
130
+ readonly organizations?: readonly { readonly id: string; readonly name: string; readonly slug?: string; readonly names?: unknown }[]
131
+ }
132
+
133
+ // `'unauthorized'` is answered before the projection is read, so a refused screen sends no kayan, field, row or page;
134
+ // what is returned is serialized into the document, so projection and report are cut to what the actor may read.
135
+ const resolveScreen = async (asked: unknown) => {
136
+ const [{ getRequest, setResponseHeader }, asas] = await Promise.all([import('@tanstack/react-start/server'), getAsas()])
137
+ const name = screenNamed(screensOf(asas.targets), asked)
138
+ const request = getRequest()
139
+ const ask = (path: string) => asas.handler(new Request(new URL(path, request.url), { headers: request.headers }))
140
+ await slide({ strategies: asas.auth.strategies, headers: request.headers, setResponseHeader })
141
+ // Off the strategy loop, not the provider's session route: in process, so
142
+ // no request counts against its rate limit.
143
+ const actor = (await asas.auth.actor(request.headers)) as Actor | null
144
+ if (!actor?.email) return undefined
145
+ const report = (await (await ask('/api/access')).json()) as AccessReport
146
+ if (!report.screens?.includes(name)) return 'unauthorized' as const
147
+ const full = (asas.targets as unknown as Record<string, { client?: ScreenClient } | undefined>)[name]!.client!
148
+ const kayan = Object.fromEntries(
149
+ Object.entries(full.kayan)
150
+ .filter(([one]) => report.collections?.[one]?.read === true)
151
+ .map(([one, value]) => [one, { ...value, fields: value.fields.filter((field) => report.collections?.[one]?.fields?.[field.name]?.read === true) }]),
152
+ )
153
+ const { onBackoffice, backofficeEntry, BACKOFFICE_SCREEN } = await import('asas')
154
+ const backoffice = backofficeEntry(actor)
155
+ // The other screens of this app the actor may enter. The backoffice's own name
156
+ // is left out of a tenant app's list, so the one link to it is `backofficeEntry`'s alone.
157
+ const served = screensOf(asas.targets) as Record<string, { client: ScreenClient }>
158
+ const siblings = (report.screens ?? [])
159
+ .filter((one) => one !== name && Object.hasOwn(served, one) && (one === BACKOFFICE_SCREEN) === onBackoffice())
160
+ .map((one) => ({ name: one, path: served[one]!.client.path }))
161
+ const others = [...siblings, ...(backoffice ? [{ name: BACKOFFICE_SCREEN, path: backoffice }] : [])]
162
+ return {
163
+ asas,
164
+ request,
165
+ name,
166
+ report,
167
+ // Whole, so a page of a kayan the cut leaves out is told from a path naming no page.
168
+ full,
169
+ user: {
170
+ ...(actor.id ? { id: actor.id } : {}),
171
+ name: actor.name ?? actor.email,
172
+ email: actor.email,
173
+ // Only the fact; the id of the person behind the session stays on the server.
174
+ ...(actor.impersonatedBy ? { impersonating: true } : {}),
175
+ ...(others.length ? { screens: others } : {}),
176
+ ...(actor.organizations && {
177
+ // Every locale's, because the frame is read before the viewer's locale is known here; the shell picks one.
178
+ organizations: actor.organizations.map(({ id, name, slug, names }) => ({ id, name, slug, ...(typeof names === 'string' && { names }) })),
179
+ activeOrganizationId: actor.activeOrganizationId ?? null,
180
+ }),
181
+ },
182
+ access: {
183
+ // So `holds` answers a `screen.<name>` key in the browser as on the server.
184
+ ...(report.screens === undefined ? {} : { screens: report.screens }),
185
+ collections: Object.fromEntries(Object.keys(kayan).map((one) => [one, report.collections![one]])),
186
+ // For a key neither a screen's nor an operation's, such as `user.ban`.
187
+ ...(report.permissions === undefined ? {} : { permissions: report.permissions }),
188
+ } satisfies AccessReport,
189
+ // The nav is cut from the whole report, before the report is cut to the screen's kayan.
190
+ screen: { ...full, kayan, nav: navOf(full, report) } as ScreenClient,
191
+ }
192
+ }
193
+
194
+ /** The layout route's answer: who, what they may do, and the screen. */
195
+ export const loadScreen = createServerFn({ method: 'GET', strict: { output: false } })
196
+ // Checked against this build's screens in `resolveScreen`.
197
+ .validator((screen: unknown) => screen)
198
+ .handler(async ({ data }) => {
199
+ const found = await resolveScreen(data)
200
+ return found === 'unauthorized' ? found : found ? { name: found.name, user: found.user, access: found.access, screen: found.screen } : null
201
+ })
202
+
203
+ /**
204
+ * What `/unauthorized` offers a signed-in actor: every screen the report lists for it, `null` without a session.
205
+ * `from` is only matched against this build's screens and pages, so nothing a caller writes is said back.
206
+ */
207
+ export const loadEnterable = createServerFn({ method: 'GET' })
208
+ .validator((asked?: { from?: unknown; locale?: unknown }) => ({
209
+ from: typeof asked?.from === 'string' ? asked.from : undefined,
210
+ locale: localeOf(typeof asked?.locale === 'string' ? asked.locale : undefined),
211
+ }))
212
+ .handler(async ({ data }): Promise<{
213
+ screens: { name: string; path: string }[]
214
+ refused?: { page: string; screen: { name: string; path: string } }
215
+ email?: string
216
+ organizations?: { id: string; name: string; slug?: string }[]
217
+ others?: { id: string; name: string; slug?: string }[]
218
+ impersonating?: string
219
+ } | null> => {
220
+ const [{ getRequest }, asas, { onBackoffice, BACKOFFICE_SCREEN }] = await Promise.all([import('@tanstack/react-start/server'), getAsas(), import('asas')])
221
+ const request = getRequest()
222
+ const actor = (await asas.auth.actor(request.headers)) as Actor | null
223
+ if (!actor?.email) return null
224
+ const report = (await (await asas.handler(new Request(new URL('/api/access', request.url), { headers: request.headers }))).json()) as AccessReport
225
+ const screens = screensOf(asas.targets) as Record<string, { client: ScreenClient }>
226
+ const entered = (report.screens ?? [])
227
+ .filter((name) => Object.hasOwn(screens, name) && (name === BACKOFFICE_SCREEN) === onBackoffice())
228
+ .map((name) => ({ name, path: screens[name]!.client.path }))
229
+ const impersonating = actor.impersonatedBy ? { impersonating: actor.name ?? actor.email } : {}
230
+ if (entered.length) {
231
+ const { from, locale } = data
232
+ const home = from === undefined ? undefined : entered.find((one) => from === one.path || from.startsWith(`${one.path}/`))
233
+ const match = home && matchPage(screens[home.name]!.client, from!.slice(home.path.length))
234
+ if (!home || !match) return { screens: entered, ...impersonating }
235
+ const page = nameOfPage({ screen: screens[home.name]!.client, name: home.name, match, copy: { t: translator(locale), locale } })
236
+ return { screens: entered, refused: { page, screen: home }, ...impersonating }
237
+ }
238
+ const memberships = actor.organizations ?? []
239
+ const active = memberships.find((one) => one.id === actor.activeOrganizationId)
240
+ // Named here, where the locale is known: the page draws what it is handed, and a name the viewer cannot read
241
+ // in the default locale would be no name to them.
242
+ const named = ({ id, name, slug, names }: NonNullable<Actor['organizations']>[number]) => ({ id, name: nameIn({ name, names }, data.locale), slug })
243
+ return {
244
+ screens: entered,
245
+ email: actor.email,
246
+ organizations: (active ? [active] : memberships).map(named),
247
+ others: onBackoffice() ? [] : memberships.filter((one) => one !== active).map(named),
248
+ ...impersonating,
249
+ }
250
+ })
251
+
252
+ export { askedFor }
253
+
254
+ const holdsForm = (page: TreePage) => formIn(nodesOf(page)) !== undefined
255
+
256
+ const relatedOf = (screen: ScreenClient, kayan: ClientKayan) => Object.values(screen.kayan).filter((one) => kayan.fields.some((field) => field.relationTo === one.name))
257
+
258
+ const scopeTo = (node: { where?: string }, id: unknown) => ({ [node.where!]: { equals: id } })
259
+
260
+ /** One page's answer: which page the path asked for, the row a form draws, the rows a list draws, and what this actor may write. */
261
+ export const loadPage = createServerFn({ method: 'GET', strict: { output: false } })
262
+ .validator((asked: { screen: unknown; path: string; query: Record<string, unknown> }) => ({
263
+ screen: asked.screen,
264
+ path: String(asked.path),
265
+ query: askedFor(asked.query),
266
+ }))
267
+ .handler(async ({ data }) => {
268
+ const found = await resolveScreen(data.screen)
269
+ if (!found) return 'signed out' as const
270
+ if (found === 'unauthorized') return found
271
+ const { asas, screen } = found
272
+ const match = matchPage(screen, data.path)
273
+ // A page of a kayan the actor may not read is refused like a page whose key
274
+ // it does not hold, so one role meets one denial page.
275
+ if (!match) return matchPage(found.full, data.path) ? ('unauthorized' as const) : null
276
+ // A page of the screen's own reads nothing, but one naming a key is served
277
+ // only to an actor the report says holds it; its nav entry is only chrome.
278
+ if (match.kayan === undefined) {
279
+ const permission = screen.pages[match.page]?.permission
280
+ if (permission !== undefined && !holds(found.report, permission)) return 'unauthorized' as const
281
+ return { name: found.name, user: found.user, access: found.access, screen, kayan: undefined, page: match.page, value: null, first: undefined, related: {}, refused: [], creates: false, operations: {}, writable: {}, drafts: undefined }
282
+ }
283
+ const kayan = screen.kayan[match.kayan]
284
+ if (!kayan) return null
285
+ const page = kayan.pages[match.page]!
286
+ const tree = 'page' in page ? page : undefined
287
+ const actor = (await asas.auth.actor(found.request.headers)) ?? undefined
288
+ const operations = (await inProcess(asas)) as unknown as Record<string, { operations: Record<string, (input: object, options: { actor?: unknown; draft?: boolean }) => Promise<unknown>> } | undefined>
289
+ const globals = asas.global as unknown as typeof operations
290
+ const readRow = async (id: string, draft: boolean) =>
291
+ (await operations[kayan.name]!.operations.findByID!({ id }, { actor, ...(draft && { draft }) }).catch((thrown: unknown) => {
292
+ if (!caused(thrown)) throw thrown
293
+ return null
294
+ })) as Row | null
295
+ const report = found.access.collections?.[kayan.name]
296
+ // A create page with no field to write is refused like a page whose key is
297
+ // not held, not drawn as an empty form.
298
+ if (match.page === 'create' && !createsIn({ kayan, report })) return 'unauthorized' as const
299
+ // A kayan that keeps drafts, for an actor holding `findVersions` (the key a
300
+ // draft read and a draft save ask): the actor edits the latest draft.
301
+ // Without it, or where it leaves this row out, the actor reads the row and saves there.
302
+ const drafting = await rowReadsDrafts({
303
+ kayan,
304
+ operations: report?.operations,
305
+ id: kayan.single ? undefined : match.params.id,
306
+ findVersions: (input) => operations[kayan.name]!.operations.findVersions!(input, { actor }).catch((thrown: unknown) => {
307
+ if (!caused(thrown)) throw thrown
308
+ return null
309
+ }) as Promise<{ docs: readonly unknown[] } | null>,
310
+ })
311
+ let value: Row | null = null
312
+ if (kayan.single ? !tree || holdsForm(tree) : match.params.id !== undefined) {
313
+ if (kayan.single) value = (await globals[kayan.name]!.operations.findOne!({}, { actor })) as Row | null
314
+ else if (match.params.id) {
315
+ value = await readRow(match.params.id, drafting)
316
+ if (!value) return null
317
+ }
318
+ }
319
+ // A create page reads Draft from the start, since no published copy exists.
320
+ // A plain read finds only a published row, which tells draft from changed.
321
+ let drafts: FormProps['drafts']
322
+ if (drafting && tree && holdsForm(tree)) {
323
+ const published = value ? await readRow(String(value.id), false) : null
324
+ drafts = {
325
+ status: !published ? 'draft' : value!._status === 'draft' ? 'changed' : 'published',
326
+ ...(kayan.drafts!.autosave && { autosave: kayan.drafts!.autosave.interval }),
327
+ }
328
+ }
329
+ // The list this page draws, or the one it opens over. A URL the list does not
330
+ // offer is refused before `find` is asked anything, and the page draws the refusal.
331
+ const list = kayan.pages.list && 'page' in kayan.pages.list ? kayan.pages.list : undefined
332
+ const listPage = tree && listIn(nodesOf(tree)) ? tree : tree?.mode ? list : undefined
333
+ let first: { input: Record<string, unknown>; rows: Page } | undefined
334
+ const node = listPage && listIn(nodesOf(listPage))
335
+ if (node) {
336
+ try {
337
+ const input = findOf({
338
+ kayan,
339
+ query: readQuery(data.query, rulesOf({ kayan, node, maxLimit: screen.maxLimit, context: { client: relatedOf(screen, kayan), locales } })),
340
+ node,
341
+ locales,
342
+ client: relatedOf(screen, kayan),
343
+ })
344
+ first = { input, rows: (await operations[kayan.name]!.operations.find!(input, { actor, ...(drafting && { draft: true }) })) as Page }
345
+ } catch (thrown) {
346
+ if (!(thrown instanceof QueryRefused)) throw thrown
347
+ }
348
+ }
349
+ // Each list of another kayan the page draws over its row. A kayan the screen
350
+ // does not cross (one the actor may not read) is read nothing, and so is a URL the list does not offer.
351
+ const related: Record<string, { input: Record<string, unknown>; rows: Page }> = {}
352
+ // Lists whose read the server refused, which the page says it cannot show rather than drawing nothing.
353
+ const refused: string[] = []
354
+ for (const one of tree && value ? relatedIn(nodesOf(tree), scopeOf({ values: {}, record: value, access: found.access, kayan })) : []) {
355
+ const target = screen.kayan[one.of!]
356
+ if (!target) continue
357
+ try {
358
+ const input = scopedTo(findOf({
359
+ kayan: target,
360
+ query: readQuery(listRaw(data.query, prefixOf(one)), rulesOf({ kayan: target, node: one, maxLimit: screen.maxLimit, context: { client: relatedOf(screen, target), locales } })),
361
+ node: one,
362
+ locales,
363
+ client: relatedOf(screen, target),
364
+ }), scopeTo(one, value!.id))
365
+ const draft = readsDrafts(target, found.access.collections?.[target.name]?.operations)
366
+ related[prefixOf(one)] = { input, rows: (await operations[target.name]!.operations.find!(input, { actor, ...(draft && { draft }) })) as Page }
367
+ } catch (thrown) {
368
+ if (!(thrown instanceof QueryRefused) && !caused(thrown)) throw thrown
369
+ if ((thrown as { status?: unknown }).status === 403) refused.push(prefixOf(one))
370
+ }
371
+ }
372
+ const own = (name: string) => (asas.operations as readonly { kayan: string; name: string; input: Parameters<typeof takesKey>[0] }[]).find((one) => one.kayan === kayan.name && one.name === name)
373
+ const update = own('update')
374
+ // The row a relationship title points at, read as this actor; one the actor
375
+ // may not read names nothing. Only its id and title field go into the page's
376
+ // data, which is serialized into the document.
377
+ const titleField = kayan.fields.find((one) => one.name === kayan.useAsTitle && one.kind === 'relationship')
378
+ const titledKayan = titleField ? screen.kayan[titleField.relationTo ?? ''] : undefined
379
+ const titledId = value && titledKayan ? idsOf(value[titleField!.name])[0] : undefined
380
+ const titledRow = titledId === undefined ? undefined : ((await operations[titledKayan!.name]?.operations.findByID?.({ id: titledId }, { actor }).catch((thrown: unknown) => {
381
+ if (!caused(thrown)) throw thrown
382
+ return null
383
+ })) as Row | null | undefined)
384
+ const titledField = titledKayan?.useAsTitle
385
+ const titledBy = titledRow && titledField ? ({ id: titledRow.id, [titledField]: titledRow[titledField] } as Row) : undefined
386
+ return {
387
+ name: found.name,
388
+ user: found.user,
389
+ access: found.access,
390
+ screen,
391
+ kayan: kayan.name,
392
+ page: match.page,
393
+ value,
394
+ first,
395
+ related,
396
+ refused,
397
+ creates: createsIn({ kayan, report }),
398
+ operations: report?.operations ?? {},
399
+ writable: report?.fields ?? {},
400
+ drafts,
401
+ ...(titledBy && { titledBy }),
402
+ // Whether the kayan's update takes the `expectedUpdatedAt` step's key, which the form then sends.
403
+ ...(update && takesKey(update.input, 'expectedUpdatedAt') && { expectedUpdatedAt: true }),
404
+ // And which of delete and restore do, for the Discards and the Versions tab's restore.
405
+ expectedUpdatedAtOn: ['delete', 'restoreVersion'].filter((name) => { const one = own(name); return !!one && takesKey(one.input, 'expectedUpdatedAt') }),
406
+ }
407
+ })
408
+
409
+ type ScreenData = Exclude<Awaited<ReturnType<typeof loadScreen>>, 'unauthorized' | null>
410
+ type PageData = Exclude<Awaited<ReturnType<typeof loadPage>>, 'signed out' | 'unauthorized' | null>
411
+
412
+ const pathOf = (href: string) => href.split(/[?#]/)[0]!
413
+
414
+ /**
415
+ * A screen's frame route's loader: resolves the session, access report and screen, and preloads the providers' files.
416
+ * It is not `beforeLoad` because route context must be serializable and the projection's `rules` are `unknown`.
417
+ */
418
+ export const frameLoader = async ({ screen, files, href }: { screen: string; files: Files; href: string }) => {
419
+ const found = await loadScreen({ data: screen })
420
+ if (!found) throw redirect({ to: '/auth/sign-in', search: { redirect: href } })
421
+ if (found === 'unauthorized') throw redirect({ to: '/unauthorized', search: { from: pathOf(href) } })
422
+ await preload(files, found.screen.providers)
423
+ return found
424
+ }
425
+
426
+ // Server: in process as this request's actor, so the first HTML has what a loader reads. Browser: the page's `fetch`.
427
+ // Only the browser's client is held to the served read's cap; in process a `limit` of 0 is allowed.
428
+ const toolsOf = createIsomorphicFn()
429
+ .server(async ({ access, maxLimit }: Pick<LoaderTools, 'access' | 'maxLimit'>): Promise<LoaderTools> => {
430
+ const [{ getRequest }, asas] = await Promise.all([import('@tanstack/react-start/server'), getAsas()])
431
+ const request = getRequest()
432
+ const actor = (await asas.auth.actor(request.headers)) ?? undefined
433
+ const kayan = (await inProcess(asas)) as unknown as Parameters<typeof actingAs>[0]
434
+ return {
435
+ client: actingAs(kayan, actor),
436
+ request: (path, init) => asas.handler(new Request(new URL(path, request.url), { ...init, headers: mergedHeaders(request.headers, init?.headers) })),
437
+ access,
438
+ maxLimit,
439
+ }
440
+ })
441
+ .client(async ({ access, maxLimit }: Pick<LoaderTools, 'access' | 'maxLimit'>): Promise<LoaderTools> => ({ client: clientOf(arm), request: fetch, access, maxLimit }))
442
+
443
+ /**
444
+ * A screen page route's loader: the page `path` names and only the files it draws. A page naming a `permission`
445
+ * is served only to an actor holding it; a page whose file exports a `loader` gets its return as `loaderData`.
446
+ */
447
+ export const pageLoader = async ({
448
+ screen,
449
+ files,
450
+ path,
451
+ query,
452
+ href,
453
+ }: {
454
+ screen: string
455
+ files: Files
456
+ path: string
457
+ query: Record<string, unknown>
458
+ href: string
459
+ }) => {
460
+ const found = await loadPage({ data: { screen, path, query } })
461
+ if (found === 'signed out') throw redirect({ to: '/auth/sign-in', search: { redirect: href } })
462
+ if (found === 'unauthorized') throw redirect({ to: '/unauthorized', search: { from: pathOf(href) } })
463
+ if (!found) throw notFound()
464
+ // A list asked for a page past its last is sent to its last, replacing the
465
+ // entry the URL made, so back does not land on it again.
466
+ const clamped = found.first && clampedHref({ href, page: found.first.rows.page, totalPages: found.first.rows.totalPages })
467
+ if (clamped) throw redirect({ href: clamped, replace: true })
468
+ await preload(files, ...partsOf(found))
469
+ if (found.kayan === undefined) {
470
+ const component = found.screen.pages[found.page]?.component
471
+ const module = component ? ((await files[component.slice(0, component.indexOf('#'))]?.()) as { loader?: unknown } | undefined) : undefined
472
+ if (typeof module?.loader === 'function') {
473
+ return { ...found, loaderData: (await (module.loader as (tools: LoaderTools) => Promise<unknown>)(await toolsOf({ access: found.access, maxLimit: found.screen.maxLimit }))) as unknown }
474
+ }
475
+ }
476
+ return found
477
+ }
478
+
479
+ const nameOfPage = ({ screen, name, match, row, titledBy, copy }: {
480
+ screen: ScreenClient
481
+ name: string
482
+ match: { kayan?: string; page: string }
483
+ row?: Row | null
484
+ titledBy?: Row | null
485
+ copy: { t: (key: string, fallback?: string) => string | undefined; locale: Locale }
486
+ }): string => {
487
+ if (match.kayan === undefined) return screen.nav.find((entry) => !entry.kayan && entry.page === match.page)?.label?.[copy.locale] ?? copy.t(`screens.${name}`, name) ?? name
488
+ const kayan = screen.kayan[match.kayan]!
489
+ const page = kayan.pages[match.page]
490
+ // A list page draws every row, whether a tree's list node or a component drawing it whole.
491
+ const many = match.page === 'list' || (!!page && 'page' in page && listIn(nodesOf(page)) !== undefined)
492
+ if (match.page === 'create') return newTitleOf({ kayan, copy })
493
+ const field = kayan.fields.find((one) => one.name === kayan.useAsTitle)
494
+ const target = field?.kind === 'relationship' ? screen.kayan[field.relationTo ?? ''] : undefined
495
+ const titled = many ? undefined : target ? titledBy && rowTitle({ kayan: target, row: titledBy, locale: copy.locale }) : row && rowTitle({ kayan, row, locale: copy.locale })
496
+ return titled || titleOf({ kayan, copy, many })
497
+ }
498
+
499
+ /** A page's route `head`: the tab names the page, then the app. A route that drew its not-found has no data and says the page was not found. */
500
+ export const pageHead = (data: PageData | undefined, context: { locale: Locale; app: string }) =>
501
+ documentHead(context, (t) =>
502
+ data ? nameOfPage({ screen: data.screen, name: data.name, match: data, row: data.value, titledBy: 'titledBy' in data ? data.titledBy : undefined, copy: { t, locale: context.locale } }) : t('admin.notFoundTitle', 'Page not found'))
503
+
504
+ const partsOf = (found: PageData): unknown[] => {
505
+ if (found.kayan === undefined) return [found.screen.pages[found.page]]
506
+ const kayan = found.screen.kayan[found.kayan]!
507
+ const page = kayan.pages[found.page]!
508
+ return [page, 'page' in page && page.mode ? kayan.pages.list : undefined, Object.values(found.screen.kayan).map((one) => one.fields)]
509
+ }
510
+
511
+ const useScreenState = (data: ScreenData): ScreenState => {
512
+ const { dir, locale, t } = useCopy()
513
+ return { t, locale, dir, screen: data.screen, access: data.access, user: data.user, client: clientOf(arm) }
514
+ }
515
+
516
+ /**
517
+ * The bar that says the session is an impersonation, with the way out. Stopping
518
+ * is a sign-out: the staff member's own session lives in the backoffice app under
519
+ * that app's cookie, and the sign-out answers where they go next.
520
+ */
521
+ export function ImpersonationBar({ name }: { name: string }): ReactElement {
522
+ const { t } = useCopy()
523
+ return (
524
+ <div role="alert" data-impersonating="" className="flex flex-wrap items-center justify-between gap-2 bg-amber-500 px-4 py-2 text-sm font-medium text-black">
525
+ <span>
526
+ {t('admin.impersonating', 'You are impersonating')} <strong>{name}</strong>.
527
+ </span>
528
+ <button
529
+ type="button"
530
+ className="rounded-md border border-black/40 px-3 py-1 font-semibold"
531
+ // `replace`, so Back does not return to the page signed out of.
532
+ onClick={() => void authArm('sign-out').then((answered) => window.location.replace(String(answered.body?.url ?? '/auth/sign-in')))}
533
+ >
534
+ {t('admin.stopImpersonating', 'Stop impersonating')}
535
+ </button>
536
+ </div>
537
+ )
538
+ }
539
+
540
+ function ScreenBar({ user }: { user: ScreenData['user'] }): ReactElement | null {
541
+ const { t } = useCopy()
542
+ const [failed, setFailed] = useState(false)
543
+ useEffect(() => {
544
+ const show = (event: Event) => setFailed((event as CustomEvent<boolean>).detail)
545
+ window.addEventListener('mithaq:after-commit', show)
546
+ return () => window.removeEventListener('mithaq:after-commit', show)
547
+ }, [])
548
+ return (
549
+ <>
550
+ {'impersonating' in user && user.impersonating ? <ImpersonationBar name={user.name} /> : null}
551
+ {failed ? (
552
+ <p role="status" className="rounded-md border border-amber-500/50 px-3 py-2 text-sm">
553
+ {t('admin.afterCommitFailed', 'Saved, but a follow-up action failed, so something this save should have caused, such as an email or a notification, may be missing. Please contact an administrator.')}
554
+ </p>
555
+ ) : null}
556
+ </>
557
+ )
558
+ }
559
+
560
+ /** The screen's own frame: its providers, outermost first, around the frame's bar and the page. */
561
+ export function ScreenFrame({ files, data, children }: { files: Files; data: ScreenData; children: ReactNode }): ReactElement {
562
+ const { t } = useCopy()
563
+ const refs = refsOf(files, data.screen)
564
+ // One per frame: made per request on the server, so no row one actor read
565
+ // reaches another's render; held for the frame's life in the browser.
566
+ const [queries] = useState(() => new QueryClient())
567
+ // A hydrating page has had no loader run in the browser, so every file the
568
+ // frame and page draw is asked for at once, off the answer the page's loader
569
+ // sent, not one round trip per file as React reaches it.
570
+ const page = useMatches({ select: (matches) => matches.at(-1)?.loaderData as PageData | undefined })
571
+ useState(() => typeof window !== 'undefined' && void preload(files, data.screen.providers, ...(page?.screen ? partsOf(page) : [])))
572
+ // A link inside the screen is followed by the router, so a tap reads the
573
+ // next page's data instead of loading a whole document.
574
+ const router = useRouter()
575
+ const base = data.screen.path
576
+ useEffect(() => {
577
+ const follow = (event: MouseEvent) => {
578
+ const href = routedHref({ event, origin: window.location.origin, base })
579
+ if (href === undefined) return
580
+ event.preventDefault()
581
+ void router.navigate({ href })
582
+ }
583
+ document.addEventListener('click', follow)
584
+ return () => document.removeEventListener('click', follow)
585
+ }, [router, base])
586
+ // Tab stays inside an open dialog, alert dialog or sheet, as in a native modal.
587
+ useEffect(() => holdFocus(document.body), [])
588
+ // A component that changed what the page reads, with no kayan operation to
589
+ // say so, asks for a re-read of the lists and the page's loader without a
590
+ // reload, so the scroll stays.
591
+ useEffect(() => {
592
+ const again = () => void Promise.all([queries.invalidateQueries(), router.invalidate()])
593
+ window.addEventListener('mithaq:refresh', again)
594
+ return () => window.removeEventListener('mithaq:refresh', again)
595
+ }, [queries, router])
596
+ // Suspends only while a hydrating page's files load.
597
+ return (
598
+ <QueryClientProvider client={queries}>
599
+ <ScreenContext value={useScreenState(data)}>
600
+ <Suspense fallback={null}>
601
+ {data.screen.providers.reduceRight<ReactElement>(
602
+ (inner, path) => {
603
+ const Provider = refOf({ screen: { refs }, place: 'provider', path })!
604
+ return <Provider>{inner}</Provider>
605
+ },
606
+ <>
607
+ <ScreenBar user={data.user} />
608
+ {children}
609
+ {/* The one place every write's toast is drawn, kept across the screen's pages. */}
610
+ <Toaster closeLabel={t('admin.close', 'Close') ?? 'Close'} />
611
+ </>,
612
+ )}
613
+ </Suspense>
614
+ </ScreenContext>
615
+ </QueryClientProvider>
616
+ )
617
+ }
618
+
619
+ type PageViewProps = {
620
+ files: Files
621
+ data: PageData & { loaderData?: unknown }
622
+ query: RawQuery
623
+ onQuery: (search: Record<string, string>) => void
624
+ }
625
+
626
+ /** One page, inside what the host provides for it: the screen, and on a kayan's page that kayan and the row, which `useKayan()` and `useRecord()` read. */
627
+ export function ScreenPageView(props: PageViewProps): ReactElement {
628
+ const { data } = props
629
+ const state = useScreenState(data)
630
+ // So a shell marks its home link on the home page alone.
631
+ const own = data.screen.pages[data.page]?.path ?? '/'
632
+ return (
633
+ <ScreenContext value={data.kayan === undefined ? { ...state, path: own === '/' ? data.screen.path : `${data.screen.path}${own}` } : { ...state, kayan: data.screen.kayan[data.kayan], record: data.value }}>
634
+ <PageBody {...props} />
635
+ </ScreenContext>
636
+ )
637
+ }
638
+
639
+ /**
640
+ * A screen page route's not-found, drawn under the screen's frame in the shell
641
+ * its pages use, so the nav stays and the way back is the screen's home. The
642
+ * shell is the first page tree's, which is every page's in this template.
643
+ */
644
+ export function ScreenNotFound({ files, screen: name }: { files: Files; screen: string }): ReactElement {
645
+ const { t } = useCopy()
646
+ const screen = useScreen()
647
+ const refs = refsOf(files, screen)
648
+ // A page that opens over its list is drawn inside the list's shell, so it names none to draw here.
649
+ const framed = Object.values(screen.kayan).flatMap((kayan) => Object.values(kayan.pages).flatMap((page) => ('page' in page && !page.mode ? [{ kayan, page }] : [])))[0]
650
+ const back = (t('auth.backTo', 'Back to {screen}') ?? 'Back to {screen}').replace('{screen}', () => t(`screens.${name}`, name) ?? name)
651
+ const regions = {
652
+ header: <h1 className="text-2xl font-bold tracking-tight">{t('admin.notFoundTitle', 'Page not found')}</h1>,
653
+ nav: <nav aria-label={t('admin.navigation', 'Navigation')}><NavLinks /></nav>,
654
+ main: (
655
+ <div className="flex flex-col items-start gap-4">
656
+ <p className="text-muted-foreground text-sm">{t('admin.notFound', 'That page does not exist.')}</p>
657
+ <a className="text-foreground text-sm underline underline-offset-4" href={screen.path}>{back}</a>
658
+ </div>
659
+ ),
660
+ }
661
+ if (!framed) return <main className="p-6">{regions.header}{regions.main}</main>
662
+ return <>{frameOf({ screen: { refs, ...framed }, t, path: framed.page.page.shell, regions })}</>
663
+ }
664
+
665
+ function PageBody({ files, data, query, onQuery }: PageViewProps): ReactElement | null {
666
+ const refs = refsOf(files, data.screen)
667
+ const router = useRouter()
668
+ const queries = useQueryClient()
669
+ const { locale, t } = useCopy()
670
+ if (data.kayan === undefined) {
671
+ const Page = refOf({ screen: { refs }, place: 'page', path: data.screen.pages[data.page]?.component })
672
+ return Page ? <Page loaderData={data.loaderData} /> : null
673
+ }
674
+ const kayan = data.screen.kayan[data.kayan]!
675
+ const declared = kayan.pages[data.page]!
676
+ if ('component' in declared) {
677
+ const Whole = refOf({ screen: { refs }, place: 'component', path: declared.component })
678
+ return Whole ? <Whole page={declared} /> : null
679
+ }
680
+ const page = declared
681
+ const base = data.screen.path
682
+ const hrefOf = (target: ClientKayan, pageName: string | undefined, id?: string) => {
683
+ const path = pageName === undefined ? undefined : target.pages[pageName]?.path
684
+ return path ? `${base}${id ? path.replace(':id', encodeURIComponent(id)) : path}` : undefined
685
+ }
686
+ // A row opens its kayan's edit page, else the one page whose path draws a
687
+ // row (how the backoffice opens a record it only reads).
688
+ const rowPage = (target: ClientKayan) => (target.pages.edit ? 'edit' : Object.keys(target.pages).find((one) => target.pages[one]!.path.includes(':id')))
689
+ const related = relatedOf(data.screen, kayan)
690
+ const go = (href: string | undefined) => void (href ? router.navigate({ href }) : router.invalidate())
691
+ const written = <Written,>(write: Promise<Written>, name = kayan.name) =>
692
+ write.then((out) => {
693
+ void queries.invalidateQueries({ queryKey: rowsKey(name) })
694
+ return out
695
+ })
696
+
697
+ // A list of another kayan on this page: the rows whose `where` holds the row
698
+ // the page draws. The screen crosses only the kayan the actor may read, so one
699
+ // it may not is refused here, as is one whose read the server refused.
700
+ const relatedList = (target: TreePage, node: Parameters<NonNullable<Draw['list']>>[0]['node'], index: number, own?: ReactElement) => {
701
+ const listed = data.screen.kayan[node.of!]
702
+ // Both look the same to the viewer: rows it cannot see.
703
+ if (!listed || data.refused.includes(prefixOf(node))) {
704
+ return (
705
+ <p key={index} role="alert" data-mithaq-refused-list={node.of} className="rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive">
706
+ {t('admin.cannotSee', 'You can\'t see this')}
707
+ </p>
708
+ )
709
+ }
710
+ const id = data.value?.id
711
+ if (id === undefined) return null
712
+ const prefix = prefixOf(node)
713
+ const report = data.access.collections?.[listed.name]
714
+ const targets = relatedOf(data.screen, listed)
715
+ return (
716
+ <ScreenList
717
+ key={index}
718
+ props={{
719
+ kayan: listed,
720
+ node,
721
+ client: targets,
722
+ api: readsOf([listed, ...targets]),
723
+ creates: createsIn({ kayan: listed, report }),
724
+ operations: report?.operations ?? {},
725
+ writes: (field) => report?.fields?.[field]?.update === true,
726
+ access: data.access,
727
+ t,
728
+ locale,
729
+ locales,
730
+ href: (row) => hrefOf(listed, rowPage(listed), row) ?? '#',
731
+ newHref: '#',
732
+ screen: { refs, kayan: listed, page: target },
733
+ edit: {
734
+ client: {
735
+ create: (input) => written(arm[listed.name]!.create!(input), listed.name),
736
+ update: (input) => written(arm[listed.name]!.update!(input), listed.name),
737
+ delete: (input) => written(arm[listed.name]!.delete!(input), listed.name),
738
+ },
739
+ writes: (field, on) => report?.fields?.[field]?.[on] === true,
740
+ prefill: { [node.where!]: id },
741
+ },
742
+ }}
743
+ raw={listRaw(query, prefix)}
744
+ onQuery={(search) => onQuery(withList({ raw: query, prefix, written: search }))}
745
+ first={data.related[prefix]}
746
+ scope={scopeTo(node, id)}
747
+ find={(input, options) => arm[listed.name]!.find!(input, options) as Promise<Page>}
748
+ run={(operation, input) => arm[listed.name]![operation]!(input)}
749
+ own={own}
750
+ />
751
+ )
752
+ }
753
+
754
+ const draw = (target: TreePage): Draw => {
755
+ return {
756
+ kayan,
757
+ copy: { t, locale },
758
+ issues: {},
759
+ // A page drawn here holds no form, so a `when` reads the row and the report alone.
760
+ scope: scopeOf({ values: {}, record: data.value, access: data.access, kayan }),
761
+ // A leaf is the row's value drawn by its cell, read and never edited.
762
+ control: (leaf) => {
763
+ const field = kayan.fields.find((one) => one.name === (typeof leaf === 'string' ? leaf : leaf.field))
764
+ return field ? (
765
+ <FieldValue
766
+ key={field.name}
767
+ kayan={kayan}
768
+ field={field}
769
+ leaf={typeof leaf === 'string' ? undefined : leaf}
770
+ row={data.value}
771
+ copy={{ t, locale }}
772
+ api={readsOf(related)}
773
+ kayans={related}
774
+ screen={{ refs, kayan, page: target }}
775
+ />
776
+ ) : null
777
+ },
778
+ component: (path, index) => {
779
+ const Component = refOf({ screen: { refs }, place: 'component', path })
780
+ return Component ? <Component key={index} page={target} /> : null
781
+ },
782
+ layout: ({ path, regions, index }) => frameOf({ screen: { refs, kayan, page: target }, t, path, regions, key: index }),
783
+ // A list node naming a component is drawn by it, which reads through
784
+ // `useList()` what the registry's list is handed.
785
+ list: ({ node, index, component }) => {
786
+ const Own = component ? refOf({ screen: { refs }, place: 'component', path: component }) : undefined
787
+ if (node.of) return relatedList(target, node, index, Own ? <Own page={target} /> : undefined)
788
+ return (
789
+ <ScreenList
790
+ key={index}
791
+ props={{
792
+ kayan,
793
+ client: related,
794
+ api: readsOf([kayan, ...related]),
795
+ creates: data.creates && !!kayan.pages.create,
796
+ operations: data.operations,
797
+ writes: (field) => data.writable[field]?.update === true,
798
+ access: data.access,
799
+ t,
800
+ locale,
801
+ locales,
802
+ href: (id) => hrefOf(kayan, rowPage(kayan), id) ?? '#',
803
+ newHref: hrefOf(kayan, 'create') ?? '#',
804
+ screen: { refs, kayan, page: target },
805
+ }}
806
+ raw={query}
807
+ onQuery={onQuery}
808
+ first={data.first}
809
+ find={(input, options) => arm[kayan.name]!.find!(input, options) as Promise<Page>}
810
+ run={(operation, input) => arm[kayan.name]![operation]!(input)}
811
+ own={Own ? <Own page={target} /> : undefined}
812
+ />
813
+ )
814
+ },
815
+ nav: () => <NavLinks />,
816
+ button: (node, index) => {
817
+ const button = pageButtonOf({ kayan, node, allowed: data.operations })
818
+ if (!button) return null
819
+ const call = async (input: Row) => {
820
+ await written(arm[kayan.name]![button.name]!({ ...input, ...(data.value?.id !== undefined && { id: data.value.id }) }))
821
+ await router.invalidate()
822
+ }
823
+ // A project's button component is handed a run that asks the confirm
824
+ // first; the registry's own button asks it before calling.
825
+ const Own = node.component ? refOf({ screen: { refs }, place: 'button', path: node.component }) : undefined
826
+ return Own ? (
827
+ <OwnButton key={index} Own={Own} operation={button} busy={false} copy={{ t, locale }} call={call} />
828
+ ) : (
829
+ <NodeButton key={index} node={node} copy={{ t, locale }} busy={false} run={() => void call({})} />
830
+ )
831
+ },
832
+ }
833
+ }
834
+ const drawn = (target: TreePage) => pageOf({ refs, kayan, page: target }, draw(target))
835
+ if (readsAsValues({ page, value: data.value, operations: data.operations })) return <>{drawn(page)}</>
836
+
837
+ const listHref = hrefOf(kayan, 'list')
838
+ const client: FormClient = kayan.single
839
+ ? {
840
+ create: ({ id: _id, ...input }) => arm[kayan.name]!.update!(input),
841
+ update: ({ id: _id, ...input }) => arm[kayan.name]!.update!(input),
842
+ delete: () => Promise.reject(new Error(`"${kayan.name}" is one row and declares no delete.`)),
843
+ }
844
+ : {
845
+ create: (input, options) => written(arm[kayan.name]!.create!(input, options)),
846
+ update: (input, options) => written(arm[kayan.name]!.update!(input, options)),
847
+ delete: (input) => written(arm[kayan.name]!.delete!(input)),
848
+ run: (operation, input) => written(arm[kayan.name]![operation]!(input)),
849
+ findByID: (input) => arm[kayan.name]!.findByID!(input),
850
+ }
851
+ const form = (
852
+ <ScreenForm
853
+ kayan={kayan}
854
+ screen={{ refs, kayan, page }}
855
+ client={client}
856
+ value={data.value}
857
+ t={t}
858
+ locale={locale}
859
+ locales={locales}
860
+ writes={(field, on) => data.writable[field]?.[on] === true}
861
+ kayans={related}
862
+ api={readsOf(related)}
863
+ operations={data.operations}
864
+ related={draw(page).list}
865
+ // A create opened over its list closes back to it once it lands, the new row in the list, as Payload's drawer does;
866
+ // one whose kayan autosaves its drafts moves to the row's page instead, since its first autosave is a create too.
867
+ edit={(id) => (page.mode && listHref && !data.drafts?.autosave ? listHref : hrefOf(kayan, 'edit', id))}
868
+ list={listHref}
869
+ drafts={data.drafts}
870
+ expectedUpdatedAt={data.expectedUpdatedAt}
871
+ expectedUpdatedAtOn={data.expectedUpdatedAtOn}
872
+ />
873
+ )
874
+ const list = kayan.pages.list
875
+ if (page.mode && list && 'page' in list) {
876
+ return (
877
+ <>
878
+ {drawn(list)}
879
+ {/* Named for a screen reader by the page's title, a create as new. */}
880
+ <RecordFrame mode={page.mode} title={(data.value ? titleOf : newTitleOf)({ kayan, copy: { t, locale } })} closeLabel={t('admin.close', 'Close') ?? 'Close'} onClose={() => go(listHref)}>
881
+ {form}
882
+ </RecordFrame>
883
+ </>
884
+ )
885
+ }
886
+ return form
887
+ }
888
+ // mithaq-admin 0.0.0 da2e742262e16a7f9f8dab050c2a4201517478795101af68736907464a0d52ce