Compare commits

..

No commits in common. "143734098e7b503ac80aac8daa983c3b4c67f048" and "d26b488e6c3c860de8ba4fd29dccd0c3e45daec8" have entirely different histories.

17 changed files with 315 additions and 1584 deletions

View File

@ -4,8 +4,6 @@ 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:23280/board}",
THALOS_DEFAULT_TENANT_ID: "${THALOS_DEFAULT_TENANT_ID:-demo-tenant}"
}; };
EOT EOT

View File

@ -3,15 +3,12 @@
- This repository hosts a React edge application for a single BFF. - This repository hosts a React edge application for a single BFF.
- 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 uses Ant Design layout/menu and keeps business views behind session checks. - Route shell and protected sections are session-aware via Thalos session endpoints.
- Unauthenticated users are redirected to the central auth host OIDC start endpoint.
## 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 login/refresh/logout/me.
- `THALOS_DEFAULT_RETURN_URL` for safe callback fallback.
- `THALOS_DEFAULT_TENANT_ID` for OIDC tenant context defaults.
## Protected Workflow Endpoints ## Protected Workflow Endpoints
@ -21,5 +18,4 @@
## UI Workflow Coverage ## UI Workflow Coverage
- Kitchen board lookup - Kitchen board lookup
- Kitchen priority updates - Kitchen priority update
- Protected route shell for board, priority update, and session inspection

View File

@ -11,9 +11,6 @@ docker build -t agilewebs/kitchen-ops-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:23280/board \
-e THALOS_DEFAULT_TENANT_ID=demo-tenant \
--name kitchen-ops-web agilewebs/kitchen-ops-web:dev --name kitchen-ops-web agilewebs/kitchen-ops-web:dev
``` ```
@ -22,7 +19,6 @@ 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`
- Central OIDC login context is configured through runtime env vars, not hardcoded per build.
## Health Check ## Health Check

View File

@ -11,14 +11,12 @@ 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:23280/board \
VITE_THALOS_DEFAULT_TENANT_ID=demo-tenant \
npm run dev npm run dev
``` ```
## Auth Model ## Auth Model
- Login is executed via central Thalos OIDC start endpoint. - Login is executed against Thalos session endpoints.
- Business calls are gated behind session checks. - Business calls are gated behind session checks.
- Session cookies are sent with `credentials: include`. - Session cookies are sent with `credentials: include`.

View File

@ -16,8 +16,7 @@ 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`: OIDC start-url generation and safe return-url fallback. - `src/App.test.tsx`: protected-route render and workflow trigger behavior.
- `src/App.test.tsx`: central login screen, protected board flow, and priority update workflow.
## Notes ## Notes

1159
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,11 +11,8 @@
"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,6 +1,4 @@
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:23280/board",
THALOS_DEFAULT_TENANT_ID: "demo-tenant"
}; };

View File

