Compare commits

..

4 Commits

Author SHA1 Message Date
José René White Enciso
39f5af23e4 feat(thalos-web): harden production auth experience 2026-03-31 16:06:15 -06:00
José René White Enciso
24bcf71048 merge: archive superseded thalos web central login branch 2026-03-11 12:56:43 -06:00
José René White Enciso
f10788691d merge: integrate thalos-web oidc login support 2026-03-11 12:06:31 -06:00
José René White Enciso
4af2e553ac feat(thalos-web): harden oidc callback and session auth ux 2026-03-11 11:30:44 -06:00
20 changed files with 1826 additions and 374 deletions

View File

@ -5,8 +5,8 @@ cat > /usr/share/nginx/html/runtime-config.js <<EOT
window.__APP_CONFIG__ = { window.__APP_CONFIG__ = {
API_BASE_URL: "${API_BASE_URL:-http://localhost:8080}", API_BASE_URL: "${API_BASE_URL:-http://localhost:8080}",
THALOS_AUTH_BASE_URL: "${THALOS_AUTH_BASE_URL:-${API_BASE_URL:-http://localhost:8080}}", THALOS_AUTH_BASE_URL: "${THALOS_AUTH_BASE_URL:-${API_BASE_URL:-http://localhost:8080}}",
THALOS_DEFAULT_RETURN_URL: "${THALOS_DEFAULT_RETURN_URL:-https://auth.dream-views.com/}", THALOS_DEFAULT_RETURN_URL: "${THALOS_DEFAULT_RETURN_URL:-http://localhost:22080/callback}",
THALOS_ALLOWED_RETURN_HOSTS: "${THALOS_ALLOWED_RETURN_HOSTS:-auth.dream-views.com,furniture-display-demo.dream-views.com,furniture-admin-demo.dream-views.com,kitchen-ops-demo.dream-views.com,waiter-floor-demo.dream-views.com,customer-orders-demo.dream-views.com,pos-transactions-demo.dream-views.com,restaurant-admin-demo.dream-views.com,localhost}", THALOS_DEFAULT_TENANT_ID: "${THALOS_DEFAULT_TENANT_ID:-demo-tenant}",
THALOS_DEFAULT_TENANT_ID: "${THALOS_DEFAULT_TENANT_ID:-demo-tenant}" THALOS_ENABLE_MANUAL_LOGIN: "${THALOS_ENABLE_MANUAL_LOGIN:-false}"
}; };
EOT EOT

View File

@ -1,21 +1,23 @@
# Frontend Boundary # Frontend Boundary
- This repository hosts a React edge application for a single BFF. - This repository hosts the central identity web surface for Thalos.
- Frontend data access flows through `src/api/*` adapter modules. - Frontend data access flows through `src/api/*` adapter modules.
- The UI does not access DAL or internal services directly. - The UI does not access DAL or internal services directly.
- Route shell and protected sections are session-aware via Thalos session endpoints. - Route shell uses Ant Design layout/menu and keeps session workspace behind session checks.
- The app is the central login shell for `auth.dream-views.com` and only redirects to allowlisted return hosts. - Callback handling validates OIDC query states and normalizes return paths to same-origin routes.
## Runtime Base URLs ## Runtime Base URLs
- `API_BASE_URL` for business BFF calls. - `API_BASE_URL` for business BFF calls.
- `THALOS_AUTH_BASE_URL` for session login/refresh/logout/me. - `THALOS_AUTH_BASE_URL` for session and OIDC endpoints.
- `THALOS_DEFAULT_RETURN_URL` as the fallback post-login destination. - `THALOS_DEFAULT_RETURN_URL` for callback fallback.
- `THALOS_ALLOWED_RETURN_HOSTS` as the return-url allowlist. - `THALOS_DEFAULT_TENANT_ID` for OIDC tenant defaults.
- `THALOS_DEFAULT_TENANT_ID` as login context default. - `THALOS_ENABLE_MANUAL_LOGIN` for explicitly enabling the dev/test fallback form.
## Protected Workflow Endpoints ## Protected Workflow Endpoints
- `GET /api/identity/oidc/google/start`
- `GET /api/identity/oidc/google/callback`
- `POST /api/identity/session/login` - `POST /api/identity/session/login`
- `POST /api/identity/session/refresh` - `POST /api/identity/session/refresh`
- `POST /api/identity/session/logout` - `POST /api/identity/session/logout`
@ -23,8 +25,7 @@
## UI Workflow Coverage ## UI Workflow Coverage
- Google OIDC start-link generation with `returnUrl` and `tenantId` - Central login launch (Google OIDC start)
- Session me/profile inspection - Callback processing and error rendering
- Session refresh - Session workspace verification and snapshot reload
- Session logout - Manual dev/test session login fallback gated by environment/runtime config
- Safe fallback when request return host is not allowlisted

View File

@ -11,10 +11,10 @@ docker build -t agilewebs/thalos-web:dev .
```bash ```bash
docker run --rm -p 8080:8080 \ docker run --rm -p 8080:8080 \
-e API_BASE_URL=http://host.docker.internal:8080 \ -e API_BASE_URL=http://host.docker.internal:8080 \
-e THALOS_AUTH_BASE_URL=http://host.docker.internal:20080 \ -e THALOS_AUTH_BASE_URL=http://host.docker.internal:22080 \
-e THALOS_DEFAULT_RETURN_URL=https://auth.dream-views.com/ \ -e THALOS_DEFAULT_RETURN_URL=http://localhost:22080/callback \
-e THALOS_ALLOWED_RETURN_HOSTS=auth.dream-views.com,localhost \
-e THALOS_DEFAULT_TENANT_ID=demo-tenant \ -e THALOS_DEFAULT_TENANT_ID=demo-tenant \
-e THALOS_ENABLE_MANUAL_LOGIN=false \
--name thalos-web agilewebs/thalos-web:dev --name thalos-web agilewebs/thalos-web:dev
``` ```
@ -23,7 +23,8 @@ docker run --rm -p 8080:8080 \
- Build-time fallback: `VITE_API_BASE_URL` - Build-time fallback: `VITE_API_BASE_URL`
- Runtime override: container env `API_BASE_URL` - Runtime override: container env `API_BASE_URL`
- Runtime file generated at startup: `/runtime-config.js` - Runtime file generated at startup: `/runtime-config.js`
- OIDC login context is configured via runtime env, not build-time hardcoding. - OIDC callback defaults are injected through runtime env vars, not hardcoded per build.
- Manual fallback login should remain disabled for deployed auth hosts unless an explicit dev/test override is required.
## Health Check ## Health Check

