@vertesia/ui 1.5.0-dev.20260713.151751Z → 1.5.0-dev.20260717.131047Z

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 (135) hide show
  1. package/lib/env/index.d.ts +19 -0
  2. package/lib/env/index.d.ts.map +1 -1
  3. package/lib/env/index.js +6 -0
  4. package/lib/env/index.js.map +1 -1
  5. package/lib/features/agent/chat/ArtifactsTab.d.ts.map +1 -1
  6. package/lib/features/agent/chat/ArtifactsTab.js +65 -11
  7. package/lib/features/agent/chat/ArtifactsTab.js.map +1 -1
  8. package/lib/features/agent/chat/ModernAgentConversation.js +51 -48
  9. package/lib/features/agent/chat/ModernAgentConversation.js.map +1 -1
  10. package/lib/features/agent/chat/hooks/useArtifacts.d.ts +8 -1
  11. package/lib/features/agent/chat/hooks/useArtifacts.d.ts.map +1 -1
  12. package/lib/features/agent/chat/hooks/useArtifacts.js +28 -10
  13. package/lib/features/agent/chat/hooks/useArtifacts.js.map +1 -1
  14. package/lib/features/document-viewer/UniversalDocumentViewer.d.ts +73 -0
  15. package/lib/features/document-viewer/UniversalDocumentViewer.d.ts.map +1 -0
  16. package/lib/features/document-viewer/UniversalDocumentViewer.js +415 -0
  17. package/lib/features/document-viewer/UniversalDocumentViewer.js.map +1 -0
  18. package/lib/features/document-viewer/index.d.ts +2 -0
  19. package/lib/features/document-viewer/index.d.ts.map +1 -0
  20. package/lib/features/document-viewer/index.js +2 -0
  21. package/lib/features/document-viewer/index.js.map +1 -0
  22. package/lib/features/index.d.ts +1 -0
  23. package/lib/features/index.d.ts.map +1 -1
  24. package/lib/features/index.js +1 -0
  25. package/lib/features/index.js.map +1 -1
  26. package/lib/features/permissions/UserPermissionsProvider.d.ts +2 -3
  27. package/lib/features/permissions/UserPermissionsProvider.d.ts.map +1 -1
  28. package/lib/features/permissions/UserPermissionsProvider.js +31 -38
  29. package/lib/features/permissions/UserPermissionsProvider.js.map +1 -1
  30. package/lib/features/store/objects/components/ContentOverview.d.ts.map +1 -1
  31. package/lib/features/store/objects/components/ContentOverview.js +73 -13
  32. package/lib/features/store/objects/components/ContentOverview.js.map +1 -1
  33. package/lib/i18n/locales/ar.json +2 -0
  34. package/lib/i18n/locales/de.json +2 -0
  35. package/lib/i18n/locales/en.json +2 -0
  36. package/lib/i18n/locales/es.json +2 -0
  37. package/lib/i18n/locales/fr.json +2 -0
  38. package/lib/i18n/locales/it.json +2 -0
  39. package/lib/i18n/locales/ja.json +2 -0
  40. package/lib/i18n/locales/ko.json +2 -0
  41. package/lib/i18n/locales/pt.json +2 -0
  42. package/lib/i18n/locales/ru.json +2 -0
  43. package/lib/i18n/locales/tr.json +2 -0
  44. package/lib/i18n/locales/zh-TW.json +2 -0
  45. package/lib/i18n/locales/zh.json +2 -0
  46. package/lib/router/HistoryNavigator.d.ts.map +1 -1
  47. package/lib/router/HistoryNavigator.js +4 -1
  48. package/lib/router/HistoryNavigator.js.map +1 -1
  49. package/lib/router/Nav.d.ts.map +1 -1
  50. package/lib/router/Nav.js +7 -1
  51. package/lib/router/Nav.js.map +1 -1
  52. package/lib/router/Router.d.ts.map +1 -1
  53. package/lib/router/Router.js +19 -4
  54. package/lib/router/Router.js.map +1 -1
  55. package/lib/router/path.d.ts +15 -0
  56. package/lib/router/path.d.ts.map +1 -1
  57. package/lib/router/path.js +43 -0
  58. package/lib/router/path.js.map +1 -1
  59. package/lib/session/UserSession.d.ts +6 -0
  60. package/lib/session/UserSession.d.ts.map +1 -1
  61. package/lib/session/UserSession.js +31 -9
  62. package/lib/session/UserSession.js.map +1 -1
  63. package/lib/session/UserSessionProvider.d.ts.map +1 -1
  64. package/lib/session/UserSessionProvider.js +111 -49
  65. package/lib/session/UserSessionProvider.js.map +1 -1
  66. package/lib/session/auth/composable.d.ts.map +1 -1
  67. package/lib/session/auth/composable.js +52 -3
  68. package/lib/session/auth/composable.js.map +1 -1
  69. package/lib/session/auth/domainRouting.d.ts +25 -0
  70. package/lib/session/auth/domainRouting.d.ts.map +1 -1
  71. package/lib/session/auth/domainRouting.js +36 -0
  72. package/lib/session/auth/domainRouting.js.map +1 -1
  73. package/lib/shell/login/SignInRestrictedEnvStep.d.ts +1 -1
  74. package/lib/shell/login/SignInRestrictedEnvStep.js +1 -1
  75. package/lib/shell/login/SigninScreen.js +9 -1
  76. package/lib/shell/login/SigninScreen.js.map +1 -1
  77. package/lib/shell/login/productionUrl.d.ts +0 -14
  78. package/lib/shell/login/productionUrl.d.ts.map +1 -1
  79. package/lib/shell/login/productionUrl.js +14 -5
  80. package/lib/shell/login/productionUrl.js.map +1 -1
  81. package/lib/vertesia-ui-core.js +3 -3
  82. package/lib/vertesia-ui-core.js.map +1 -1
  83. package/lib/vertesia-ui-env.js +1 -1
  84. package/lib/vertesia-ui-env.js.map +1 -1
  85. package/lib/vertesia-ui-features.js +28 -28
  86. package/lib/vertesia-ui-features.js.map +1 -1
  87. package/lib/vertesia-ui-i18n.js +13 -13
  88. package/lib/vertesia-ui-router.js +1 -1
  89. package/lib/vertesia-ui-router.js.map +1 -1
  90. package/lib/vertesia-ui-session.js +1 -1
  91. package/lib/vertesia-ui-session.js.map +1 -1
  92. package/lib/vertesia-ui-shell.js +1 -1
  93. package/lib/vertesia-ui-shell.js.map +1 -1
  94. package/lib/vertesia-ui-widgets.js +2 -2
  95. package/lib/vertesia-ui-widgets.js.map +1 -1
  96. package/llms.txt +70 -70
  97. package/package.json +8 -7
  98. package/src/env/index.ts +24 -0
  99. package/src/features/agent/chat/ArtifactsTab.tsx +155 -21
  100. package/src/features/agent/chat/ModernAgentConversation.tsx +53 -49
  101. package/src/features/agent/chat/hooks/useArtifacts.ts +44 -11
  102. package/src/features/document-viewer/UniversalDocumentViewer.test.tsx +123 -0
  103. package/src/features/document-viewer/UniversalDocumentViewer.tsx +746 -0
  104. package/src/features/document-viewer/index.ts +1 -0
  105. package/src/features/index.ts +1 -0
  106. package/src/features/permissions/UserPermissionsProvider.tsx +36 -39
  107. package/src/features/store/objects/components/ContentOverview.tsx +99 -26
  108. package/src/i18n/locales/ar.json +2 -0
  109. package/src/i18n/locales/de.json +2 -0
  110. package/src/i18n/locales/en.json +2 -0
  111. package/src/i18n/locales/es.json +2 -0
  112. package/src/i18n/locales/fr.json +2 -0
  113. package/src/i18n/locales/it.json +2 -0
  114. package/src/i18n/locales/ja.json +2 -0
  115. package/src/i18n/locales/ko.json +2 -0
  116. package/src/i18n/locales/pt.json +2 -0
  117. package/src/i18n/locales/ru.json +2 -0
  118. package/src/i18n/locales/tr.json +2 -0
  119. package/src/i18n/locales/zh-TW.json +2 -0
  120. package/src/i18n/locales/zh.json +2 -0
  121. package/src/router/HistoryNavigator.ts +4 -1
  122. package/src/router/Nav.tsx +7 -1
  123. package/src/router/Router.tsx +18 -4
  124. package/src/router/path.test.ts +87 -0
  125. package/src/router/path.ts +38 -0
  126. package/src/session/UserSession.ts +32 -9
  127. package/src/session/UserSessionProvider.tsx +138 -62
  128. package/src/session/auth/composable.test.ts +111 -0
  129. package/src/session/auth/composable.ts +58 -3
  130. package/src/session/auth/domainRouting.test.ts +28 -1
  131. package/src/session/auth/domainRouting.ts +38 -0
  132. package/src/shell/login/SignInRestrictedEnvStep.tsx +1 -1
  133. package/src/shell/login/SigninScreen.tsx +9 -1
  134. package/src/shell/login/productionUrl.test.ts +8 -5
  135. package/src/shell/login/productionUrl.ts +15 -5
