@antelopejs/dms 0.8.3 → 0.9.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 (351) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/implementations/dms/frontend-backend-proxies.d.ts +3 -0
  3. package/dist/implementations/dms/frontend-backend-proxies.js +39 -0
  4. package/dist/implementations/dms/frontend-backend-proxies.js.map +1 -0
  5. package/dist/implementations/dms/frontend-bootstrap.d.ts +1 -0
  6. package/dist/implementations/dms/frontend-bootstrap.js +13 -0
  7. package/dist/implementations/dms/frontend-bootstrap.js.map +1 -1
  8. package/dist/implementations/dms/frontend-modules.d.ts +4 -2
  9. package/dist/implementations/dms/frontend-modules.js +8 -7
  10. package/dist/implementations/dms/frontend-modules.js.map +1 -1
  11. package/dist/implementations/dms/manifest.d.ts +6 -0
  12. package/dist/implementations/dms/manifest.js +13 -0
  13. package/dist/implementations/dms/manifest.js.map +1 -1
  14. package/dist/implementations/dms/menu-categories.d.ts +16 -0
  15. package/dist/implementations/dms/menu-categories.js +30 -0
  16. package/dist/implementations/dms/menu-categories.js.map +1 -0
  17. package/dist/implementations/dms/nav-badges.d.ts +0 -8
  18. package/dist/implementations/dms/nav-badges.js +0 -16
  19. package/dist/implementations/dms/nav-badges.js.map +1 -1
  20. package/dist/implementations/dms/page.d.ts +11 -5
  21. package/dist/implementations/dms/page.js +56 -41
  22. package/dist/implementations/dms/page.js.map +1 -1
  23. package/dist/implementations/dms-html-render/index.js +5 -2
  24. package/dist/implementations/dms-html-render/index.js.map +1 -1
  25. package/dist/implementations/dms-notifications/index.d.ts +4 -3
  26. package/dist/implementations/dms-notifications/index.js +14 -6
  27. package/dist/implementations/dms-notifications/index.js.map +1 -1
  28. package/dist/pages/settings/settings-index.js +3 -2
  29. package/dist/pages/settings/settings-index.js.map +1 -1
  30. package/dist/pages/settings/users/invites.js +2 -2
  31. package/dist/pages/settings/users/invites.js.map +1 -1
  32. package/dist/pages/settings/users/member-change-notifications.js +17 -6
  33. package/dist/pages/settings/users/member-change-notifications.js.map +1 -1
  34. package/dist/pages/settings/users/member-invite-form.d.ts +3 -2
  35. package/dist/pages/settings/users/member-invite-form.js +7 -4
  36. package/dist/pages/settings/users/member-invite-form.js.map +1 -1
  37. package/dist/pages/settings/users/member-roles-field.js +2 -1
  38. package/dist/pages/settings/users/member-roles-field.js.map +1 -1
  39. package/dist/pages/settings/users/members.js +4 -1
  40. package/dist/pages/settings/users/members.js.map +1 -1
  41. package/dist/pages/settings/users/notifications.d.ts +6 -0
  42. package/dist/pages/settings/users/notifications.js +23 -15
  43. package/dist/pages/settings/users/notifications.js.map +1 -1
  44. package/dist/pages/settings/users/profile.js +1 -1
  45. package/dist/pages/settings/users/profile.js.map +1 -1
  46. package/dist/pages/settings/users/region-form.d.ts +1 -1
  47. package/dist/pages/settings/users/region-form.js +2 -1
  48. package/dist/pages/settings/users/region-form.js.map +1 -1
  49. package/dist/pages/settings/users/role-editor-store.d.ts +3 -1
  50. package/dist/pages/settings/users/role-editor-store.js +17 -4
  51. package/dist/pages/settings/users/role-editor-store.js.map +1 -1
  52. package/dist/pages/settings/users/roles.js +1 -0
  53. package/dist/pages/settings/users/roles.js.map +1 -1
  54. package/dist/realtime/registry.d.ts +1 -0
  55. package/dist/realtime/registry.js +5 -0
  56. package/dist/realtime/registry.js.map +1 -1
  57. package/dist/routes/realtime.d.ts +5 -1
  58. package/dist/routes/realtime.js +16 -9
  59. package/dist/routes/realtime.js.map +1 -1
  60. package/dist/test/integration/account-deletion.test.js +2 -2
  61. package/dist/test/integration/account-deletion.test.js.map +1 -1
  62. package/dist/test/integration/auth/auth-key-rotation.test.js +2 -1
  63. package/dist/test/integration/auth/auth-key-rotation.test.js.map +1 -1
  64. package/dist/test/integration/auth/current-password.test.js +2 -1
  65. package/dist/test/integration/auth/current-password.test.js.map +1 -1
  66. package/dist/test/integration/auth/session-revocation.test.js +2 -1
  67. package/dist/test/integration/auth/session-revocation.test.js.map +1 -1
  68. package/dist/test/integration/auth/sign-in-country.test.js +2 -1
  69. package/dist/test/integration/auth/sign-in-country.test.js.map +1 -1
  70. package/dist/test/integration/auth/two-factor-codes.test.js +2 -1
  71. package/dist/test/integration/auth/two-factor-codes.test.js.map +1 -1
  72. package/dist/test/integration/auth/two-factor-password.test.js +3 -2
  73. package/dist/test/integration/auth/two-factor-password.test.js.map +1 -1
  74. package/dist/test/integration/email-change.test.js +2 -1
  75. package/dist/test/integration/email-change.test.js.map +1 -1
  76. package/dist/test/integration/files/component-files.test.js +2 -2
  77. package/dist/test/integration/files/component-files.test.js.map +1 -1
  78. package/dist/test/integration/member-settings-access.test.js +8 -7
  79. package/dist/test/integration/member-settings-access.test.js.map +1 -1
  80. package/dist/test/integration/menu-access-events.test.d.ts +1 -0
  81. package/dist/test/integration/menu-access-events.test.js +148 -0
  82. package/dist/test/integration/menu-access-events.test.js.map +1 -0
  83. package/dist/test/integration/notification-inbox.test.js +4 -1
  84. package/dist/test/integration/notification-inbox.test.js.map +1 -1
  85. package/dist/test/integration/notification-locked-subjects.test.js +3 -2
  86. package/dist/test/integration/notification-locked-subjects.test.js.map +1 -1
  87. package/dist/test/integration/notification-unread-badge.test.js +51 -5
  88. package/dist/test/integration/notification-unread-badge.test.js.map +1 -1
  89. package/dist/test/integration/page-layout-route-guard.test.d.ts +1 -0
  90. package/dist/test/integration/page-layout-route-guard.test.js +56 -0
  91. package/dist/test/integration/page-layout-route-guard.test.js.map +1 -0
  92. package/dist/test/integration/permission-warnings.test.js +3 -2
  93. package/dist/test/integration/permission-warnings.test.js.map +1 -1
  94. package/dist/test/integration/removed-member-notifications.test.js +3 -2
  95. package/dist/test/integration/removed-member-notifications.test.js.map +1 -1
  96. package/dist/test/integration/role-delete-reassign.test.js +2 -1
  97. package/dist/test/integration/role-delete-reassign.test.js.map +1 -1
  98. package/dist/test/integration/role-permission-ancestors.test.js +2 -1
  99. package/dist/test/integration/role-permission-ancestors.test.js.map +1 -1
  100. package/dist/test/unit/frontend-bootstrap.test.js +13 -0
  101. package/dist/test/unit/frontend-bootstrap.test.js.map +1 -1
  102. package/dist/test/unit/implementations/dms/category-slug-registry.test.js +14 -5
  103. package/dist/test/unit/implementations/dms/category-slug-registry.test.js.map +1 -1
  104. package/dist/test/unit/implementations/dms/frontend-backend-proxies.test.d.ts +1 -0
  105. package/dist/test/unit/implementations/dms/frontend-backend-proxies.test.js +66 -0
  106. package/dist/test/unit/implementations/dms/frontend-backend-proxies.test.js.map +1 -0
  107. package/dist/test/unit/implementations/dms/manifest-strip.test.js +26 -0
  108. package/dist/test/unit/implementations/dms/manifest-strip.test.js.map +1 -1
  109. package/dist/test/unit/implementations/dms/nav-badges.test.js +23 -9
  110. package/dist/test/unit/implementations/dms/nav-badges.test.js.map +1 -1
  111. package/dist/test/unit/implementations/dms-html-render/generate-html.test.js +22 -4
  112. package/dist/test/unit/implementations/dms-html-render/generate-html.test.js.map +1 -1
  113. package/dist/test/unit/interfaces/dms/page-layout-query.test.js +12 -0
  114. package/dist/test/unit/interfaces/dms/page-layout-query.test.js.map +1 -1
  115. package/dist/test/unit/interfaces/dms/page-teardown.test.js +1 -1
  116. package/dist/test/unit/interfaces/dms/page-teardown.test.js.map +1 -1
  117. package/dist/test/unit/pages/settings/member-roles-field.test.js +1 -1
  118. package/dist/test/unit/pages/settings/member-roles-field.test.js.map +1 -1
  119. package/dist/test/unit/pages/settings/role-delete-confirm.test.js.map +1 -1
  120. package/dist/test/unit/realtime/menu-topics.test.d.ts +1 -0
  121. package/dist/test/unit/realtime/menu-topics.test.js +26 -0
  122. package/dist/test/unit/realtime/menu-topics.test.js.map +1 -0
  123. package/frontend-vue/deferred-plugins.ts +3 -4
  124. package/frontend-vue/dms.email.ts +5 -1
  125. package/frontend-vue/dms.frontend.ts +15 -5
  126. package/frontend-vue/dms.server.ts +74 -0
  127. package/frontend-vue/layers/dms-auth/app/composables/useLogout.ts +1 -1
  128. package/frontend-vue/layers/dms-auth/app/composables/usePostLoginRedirect.ts +16 -3
  129. package/frontend-vue/layers/dms-auth/app/custom-pages/auth/2fa.vue +3 -1
  130. package/frontend-vue/layers/dms-auth/app/custom-pages/auth/backup.vue +3 -1
  131. package/frontend-vue/layers/dms-auth/app/custom-pages/auth/forgot-validation.vue +1 -1
  132. package/frontend-vue/layers/dms-auth/app/custom-pages/auth/oauth-complete.vue +26 -3
  133. package/frontend-vue/layers/dms-auth/app/custom-pages/auth/recover.vue +1 -1
  134. package/frontend-vue/layers/dms-auth/app/custom-pages/auth/signup.vue +22 -25
  135. package/frontend-vue/layers/dms-auth/app/custom-pages/auth/validate.vue +1 -1
  136. package/frontend-vue/layers/dms-auth/app/middleware/auth.ts +5 -7
  137. package/frontend-vue/layers/dms-auth/app/plugins/permissions-preload.ts +17 -0
  138. package/frontend-vue/layers/dms-auth/server/accounts.ts +135 -0
  139. package/frontend-vue/layers/dms-auth/server/actions.ts +251 -0
  140. package/frontend-vue/layers/dms-auth/server/oauth.ts +260 -0
  141. package/frontend-vue/layers/dms-auth/server/session.ts +158 -0
  142. package/frontend-vue/layers/dms-core/app/build/utils/asyncDataScope.ts +47 -0
  143. package/frontend-vue/layers/dms-core/app/build/utils/permission-preview.ts +6 -2
  144. package/frontend-vue/layers/dms-core/app/composables/auth/useApiData.ts +148 -0
  145. package/frontend-vue/layers/dms-core/app/composables/auth/useAuthFetch.ts +7 -20
  146. package/frontend-vue/layers/dms-core/app/composables/auth/useCurrentUser.ts +2 -2
  147. package/frontend-vue/layers/dms-core/app/composables/auth/useMultiAccount.ts +18 -5
  148. package/frontend-vue/layers/dms-core/app/composables/auth/usePermissions.ts +35 -6
  149. package/frontend-vue/layers/dms-core/app/composables/auth/useSessionRecovery.ts +1 -1
  150. package/frontend-vue/layers/dms-core/app/composables/period/compareResolvers.ts +20 -14
  151. package/frontend-vue/layers/dms-core/app/composables/period/periodClock.ts +121 -0
  152. package/frontend-vue/layers/dms-core/app/composables/period/periodRenderNow.ts +42 -0
  153. package/frontend-vue/layers/dms-core/app/composables/period/periodTimeZone.ts +31 -0
  154. package/frontend-vue/layers/dms-core/app/composables/period/presetResolvers.ts +48 -19
  155. package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +33 -4
  156. package/frontend-vue/layers/dms-core/app/composables/period/usePeriodScope.ts +78 -25
  157. package/frontend-vue/layers/dms-core/app/composables/realtime/usePageRealtime.ts +3 -0
  158. package/frontend-vue/layers/dms-core/app/composables/realtime/useUserRealtime.ts +36 -6
  159. package/frontend-vue/layers/dms-core/app/composables/user/useUserRegionalPreferences.ts +1 -1
  160. package/frontend-vue/layers/dms-core/app/plugins/async-data-scope.ts +26 -0
  161. package/frontend-vue/layers/dms-core/app/utils/pageRoutes.ts +22 -0
  162. package/frontend-vue/layers/dms-core/app/utils/regional.ts +36 -3
  163. package/frontend-vue/layers/dms-core/app/utils/trailingDeduper.ts +10 -1
  164. package/frontend-vue/layers/dms-core/shared/types/app-config.ts +48 -4
  165. package/frontend-vue/layers/dms-layout/app/app.config.ts +42 -23
  166. package/frontend-vue/layers/dms-layout/app/assets/css/main.css +72 -20
  167. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardFrame.vue +9 -0
  168. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardSidebar.vue +0 -3
  169. package/frontend-vue/layers/dms-layout/app/build/components/notification/NotificationPopover.vue +9 -3
  170. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/members/MemberRolePicker.vue +13 -12
  171. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/notification/NotificationInboxDisplay.vue +17 -7
  172. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/notification/NotificationPreferencesForm.vue +0 -1
  173. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/profile/ProfilePersonalInfo.vue +36 -33
  174. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/profile/ProfileSecuritySummary.vue +1 -2
  175. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/roles/role-types.ts +5 -4
  176. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecuritySessions.vue +35 -28
  177. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityStatus.vue +0 -1
  178. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityTwoFactor.vue +1 -1
  179. package/frontend-vue/layers/dms-layout/app/build/composables/dev-reload/useDevReloadHolder.ts +7 -6
  180. package/frontend-vue/layers/dms-layout/app/build/composables/layout/sidePanelState.ts +1 -1
  181. package/frontend-vue/layers/dms-layout/app/build/composables/navigation/useVisitNavBadges.ts +26 -0
  182. package/frontend-vue/layers/dms-layout/app/build/composables/notification/useNotificationCatalog.ts +25 -23
  183. package/frontend-vue/layers/dms-layout/app/build/composables/notification/useNotificationPreferences.ts +44 -23
  184. package/frontend-vue/layers/dms-layout/app/build/composables/security/useSessionHandoff.ts +1 -1
  185. package/frontend-vue/layers/dms-layout/app/build/composables/settings/profile/preferencesSummary.ts +1 -1
  186. package/frontend-vue/layers/dms-layout/app/build/composables/settings/profile/useAccountData.ts +3 -2
  187. package/frontend-vue/layers/dms-layout/app/build/composables/settings/profile/useProfileAvatar.ts +1 -18
  188. package/frontend-vue/layers/dms-layout/app/build/composables/settings/profile/useProfilePreferences.ts +19 -23
  189. package/frontend-vue/layers/dms-layout/app/build/composables/settings/security/useSecurityFormat.ts +6 -3
  190. package/frontend-vue/layers/dms-layout/app/build/composables/settings/security/useSecurityOverview.ts +13 -18
  191. package/frontend-vue/layers/dms-layout/app/build/utils/modules-catalog.ts +8 -6
  192. package/frontend-vue/layers/dms-layout/app/build/utils/publicErrorMessage.ts +19 -0
  193. package/frontend-vue/layers/dms-layout/app/components/navigation/CollapsedMenuItem.vue +0 -11
  194. package/frontend-vue/layers/dms-layout/app/components/navigation/NavigationMenu.vue +3 -13
  195. package/frontend-vue/layers/dms-layout/app/composables/general/useSystemState.ts +7 -11
  196. package/frontend-vue/layers/dms-layout/app/composables/notification/useNotifications.ts +103 -22
  197. package/frontend-vue/layers/dms-layout/app/composables/page/useModulesListing.ts +25 -102
  198. package/frontend-vue/layers/dms-layout/app/composables/page/usePageComponentPreload.ts +0 -10
  199. package/frontend-vue/layers/dms-layout/app/composables/page/useSiteLayout.ts +177 -96
  200. package/frontend-vue/layers/dms-layout/app/composables/profile/useUserLanguage.ts +3 -2
  201. package/frontend-vue/layers/dms-layout/app/custom-pages/modules.vue +6 -28
  202. package/frontend-vue/layers/dms-layout/app/emails/components/EmailLayout.vue +31 -11
  203. package/frontend-vue/layers/dms-layout/app/error.vue +5 -2
  204. package/frontend-vue/layers/dms-layout/app/middleware/module-routing.global.ts +1 -1
  205. package/frontend-vue/layers/dms-layout/app/middleware/page-leave-guard.global.ts +2 -2
  206. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +12 -9
  207. package/frontend-vue/layers/dms-layout/app/plugins/dms-menu-sync.client.ts +31 -3
  208. package/frontend-vue/layers/dms-layout/app/plugins/favicon.ts +58 -0
  209. package/frontend-vue/layers/dms-layout/app/plugins/notification-stream.client.ts +15 -7
  210. package/frontend-vue/layers/dms-layout/app/plugins/profile-sync.client.ts +5 -1
  211. package/frontend-vue/layers/dms-layout/app/plugins/regional-preferences.ts +10 -4
  212. package/frontend-vue/layers/dms-layout/app/plugins/time-zone-cookie.client.ts +12 -0
  213. package/frontend-vue/layers/dms-layout/app/types/page.ts +13 -0
  214. package/frontend-vue/layers/dms-layout/server/html-render.ts +58 -0
  215. package/frontend-vue/layers/dms-layout/server/render-token.ts +95 -0
  216. package/frontend-vue/layers/dms-onboarding/app/composables/onboarding/useOnboarding.ts +25 -21
  217. package/frontend-vue/layers/dms-onboarding/app/middleware/onboarding.global.ts +1 -1
  218. package/frontend-vue/layers/dms-onboarding/app/plugins/onboarding-preload.ts +9 -0
  219. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +4 -1
  220. package/frontend-vue/layers/dms-ui/app/build/composables/files/useFileMetadata.ts +162 -0
  221. package/frontend-vue/layers/dms-ui/app/build/composables/form/formRecordLoad.ts +23 -4
  222. package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageDataRefresh.ts +101 -0
  223. package/frontend-vue/layers/dms-ui/app/build/composables/navigation/useNavBadges.ts +33 -6
  224. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodDisplay.ts +19 -16
  225. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +40 -20
  226. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableFooter.ts +6 -8
  227. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +42 -14
  228. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +19 -8
  229. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViews.ts +1 -1
  230. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +5 -2
  231. package/frontend-vue/layers/dms-ui/app/components/flow-canvas/FlowCanvas.vue +9 -9
  232. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +20 -18
  233. package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +2 -1
  234. package/frontend-vue/layers/dms-ui/app/components/form/components/File.vue +26 -132
  235. package/frontend-vue/layers/dms-ui/app/components/form/components/Image.vue +40 -105
  236. package/frontend-vue/layers/dms-ui/app/components/form/components/InputAddress.vue +2 -0
  237. package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +4 -4
  238. package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +9 -13
  239. package/frontend-vue/layers/dms-ui/app/components/table-view/CascaderPath.vue +2 -1
  240. package/frontend-vue/layers/dms-ui/app/components/table-view/FilePreview.vue +12 -24
  241. package/frontend-vue/layers/dms-ui/app/components/table-view/ImagePreview.vue +17 -29
  242. package/frontend-vue/layers/dms-ui/app/components/table-view/KanbanBoard.vue +7 -3
  243. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +20 -8
  244. package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +8 -11
  245. package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +146 -182
  246. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLiveRefresh.ts +8 -2
  247. package/frontend-vue/layers/dms-ui/app/composables/useFileReadUrls.ts +11 -99
  248. package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +32 -3
  249. package/frontend-vue/package.json +2 -2
  250. package/frontend-vue/pnpm-lock.yaml +5 -5
  251. package/frontend-vue/server/private-options.ts +27 -0
  252. package/frontend-vue/tests/async-data-scope.test.ts +121 -0
  253. package/frontend-vue/tests/auth-middleware-error.test.ts +3 -3
  254. package/frontend-vue/tests/banner-block-fetch.test.ts +3 -0
  255. package/frontend-vue/tests/block-fetch-options-forwarding.test.ts +3 -0
  256. package/frontend-vue/tests/block-items-period-realtime.test.ts +3 -0
  257. package/frontend-vue/tests/block-not-found.test.ts +16 -2
  258. package/frontend-vue/tests/block-page-refresh.test.ts +3 -0
  259. package/frontend-vue/tests/block-route-params.test.ts +3 -0
  260. package/frontend-vue/tests/branding-defaults.test.ts +34 -0
  261. package/frontend-vue/tests/builtin-emails.test.ts +23 -2
  262. package/frontend-vue/tests/chart-live-pause.test.ts +4 -1
  263. package/frontend-vue/tests/chart-value-propagation.test.ts +4 -1
  264. package/frontend-vue/tests/composed-text-blocks.test.ts +3 -0
  265. package/frontend-vue/tests/email-layout.test.ts +66 -4
  266. package/frontend-vue/tests/error-page-message.test.ts +25 -2
  267. package/frontend-vue/tests/favicon.test.ts +131 -0
  268. package/frontend-vue/tests/file-fields-ssr.test.ts +145 -0
  269. package/frontend-vue/tests/file-read-urls.test.ts +145 -34
  270. package/frontend-vue/tests/file-unavailable.test.ts +5 -1
  271. package/frontend-vue/tests/fixtures/dataEngine.ts +44 -0
  272. package/frontend-vue/tests/form-load-error.test.ts +55 -1
  273. package/frontend-vue/tests/helpers/api-data-runtime.ts +77 -0
  274. package/frontend-vue/tests/image-hydration-ids.test.ts +21 -16
  275. package/frontend-vue/tests/image-reference-preservation.test.ts +59 -53
  276. package/frontend-vue/tests/input-number-adjust.test.ts +14 -0
  277. package/frontend-vue/tests/key-value-list-copy.test.ts +3 -0
  278. package/frontend-vue/tests/member-role-picker.test.ts +4 -2
  279. package/frontend-vue/tests/menu-sync-reconnect.test.ts +47 -0
  280. package/frontend-vue/tests/metadata-retry.test.ts +5 -1
  281. package/frontend-vue/tests/module-routing.test.ts +2 -5
  282. package/frontend-vue/tests/modules-catalog.test.ts +17 -4
  283. package/frontend-vue/tests/modules-listing-transfer.test.ts +95 -0
  284. package/frontend-vue/tests/multi-account-validate.test.ts +23 -1
  285. package/frontend-vue/tests/nav-badge-tones.test.ts +66 -3
  286. package/frontend-vue/tests/notification-counts-from-writes.test.ts +223 -0
  287. package/frontend-vue/tests/notification-counts-transfer.test.ts +88 -0
  288. package/frontend-vue/tests/notification-inbox-display.test.ts +57 -5
  289. package/frontend-vue/tests/notification-popover-errors.test.ts +21 -1
  290. package/frontend-vue/tests/oauth-complete-handoff.test.ts +209 -0
  291. package/frontend-vue/tests/onboarding-middleware.test.ts +1 -1
  292. package/frontend-vue/tests/onboarding-transfer.test.ts +106 -0
  293. package/frontend-vue/tests/page-data-refresh.test.ts +141 -0
  294. package/frontend-vue/tests/page-fill-height.test.ts +16 -3
  295. package/frontend-vue/tests/page-no-visible-blocks.test.ts +12 -2
  296. package/frontend-vue/tests/page-realtime-remount.test.ts +1 -1
  297. package/frontend-vue/tests/page-record-label.test.ts +0 -7
  298. package/frontend-vue/tests/page-validation-redirect.test.ts +12 -2
  299. package/frontend-vue/tests/period-hydration-now.test.ts +185 -0
  300. package/frontend-vue/tests/period-live-pause.test.ts +3 -0
  301. package/frontend-vue/tests/period-reader-zone.test.ts +137 -0
  302. package/frontend-vue/tests/period-scope-first-fetch.test.ts +17 -1
  303. package/frontend-vue/tests/period-ssr.test.ts +195 -0
  304. package/frontend-vue/tests/permission-preview-partial.test.ts +0 -2
  305. package/frontend-vue/tests/permissions-transfer.test.ts +194 -0
  306. package/frontend-vue/tests/post-login-redirect.test.ts +10 -5
  307. package/frontend-vue/tests/profile-personal-info-ssr.test.ts +150 -0
  308. package/frontend-vue/tests/profile-preferences-summary.test.ts +15 -11
  309. package/frontend-vue/tests/realtime-reconnect.test.ts +22 -3
  310. package/frontend-vue/tests/roles-name-errors.test.ts +1 -1
  311. package/frontend-vue/tests/row-side-panel.test.ts +1 -1
  312. package/frontend-vue/tests/security-sessions-load-error.test.ts +3 -1
  313. package/frontend-vue/tests/server-routes-vs-pages.test.ts +26 -0
  314. package/frontend-vue/tests/server-routes.test.ts +582 -0
  315. package/frontend-vue/tests/session-handoff.test.ts +1 -1
  316. package/frontend-vue/tests/settings-reads-ssr.test.ts +232 -0
  317. package/frontend-vue/tests/side-panels.test.ts +8 -1
  318. package/frontend-vue/tests/sign-in-exit-history.test.ts +219 -0
  319. package/frontend-vue/tests/signup-invitation-link.test.ts +56 -8
  320. package/frontend-vue/tests/site-layout-categories.test.ts +42 -0
  321. package/frontend-vue/tests/site-layout-hydration.test.ts +3 -3
  322. package/frontend-vue/tests/site-layout-rendered.test.ts +103 -0
  323. package/frontend-vue/tests/site-layout-resync.test.ts +87 -0
  324. package/frontend-vue/tests/ssr-guard-preload.test.ts +188 -0
  325. package/frontend-vue/tests/table-cell-tones.test.ts +36 -1
  326. package/frontend-vue/tests/table-data-ssr.test.ts +195 -0
  327. package/frontend-vue/tests/table-list-data-requests.test.ts +154 -0
  328. package/frontend-vue/tests/table-retry-loading.test.ts +37 -0
  329. package/frontend-vue/tests/table-source-navigation.test.ts +0 -1
  330. package/frontend-vue/tests/table-source-period-scope.test.ts +0 -1
  331. package/frontend-vue/tests/table-view-views.test.ts +1 -1
  332. package/frontend-vue/tests/top-list-card-actions.test.ts +3 -0
  333. package/frontend-vue/tests/top-list-card-period-badge.test.ts +79 -0
  334. package/frontend-vue/tests/top-list-card-value-suffix.test.ts +3 -0
  335. package/frontend-vue/tests/trailing-deduper.test.ts +22 -0
  336. package/frontend-vue/tests/use-api-data-update.test.ts +119 -0
  337. package/frontend-vue/tests/use-api-data.test.ts +410 -0
  338. package/frontend-vue/tests/user-language.test.ts +8 -5
  339. package/frontend-vue/tests/widget-label-i18n.test.ts +4 -1
  340. package/frontend-vue/vitest.config.ts +6 -0
  341. package/package.json +2 -2
  342. package/skills/dms/SKILL.md +2 -1
  343. package/skills/dms-module/SKILL.md +1 -1
  344. package/skills/dms-pages/SKILL.md +4 -1
  345. package/frontend-vue/layers/dms-core/app/build/composables/page/usePageRecordLabel.ts +0 -9
  346. package/frontend-vue/layers/dms-layout/app/composables/page/usePrefetch.ts +0 -21
  347. package/frontend-vue/layers/dms-ui/app/build/composables/form/useControlError.ts +0 -10
  348. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useServerRenderedAsyncData.ts +0 -23
  349. package/frontend-vue/tests/control-error-path.test.ts +0 -11
  350. package/frontend-vue/tests/modules-listing-per-request.test.ts +0 -104
  351. package/frontend-vue/tests/page-layout-background-refresh.test.ts +0 -321
