@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.
- package/lib/env/index.d.ts +19 -0
- package/lib/env/index.d.ts.map +1 -1
- package/lib/env/index.js +6 -0
- package/lib/env/index.js.map +1 -1
- package/lib/features/agent/chat/ArtifactsTab.d.ts.map +1 -1
- package/lib/features/agent/chat/ArtifactsTab.js +65 -11
- package/lib/features/agent/chat/ArtifactsTab.js.map +1 -1
- package/lib/features/agent/chat/ModernAgentConversation.js +51 -48
- package/lib/features/agent/chat/ModernAgentConversation.js.map +1 -1
- package/lib/features/agent/chat/hooks/useArtifacts.d.ts +8 -1
- package/lib/features/agent/chat/hooks/useArtifacts.d.ts.map +1 -1
- package/lib/features/agent/chat/hooks/useArtifacts.js +28 -10
- package/lib/features/agent/chat/hooks/useArtifacts.js.map +1 -1
- package/lib/features/document-viewer/UniversalDocumentViewer.d.ts +73 -0
- package/lib/features/document-viewer/UniversalDocumentViewer.d.ts.map +1 -0
- package/lib/features/document-viewer/UniversalDocumentViewer.js +415 -0
- package/lib/features/document-viewer/UniversalDocumentViewer.js.map +1 -0
- package/lib/features/document-viewer/index.d.ts +2 -0
- package/lib/features/document-viewer/index.d.ts.map +1 -0
- package/lib/features/document-viewer/index.js +2 -0
- package/lib/features/document-viewer/index.js.map +1 -0
- package/lib/features/index.d.ts +1 -0
- package/lib/features/index.d.ts.map +1 -1
- package/lib/features/index.js +1 -0
- package/lib/features/index.js.map +1 -1
- package/lib/features/permissions/UserPermissionsProvider.d.ts +2 -3
- package/lib/features/permissions/UserPermissionsProvider.d.ts.map +1 -1
- package/lib/features/permissions/UserPermissionsProvider.js +31 -38
- package/lib/features/permissions/UserPermissionsProvider.js.map +1 -1
- package/lib/features/store/objects/components/ContentOverview.d.ts.map +1 -1
- package/lib/features/store/objects/components/ContentOverview.js +73 -13
- package/lib/features/store/objects/components/ContentOverview.js.map +1 -1
- package/lib/i18n/locales/ar.json +2 -0
- package/lib/i18n/locales/de.json +2 -0
- package/lib/i18n/locales/en.json +2 -0
- package/lib/i18n/locales/es.json +2 -0
- package/lib/i18n/locales/fr.json +2 -0
- package/lib/i18n/locales/it.json +2 -0
- package/lib/i18n/locales/ja.json +2 -0
- package/lib/i18n/locales/ko.json +2 -0
- package/lib/i18n/locales/pt.json +2 -0
- package/lib/i18n/locales/ru.json +2 -0
- package/lib/i18n/locales/tr.json +2 -0
- package/lib/i18n/locales/zh-TW.json +2 -0
- package/lib/i18n/locales/zh.json +2 -0
- package/lib/router/HistoryNavigator.d.ts.map +1 -1
- package/lib/router/HistoryNavigator.js +4 -1
- package/lib/router/HistoryNavigator.js.map +1 -1
- package/lib/router/Nav.d.ts.map +1 -1
- package/lib/router/Nav.js +7 -1
- package/lib/router/Nav.js.map +1 -1
- package/lib/router/Router.d.ts.map +1 -1
- package/lib/router/Router.js +19 -4
- package/lib/router/Router.js.map +1 -1
- package/lib/router/path.d.ts +15 -0
- package/lib/router/path.d.ts.map +1 -1
- package/lib/router/path.js +43 -0
- package/lib/router/path.js.map +1 -1
- package/lib/session/UserSession.d.ts +6 -0
- package/lib/session/UserSession.d.ts.map +1 -1
- package/lib/session/UserSession.js +31 -9
- package/lib/session/UserSession.js.map +1 -1
- package/lib/session/UserSessionProvider.d.ts.map +1 -1
- package/lib/session/UserSessionProvider.js +111 -49
- package/lib/session/UserSessionProvider.js.map +1 -1
- package/lib/session/auth/composable.d.ts.map +1 -1
- package/lib/session/auth/composable.js +52 -3
- package/lib/session/auth/composable.js.map +1 -1
- package/lib/session/auth/domainRouting.d.ts +25 -0
- package/lib/session/auth/domainRouting.d.ts.map +1 -1
- package/lib/session/auth/domainRouting.js +36 -0
- package/lib/session/auth/domainRouting.js.map +1 -1
- package/lib/shell/login/SignInRestrictedEnvStep.d.ts +1 -1
- package/lib/shell/login/SignInRestrictedEnvStep.js +1 -1
- package/lib/shell/login/SigninScreen.js +9 -1
- package/lib/shell/login/SigninScreen.js.map +1 -1
- package/lib/shell/login/productionUrl.d.ts +0 -14
- package/lib/shell/login/productionUrl.d.ts.map +1 -1
- package/lib/shell/login/productionUrl.js +14 -5
- package/lib/shell/login/productionUrl.js.map +1 -1
- package/lib/vertesia-ui-core.js +3 -3
- 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 +28 -28
- package/lib/vertesia-ui-features.js.map +1 -1
- package/lib/vertesia-ui-i18n.js +13 -13
- package/lib/vertesia-ui-router.js +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 +1 -1
- package/lib/vertesia-ui-shell.js.map +1 -1
- package/lib/vertesia-ui-widgets.js +2 -2
- package/lib/vertesia-ui-widgets.js.map +1 -1
- package/llms.txt +70 -70
- package/package.json +8 -7
- package/src/env/index.ts +24 -0
- package/src/features/agent/chat/ArtifactsTab.tsx +155 -21
- package/src/features/agent/chat/ModernAgentConversation.tsx +53 -49
- package/src/features/agent/chat/hooks/useArtifacts.ts +44 -11
- package/src/features/document-viewer/UniversalDocumentViewer.test.tsx +123 -0
- package/src/features/document-viewer/UniversalDocumentViewer.tsx +746 -0
- package/src/features/document-viewer/index.ts +1 -0
- package/src/features/index.ts +1 -0
- package/src/features/permissions/UserPermissionsProvider.tsx +36 -39
- package/src/features/store/objects/components/ContentOverview.tsx +99 -26
- package/src/i18n/locales/ar.json +2 -0
- package/src/i18n/locales/de.json +2 -0
- package/src/i18n/locales/en.json +2 -0
- package/src/i18n/locales/es.json +2 -0
- package/src/i18n/locales/fr.json +2 -0
- package/src/i18n/locales/it.json +2 -0
- package/src/i18n/locales/ja.json +2 -0
- package/src/i18n/locales/ko.json +2 -0
- package/src/i18n/locales/pt.json +2 -0
- package/src/i18n/locales/ru.json +2 -0
- package/src/i18n/locales/tr.json +2 -0
- package/src/i18n/locales/zh-TW.json +2 -0
- package/src/i18n/locales/zh.json +2 -0
- package/src/router/HistoryNavigator.ts +4 -1
- package/src/router/Nav.tsx +7 -1
- package/src/router/Router.tsx +18 -4
- package/src/router/path.test.ts +87 -0
- package/src/router/path.ts +38 -0
- package/src/session/UserSession.ts +32 -9
- package/src/session/UserSessionProvider.tsx +138 -62
- package/src/session/auth/composable.test.ts +111 -0
- package/src/session/auth/composable.ts +58 -3
- package/src/session/auth/domainRouting.test.ts +28 -1
- package/src/session/auth/domainRouting.ts +38 -0
- package/src/shell/login/SignInRestrictedEnvStep.tsx +1 -1
- package/src/shell/login/SigninScreen.tsx +9 -1
- package/src/shell/login/productionUrl.test.ts +8 -5
- package/src/shell/login/productionUrl.ts +15 -5
package/src/router/Router.tsx
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
188
|
-
|
|
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
|
+
});
|
package/src/router/path.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
//
|
|
159
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
})
|
|
189
|
-
.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
+
});
|