@vertesia/ui 1.5.0 → 1.6.0-dev.20260918.061604Z
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.
- package/README.md +125 -0
- package/lib/boot/branding.d.ts +58 -0
- package/lib/boot/branding.d.ts.map +1 -0
- package/lib/boot/branding.js +62 -0
- package/lib/boot/branding.js.map +1 -0
- package/lib/boot/index.d.ts +27 -0
- package/lib/boot/index.d.ts.map +1 -0
- package/lib/boot/index.js +325 -0
- package/lib/boot/index.js.map +1 -0
- package/lib/boot/vite.d.ts +63 -0
- package/lib/boot/vite.d.ts.map +1 -0
- package/lib/boot/vite.js +108 -0
- package/lib/boot/vite.js.map +1 -0
- package/lib/core/components/shadcn/button.d.ts.map +1 -1
- package/lib/core/components/shadcn/button.js +1 -2
- package/lib/core/components/shadcn/button.js.map +1 -1
- package/lib/core/components/shadcn/filters/filter-styles.d.ts +1 -1
- package/lib/core/components/shadcn/filters/filter-styles.d.ts.map +1 -1
- package/lib/core/components/shadcn/filters/filter-styles.js +4 -4
- package/lib/core/components/shadcn/label.js +1 -1
- package/lib/core/components/shadcn/label.js.map +1 -1
- package/lib/env/index.d.ts +32 -1
- package/lib/env/index.d.ts.map +1 -1
- package/lib/env/index.js +55 -3
- package/lib/env/index.js.map +1 -1
- package/lib/features/agent/PayloadBuilder.d.ts +4 -0
- package/lib/features/agent/PayloadBuilder.d.ts.map +1 -1
- package/lib/features/agent/PayloadBuilder.js +6 -1
- package/lib/features/agent/PayloadBuilder.js.map +1 -1
- package/lib/features/agent/chat/AgentRunFeedback.d.ts +66 -0
- package/lib/features/agent/chat/AgentRunFeedback.d.ts.map +1 -0
- package/lib/features/agent/chat/AgentRunFeedback.js +298 -0
- package/lib/features/agent/chat/AgentRunFeedback.js.map +1 -0
- package/lib/features/agent/chat/ModernAgentConversation.d.ts.map +1 -1
- package/lib/features/agent/chat/ModernAgentConversation.js +7 -5
- package/lib/features/agent/chat/ModernAgentConversation.js.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.d.ts +3 -1
- package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.d.ts.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.js +11 -10
- package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.js.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/Header.d.ts.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/Header.js.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/MessageItem.d.ts +3 -1
- package/lib/features/agent/chat/ModernAgentOutput/MessageItem.d.ts.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/MessageItem.js +28 -7
- package/lib/features/agent/chat/ModernAgentOutput/MessageItem.js.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/SummaryConversation.d.ts.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/SummaryConversation.js +2 -0
- package/lib/features/agent/chat/ModernAgentOutput/SummaryConversation.js.map +1 -1
- package/lib/features/agent/chat/hooks/useAgentPlans.d.ts +1 -1
- package/lib/features/agent/chat/hooks/useAgentPlans.d.ts.map +1 -1
- package/lib/features/agent/chat/hooks/useAgentPlans.js +5 -5
- package/lib/features/agent/chat/hooks/useAgentPlans.js.map +1 -1
- package/lib/features/agent/chat/index.d.ts +1 -0
- package/lib/features/agent/chat/index.d.ts.map +1 -1
- package/lib/features/agent/chat/index.js +1 -0
- package/lib/features/agent/chat/index.js.map +1 -1
- package/lib/features/facets/AgentRunnerFacetsNav.d.ts +8 -0
- package/lib/features/facets/AgentRunnerFacetsNav.d.ts.map +1 -1
- package/lib/features/facets/AgentRunnerFacetsNav.js +12 -0
- package/lib/features/facets/AgentRunnerFacetsNav.js.map +1 -1
- package/lib/features/permissions/UserPermissionsProvider.d.ts +15 -1
- package/lib/features/permissions/UserPermissionsProvider.d.ts.map +1 -1
- package/lib/features/permissions/UserPermissionsProvider.js +20 -12
- package/lib/features/permissions/UserPermissionsProvider.js.map +1 -1
- package/lib/features/store/objects/search/DocumentSearchContext.d.ts.map +1 -1
- package/lib/features/store/objects/search/DocumentSearchContext.js +6 -1
- package/lib/features/store/objects/search/DocumentSearchContext.js.map +1 -1
- package/lib/features/store/objects/selection/SelectionActions.js +1 -1
- package/lib/features/store/objects/selection/SelectionActions.js.map +1 -1
- package/lib/features/store/types/TypeRegistry.d.ts +130 -0
- package/lib/features/store/types/TypeRegistry.d.ts.map +1 -1
- package/lib/features/store/types/search/ObjectTypeSearchContext.d.ts +130 -0
- package/lib/features/store/types/search/ObjectTypeSearchContext.d.ts.map +1 -1
- package/lib/i18n/index.d.ts +5 -4
- package/lib/i18n/index.d.ts.map +1 -1
- package/lib/i18n/index.js +16 -2
- package/lib/i18n/index.js.map +1 -1
- package/lib/i18n/locales/ar.json +30 -0
- package/lib/i18n/locales/de.json +30 -0
- package/lib/i18n/locales/en.json +30 -0
- package/lib/i18n/locales/es.json +30 -0
- package/lib/i18n/locales/fr.json +30 -0
- package/lib/i18n/locales/it.json +30 -0
- package/lib/i18n/locales/ja.json +30 -0
- package/lib/i18n/locales/ko.json +30 -0
- package/lib/i18n/locales/pt.json +30 -0
- package/lib/i18n/locales/ru.json +30 -0
- package/lib/i18n/locales/tr.json +30 -0
- package/lib/i18n/locales/zh-TW.json +30 -0
- package/lib/i18n/locales/zh.json +30 -0
- package/lib/layout/ErrorPage.js +1 -1
- package/lib/layout/ErrorPage.js.map +1 -1
- package/lib/layout/Navbar.d.ts.map +1 -1
- package/lib/layout/Navbar.js +3 -2
- package/lib/layout/Navbar.js.map +1 -1
- package/lib/session/UserSession.d.ts.map +1 -1
- package/lib/session/UserSession.js +2 -3
- package/lib/session/UserSession.js.map +1 -1
- package/lib/session/UserSessionProvider.d.ts +2 -1
- package/lib/session/UserSessionProvider.d.ts.map +1 -1
- package/lib/session/UserSessionProvider.js +13 -4
- package/lib/session/UserSessionProvider.js.map +1 -1
- package/lib/session/auth/composable.d.ts.map +1 -1
- package/lib/session/auth/composable.js +88 -3
- package/lib/session/auth/composable.js.map +1 -1
- package/lib/session/auth/domainRouting.d.ts +22 -12
- package/lib/session/auth/domainRouting.d.ts.map +1 -1
- package/lib/session/auth/domainRouting.js +35 -21
- package/lib/session/auth/domainRouting.js.map +1 -1
- package/lib/session/auth/firebase.d.ts.map +1 -1
- package/lib/session/auth/firebase.js +10 -0
- package/lib/session/auth/firebase.js.map +1 -1
- package/lib/session/auth/useAuthState.d.ts +9 -4
- package/lib/session/auth/useAuthState.d.ts.map +1 -1
- package/lib/session/auth/useAuthState.js +9 -4
- package/lib/session/auth/useAuthState.js.map +1 -1
- package/lib/session/index.d.ts.map +1 -1
- package/lib/session/index.js +3 -1
- package/lib/session/index.js.map +1 -1
- package/lib/shell/AuthScreenPreview.d.ts +12 -0
- package/lib/shell/AuthScreenPreview.d.ts.map +1 -0
- package/lib/shell/AuthScreenPreview.js +140 -0
- package/lib/shell/AuthScreenPreview.js.map +1 -0
- package/lib/shell/BrandedAuthScreens.d.ts +19 -0
- package/lib/shell/BrandedAuthScreens.d.ts.map +1 -0
- package/lib/shell/BrandedAuthScreens.js +64 -0
- package/lib/shell/BrandedAuthScreens.js.map +1 -0
- package/lib/shell/SplashScreen.d.ts +13 -2
- package/lib/shell/SplashScreen.d.ts.map +1 -1
- package/lib/shell/SplashScreen.js +20 -19
- package/lib/shell/SplashScreen.js.map +1 -1
- package/lib/shell/VertesiaShell.d.ts +15 -4
- package/lib/shell/VertesiaShell.d.ts.map +1 -1
- package/lib/shell/VertesiaShell.js +8 -3
- package/lib/shell/VertesiaShell.js.map +1 -1
- package/lib/shell/apps/StandaloneApp.js +5 -1
- package/lib/shell/apps/StandaloneApp.js.map +1 -1
- package/lib/shell/index.d.ts +6 -0
- package/lib/shell/index.d.ts.map +1 -1
- package/lib/shell/index.js +5 -0
- package/lib/shell/index.js.map +1 -1
- package/lib/shell/login/SignInAuthPending.js +1 -1
- package/lib/shell/login/SignInAuthPending.js.map +1 -1
- package/lib/shell/login/SignInEmailStep.d.ts.map +1 -1
- package/lib/shell/login/SignInEmailStep.js +2 -2
- package/lib/shell/login/SignInEmailStep.js.map +1 -1
- package/lib/shell/login/SignInFlow.d.ts +68 -0
- package/lib/shell/login/SignInFlow.d.ts.map +1 -0
- package/lib/shell/login/SignInFlow.js +108 -0
- package/lib/shell/login/SignInFlow.js.map +1 -0
- package/lib/shell/login/SignInIcons.d.ts +1 -1
- package/lib/shell/login/SignInIcons.d.ts.map +1 -1
- package/lib/shell/login/SignInPageShell.d.ts +13 -0
- package/lib/shell/login/SignInPageShell.d.ts.map +1 -0
- package/lib/shell/login/SignInPageShell.js +14 -0
- package/lib/shell/login/SignInPageShell.js.map +1 -0
- package/lib/shell/login/SignInPrimitives.d.ts +3 -3
- package/lib/shell/login/SignInPrimitives.d.ts.map +1 -1
- package/lib/shell/login/SignInPrimitives.js +13 -11
- package/lib/shell/login/SignInPrimitives.js.map +1 -1
- package/lib/shell/login/SignInRestrictedEnvStep.d.ts +5 -0
- package/lib/shell/login/SignInRestrictedEnvStep.d.ts.map +1 -1
- package/lib/shell/login/SignInRestrictedEnvStep.js +8 -4
- package/lib/shell/login/SignInRestrictedEnvStep.js.map +1 -1
- package/lib/shell/login/SigninScreen.d.ts +38 -3
- package/lib/shell/login/SigninScreen.d.ts.map +1 -1
- package/lib/shell/login/SigninScreen.js +33 -86
- package/lib/shell/login/SigninScreen.js.map +1 -1
- package/lib/shell/login/SignupForm.d.ts +3 -1
- package/lib/shell/login/SignupForm.d.ts.map +1 -1
- package/lib/shell/login/SignupForm.js +7 -3
- package/lib/shell/login/SignupForm.js.map +1 -1
- package/lib/shell/login/signInUtils.d.ts +12 -1
- package/lib/shell/login/signInUtils.d.ts.map +1 -1
- package/lib/shell/login/signInUtils.js +34 -11
- package/lib/shell/login/signInUtils.js.map +1 -1
- package/lib/vertesia-ui-boot.js +237 -0
- package/lib/vertesia-ui-boot.js.map +1 -0
- package/lib/vertesia-ui-core.js +88 -46
- package/lib/vertesia-ui-core.js.map +1 -1
- package/lib/vertesia-ui-env.js +1 -1
- package/lib/vertesia-ui-env.js.map +1 -1
- package/lib/vertesia-ui-features.js +350 -317
- package/lib/vertesia-ui-features.js.map +1 -1
- package/lib/vertesia-ui-i18n.B3NFclwl.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.B3NFclwl.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.BWSrNIxc.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.BWSrNIxc.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.BWvWeB1p.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.BWvWeB1p.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.BZCXvTC1.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.BZCXvTC1.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.BbJMvFZW.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.BbJMvFZW.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.CE5ldwpt.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.CE5ldwpt.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.CMC0IShY.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.CMC0IShY.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.CkcRqBKU.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.CkcRqBKU.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.D-Mq1sw_.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.D-Mq1sw_.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.DkK68-A-.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.DkK68-A-.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.XE_dcCg6.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.XE_dcCg6.chunk.js.map +1 -0
- package/lib/vertesia-ui-i18n.js +2 -2
- package/lib/vertesia-ui-i18n.js.map +1 -1
- package/lib/vertesia-ui-i18n.qgNqMPvK.chunk.js +5 -0
- package/lib/vertesia-ui-i18n.qgNqMPvK.chunk.js.map +1 -0
- package/lib/vertesia-ui-layout.js +43 -19
- package/lib/vertesia-ui-layout.js.map +1 -1
- package/lib/vertesia-ui-rich-text.js +123 -74
- package/lib/vertesia-ui-rich-text.js.map +1 -1
- package/lib/vertesia-ui-router.js.map +1 -1
- package/lib/vertesia-ui-session.js +1 -1
- package/lib/vertesia-ui-session.js.map +1 -1
- package/lib/vertesia-ui-shell.js +87 -33
- package/lib/vertesia-ui-shell.js.map +1 -1
- package/lib/vertesia-ui-widgets.js +154 -98
- package/lib/vertesia-ui-widgets.js.map +1 -1
- package/lib/widgets/xml-viewer/hooks/useCollapsible.d.ts +1 -1
- package/llms.txt +6 -2
- package/package.json +45 -36
- package/src/__tests__/a11y.test.tsx +25 -0
- package/src/__tests__/theme.test.ts +91 -0
- package/src/boot/branding.test.ts +72 -0
- package/src/boot/branding.ts +108 -0
- package/src/boot/index.test.ts +216 -0
- package/src/boot/index.ts +356 -0
- package/src/boot/vite.test.ts +93 -0
- package/src/boot/vite.ts +118 -0
- package/src/core/components/shadcn/button.tsx +1 -2
- package/src/core/components/shadcn/filters/filter-styles.ts +4 -4
- package/src/core/components/shadcn/label.tsx +1 -1
- package/src/env/index.test.ts +127 -0
- package/src/env/index.ts +73 -3
- package/src/features/agent/PayloadBuilder.tsx +10 -1
- package/src/features/agent/chat/AgentRunFeedback.test.tsx +347 -0
- package/src/features/agent/chat/AgentRunFeedback.tsx +465 -0
- package/src/features/agent/chat/ModernAgentConversation.test.tsx +1 -1
- package/src/features/agent/chat/ModernAgentConversation.tsx +52 -47
- package/src/features/agent/chat/ModernAgentOutput/AllMessagesMixed.test.tsx +145 -0
- package/src/features/agent/chat/ModernAgentOutput/AllMessagesMixed.tsx +42 -24
- package/src/features/agent/chat/ModernAgentOutput/Header.tsx +4 -0
- package/src/features/agent/chat/ModernAgentOutput/MessageItem.tsx +38 -5
- package/src/features/agent/chat/ModernAgentOutput/SummaryConversation.ts +2 -0
- package/src/features/agent/chat/ModernAgentOutput/utils.test.ts +14 -0
- package/src/features/agent/chat/hooks/useAgentPlans.test.ts +52 -0
- package/src/features/agent/chat/hooks/useAgentPlans.ts +5 -5
- package/src/features/agent/chat/hooks/useFileProcessing.test.tsx +0 -5
- package/src/features/agent/chat/index.ts +6 -0
- package/src/features/facets/AgentRunnerFacetsNav.tsx +26 -0
- package/src/features/permissions/UserPermissionsProvider.test.tsx +50 -1
- package/src/features/permissions/UserPermissionsProvider.tsx +82 -44
- package/src/features/store/objects/search/DocumentSearchContext.ts +8 -3
- package/src/features/store/objects/selection/SelectionActions.tsx +4 -1
- package/src/i18n/index.tsx +25 -2
- package/src/i18n/locales/ar.json +30 -0
- package/src/i18n/locales/de.json +30 -0
- package/src/i18n/locales/en.json +30 -0
- package/src/i18n/locales/es.json +30 -0
- package/src/i18n/locales/fr.json +30 -0
- package/src/i18n/locales/it.json +30 -0
- package/src/i18n/locales/ja.json +30 -0
- package/src/i18n/locales/ko.json +30 -0
- package/src/i18n/locales/pt.json +30 -0
- package/src/i18n/locales/ru.json +30 -0
- package/src/i18n/locales/tr.json +30 -0
- package/src/i18n/locales/zh-TW.json +30 -0
- package/src/i18n/locales/zh.json +30 -0
- package/src/layout/ErrorPage.tsx +1 -1
- package/src/layout/Navbar.test.tsx +58 -0
- package/src/layout/Navbar.tsx +5 -2
- package/src/router/path.test.ts +5 -5
- package/src/session/UserSession.ts +2 -4
- package/src/session/UserSessionProvider.strict-mode.test.tsx +44 -0
- package/src/session/UserSessionProvider.test.ts +62 -1
- package/src/session/UserSessionProvider.tsx +12 -4
- package/src/session/auth/composable.renewal.test.ts +170 -0
- package/src/session/auth/composable.test.ts +23 -1
- package/src/session/auth/composable.ts +101 -2
- package/src/session/auth/domainRouting.test.ts +35 -65
- package/src/session/auth/domainRouting.ts +51 -34
- package/src/session/auth/firebase.test.ts +25 -0
- package/src/session/auth/firebase.ts +10 -0
- package/src/session/auth/useAuthState.ts +11 -4
- package/src/session/index.ts +3 -1
- package/src/shell/AuthScreenPreview.test.tsx +44 -0
- package/src/shell/AuthScreenPreview.tsx +228 -0
- package/src/shell/BrandedAuthScreens.test.tsx +181 -0
- package/src/shell/BrandedAuthScreens.tsx +117 -0
- package/src/shell/SplashScreen.test.tsx +37 -0
- package/src/shell/SplashScreen.tsx +44 -39
- package/src/shell/VertesiaShell.tsx +49 -22
- package/src/shell/apps/StandaloneApp.tsx +5 -1
- package/src/shell/index.tsx +6 -0
- package/src/shell/login/SignInAuthPending.tsx +3 -3
- package/src/shell/login/SignInEmailStep.test.tsx +32 -0
- package/src/shell/login/SignInEmailStep.tsx +13 -9
- package/src/shell/login/SignInFlow.tsx +213 -0
- package/src/shell/login/SignInPageShell.tsx +62 -0
- package/src/shell/login/SignInPrimitives.tsx +14 -11
- package/src/shell/login/SignInRestrictedEnvStep.tsx +19 -7
- package/src/shell/login/SigninScreen.test.tsx +104 -0
- package/src/shell/login/SigninScreen.tsx +115 -145
- package/src/shell/login/SignupForm.tsx +8 -3
- package/src/shell/login/signInUtils.ts +42 -9
- package/src/widgets/markdown/CollaborativeMarkdownRenderer.test.tsx +11 -3
- package/src/widgets/markdown/markdown.css +21 -21
- package/lib/vertesia-ui-i18n.BfN7iOHb.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.BfN7iOHb.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.BjH2zEZw.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.BjH2zEZw.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.BjSwAZXl.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.BjSwAZXl.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.C7PUm5Au.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.C7PUm5Au.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.CF_1mUS5.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.CF_1mUS5.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.ChfUl3_-.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.ChfUl3_-.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.D-Sk-5d1.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.D-Sk-5d1.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.D4rnu1_S.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.D4rnu1_S.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.DDf0-laK.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.DDf0-laK.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.DN79y06J.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.DN79y06J.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.DWLIciYQ.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.DWLIciYQ.chunk.js.map +0 -1
- package/lib/vertesia-ui-i18n.DvF_YaAJ.chunk.js +0 -5
- package/lib/vertesia-ui-i18n.DvF_YaAJ.chunk.js.map +0 -1
- package/src/session/auth/authState.test.ts +0 -35
|
@@ -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(
|
|
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
|
+
});
|
|
@@ -10,11 +10,86 @@ import {
|
|
|
10
10
|
import { Env } from '@vertesia/ui/env';
|
|
11
11
|
import { jwtDecode } from 'jwt-decode';
|
|
12
12
|
import { LastSelectedAccountId_KEY, LastSelectedProjectId_KEY } from '../constants';
|
|
13
|
+
import { generateAuthState } from './authState';
|
|
14
|
+
import {
|
|
15
|
+
authReturnUrl,
|
|
16
|
+
buildCentralAuthRedirectUrl,
|
|
17
|
+
centralAuthUrl,
|
|
18
|
+
shouldRedirectToCentralAuth,
|
|
19
|
+
} from './domainRouting';
|
|
13
20
|
import { getFirebaseAuth, getFirebaseAuthToken } from './firebase';
|
|
14
21
|
|
|
15
22
|
let AUTH_TOKEN_RAW: string | undefined;
|
|
16
23
|
let AUTH_TOKEN: AuthTokenPayload | undefined;
|
|
17
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Whether this tab ever held a token.
|
|
27
|
+
*
|
|
28
|
+
* Separates "the session expired" from "there is no session yet". Only the first is renewed from
|
|
29
|
+
* here; the first load is UserSessionProvider's, and it has the account/project selection that
|
|
30
|
+
* this layer does not.
|
|
31
|
+
*/
|
|
32
|
+
let HAD_SESSION = false;
|
|
33
|
+
|
|
34
|
+
/** Session-storage stamp of the last renewal redirect, so the cooldown survives the navigation. */
|
|
35
|
+
const RENEWAL_ATTEMPT_KEY = 'vt.centralAuthRenewalAt';
|
|
36
|
+
const RENEWAL_COOLDOWN_MS = 60_000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Renew an expired Central Auth session by bouncing through the broker.
|
|
40
|
+
*
|
|
41
|
+
* A Central Auth session holds exactly one credential -- the STS JWT, in memory -- and STS refuses
|
|
42
|
+
* it once it has expired. There is no Firebase user in this browser to mint a replacement from and
|
|
43
|
+
* no refresh token, so the only way back is the broker, which still has the user's own session and
|
|
44
|
+
* returns a fresh identity token without prompting. That is precisely what a manual reload does
|
|
45
|
+
* today; doing it here turns a page that dead-ends on "Cannot acquire a composable token" into a
|
|
46
|
+
* redirect the user usually does not notice.
|
|
47
|
+
*
|
|
48
|
+
* The cooldown is the important part. If the broker returns a credential STS also rejects, an
|
|
49
|
+
* unguarded redirect bounces the browser between the two forever -- and a redirect loop is a worse
|
|
50
|
+
* failure than the error page it replaced, because it never comes to rest anywhere the user can
|
|
51
|
+
* read. One attempt per minute, recorded in sessionStorage because the module state does not
|
|
52
|
+
* survive the navigation this function starts.
|
|
53
|
+
*
|
|
54
|
+
* Returns whether a redirect was started.
|
|
55
|
+
*/
|
|
56
|
+
function renewExpiredCentralAuthSession(): boolean {
|
|
57
|
+
if (!HAD_SESSION || !shouldRedirectToCentralAuth()) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let lastAttempt = 0;
|
|
62
|
+
try {
|
|
63
|
+
lastAttempt = Number(sessionStorage.getItem(RENEWAL_ATTEMPT_KEY) ?? '0');
|
|
64
|
+
} catch {
|
|
65
|
+
// sessionStorage unavailable -- treated as "no previous attempt", so the redirect is
|
|
66
|
+
// allowed once and the failure surfaces normally if it does not stick.
|
|
67
|
+
}
|
|
68
|
+
if (Number.isFinite(lastAttempt) && lastAttempt > 0 && Date.now() - lastAttempt < RENEWAL_COOLDOWN_MS) {
|
|
69
|
+
Env.logger.warn('Central Auth session renewal already attempted; surfacing the failure instead', {
|
|
70
|
+
vertesia: { last_attempt_ms_ago: Date.now() - lastAttempt },
|
|
71
|
+
});
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
sessionStorage.setItem(RENEWAL_ATTEMPT_KEY, String(Date.now()));
|
|
77
|
+
} catch {
|
|
78
|
+
// Without the stamp the cooldown cannot be enforced across the navigation. Still worth
|
|
79
|
+
// redirecting once: the alternative is a page the user can only fix by reloading manually.
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
Env.logger.info('Renewing an expired Central Auth session through the broker');
|
|
83
|
+
const url = buildCentralAuthRedirectUrl(
|
|
84
|
+
centralAuthUrl(),
|
|
85
|
+
Env.endpoints.sts ?? 'https://sts.vertesia.io',
|
|
86
|
+
authReturnUrl(),
|
|
87
|
+
generateAuthState(),
|
|
88
|
+
);
|
|
89
|
+
location.replace(url.toString());
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
18
93
|
function clearRejectedPersistedScope(accountId?: string, projectId?: string) {
|
|
19
94
|
if (!accountId) return;
|
|
20
95
|
|
|
@@ -62,8 +137,11 @@ function identityFromAcceptedToken(token: string): AuthenticatedIdentity | undef
|
|
|
62
137
|
}
|
|
63
138
|
|
|
64
139
|
export function resolveAuthSelection(currentUrl: URL): { accountId?: string; projectId?: string } {
|
|
65
|
-
|
|
66
|
-
const
|
|
140
|
+
// A URL selection owns the whole pair: never attach an unrelated configured account/project.
|
|
141
|
+
const hasUrlScope = currentUrl.searchParams.has('a') || currentUrl.searchParams.has('p');
|
|
142
|
+
const defaults = hasUrlScope ? undefined : Env.defaultAuthSelection;
|
|
143
|
+
const urlAccount = currentUrl.searchParams.get('a') ?? defaults?.accountId;
|
|
144
|
+
const urlProject = currentUrl.searchParams.get('p') ?? defaults?.projectId;
|
|
67
145
|
const accountId =
|
|
68
146
|
urlAccount ??
|
|
69
147
|
(urlProject === undefined ? (localStorage.getItem(LastSelectedAccountId_KEY) ?? undefined) : undefined);
|
|
@@ -434,6 +512,7 @@ export async function getComposableToken(
|
|
|
434
512
|
cachedTokenMatchesScope &&
|
|
435
513
|
AUTH_TOKEN.exp > Date.now() / 1000 + 300
|
|
436
514
|
) {
|
|
515
|
+
HAD_SESSION = true;
|
|
437
516
|
return { rawToken: AUTH_TOKEN_RAW, token: AUTH_TOKEN, error: false };
|
|
438
517
|
}
|
|
439
518
|
|
|
@@ -447,6 +526,7 @@ export async function getComposableToken(
|
|
|
447
526
|
if (!AUTH_TOKEN.exp) {
|
|
448
527
|
throw new Error('Invalid composable token');
|
|
449
528
|
}
|
|
529
|
+
HAD_SESSION = true;
|
|
450
530
|
return { rawToken: AUTH_TOKEN_RAW, token: AUTH_TOKEN, error: false };
|
|
451
531
|
}
|
|
452
532
|
|
|
@@ -458,7 +538,14 @@ export async function getComposableToken(
|
|
|
458
538
|
} else if (!devAuthToken) {
|
|
459
539
|
// Embedded apps can reacquire a fresh credential from their host after their cached token expires.
|
|
460
540
|
const refreshCredential = (await Env.authTokenProvider?.()) ?? initToken ?? AUTH_TOKEN_RAW;
|
|
541
|
+
// `forceRefresh` has to defeat this shortcut, not just the cache above it. A caller
|
|
542
|
+
// asking for a forced refresh wants claims recomputed -- `refreshAuthToken()` exists so
|
|
543
|
+
// a stale `apps` claim can be re-read after an ACE change, and STS recomputes it on
|
|
544
|
+
// every issuance. Adopting the credential we already hold satisfies the check and
|
|
545
|
+
// returns the very token whose claims were suspect, silently making the call a no-op
|
|
546
|
+
// for exactly the sessions that have no other credential to fall back on.
|
|
461
547
|
if (
|
|
548
|
+
!forceRefresh &&
|
|
462
549
|
refreshCredential &&
|
|
463
550
|
isVertesiaIssuedToken(refreshCredential) &&
|
|
464
551
|
canUseVertesiaTokenDirectly(refreshCredential, selectedAccount, selectedProject)
|
|
@@ -488,6 +575,13 @@ export async function getComposableToken(
|
|
|
488
575
|
clearRejectedPersistedScope(selectedAccount, selectedProject);
|
|
489
576
|
}
|
|
490
577
|
}
|
|
578
|
+
// An expired Central Auth session presents as a rejected credential: the JWT we sent STS to
|
|
579
|
+
// renew from is the one that timed out. The broker can still vouch for the user. Only this
|
|
580
|
+
// error -- a scope or account failure is about who the user is, and the broker would hand
|
|
581
|
+
// back the same identity and the same refusal.
|
|
582
|
+
if (error instanceof CredentialError) {
|
|
583
|
+
renewExpiredCentralAuthSession();
|
|
584
|
+
}
|
|
491
585
|
throw error;
|
|
492
586
|
}
|
|
493
587
|
|
|
@@ -498,6 +592,10 @@ export async function getComposableToken(
|
|
|
498
592
|
project_id: selectedProject,
|
|
499
593
|
},
|
|
500
594
|
});
|
|
595
|
+
// Reached once the credential has already been discarded -- typically the call after the
|
|
596
|
+
// one that saw STS reject it. Same remedy, and the cooldown keeps this from redirecting a
|
|
597
|
+
// second time for the same expiry.
|
|
598
|
+
renewExpiredCentralAuthSession();
|
|
501
599
|
throw new Error('Cannot acquire a composable token');
|
|
502
600
|
}
|
|
503
601
|
|
|
@@ -514,6 +612,7 @@ export async function getComposableToken(
|
|
|
514
612
|
throw new Error('Invalid composable token');
|
|
515
613
|
}
|
|
516
614
|
|
|
615
|
+
HAD_SESSION = true;
|
|
517
616
|
return { rawToken: AUTH_TOKEN_RAW, token: AUTH_TOKEN, error: false };
|
|
518
617
|
}
|
|
519
618
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { Env } from '@vertesia/ui/env';
|
|
2
|
-
import { afterEach, describe, expect, it
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
centralAuthUrl,
|
|
5
5
|
clearCentralAuthRoundTripMarker,
|
|
6
|
+
DEFAULT_CENTRAL_AUTH_URL,
|
|
6
7
|
isCentralAuthRedirectPending,
|
|
7
8
|
markCentralAuthRoundTripStarted,
|
|
8
9
|
mountRootUrl,
|
|
9
|
-
redirectToCentralAuth,
|
|
10
10
|
shouldRedirectToCentralAuth,
|
|
11
11
|
shouldUseFirebaseAuth,
|
|
12
12
|
} from './domainRouting';
|
|
@@ -40,24 +40,6 @@ describe('domainRouting', () => {
|
|
|
40
40
|
expect(shouldRedirectToCentralAuth()).toBe(true);
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
it('carries the selected account and project through central auth for a gateway-mounted app', () => {
|
|
44
|
-
const redirect = centralAuthRedirectUrl({
|
|
45
|
-
centralAuthUrl: 'https://internal-auth.vertesia.app/',
|
|
46
|
-
stsEndpoint: 'https://sts.example.test',
|
|
47
|
-
returnUrl: new URL('https://apps.example.test/tenants/t/apps/a/versions/v/app/?view=grid'),
|
|
48
|
-
state: 'state-1',
|
|
49
|
-
accountId: 'account-1',
|
|
50
|
-
projectId: 'project-1',
|
|
51
|
-
});
|
|
52
|
-
const returnUrl = new URL(redirect.searchParams.get('redirect_uri') || '');
|
|
53
|
-
|
|
54
|
-
expect(redirect.searchParams.get('sts')).toBe('https://sts.example.test');
|
|
55
|
-
expect(redirect.searchParams.get('state')).toBe('state-1');
|
|
56
|
-
expect(returnUrl.toString()).toBe(
|
|
57
|
-
'https://apps.example.test/tenants/t/apps/a/versions/v/app/?view=grid&p=project-1&a=account-1',
|
|
58
|
-
);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
43
|
describe('mountRootUrl', () => {
|
|
62
44
|
it('returns the deep gateway mount root (dropping any deep route / query / hash)', () => {
|
|
63
45
|
const mount = 'https://gw.example.com/tenants/05948c_5ed5f4/apps/furniture-catalog/versions/v1/app/';
|
|
@@ -80,6 +62,37 @@ describe('domainRouting', () => {
|
|
|
80
62
|
});
|
|
81
63
|
});
|
|
82
64
|
|
|
65
|
+
describe('centralAuthUrl', () => {
|
|
66
|
+
function initEnv(auth?: string) {
|
|
67
|
+
Env.init({
|
|
68
|
+
name: 'test',
|
|
69
|
+
version: '0',
|
|
70
|
+
isLocalDev: true,
|
|
71
|
+
isDocker: false,
|
|
72
|
+
type: 'development',
|
|
73
|
+
endpoints: { studio: 'https://studio.test', zeno: 'https://zeno.test', sts: 'https://sts.test', auth },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
it('falls back to the default broker when no auth endpoint is configured', () => {
|
|
78
|
+
initEnv(undefined);
|
|
79
|
+
expect(centralAuthUrl()).toBe(DEFAULT_CENTRAL_AUTH_URL);
|
|
80
|
+
expect(DEFAULT_CENTRAL_AUTH_URL).toBe('https://internal-auth.vertesia.app/');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('uses the configured auth endpoint when one is set', () => {
|
|
84
|
+
initEnv('https://auth.vertesia.io/');
|
|
85
|
+
expect(centralAuthUrl()).toBe('https://auth.vertesia.io/');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// An empty string is what an unset VITE_* override collapses to; it must not become the broker
|
|
89
|
+
// URL, or the app would redirect to its own origin instead of a login page.
|
|
90
|
+
it('treats an empty auth endpoint as unconfigured', () => {
|
|
91
|
+
initEnv('');
|
|
92
|
+
expect(centralAuthUrl()).toBe(DEFAULT_CENTRAL_AUTH_URL);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
83
96
|
// The boot-time counterpart of the redirect UserSessionProvider performs after mounting. It must
|
|
84
97
|
// answer true only when that provider is certain to redirect, because an app's entry module skips
|
|
85
98
|
// the whole React tree on the strength of it -- and false whenever the answer is not certain, which
|
|
@@ -116,7 +129,7 @@ describe('isCentralAuthRedirectPending', () => {
|
|
|
116
129
|
expect(isCentralAuthRedirectPending()).toBe(true);
|
|
117
130
|
});
|
|
118
131
|
|
|
119
|
-
it('is false
|
|
132
|
+
it('is false on a Firebase-allowlisted host, which signs in without the broker', () => {
|
|
120
133
|
initEnv({});
|
|
121
134
|
(globalThis as { window?: unknown }).window = { AUTH_MODE: 'firebase', location: { hash: '' } };
|
|
122
135
|
expect(isCentralAuthRedirectPending()).toBe(false);
|
|
@@ -199,46 +212,3 @@ describe('central auth round-trip marker', () => {
|
|
|
199
212
|
expect(document.cookie).toBe('vtsauth=; Path=/; Max-Age=0; SameSite=Lax; Secure');
|
|
200
213
|
});
|
|
201
214
|
});
|
|
202
|
-
|
|
203
|
-
// The shared redirect must preserve release/1.5's gateway selection and write state that
|
|
204
|
-
// the provider's hook accepts when the broker returns.
|
|
205
|
-
describe('redirectToCentralAuth', () => {
|
|
206
|
-
afterEach(() => {
|
|
207
|
-
vi.unstubAllGlobals();
|
|
208
|
-
sessionStorage.clear();
|
|
209
|
-
Env.init();
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it('keeps the gateway mount and selection, records state, and marks the round trip before navigating', async () => {
|
|
213
|
-
const { verifyAuthState, clearAuthState } = await import('./authState');
|
|
214
|
-
const mount = 'https://apps.test/tenants/t/apps/a/versions/v/app/';
|
|
215
|
-
const document = { baseURI: mount, cookie: '' };
|
|
216
|
-
const replace = vi.fn((target: string) => {
|
|
217
|
-
const url = new URL(target);
|
|
218
|
-
expect(document.cookie).toBe('vtsauth=1; Path=/; Max-Age=300; SameSite=Lax; Secure');
|
|
219
|
-
expect(verifyAuthState(url.searchParams.get('state'))).toBeUndefined();
|
|
220
|
-
});
|
|
221
|
-
vi.stubGlobal('window', { location: { href: 'https://apps.test/?view=grid#old', protocol: 'https:' } });
|
|
222
|
-
vi.stubGlobal('document', document);
|
|
223
|
-
vi.stubGlobal('location', { replace });
|
|
224
|
-
Env.init({
|
|
225
|
-
name: 'test',
|
|
226
|
-
version: '0',
|
|
227
|
-
isLocalDev: false,
|
|
228
|
-
isDocker: false,
|
|
229
|
-
type: 'production',
|
|
230
|
-
endpoints: { studio: 'https://studio.test', zeno: 'https://zeno.test', sts: 'https://sts.test/?x=1&y=2' },
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
redirectToCentralAuth({ accountId: 'account-1', projectId: 'project-1' });
|
|
234
|
-
|
|
235
|
-
expect(replace).toHaveBeenCalledOnce();
|
|
236
|
-
const url = new URL(replace.mock.calls[0][0]);
|
|
237
|
-
expect(url.origin).toBe('https://internal-auth.vertesia.app');
|
|
238
|
-
expect(url.searchParams.get('sts')).toBe('https://sts.test/?x=1&y=2');
|
|
239
|
-
expect(url.searchParams.get('redirect_uri')).toBe(`${mount}?p=project-1&a=account-1`);
|
|
240
|
-
expect(verifyAuthState('wrong')).toContain('State mismatched');
|
|
241
|
-
clearAuthState();
|
|
242
|
-
expect(verifyAuthState(url.searchParams.get('state'))).toContain('State mismatched');
|
|
243
|
-
});
|
|
244
|
-
});
|
|
@@ -7,6 +7,20 @@ declare global {
|
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The broker this app sends users to for sign-in and logout.
|
|
12
|
+
*
|
|
13
|
+
* Hard-coded until now, which meant every consumer of this package reached the same deployment no
|
|
14
|
+
* matter what was running. It is read from the environment so a single app can be pointed at a
|
|
15
|
+
* different broker, and it keeps this default so an app that configures nothing is unaffected.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_CENTRAL_AUTH_URL = 'https://internal-auth.vertesia.app/';
|
|
18
|
+
|
|
19
|
+
export function centralAuthUrl(): string {
|
|
20
|
+
const configured = Env.endpoints.auth;
|
|
21
|
+
return configured ? configured : DEFAULT_CENTRAL_AUTH_URL;
|
|
22
|
+
}
|
|
23
|
+
|
|
10
24
|
export function shouldUseFirebaseAuth(_hostname?: string) {
|
|
11
25
|
return window.AUTH_MODE === 'firebase';
|
|
12
26
|
}
|
|
@@ -15,35 +29,11 @@ export function shouldRedirectToCentralAuth(_hostname?: string) {
|
|
|
15
29
|
return !shouldUseFirebaseAuth();
|
|
16
30
|
}
|
|
17
31
|
|
|
18
|
-
interface
|
|
19
|
-
centralAuthUrl: string;
|
|
20
|
-
stsEndpoint: string;
|
|
21
|
-
returnUrl: URL;
|
|
22
|
-
state: string;
|
|
32
|
+
interface AuthSelection {
|
|
23
33
|
accountId?: string;
|
|
24
34
|
projectId?: string;
|
|
25
35
|
}
|
|
26
36
|
|
|
27
|
-
/** Build a central-auth round trip without losing the app's requested account/project selection. */
|
|
28
|
-
export function centralAuthRedirectUrl({
|
|
29
|
-
centralAuthUrl,
|
|
30
|
-
stsEndpoint,
|
|
31
|
-
returnUrl,
|
|
32
|
-
state,
|
|
33
|
-
accountId,
|
|
34
|
-
projectId,
|
|
35
|
-
}: CentralAuthRedirectOptions): URL {
|
|
36
|
-
const selectedReturnUrl = new URL(returnUrl);
|
|
37
|
-
if (projectId) selectedReturnUrl.searchParams.set('p', projectId);
|
|
38
|
-
if (accountId) selectedReturnUrl.searchParams.set('a', accountId);
|
|
39
|
-
|
|
40
|
-
const url = new URL(centralAuthUrl);
|
|
41
|
-
url.searchParams.set('sts', stsEndpoint);
|
|
42
|
-
url.searchParams.set('redirect_uri', selectedReturnUrl.toString());
|
|
43
|
-
url.searchParams.set('state', state);
|
|
44
|
-
return url;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
37
|
/**
|
|
48
38
|
* The page URL to return to after a central-auth round-trip (`redirect_uri`).
|
|
49
39
|
*
|
|
@@ -82,6 +72,33 @@ export function mountRootUrl(): URL {
|
|
|
82
72
|
return url;
|
|
83
73
|
}
|
|
84
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The broker URL a sign-in or renewal round-trip navigates to.
|
|
77
|
+
*
|
|
78
|
+
* Every parameter goes on through `searchParams`, never by concatenating a query string onto
|
|
79
|
+
* `centralAuth`. The endpoint is configurable, so it may already carry its own query or a
|
|
80
|
+
* fragment -- and appending `?sts=...` to one of those folds the parameter into the existing value
|
|
81
|
+
* or hides it in the fragment, leaving Central Auth with no `sts` at all. `searchParams` also
|
|
82
|
+
* percent-encodes the values, which the interpolated URLs did not.
|
|
83
|
+
*/
|
|
84
|
+
export function buildCentralAuthRedirectUrl(
|
|
85
|
+
centralAuth: string,
|
|
86
|
+
stsUrl: string,
|
|
87
|
+
returnUrl: URL,
|
|
88
|
+
state: string,
|
|
89
|
+
selection: AuthSelection = {},
|
|
90
|
+
): URL {
|
|
91
|
+
const selectedReturnUrl = new URL(returnUrl);
|
|
92
|
+
if (selection.projectId) selectedReturnUrl.searchParams.set('p', selection.projectId);
|
|
93
|
+
if (selection.accountId) selectedReturnUrl.searchParams.set('a', selection.accountId);
|
|
94
|
+
|
|
95
|
+
const url = new URL(centralAuth);
|
|
96
|
+
url.searchParams.set('sts', stsUrl);
|
|
97
|
+
url.searchParams.set('redirect_uri', selectedReturnUrl.toString());
|
|
98
|
+
url.searchParams.set('state', state);
|
|
99
|
+
return url;
|
|
100
|
+
}
|
|
101
|
+
|
|
85
102
|
/**
|
|
86
103
|
* A short-lived marker saying "a Central Auth round-trip is in flight for this browser".
|
|
87
104
|
*
|
|
@@ -128,14 +145,14 @@ export function clearCentralAuthRoundTripMarker(): void {
|
|
|
128
145
|
* and {@link isCentralAuthRedirectPending}'s counterpart inside `UserSessionProvider` cannot drift
|
|
129
146
|
* into producing different `redirect_uri` / `state` / `sts` values for the same page load.
|
|
130
147
|
*/
|
|
131
|
-
export function redirectToCentralAuth(selection:
|
|
132
|
-
const url =
|
|
133
|
-
centralAuthUrl
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
148
|
+
export function redirectToCentralAuth(selection: AuthSelection = {}): void {
|
|
149
|
+
const url = buildCentralAuthRedirectUrl(
|
|
150
|
+
centralAuthUrl(),
|
|
151
|
+
Env.endpoints.sts ?? 'https://sts.vertesia.io',
|
|
152
|
+
authReturnUrl(),
|
|
153
|
+
generateAuthState(),
|
|
154
|
+
selection,
|
|
155
|
+
);
|
|
139
156
|
markCentralAuthRoundTripStarted();
|
|
140
157
|
location.replace(url.toString());
|
|
141
158
|
}
|
|
@@ -149,7 +166,7 @@ export function redirectToCentralAuth(selection: { accountId?: string; projectId
|
|
|
149
166
|
* that provider from redirecting is therefore knowable up front, and this predicate enumerates
|
|
150
167
|
* them in the same order:
|
|
151
168
|
*
|
|
152
|
-
* -
|
|
169
|
+
* - Firebase-allowlisted hosts sign in in place rather than at the broker;
|
|
153
170
|
* - a host app that injects a token through `Env.authTokenProvider` may not need the broker
|
|
154
171
|
* (and when its token turns out to be empty the provider still redirects — answering `false`
|
|
155
172
|
* here just lets the normal flow decide, which is the safe direction);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Env } from '@vertesia/ui/env';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { getFirebaseAuth, setFirebaseTenant } from './firebase';
|
|
4
|
+
|
|
5
|
+
vi.mock('firebase/app', () => ({ initializeApp: vi.fn(() => ({})) }));
|
|
6
|
+
vi.mock('firebase/auth', () => ({ getAuth: vi.fn(() => ({ tenantId: null })) }));
|
|
7
|
+
|
|
8
|
+
describe('configured Firebase tenant', () => {
|
|
9
|
+
afterEach(() => vi.restoreAllMocks());
|
|
10
|
+
|
|
11
|
+
it('initializes redirect handling with the tenant and skips email discovery', async () => {
|
|
12
|
+
vi.spyOn(Env, 'firebase', 'get').mockReturnValue({
|
|
13
|
+
apiKey: 'key',
|
|
14
|
+
authDomain: 'app.example.com',
|
|
15
|
+
projectId: 'project',
|
|
16
|
+
tenantId: 'fixed-tenant',
|
|
17
|
+
});
|
|
18
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
19
|
+
expect(getFirebaseAuth().tenantId).toBe('fixed-tenant');
|
|
20
|
+
const tenant = await setFirebaseTenant('someone@another-company.com');
|
|
21
|
+
expect(tenant?.firebaseTenantId).toBe('fixed-tenant');
|
|
22
|
+
expect(tenant?.provider).toBe('oidc');
|
|
23
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
24
|
+
});
|
|
25
|
+
});
|