View File

@ -11,18 +11,18 @@ npm install
```bash ```bash
VITE_API_BASE_URL=http://localhost:8080 \ VITE_API_BASE_URL=http://localhost:8080 \
VITE_THALOS_AUTH_BASE_URL=http://localhost:20080 \ VITE_THALOS_AUTH_BASE_URL=http://localhost:20080 \
VITE_THALOS_DEFAULT_RETURN_URL=https://auth.dream-views.com/ \ VITE_THALOS_DEFAULT_RETURN_URL=http://localhost:22080/callback \
VITE_THALOS_ALLOWED_RETURN_HOSTS=auth.dream-views.com,localhost \
VITE_THALOS_DEFAULT_TENANT_ID=demo-tenant \ VITE_THALOS_DEFAULT_TENANT_ID=demo-tenant \
VITE_THALOS_ENABLE_MANUAL_LOGIN=true \
npm run dev npm run dev
``` ```
## Auth Model ## Auth Model
- Login is executed against Thalos session endpoints. - Central login starts via `GET /api/identity/oidc/google/start`.
- Google OIDC login start URL is generated from query params with return-url allowlist validation. - Callback route validates query parameters and resolves session by calling refresh/me endpoints.
- Business calls are gated behind session checks.
- Session cookies are sent with `credentials: include`. - Session cookies are sent with `credentials: include`.
- Manual session login is available locally by setting `VITE_THALOS_ENABLE_MANUAL_LOGIN=true`.
## Build ## Build

View File

@ -16,8 +16,9 @@ npm run test:ci
- `src/api/client.test.ts`: runtime-config precedence and fallback behavior. - `src/api/client.test.ts`: runtime-config precedence and fallback behavior.
- `src/api/dashboardApi.test.ts`: endpoint path/query composition and payload mapping. - `src/api/dashboardApi.test.ts`: endpoint path/query composition and payload mapping.
- `src/auth/oidcLogin.test.ts`: return-url sanitization and allowlist behavior. - `src/auth/oidcLogin.test.ts`: OIDC start-url generation and safe return-url fallback.
- `src/App.test.tsx`: central login render, safe return-url fallback, and authenticated session workflow. - `src/auth/callbackState.test.ts`: callback query parsing and safe return-path behavior.
- `src/App.test.tsx`: central login route, callback success redirect, and callback error journey.
## Notes ## Notes

1159
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,8 +11,11 @@
"test:ci": "vitest run --coverage=false" "test:ci": "vitest run --coverage=false"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.1.0",
"antd": "^5.27.4",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1" "react-dom": "^19.1.1",
"react-router-dom": "^7.9.2"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.1.10", "@types/react": "^19.1.10",

View File

@ -1,7 +1,7 @@
window.__APP_CONFIG__ = { window.__APP_CONFIG__ = {
API_BASE_URL: "http://localhost:8080", API_BASE_URL: "http://localhost:8080",
THALOS_AUTH_BASE_URL: "http://localhost:20080", THALOS_AUTH_BASE_URL: "http://localhost:20080",
THALOS_DEFAULT_RETURN_URL: "https://auth.dream-views.com/", THALOS_DEFAULT_RETURN_URL: "http://localhost:22080/callback",
THALOS_ALLOWED_RETURN_HOSTS: "auth.dream-views.com,localhost", THALOS_DEFAULT_TENANT_ID: "demo-tenant",
THALOS_DEFAULT_TENANT_ID: "demo-tenant" THALOS_ENABLE_MANUAL_LOGIN: "true"
}; };

View File

