@vertesia/ui 1.5.0-dev.20260910.052912Z → 1.6.0-dev.20260912.110338Z

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 (389) hide show
  1. package/README.md +125 -0
  2. package/lib/boot/branding.d.ts +58 -0
  3. package/lib/boot/branding.d.ts.map +1 -0
  4. package/lib/boot/branding.js +62 -0
  5. package/lib/boot/branding.js.map +1 -0
  6. package/lib/boot/index.d.ts +27 -0
  7. package/lib/boot/index.d.ts.map +1 -0
  8. package/lib/boot/index.js +325 -0
  9. package/lib/boot/index.js.map +1 -0
  10. package/lib/boot/vite.d.ts +63 -0
  11. package/lib/boot/vite.d.ts.map +1 -0
  12. package/lib/boot/vite.js +108 -0
  13. package/lib/boot/vite.js.map +1 -0
  14. package/lib/core/components/Link.js +1 -1
  15. package/lib/core/components/Link.js.map +1 -1
  16. package/lib/core/components/Spinner.js +1 -1
  17. package/lib/core/components/Spinner.js.map +1 -1
  18. package/lib/core/components/TagsInput.js +7 -7
  19. package/lib/core/components/TagsInput.js.map +1 -1
  20. package/lib/core/components/shadcn/badge.js +1 -1
  21. package/lib/core/components/shadcn/badge.js.map +1 -1
  22. package/lib/core/components/shadcn/button.d.ts.map +1 -1
  23. package/lib/core/components/shadcn/button.js +3 -4
  24. package/lib/core/components/shadcn/button.js.map +1 -1
  25. package/lib/core/components/shadcn/filters/comboBox/DateCombobox.js +2 -2
  26. package/lib/core/components/shadcn/filters/comboBox/DateCombobox.js.map +1 -1
  27. package/lib/core/components/shadcn/filters/comboBox/SelectCombobox.js +1 -1
  28. package/lib/core/components/shadcn/filters/comboBox/SelectCombobox.js.map +1 -1
  29. package/lib/core/components/shadcn/filters/comboBox/StringListCombobox.js +1 -1
  30. package/lib/core/components/shadcn/filters/comboBox/StringListCombobox.js.map +1 -1
  31. package/lib/core/components/shadcn/filters/comboBox/TextCombobox.js +1 -1
  32. package/lib/core/components/shadcn/filters/comboBox/TextCombobox.js.map +1 -1
  33. package/lib/core/components/shadcn/filters/filter/dateFilter.js +1 -1
  34. package/lib/core/components/shadcn/filters/filter/dateFilter.js.map +1 -1
  35. package/lib/core/components/shadcn/filters/filter-styles.d.ts +1 -1
  36. package/lib/core/components/shadcn/filters/filter-styles.d.ts.map +1 -1
  37. package/lib/core/components/shadcn/filters/filter-styles.js +4 -4
  38. package/lib/core/components/shadcn/filters/filters.js +1 -1
  39. package/lib/core/components/shadcn/filters/filters.js.map +1 -1
  40. package/lib/core/components/shadcn/label.js +1 -1
  41. package/lib/core/components/shadcn/label.js.map +1 -1
  42. package/lib/core/components/shadcn/overflowTabs.js +2 -2
  43. package/lib/core/components/shadcn/overflowTabs.js.map +1 -1
  44. package/lib/core/components/shadcn/tabs.js +1 -1
  45. package/lib/core/components/shadcn/tabs.js.map +1 -1
  46. package/lib/env/index.d.ts +28 -1
  47. package/lib/env/index.d.ts.map +1 -1
  48. package/lib/env/index.js +51 -3
  49. package/lib/env/index.js.map +1 -1
  50. package/lib/features/activity-doc/ActivityDoc.js +1 -1
  51. package/lib/features/activity-doc/ActivityDoc.js.map +1 -1
  52. package/lib/features/agent/PayloadBuilder.d.ts +4 -0
  53. package/lib/features/agent/PayloadBuilder.d.ts.map +1 -1
  54. package/lib/features/agent/PayloadBuilder.js.map +1 -1
  55. package/lib/features/agent/chat/AgentRunFeedback.d.ts +59 -0
  56. package/lib/features/agent/chat/AgentRunFeedback.d.ts.map +1 -0
  57. package/lib/features/agent/chat/AgentRunFeedback.js +286 -0
  58. package/lib/features/agent/chat/AgentRunFeedback.js.map +1 -0
  59. package/lib/features/agent/chat/AskUserWidget.d.ts +1 -0
  60. package/lib/features/agent/chat/AskUserWidget.d.ts.map +1 -1
  61. package/lib/features/agent/chat/AskUserWidget.js +15 -4
  62. package/lib/features/agent/chat/AskUserWidget.js.map +1 -1
  63. package/lib/features/agent/chat/ModernAgentConversation.d.ts.map +1 -1
  64. package/lib/features/agent/chat/ModernAgentConversation.js +3 -2
  65. package/lib/features/agent/chat/ModernAgentConversation.js.map +1 -1
  66. package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.d.ts +3 -1
  67. package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.d.ts.map +1 -1
  68. package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.js +16 -13
  69. package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.js.map +1 -1
  70. package/lib/features/agent/chat/ModernAgentOutput/Header.d.ts.map +1 -1
  71. package/lib/features/agent/chat/ModernAgentOutput/Header.js.map +1 -1
  72. package/lib/features/agent/chat/ModernAgentOutput/MessageItem.d.ts +3 -1
  73. package/lib/features/agent/chat/ModernAgentOutput/MessageItem.d.ts.map +1 -1
  74. package/lib/features/agent/chat/ModernAgentOutput/MessageItem.js +30 -8
  75. package/lib/features/agent/chat/ModernAgentOutput/MessageItem.js.map +1 -1
  76. package/lib/features/agent/chat/ModernAgentOutput/SummaryConversation.d.ts.map +1 -1
  77. package/lib/features/agent/chat/ModernAgentOutput/SummaryConversation.js +2 -0
  78. package/lib/features/agent/chat/ModernAgentOutput/SummaryConversation.js.map +1 -1
  79. package/lib/features/agent/chat/index.d.ts +1 -0
  80. package/lib/features/agent/chat/index.d.ts.map +1 -1
  81. package/lib/features/agent/chat/index.js +1 -0
  82. package/lib/features/agent/chat/index.js.map +1 -1
  83. package/lib/features/facets/AgentRunnerFacetsNav.d.ts +8 -0
  84. package/lib/features/facets/AgentRunnerFacetsNav.d.ts.map +1 -1
  85. package/lib/features/facets/AgentRunnerFacetsNav.js +12 -0
  86. package/lib/features/facets/AgentRunnerFacetsNav.js.map +1 -1
  87. package/lib/features/layout/GenericPageNavHeader.js +1 -1
  88. package/lib/features/layout/GenericPageNavHeader.js.map +1 -1
  89. package/lib/features/magic-pdf/AnnotatedImageSlider.js +1 -1
  90. package/lib/features/magic-pdf/AnnotatedImageSlider.js.map +1 -1
  91. package/lib/features/magic-pdf/GroundedExtractionView.js +3 -3
  92. package/lib/features/magic-pdf/GroundedExtractionView.js.map +1 -1
  93. package/lib/features/permissions/UserPermissionsProvider.d.ts +15 -1
  94. package/lib/features/permissions/UserPermissionsProvider.d.ts.map +1 -1
  95. package/lib/features/permissions/UserPermissionsProvider.js +20 -12
  96. package/lib/features/permissions/UserPermissionsProvider.js.map +1 -1
  97. package/lib/features/store/objects/DocumentPreviewPanel.js +5 -5
  98. package/lib/features/store/objects/DocumentPreviewPanel.js.map +1 -1
  99. package/lib/features/store/objects/components/DocumentInput.js +1 -1
  100. package/lib/features/store/objects/components/DocumentInput.js.map +1 -1
  101. package/lib/features/store/objects/selection/SelectionActions.js +1 -1
  102. package/lib/features/store/objects/selection/SelectionActions.js.map +1 -1
  103. package/lib/features/store/objects/upload/DocumentUploadModal.js +1 -1
  104. package/lib/features/store/objects/upload/DocumentUploadModal.js.map +1 -1
  105. package/lib/features/store/types/TypeRegistry.d.ts +130 -0
  106. package/lib/features/store/types/TypeRegistry.d.ts.map +1 -1
  107. package/lib/features/store/types/search/ObjectTypeSearchContext.d.ts +130 -0
  108. package/lib/features/store/types/search/ObjectTypeSearchContext.d.ts.map +1 -1
  109. package/lib/features/user/UserInfo.js +1 -1
  110. package/lib/features/user/UserInfo.js.map +1 -1
  111. package/lib/i18n/index.d.ts +5 -4
  112. package/lib/i18n/index.d.ts.map +1 -1
  113. package/lib/i18n/index.js +16 -2
  114. package/lib/i18n/index.js.map +1 -1
  115. package/lib/i18n/locales/ar.json +30 -0
  116. package/lib/i18n/locales/de.json +30 -0
  117. package/lib/i18n/locales/en.json +30 -0
  118. package/lib/i18n/locales/es.json +30 -0
  119. package/lib/i18n/locales/fr.json +30 -0
  120. package/lib/i18n/locales/it.json +30 -0
  121. package/lib/i18n/locales/ja.json +30 -0
  122. package/lib/i18n/locales/ko.json +30 -0
  123. package/lib/i18n/locales/pt.json +30 -0
  124. package/lib/i18n/locales/ru.json +30 -0
  125. package/lib/i18n/locales/tr.json +30 -0
  126. package/lib/i18n/locales/zh-TW.json +30 -0
  127. package/lib/i18n/locales/zh.json +30 -0
  128. package/lib/layout/ErrorPage.js +1 -1
  129. package/lib/layout/ErrorPage.js.map +1 -1
  130. package/lib/layout/Navbar.d.ts.map +1 -1
  131. package/lib/layout/Navbar.js +3 -2
  132. package/lib/layout/Navbar.js.map +1 -1
  133. package/lib/router/RouteComponent.d.ts.map +1 -1
  134. package/lib/router/RouteComponent.js +15 -12
  135. package/lib/router/RouteComponent.js.map +1 -1
  136. package/lib/session/UserSession.d.ts.map +1 -1
  137. package/lib/session/UserSession.js +6 -3
  138. package/lib/session/UserSession.js.map +1 -1
  139. package/lib/session/UserSessionProvider.d.ts +2 -1
  140. package/lib/session/UserSessionProvider.d.ts.map +1 -1
  141. package/lib/session/UserSessionProvider.js +10 -1
  142. package/lib/session/UserSessionProvider.js.map +1 -1
  143. package/lib/session/auth/composable.d.ts.map +1 -1
  144. package/lib/session/auth/composable.js +88 -3
  145. package/lib/session/auth/composable.js.map +1 -1
  146. package/lib/session/auth/domainRouting.d.ts +22 -12
  147. package/lib/session/auth/domainRouting.d.ts.map +1 -1
  148. package/lib/session/auth/domainRouting.js +35 -21
  149. package/lib/session/auth/domainRouting.js.map +1 -1
  150. package/lib/session/auth/useAuthState.d.ts +9 -4
  151. package/lib/session/auth/useAuthState.d.ts.map +1 -1
  152. package/lib/session/auth/useAuthState.js +9 -4
  153. package/lib/session/auth/useAuthState.js.map +1 -1
  154. package/lib/session/index.d.ts.map +1 -1
  155. package/lib/session/index.js +3 -1
  156. package/lib/session/index.js.map +1 -1
  157. package/lib/shell/AuthScreenPreview.d.ts +12 -0
  158. package/lib/shell/AuthScreenPreview.d.ts.map +1 -0
  159. package/lib/shell/AuthScreenPreview.js +140 -0
  160. package/lib/shell/AuthScreenPreview.js.map +1 -0
  161. package/lib/shell/BrandedAuthScreens.d.ts +19 -0
  162. package/lib/shell/BrandedAuthScreens.d.ts.map +1 -0
  163. package/lib/shell/BrandedAuthScreens.js +64 -0
  164. package/lib/shell/BrandedAuthScreens.js.map +1 -0
  165. package/lib/shell/SplashScreen.d.ts +13 -2
  166. package/lib/shell/SplashScreen.d.ts.map +1 -1
  167. package/lib/shell/SplashScreen.js +21 -20
  168. package/lib/shell/SplashScreen.js.map +1 -1
  169. package/lib/shell/VertesiaShell.d.ts +15 -4
  170. package/lib/shell/VertesiaShell.d.ts.map +1 -1
  171. package/lib/shell/VertesiaShell.js +8 -3
  172. package/lib/shell/VertesiaShell.js.map +1 -1
  173. package/lib/shell/apps/StandaloneApp.js +5 -1
  174. package/lib/shell/apps/StandaloneApp.js.map +1 -1
  175. package/lib/shell/index.d.ts +6 -0
  176. package/lib/shell/index.d.ts.map +1 -1
  177. package/lib/shell/index.js +5 -0
  178. package/lib/shell/index.js.map +1 -1
  179. package/lib/shell/login/SignInFlow.d.ts +68 -0
  180. package/lib/shell/login/SignInFlow.d.ts.map +1 -0
  181. package/lib/shell/login/SignInFlow.js +108 -0
  182. package/lib/shell/login/SignInFlow.js.map +1 -0
  183. package/lib/shell/login/SignInPageShell.d.ts +13 -0
  184. package/lib/shell/login/SignInPageShell.d.ts.map +1 -0
  185. package/lib/shell/login/SignInPageShell.js +14 -0
  186. package/lib/shell/login/SignInPageShell.js.map +1 -0
  187. package/lib/shell/login/SignInPrimitives.d.ts +3 -3
  188. package/lib/shell/login/SignInPrimitives.d.ts.map +1 -1
  189. package/lib/shell/login/SignInPrimitives.js +13 -11
  190. package/lib/shell/login/SignInPrimitives.js.map +1 -1
  191. package/lib/shell/login/SignInRestrictedEnvStep.d.ts +5 -0
  192. package/lib/shell/login/SignInRestrictedEnvStep.d.ts.map +1 -1
  193. package/lib/shell/login/SignInRestrictedEnvStep.js +8 -4
  194. package/lib/shell/login/SignInRestrictedEnvStep.js.map +1 -1
  195. package/lib/shell/login/SigninScreen.d.ts +38 -3
  196. package/lib/shell/login/SigninScreen.d.ts.map +1 -1
  197. package/lib/shell/login/SigninScreen.js +33 -86
  198. package/lib/shell/login/SigninScreen.js.map +1 -1
  199. package/lib/shell/login/SignupForm.d.ts +3 -1
  200. package/lib/shell/login/SignupForm.d.ts.map +1 -1
  201. package/lib/shell/login/SignupForm.js +7 -3
  202. package/lib/shell/login/SignupForm.js.map +1 -1
  203. package/lib/shell/login/signInUtils.d.ts +11 -0
  204. package/lib/shell/login/signInUtils.d.ts.map +1 -1
  205. package/lib/shell/login/signInUtils.js +29 -5
  206. package/lib/shell/login/signInUtils.js.map +1 -1
  207. package/lib/vertesia-ui-boot.js +237 -0
  208. package/lib/vertesia-ui-boot.js.map +1 -0
  209. package/lib/vertesia-ui-core.js +46 -46
  210. package/lib/vertesia-ui-core.js.map +1 -1
  211. package/lib/vertesia-ui-env.js +1 -1
  212. package/lib/vertesia-ui-env.js.map +1 -1
  213. package/lib/vertesia-ui-features.js +222 -208
  214. package/lib/vertesia-ui-features.js.map +1 -1
  215. package/lib/vertesia-ui-i18n.B3NFclwl.chunk.js +5 -0
  216. package/lib/vertesia-ui-i18n.B3NFclwl.chunk.js.map +1 -0
  217. package/lib/vertesia-ui-i18n.BWSrNIxc.chunk.js +5 -0
  218. package/lib/vertesia-ui-i18n.BWSrNIxc.chunk.js.map +1 -0
  219. package/lib/vertesia-ui-i18n.BWvWeB1p.chunk.js +5 -0
  220. package/lib/vertesia-ui-i18n.BWvWeB1p.chunk.js.map +1 -0
  221. package/lib/vertesia-ui-i18n.BZCXvTC1.chunk.js +5 -0
  222. package/lib/vertesia-ui-i18n.BZCXvTC1.chunk.js.map +1 -0
  223. package/lib/vertesia-ui-i18n.BbJMvFZW.chunk.js +5 -0
  224. package/lib/vertesia-ui-i18n.BbJMvFZW.chunk.js.map +1 -0
  225. package/lib/vertesia-ui-i18n.CE5ldwpt.chunk.js +5 -0
  226. package/lib/vertesia-ui-i18n.CE5ldwpt.chunk.js.map +1 -0
  227. package/lib/vertesia-ui-i18n.CMC0IShY.chunk.js +5 -0
  228. package/lib/vertesia-ui-i18n.CMC0IShY.chunk.js.map +1 -0
  229. package/lib/vertesia-ui-i18n.CkcRqBKU.chunk.js +5 -0
  230. package/lib/vertesia-ui-i18n.CkcRqBKU.chunk.js.map +1 -0
  231. package/lib/vertesia-ui-i18n.D-Mq1sw_.chunk.js +5 -0
  232. package/lib/vertesia-ui-i18n.D-Mq1sw_.chunk.js.map +1 -0
  233. package/lib/vertesia-ui-i18n.DkK68-A-.chunk.js +5 -0
  234. package/lib/vertesia-ui-i18n.DkK68-A-.chunk.js.map +1 -0
  235. package/lib/vertesia-ui-i18n.XE_dcCg6.chunk.js +5 -0
  236. package/lib/vertesia-ui-i18n.XE_dcCg6.chunk.js.map +1 -0
  237. package/lib/vertesia-ui-i18n.js +2 -2
  238. package/lib/vertesia-ui-i18n.js.map +1 -1
  239. package/lib/vertesia-ui-i18n.qgNqMPvK.chunk.js +5 -0
  240. package/lib/vertesia-ui-i18n.qgNqMPvK.chunk.js.map +1 -0
  241. package/lib/vertesia-ui-layout.js +13 -13
  242. package/lib/vertesia-ui-layout.js.map +1 -1
  243. package/lib/vertesia-ui-rich-text.js +68 -68
  244. package/lib/vertesia-ui-rich-text.js.map +1 -1
  245. package/lib/vertesia-ui-router.js +1 -1
  246. package/lib/vertesia-ui-router.js.map +1 -1
  247. package/lib/vertesia-ui-session.js +1 -1
  248. package/lib/vertesia-ui-session.js.map +1 -1
  249. package/lib/vertesia-ui-shell.js +58 -33
  250. package/lib/vertesia-ui-shell.js.map +1 -1
  251. package/lib/vertesia-ui-widgets.js +94 -94
  252. package/lib/vertesia-ui-widgets.js.map +1 -1
  253. package/lib/widgets/json-view/JSONCode.js +1 -1
  254. package/lib/widgets/json-view/JSONCode.js.map +1 -1
  255. package/lib/widgets/upload/DropZone.d.ts.map +1 -1
  256. package/lib/widgets/upload/DropZone.js +1 -1
  257. package/lib/widgets/upload/DropZone.js.map +1 -1
  258. package/llms.txt +6 -2
  259. package/package.json +44 -35
  260. package/src/__tests__/a11y.test.tsx +25 -0
  261. package/src/__tests__/theme.test.ts +91 -0
  262. package/src/boot/branding.test.ts +72 -0
  263. package/src/boot/branding.ts +108 -0
  264. package/src/boot/index.test.ts +216 -0
  265. package/src/boot/index.ts +356 -0
  266. package/src/boot/vite.test.ts +93 -0
  267. package/src/boot/vite.ts +118 -0
  268. package/src/core/components/Link.tsx +1 -1
  269. package/src/core/components/Spinner.tsx +1 -1
  270. package/src/core/components/TagsInput.tsx +7 -7
  271. package/src/core/components/shadcn/badge.tsx +1 -1
  272. package/src/core/components/shadcn/button.tsx +3 -5
  273. package/src/core/components/shadcn/filters/comboBox/DateCombobox.tsx +2 -2
  274. package/src/core/components/shadcn/filters/comboBox/SelectCombobox.tsx +1 -1
  275. package/src/core/components/shadcn/filters/comboBox/StringListCombobox.tsx +1 -1
  276. package/src/core/components/shadcn/filters/comboBox/TextCombobox.tsx +1 -1
  277. package/src/core/components/shadcn/filters/filter/dateFilter.tsx +1 -1
  278. package/src/core/components/shadcn/filters/filter-styles.ts +4 -4
  279. package/src/core/components/shadcn/filters/filters.tsx +1 -1
  280. package/src/core/components/shadcn/label.tsx +1 -1
  281. package/src/core/components/shadcn/overflowTabs.tsx +2 -2
  282. package/src/core/components/shadcn/tabs.tsx +1 -1
  283. package/src/css/utilities.css +7 -0
  284. package/src/env/index.test.ts +100 -0
  285. package/src/env/index.ts +66 -3
  286. package/src/features/activity-doc/ActivityDoc.tsx +1 -1
  287. package/src/features/agent/PayloadBuilder.tsx +4 -0
  288. package/src/features/agent/chat/AgentRunFeedback.test.tsx +330 -0
  289. package/src/features/agent/chat/AgentRunFeedback.tsx +452 -0
  290. package/src/features/agent/chat/AskUserWidget.test.tsx +24 -2
  291. package/src/features/agent/chat/AskUserWidget.tsx +19 -4
  292. package/src/features/agent/chat/ModernAgentConversation.tsx +46 -42
  293. package/src/features/agent/chat/ModernAgentOutput/AllMessagesMixed.test.tsx +207 -0
  294. package/src/features/agent/chat/ModernAgentOutput/AllMessagesMixed.tsx +50 -31
  295. package/src/features/agent/chat/ModernAgentOutput/Header.tsx +4 -0
  296. package/src/features/agent/chat/ModernAgentOutput/MessageItem.tsx +42 -8
  297. package/src/features/agent/chat/ModernAgentOutput/SummaryConversation.ts +2 -0
  298. package/src/features/agent/chat/ModernAgentOutput/utils.test.ts +14 -0
  299. package/src/features/agent/chat/hooks/useFileProcessing.test.tsx +0 -5
  300. package/src/features/agent/chat/index.ts +6 -0
  301. package/src/features/facets/AgentRunnerFacetsNav.tsx +26 -0
  302. package/src/features/layout/GenericPageNavHeader.test.tsx +36 -0
  303. package/src/features/layout/GenericPageNavHeader.tsx +1 -1
  304. package/src/features/magic-pdf/AnnotatedImageSlider.tsx +1 -1
  305. package/src/features/magic-pdf/GroundedExtractionView.tsx +3 -3
  306. package/src/features/permissions/UserPermissionsProvider.test.tsx +50 -1
  307. package/src/features/permissions/UserPermissionsProvider.tsx +82 -44
  308. package/src/features/store/objects/DocumentPreviewPanel.tsx +10 -10
  309. package/src/features/store/objects/components/DocumentInput.tsx +1 -1
  310. package/src/features/store/objects/selection/SelectionActions.tsx +4 -1
  311. package/src/features/store/objects/upload/DocumentUploadModal.tsx +1 -1
  312. package/src/features/user/UserInfo.tsx +1 -1
  313. package/src/i18n/index.tsx +25 -2
  314. package/src/i18n/locales/ar.json +30 -0
  315. package/src/i18n/locales/de.json +30 -0
  316. package/src/i18n/locales/en.json +30 -0
  317. package/src/i18n/locales/es.json +30 -0
  318. package/src/i18n/locales/fr.json +30 -0
  319. package/src/i18n/locales/it.json +30 -0
  320. package/src/i18n/locales/ja.json +30 -0
  321. package/src/i18n/locales/ko.json +30 -0
  322. package/src/i18n/locales/pt.json +30 -0
  323. package/src/i18n/locales/ru.json +30 -0
  324. package/src/i18n/locales/tr.json +30 -0
  325. package/src/i18n/locales/zh-TW.json +30 -0
  326. package/src/i18n/locales/zh.json +30 -0
  327. package/src/layout/ErrorPage.tsx +1 -1
  328. package/src/layout/Navbar.test.tsx +58 -0
  329. package/src/layout/Navbar.tsx +5 -2
  330. package/src/router/RouteComponent.test.tsx +65 -1
  331. package/src/router/RouteComponent.tsx +18 -15
  332. package/src/session/UserSession.test.ts +65 -0
  333. package/src/session/UserSession.ts +6 -4
  334. package/src/session/UserSessionProvider.strict-mode.test.tsx +44 -0
  335. package/src/session/UserSessionProvider.test.ts +62 -1
  336. package/src/session/UserSessionProvider.tsx +9 -1
  337. package/src/session/auth/composable.renewal.test.ts +170 -0
  338. package/src/session/auth/composable.test.ts +23 -1
  339. package/src/session/auth/composable.ts +101 -2
  340. package/src/session/auth/domainRouting.test.ts +35 -65
  341. package/src/session/auth/domainRouting.ts +51 -34
  342. package/src/session/auth/useAuthState.ts +11 -4
  343. package/src/session/index.ts +3 -1
  344. package/src/shell/AuthScreenPreview.test.tsx +44 -0
  345. package/src/shell/AuthScreenPreview.tsx +228 -0
  346. package/src/shell/BrandedAuthScreens.test.tsx +181 -0
  347. package/src/shell/BrandedAuthScreens.tsx +117 -0
  348. package/src/shell/SplashScreen.test.tsx +37 -0
  349. package/src/shell/SplashScreen.tsx +45 -40
  350. package/src/shell/VertesiaShell.tsx +49 -22
  351. package/src/shell/apps/StandaloneApp.tsx +5 -1
  352. package/src/shell/index.tsx +6 -0
  353. package/src/shell/login/SignInFlow.tsx +213 -0
  354. package/src/shell/login/SignInPageShell.tsx +62 -0
  355. package/src/shell/login/SignInPrimitives.tsx +14 -11
  356. package/src/shell/login/SignInRestrictedEnvStep.tsx +19 -7
  357. package/src/shell/login/SigninScreen.test.tsx +104 -0
  358. package/src/shell/login/SigninScreen.tsx +115 -145
  359. package/src/shell/login/SignupForm.tsx +8 -3
  360. package/src/shell/login/signInUtils.ts +30 -5
  361. package/src/widgets/json-view/JSONCode.test.tsx +2 -2
  362. package/src/widgets/json-view/JSONCode.tsx +1 -1
  363. package/src/widgets/markdown/markdown.css +21 -21
  364. package/src/widgets/upload/DropZone.tsx +2 -4
  365. package/lib/vertesia-ui-i18n.BfN7iOHb.chunk.js +0 -5
  366. package/lib/vertesia-ui-i18n.BfN7iOHb.chunk.js.map +0 -1
  367. package/lib/vertesia-ui-i18n.BjH2zEZw.chunk.js +0 -5
  368. package/lib/vertesia-ui-i18n.BjH2zEZw.chunk.js.map +0 -1
  369. package/lib/vertesia-ui-i18n.BjSwAZXl.chunk.js +0 -5
  370. package/lib/vertesia-ui-i18n.BjSwAZXl.chunk.js.map +0 -1
  371. package/lib/vertesia-ui-i18n.C7PUm5Au.chunk.js +0 -5
  372. package/lib/vertesia-ui-i18n.C7PUm5Au.chunk.js.map +0 -1
  373. package/lib/vertesia-ui-i18n.CF_1mUS5.chunk.js +0 -5
  374. package/lib/vertesia-ui-i18n.CF_1mUS5.chunk.js.map +0 -1
  375. package/lib/vertesia-ui-i18n.ChfUl3_-.chunk.js +0 -5
  376. package/lib/vertesia-ui-i18n.ChfUl3_-.chunk.js.map +0 -1
  377. package/lib/vertesia-ui-i18n.D-Sk-5d1.chunk.js +0 -5
  378. package/lib/vertesia-ui-i18n.D-Sk-5d1.chunk.js.map +0 -1
  379. package/lib/vertesia-ui-i18n.D4rnu1_S.chunk.js +0 -5
  380. package/lib/vertesia-ui-i18n.D4rnu1_S.chunk.js.map +0 -1
  381. package/lib/vertesia-ui-i18n.DDf0-laK.chunk.js +0 -5
  382. package/lib/vertesia-ui-i18n.DDf0-laK.chunk.js.map +0 -1
  383. package/lib/vertesia-ui-i18n.DN79y06J.chunk.js +0 -5
  384. package/lib/vertesia-ui-i18n.DN79y06J.chunk.js.map +0 -1
  385. package/lib/vertesia-ui-i18n.DWLIciYQ.chunk.js +0 -5
  386. package/lib/vertesia-ui-i18n.DWLIciYQ.chunk.js.map +0 -1
  387. package/lib/vertesia-ui-i18n.DvF_YaAJ.chunk.js +0 -5
  388. package/lib/vertesia-ui-i18n.DvF_YaAJ.chunk.js.map +0 -1
  389. package/src/session/auth/authState.test.ts +0 -35
