feat(waiter-floor-web): add protected ant shell and oidc login flow
This commit is contained in:
parent
46aabd9927
commit
22a2d7a943
@ -4,6 +4,8 @@ set -eu
|
||||
cat > /usr/share/nginx/html/runtime-config.js <<EOT
|
||||
window.__APP_CONFIG__ = {
|
||||
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://waiter-floor-demo.dream-views.com/assignments}",
|
||||
THALOS_DEFAULT_TENANT_ID: "${THALOS_DEFAULT_TENANT_ID:-demo-tenant}"
|
||||
};
|
||||
EOT
|
||||
|
||||
@ -3,12 +3,15 @@
|
||||
- This repository hosts a React edge application for a single BFF.
|
||||
- Frontend data access flows through `src/api/*` adapter modules.
|
||||
- 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 business views behind session checks.
|
||||
- Unauthenticated users are redirected to the central auth host OIDC start endpoint.
|
||||
|
||||
## Runtime Base URLs
|
||||
|
||||
- `API_BASE_URL` for business BFF calls.
|
||||
- `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
|
||||
|
||||
@ -19,3 +22,4 @@
|
||||
|
||||
- Waiter assignment lookup
|
||||
- Floor order submission
|
||||
- Protected route shell for assignments, order submission, and session inspection
|
||||
|
||||
@ -11,6 +11,9 @@ docker build -t agilewebs/waiter-floor-web:dev .
|
||||
```bash
|
||||
docker run --rm -p 8080: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:23080/assignments \
|
||||
-e THALOS_DEFAULT_TENANT_ID=demo-tenant \
|
||||
--name waiter-floor-web agilewebs/waiter-floor-web:dev
|
||||
```
|
||||
|
||||
@ -19,6 +22,7 @@ docker run --rm -p 8080:8080 \
|
||||
- Build-time fallback: `VITE_API_BASE_URL`
|
||||
- Runtime override: container env `API_BASE_URL`
|
||||
- Runtime file generated at startup: `/runtime-config.js`
|
||||
- Central OIDC login context is configured through runtime env vars, not hardcoded per build.
|
||||
|
||||
## Health Check
|
||||
|
||||
|
||||
@ -11,12 +11,14 @@ npm install
|
||||
```bash
|
||||
VITE_API_BASE_URL=http://localhost:8080 \
|
||||
VITE_THALOS_AUTH_BASE_URL=http://localhost:20080 \
|
||||
VITE_THALOS_DEFAULT_RETURN_URL=http://localhost:23080/assignments \
|
||||
VITE_THALOS_DEFAULT_TENANT_ID=demo-tenant \
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Auth Model
|
||||
|
||||
- Login is executed against Thalos session endpoints.
|
||||
- Login is executed via central Thalos OIDC start endpoint.
|
||||
- Business calls are gated behind session checks.
|
||||
- Session cookies are sent with `credentials: include`.
|
||||
|
||||
|
||||
@ -16,7 +16,8 @@ npm run test:ci
|
||||
|
||||
- `src/api/client.test.ts`: runtime-config precedence and fallback behavior.
|
||||
- `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/App.test.tsx`: central login screen, protected assignment flow, and order submission workflow.
|
||||
|
||||
## 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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"antd": "^5.27.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.10",
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
window.__APP_CONFIG__ = {
|
||||
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://waiter-floor-demo.dream-views.com/assignments",
|
||||
THALOS_DEFAULT_TENANT_ID: "demo-tenant"
|
||||
};
|
||||
|
||||
@ -22,6 +22,24 @@ describe('Waiter Floor App', () => {
|
||||
vi.mocked(loadDashboard).mockReset();
|
||||
vi.mocked(submitFloorOrder).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://waiter-floor-demo.dream-views.com/assignments',
|
||||
THALOS_DEFAULT_TENANT_ID: 'demo-tenant'
|
||||
};
|
||||
window.history.pushState({}, '', '/assignments');
|
||||
});
|
||||
|
||||
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 assignments for authenticated users', async () => {
|
||||
@ -52,8 +70,9 @@ describe('Waiter Floor App', () => {
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Submit Order' })).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit Order' }));
|
||||
await waitFor(() => expect(screen.getByText('Submit Order')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('Submit Order'));
|
||||
fireEvent.change(screen.getByPlaceholderText('Order Id'), { target: { value: 'ORD-2200' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit Floor Order' }));
|
||||
|
||||
await waitFor(() => expect(submitFloorOrder).toHaveBeenCalledTimes(1));
|
||||
|
||||
368
src/App.tsx
368
src/App.tsx
@ -1,4 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { DeploymentUnitOutlined, OrderedListOutlined, ReloadOutlined, ShoppingCartOutlined } from '@ant-design/icons';
|
||||
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 type { IdentityProvider } from './api/sessionApi';
|
||||
import { SessionProvider, useSessionContext } from './auth/sessionContext';
|
||||
import { buildGoogleOidcStartUrl } from './auth/oidcLogin';
|
||||
import {
|
||||
loadDashboard,
|
||||
submitFloorOrder,
|
||||
@ -6,236 +12,230 @@ import {
|
||||
type SubmitFloorOrderResponse,
|
||||
type WaiterAssignmentsResponse
|
||||
} from './api/dashboardApi';
|
||||
import { SessionProvider, useSessionContext } from './auth/sessionContext';
|
||||
import type { IdentityProvider } from './api/sessionApi';
|
||||
|
||||
type RouteKey = 'overview' | 'actions';
|
||||
type AppRoute = '/assignments' | '/orders' | '/session';
|
||||
|
||||
const routeItems: Array<{ key: AppRoute; label: string; icon: ReactNode }> = [
|
||||
{ key: '/assignments', label: 'Assignments', icon: <OrderedListOutlined /> },
|
||||
{ key: '/orders', label: 'Submit Order', icon: <ShoppingCartOutlined /> },
|
||||
{ key: '/session', label: 'Session', icon: <DeploymentUnitOutlined /> }
|
||||
];
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<WaiterFloorShell />
|
||||
<BrowserRouter>
|
||||
<WaiterFloorShell />
|
||||
</BrowserRouter>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WaiterFloorShell() {
|
||||
const session = useSessionContext();
|
||||
const [route, setRoute] = useState<RouteKey>('overview');
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [contextId, setContextId] = useState('demo-context');
|
||||
const [assignments, setAssignments] = useState<WaiterAssignmentsResponse | null>(null);
|
||||
|
||||
const [orderRequest, setOrderRequest] = useState<SubmitFloorOrderRequest>({
|
||||
contextId: 'demo-context',
|
||||
tableId: 'T-12',
|
||||
orderId: 'ORD-1001',
|
||||
itemCount: 3
|
||||
});
|
||||
const [orderResponse, setOrderResponse] = useState<SubmitFloorOrderResponse | null>(null);
|
||||
const [orderHistory, setOrderHistory] = useState<SubmitFloorOrderResponse[]>([]);
|
||||
const [globalError, setGlobalError] = useState<string | null>(null);
|
||||
const [loadingAssignments, setLoadingAssignments] = useState(false);
|
||||
const [submittingOrder, setSubmittingOrder] = useState(false);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const loginUrl = useMemo(() => buildGoogleOidcStartUrl(window.location.href), []);
|
||||
const selectedKey = useMemo(() => {
|
||||
const candidate = routeItems.find((item) => location.pathname.startsWith(item.key));
|
||||
return candidate?.key ?? '/assignments';
|
||||
}, [location.pathname]);
|
||||
|
||||
const loadAssignments = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoadingAssignments(true);
|
||||
setGlobalError(null);
|
||||
try {
|
||||
const payload = await loadDashboard(contextId);
|
||||
setAssignments(payload);
|
||||
setOrderRequest((previous) => ({ ...previous, contextId }));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load waiter assignments.');
|
||||
setGlobalError(err instanceof Error ? err.message : 'Failed to load waiter assignments.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingAssignments(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitOrder = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const submitOrder = async (request: SubmitFloorOrderRequest) => {
|
||||
setSubmittingOrder(true);
|
||||
setGlobalError(null);
|
||||
try {
|
||||
const payload = await submitFloorOrder(orderRequest);
|
||||
const payload = await submitFloorOrder(request);
|
||||
setOrderResponse(payload);
|
||||
setOrderHistory((previous) => [payload, ...previous].slice(0, 8));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to submit floor order.');
|
||||
setGlobalError(err instanceof Error ? err.message : 'Failed to submit floor order.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSubmittingOrder(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (session.status === 'loading') {
|
||||
return (
|
||||
<main className="app">
|
||||
<h1>Waiter Floor Web</h1>
|
||||
<p className="muted">Restoring session...</p>
|
||||
</main>
|
||||
<div className="fullscreen-center">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (session.status !== 'authenticated' || !session.profile) {
|
||||
return (
|
||||
<main className="app">
|
||||
<h1>Waiter Floor Web</h1>
|
||||
<p className="muted">Sign in with Thalos to access protected routes.</p>
|
||||
{session.error && <div className="alert">{session.error}</div>}
|
||||
<LoginCard />
|
||||
<Result
|
||||
status="403"
|
||||
title="Authentication Required"
|
||||
subTitle="Sign in through the central auth host to access waiter-floor operations."
|
||||
extra={
|
||||
<Button type="primary" href={loginUrl}>
|
||||
Continue with Google
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{session.error && <Alert type="error" showIcon message={session.error} />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="app">
|
||||
<h1>Waiter Floor Web</h1>
|
||||
<p className="muted">Assignments and order submission MVP for floor operations.</p>
|
||||
<Layout className="full-layout">
|
||||
<Layout.Sider width={240} breakpoint="lg" collapsedWidth={0}>
|
||||
<div className="brand">Waiter Floor Web</div>
|
||||
<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}>Waiter Floor Operations</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
Protected floor workflows for assignment visibility and order submission.
|
||||
</Typography.Paragraph>
|
||||
{session.error && <Alert className="stack-gap" type="warning" showIcon message={session.error} />}
|
||||
{globalError && <Alert className="stack-gap" type="error" showIcon message={globalError} />}
|
||||
|
||||
<section className="card row">
|
||||
<span className="badge">subject: {session.profile.subjectId}</span>
|
||||
<span className="badge">tenant: {session.profile.tenantId}</span>
|
||||
<span className="badge">provider: {providerLabel(session.profile.provider)}</span>
|
||||
<span className="spacer" />
|
||||
<button type="button" className="secondary" onClick={() => void session.refresh()}>
|
||||
Refresh Session
|
||||
</button>
|
||||
<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')}
|
||||
>
|
||||
Assignments
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={route === 'actions' ? 'active' : undefined}
|
||||
onClick={() => setRoute('actions')}
|
||||
>
|
||||
Submit Order
|
||||
</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">
|
||||
<label className="col">
|
||||
Context Id
|
||||
<input value={contextId} onChange={(event) => setContextId(event.target.value)} />
|
||||
</label>
|
||||
<button type="button" onClick={() => void loadAssignments()} disabled={loading}>
|
||||
{loading ? 'Loading...' : 'Load Assignments'}
|
||||
</button>
|
||||
</div>
|
||||
<pre>{JSON.stringify(assignments, null, 2)}</pre>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{route === 'actions' && (
|
||||
<section className="card col">
|
||||
<div className="grid">
|
||||
<label className="col">
|
||||
Context Id
|
||||
<input
|
||||
value={orderRequest.contextId}
|
||||
onChange={(event) => setOrderRequest((previous) => ({ ...previous, contextId: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="col">
|
||||
Table Id
|
||||
<input
|
||||
value={orderRequest.tableId}
|
||||
onChange={(event) => setOrderRequest((previous) => ({ ...previous, tableId: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="col">
|
||||
Order Id
|
||||
<input
|
||||
value={orderRequest.orderId}
|
||||
onChange={(event) => setOrderRequest((previous) => ({ ...previous, orderId: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="col">
|
||||
Item Count
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={orderRequest.itemCount}
|
||||
onChange={(event) =>
|
||||
setOrderRequest((previous) => ({
|
||||
...previous,
|
||||
itemCount: Math.max(1, Number(event.target.value) || 1)
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" onClick={() => void submitOrder()} disabled={loading}>
|
||||
{loading ? 'Submitting...' : 'Submit Floor Order'}
|
||||
</button>
|
||||
<pre>{JSON.stringify(orderResponse, 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>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/assignments"
|
||||
element={
|
||||
<Card title="Assignments">
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Space wrap>
|
||||
<Input
|
||||
value={contextId}
|
||||
onChange={(event) => setContextId(event.target.value)}
|
||||
placeholder="Context Id"
|
||||
style={{ width: 280 }}
|
||||
/>
|
||||
<Button type="primary" loading={loadingAssignments} onClick={() => void loadAssignments()}>
|
||||
Load Assignments
|
||||
</Button>
|
||||
</Space>
|
||||
{assignments ? (
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
<Descriptions.Item label="Context Id">{assignments.contextId}</Descriptions.Item>
|
||||
<Descriptions.Item label="Summary">{assignments.summary}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : (
|
||||
<Typography.Text type="secondary">No assignment snapshot loaded.</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/orders"
|
||||
element={
|
||||
<Card title="Submit Floor Order">
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Form
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
contextId,
|
||||
tableId: 'T-12',
|
||||
orderId: 'ORD-1001',
|
||||
itemCount: 3
|
||||
}}
|
||||
onFinish={(values: SubmitFloorOrderRequest) => void submitOrder(values)}
|
||||
>
|
||||
<Form.Item name="contextId" label="Context Id" rules={[{ required: true }]}>
|
||||
<Input placeholder="Context Id" />
|
||||
</Form.Item>
|
||||
<Form.Item name="tableId" label="Table Id" rules={[{ required: true }]}>
|
||||
<Input placeholder="Table Id" />
|
||||
</Form.Item>
|
||||
<Form.Item name="orderId" label="Order Id" rules={[{ required: true }]}>
|
||||
<Input placeholder="Order Id" />
|
||||
</Form.Item>
|
||||
<Form.Item name="itemCount" label="Item Count" rules={[{ required: true, type: 'number', min: 1 }]}>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={submittingOrder}>
|
||||
Submit Floor Order
|
||||
</Button>
|
||||
</Form>
|
||||
{orderResponse && (
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
<Descriptions.Item label="Order Id">{orderResponse.orderId}</Descriptions.Item>
|
||||
<Descriptions.Item label="Accepted">{String(orderResponse.accepted)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Message">{orderResponse.message}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
<Table<SubmitFloorOrderResponse>
|
||||
pagination={false}
|
||||
rowKey={(record) => record.orderId}
|
||||
dataSource={orderHistory}
|
||||
columns={[
|
||||
{ title: 'Order Id', dataIndex: 'orderId' },
|
||||
{
|
||||
title: 'Accepted',
|
||||
render: (_, record) => <Tag color={record.accepted ? 'green' : 'red'}>{String(record.accepted)}</Tag>
|
||||
},
|
||||
{ title: 'Message', dataIndex: 'message' }
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/session"
|
||||
element={
|
||||
<Card title="Session Details">
|
||||
<pre>{JSON.stringify(session.profile, null, 2)}</pre>
|
||||
</Card>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to="/assignments" replace />} />
|
||||
<Route path="*" element={<Navigate to="/assignments" replace />} />
|
||||
</Routes>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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', () => {
|
||||
afterEach(() => {
|
||||
@ -38,4 +38,14 @@ describe('client runtime base URLs', () => {
|
||||
it('falls back to localhost default when both runtime and env are missing', () => {
|
||||
expect(getApiBaseUrl()).toBe('http://localhost:8080');
|
||||
});
|
||||
|
||||
it('uses runtime defaults for return URL and tenant when present', () => {
|
||||
window.__APP_CONFIG__ = {
|
||||
THALOS_DEFAULT_RETURN_URL: 'https://waiter-floor-demo.dream-views.com/assignments',
|
||||
THALOS_DEFAULT_TENANT_ID: 'tenant-1'
|
||||
};
|
||||
|
||||
expect(getThalosDefaultReturnUrl()).toBe('https://waiter-floor-demo.dream-views.com/assignments');
|
||||
expect(getThalosDefaultTenantId()).toBe('tenant-1');
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,6 +3,8 @@ declare global {
|
||||
__APP_CONFIG__?: {
|
||||
API_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();
|
||||
}
|
||||
|
||||
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}/assignments`;
|
||||
}
|
||||
|
||||
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> {
|
||||
return requestJson<T>(baseUrl, path, { method: 'GET' });
|
||||
}
|
||||
|
||||
21
src/auth/oidcLogin.test.ts
Normal file
21
src/auth/oidcLogin.test.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildGoogleOidcStartUrl } from './oidcLogin';
|
||||
|
||||
describe('oidc login url builder', () => {
|
||||
afterEach(() => {
|
||||
delete window.__APP_CONFIG__;
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('builds Google start URL with safe return URL and tenant context', () => {
|
||||
window.__APP_CONFIG__ = {
|
||||
THALOS_AUTH_BASE_URL: 'https://auth.dream-views.com',
|
||||
THALOS_DEFAULT_TENANT_ID: 'demo-tenant'
|
||||
};
|
||||
|
||||
const url = buildGoogleOidcStartUrl('https://waiter-floor-demo.dream-views.com/assignments');
|
||||
expect(url).toContain('/api/identity/oidc/google/start');
|
||||
expect(url).toContain('tenantId=demo-tenant');
|
||||
expect(url).toContain(encodeURIComponent('https://waiter-floor-demo.dream-views.com/assignments'));
|
||||
});
|
||||
});
|
||||
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 = 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();
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import 'antd/dist/reset.css';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
|
||||
140
src/styles.css
140
src/styles.css
@ -1,138 +1,60 @@
|
||||
:root {
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
color: #111827;
|
||||
background: radial-gradient(circle at top, #f7f8fb 0%, #eef2ff 45%, #f8fafc 100%);
|
||||
background: radial-gradient(circle at 20% 0%, #f8fbff 0%, #eef4ff 45%, #f7fafc 100%);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app {
|
||||
width: min(960px, 94vw);
|
||||
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 {
|
||||
min-height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
place-items: center;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
border-radius: 10px;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #7f1d1d;
|
||||
.fullscreen-center {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #475569;
|
||||
.full-layout {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
color: #0f172a;
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
.brand {
|
||||
color: #e2e8f0;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
.header {
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
background: #dbeafe;
|
||||
color: #1e3a8a;
|
||||
.content {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
background: #1d4ed8;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
.stack-gap {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
|
||||
@ -1 +1,30 @@
|
||||
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