@ -13,56 +13,60 @@ vi.mock('./api/dashboardApi', () => ({
})); }));
import { loadDashboard } from './api/dashboardApi'; import { loadDashboard } from './api/dashboardApi';
import { getSessionMe } from './api/sessionApi'; import { getSessionMe, refreshSession } from './api/sessionApi';
import App from './App'; import App from './App';
describe('Thalos App', () => { describe('Thalos App', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(loadDashboard).mockReset(); vi.mocked(loadDashboard).mockReset();
vi.mocked(getSessionMe).mockReset(); vi.mocked(getSessionMe).mockReset();
window.history.pushState({}, '', '/'); vi.mocked(refreshSession).mockReset();
window.__APP_CONFIG__ = { window.__APP_CONFIG__ = {
API_BASE_URL: 'http://localhost:8080', API_BASE_URL: 'http://localhost:8080',
THALOS_AUTH_BASE_URL: 'http://localhost:20080', THALOS_AUTH_BASE_URL: 'https://auth.dream-views.com',
THALOS_DEFAULT_RETURN_URL: 'https://auth.dream-views.com/', THALOS_DEFAULT_RETURN_URL: `${window.location.origin}/callback`,
THALOS_ALLOWED_RETURN_HOSTS: 'auth.dream-views.com,furniture-display-demo.dream-views.com', THALOS_DEFAULT_TENANT_ID: 'demo-tenant',
THALOS_DEFAULT_TENANT_ID: 'demo-tenant' THALOS_ENABLE_MANUAL_LOGIN: 'false'
}; };
}); });
it('renders central login link with safe return URL context', async () => { it('renders central google login action on login route', async () => {
window.history.pushState(
{},
'',
'/?returnUrl=https%3A%2F%2Ffurniture-display-demo.dream-views.com%2Fdashboard&tenantId=tenant-a'
);
vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 0 }); vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 0 });
window.history.pushState({}, '', '/login');
render(<App />); render(<App />);
await waitFor(() => expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeInTheDocument()); await waitFor(() => expect(screen.getByRole('link', { name: 'Continue with Google' })).toBeInTheDocument());
expect(screen.getByText(/tenant: tenant-a/i)).toBeInTheDocument(); const link = screen.getByRole('link', { name: 'Continue with Google' }) as HTMLAnchorElement;
expect(screen.getByText(/return: https:\/\/furniture-display-demo\.dream-views\.com\/dashboard/i)).toBeInTheDocument();
const link = screen.getByRole('link') as HTMLAnchorElement;
expect(link.href).toContain('/api/identity/oidc/google/start'); expect(link.href).toContain('/api/identity/oidc/google/start');
expect(link.href).toContain('tenantId=tenant-a'); expect(link.href).toContain('tenantId=demo-tenant');
expect(link.href).toContain(
encodeURIComponent('https://furniture-display-demo.dream-views.com/dashboard')
);
}); });
it('falls back to default return URL when requested host is not allowed', async () => { it('processes callback route and refreshes session state', async () => {
window.history.pushState({}, '', '/?returnUrl=https%3A%2F%2Fevil.example.com%2Fsteal'); vi.mocked(getSessionMe)
vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 0 }); .mockResolvedValueOnce({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 0 })
.mockResolvedValueOnce({ isAuthenticated: true, subjectId: 'demo-user', tenantId: 'demo-tenant', provider: 0 });
vi.mocked(refreshSession).mockResolvedValue({
subjectId: 'demo-user',
tenantId: 'demo-tenant',
provider: 0,
expiresInSeconds: 1800
});
vi.mocked(loadDashboard).mockResolvedValue({
isAuthenticated: true,
subjectId: 'demo-user',
tenantId: 'demo-tenant',
provider: 0
});
window.history.pushState({}, '', '/callback?returnUrl=%2Fsession');
render(<App />); render(<App />);
await waitFor(() => expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeInTheDocument()); await waitFor(() => expect(refreshSession).toHaveBeenCalledTimes(1));
expect(screen.getByText(/return: https:\/\/auth\.dream-views\.com\//i)).toBeInTheDocument(); await waitFor(() => expect(screen.getByText('Authentication callback completed.')).toBeInTheDocument());
}); });
it('loads protected session snapshot for authenticated users', async () => { it('loads session snapshot for authenticated users', async () => {
vi.mocked(getSessionMe).mockResolvedValue({ vi.mocked(getSessionMe).mockResolvedValue({
isAuthenticated: true, isAuthenticated: true,
subjectId: 'demo-user', subjectId: 'demo-user',
@ -76,14 +80,42 @@ describe('Thalos App', () => {
provider: 0 provider: 0
}); });
window.history.pushState({}, '', '/session');
render(<App />); render(<App />);
await waitFor(() => expect(screen.getByRole('button', { name: 'Reload Snapshot' })).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('Session Workspace')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Reload Snapshot' })); fireEvent.click(screen.getByRole('button', { name: 'Reload Snapshot' }));
await waitFor(() => expect(loadDashboard).toHaveBeenCalledTimes(1));
});
await waitFor(() => { it('shows callback error state when oidc provider returns an error', async () => {
expect(loadDashboard).toHaveBeenCalledTimes(1); vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 0 });
expect(screen.getByText('subject: demo-user')).toBeInTheDocument(); window.history.pushState({}, '', '/callback?error=access_denied&error_description=User%20cancelled');
});
render(<App />);
await waitFor(() => expect(screen.getByText('User cancelled')).toBeInTheDocument());
expect(refreshSession).not.toHaveBeenCalled();
});
it('hides manual fallback on production-style auth host config', async () => {
vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 0 });
window.history.pushState({}, '', '/login');
render(<App />);
await waitFor(() => expect(screen.getByText('Sign in required')).toBeInTheDocument());
expect(screen.queryByText('Manual Session Login (Dev/Test)')).not.toBeInTheDocument();
expect(screen.getByText('Manual session login is disabled in this environment. Use the Google sign-in path above.')).toBeInTheDocument();
});
it('shows correlation id when callback returns bff auth error context', async () => {
vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 0 });
window.history.pushState({}, '', '/callback?authError=oidc_exchange_failed&correlationId=corr-123');
render(<App />);
await waitFor(() => expect(screen.getByText('The sign-in code could not be exchanged. Please retry the login flow.')).toBeInTheDocument());
expect(screen.getByText('Correlation ID: corr-123')).toBeInTheDocument();
}); });
}); });

View File