@@ -1,5 +1,6 @@
1
1
  import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
2
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { ErrorBoundary } from 'react-error-boundary';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
4
  import { prefetchLazyRoutes, RouteComponent } from './RouteComponent';
4
5
  import { type LazyRouteModule, useNavigate } from './Router';
5
6
  import { RouterProvider } from './RouterProvider';
@@ -38,6 +39,44 @@ describe('RouteComponent lazy routes', () => {
38
39
  window.history.pushState({}, '', '/');
39
40
  });
40
41
 
42
+ it.each(['missing module', 'rejected import', 'synchronous failure'] as const)(
43
+ 'reports a %s through the error boundary instead of remaining on the spinner',
44
+ async (failure) => {
45
+ const error = new Error('Module could not be loaded');
46
+ const onError = vi.fn();
47
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
48
+ const routes = [
49
+ {
50
+ path: '/a',
51
+ LazyComponent: () => {
52
+ if (failure === 'synchronous failure') throw error;
53
+ return failure === 'missing module'
54
+ ? Promise.resolve(undefined as unknown as LazyRouteModule)
55
+ : Promise.reject(error);
56
+ },
57
+ },
58
+ ];
59
+ try {
60
+ render(
61
+ <ErrorBoundary fallback={<div>Route unavailable</div>} onError={onError}>
62
+ <RouterProvider routes={routes}>
63
+ <RouteComponent spinner={<div>loading</div>} />
64
+ </RouterProvider>
65
+ </ErrorBoundary>,
66
+ );
67
+ await screen.findByText('Route unavailable');
68
+ expect(screen.queryByText('loading')).toBeNull();
69
+ expect(onError.mock.calls[0][0]).toEqual(
70
+ failure === 'missing module'
71
+ ? new Error('Lazy module for /a does not have a default export')
72
+ : error,
73
+ );
74
+ } finally {
75
+ consoleError.mockRestore();
76
+ }
77
+ },
78
+ );
79
+
41
80
  it('stops showing the previous lazy route while the next one loads', async () => {
42
81
  const pageA = deferredModule('Page A');
43
82
  const pageB = deferredModule('Page B');
@@ -87,6 +126,31 @@ describe('RouteComponent lazy routes', () => {
87
126
  expect(screen.getByText('Page B')).toBeDefined();
88
127
  });
89
128
 
129
+ it('ignores a rejected import after navigating away', async () => {
130
+ let reject!: (error: Error) => void;
131
+ const promise = new Promise<LazyRouteModule>((_resolve, rejectPromise) => {
132
+ reject = rejectPromise;
133
+ });
134
+ const onError = vi.fn();
135
+ render(
136
+ <ErrorBoundary fallback={<div>Route unavailable</div>} onError={onError}>
137
+ <RouterProvider
138
+ routes={[
139
+ { path: '/a', LazyComponent: () => promise },
140
+ { path: '/b', Component: () => <div>Page B</div> },
141
+ ]}
142
+ >
143
+ <CaptureNavigate />
144
+ <RouteComponent />
145
+ </RouterProvider>
146
+ </ErrorBoundary>,
147
+ );
148
+ await act(async () => navigateFn('/b'));
149
+ await act(async () => reject(new Error('Previous route failed')));
150
+ expect(screen.getByText('Page B')).toBeDefined();
151
+ expect(onError).not.toHaveBeenCalled();
152
+ });
153
+
90
154
  it('renders a previously resolved lazy route synchronously on revisit', async () => {
91
155
  const pageA = deferredModule('Page A');
92
156
  const pageB = deferredModule('Page B');
@@ -13,14 +13,13 @@ import {
13
13
  // between two lazy routes blanks the page for at least a frame, even when the module is loaded.
14
14
  const resolvedComponents = new Map<LazyComponentRoute['LazyComponent'], LazyRouteModule['default']>();
15
15
 
16
- function resolveLazyRoute(route: LazyComponentRoute): Promise<LazyRouteModule['default']> {
17
- return route.LazyComponent().then((module) => {
18
- if (!module.default) {
19
- throw new Error(`Lazy module for ${route.path} does not have a default export`);
20
- }
21
- resolvedComponents.set(route.LazyComponent, module.default);
22
- return module.default;
23
- });
16
+ async function resolveLazyRoute(route: LazyComponentRoute): Promise<LazyRouteModule['default']> {
17
+ const module = await route.LazyComponent();
18
+ if (!module?.default) {
19
+ throw new Error(`Lazy module for ${route.path} does not have a default export`);
20
+ }
21
+ resolvedComponents.set(route.LazyComponent, module.default);
22
+ return module.default;
24
23
  }
25
24
 
26
25
  /**
@@ -64,21 +63,25 @@ function LazyRouteComponent({ route, spinner }: LazyRouteComponentProps) {
64
63
  const [Component, setComponent] = useState<LazyRouteModule['default'] | null>(
65
64
  () => resolvedComponents.get(route.LazyComponent) ?? null,
66
65
  );
66
+ const [error, setError] = useState<unknown>();
67
67
  useEffect(() => {
68
68
  // A slow import must not land after this instance moved on (unmount, or a route object
69
69
  // swap without a path change): the stale module would overwrite the current one.
70
70
  let stale = false;
71
- void resolveLazyRoute(route).then((component) => {
72
- if (!stale) {
73
- // we need to wrap the component type in an arrow function
74
- // otherwise the setState function will execute the function as a state update function
75
- setComponent(() => component);
76
- }
77
- });
71
+ void resolveLazyRoute(route).then(
72
+ (component) => {
73
+ if (!stale) setComponent(() => component);
74
+ },
75
+ (error: unknown) => {
76
+ // Render failures through the surrounding error boundary, including import failures.
77
+ if (!stale) setError(() => error);
78
+ },
79
+ );
78
80
  return () => {
79
81
  stale = true;
80
82
  };
81
83
  }, [route]);
82
84
 
85
+ if (error !== undefined) throw error;
83
86
  return Component ? <Component /> : spinner || null;
84
87
  }
@@ -1,17 +1,64 @@
1
1
  import type { VertesiaClient } from '@vertesia/client';
2
+ import { type AuthTokenPayload, PrincipalType } from '@vertesia/common';
2
3
  import { describe, expect, it, vi } from 'vitest';
3
4
  import { UserSession } from './UserSession';
4
5
 
6
+ const projectToken: AuthTokenPayload = {
7
+ sub: 'test-user',
8
+ name: 'Test User',
9
+ type: PrincipalType.User,
10
+ account: { id: 'test-account', name: 'Test Account' },
11
+ account_roles: [],
12
+ accounts: [],
13
+ project: { id: 'test-project', name: 'Test Project', account: 'test-account' },
14
+ apps: [],
15
+ iss: 'https://auth.example.com',
16
+ aud: 'test-client',
17
+ exp: 4102444800,
18
+ };
19
+
20
+ function encodeToken(payload: AuthTokenPayload): string {
21
+ return `${btoa(JSON.stringify({ alg: 'none' }))}.${btoa(JSON.stringify(payload))}.`;
22
+ }
23
+
5
24
  function createSession(onboardingProgress: () => Promise<Record<string, boolean>>) {
6
25
  const client = {
7
26
  account: { onboardingProgress },
27
+ withAuthCallback: vi.fn(),
8
28
  } as unknown as VertesiaClient;
9
29
  const setSession = vi.fn<(session: UserSession) => void>();
10
30
  const session = new UserSession(client, setSession);
31
+ session.authToken = { ...projectToken };
11
32
  return { session, setSession };
12
33
  }
13
34
 
14
35
  describe('UserSession.fetchOnboardingStatus', () => {
36
+ it('skips onboarding before authentication without publishing a status', async () => {
37
+ const onboardingProgress = vi.fn(async () => ({ project_created: true }));
38
+ const { session, setSession } = createSession(onboardingProgress);
39
+ session.authToken = undefined;
40
+
41
+ await expect(session.fetchOnboardingStatus()).resolves.toBe(false);
42
+ expect(onboardingProgress).not.toHaveBeenCalled();
43
+ expect(session.onboardingComplete).toBeUndefined();
44
+ expect(setSession).not.toHaveBeenCalled();
45
+ });
46
+
47
+ it('defers onboarding during account-only login and loads it after project selection', async () => {
48
+ const onboardingProgress = vi.fn(async () => ({ project_created: true }));
49
+ const { session, setSession } = createSession(onboardingProgress);
50
+
51
+ await session.login(encodeToken({ ...projectToken, project: undefined }));
52
+ expect(onboardingProgress).not.toHaveBeenCalled();
53
+ expect(session.onboardingComplete).toBeUndefined();
54
+ expect(setSession).not.toHaveBeenCalled();
55
+
56
+ await session.login(encodeToken(projectToken));
57
+ expect(onboardingProgress).toHaveBeenCalledOnce();
58
+ expect(session.onboardingComplete).toBe(true);
59
+ expect(setSession).toHaveBeenCalledOnce();
60
+ });
61
+
15
62
  it('does not replace the session when an incomplete status is unchanged', async () => {
16
63
  const { session, setSession } = createSession(async () => ({ project_created: false }));
17
64
  session.onboardingComplete = false;
@@ -37,3 +84,21 @@ describe('UserSession.fetchOnboardingStatus', () => {
37
84
  expect(setSession.mock.calls[0]?.[0]?.onboardingComplete).toBe(false);
38
85
  });
39
86
  });
87
+
88
+ describe('UserSession.signOut', () => {
89
+ it('keeps the receiver when used as a callback, including cloned sessions', () => {
90
+ const { session } = createSession(async () => ({}));
91
+ const clone = session.clone();
92
+ const logout = vi.spyOn(session, 'logout').mockImplementation(() => undefined);
93
+ const cloneLogout = vi.spyOn(clone, 'logout').mockImplementation(() => undefined);
94
+ const { signOut } = session;
95
+ const { signOut: signOutClone } = clone;
96
+
97
+ signOut();
98
+ expect(logout).toHaveBeenCalledOnce();
99
+ expect(cloneLogout).not.toHaveBeenCalled();
100
+ signOutClone();
101
+ expect(cloneLogout).toHaveBeenCalledOnce();
102
+ expect(logout).toHaveBeenCalledOnce();
103
+ });
104
+ });
@@ -5,15 +5,13 @@ import { jwtDecode } from 'jwt-decode';
5
5
  import { createContext, useContext } from 'react';
6
6
 
7
7
  import { getComposableToken } from './auth/composable';
8
- import { authReturnUrl, mountRootUrl, shouldRedirectToCentralAuth } from './auth/domainRouting';
8
+ import { authReturnUrl, centralAuthUrl, mountRootUrl, shouldRedirectToCentralAuth } from './auth/domainRouting';
9
9
  import { getFirebaseAuth } from './auth/firebase';
10
10
 
11
11
  import { LastSelectedAccountId_KEY, LastSelectedProjectId_KEY } from './constants';
12
12
 
13
13
  export { LastSelectedAccountId_KEY, LastSelectedProjectId_KEY };
14
14
 
15
- const CENTRAL_AUTH_REDIRECT = 'https://internal-auth.vertesia.app/';
16
-
17
15
  export interface UserSessionLoginOptions {
18
16
  loadOnboardingStatus?: boolean;
19
17
  }
@@ -44,6 +42,7 @@ class UserSession {
44
42
  }
45
43
 
46
44
  this.logout = this.logout.bind(this);
45
+ this.signOut = this.signOut.bind(this);
47
46
  }
48
47
 
49
48
  get store() {
@@ -151,7 +150,7 @@ class UserSession {
151
150
  this.setSession = undefined;
152
151
  this.client.withAuthCallback(undefined);
153
152
 
154
- const logoutUrl = new URL(CENTRAL_AUTH_REDIRECT);
153
+ const logoutUrl = new URL(centralAuthUrl());
155
154
  const currentUrl = authReturnUrl();
156
155
  logoutUrl.pathname = '/logout';
157
156
  logoutUrl.searchParams.set('redirect_uri', currentUrl.toString());
@@ -251,6 +250,9 @@ class UserSession {
251
250
  }
252
251
 
253
252
  async fetchOnboardingStatus(): Promise<boolean> {
253
+ if (!this.project?.id) {
254
+ return false;
255
+ }
254
256
  if (this.onboardingComplete) {
255
257
  console.log('Onboarding already completed');
256
258
  return false;
@@ -0,0 +1,44 @@
1
+ import { cleanup, render, waitFor } from '@testing-library/react';
2
+ import { Env } from '@vertesia/ui/env';
3
+ import { StrictMode } from 'react';
4
+ import { afterEach, expect, it, vi } from 'vitest';
5
+ import { redirectToCentralAuth } from './auth/domainRouting';
6
+ import { UserSessionProvider } from './UserSessionProvider';
7
+
8
+ vi.mock('./auth/useAuthState', () => ({ useAuthState: () => ({}) }));
9
+ vi.mock('./auth/domainRouting', async (importOriginal) => ({
10
+ ...(await importOriginal<typeof import('./auth/domainRouting')>()),
11
+ shouldRedirectToCentralAuth: () => true,
12
+ redirectToCentralAuth: vi.fn(),
13
+ }));
14
+
15
+ afterEach(() => {
16
+ cleanup();
17
+ vi.clearAllMocks();
18
+ });
19
+
20
+ it('continues authentication after StrictMode cancels the first host-token attempt', async () => {
21
+ const authTokenProvider = vi.fn(async () => undefined);
22
+ Env.init({
23
+ name: 'test',
24
+ version: '1',
25
+ type: 'development',
26
+ isLocalDev: true,
27
+ isDocker: false,
28
+ endpoints: {
29
+ studio: 'https://api.example.com',
30
+ zeno: 'https://api.example.com',
31
+ sts: 'https://sts.example.com',
32
+ },
33
+ authTokenProvider,
34
+ });
35
+ render(
36
+ <StrictMode>
37
+ <UserSessionProvider>
38
+ <span>App</span>
39
+ </UserSessionProvider>
40
+ </StrictMode>,
41
+ );
42
+ await waitFor(() => expect(redirectToCentralAuth).toHaveBeenCalledTimes(1));
43
+ expect(authTokenProvider).toHaveBeenCalledTimes(2);
44
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { RequestedScopeUnavailableError } from './auth/composable';
3
- import { sanitizeRejectedScopeUrl } from './UserSessionProvider';
3
+ import { buildCentralAuthRedirectUrl, sanitizeRejectedScopeUrl } from './UserSessionProvider';
4
4
 
5
5
  describe('sanitizeRejectedScopeUrl', () => {
6
6
  it.each([
@@ -67,3 +67,64 @@ describe('sanitizeRejectedScopeUrl', () => {
67
67
  expect(error.requestedScope).toBe('project');
68
68
  });
69
69
  });
70
+
71
+ describe('buildCentralAuthRedirectUrl', () => {
72
+ const RETURN_URL = new URL('https://app.example.test/studio/dashboard?tab=runs');
73
+ const STS = 'https://sts.example.test';
74
+
75
+ it('carries the sts, redirect_uri and state parameters', () => {
76
+ const url = buildCentralAuthRedirectUrl('https://auth.example.test/', STS, RETURN_URL, 'state-123');
77
+
78
+ expect(url.origin).toBe('https://auth.example.test');
79
+ expect(url.pathname).toBe('/');
80
+ expect(url.searchParams.get('sts')).toBe(STS);
81
+ expect(url.searchParams.get('redirect_uri')).toBe(RETURN_URL.toString());
82
+ expect(url.searchParams.get('state')).toBe('state-123');
83
+ });
84
+
85
+ it('carries the selected account and project through a gateway-mounted app redirect', () => {
86
+ const returnUrl = new URL('https://apps.example.test/tenants/t/apps/a/versions/v/app/?view=grid');
87
+ const url = buildCentralAuthRedirectUrl('https://auth.example.test/', STS, returnUrl, 'state-123', {
88
+ accountId: 'account-1',
89
+ projectId: 'project-1',
90
+ });
91
+
92
+ expect(url.searchParams.get('redirect_uri')).toBe(
93
+ 'https://apps.example.test/tenants/t/apps/a/versions/v/app/?view=grid&p=project-1&a=account-1',
94
+ );
95
+ });
96
+
97
+ // The endpoint is configurable, so it can arrive with a query of its own. Concatenating
98
+ // `?sts=...` onto it folds the parameter into the existing value and Central Auth sees no sts.
99
+ it('preserves a query already present on the configured endpoint', () => {
100
+ const url = buildCentralAuthRedirectUrl('https://auth.example.test/?tenant=acme', STS, RETURN_URL, 'state-123');
101
+
102
+ expect(url.searchParams.get('tenant')).toBe('acme');
103
+ expect(url.searchParams.get('sts')).toBe(STS);
104
+ expect(url.searchParams.get('redirect_uri')).toBe(RETURN_URL.toString());
105
+ expect(url.searchParams.get('state')).toBe('state-123');
106
+ });
107
+
108
+ // Same hazard from the other side: appended after a fragment, the parameters land inside it.
109
+ it('keeps the parameters out of a fragment on the configured endpoint', () => {
110
+ const url = buildCentralAuthRedirectUrl('https://auth.example.test/#section', STS, RETURN_URL, 'state-123');
111
+
112
+ expect(url.hash).toBe('#section');
113
+ expect(url.searchParams.get('sts')).toBe(STS);
114
+ expect(url.searchParams.get('state')).toBe('state-123');
115
+ });
116
+
117
+ it('preserves a path prefix on the configured endpoint', () => {
118
+ const url = buildCentralAuthRedirectUrl('https://auth.example.test/signin', STS, RETURN_URL, 'state-123');
119
+
120
+ expect(url.pathname).toBe('/signin');
121
+ expect(url.searchParams.get('sts')).toBe(STS);
122
+ });
123
+
124
+ it('percent-encodes the parameter values', () => {
125
+ const url = buildCentralAuthRedirectUrl('https://auth.example.test/', STS, RETURN_URL, 'a b&c=d');
126
+
127
+ expect(url.search).toContain('sts=https%3A%2F%2Fsts.example.test');
128
+ expect(url.searchParams.get('state')).toBe('a b&c=d');
129
+ });
130
+ });
@@ -12,7 +12,7 @@ import {
12
12
  resolveAuthSelection,
13
13
  UserNotFoundError,
14
14
  } from './auth/composable';
15
- import { redirectToCentralAuth, shouldRedirectToCentralAuth } from './auth/domainRouting';
15
+ import { buildCentralAuthRedirectUrl, redirectToCentralAuth, shouldRedirectToCentralAuth } from './auth/domainRouting';
16
16
  import { getFirebaseAuth } from './auth/firebase';
17
17
  import { useAuthState } from './auth/useAuthState';
18
18
  import { UserSession, UserSessionContext } from './UserSession';
@@ -23,6 +23,10 @@ function clearAuthHash() {
23
23
  window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}`);
24
24
  }
25
25
 
26
+ // Re-exported from its new home in ./auth/domainRouting, which the token layer can import without
27
+ // a cycle -- it renews an expired Central Auth session through the same URL.
28
+ export { buildCentralAuthRedirectUrl };
29
+
26
30
  export function sanitizeRejectedScopeUrl(
27
31
  currentUrl: URL,
28
32
  error: RequestedScopeUnavailableError,
@@ -268,6 +272,7 @@ export function UserSessionProvider({ children, loadOnboardingStatus = true }: U
268
272
  session.setSession = setSession;
269
273
  void Env.authTokenProvider()
270
274
  .then(async (injectedToken) => {
275
+ if (cancelled) return;
271
276
  if (!injectedToken) {
272
277
  startFirebaseOrCentralAuth();
273
278
  return;
@@ -283,6 +288,7 @@ export function UserSessionProvider({ children, loadOnboardingStatus = true }: U
283
288
  if (!cancelled) setSession(session.clone());
284
289
  })
285
290
  .catch((err: unknown) => {
291
+ if (cancelled) return;
286
292
  if (surfaceAuthError(err)) return;
287
293
  console.warn('Auth: failed to initialize injected auth token', err);
288
294
  Env.logger.warn('Failed to initialize injected auth token', {
@@ -298,6 +304,7 @@ export function UserSessionProvider({ children, loadOnboardingStatus = true }: U
298
304
  });
299
305
  return () => {
300
306
  cancelled = true;
307
+ hasInitiatedAuthRef.current = false;
301
308
  unsubscribe?.();
302
309
  };
303
310
  }
@@ -305,6 +312,7 @@ export function UserSessionProvider({ children, loadOnboardingStatus = true }: U
305
312
  startFirebaseOrCentralAuth();
306
313
  return () => {
307
314
  cancelled = true;
315
+ hasInitiatedAuthRef.current = false;
308
316
  unsubscribe?.();
309
317
  };
310
318
  };
@@ -0,0 +1,170 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Renewal of a Central Auth session: the case where the browser holds an STS JWT, nothing else, and
4
+ // that JWT has expired. There is no Firebase user to mint a replacement from and no refresh token,
5
+ // so the token layer either bounces through the broker or the page dead-ends until someone reloads
6
+ // it by hand.
7
+ import { NO_ACCESSIBLE_ACCOUNT_ERROR_CODE } from '@vertesia/common';
8
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
9
+
10
+ const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
11
+
12
+ vi.mock('@vertesia/ui/env', () => ({
13
+ Env: {
14
+ endpoints: { sts: 'https://sts.test', studio: 'https://studio.test', auth: 'https://auth.test/' },
15
+ logger,
16
+ },
17
+ }));
18
+
19
+ // No Firebase user: that is what makes this a Central Auth session rather than a Firebase one.
20
+ vi.mock('./firebase', () => ({
21
+ getFirebaseAuth: () => ({ currentUser: null }),
22
+ getFirebaseAuthToken: vi.fn(),
23
+ }));
24
+
25
+ function unsignedJwt(payload: Record<string, unknown>): string {
26
+ const encode = (value: string) => btoa(value).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
27
+ return `${encode(JSON.stringify({ alg: 'none', typ: 'JWT' }))}.${encode(JSON.stringify(payload))}.signature`;
28
+ }
29
+
30
+ const ACCOUNT_ID = 'account-1';
31
+ const PROJECT_ID = 'project-1';
32
+
33
+ /** A token STS would accept back for re-issuance: its own issuer, and real authorization claims. */
34
+ function stsToken(secondsFromNow: number): string {
35
+ return unsignedJwt({
36
+ iss: 'https://sts.test',
37
+ exp: Math.floor(Date.now() / 1000) + secondsFromNow,
38
+ account: { id: ACCOUNT_ID },
39
+ project: { id: PROJECT_ID },
40
+ permissions: ['content:read'],
41
+ });
42
+ }
43
+
44
+ function okResponse(token: string): Response {
45
+ return new Response(JSON.stringify({ token }), { status: 200 });
46
+ }
47
+
48
+ let replace: ReturnType<typeof vi.fn>;
49
+
50
+ beforeEach(() => {
51
+ vi.restoreAllMocks();
52
+ vi.resetModules();
53
+ localStorage.clear();
54
+ sessionStorage.clear();
55
+ logger.info.mockClear();
56
+ logger.warn.mockClear();
57
+
58
+ // `location.replace` is the redirect under test; jsdom's own implementation would only warn.
59
+ replace = vi.fn();
60
+ vi.stubGlobal('location', {
61
+ href: 'https://cloud.test/studio/environments',
62
+ replace,
63
+ });
64
+ // `shouldRedirectToCentralAuth()` is "not Firebase mode", which is the default here.
65
+ window.AUTH_MODE = undefined;
66
+ });
67
+
68
+ describe('forced refresh of a Central Auth session', () => {
69
+ it('re-mints through STS instead of handing back the credential it already holds', async () => {
70
+ // The bug this pins: the refresh path adopted a still-valid Vertesia token as its own
71
+ // result, so `refreshAuthToken()` -- whose whole purpose is to re-read claims STS
72
+ // recomputes on every issuance, such as `apps` after an ACE change -- silently returned the
73
+ // very token whose claims were suspect. A Central Auth session has no other credential, so
74
+ // the no-op was total there.
75
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse(stsToken(7200)));
76
+
77
+ const { getComposableToken } = await import('./composable');
78
+ // Adopted without a round-trip, which is the right call when nothing forced a refresh.
79
+ const first = await getComposableToken(ACCOUNT_ID, PROJECT_ID, stsToken(3600));
80
+ expect(fetchMock).not.toHaveBeenCalled();
81
+
82
+ const refreshed = await getComposableToken(undefined, undefined, undefined, true);
83
+
84
+ // Before the fix this was zero: the same shortcut swallowed the forced refresh too.
85
+ expect(fetchMock).toHaveBeenCalledTimes(1);
86
+ expect(refreshed.token.exp).toBeGreaterThan(first.token.exp);
87
+ });
88
+ });
89
+
90
+ describe('an expired Central Auth session', () => {
91
+ /**
92
+ * Establish a session whose token is already inside the five-minute renewal window, then answer
93
+ * the renewal attempt with `status`. This is the shape of the reported incident: the tab sat
94
+ * idle, the token aged out, and the next click tried to renew from a credential STS refuses.
95
+ */
96
+ async function sessionThenRenewalFailure(status: number, body?: unknown) {
97
+ const nearlyExpired = stsToken(60);
98
+ vi.spyOn(globalThis, 'fetch')
99
+ .mockResolvedValueOnce(okResponse(nearlyExpired))
100
+ .mockResolvedValueOnce(new Response(body === undefined ? null : JSON.stringify(body), { status }));
101
+
102
+ const { getComposableToken } = await import('./composable');
103
+ await getComposableToken(ACCOUNT_ID, PROJECT_ID, nearlyExpired);
104
+ return getComposableToken;
105
+ }
106
+
107
+ it('bounces through the broker rather than dead-ending', async () => {
108
+ const getComposableToken = await sessionThenRenewalFailure(401);
109
+
110
+ await expect(getComposableToken(ACCOUNT_ID, PROJECT_ID)).rejects.toThrow();
111
+
112
+ expect(replace).toHaveBeenCalledTimes(1);
113
+ const url = new URL(replace.mock.calls[0][0] as string);
114
+ expect(url.origin).toBe('https://auth.test');
115
+ expect(url.searchParams.get('sts')).toBe('https://sts.test');
116
+ expect(url.searchParams.get('redirect_uri')).toBe('https://cloud.test/studio/environments');
117
+ // The broker echoes `state` back; the return leg verifies it against this record.
118
+ expect(url.searchParams.get('state')).toBe(sessionStorage.getItem('auth_state'));
119
+ });
120
+
121
+ it('redirects once per expiry, not once per failing call', async () => {
122
+ // Every in-flight request fails at the same moment, and the first failure discards the
123
+ // credential -- so the calls behind it reach the "no credential at all" branch. Without the
124
+ // cooldown each one would start its own navigation.
125
+ const getComposableToken = await sessionThenRenewalFailure(401);
126
+
127
+ await expect(getComposableToken(ACCOUNT_ID, PROJECT_ID)).rejects.toThrow();
128
+ await expect(getComposableToken(ACCOUNT_ID, PROJECT_ID)).rejects.toThrow(/Cannot acquire/);
129
+
130
+ expect(replace).toHaveBeenCalledTimes(1);
131
+ });
132
+
133
+ it('does not redirect again for a broker round-trip that just failed', async () => {
134
+ // The loop guard, from the other side of the navigation: module state is gone, so the
135
+ // cooldown has to be read back from sessionStorage. A broker that returns a credential STS
136
+ // also refuses would otherwise bounce the browser between the two forever.
137
+ sessionStorage.setItem('vt.centralAuthRenewalAt', String(Date.now() - 1_000));
138
+ const getComposableToken = await sessionThenRenewalFailure(401);
139
+
140
+ await expect(getComposableToken(ACCOUNT_ID, PROJECT_ID)).rejects.toThrow();
141
+
142
+ expect(replace).not.toHaveBeenCalled();
143
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('already attempted'), expect.anything());
144
+ });
145
+
146
+ it('leaves an account-level refusal alone', async () => {
147
+ // The broker would hand back the same identity and STS would refuse it again. This is a
148
+ // question for the recovery screen, not for another round-trip.
149
+ const getComposableToken = await sessionThenRenewalFailure(403, {
150
+ errorCode: NO_ACCESSIBLE_ACCOUNT_ERROR_CODE,
151
+ });
152
+
153
+ await expect(getComposableToken(ACCOUNT_ID, PROJECT_ID)).rejects.toThrow();
154
+
155
+ expect(replace).not.toHaveBeenCalled();
156
+ });
157
+ });
158
+
159
+ describe('a browser with no session yet', () => {
160
+ it('does not redirect from the token layer', async () => {
161
+ // First load is UserSessionProvider's to route: it holds the account/project selection this
162
+ // layer cannot see, and it already redirects. Renewing from here would race it.
163
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 401 }));
164
+
165
+ const { getComposableToken } = await import('./composable');
166
+ await expect(getComposableToken(ACCOUNT_ID, PROJECT_ID, stsToken(-60))).rejects.toThrow();
167
+
168
+ expect(replace).not.toHaveBeenCalled();
169
+ });
170
+ });
@@ -5,7 +5,10 @@ function makeJwt(payload: Record<string, unknown>) {
5
5
  return `${encode({ alg: 'ES256', typ: 'JWT' })}.${encode(payload)}.signature`;
6
6
  }
7
7
 
8
- async function importComposableAuth(authTokenProvider?: () => Promise<string | undefined>) {
8
+ async function importComposableAuth(
9
+ authTokenProvider?: () => Promise<string | undefined>,
10
+ defaultAuthSelection?: { accountId?: string; projectId?: string },
11
+ ) {
9
12
  vi.resetModules();
10
13
  const [{ Env }, composableAuth] = await Promise.all([import('@vertesia/ui/env'), import('./composable')]);
11
14
  Env.init({
@@ -20,6 +23,7 @@ async function importComposableAuth(authTokenProvider?: () => Promise<string | u
20
23
  sts: 'https://sts.dev1.vertesia.io',
21
24
  },
22
25
  authTokenProvider,
26
+ defaultAuthSelection,
23
27
  });
24
28
  return composableAuth;
25
29
  }
@@ -374,3 +378,21 @@ describe('resolveAuthSelection', () => {
374
378
  });
375
379
  });
