Compare commits
2 Commits
d867b966da
...
f10788691d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f10788691d | ||
|
|
4af2e553ac |
@ -4,6 +4,8 @@ set -eu
|
|||||||
cat > /usr/share/nginx/html/runtime-config.js <<EOT
|
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:-http://localhost:22080/callback}",
|
||||||
|
THALOS_DEFAULT_TENANT_ID: "${THALOS_DEFAULT_TENANT_ID:-demo-tenant}"
|
||||||
};
|
};
|
||||||
EOT
|
EOT
|
||||||
|
|||||||
@ -1,17 +1,22 @@
|
|||||||
# 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.
|
||||||
|
- 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` for callback fallback.
|
||||||
|
- `THALOS_DEFAULT_TENANT_ID` for OIDC tenant defaults.
|
||||||
|
|
||||||
## 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`
|
||||||
@ -19,7 +24,7 @@
|
|||||||
|
|
||||||
## UI Workflow Coverage
|
## UI Workflow Coverage
|
||||||
|
|
||||||
- Session login
|
- 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
|
||||||
|
|||||||
@ -11,6 +11,9 @@ 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:22080 \
|
||||||
|
-e THALOS_DEFAULT_RETURN_URL=http://localhost:22080/callback \
|
||||||
|
-e THALOS_DEFAULT_TENANT_ID=demo-tenant \
|
||||||
--name thalos-web agilewebs/thalos-web:dev
|
--name thalos-web agilewebs/thalos-web:dev
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -19,6 +22,7 @@ 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 callback defaults are injected through runtime env vars, not hardcoded per build.
|
||||||
|
|
||||||
## Health Check
|
## Health Check
|
||||||
|
|
||||||
|
|||||||
@ -11,13 +11,15 @@ 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=http://localhost:22080/callback \
|
||||||
|
VITE_THALOS_DEFAULT_TENANT_ID=demo-tenant \
|
||||||
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`.
|
||||||
- Business calls are gated behind session checks.
|
- Callback route validates query parameters and resolves session by calling refresh/me endpoints.
|
||||||
- Session cookies are sent with `credentials: include`.
|
- Session cookies are sent with `credentials: include`.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|||||||
@ -16,7 +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/App.test.tsx`: protected-route render and workflow trigger behavior.
|
- `src/auth/oidcLogin.test.ts`: OIDC start-url generation and safe return-url fallback.
|
||||||
|
- `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
1159
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
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: "http://localhost:22080/callback",
|
||||||
|
THALOS_DEFAULT_TENANT_ID: "demo-tenant"
|
||||||
};
|
};
|
||||||
|
|||||||
@ -13,35 +13,59 @@ vi.mock('./api/dashboardApi', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { loadDashboard } from './api/dashboardApi';
|
import { loadDashboard } from './api/dashboardApi';
|
||||||
import { getSessionMe, loginSession } 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();
|
||||||
vi.mocked(loginSession).mockReset();
|
vi.mocked(refreshSession).mockReset();
|
||||||
|
window.__APP_CONFIG__ = {
|
||||||
|
API_BASE_URL: 'http://localhost:8080',
|
||||||
|
THALOS_AUTH_BASE_URL: 'https://auth.dream-views.com',
|
||||||
|
THALOS_DEFAULT_RETURN_URL: `${window.location.origin}/callback`,
|
||||||
|
THALOS_DEFAULT_TENANT_ID: 'demo-tenant'
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders login gate and signs in through Thalos session endpoint', async () => {
|
it('renders central google login action on login route', async () => {
|
||||||
vi.mocked(getSessionMe)
|
vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 0 });
|
||||||
.mockResolvedValueOnce({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 0 })
|
window.history.pushState({}, '', '/login');
|
||||||
.mockResolvedValueOnce({ isAuthenticated: true, subjectId: 'demo-user', tenantId: 'demo-tenant', provider: 0 });
|
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByRole('link', { name: 'Continue with Google' })).toBeInTheDocument());
|
||||||
|
const link = screen.getByRole('link', { name: 'Continue with Google' }) as HTMLAnchorElement;
|
||||||
fireEvent.change(screen.getByLabelText('Subject Id'), { target: { value: 'alice' } });
|
expect(link.href).toContain('/api/identity/oidc/google/start');
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Sign In' }));
|
expect(link.href).toContain('tenantId=demo-tenant');
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(loginSession).toHaveBeenCalledTimes(1);
|
|
||||||
expect(screen.getByText(/subject: demo-user/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads protected session snapshot through dashboard adapter', async () => {
|
it('processes callback route and refreshes session state', async () => {
|
||||||
|
vi.mocked(getSessionMe)
|
||||||
|
.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 />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(refreshSession).toHaveBeenCalledTimes(1));
|
||||||
|
await waitFor(() => expect(screen.getByText('Authentication callback completed.')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
@ -55,14 +79,21 @@ 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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
379
src/App.tsx
379
src/App.tsx
@ -1,115 +1,103 @@
|
|||||||
import { 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 } from './api/client';
|
||||||
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>
|
||||||
|
<BrowserRouter>
|
||||||
<ThalosShell />
|
<ThalosShell />
|
||||||
|
</BrowserRouter>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type RouteKey = 'overview' | 'session';
|
|
||||||
|
|
||||||
function ThalosShell() {
|
function ThalosShell() {
|
||||||
const session = useSessionContext();
|
const session = useSessionContext();
|
||||||
const [route, setRoute] = useState<RouteKey>('overview');
|
const location = useLocation();
|
||||||
const [snapshot, setSnapshot] = useState<unknown>(null);
|
const navigate = useNavigate();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const selectedKey = useMemo(() => {
|
||||||
const [busy, setBusy] = useState(false);
|
const candidate = routeItems.find((item) => location.pathname.startsWith(item.key));
|
||||||
|
return candidate?.key ?? '/login';
|
||||||
const onReloadSnapshot = async () => {
|
}, [location.pathname]);
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const payload = await loadDashboard();
|
|
||||||
setSnapshot(payload);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to reload session snapshot.');
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (session.status === 'loading') {
|
|
||||||
return (
|
|
||||||
<main className="app">
|
|
||||||
<h1>Thalos Web</h1>
|
|
||||||
<p className="muted">Restoring session...</p>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.status !== 'authenticated' || !session.profile) {
|
|
||||||
return (
|
|
||||||
<main className="app">
|
|
||||||
<h1>Thalos Web</h1>
|
|
||||||
<p className="muted">Login against Thalos session endpoints to access protected routes.</p>
|
|
||||||
{session.error && <div className="alert">{session.error}</div>}
|
|
||||||
<LoginCard />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="app">
|
<Layout className="full-layout">
|
||||||
<h1>Thalos Web</h1>
|
<Layout.Sider width={240} breakpoint="lg" collapsedWidth={0}>
|
||||||
<p className="muted">Session management MVP for login, me, refresh, and logout.</p>
|
<div className="brand">Thalos Web</div>
|
||||||
|
<Menu
|
||||||
<section className="card row">
|
mode="inline"
|
||||||
<span className="badge">subject: {session.profile.subjectId}</span>
|
selectedKeys={[selectedKey]}
|
||||||
<span className="badge">tenant: {session.profile.tenantId}</span>
|
items={routeItems}
|
||||||
<span className="badge">provider: {providerLabel(session.profile.provider)}</span>
|
onClick={(event) => navigate(event.key as AppRoute)}
|
||||||
<span className="spacer" />
|
/>
|
||||||
<button type="button" className="secondary" onClick={() => void session.refresh()}>
|
</Layout.Sider>
|
||||||
Refresh Session
|
<Layout>
|
||||||
</button>
|
<Layout.Header className="header">
|
||||||
<button type="button" className="warn" onClick={() => void session.logout()}>
|
{session.profile ? (
|
||||||
Logout
|
<Space wrap>
|
||||||
</button>
|
<Tag color="blue">subject: {session.profile.subjectId}</Tag>
|
||||||
</section>
|
<Tag color="geekblue">tenant: {session.profile.tenantId}</Tag>
|
||||||
|
<Tag color="purple">provider: {providerLabel(session.profile.provider)}</Tag>
|
||||||
<section className="card tabs" aria-label="route-shell">
|
</Space>
|
||||||
<button
|
) : (
|
||||||
type="button"
|
<Typography.Text type="secondary">No active session</Typography.Text>
|
||||||
className={route === 'overview' ? 'active' : undefined}
|
|
||||||
onClick={() => setRoute('overview')}
|
|
||||||
>
|
|
||||||
Overview
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={route === 'session' ? 'active' : undefined}
|
|
||||||
onClick={() => setRoute('session')}
|
|
||||||
>
|
|
||||||
Session Actions
|
|
||||||
</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>
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
|
<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} />}
|
||||||
|
|
||||||
{route === 'session' && <LoginCard />}
|
<Routes>
|
||||||
</main>
|
<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 LoginCard() {
|
function LoginRoute() {
|
||||||
const session = useSessionContext();
|
const session = useSessionContext();
|
||||||
const [subjectId, setSubjectId] = useState('demo-user');
|
const [subjectId, setSubjectId] = useState('demo-user');
|
||||||
const [tenantId, setTenantId] = useState('demo-tenant');
|
const [tenantId, setTenantId] = useState('demo-tenant');
|
||||||
@ -118,6 +106,8 @@ function LoginCard() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const loginUrl = useMemo(() => buildGoogleOidcStartUrl(getThalosDefaultReturnUrl()), []);
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -136,39 +126,180 @@ function LoginCard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card col">
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
<div className="grid">
|
<Typography.Title level={3}>Thalos Authentication</Typography.Title>
|
||||||
<label className="col">
|
<Typography.Paragraph type="secondary">
|
||||||
Subject Id
|
Use the central OIDC journey for browser login. Manual form login remains available for local simulated-provider testing.
|
||||||
<input value={subjectId} onChange={(event) => setSubjectId(event.target.value)} />
|
</Typography.Paragraph>
|
||||||
</label>
|
|
||||||
<label className="col">
|
<Card>
|
||||||
Tenant Id
|
<Result
|
||||||
<input value={tenantId} onChange={(event) => setTenantId(event.target.value)} />
|
status={session.status === 'authenticated' ? 'success' : 'info'}
|
||||||
</label>
|
title={session.status === 'authenticated' ? 'Session active' : 'Sign in required'}
|
||||||
<label className="col">
|
subTitle="Start Google OIDC through the central auth host callback flow."
|
||||||
Provider
|
extra={
|
||||||
<select
|
<Button type="primary" href={loginUrl}>
|
||||||
value={String(provider)}
|
Continue with Google
|
||||||
onChange={(event) => setProvider(Number(event.target.value) as IdentityProvider)}
|
</Button>
|
||||||
>
|
}
|
||||||
<option value="0">Internal JWT</option>
|
/>
|
||||||
<option value="1">Azure AD (simulated)</option>
|
</Card>
|
||||||
<option value="2">Google (simulated)</option>
|
|
||||||
</select>
|
<Card title="Manual Session Login (Dev/Test)">
|
||||||
</label>
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
|
<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>
|
||||||
|
</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.'} />
|
||||||
|
<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 [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const onReloadSnapshot = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const payload = await loadDashboard();
|
||||||
|
setSnapshot(payload);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to reload session snapshot.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (session.status === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="fullscreen-center">
|
||||||
|
<Spin size="large" />
|
||||||
</div>
|
</div>
|
||||||
<label className="col">
|
);
|
||||||
External Token (optional for simulated external providers)
|
}
|
||||||
<input value={externalToken} onChange={(event) => setExternalToken(event.target.value)} />
|
|
||||||
</label>
|
if (session.status !== 'authenticated' || !session.profile) {
|
||||||
<div className="row">
|
return <Navigate to="/login" replace />;
|
||||||
<button type="button" onClick={() => void onSubmit()} disabled={submitting}>
|
}
|
||||||
{submitting ? 'Signing In...' : 'Sign In'}
|
|
||||||
</button>
|
return (
|
||||||
</div>
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
{error && <div className="alert">{error}</div>}
|
<Typography.Title level={3}>Session Workspace</Typography.Title>
|
||||||
</section>
|
<Typography.Paragraph type="secondary">
|
||||||
|
Validate session continuity, profile inspection, refresh behavior, and logout flow.
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Card title="Session Snapshot">
|
||||||
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
|
<Button type="primary" onClick={() => void onReloadSnapshot()} loading={busy}>
|
||||||
|
Reload Snapshot
|
||||||
|
</Button>
|
||||||
|
{error && <Alert type="error" showIcon message={error} />}
|
||||||
|
<pre>{JSON.stringify(snapshot ?? session.profile, null, 2)}</pre>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Session Profile">
|
||||||
|
<Descriptions bordered size="small" column={1}>
|
||||||
|
<Descriptions.Item label="Subject">{session.profile.subjectId}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Tenant">{session.profile.tenantId}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Provider">{providerLabel(session.profile.provider)}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { getApiBaseUrl, getThalosAuthBaseUrl } from './client';
|
import { getApiBaseUrl, getThalosAuthBaseUrl, getThalosDefaultReturnUrl, getThalosDefaultTenantId } from './client';
|
||||||
|
|
||||||
describe('client runtime base URLs', () => {
|
describe('client runtime base URLs', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -38,4 +38,28 @@ describe('client runtime base URLs', () => {
|
|||||||
it('falls back to localhost default when both runtime and env are missing', () => {
|
it('falls back to localhost default when both runtime and env are missing', () => {
|
||||||
expect(getApiBaseUrl()).toBe('http://localhost:8080');
|
expect(getApiBaseUrl()).toBe('http://localhost:8080');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses configured default return URL when present', () => {
|
||||||
|
window.__APP_CONFIG__ = {
|
||||||
|
THALOS_DEFAULT_RETURN_URL: `${window.location.origin}/callback`
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getThalosDefaultReturnUrl()).toBe(`${window.location.origin}/callback`);
|
||||||
|
});
|
||||||
|
|
||||||
|
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__ = {
|
||||||
|
THALOS_DEFAULT_TENANT_ID: 'tenant-alpha'
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getThalosDefaultTenantId()).toBe('tenant-alpha');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to demo tenant when no tenant is configured', () => {
|
||||||
|
expect(getThalosDefaultTenantId()).toBe('demo-tenant');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,6 +3,8 @@ declare global {
|
|||||||
__APP_CONFIG__?: {
|
__APP_CONFIG__?: {
|
||||||
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_TENANT_ID?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -47,6 +49,24 @@ export function getThalosAuthBaseUrl(): string {
|
|||||||
return import.meta.env.VITE_THALOS_AUTH_BASE_URL ?? getApiBaseUrl();
|
return import.meta.env.VITE_THALOS_AUTH_BASE_URL ?? getApiBaseUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getThalosDefaultReturnUrl(): string {
|
||||||
|
const runtimeValue = window.__APP_CONFIG__?.THALOS_DEFAULT_RETURN_URL;
|
||||||
|
if (runtimeValue && runtimeValue.length > 0) {
|
||||||
|
return runtimeValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return import.meta.env.VITE_THALOS_DEFAULT_RETURN_URL ?? `${window.location.origin}/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThalosDefaultTenantId(): string {
|
||||||
|
const runtimeValue = window.__APP_CONFIG__?.THALOS_DEFAULT_TENANT_ID;
|
||||||
|
if (runtimeValue && runtimeValue.length > 0) {
|
||||||
|
return runtimeValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return import.meta.env.VITE_THALOS_DEFAULT_TENANT_ID ?? 'demo-tenant';
|
||||||
|
}
|
||||||
|
|
||||||
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' });
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/auth/callbackState.test.ts
Normal file
30
src/auth/callbackState.test.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
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 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
49
src/auth/callbackState.ts
Normal file
49
src/auth/callbackState.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { getThalosDefaultReturnUrl } from '../api/client';
|
||||||
|
|
||||||
|
type OidcCallbackSuccess = {
|
||||||
|
kind: 'success';
|
||||||
|
returnPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OidcCallbackError = {
|
||||||
|
kind: 'error';
|
||||||
|
message: 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');
|
||||||
|
|
||||||
|
if (error && error.length > 0) {
|
||||||
|
return {
|
||||||
|
kind: 'error',
|
||||||
|
message: errorDescription && errorDescription.length > 0 ? errorDescription : `OIDC callback failed: ${error}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const returnUrl = query.get('returnUrl');
|
||||||
|
return {
|
||||||
|
kind: 'success',
|
||||||
|
returnPath: sanitizeReturnPath(returnUrl)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/auth/oidcLogin.test.ts
Normal file
38
src/auth/oidcLogin.test.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { buildGoogleOidcStartUrl } from './oidcLogin';
|
||||||
|
|
||||||
|
describe('oidc login url', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete window.__APP_CONFIG__;
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds google oidc start url from runtime config', () => {
|
||||||
|
window.__APP_CONFIG__ = {
|
||||||
|
THALOS_AUTH_BASE_URL: 'https://auth.dream-views.com',
|
||||||
|
THALOS_DEFAULT_RETURN_URL: `${window.location.origin}/callback`,
|
||||||
|
THALOS_DEFAULT_TENANT_ID: 'tenant-alpha'
|
||||||
|
};
|
||||||
|
|
||||||
|
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 provided return url is not same-origin', () => {
|
||||||
|
window.__APP_CONFIG__ = {
|
||||||
|
THALOS_AUTH_BASE_URL: 'https://auth.dream-views.com',
|
||||||
|
THALOS_DEFAULT_RETURN_URL: `${window.location.origin}/callback`,
|
||||||
|
THALOS_DEFAULT_TENANT_ID: 'tenant-alpha'
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = buildGoogleOidcStartUrl('https://external.example/callback');
|
||||||
|
const parsed = new URL(result);
|
||||||
|
|
||||||
|
expect(parsed.searchParams.get('returnUrl')).toBe(`${window.location.origin}/callback`);
|
||||||
|
});
|
||||||
|
});
|
||||||
25
src/auth/oidcLogin.ts
Normal file
25
src/auth/oidcLogin.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { getThalosAuthBaseUrl, getThalosDefaultReturnUrl, getThalosDefaultTenantId } from '../api/client';
|
||||||
|
|
||||||
|
export function buildGoogleOidcStartUrl(returnUrl = getThalosDefaultReturnUrl(), tenantId = getThalosDefaultTenantId()): string {
|
||||||
|
const authBase = getThalosAuthBaseUrl().replace(/\/+$/, '');
|
||||||
|
const safeReturnUrl = sanitizeReturnUrl(returnUrl);
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
returnUrl: safeReturnUrl,
|
||||||
|
tenantId
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
@ -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(
|
||||||
|
|||||||
140
src/styles.css
140
src/styles.css
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user