@ -1,23 +1,268 @@
import { useMemo, useState } from 'react'; import { DeploymentUnitOutlined, LoginOutlined, ReloadOutlined } from '@ant-design/icons';
import {
Alert,
Button,
Card,
Descriptions,
Form,
Input,
Layout,
Menu,
Result,
Select,
Space,
Spin,
Tag,
Typography
} from 'antd';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { loadDashboard } from './api/dashboardApi'; import { loadDashboard } from './api/dashboardApi';
import { SessionProvider, useSessionContext } from './auth/sessionContext'; import { getThalosDefaultReturnUrl, isThalosManualLoginEnabled } from './api/client';
import { resolveOidcLoginContext } from './auth/oidcLogin';
import type { IdentityProvider } from './api/sessionApi'; import type { IdentityProvider } from './api/sessionApi';
import { parseOidcCallbackState } from './auth/callbackState';
import { buildGoogleOidcStartUrl } from './auth/oidcLogin';
import { SessionProvider, useSessionContext } from './auth/sessionContext';
type AppRoute = '/login' | '/session';
const routeItems: Array<{ key: AppRoute; label: string; icon: ReactNode }> = [
{ key: '/login', label: 'Login', icon: <LoginOutlined /> },
{ key: '/session', label: 'Session', icon: <DeploymentUnitOutlined /> }
];
function App() { function App() {
return ( return (
<SessionProvider> <SessionProvider>
<ThalosShell /> <BrowserRouter>
<ThalosShell />
</BrowserRouter>
</SessionProvider> </SessionProvider>
); );
} }
type RouteKey = 'overview' | 'session';
function ThalosShell() { function ThalosShell() {
const session = useSessionContext(); const session = useSessionContext();
const oidcLogin = useMemo(() => resolveOidcLoginContext(window.location.search), []); const location = useLocation();
const [route, setRoute] = useState<RouteKey>('overview'); const navigate = useNavigate();
const selectedKey = useMemo(() => {
const candidate = routeItems.find((item) => location.pathname.startsWith(item.key));
return candidate?.key ?? '/login';
}, [location.pathname]);
return (
<Layout className="full-layout">
<Layout.Sider width={240} breakpoint="lg" collapsedWidth={0}>
<div className="brand">Thalos Web</div>
<Menu
mode="inline"
selectedKeys={[selectedKey]}
items={routeItems}
onClick={(event) => navigate(event.key as AppRoute)}
/>
</Layout.Sider>
<Layout>
<Layout.Header className="header">
{session.profile ? (
<Space wrap>
<Tag color="blue">subject: {session.profile.subjectId}</Tag>
<Tag color="geekblue">tenant: {session.profile.tenantId}</Tag>
<Tag color="purple">provider: {providerLabel(session.profile.provider)}</Tag>
</Space>
) : (
<Typography.Text type="secondary">No active session</Typography.Text>
)}
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void session.refresh()}>
Refresh Session
</Button>
<Button danger onClick={() => void session.logout()}>
Logout
</Button>
</Space>
</Layout.Header>
<Layout.Content className="content">
{session.error && <Alert className="stack-gap" type="warning" showIcon message={session.error} />}
<Routes>
<Route path="/login" element={<LoginRoute />} />
<Route path="/callback" element={<CallbackRoute />} />
<Route path="/session" element={<SessionRoute />} />
<Route path="/" element={<Navigate to={session.status === 'authenticated' ? '/session' : '/login'} replace />} />
<Route path="*" element={<Navigate to={session.status === 'authenticated' ? '/session' : '/login'} replace />} />
</Routes>
</Layout.Content>
</Layout>
</Layout>
);
}
function LoginRoute() {
const session = useSessionContext();
const manualLoginEnabled = useMemo(() => isThalosManualLoginEnabled(), []);
const [subjectId, setSubjectId] = useState('demo-user');
const [tenantId, setTenantId] = useState('demo-tenant');
const [provider, setProvider] = useState<IdentityProvider>(0);
const [externalToken, setExternalToken] = useState('');
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const loginUrl = useMemo(() => buildGoogleOidcStartUrl(getThalosDefaultReturnUrl()), []);
const onSubmit = async () => {
setSubmitting(true);
setError(null);
try {
await session.login({
subjectId,
tenantId,
provider,
externalToken
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed.');
} finally {
setSubmitting(false);
}
};
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Typography.Title level={3}>Thalos Authentication</Typography.Title>
<Typography.Paragraph type="secondary">
Use the central OIDC journey for browser login. Manual form login is only exposed for local and explicitly enabled dev/test environments.
</Typography.Paragraph>
<Card>
<Result
status={session.status === 'authenticated' ? 'success' : 'info'}
title={session.status === 'authenticated' ? 'Session active' : 'Sign in required'}
subTitle="Start Google OIDC through the central auth host callback flow."
extra={
<Button type="primary" href={loginUrl}>
Continue with Google
</Button>
}
/>
</Card>
{manualLoginEnabled ? (
<Card title="Manual Session Login (Dev/Test)">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="This fallback is intended for local development, simulated-provider testing, and operator troubleshooting only."
/>
<Form layout="vertical" onFinish={() => void onSubmit()}>
<Form.Item label="Subject Id" required>
<Input value={subjectId} onChange={(event) => setSubjectId(event.target.value)} />
</Form.Item>
<Form.Item label="Tenant Id" required>
<Input value={tenantId} onChange={(event) => setTenantId(event.target.value)} />
</Form.Item>
<Form.Item label="Provider" required>
<Select
value={provider}
onChange={(value) => setProvider(value as IdentityProvider)}
options={[
{ value: 0, label: 'Internal JWT' },
{ value: 1, label: 'Azure AD (simulated)' },
{ value: 2, label: 'Google (simulated)' }
]}
/>
</Form.Item>
<Form.Item label="External Token (optional)">
<Input value={externalToken} onChange={(event) => setExternalToken(event.target.value)} />
</Form.Item>
<Button type="primary" htmlType="submit" loading={submitting}>
Sign In
</Button>
</Form>
{error && <Alert type="error" showIcon message={error} />}
</Space>
</Card>
) : (
<Alert
type="info"
showIcon
message="Manual session login is disabled in this environment. Use the Google sign-in path above."
/>
)}
</Space>
);
}
function CallbackRoute() {
const session = useSessionContext();
const location = useLocation();
const navigate = useNavigate();
const callback = useMemo(() => parseOidcCallbackState(location.search), [location.search]);
const [phase, setPhase] = useState<'processing' | 'success' | 'failure'>(callback.kind === 'error' ? 'failure' : 'processing');
const [message, setMessage] = useState<string | null>(callback.kind === 'error' ? callback.message : null);
useEffect(() => {
if (callback.kind === 'error') {
setPhase('failure');
setMessage(callback.message);
return;
}
let isCancelled = false;
const finalize = async () => {
try {
await session.refresh();
if (!isCancelled) {
setPhase('success');
setMessage('Authentication callback completed.');
navigate(callback.returnPath, { replace: true });
}
} catch (err) {
if (!isCancelled) {
setPhase('failure');
setMessage(err instanceof Error ? err.message : 'Callback processing failed.');
}
}
};
void finalize();
return () => {
isCancelled = true;
};
}, [callback, navigate, session]);
if (phase === 'processing') {
return (
<Card>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Spin />
<Typography.Text>Processing authentication callback...</Typography.Text>
</Space>
</Card>
);
}
if (phase === 'success') {
return <Alert type="success" showIcon message={message ?? 'Authenticated.'} />;
}
return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Alert type="error" showIcon message={message ?? 'Callback failed.'} />
{callback.kind === 'error' && callback.correlationId ? (
<Typography.Text type="secondary">Correlation ID: {callback.correlationId}</Typography.Text>
) : null}
<Button type="primary" onClick={() => navigate('/login', { replace: true })}>
Back to login
</Button>
</Space>
);
}
function SessionRoute() {
const session = useSessionContext();
const [snapshot, setSnapshot] = useState<unknown>(null); const [snapshot, setSnapshot] = useState<unknown>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -37,96 +282,41 @@ function ThalosShell() {
if (session.status === 'loading') { if (session.status === 'loading') {
return ( return (
<main className="app"> <div className="fullscreen-center">
<h1>Thalos Web</h1> <Spin size="large" />
<p className="muted">Restoring session...</p> </div>
</main>
); );
} }
if (session.status !== 'authenticated' || !session.profile) { if (session.status !== 'authenticated' || !session.profile) {
return ( return <Navigate to="/login" replace />;
<main className="app">
<h1>Thalos Web</h1>
<p className="muted">Central login entrypoint for `auth.dream-views.com`.</p>
<p className="muted">After successful authentication, you will return to the requested allowed destination.</p>
{session.error && <div className="alert">{session.error}</div>}
<section className="card col">
<strong>Login Context</strong>
<span className="badge">tenant: {oidcLogin.tenantId}</span>
<span className="badge">return: {oidcLogin.returnUrl}</span>
<a href={oidcLogin.startUrl}>
<button type="button">Continue with Google</button>
</a>
</section>
</main>
);
} }
return ( return (
<main className="app"> <Space direction="vertical" size="large" style={{ width: '100%' }}>
<h1>Thalos Web</h1> <Typography.Title level={3}>Session Workspace</Typography.Title>
<p className="muted">Central session management shell for auth callback and profile checks.</p> <Typography.Paragraph type="secondary">
Validate session continuity, profile inspection, refresh behavior, and logout flow.
</Typography.Paragraph>
<section className="card row"> <Card title="Session Snapshot">
<span className="badge">subject: {session.profile.subjectId}</span> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
<span className="badge">tenant: {session.profile.tenantId}</span> <Button type="primary" onClick={() => void onReloadSnapshot()} loading={busy}>
<span className="badge">provider: {providerLabel(session.profile.provider)}</span> Reload Snapshot
<span className="spacer" /> </Button>
<button type="button" onClick={() => void session.refresh()}> {error && <Alert type="error" showIcon message={error} />}
Refresh Session
</button>
<a href={oidcLogin.returnUrl}>
<button type="button" className="secondary">
Continue to App
</button>
</a>
<button type="button" className="warn" onClick={() => void session.logout()}>
Logout
</button>
</section>
<section className="card tabs" aria-label="route-shell">
<button
type="button"
className={route === 'overview' ? 'active' : undefined}
onClick={() => setRoute('overview')}
>
Overview
</button>
<button
type="button"
className={route === 'session' ? 'active' : undefined}
onClick={() => setRoute('session')}
>
Session Routing
</button>
</section>
{session.error && <div className="alert">{session.error}</div>}
{error && <div className="alert">{error}</div>}
{route === 'overview' && (
<section className="card col">
<div className="row">
<strong>Session Snapshot</strong>
<span className="spacer" />
<button type="button" onClick={() => void onReloadSnapshot()} disabled={busy}>
{busy ? 'Loading...' : 'Reload Snapshot'}
</button>
</div>
<pre>{JSON.stringify(snapshot ?? session.profile, null, 2)}</pre> <pre>{JSON.stringify(snapshot ?? session.profile, null, 2)}</pre>
</section> </Space>
)} </Card>
{route === 'session' && ( <Card title="Session Profile">
<section className="card col"> <Descriptions bordered size="small" column={1}>
<strong>Central Redirect Target</strong> <Descriptions.Item label="Subject">{session.profile.subjectId}</Descriptions.Item>
<span className="badge">{oidcLogin.returnUrl}</span> <Descriptions.Item label="Tenant">{session.profile.tenantId}</Descriptions.Item>
<p className="muted">Use this shell to confirm session state before returning to the requested app.</p> <Descriptions.Item label="Provider">{providerLabel(session.profile.provider)}</Descriptions.Item>
</section> </Descriptions>
)} </Card>
</main> </Space>
); );
} }