376
380
  });
381
+
382
+ describe('configured workspace selection', () => {
383
+ afterEach(() => localStorage.clear());
384
+ it.each([
385
+ [{ accountId: 'env-a', projectId: 'env-p' }, '', { accountId: 'env-a', projectId: 'env-p' }],
386
+ [{ projectId: 'env-p' }, '', { accountId: undefined, projectId: 'env-p' }],
387
+ [{ accountId: 'env-a' }, '', { accountId: 'env-a', projectId: 'env-a-cached-p' }],
388
+ [{ accountId: 'env-a', projectId: 'env-p' }, '?p=url-p', { accountId: undefined, projectId: 'url-p' }],
389
+ [{ accountId: 'env-a', projectId: 'env-p' }, '?a=url-a', { accountId: 'url-a', projectId: undefined }],
390
+ [{ accountId: 'env-a', projectId: 'env-p' }, '?a=url-a&p=url-p', { accountId: 'url-a', projectId: 'url-p' }],
391
+ ] as const)('selects %j with URL %s', async (selection, query, expected) => {
392
+ localStorage.setItem('composableai.lastSelectedAccountId', 'old-a');
393
+ localStorage.setItem('composableai.lastSelectedProjectId-old-a', 'old-p');
394
+ localStorage.setItem('composableai.lastSelectedProjectId-env-a', 'env-a-cached-p');
395
+ const { resolveAuthSelection } = await importComposableAuth(undefined, selection);
396
+ expect(resolveAuthSelection(new URL(`https://app.example.test/${query}`))).toEqual(expected);
397
+ });
398
+ });