@@ -1,7 +1,7 @@
1
1
  import { createContext, useContext, useEffect } from 'react';
2
2
  import { HistoryNavigator, type LocationChangeEvent, type NavigateOptions } from './HistoryNavigator';
3
3
  import { type PathMatch, PathMatcher } from './PathMatcher';
4
- import { isRootPath, joinPath, type PathMatchParams } from './path';
4
+ import { getMountBasename, isRootPath, joinPath, type PathMatchParams, stripMountBasename } from './path';
5
5
 
6
6
  export type RouteComponentProps = PathMatchParams;
7
7
  export type LazyRouteModule = { default: React.ComponentType<Record<string, never>> };
@@ -61,7 +61,8 @@ export class Router extends BaseRouter {
61
61
  }
62
62
  // only process afterChange events
63
63
  if (event.name === 'afterChange') {
64
- const match = this.match(event.location.pathname);
64
+ // Match app-relative: strip the served `<base href>` mount prefix (no-op when origin-served).
65
+ const match = this.match(stripMountBasename(event.location.pathname));
65
66
  if (match?.value) {
66
67
  updateState({
67
68
  ...match,
@@ -184,8 +185,21 @@ export function useNavigate() {
184
185
  }
185
186
 
186
187
  export function useRouterBasePath() {
187
- const { matchedRoutePath, router } = useRouterContext();
188
- return router instanceof NestedRouter ? router.basePath : matchedRoutePath;
188
+ const { router } = useRouterContext();
189
+ // The base path apps prepend to build absolute in-app links (e.g. `${base}/items/${id}`). It MUST
190
+ // include the served `<base href>` mount so links stay on the mount and match the full
191
+ // `useLocation().pathname` (which carries the mount). `getMountBasename()` is '' for the
192
+ // origin-served Studio UI and the mount (no trailing slash) for gateway-served apps.
193
+ const mount = getMountBasename();
194
+ if (router instanceof NestedRouter) {
195
+ // NestedRouter.basePath is relative to the parent router, which sits at the mount, so it must
196
+ // be mount-prefixed too. A bare nested base of '/' (an app whose NestedRouterProvider has no
197
+ // basePath) would otherwise make `${base}/items` resolve to `//items` — a protocol-relative
198
+ // URL that escapes the app. Origin-served Studio (mount '') keeps the raw nested basePath.
199
+ if (!mount) return router.basePath;
200
+ return router.basePath === '/' ? mount : joinPath(mount, router.basePath);
201
+ }
202
+ return mount;
189
203
  }
190
204
 
191
205
  type UseParamsReturn<T> = T extends string ? string : Record<string, string>;
@@ -0,0 +1,87 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { getMountBasename, joinPath, stripMountBasename, withMountBasename } from './path';
3
+
4
+ // Simulate a served `<base href>` (or its absence) by stubbing document.querySelector + document.URL.
5
+ function setBaseHref(href: string | null, docUrl = 'https://gw.example.com/x') {
6
+ (globalThis as { document?: unknown }).document = {
7
+ URL: docUrl,
8
+ querySelector: (sel: string) =>
9
+ sel === 'base[href]' && href ? ({ href: new URL(href, docUrl).href } as unknown) : null,
10
+ };
11
+ }
12
+
13
+ const MOUNT = '/tenants/05948c_5ed5f4/apps/furniture-catalog/versions/20260620T064257113Z/app';
14
+
15
+ describe('mount basename helpers', () => {
16
+ afterEach(() => {
17
+ delete (globalThis as { document?: unknown }).document;
18
+ });
19
+
20
+ describe('getMountBasename', () => {
21
+ it('returns the base path (no trailing slash) when a deep <base href> is present', () => {
22
+ setBaseHref(`${MOUNT}/`);
23
+ expect(getMountBasename()).toBe(MOUNT);
24
+ });
25
+ it('returns "" for the Studio UI (no <base> element)', () => {
26
+ setBaseHref(null);
27
+ expect(getMountBasename()).toBe('');
28
+ });
29
+ it('returns "" for a root <base href="/">', () => {
30
+ setBaseHref('/');
31
+ expect(getMountBasename()).toBe('');
32
+ });
33
+ it('returns "" when there is no document (SSR)', () => {
34
+ delete (globalThis as { document?: unknown }).document;
35
+ expect(getMountBasename()).toBe('');
36
+ });
37
+ });
38
+
39
+ describe('withMountBasename (navigate side)', () => {
40
+ it('prepends the mount to an absolute app route', () => {
41
+ setBaseHref(`${MOUNT}/`);
42
+ expect(withMountBasename('/assistant')).toBe(`${MOUNT}/assistant`);
43
+ expect(withMountBasename('/')).toBe(`${MOUNT}/`);
44
+ });
45
+ it('does not double-prepend a path already under the mount', () => {
46
+ setBaseHref(`${MOUNT}/`);
47
+ expect(withMountBasename(`${MOUNT}/items/5`)).toBe(`${MOUNT}/items/5`);
48
+ expect(withMountBasename(MOUNT)).toBe(MOUNT);
49
+ });
50
+ it('leaves relative paths untouched', () => {
51
+ setBaseHref(`${MOUNT}/`);
52
+ expect(withMountBasename('assistant')).toBe('assistant');
53
+ });
54
+ it('is a no-op for the Studio UI (no mount)', () => {
55
+ setBaseHref(null);
56
+ expect(withMountBasename('/assistant')).toBe('/assistant');
57
+ expect(withMountBasename('/')).toBe('/');
58
+ });
59
+ });
60
+
61
+ describe('stripMountBasename (match side)', () => {
62
+ it('strips the mount so route patterns match app-relative', () => {
63
+ setBaseHref(`${MOUNT}/`);
64
+ expect(stripMountBasename(`${MOUNT}/assistant`)).toBe('/assistant');
65
+ expect(stripMountBasename(MOUNT)).toBe('/');
66
+ expect(stripMountBasename(`${MOUNT}/`)).toBe('/');
67
+ });
68
+ it('leaves a path not under the mount untouched', () => {
69
+ setBaseHref(`${MOUNT}/`);
70
+ expect(stripMountBasename('/somewhere-else')).toBe('/somewhere-else');
71
+ });
72
+ it('is a no-op for the Studio UI (no mount)', () => {
73
+ setBaseHref(null);
74
+ expect(stripMountBasename('/agents/123')).toBe('/agents/123');
75
+ expect(stripMountBasename('/')).toBe('/');
76
+ });
77
+ });
78
+
79
+ it('round-trips: strip(with(route)) === route under a mount', () => {
80
+ setBaseHref(`${MOUNT}/`);
81
+ for (const route of ['/', '/assistant', '/items/5']) {
82
+ expect(stripMountBasename(withMountBasename(route))).toBe(route === '/' ? '/' : route);
83
+ }
84
+ // sanity: joinPath stays available/correct
85
+ expect(joinPath(MOUNT, '/assistant')).toBe(`${MOUNT}/assistant`);
86
+ });
87
+ });
@@ -19,6 +19,44 @@ export function joinPath(path1: string, path2: string) {
19
19
  return path2;
20
20
  }
21
21
 
22
+ /**
23
+ * The mount base path from an explicit `<base href>` element, or `''` when there is none.
24
+ *
25
+ * Apps served under a deep gateway mount carry `<base href="/tenants/.../app/">`, injected at serve
26
+ * time. The router otherwise resolves absolute routes against the origin, which collapses the URL
27
+ * off the mount (and breaks in-app links). This returns the base pathname without a trailing slash
28
+ * so {@link withMountBasename} can keep navigation under the mount and {@link stripMountBasename}
29
+ * can match routes app-relative. The Studio UI has no `<base>` element, so this returns `''` and
30
+ * every caller becomes a no-op there.
31
+ */
32
+ export function getMountBasename(): string {
33
+ if (typeof document === 'undefined') return '';
34
+ const base = document.querySelector('base[href]') as HTMLBaseElement | null;
35
+ if (!base) return '';
36
+ try {
37
+ return new URL(base.href, document.URL).pathname.replace(/\/+$/, '');
38
+ } catch {
39
+ return '';
40
+ }
41
+ }
42
+
43
+ /** Prepend the mount basename to an absolute app path (no-op when no mount, path is relative, or already prefixed). */
44
+ export function withMountBasename(path: string): string {
45
+ const base = getMountBasename();
46
+ if (!base || !path.startsWith('/')) return path;
47
+ if (path === base || path.startsWith(`${base}/`)) return path;
48
+ return joinPath(base, path);
49
+ }
50
+
51
+ /** Strip the mount basename from a browser path so route patterns match app-relative (no-op when no mount). */
52
+ export function stripMountBasename(path: string): string {
53
+ const base = getMountBasename();
54
+ if (!base) return path;
55
+ if (path === base) return '/';
56
+ if (path.startsWith(`${base}/`)) return path.slice(base.length) || '/';
57
+ return path;
58
+ }
59
+
22
60
  export function getPathSegments(path: string) {
23
61
  if (path === '') {
24
62
  return [];
@@ -5,7 +5,7 @@ import { jwtDecode } from 'jwt-decode';
5
5
  import { createContext, useContext } from 'react';
6
6
 
7
7
  import { getComposableToken } from './auth/composable';
8
- import { shouldRedirectToCentralAuth } from './auth/domainRouting';
8
+ import { authReturnUrl, mountRootUrl, shouldRedirectToCentralAuth } from './auth/domainRouting';
9
9
  import { getFirebaseAuth } from './auth/firebase';
10
10
 
11
11
  import { LastSelectedAccountId_KEY, LastSelectedProjectId_KEY } from './constants';
@@ -84,6 +84,22 @@ class UserSession {
84
84
  });
85
85
  }
86
86
 
87
+ /**
88
+ * Force a fresh Vertesia JWT from STS, bypassing the in-memory cache.
89
+ * Use this when the current token's claims (e.g. `apps`) are suspected
90
+ * stale — STS recomputes the `apps` claim from ACEs on every issuance.
91
+ */
92
+ async refreshAuthToken(): Promise<AuthTokenPayload> {
93
+ const res = await getComposableToken(undefined, undefined, undefined, true);
94
+ const token = res?.rawToken;
95
+ if (!token) {
96
+ throw new Error('No token available');
97
+ }
98
+ this.authToken = jwtDecode(token) as unknown as AuthTokenPayload;
99
+ this.setSession?.(this.clone());
100
+ return this.authToken;
101
+ }
102
+
87
103
  signOut() {
88
104
  //compatibility
89
105
  this.logout();
@@ -136,8 +152,7 @@ class UserSession {
136
152
  this.client.withAuthCallback(undefined);
137
153
 
138
154
  const logoutUrl = new URL(CENTRAL_AUTH_REDIRECT);
139
- const currentUrl = new URL(window.location.href);
140
- currentUrl.hash = '';
155
+ const currentUrl = authReturnUrl();
141
156
  logoutUrl.pathname = '/logout';
142
157
  logoutUrl.searchParams.set('redirect_uri', currentUrl.toString());
143
158
  location.replace(logoutUrl.toString());
@@ -153,12 +168,13 @@ class UserSession {
153
168
  this.authToken = undefined;
154
169
  this.setSession = undefined;
155
170
  this.client.withAuthCallback(undefined);
156
- // Navigate to root to avoid React rendering errors when
171
+ // Navigate to the app root to avoid React rendering errors when
157
172
  // unmounting deeply nested route components during logout.
158
- // Only navigate if user was actually logged in to avoid
159
- // infinite reload loop on fresh/incognito sessions.
173
+ // Use the mount root (not bare '/') so a gateway-mounted app stays
174
+ // on its mount. Only navigate if user was actually logged in to
175
+ // avoid an infinite reload loop on fresh/incognito sessions.
160
176
  if (wasLoggedIn) {
161
- location.replace('/');
177
+ location.replace(mountRootUrl().toString());
162
178
  }
163
179
  }
164
180
  }
@@ -173,7 +189,9 @@ class UserSession {
173
189
  }
174
190
  }
175
191
 
176
- window.location.replace(`/?a=${targetAccountId}`);
192
+ const url = mountRootUrl();
193
+ url.searchParams.set('a', targetAccountId);
194
+ window.location.replace(url.toString());
177
195
  }
178
196
 
179
197
  async switchProject(targetProjectId: string) {
@@ -181,7 +199,12 @@ class UserSession {
181
199
  localStorage.setItem(`${LastSelectedProjectId_KEY}-${this.account.id}`, targetProjectId);
182
200
  }
183
201
 
184
- window.location.replace(`/?a=${this.account?.id}&p=${targetProjectId}`);
202
+ const url = mountRootUrl();
203
+ if (this.account?.id) {
204
+ url.searchParams.set('a', this.account.id);
205
+ }
206
+ url.searchParams.set('p', targetProjectId);
207
+ window.location.replace(url.toString());
185
208
  }
186
209
 
187
210
  async fetchAccounts() {
@@ -2,13 +2,19 @@ import { Env } from '@vertesia/ui/env';
2
2
  import { onAuthStateChanged } from 'firebase/auth';
3
3
  import { type ReactNode, useEffect, useRef, useState } from 'react';
4
4
  import { getComposableToken, RestrictedEnvironmentError, STSError, UserNotFoundError } from './auth/composable';
5
- import { shouldRedirectToCentralAuth } from './auth/domainRouting';
5
+ import { authReturnUrl, shouldRedirectToCentralAuth } from './auth/domainRouting';
6
6
  import { getFirebaseAuth } from './auth/firebase';
7
7
  import { useAuthState } from './auth/useAuthState';
8
8
  import { LastSelectedAccountId_KEY, LastSelectedProjectId_KEY, UserSession, UserSessionContext } from './UserSession';
9
9
 
10
10
  const CENTRAL_AUTH_REDIRECT = 'https://internal-auth.vertesia.app/';
11
11
 
12
+ function clearAuthHash() {
13
+ const url = new URL(window.location.href);
14
+ url.hash = '';
15
+ window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}`);
16
+ }
17
+
12
18
  interface UserSessionProviderProps {
13
19
  children: ReactNode | ReactNode[];
14
20
  loadOnboardingStatus?: boolean;
@@ -24,8 +30,7 @@ export function UserSessionProvider({ children, loadOnboardingStatus = true }: U
24
30
 
25
31
  const redirectToCentralAuth = (projectId?: string, accountId?: string) => {
26
32
  const url = new URL(`${CENTRAL_AUTH_REDIRECT}?sts=${Env.endpoints.sts ?? 'https://sts.vertesia.io'}`);
27
- const currentUrl = new URL(window.location.href);
28
- currentUrl.hash = '';
33
+ const currentUrl = authReturnUrl();
29
34
  if (projectId) currentUrl.searchParams.set('p', projectId);
30
35
  if (accountId) currentUrl.searchParams.set('a', accountId);
31
36
  url.searchParams.set('redirect_uri', currentUrl.toString());
@@ -59,6 +64,28 @@ export function UserSessionProvider({ children, loadOnboardingStatus = true }: U
59
64
  },
60
65
  });
61
66
 
67
+ if (Env.isLocalDev && Env.devAuthToken) {
68
+ session.setSession = setSession;
69
+ getComposableToken(selectedAccount, selectedProject, Env.devAuthToken)
70
+ .then((res) => {
71
+ session.login(res.rawToken).then(() => setSession(session.clone()));
72
+ })
73
+ .catch((err) => {
74
+ console.error('Failed to initialize dev auth token', err);
75
+ Env.logger.error('Failed to initialize dev auth token', {
76
+ vertesia: {
77
+ account_id: selectedAccount,
78
+ project_id: selectedProject,
79
+ error: err,
80
+ },
81
+ });
82
+ session.isLoading = false;
83
+ session.authError = err instanceof Error ? err : new Error(String(err));
84
+ setSession(session.clone());
85
+ });
86
+ return;
87
+ }
88
+
62
89
  if (token && state) {
63
90
  session.setSession = setSession;
64
91
  const validationError = verifyState(state);
@@ -77,8 +104,7 @@ export function UserSessionProvider({ children, loadOnboardingStatus = true }: U
77
104
  .then((res) => {
78
105
  session.login(res.rawToken, { loadOnboardingStatus }).then(() => {
79
106
  setSession(session.clone());
80
- //cleanup the hash
81
- window.location.hash = '';
107
+ clearAuthHash();
82
108
  });
83
109
  })
84
110
  .catch((err) => {
@@ -117,7 +143,7 @@ export function UserSessionProvider({ children, loadOnboardingStatus = true }: U
117
143
  );
118
144
  session.logout();
119
145
  setSession(session.clone());
120
- window.location.hash = '';
146
+ clearAuthHash();
121
147
  return;
122
148
  }
123
149
 
@@ -130,7 +156,14 @@ export function UserSessionProvider({ children, loadOnboardingStatus = true }: U
130
156
  redirectToCentralAuth();
131
157
  });
132
158
  return;
133
- } else {
159
+ }
160
+
161
+ let cancelled = false;
162
+ let unsubscribe: (() => void) | undefined;
163
+
164
+ const startFirebaseOrCentralAuth = () => {
165
+ if (cancelled) return;
166
+
134
167
  // If the current host is not in the Firebase allowlist, central auth owns sign-in.
135
168
  if (!session.isLoggedIn()) {
136
169
  console.log('Auth: not logged in & no token/state');
@@ -154,71 +187,114 @@ export function UserSessionProvider({ children, loadOnboardingStatus = true }: U
154
187
  });
155
188
  redirectToCentralAuth();
156
189
  return; // Don't register onAuthStateChanged listener when redirecting
157
- } else {
158
- console.log('Auth: host is in Firebase auth allowlist');
159
- Env.logger.info('Host is in Firebase auth allowlist', {
160
- vertesia: {
161
- account_id: selectedAccount,
162
- project_id: selectedProject,
163
- },
164
- });
165
190
  }
166
- }
167
- }
168
191
 
169
- return onAuthStateChanged(getFirebaseAuth(), async (firebaseUser) => {
170
- if (firebaseUser) {
171
- console.log('Auth: successful login with firebase');
172
- Env.logger.info('Successful login with firebase', {
192
+ console.log('Auth: host is in Firebase auth allowlist');
193
+ Env.logger.info('Host is in Firebase auth allowlist', {
173
194
  vertesia: {
174
195
  account_id: selectedAccount,
175
196
  project_id: selectedProject,
176
197
  },
177
198
  });
178
- session.setSession = setSession;
179
- await getComposableToken(
180
- selectedAccount,
181
- selectedProject,
182
- undefined,
183
- false,
184
- shouldRedirectToCentralAuth(),
185
- )
186
- .then((res) => {
187
- session.login(res.rawToken, { loadOnboardingStatus }).then(() => setSession(session.clone()));
188
- })
189
- .catch((err) => {
190
- console.error('Failed to fetch user token from studio', err);
191
- Env.logger.error('Failed to fetch user token from studio', {
192
- vertesia: {
193
- account_id: selectedAccount,
194
- project_id: selectedProject,
195
- error: err,
196
- },
199
+ }
200
+
201
+ unsubscribe = onAuthStateChanged(getFirebaseAuth(), async (firebaseUser) => {
202
+ if (firebaseUser) {
203
+ console.log('Auth: successful login with firebase');
204
+ Env.logger.info('Successful login with firebase', {
205
+ vertesia: {
206
+ account_id: selectedAccount,
207
+ project_id: selectedProject,
208
+ },
209
+ });
210
+ session.setSession = setSession;
211
+ await getComposableToken(
212
+ selectedAccount,
213
+ selectedProject,
214
+ undefined,
215
+ false,
216
+ shouldRedirectToCentralAuth(),
217
+ )
218
+ .then((res) => {
219
+ session
220
+ .login(res.rawToken, { loadOnboardingStatus })
221
+ .then(() => setSession(session.clone()));
222
+ })
223
+ .catch((err) => {
224
+ console.error('Failed to fetch user token from studio', err);
225
+ Env.logger.error('Failed to fetch user token from studio', {
226
+ vertesia: {
227
+ account_id: selectedAccount,
228
+ project_id: selectedProject,
229
+ error: err,
230
+ },
231
+ });
232
+ // Keep the Firebase session for UserNotFoundError (signup flow) and
233
+ // RestrictedEnvironmentError (identity is valid; the environment is gated).
234
+ // Logging out would retrigger onAuthStateChanged and clear the authError.
235
+ if (!(err instanceof UserNotFoundError) && !(err instanceof RestrictedEnvironmentError)) {
236
+ session.logout();
237
+ }
238
+ session.isLoading = false;
239
+ session.authError = err;
240
+ setSession(session.clone());
197
241
  });
198
- // Keep the Firebase session for UserNotFoundError (signup flow) and
199
- // RestrictedEnvironmentError (identity is valid; the environment is gated).
200
- // Logging out would retrigger onAuthStateChanged and clear the authError.
201
- if (!(err instanceof UserNotFoundError) && !(err instanceof RestrictedEnvironmentError)) {
202
- session.logout();
203
- }
204
- session.isLoading = false;
205
- session.authError = err;
206
- setSession(session.clone());
242
+ } else {
243
+ // anonymous user
244
+ console.log('Auth: using anonymous user');
245
+ Env.logger.info('Using anonymous user', {
246
+ vertesia: {
247
+ account_id: selectedAccount,
248
+ project_id: selectedProject,
249
+ },
207
250
  });
208
- } else {
209
- // anonymous user
210
- console.log('Auth: using anonymous user');
211
- Env.logger.info('Using anonymous user', {
212
- vertesia: {
213
- account_id: selectedAccount,
214
- project_id: selectedProject,
215
- },
251
+ session.client.withAuthCallback(undefined);
252
+ session.logout();
253
+ setSession(session.clone());
254
+ }
255
+ });
256
+ };
257
+
258
+ if (Env.authTokenProvider) {
259
+ session.setSession = setSession;
260
+ void Env.authTokenProvider()
261
+ .then(async (injectedToken) => {
262
+ if (!injectedToken) {
263
+ startFirebaseOrCentralAuth();
264
+ return;
265
+ }
266
+ const res = await getComposableToken(
267
+ selectedAccount,
268
+ selectedProject,
269
+ injectedToken,
270
+ false,
271
+ shouldRedirectToCentralAuth(),
272
+ );
273
+ await session.login(res.rawToken, { loadOnboardingStatus });
274
+ if (!cancelled) setSession(session.clone());
275
+ })
276
+ .catch((err: unknown) => {
277
+ console.warn('Auth: failed to initialize injected auth token', err);
278
+ Env.logger.warn('Failed to initialize injected auth token', {
279
+ vertesia: {
280
+ account_id: selectedAccount,
281
+ project_id: selectedProject,
282
+ error: err,
283
+ },
284
+ });
285
+ startFirebaseOrCentralAuth();
216
286
  });
217
- session.client.withAuthCallback(undefined);
218
- session.logout();
219
- setSession(session.clone());
220
- }
221
- });
287
+ return () => {
288
+ cancelled = true;
289
+ unsubscribe?.();
290
+ };
291
+ }
292
+
293
+ startFirebaseOrCentralAuth();
294
+ return () => {
295
+ cancelled = true;
296
+ unsubscribe?.();
297
+ };
222
298
  };
223
299
 
224
300
  useEffect(() => {
@@ -0,0 +1,111 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ function makeJwt(payload: Record<string, unknown>) {
4
+ const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url');
5
+ return `${encode({ alg: 'ES256', typ: 'JWT' })}.${encode(payload)}.signature`;
6
+ }
7
+
8
+ async function importComposableAuth() {
9
+ vi.resetModules();
10
+ const [{ Env }, composableAuth] = await Promise.all([import('@vertesia/ui/env'), import('./composable')]);
11
+ Env.init({
12
+ name: 'test',
13
+ version: '0.0.0',
14
+ isLocalDev: false,
15
+ isDocker: false,
16
+ type: 'test',
17
+ endpoints: {
18
+ studio: 'https://studio-server-dev-feat-appgen.api.dev1.vertesia.io',
19
+ zeno: 'https://zeno-server-dev-feat-appgen.api.dev1.vertesia.io',
20
+ sts: 'https://sts.dev1.vertesia.io',
21
+ },
22
+ });
23
+ return composableAuth;
24
+ }
25
+
26
+ describe('getComposableToken', () => {
27
+ beforeEach(() => {
28
+ localStorage.clear();
29
+ });
30
+
31
+ afterEach(() => {
32
+ vi.unstubAllGlobals();
33
+ });
34
+
35
+ it('uses an authorization-bearing STS-issued Vertesia token directly instead of exchanging it', async () => {
36
+ const token = makeJwt({
37
+ iss: 'https://sts.dev1.vertesia.io',
38
+ exp: Math.floor(Date.now() / 1000) + 3600,
39
+ account: { id: 'account-id', name: 'Account' },
40
+ account_roles: [],
41
+ project: { id: 'project-id', name: 'Project', account: 'account-id' },
42
+ project_roles: ['developer'],
43
+ });
44
+ const fetchMock = vi.fn();
45
+ vi.stubGlobal('fetch', fetchMock);
46
+
47
+ const { getComposableToken } = await importComposableAuth();
48
+ const result = await getComposableToken('account-id', 'project-id', token, false, true);
49
+
50
+ expect(result.rawToken).toBe(token);
51
+ expect(result.token.iss).toBe('https://sts.dev1.vertesia.io');
52
+ expect(fetchMock).not.toHaveBeenCalled();
53
+ });
54
+
55
+ it('exchanges a bare STS-issued Vertesia token so roles can be hydrated', async () => {
56
+ const sourceToken = makeJwt({
57
+ iss: 'https://sts.dev1.vertesia.io',
58
+ exp: Math.floor(Date.now() / 1000) + 3600,
59
+ account: { id: 'account-id', name: 'Account' },
60
+ project: { id: 'project-id', name: 'Project', account: 'account-id' },
61
+ });
62
+ const exchangedToken = makeJwt({
63
+ iss: 'https://sts.dev1.vertesia.io',
64
+ exp: Math.floor(Date.now() / 1000) + 3600,
65
+ account: { id: 'account-id', name: 'Account' },
66
+ account_roles: [],
67
+ project: { id: 'project-id', name: 'Project', account: 'account-id' },
68
+ project_roles: ['admin'],
69
+ });
70
+ const fetchMock = vi.fn().mockResolvedValue({
71
+ ok: true,
72
+ status: 200,
73
+ json: () => Promise.resolve({ token: exchangedToken }),
74
+ });
75
+ vi.stubGlobal('fetch', fetchMock);
76
+
77
+ const { getComposableToken } = await importComposableAuth();
78
+ const result = await getComposableToken('account-id', 'project-id', sourceToken, false, true);
79
+
80
+ expect(result.rawToken).toBe(exchangedToken);
81
+ expect(fetchMock).toHaveBeenCalledOnce();
82
+ expect(fetchMock.mock.calls[0]?.[0]?.toString()).toBe('https://sts.dev1.vertesia.io/token/issue');
83
+ });
84
+
85
+ it('still exchanges a non-STS source token', async () => {
86
+ const sourceToken = makeJwt({
87
+ iss: 'https://securetoken.google.com/example',
88
+ exp: Math.floor(Date.now() / 1000) + 3600,
89
+ email: 'user@example.com',
90
+ });
91
+ const exchangedToken = makeJwt({
92
+ iss: 'https://sts.dev1.vertesia.io',
93
+ exp: Math.floor(Date.now() / 1000) + 3600,
94
+ account: { id: 'account-id', name: 'Account' },
95
+ project: { id: 'project-id', name: 'Project', account: 'account-id' },
96
+ });
97
+ const fetchMock = vi.fn().mockResolvedValue({
98
+ ok: true,
99
+ status: 200,
100
+ json: () => Promise.resolve({ token: exchangedToken }),
101
+ });
102
+ vi.stubGlobal('fetch', fetchMock);
103
+
104
+ const { getComposableToken } = await importComposableAuth();
105
+ const result = await getComposableToken('account-id', 'project-id', sourceToken, false, true);
106
+
107
+ expect(result.rawToken).toBe(exchangedToken);
108
+ expect(fetchMock).toHaveBeenCalledOnce();
109
+ expect(fetchMock.mock.calls[0]?.[0]?.toString()).toBe('https://sts.dev1.vertesia.io/token/issue');
110
+ });
111
+ });