View File

@ -1,10 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
getApiBaseUrl, getApiBaseUrl,
getThalosAllowedReturnHosts,
getThalosAuthBaseUrl, getThalosAuthBaseUrl,
getThalosDefaultReturnUrl, getThalosDefaultReturnUrl,
getThalosDefaultTenantId getThalosDefaultTenantId,
isThalosManualLoginEnabled
} from './client'; } from './client';
describe('client runtime base URLs', () => { describe('client runtime base URLs', () => {
@ -45,24 +45,41 @@ describe('client runtime base URLs', () => {
expect(getApiBaseUrl()).toBe('http://localhost:8080'); expect(getApiBaseUrl()).toBe('http://localhost:8080');
}); });
it('reads default return URL and tenant from runtime config when available', () => { it('uses configured default return URL when present', () => {
window.__APP_CONFIG__ = { window.__APP_CONFIG__ = {
THALOS_DEFAULT_RETURN_URL: 'https://auth.dream-views.com/', THALOS_DEFAULT_RETURN_URL: `${window.location.origin}/callback`
THALOS_DEFAULT_TENANT_ID: 'tenant-1'
}; };
expect(getThalosDefaultReturnUrl()).toBe('https://auth.dream-views.com/'); expect(getThalosDefaultReturnUrl()).toBe(`${window.location.origin}/callback`);
expect(getThalosDefaultTenantId()).toBe('tenant-1');
}); });
it('parses allowed return hosts from runtime config', () => { it('falls back to callback route when no return URL is configured', () => {
expect(getThalosDefaultReturnUrl()).toBe(`${window.location.origin}/callback`);
});
it('uses configured default tenant when present', () => {
window.__APP_CONFIG__ = { window.__APP_CONFIG__ = {
THALOS_ALLOWED_RETURN_HOSTS: 'auth.dream-views.com, furniture-display-demo.dream-views.com' THALOS_DEFAULT_TENANT_ID: 'tenant-alpha'
}; };
expect(getThalosAllowedReturnHosts()).toEqual([ expect(getThalosDefaultTenantId()).toBe('tenant-alpha');
'auth.dream-views.com', });
'furniture-display-demo.dream-views.com'
]); it('falls back to demo tenant when no tenant is configured', () => {
expect(getThalosDefaultTenantId()).toBe('demo-tenant');
});
it('uses runtime-config manual-login toggle when present', () => {
window.__APP_CONFIG__ = {
THALOS_ENABLE_MANUAL_LOGIN: 'true'
};
expect(isThalosManualLoginEnabled()).toBe(true);
});
it('falls back to env manual-login toggle when runtime config is missing', () => {
vi.stubEnv('VITE_THALOS_ENABLE_MANUAL_LOGIN', 'false');
expect(isThalosManualLoginEnabled()).toBe(false);
}); });
}); });