@ -22,24 +22,6 @@ describe('Kitchen Ops App', () => {
vi.mocked(loadDashboard).mockReset(); vi.mocked(loadDashboard).mockReset();
vi.mocked(setKitchenOrderPriority).mockReset(); vi.mocked(setKitchenOrderPriority).mockReset();
vi.mocked(getSessionMe).mockReset(); vi.mocked(getSessionMe).mockReset();
window.__APP_CONFIG__ = {
API_BASE_URL: 'http://localhost:8080',
THALOS_AUTH_BASE_URL: 'https://auth.dream-views.com',
THALOS_DEFAULT_RETURN_URL: 'https://kitchen-ops-demo.dream-views.com/board',
THALOS_DEFAULT_TENANT_ID: 'demo-tenant'
};
window.history.pushState({}, '', '/board');
});
it('shows central login action when session is missing', async () => {
vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 0 });
render(<App />);
await waitFor(() => expect(screen.getByRole('link', { name: 'Continue with Google' })).toBeInTheDocument());
const link = screen.getByRole('link', { name: 'Continue with Google' }) as HTMLAnchorElement;
expect(link.href).toContain('/api/identity/oidc/google/start');
expect(link.href).toContain('tenantId=demo-tenant');
}); });
it('loads kitchen board for authenticated users', async () => { it('loads kitchen board for authenticated users', async () => {
@ -49,7 +31,7 @@ describe('Kitchen Ops App', () => {
tenantId: 'demo-tenant', tenantId: 'demo-tenant',
provider: 0 provider: 0
}); });
vi.mocked(loadDashboard).mockResolvedValue({ contextId: 'demo-context', summary: 'ready' }); vi.mocked(loadDashboard).mockResolvedValue({ contextId: 'demo-context', summary: 'board' });
render(<App />); render(<App />);
@ -59,7 +41,7 @@ describe('Kitchen Ops App', () => {
await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context')); await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context'));
}); });
it('updates kitchen priority from action route', async () => { it('updates order priority from action route', async () => {
vi.mocked(getSessionMe).mockResolvedValue({ vi.mocked(getSessionMe).mockResolvedValue({
isAuthenticated: true, isAuthenticated: true,
subjectId: 'demo-user', subjectId: 'demo-user',
@ -68,16 +50,15 @@ describe('Kitchen Ops App', () => {
}); });
vi.mocked(setKitchenOrderPriority).mockResolvedValue({ vi.mocked(setKitchenOrderPriority).mockResolvedValue({
contextId: 'demo-context', contextId: 'demo-context',
orderId: 'ORD-2200', orderId: 'ORD-1001',
updated: true, updated: true,
summary: 'updated' summary: 'ok'
}); });
render(<App />); render(<App />);
await waitFor(() => expect(screen.getByText('Set Priority')).toBeInTheDocument()); await waitFor(() => expect(screen.getByRole('button', { name: 'Set Priority' })).toBeInTheDocument());
fireEvent.click(screen.getByText('Set Priority')); fireEvent.click(screen.getByRole('button', { name: 'Set Priority' }));
fireEvent.change(screen.getByPlaceholderText('Order Id'), { target: { value: 'ORD-2200' } });
fireEvent.click(screen.getByRole('button', { name: 'Set Kitchen Priority' })); fireEvent.click(screen.getByRole('button', { name: 'Set Kitchen Priority' }));
await waitFor(() => expect(setKitchenOrderPriority).toHaveBeenCalledTimes(1)); await waitFor(() => expect(setKitchenOrderPriority).toHaveBeenCalledTimes(1));

View File

@ -1,23 +1,4 @@
import { DeploymentUnitOutlined, OrderedListOutlined, ReloadOutlined, ToolOutlined } from '@ant-design/icons'; import { useState } from 'react';
import {
Alert,
Button,
Card,
Descriptions,
Form,
Input,
InputNumber,
Layout,
Menu,
Result,
Space,
Spin,
Table,
Tag,
Typography
} from 'antd';
import { type ReactNode, useMemo, useState } from 'react';
import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { import {
loadDashboard, loadDashboard,
setKitchenOrderPriority, setKitchenOrderPriority,
@ -25,237 +6,236 @@ import {
type SetKitchenOrderPriorityRequest, type SetKitchenOrderPriorityRequest,
type SetKitchenOrderPriorityResponse type SetKitchenOrderPriorityResponse
} from './api/dashboardApi'; } from './api/dashboardApi';
import type { IdentityProvider } from './api/sessionApi';
import { buildGoogleOidcStartUrl } from './auth/oidcLogin';
import { SessionProvider, useSessionContext } from './auth/sessionContext'; import { SessionProvider, useSessionContext } from './auth/sessionContext';
import type { IdentityProvider } from './api/sessionApi';
type AppRoute = '/board' | '/priority' | '/session'; type RouteKey = 'overview' | 'actions';
const routeItems: Array<{ key: AppRoute; label: string; icon: ReactNode }> = [
{ key: '/board', label: 'Kitchen Board', icon: <OrderedListOutlined /> },
{ key: '/priority', label: 'Set Priority', icon: <ToolOutlined /> },
{ key: '/session', label: 'Session', icon: <DeploymentUnitOutlined /> }
];
function App() { function App() {
return ( return (
<SessionProvider> <SessionProvider>
<BrowserRouter>
<KitchenOpsShell /> <KitchenOpsShell />
</BrowserRouter>
</SessionProvider> </SessionProvider>
); );
} }
function KitchenOpsShell() { function KitchenOpsShell() {
const session = useSessionContext(); const session = useSessionContext();
const location = useLocation(); const [route, setRoute] = useState<RouteKey>('overview');
const navigate = useNavigate();
const [contextId, setContextId] = useState('demo-context'); const [contextId, setContextId] = useState('demo-context');
const [boardPayload, setBoardPayload] = useState<KitchenOpsBoardResponse | null>(null); const [boardPayload, setBoardPayload] = useState<KitchenOpsBoardResponse | null>(null);
const [priorityResponse, setPriorityResponse] = useState<SetKitchenOrderPriorityResponse | null>(null);
const [priorityHistory, setPriorityHistory] = useState<SetKitchenOrderPriorityResponse[]>([]);
const [globalError, setGlobalError] = useState<string | null>(null);
const [loadingBoard, setLoadingBoard] = useState(false);
const [submittingPriority, setSubmittingPriority] = useState(false);
const loginUrl = useMemo(() => buildGoogleOidcStartUrl(window.location.href), []); const [priorityRequest, setPriorityRequest] = useState<SetKitchenOrderPriorityRequest>({
const selectedKey = useMemo(() => { contextId: 'demo-context',
const candidate = routeItems.find((item) => location.pathname.startsWith(item.key)); orderId: 'ORD-1001',
return candidate?.key ?? '/board'; priority: 1,
}, [location.pathname]); updatedBy: 'kitchen-supervisor'
});
const [priorityResponse, setPriorityResponse] = useState<SetKitchenOrderPriorityResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const loadBoard = async () => { const loadBoard = async () => {
setLoadingBoard(true); setLoading(true);
setGlobalError(null); setError(null);
try { try {
const payload = await loadDashboard(contextId); const payload = await loadDashboard(contextId);
setBoardPayload(payload); setBoardPayload(payload);
setPriorityRequest((previous) => ({ ...previous, contextId }));
} catch (err) { } catch (err) {
setGlobalError(err instanceof Error ? err.message : 'Failed to load kitchen board.'); setError(err instanceof Error ? err.message : 'Failed to load kitchen board.');
} finally { } finally {
setLoadingBoard(false); setLoading(false);
} }
}; };
const updatePriority = async (request: SetKitchenOrderPriorityRequest) => { const updatePriority = async () => {
setSubmittingPriority(true); setLoading(true);
setGlobalError(null); setError(null);
try { try {
const payload = await setKitchenOrderPriority(request); const payload = await setKitchenOrderPriority(priorityRequest);
setPriorityResponse(payload); setPriorityResponse(payload);
// Keep recent responses bounded so the session view stays readable over long demos.
setPriorityHistory((previous) => [payload, ...previous].slice(0, 8));
} catch (err) { } catch (err) {
setGlobalError(err instanceof Error ? err.message : 'Failed to update kitchen order priority.'); setError(err instanceof Error ? err.message : 'Failed to update kitchen order priority.');
} finally { } finally {
setSubmittingPriority(false); setLoading(false);
} }
}; };
if (session.status === 'loading') { if (session.status === 'loading') {
return ( return (
<div className="fullscreen-center"> <main className="app">
<Spin size="large" /> <h1>Kitchen Ops Web</h1>
</div> <p className="muted">Restoring session...</p>
</main>
); );
} }
if (session.status !== 'authenticated' || !session.profile) { if (session.status !== 'authenticated' || !session.profile) {
return ( return (
<main className="app"> <main className="app">
<Result <h1>Kitchen Ops Web</h1>
status="403" <p className="muted">Sign in with Thalos to access protected routes.</p>
title="Authentication Required" {session.error && <div className="alert">{session.error}</div>}
subTitle="Sign in through the central auth host to access kitchen operations workflows." <LoginCard />
extra={
<Button type="primary" href={loginUrl}>
Continue with Google
</Button>
}
/>
{session.error && <Alert type="error" showIcon message={session.error} />}
</main> </main>
); );
} }
return ( return (
<Layout className="full-layout"> <main className="app">
<Layout.Sider width={240} breakpoint="lg" collapsedWidth={0}> <h1>Kitchen Ops Web</h1>
<div className="brand">Kitchen Ops Web</div> <p className="muted">Kitchen board monitoring and priority update MVP workflows.</p>
<Menu
mode="inline"
selectedKeys={[selectedKey]}
items={routeItems}
onClick={(event) => navigate(event.key as AppRoute)}
/>
</Layout.Sider>
<Layout>
<Layout.Header className="header">
<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>
<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">
<Typography.Title level={3}>Kitchen Operations</Typography.Title>
<Typography.Paragraph type="secondary">
Protected kitchen board workflows for board visibility and priority updates.
</Typography.Paragraph>
{session.error && <Alert className="stack-gap" type="warning" showIcon message={session.error} />}
{globalError && <Alert className="stack-gap" type="error" showIcon message={globalError} />}
<Routes> <section className="card row">
<Route <span className="badge">subject: {session.profile.subjectId}</span>
path="/board" <span className="badge">tenant: {session.profile.tenantId}</span>
element={ <span className="badge">provider: {providerLabel(session.profile.provider)}</span>
<Card title="Kitchen Board"> <span className="spacer" />
<Space direction="vertical" size="middle" style={{ width: '100%' }}> <button type="button" className="secondary" onClick={() => void session.refresh()}>
<Space wrap> Refresh Session
<Input </button>
value={contextId} <button type="button" className="warn" onClick={() => void session.logout()}>
onChange={(event) => setContextId(event.target.value)} Logout
placeholder="Context Id" </button>
style={{ width: 280 }} </section>
/>
<Button type="primary" loading={loadingBoard} onClick={() => void loadBoard()}> <section className="card tabs" aria-label="route-shell">
Load Kitchen Board <button
</Button> type="button"
</Space> className={route === 'overview' ? 'active' : undefined}
{boardPayload ? ( onClick={() => setRoute('overview')}
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Context Id">{boardPayload.contextId}</Descriptions.Item>
<Descriptions.Item label="Summary">{boardPayload.summary}</Descriptions.Item>
</Descriptions>
) : (
<Typography.Text type="secondary">No kitchen board payload loaded.</Typography.Text>
)}
</Space>
</Card>
}
/>
<Route
path="/priority"
element={
<Card title="Set Kitchen Priority">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Form<SetKitchenOrderPriorityRequest>
layout="vertical"
initialValues={{
contextId,
orderId: 'ORD-1001',
priority: 1,
updatedBy: 'kitchen-supervisor'
}}
onFinish={(values) => void updatePriority(values)}
> >
<Form.Item name="contextId" label="Context Id" rules={[{ required: true }]}> Kitchen Board
<Input placeholder="Context Id" /> </button>
</Form.Item> <button
<Form.Item name="orderId" label="Order Id" rules={[{ required: true }]}> type="button"
<Input placeholder="Order Id" /> className={route === 'actions' ? 'active' : undefined}
</Form.Item> onClick={() => setRoute('actions')}
<Form.Item name="priority" label="Priority" rules={[{ required: true, type: 'number', min: 0 }]}> >
<InputNumber min={0} style={{ width: '100%' }} /> Set Priority
</Form.Item> </button>
<Form.Item name="updatedBy" label="Updated By" rules={[{ required: true }]}> </section>
<Input placeholder="Operator Id" />
</Form.Item> {session.error && <div className="alert">{session.error}</div>}
<Button type="primary" htmlType="submit" loading={submittingPriority}> {error && <div className="alert">{error}</div>}
Set Kitchen Priority
</Button> {route === 'overview' && (
</Form> <section className="card col">
{priorityResponse && ( <div className="row">
<Descriptions bordered size="small" column={1}> <label className="col">
<Descriptions.Item label="Context Id">{priorityResponse.contextId}</Descriptions.Item> Context Id
<Descriptions.Item label="Order Id">{priorityResponse.orderId}</Descriptions.Item> <input value={contextId} onChange={(event) => setContextId(event.target.value)} />
<Descriptions.Item label="Updated">{String(priorityResponse.updated)}</Descriptions.Item> </label>
<Descriptions.Item label="Summary">{priorityResponse.summary}</Descriptions.Item> <button type="button" onClick={() => void loadBoard()} disabled={loading}>
</Descriptions> {loading ? 'Loading...' : 'Load Kitchen Board'}
</button>
</div>
<pre>{JSON.stringify(boardPayload, null, 2)}</pre>
</section>
)} )}
<Table<SetKitchenOrderPriorityResponse>
pagination={false} {route === 'actions' && (
rowKey={(record) => `${record.contextId}-${record.orderId}`} <section className="card col">
dataSource={priorityHistory} <div className="grid">
columns={[ <label className="col">
{ title: 'Order Id', dataIndex: 'orderId' }, Context Id
{ title: 'Context Id', dataIndex: 'contextId' }, <input
{ value={priorityRequest.contextId}
title: 'Updated', onChange={(event) => setPriorityRequest((previous) => ({ ...previous, contextId: event.target.value }))}
render: (_, record) => <Tag color={record.updated ? 'green' : 'red'}>{String(record.updated)}</Tag>
},
{ title: 'Summary', dataIndex: 'summary' }
]}
/> />
</Space> </label>
</Card> <label className="col">
Order Id
<input
value={priorityRequest.orderId}
onChange={(event) => setPriorityRequest((previous) => ({ ...previous, orderId: event.target.value }))}
/>
</label>
<label className="col">
Priority
<input
type="number"
min={0}
value={priorityRequest.priority}
onChange={(event) =>
setPriorityRequest((previous) => ({
...previous,
priority: Math.max(0, Number(event.target.value) || 0)
}))
} }
/> />
<Route </label>
path="/session" <label className="col">
element={ Updated By
<Card title="Session Details"> <input
<pre>{JSON.stringify(session.profile, null, 2)}</pre> value={priorityRequest.updatedBy}
</Card> onChange={(event) => setPriorityRequest((previous) => ({ ...previous, updatedBy: event.target.value }))}
}
/> />
<Route path="/" element={<Navigate to="/board" replace />} /> </label>
<Route path="*" element={<Navigate to="/board" replace />} /> </div>
</Routes> <button type="button" onClick={() => void updatePriority()} disabled={loading}>
</Layout.Content> {loading ? 'Updating...' : 'Set Kitchen Priority'}
</Layout> </button>
</Layout> <pre>{JSON.stringify(priorityResponse, null, 2)}</pre>
</section>
)}
</main>
);
}
function LoginCard() {
const session = useSessionContext();
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 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 (
<section className="card col">
<div className="grid">
<label className="col">
Subject Id
<input value={subjectId} onChange={(event) => setSubjectId(event.target.value)} />
</label>
<label className="col">
Tenant Id
<input value={tenantId} onChange={(event) => setTenantId(event.target.value)} />
</label>
<label className="col">
Provider
<select value={String(provider)} onChange={(event) => setProvider(Number(event.target.value) as IdentityProvider)}>
<option value="0">Internal JWT</option>
<option value="1">Azure AD (simulated)</option>
<option value="2">Google (simulated)</option>
</select>
</label>
</div>
<label className="col">
External Token (optional)
<input value={externalToken} onChange={(event) => setExternalToken(event.target.value)} />
</label>
<button type="button" onClick={() => void onSubmit()} disabled={submitting}>
{submitting ? 'Signing In...' : 'Sign In'}
</button>
{error && <div className="alert">{error}</div>}
</section>
); );
} }

View File

@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { getApiBaseUrl, getThalosAuthBaseUrl, getThalosDefaultReturnUrl, getThalosDefaultTenantId } from './client'; import { getApiBaseUrl, getThalosAuthBaseUrl } from './client';
describe('client runtime base URLs', () => { describe('client runtime base URLs', () => {
afterEach(() => { afterEach(() => {
@ -38,28 +38,4 @@ 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: 'https://kitchen-ops-demo.dream-views.com/board'
};
expect(getThalosDefaultReturnUrl()).toBe('https://kitchen-ops-demo.dream-views.com/board');
});
it('falls back to location-based return URL when no return URL is configured', () => {
expect(getThalosDefaultReturnUrl()).toBe(`${window.location.origin}/board`);
});
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');
});
}); });

View File

@ -3,8 +3,6 @@ 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;
}; };
} }
} }
@ -49,24 +47,6 @@ 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}/board`;
}
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' });
} }

View File

@ -1,38 +0,0 @@
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: 'https://kitchen-ops-demo.dream-views.com/board',
THALOS_DEFAULT_TENANT_ID: 'tenant-alpha'
};
const result = buildGoogleOidcStartUrl('https://kitchen-ops-demo.dream-views.com/priority?order=ORD-1');
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('https://kitchen-ops-demo.dream-views.com/priority?order=ORD-1');
});
it('falls back to default return url when provided return url is invalid', () => {
window.__APP_CONFIG__ = {
THALOS_AUTH_BASE_URL: 'https://auth.dream-views.com',
THALOS_DEFAULT_RETURN_URL: 'https://kitchen-ops-demo.dream-views.com/board',
THALOS_DEFAULT_TENANT_ID: 'tenant-alpha'
};
const result = buildGoogleOidcStartUrl('not-a-url');
const parsed = new URL(result);
expect(parsed.searchParams.get('returnUrl')).toBe('https://kitchen-ops-demo.dream-views.com/board');
});
});

View File

@ -1,25 +0,0 @@
import { getThalosAuthBaseUrl, getThalosDefaultReturnUrl, getThalosDefaultTenantId } from '../api/client';
export function buildGoogleOidcStartUrl(returnUrl = window.location.href, 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);
if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
return parsed.toString();
}
} catch {
return getThalosDefaultReturnUrl();
}
return getThalosDefaultReturnUrl();
}

View File

@ -1,7 +1,6 @@
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,60 +1,138 @@
:root { :root {
font-family: "IBM Plex Sans", "Segoe UI", sans-serif; font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background: radial-gradient(circle at 20% 0%, #f8fbff 0%, #eef4ff 45%, #f7fafc 100%); color: #111827;
background: radial-gradient(circle at top, #f7f8fb 0%, #eef2ff 45%, #f8fafc 100%);
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
html, body {
body,
#root {
margin: 0; margin: 0;
min-height: 100%; min-height: 100vh;
height: 100%; }
#root {
min-height: 100vh;
} }
.app { .app {
min-height: 100%; width: min(960px, 94vw);
display: grid; margin: 0 auto;
place-items: center; padding: 1.5rem 0 3rem;
padding: 2rem 1rem;
} }
.fullscreen-center { .card {
height: 100%;
display: grid;
place-items: center;
}
.full-layout {
min-height: 100%;
}
.brand {
color: #e2e8f0;
font-weight: 700;
letter-spacing: 0.04em;
padding: 1rem;
}
.header {
background: #ffffff; background: #ffffff;
border-bottom: 1px solid #e2e8f0; 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; display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem; gap: 0.75rem;
padding-inline: 1rem; align-items: center;
flex-wrap: wrap;
} }
.content { .col {
padding: 1.25rem; display: flex;
flex-direction: column;
gap: 0.5rem;
} }
.stack-gap { input,
margin-bottom: 1rem; 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;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
}
.alert {
border-radius: 10px;
padding: 0.7rem 0.9rem;
border: 1px solid #fecaca;
background: #fef2f2;
color: #7f1d1d;
}
.muted {
color: #475569;
}
.badge {
display: inline-block;
border-radius: 999px;
background: #e2e8f0;
color: #0f172a;
padding: 0.2rem 0.6rem;
font-size: 0.78rem;
font-weight: 600;
}
.tabs {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.tabs button {
background: #dbeafe;
color: #1e3a8a;
}
.tabs button.active {
background: #1d4ed8;
color: #ffffff;
}
.spacer {
flex: 1;
} }
pre { pre {

View File

@ -1,30 +1 @@
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;