@@ -1,8 +1,34 @@
1
+ import { type Ref, watch } from "vue";
2
+
1
3
  const MENU_CHANGED_EVENT_TYPE = "menu-changed";
2
4
 
5
+ /**
6
+ * Calls `onReconnect` each time the realtime stream opens again after it was
7
+ * lost: the events sent meanwhile never arrived. Not on its first opening,
8
+ * which follows the page load that brought the current menu.
9
+ *
10
+ * @param sessionId The stream's session id, unset while it is closed
11
+ * @param onReconnect What to run once it is back
12
+ */
13
+ export function watchRealtimeReconnects(
14
+ sessionId: Ref<string | undefined>,
15
+ onReconnect: () => void,
16
+ ): void {
17
+ let hasConnected = !!sessionId.value;
18
+ watch(sessionId, (next, previous) => {
19
+ if (!next || next === previous) return;
20
+ if (hasConnected) onReconnect();
21
+ hasConnected = true;
22
+ });
23
+ }
24
+
3
25
  // A module signalled that the data behind the menu changed (a project created,
4
- // an access scope edited). The event carries no data: the site layout and the
5
- // permission set are re-fetched, each resolved server-side against this user.
26
+ // an access scope edited, a role or a membership changed): on the workspace's
27
+ // topic, or on this member's own when only their access changed. The event
28
+ // carries no data: the site layout, the page on screen and the permission set
29
+ // are re-fetched, each resolved server-side against this user. The layout
30
+ // comes back through Inertia, so the copy each visit carries over is the new
31
+ // one. A stream that reconnects resyncs too: it missed what was sent meanwhile.
6
32
  export default defineDmsPlugin(() => {
7
33
  const realtime = useUserRealtime();
8
34
  const siteLayout = useSiteLayout();
@@ -16,7 +42,7 @@ export default defineDmsPlugin(() => {
16
42
  await Promise.all([
17
43
  // Background resync: a transient failure is logged below, never
18
44
  // escalated to the fatal error page over a working session.
19
- siteLayout.refresh({ fatal: false }),
45
+ siteLayout.resync(),
20
46
  permissions.fetchPermissions(),
21
47
  ]);
22
48
  })
@@ -30,6 +56,8 @@ export default defineDmsPlugin(() => {
30
56
  void refresh();
31
57
  };
32
58
 
59
+ watchRealtimeReconnects(realtime.sessionId, () => void refresh());
60
+
33
61
  const unsubscribes: Array<() => void> = [];
34
62
 
35
63
  watch(
@@ -0,0 +1,58 @@
1
+ import { computed } from "vue";
2
+ import type { DmsLogoSources } from "#dms-core/shared/types/app-config";
3
+
4
+ const ICON_TYPES: Record<string, string> = {
5
+ svg: "image/svg+xml",
6
+ png: "image/png",
7
+ ico: "image/x-icon",
8
+ webp: "image/webp",
9
+ };
10
+ const FILE_EXTENSION = /\.([a-z0-9]+)(?:[?#]|$)/i;
11
+ const LIGHT_SCHEME_MEDIA = "(prefers-color-scheme: light)";
12
+ const DARK_SCHEME_MEDIA = "(prefers-color-scheme: dark)";
13
+
14
+ // A type rather than an interface: unhead's link type carries an index
15
+ // signature (`data-*`), which only a type alias satisfies implicitly.
16
+ type IconLink = {
17
+ rel: "icon";
18
+ href: string;
19
+ type?: string;
20
+ media?: string;
21
+ };
22
+
23
+ function iconLink(href: string, media?: string): IconLink {
24
+ const extension = FILE_EXTENSION.exec(href)?.[1].toLowerCase() ?? "";
25
+ const type = ICON_TYPES[extension];
26
+ return {
27
+ rel: "icon",
28
+ href,
29
+ ...(type ? { type } : {}),
30
+ ...(media ? { media } : {}),
31
+ };
32
+ }
33
+
34
+ function faviconLinks(favicon: Partial<DmsLogoSources> = {}): IconLink[] {
35
+ const { light, dark } = favicon;
36
+ if (light && dark && light !== dark)
37
+ return [
38
+ iconLink(light, LIGHT_SCHEME_MEDIA),
39
+ iconLink(dark, DARK_SCHEME_MEDIA),
40
+ ];
41
+ const icon = light || dark;
42
+ return icon ? [iconLink(icon)] : [];
43
+ }
44
+
45
+ /**
46
+ * The browser tab icon, `branding.logo.favicon`. Its light and dark files
47
+ * follow the operating system's scheme: the browser draws the tab outside the
48
+ * page, where the dashboard's color mode does not reach. The app config is
49
+ * read inside the computed, so an edit applied in development without a
50
+ * reload replaces the icon too.
51
+ */
52
+ export default defineDmsPlugin(() => {
53
+ useHead(
54
+ computed(() => ({
55
+ link: faviconLinks(useDmsAppConfig().branding.logo?.favicon),
56
+ })),
57
+ );
58
+ });
@@ -1,4 +1,7 @@
1
- import type { UserNotification } from "../composables/notification/useNotifications";
1
+ import type {
2
+ FeedCounts,
3
+ UserNotification,
4
+ } from "../composables/notification/useNotifications";
2
5
 
3
6
  const NOTIFICATION_TOPIC_PREFIX = "notifications:user:";
4
7
  const NOTIFICATION_NEW_TYPE = "notification:new";
@@ -9,6 +12,8 @@ const NOTIFICATION_UNREAD_TYPE = "notification:unread";
9
12
  interface IncomingPayload {
10
13
  notification?: UserNotification;
11
14
  ids?: string[];
15
+ /** The whole feed's counts after a read state change. */
16
+ counts?: FeedCounts;
12
17
  }
13
18
 
14
19
  const isNotificationEvent = (
@@ -21,7 +26,7 @@ const isNotificationEvent = (
21
26
  type Handler = (payload: IncomingPayload | undefined) => void | Promise<void>;
22
27
 
23
28
  export default defineDmsPlugin(() => {
24
- const { user, loggedIn } = useUserSession();
29
+ const { user, loggedIn } = useDmsUserSession();
25
30
  const {
26
31
  handleIncomingNotification,
27
32
  handleRemoteRead,
@@ -32,16 +37,19 @@ export default defineDmsPlugin(() => {
32
37
 
33
38
  const handlers: Record<string, Handler> = {
34
39
  [NOTIFICATION_NEW_TYPE]: (payload) => {
35
- if (payload?.notification) handleIncomingNotification(payload.notification);
40
+ if (payload?.notification)
41
+ handleIncomingNotification(payload.notification);
36
42
  },
37
43
  [NOTIFICATION_READ_TYPE]: async (payload) => {
38
- if (Array.isArray(payload?.ids)) await handleRemoteRead(payload.ids);
44
+ if (Array.isArray(payload?.ids))
45
+ await handleRemoteRead(payload.ids, payload.counts);
39
46
  },
40
47
  [NOTIFICATION_UNREAD_TYPE]: async (payload) => {
41
- if (Array.isArray(payload?.ids)) await handleRemoteUnread(payload.ids);
48
+ if (Array.isArray(payload?.ids))
49
+ await handleRemoteUnread(payload.ids, payload.counts);
42
50
  },
43
- [NOTIFICATION_ALL_READ_TYPE]: async () => {
44
- await handleRemoteAllRead();
51
+ [NOTIFICATION_ALL_READ_TYPE]: async (payload) => {
52
+ await handleRemoteAllRead(payload?.counts);
45
53
  },
46
54
  };
47
55
 
@@ -1,3 +1,4 @@
1
+ import { pageRoutePath } from "#dms-core/app/utils/pageRoutes";
1
2
  import { FormEvents } from "#dms-ui/app/composables/form/types/events";
2
3
  import type { ComponentEventData } from "#dms-core/app/types/component";
3
4
 
@@ -5,7 +6,10 @@ interface FormSubmitSuccessPayload {
5
6
  submitUrl?: string;
6
7
  }
7
8
 
8
- const REGION_PREFERENCES_ROUTE = "/settings/user/region/preferences";
9
+ const REGION_PREFERENCES_ROUTE = pageRoutePath(
10
+ "/settings/user/region",
11
+ "/preferences",
12
+ );
9
13
 
10
14
  /** The forms that edit the signed-in user's own record. */
11
15
  const SESSION_FORM_ROUTES = new Set([PROFILE_ROUTE, REGION_PREFERENCES_ROUTE]);
@@ -1,19 +1,25 @@
1
1
  import {
2
2
  readRegionalPreferences,
3
3
  type RegionalPreferenceFields,
4
+ setReaderTimeZoneSource,
4
5
  setRegionalPreferencesSource,
5
6
  } from "#dms-core/app/utils/regional";
7
+ import { useTimeZoneCookie } from "#dms-core/app/composables/period/periodTimeZone";
6
8
 
7
9
  /**
8
10
  * Points the shared date formatters at the signed-in user's regional
9
- * preferences (time zone, clock, date format, first day of the week). Read on
10
- * every format, from the session of the request being rendered, so the server
11
- * HTML and the browser agree and a saved preference applies at once.
11
+ * preferences (time zone, clock, date format, first day of the week), and a
12
+ * server render at the browser's time zone. Read on every format, from the
13
+ * request being rendered, so the server HTML and the browser agree and a
14
+ * saved preference applies at once.
12
15
  */
13
16
  export default defineDmsPlugin(() => {
14
17
  setRegionalPreferencesSource(() =>
15
18
  readRegionalPreferences(
16
- useUserSession<RegionalPreferenceFields>().user.value,
19
+ useDmsUserSession<RegionalPreferenceFields>().user.value,
17
20
  ),
18
21
  );
22
+ // A server render writes dates, and resolves periods, in the zone of the
23
+ // browser it renders for (its cookie), as the browser will.
24
+ setReaderTimeZoneSource(() => useTimeZoneCookie().value);
19
25
  });
@@ -0,0 +1,12 @@
1
+ import { useTimeZoneCookie } from "#dms-core/app/composables/period/periodTimeZone";
2
+ import { browserTimeZone } from "#dms-core/app/utils/regional";
3
+
4
+ /**
5
+ * Keeps the browser's time zone in its cookie, so that the next server render
6
+ * resolves the dashboards' periods in it (see `usePeriodScope`).
7
+ */
8
+ export default defineDmsPlugin(() => {
9
+ const cookie = useTimeZoneCookie();
10
+ const zone = browserTimeZone();
11
+ if (cookie.value !== zone) cookie.value = zone;
12
+ });
@@ -41,6 +41,13 @@ export interface CategoryInfo extends Omit<MenuOptions, "category"> {
41
41
  fullSlug: string;
42
42
  hasAccess?: boolean;
43
43
  isModuleRoot?: boolean;
44
+ /**
45
+ * The full id of the category this entry sits in, listed once in
46
+ * `SiteLayout.categories`; none at the root.
47
+ */
48
+ categoryId?: string;
49
+ /** Answers at its parent's slug: never the target of a URL of its own. */
50
+ urlTransparent?: boolean;
44
51
  }
45
52
 
46
53
  // Mirror of ModuleInfo and MODULE_URL_PREFIX from @antelopejs/interface-dms/page.
@@ -120,7 +127,13 @@ export interface LayoutBanner {
120
127
  }
121
128
 
122
129
  export type SiteLayout = {
130
+ /** By slug, the key a URL is resolved against. */
123
131
  pages: Record<string, PageInfo>;
132
+ /**
133
+ * Every category, once, by full id: pages and categories name theirs by
134
+ * `categoryId`. A URL resolves against the ones that are not
135
+ * `urlTransparent`, by `fullSlug`.
136
+ */
124
137
  categories: Record<string, CategoryInfo>;
125
138
  /** Absent when the backend predates layout banners. */
126
139
  banners?: LayoutBanner[];
@@ -0,0 +1,58 @@
1
+ import type { DmsServerEvent } from "#dms/server";
2
+ import { privateSecret } from "../../../server/private-options";
3
+ import { validRenderToken } from "./render-token";
4
+
5
+ const SERVICE_TOKEN_HEADER = "x-dms-service-token";
6
+
7
+ interface RenderInput {
8
+ templateName?: unknown;
9
+ props?: unknown;
10
+ }
11
+
12
+ function failure(status: number, message: string): Response {
13
+ return Response.json({ message }, { status });
14
+ }
15
+
16
+ function renderSecret(event: DmsServerEvent): string | undefined {
17
+ return privateSecret(
18
+ event,
19
+ "htmlRender",
20
+ "serviceSecret",
21
+ "DMS_HTML_RENDER_SECRET",
22
+ );
23
+ }
24
+
25
+ /**
26
+ * `POST /api/html/render`: renders one of the application's email templates
27
+ * for the DMS backend (`GenerateHtml`), which authenticates with a
28
+ * single-use service token rather than a browser session.
29
+ */
30
+ export async function renderHtml(event: DmsServerEvent): Promise<Response> {
31
+ if (
32
+ !validRenderToken(
33
+ event.request.headers.get(SERVICE_TOKEN_HEADER),
34
+ renderSecret(event),
35
+ )
36
+ )
37
+ return failure(401, "Invalid service token");
38
+ const input = await event.readJson<RenderInput>();
39
+ if (
40
+ typeof input.templateName !== "string" ||
41
+ input.templateName.trim() === "" ||
42
+ typeof input.props !== "object"
43
+ )
44
+ return failure(400, "Invalid request body");
45
+ try {
46
+ const html = await event.renderEmail(
47
+ input.templateName,
48
+ (input.props ?? {}) as Record<string, unknown>,
49
+ { locale: event.request.headers.get("x-content-language") ?? undefined },
50
+ );
51
+ return Response.json({ html });
52
+ } catch {
53
+ return failure(
54
+ 404,
55
+ `Template "${input.templateName}" not found or render failed`,
56
+ );
57
+ }
58
+ }
@@ -0,0 +1,95 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ // The service token that guards the HTML render route, minted by the DMS
4
+ // backend (`service-jwt.ts`) for its own render requests: its signature, its
5
+ // claims and its single use.
6
+
7
+ const RENDER_TOKEN_LIFETIME_SECONDS = 5 * 60;
8
+ const RENDER_REPLAY_LIMIT = 10_000;
9
+
10
+ interface RenderTokenClaims {
11
+ namespace?: unknown;
12
+ purpose?: unknown;
13
+ aud?: unknown;
14
+ jti?: unknown;
15
+ iat?: unknown;
16
+ exp?: unknown;
17
+ }
18
+
19
+ /** jti -> exp, for tokens already spent. Pruned on every accepted token. */
20
+ const usedRenderTokens = new Map<string, number>();
21
+
22
+ /**
23
+ * Constant-time check of the token's HMAC. The length comparison guards
24
+ * timingSafeEqual, which throws on mismatched lengths; it is not itself a
25
+ * timing leak, since the length of a base64url HMAC is fixed.
26
+ */
27
+ function signatureValid(parts: string[], secret: string): boolean {
28
+ const expected = createHmac("sha256", secret)
29
+ .update(`${parts[0]}.${parts[1]}`)
30
+ .digest("base64url");
31
+ if (expected.length !== parts[2].length) return false;
32
+ return timingSafeEqual(Buffer.from(expected), Buffer.from(parts[2]));
33
+ }
34
+
35
+ /**
36
+ * The claims this service accepts. `exp - iat` is bounded so a token minted
37
+ * with a far-future expiry cannot outlive the intended window.
38
+ */
39
+ function claimsValid(
40
+ payload: RenderTokenClaims,
41
+ now: number,
42
+ ): payload is RenderTokenClaims & { jti: string; exp: number } {
43
+ return (
44
+ payload.namespace === "html-render-service" &&
45
+ payload.purpose === "html-render" &&
46
+ payload.aud === "dms-frontend" &&
47
+ typeof payload.jti === "string" &&
48
+ payload.jti.length >= 16 &&
49
+ Number.isSafeInteger(payload.iat) &&
50
+ Number.isSafeInteger(payload.exp) &&
51
+ (payload.exp as number) > now &&
52
+ (payload.exp as number) - (payload.iat as number) <=
53
+ RENDER_TOKEN_LIFETIME_SECONDS &&
54
+ (payload.iat as number) <= now
55
+ );
56
+ }
57
+
58
+ /**
59
+ * Records a token as spent, dropping expired entries first and evicting the
60
+ * oldest if the map is at its cap, so a flood of tokens cannot grow it without
61
+ * bound.
62
+ */
63
+ function rememberToken(jti: string, exp: number, now: number) {
64
+ for (const [spent, expiresAt] of usedRenderTokens)
65
+ if (expiresAt <= now) usedRenderTokens.delete(spent);
66
+ while (usedRenderTokens.size >= RENDER_REPLAY_LIMIT)
67
+ usedRenderTokens.delete(usedRenderTokens.keys().next().value as string);
68
+ usedRenderTokens.set(jti, exp);
69
+ }
70
+
71
+ /**
72
+ * Whether the token is well-formed, signed with `secret`, unexpired and not
73
+ * already spent. A token accepted here is immediately marked as spent.
74
+ */
75
+ export function validRenderToken(
76
+ token: string | null,
77
+ secret: string | undefined,
78
+ ): boolean {
79
+ if (!token || !secret) return false;
80
+ const parts = token.split(".");
81
+ if (parts.length !== 3) return false;
82
+ if (!signatureValid(parts, secret)) return false;
83
+ try {
84
+ const payload = JSON.parse(
85
+ Buffer.from(parts[1], "base64url").toString("utf8"),
86
+ ) as RenderTokenClaims;
87
+ const now = Math.floor(Date.now() / 1000);
88
+ if (!claimsValid(payload, now)) return false;
89
+ if (usedRenderTokens.has(payload.jti)) return false;
90
+ rememberToken(payload.jti, payload.exp, now);
91
+ return true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
@@ -6,34 +6,38 @@ interface OnboardingInfoResponse {
6
6
  has_onboarded: boolean;
7
7
  }
8
8
 
9
+ /**
10
+ * Transferred: the global middleware reads it while a server renders the
11
+ * page, and the browser's run of the middleware then finds it instead of
12
+ * asking again. It holds nothing private.
13
+ */
14
+ function useOnboardingCache() {
15
+ return useDmsState<OnboardingInfo | null>("dms-onboarding-info", () => null, {
16
+ transfer: true,
17
+ });
18
+ }
19
+
9
20
  export const useOnboarding = async (): Promise<OnboardingInfo> => {
10
- const cache = useDmsState<OnboardingInfo | null>(
11
- "dms-onboarding-info",
12
- () => null,
13
- );
21
+ const cache = useOnboardingCache();
14
22
 
15
- if (cache.value) return cache.value;
23
+ const known = cache.value;
24
+ if (known) return known;
16
25
 
17
26
  const { $authFetch } = useAuthFetch();
18
27
 
19
- const response = await $authFetch<OnboardingInfoResponse>(
20
- "/api/onboarding/informations",
21
- { method: "GET" },
22
- );
23
-
24
- const info: OnboardingInfo = {
25
- hasOnboarded: response.has_onboarded,
26
- };
27
-
28
- cache.value = info;
29
- return info;
28
+ // One request per app however many callers ask before it answers: the
29
+ // preload started with the app and the `onboarding` middleware share it.
30
+ await useTrailingDeduper("onboarding").join(async () => {
31
+ const response = await $authFetch<OnboardingInfoResponse>(
32
+ "/api/onboarding/informations",
33
+ { method: "GET" },
34
+ );
35
+ cache.value = { hasOnboarded: response.has_onboarded };
36
+ });
37
+ return cache.value as OnboardingInfo;
30
38
  };
31
39
 
32
40
  /** Release the cached onboarding guard after the backend creates the admin. */
33
41
  export const setOnboardingComplete = () => {
34
- const cache = useDmsState<OnboardingInfo | null>(
35
- "dms-onboarding-info",
36
- () => null,
37
- );
38
- cache.value = { hasOnboarded: true };
42
+ useOnboardingCache().value = { hasOnboarded: true };
39
43
  };
@@ -5,7 +5,7 @@ const LOGIN_PATH = "/auth";
5
5
  // login page for anyone else rather than a homepage whose 401 would bounce
6
6
  // them there anyway.
7
7
  function completedOnboardingDestination(): string {
8
- const { loggedIn } = useUserSession();
8
+ const { loggedIn } = useDmsUserSession();
9
9
  return loggedIn.value ? useHomepage() : LOGIN_PATH;
10
10
  }
11
11
 
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Asks for the onboarding status as the app starts rather than when the
3
+ * `onboarding` middleware does, once the plugins and the page have set up:
4
+ * the request runs alongside the others the render makes, and the middleware
5
+ * joins it. A failure stays the middleware's to handle.
6
+ */
7
+ export default defineDmsPlugin(() => {
8
+ useOnboarding().catch(() => undefined);
9
+ });
@@ -742,7 +742,9 @@ interface TextOptions {
742
742
  /**
743
743
  * `string`: the value as written, in the tone the row gives (`toneField`) when
744
744
  * it gives one; a row without one keeps the bare value, as a string column
745
- * always drew it.
745
+ * always drew it. An empty one is `null`: a cell rendering `""` as its root
746
+ * leaves no node in the server's HTML, which the browser hydrates as a
747
+ * mismatch, where `null` renders a placeholder both sides agree on.
746
748
  */
747
749
  function renderText(
748
750
  value: unknown,
@@ -750,6 +752,7 @@ function renderText(
750
752
  options?: unknown,
751
753
  row?: Row,
752
754
  ): unknown {
755
+ if (value === "") return null;
753
756
  const tone = readRowTone(
754
757
  row,
755
758
  (options as TextOptions | undefined)?.toneField,
@@ -0,0 +1,162 @@
1
+ import {
2
+ effectScope,
3
+ getCurrentInstance,
4
+ inject,
5
+ onScopeDispose,
6
+ shallowReactive,
7
+ watch,
8
+ } from "vue";
9
+ import { FORM_CONTENT_LANGUAGE_KEY } from "../../../composables/form/types/content-language";
10
+ import {
11
+ isDefinitiveMetadataFailure,
12
+ resolveMetadataRetryDelay,
13
+ } from "../../../utils/metadataRetry";
14
+
15
+ /** What `/api/files/metadata` answers for a stored file. */
16
+ export interface FileMetadata {
17
+ resourceKey: string;
18
+ filename: string;
19
+ size: number;
20
+ mimetype: string;
21
+ url: string;
22
+ /** When `url` stops working, in milliseconds since the epoch. */
23
+ expiresAt?: number;
24
+ }
25
+
26
+ const METADATA_URL = "/api/files/metadata";
27
+ // A read URL is read again this long before it expires.
28
+ const URL_REFRESH_LEAD_MS = 5000;
29
+
30
+ const isExpired = (metadata: FileMetadata): boolean =>
31
+ metadata.expiresAt !== undefined && metadata.expiresAt <= Date.now();
32
+
33
+ /**
34
+ * The metadata of stored files (name, size, type, read URL), one read per
35
+ * file key, shared by every component showing that file.
36
+ *
37
+ * A key resolved during a component's setup (directly, or from an immediate
38
+ * watcher) is read with the page: a server render waits for it and hands it
39
+ * to the browser, which does not ask again. A key resolved later (a new
40
+ * value, an upload) is read in the browser. While a component shows a file,
41
+ * its URL is read again before it expires, and a failed read that may heal
42
+ * (network, 5xx, rate limiting) is retried with a growing delay; a refused or
43
+ * missing file (another 4xx) is not.
44
+ *
45
+ * @param storage The storage the keys live in (the field's `storage`)
46
+ */
47
+ export function useFileMetadata(storage?: string) {
48
+ const contentLanguage = inject(FORM_CONTENT_LANGUAGE_KEY, undefined);
49
+ // Reads resolved past the setup run with the application's context.
50
+ const app = getCurrentInstance()?.appContext.app;
51
+ const scope = effectScope();
52
+ const reads = shallowReactive(new Map<string, ApiDataResult<FileMetadata>>());
53
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
54
+ const failures = new Map<string, number>();
55
+
56
+ function schedule(key: string, delay: number, read: () => Promise<void>) {
57
+ clearTimeout(timers.get(key));
58
+ timers.set(
59
+ key,
60
+ setTimeout(() => {
61
+ timers.delete(key);
62
+ void read();
63
+ }, delay),
64
+ );
65
+ }
66
+
67
+ /** The next read of `key` its last answer calls for, in the browser. */
68
+ function follow(key: string, result: ApiDataResult<FileMetadata>): void {
69
+ const refresh = () => result.refresh({ dedupe: "defer" });
70
+ watch(
71
+ [result.data, result.error, result.status],
72
+ ([metadata, error, status]) => {
73
+ if (status === "pending" || status === "idle") return;
74
+ clearTimeout(timers.get(key));
75
+ if (error) {
76
+ const attempts = (failures.get(key) ?? 0) + 1;
77
+ failures.set(key, attempts);
78
+ const delay = resolveMetadataRetryDelay(error, attempts);
79
+ if (delay !== null) schedule(key, delay, refresh);
80
+ return;
81
+ }
82
+ failures.delete(key);
83
+ if (metadata?.expiresAt === undefined) return;
84
+ const remaining = Math.max(metadata.expiresAt - Date.now(), 0);
85
+ const lead = Math.min(URL_REFRESH_LEAD_MS, remaining / 2);
86
+ schedule(key, remaining - lead, refresh);
87
+ },
88
+ { immediate: true },
89
+ );
90
+ }
91
+
92
+ function readOf(key: string): ApiDataResult<FileMetadata> {
93
+ const known = reads.get(key);
94
+ if (known) return known;
95
+ const read = () =>
96
+ useApiData<FileMetadata>(
97
+ `dms-file-metadata:${storage ?? ""}:${contentLanguage ?? ""}:${key}`,
98
+ ({ $authFetch, signal }) =>
99
+ $authFetch<FileMetadata>(METADATA_URL, {
100
+ query: { resourceKey: key, storage },
101
+ headers: contentLanguage
102
+ ? { [CONTENT_LANGUAGE_HEADER]: contentLanguage }
103
+ : undefined,
104
+ signal,
105
+ }),
106
+ );
107
+ const result = scope.run(() => {
108
+ const started =
109
+ getCurrentInstance() || !app ? read() : app.runWithContext(read);
110
+ if (!import.meta.env.SSR) follow(key, started);
111
+ return started;
112
+ })!;
113
+ reads.set(key, result);
114
+ return result;
115
+ }
116
+
117
+ /**
118
+ * Reads `key`'s metadata unless it is known; `force` reads it again (to
119
+ * open a file with a fresh URL).
120
+ */
121
+ async function resolve(key: string, force = false): Promise<void> {
122
+ const result = readOf(key);
123
+ if (force) await result.refresh({ dedupe: "defer" });
124
+ else await result;
125
+ }
126
+
127
+ /** The last metadata read for `key`, its URL possibly expired. */
128
+ const lastRead = (key: string): FileMetadata | undefined =>
129
+ reads.get(key)?.data.value ?? undefined;
130
+
131
+ /**
132
+ * Whether `key` cannot be shown: its last read failed and the server
133
+ * refused it for good, or nothing usable is left (never read, or expired).
134
+ */
135
+ function isUnavailable(key: string): boolean {
136
+ const result = reads.get(key);
137
+ // A failure stays until the next answer: a retry keeps the file marked.
138
+ if (!result?.error.value) return false;
139
+ const known = result.data.value;
140
+ return (
141
+ !known ||
142
+ isExpired(known) ||
143
+ isDefinitiveMetadataFailure(result.error.value)
144
+ );
145
+ }
146
+
147
+ /** The metadata to show for `key`: read, not expired, still available. */
148
+ function metadata(key: string): FileMetadata | undefined {
149
+ const known = lastRead(key);
150
+ return known && !isExpired(known) && !isUnavailable(key)
151
+ ? known
152
+ : undefined;
153
+ }
154
+
155
+ onScopeDispose(() => {
156
+ scope.stop();
157
+ timers.forEach(clearTimeout);
158
+ timers.clear();
159
+ });
160
+
161
+ return { resolve, metadata, lastRead, isUnavailable };
162
+ }