View File

@ -4,8 +4,8 @@ declare global {
API_BASE_URL?: string; API_BASE_URL?: string;
THALOS_AUTH_BASE_URL?: string; THALOS_AUTH_BASE_URL?: string;
THALOS_DEFAULT_RETURN_URL?: string; THALOS_DEFAULT_RETURN_URL?: string;
THALOS_ALLOWED_RETURN_HOSTS?: string;
THALOS_DEFAULT_TENANT_ID?: string; THALOS_DEFAULT_TENANT_ID?: string;
THALOS_ENABLE_MANUAL_LOGIN?: string;
}; };
} }
} }
@ -56,21 +56,7 @@ export function getThalosDefaultReturnUrl(): string {
return runtimeValue; return runtimeValue;
} }
return import.meta.env.VITE_THALOS_DEFAULT_RETURN_URL ?? `${window.location.origin}/`; return import.meta.env.VITE_THALOS_DEFAULT_RETURN_URL ?? `${window.location.origin}/callback`;
}
export function getThalosAllowedReturnHosts(): string[] {
const runtimeValue = window.__APP_CONFIG__?.THALOS_ALLOWED_RETURN_HOSTS;
if (runtimeValue && runtimeValue.length > 0) {
return parseHosts(runtimeValue);
}
const envValue = import.meta.env.VITE_THALOS_ALLOWED_RETURN_HOSTS;
if (envValue && envValue.length > 0) {
return parseHosts(envValue);
}
return [window.location.hostname];
} }
export function getThalosDefaultTenantId(): string { export function getThalosDefaultTenantId(): string {
@ -82,6 +68,20 @@ export function getThalosDefaultTenantId(): string {
return import.meta.env.VITE_THALOS_DEFAULT_TENANT_ID ?? 'demo-tenant'; return import.meta.env.VITE_THALOS_DEFAULT_TENANT_ID ?? 'demo-tenant';
} }
export function isThalosManualLoginEnabled(): boolean {
const runtimeValue = parseBoolean(window.__APP_CONFIG__?.THALOS_ENABLE_MANUAL_LOGIN);
if (runtimeValue !== undefined) {
return runtimeValue;
}
const envValue = parseBoolean(import.meta.env.VITE_THALOS_ENABLE_MANUAL_LOGIN);
if (envValue !== undefined) {
return envValue;
}
return window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
}
export async function getJson<T>(path: string, baseUrl = getApiBaseUrl()): Promise<T> { export async function getJson<T>(path: string, baseUrl = getApiBaseUrl()): Promise<T> {
return requestJson<T>(baseUrl, path, { method: 'GET' }); return requestJson<T>(baseUrl, path, { method: 'GET' });
} }
@ -158,9 +158,23 @@ function createCorrelationId(): string {
return `corr-${Date.now()}-${Math.random().toString(16).slice(2)}`; return `corr-${Date.now()}-${Math.random().toString(16).slice(2)}`;
} }
function parseHosts(rawValue: string): string[] { function parseBoolean(value: string | boolean | undefined): boolean | undefined {
return rawValue if (typeof value === 'boolean') {
.split(',') return value;
.map((host) => host.trim()) }
.filter((host) => host.length > 0);
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (normalized === 'true') {
return true;
}
if (normalized === 'false') {
return false;
}
return undefined;
} }

View File

@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { parseOidcCallbackState } from './callbackState';
describe('callback state parser', () => {
afterEach(() => {
delete window.__APP_CONFIG__;
vi.unstubAllEnvs();
});
it('returns error state when callback has error query', () => {
const result = parseOidcCallbackState('?error=access_denied&error_description=User%20cancelled');
expect(result.kind).toBe('error');
if (result.kind === 'error') {
expect(result.message).toBe('User cancelled');
}
});
it('returns auth error state when bff redirects with authError query values', () => {
const result = parseOidcCallbackState('?authError=oidc_exchange_failed&correlationId=corr-123');
expect(result.kind).toBe('error');
if (result.kind === 'error') {
expect(result.message).toBe('The sign-in code could not be exchanged. Please retry the login flow.');
expect(result.correlationId).toBe('corr-123');
}
});
it('returns success state with sanitized same-origin return path', () => {
const result = parseOidcCallbackState(`?returnUrl=${encodeURIComponent(`${window.location.origin}/session?tab=profile`)}`);
expect(result).toEqual({ kind: 'success', returnPath: '/session?tab=profile' });
});
it('rejects external return paths and falls back to /session', () => {
const result = parseOidcCallbackState('?returnUrl=https%3A%2F%2Fevil.example%2Fpwn');
expect(result).toEqual({ kind: 'success', returnPath: '/session' });
});
});

78
src/auth/callbackState.ts Normal file
View File

@ -0,0 +1,78 @@
import { getThalosDefaultReturnUrl } from '../api/client';
type OidcCallbackSuccess = {
kind: 'success';
returnPath: string;
};
type OidcCallbackError = {
kind: 'error';
message: string;
correlationId?: string;
};
export type OidcCallbackState = OidcCallbackSuccess | OidcCallbackError;
export function parseOidcCallbackState(search: string): OidcCallbackState {
const query = new URLSearchParams(search);
const error = query.get('error');
const errorDescription = query.get('error_description');
const authError = query.get('authError');
const correlationId = query.get('correlationId') ?? undefined;
if (error && error.length > 0) {
return {
kind: 'error',
message: errorDescription && errorDescription.length > 0 ? errorDescription : `OIDC callback failed: ${error}`,
correlationId
};
}
if (authError && authError.length > 0) {
return {
kind: 'error',
message: getAuthErrorMessage(authError),
correlationId
};
}
const returnUrl = query.get('returnUrl');
return {
kind: 'success',
returnPath: sanitizeReturnPath(returnUrl)
};
}
function getAuthErrorMessage(authError: string): string {
switch (authError) {
case 'oidc_provider_error':
return 'The identity provider rejected the sign-in request.';
case 'oidc_state_invalid':
return 'The sign-in session is no longer valid. Please start again.';
case 'oidc_code_missing':
return 'The identity provider did not return an authorization code.';
case 'oidc_exchange_failed':
return 'The sign-in code could not be exchanged. Please retry the login flow.';
case 'session_login_failed':
return 'The session could not be created after authentication.';
default:
return `Authentication failed: ${authError}`;
}
}
function sanitizeReturnPath(rawReturnUrl: string | null): string {
if (!rawReturnUrl || rawReturnUrl.length === 0) {
return '/session';
}
try {
const parsed = new URL(rawReturnUrl, window.location.origin);
if (parsed.origin !== window.location.origin) {
return '/session';
}
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
} catch {
return sanitizeReturnPath(getThalosDefaultReturnUrl());
}
}

View File

@ -1,34 +1,38 @@
import { describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { sanitizeReturnUrl } from './oidcLogin'; import { buildGoogleOidcStartUrl } from './oidcLogin';
describe('oidc return url handling', () => { describe('oidc login url', () => {
it('keeps return URL when host is allowed', () => { afterEach(() => {
const value = sanitizeReturnUrl( delete window.__APP_CONFIG__;
'https://furniture-display-demo.dream-views.com/app', vi.unstubAllEnvs();
'https://auth.dream-views.com/',
['auth.dream-views.com', 'furniture-display-demo.dream-views.com']
);
expect(value).toBe('https://furniture-display-demo.dream-views.com/app');
}); });
it('falls back to default return URL when host is not allowed', () => { it('builds google oidc start url from runtime config', () => {
const value = sanitizeReturnUrl( window.__APP_CONFIG__ = {
'https://evil.example.com/callback', THALOS_AUTH_BASE_URL: 'https://auth.dream-views.com',
'https://auth.dream-views.com/', THALOS_DEFAULT_RETURN_URL: `${window.location.origin}/callback`,
['auth.dream-views.com', 'furniture-display-demo.dream-views.com'] THALOS_DEFAULT_TENANT_ID: 'tenant-alpha'
); };
expect(value).toBe('https://auth.dream-views.com/'); const result = buildGoogleOidcStartUrl(`${window.location.origin}/callback?source=ui`);
const parsed = new URL(result);
expect(parsed.origin).toBe('https://auth.dream-views.com');
expect(parsed.pathname).toBe('/api/identity/oidc/google/start');
expect(parsed.searchParams.get('tenantId')).toBe('tenant-alpha');
expect(parsed.searchParams.get('returnUrl')).toBe(`${window.location.origin}/callback?source=ui`);
}); });
it('falls back to default return URL when protocol is not http/https', () => { it('falls back to default return url when provided return url is not same-origin', () => {
const value = sanitizeReturnUrl( window.__APP_CONFIG__ = {
'javascript:alert(1)', THALOS_AUTH_BASE_URL: 'https://auth.dream-views.com',
'https://auth.dream-views.com/', THALOS_DEFAULT_RETURN_URL: `${window.location.origin}/callback`,
['auth.dream-views.com'] THALOS_DEFAULT_TENANT_ID: 'tenant-alpha'
); };
expect(value).toBe('https://auth.dream-views.com/'); const result = buildGoogleOidcStartUrl('https://external.example/callback');
const parsed = new URL(result);
expect(parsed.searchParams.get('returnUrl')).toBe(`${window.location.origin}/callback`);
}); });
}); });

View File

@ -1,59 +1,25 @@
import { import { getThalosAuthBaseUrl, getThalosDefaultReturnUrl, getThalosDefaultTenantId } from '../api/client';
getThalosAllowedReturnHosts,
getThalosAuthBaseUrl,
getThalosDefaultReturnUrl,
getThalosDefaultTenantId
} from '../api/client';
export type OidcLoginContext = { export function buildGoogleOidcStartUrl(returnUrl = getThalosDefaultReturnUrl(), tenantId = getThalosDefaultTenantId()): string {
returnUrl: string;
tenantId: string;
startUrl: string;
};
export function resolveOidcLoginContext(search: string): OidcLoginContext {
const query = new URLSearchParams(search);
const returnUrl = sanitizeReturnUrl(query.get('returnUrl'), getThalosDefaultReturnUrl(), getThalosAllowedReturnHosts());
const tenantId = sanitizeTenantId(query.get('tenantId'));
const startUrl = buildGoogleOidcStartUrl(returnUrl, tenantId);
return { returnUrl, tenantId, startUrl };
}
export function sanitizeReturnUrl(rawReturnUrl: string | null, defaultReturnUrl: string, allowedHosts: string[]): string {
if (!rawReturnUrl || rawReturnUrl.length === 0) {
return defaultReturnUrl;
}
let parsed: URL;
try {
parsed = new URL(rawReturnUrl);
} catch {
return defaultReturnUrl;
}
// Prevent protocol abuse and open-redirect fallback to non-browser schemes.
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return defaultReturnUrl;
}
const isAllowed = allowedHosts.some((host) => host.toLowerCase() === parsed.hostname.toLowerCase());
return isAllowed ? parsed.toString() : defaultReturnUrl;
}
export function sanitizeTenantId(rawTenantId: string | null): string {
if (!rawTenantId || rawTenantId.length === 0) {
return getThalosDefaultTenantId();
}
const candidate = rawTenantId.trim();
return candidate.length === 0 ? getThalosDefaultTenantId() : candidate;
}
export function buildGoogleOidcStartUrl(returnUrl: string, tenantId: string): string {
const authBase = getThalosAuthBaseUrl().replace(/\/+$/, ''); const authBase = getThalosAuthBaseUrl().replace(/\/+$/, '');
const safeReturnUrl = sanitizeReturnUrl(returnUrl);
const query = new URLSearchParams({ const query = new URLSearchParams({
returnUrl, returnUrl: safeReturnUrl,
tenantId tenantId
}); });
return `${authBase}/api/identity/oidc/google/start?${query.toString()}`; return `${authBase}/api/identity/oidc/google/start?${query.toString()}`;
} }
function sanitizeReturnUrl(rawReturnUrl: string): string {
try {
const parsed = new URL(rawReturnUrl, window.location.origin);
if ((parsed.protocol === 'https:' || parsed.protocol === 'http:') && parsed.origin === window.location.origin) {
return parsed.toString();
}
} catch {
return getThalosDefaultReturnUrl();
}
return getThalosDefaultReturnUrl();
}

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import 'antd/dist/reset.css';
import './styles.css'; import './styles.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(

View File

@ -1,138 +1,60 @@
:root { :root {
font-family: "IBM Plex Sans", "Segoe UI", sans-serif; font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
color: #111827; background: radial-gradient(circle at 20% 0%, #f8fbff 0%, #eef4ff 45%, #f7fafc 100%);
background: radial-gradient(circle at top, #f7f8fb 0%, #eef2ff 45%, #f8fafc 100%);
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
body { html,
margin: 0; body,
min-height: 100vh;
}
#root { #root {
min-height: 100vh; margin: 0;
min-height: 100%;
height: 100%;
} }
.app { .app {
width: min(960px, 94vw); min-height: 100%;
margin: 0 auto;
padding: 1.5rem 0 3rem;
}
.card {
background: #ffffff;
border: 1px solid #dbe3f4;
border-radius: 14px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
padding: 1rem;
margin-top: 1rem;
}
.row {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.col {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
input,
select,
textarea {
border: 1px solid #c8d2ea;
border-radius: 10px;
padding: 0.6rem 0.8rem;
font: inherit;
}
textarea {
min-height: 120px;
resize: vertical;
}
button {
border: none;
border-radius: 10px;
padding: 0.6rem 0.9rem;
font-weight: 600;
cursor: pointer;
background: #1d4ed8;
color: #ffffff;
}
button.secondary {
background: #475569;
}
button.warn {
background: #b91c1c;
}
button.ghost {
background: #e2e8f0;
color: #0f172a;
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); place-items: center;
gap: 1rem; padding: 2rem 1rem;
} }
.alert { .fullscreen-center {
border-radius: 10px; height: 100%;
padding: 0.7rem 0.9rem; display: grid;
border: 1px solid #fecaca; place-items: center;
background: #fef2f2;
color: #7f1d1d;
} }
.muted { .full-layout {
color: #475569; min-height: 100%;
} }
.badge { .brand {
display: inline-block; color: #e2e8f0;
border-radius: 999px; font-weight: 700;
background: #e2e8f0; letter-spacing: 0.04em;
color: #0f172a; padding: 1rem;
padding: 0.2rem 0.6rem;
font-size: 0.78rem;
font-weight: 600;
} }
.tabs { .header {
background: #ffffff;
border-bottom: 1px solid #e2e8f0;
display: flex; display: flex;
gap: 0.5rem; justify-content: space-between;
flex-wrap: wrap; align-items: center;
gap: 0.75rem;
padding-inline: 1rem;
} }
.tabs button { .content {
background: #dbeafe; padding: 1.25rem;
color: #1e3a8a;
} }
.tabs button.active { .stack-gap {
background: #1d4ed8; margin-bottom: 1rem;
color: #ffffff;
}
.spacer {
flex: 1;
} }
pre { pre {

View File

@ -1 +1,30 @@
import '@testing-library/jest-dom/vitest'; import '@testing-library/jest-dom/vitest';
if (!window.matchMedia) {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false
})
});
}
if (!window.ResizeObserver) {
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserverMock;
}
const originalGetComputedStyle = window.getComputedStyle.bind(window);
window.getComputedStyle = ((element: Element) => originalGetComputedStyle(element)) as typeof window.getComputedStyle;

View File

@ -1 +1 @@
{"root":["./src/App.test.tsx","./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.test.ts","./src/api/client.ts","./src/api/dashboardApi.test.ts","./src/api/dashboardApi.ts","./src/test/setup.ts"],"version":"5.9.3"} {"root":["./src/App.test.tsx","./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.test.ts","./src/api/client.ts","./src/api/dashboardApi.test.ts","./src/api/dashboardApi.ts","./src/api/sessionApi.ts","./src/auth/callbackState.test.ts","./src/auth/callbackState.ts","./src/auth/oidcLogin.test.ts","./src/auth/oidcLogin.ts","./src/auth/sessionContext.tsx","./src/test/setup.ts"],"version":"5.9.3"}