Compare commits
No commits in common. "1b28417e0450486734544f5f22a48f26d93f348b" and "70b9ef1f3f6c191fa61af75e48c43ca07daa8e3b" have entirely different histories.
1b28417e04
...
70b9ef1f3f
@ -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:-https://furniture-display-demo.dream-views.com/availability}",
|
|
||||||
THALOS_DEFAULT_TENANT_ID: "${THALOS_DEFAULT_TENANT_ID:-demo-tenant}"
|
|
||||||
};
|
};
|
||||||
EOT
|
EOT
|
||||||
|
|||||||
@ -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,3 @@
|
|||||||
|
|
||||||
- Furniture availability lookup
|
- Furniture availability lookup
|
||||||
- Protected quick-reload actions for recent furniture ids
|
- Protected quick-reload actions for recent furniture ids
|
||||||
- Reservation draft workflow (local projection) for UX validation until reservation APIs are exposed
|
|
||||||
- Session inspection route and auth-state-aware route shell
|
|
||||||
|
|||||||
@ -11,9 +11,6 @@ docker build -t agilewebs/furniture-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:22380/availability \
|
|
||||||
-e THALOS_DEFAULT_TENANT_ID=demo-tenant \
|
|
||||||
--name furniture-web agilewebs/furniture-web:dev
|
--name furniture-web agilewebs/furniture-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
|
||||||
|
|
||||||
|
|||||||
@ -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:22380/availability \
|
|
||||||
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`.
|
||||||
|
|
||||||
|
|||||||
@ -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 availability flow, and reservation draft workflow.
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
1159
package-lock.json
generated
1159
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -11,11 +11,8 @@
|
|||||||
"test:ci": "vitest run --coverage=false"
|
"test:ci": "vitest run --coverage=false"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.0.0",
|
|
||||||
"antd": "^5.27.0",
|
|
||||||
"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,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: "https://furniture-display-demo.dream-views.com/availability",
|
|
||||||
THALOS_DEFAULT_TENANT_ID: "demo-tenant"
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -13,32 +13,25 @@ vi.mock('./api/dashboardApi', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { loadDashboard } from './api/dashboardApi';
|
import { loadDashboard } from './api/dashboardApi';
|
||||||
import { getSessionMe } from './api/sessionApi';
|
import { getSessionMe, loginSession } from './api/sessionApi';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
describe('Furniture App', () => {
|
describe('Furniture App', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(loadDashboard).mockReset();
|
vi.mocked(loadDashboard).mockReset();
|
||||||
vi.mocked(getSessionMe).mockReset();
|
vi.mocked(getSessionMe).mockReset();
|
||||||
window.__APP_CONFIG__ = {
|
vi.mocked(loginSession).mockReset();
|
||||||
API_BASE_URL: 'http://localhost:8080',
|
|
||||||
THALOS_AUTH_BASE_URL: 'https://auth.dream-views.com',
|
|
||||||
THALOS_DEFAULT_RETURN_URL: 'https://furniture-display-demo.dream-views.com/availability',
|
|
||||||
THALOS_DEFAULT_TENANT_ID: 'demo-tenant'
|
|
||||||
};
|
|
||||||
window.history.pushState({}, '', '/availability');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows central login action when session is missing', async () => {
|
it('requires login when session is missing', async () => {
|
||||||
vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 0 });
|
vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 0 });
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByRole('link', { name: 'Continue with Google' })).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument());
|
||||||
const link = screen.getByRole('link', { name: 'Continue with Google' }) as HTMLAnchorElement;
|
fireEvent.click(screen.getByRole('button', { name: 'Sign In' }));
|
||||||
|
|
||||||
expect(link.href).toContain('/api/identity/oidc/google/start');
|
await waitFor(() => expect(loginSession).toHaveBeenCalledTimes(1));
|
||||||
expect(link.href).toContain('tenantId=demo-tenant');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads protected availability data', async () => {
|
it('loads protected availability data', async () => {
|
||||||
@ -61,33 +54,7 @@ describe('Furniture App', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(loadDashboard).toHaveBeenCalledWith('FURN-001');
|
expect(loadDashboard).toHaveBeenCalledWith('FURN-001');
|
||||||
expect(screen.getAllByText(/Chair/).length).toBeGreaterThan(0);
|
expect(screen.getByText(/Chair/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows creating local reservation records after loading availability', async () => {
|
|
||||||
vi.mocked(getSessionMe).mockResolvedValue({
|
|
||||||
isAuthenticated: true,
|
|
||||||
subjectId: 'demo-user',
|
|
||||||
tenantId: 'demo-tenant',
|
|
||||||
provider: 0
|
|
||||||
});
|
|
||||||
vi.mocked(loadDashboard).mockResolvedValue({
|
|
||||||
furnitureId: 'FURN-001',
|
|
||||||
displayName: 'Chair',
|
|
||||||
quantityAvailable: 4
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<App />);
|
|
||||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Load Availability' })).toBeInTheDocument());
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Load Availability' }));
|
|
||||||
await waitFor(() => expect(screen.getAllByText('Chair').length).toBeGreaterThan(0));
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Reservations'));
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('Customer name'), { target: { value: 'Jane Doe' } });
|
|
||||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '2' } });
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Create Reservation' }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('Jane Doe')).toBeInTheDocument());
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
383
src/App.tsx
383
src/App.tsx
@ -1,279 +1,190 @@
|
|||||||
import { CalendarOutlined, DatabaseOutlined, LoginOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { useState } from 'react';
|
||||||
import { Alert, Button, Card, Descriptions, Empty, 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 { loadDashboard, type FurnitureAvailabilityResponse } from './api/dashboardApi';
|
import { loadDashboard, type FurnitureAvailabilityResponse } from './api/dashboardApi';
|
||||||
import type { IdentityProvider } from './api/sessionApi';
|
|
||||||
import { SessionProvider, useSessionContext } from './auth/sessionContext';
|
import { SessionProvider, useSessionContext } from './auth/sessionContext';
|
||||||
import { buildGoogleOidcStartUrl } from './auth/oidcLogin';
|
import type { IdentityProvider } from './api/sessionApi';
|
||||||
|
|
||||||
type AppRoute = '/availability' | '/reservations' | '/session';
|
type RouteKey = 'overview' | 'actions';
|
||||||
type ReservationRecord = {
|
|
||||||
key: string;
|
|
||||||
reservationId: string;
|
|
||||||
furnitureId: string;
|
|
||||||
customerName: string;
|
|
||||||
quantity: number;
|
|
||||||
status: 'pending' | 'confirmed';
|
|
||||||
createdAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const routeItems: Array<{ key: AppRoute; label: string; icon: ReactNode }> = [
|
|
||||||
{ key: '/availability', label: 'Availability', icon: <DatabaseOutlined /> },
|
|
||||||
{ key: '/reservations', label: 'Reservations', icon: <CalendarOutlined /> },
|
|
||||||
{ key: '/session', label: 'Session', icon: <LoginOutlined /> }
|
|
||||||
];
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<BrowserRouter>
|
<FurnitureShell />
|
||||||
<FurnitureShell />
|
|
||||||
</BrowserRouter>
|
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FurnitureShell() {
|
function FurnitureShell() {
|
||||||
const session = useSessionContext();
|
const session = useSessionContext();
|
||||||
const location = useLocation();
|
const [route, setRoute] = useState<RouteKey>('overview');
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [furnitureId, setFurnitureId] = useState('FURN-001');
|
const [furnitureId, setFurnitureId] = useState('FURN-001');
|
||||||
const [availability, setAvailability] = useState<FurnitureAvailabilityResponse | null>(null);
|
const [availability, setAvailability] = useState<FurnitureAvailabilityResponse | null>(null);
|
||||||
const [history, setHistory] = useState<FurnitureAvailabilityResponse[]>([]);
|
const [history, setHistory] = useState<FurnitureAvailabilityResponse[]>([]);
|
||||||
const [reservations, setReservations] = useState<ReservationRecord[]>([]);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [globalError, setGlobalError] = useState<string | null>(null);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadingAvailability, setLoadingAvailability] = useState(false);
|
|
||||||
const [creatingReservation, setCreatingReservation] = 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 ?? '/availability';
|
|
||||||
}, [location.pathname]);
|
|
||||||
|
|
||||||
const loadAvailability = async (targetFurnitureId = furnitureId) => {
|
const loadAvailability = async (targetFurnitureId = furnitureId) => {
|
||||||
setLoadingAvailability(true);
|
setLoading(true);
|
||||||
setGlobalError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await loadDashboard(targetFurnitureId);
|
const response = await loadDashboard(targetFurnitureId);
|
||||||
setAvailability(response);
|
setAvailability(response);
|
||||||
setHistory((previous) => [response, ...previous.filter((item) => item.furnitureId !== response.furnitureId)].slice(0, 8));
|
setHistory((previous) => [response, ...previous.filter((item) => item.furnitureId !== response.furnitureId)].slice(0, 5));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setGlobalError(err instanceof Error ? err.message : 'Unable to load furniture availability.');
|
setError(err instanceof Error ? err.message : 'Unable to load furniture availability.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingAvailability(false);
|
setLoading(false);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createReservation = async (values: { customerName: string; quantity: number }) => {
|
|
||||||
setCreatingReservation(true);
|
|
||||||
setGlobalError(null);
|
|
||||||
try {
|
|
||||||
if (!availability) {
|
|
||||||
setGlobalError('Load availability before creating a reservation.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (values.quantity > availability.quantityAvailable) {
|
|
||||||
setGlobalError('Requested quantity is greater than current availability.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Temporary local reservation projection until reservation endpoints are exposed by furniture-bff.
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const reservationId = `RSV-${Date.now().toString(36).toUpperCase()}`;
|
|
||||||
setReservations((previous) => [
|
|
||||||
{
|
|
||||||
key: reservationId,
|
|
||||||
reservationId,
|
|
||||||
furnitureId: availability.furnitureId,
|
|
||||||
customerName: values.customerName,
|
|
||||||
quantity: values.quantity,
|
|
||||||
status: 'pending',
|
|
||||||
createdAt: timestamp
|
|
||||||
},
|
|
||||||
...previous
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
setCreatingReservation(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (session.status === 'loading') {
|
if (session.status === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div className="fullscreen-center">
|
<main className="app">
|
||||||
<Spin size="large" />
|
<h1>Furniture 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>Furniture Web</h1>
|
||||||
status="403"
|
<p className="muted">Sign in with Thalos to access furniture operations.</p>
|
||||||
title="Authentication Required"
|
{session.error && <div className="alert">{session.error}</div>}
|
||||||
subTitle="Sign in through the central auth host to access furniture 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>Furniture Web</h1>
|
||||||
<div className="brand">Furniture Web</div>
|
<p className="muted">Availability MVP with protected routes and resilient request handling.</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}>Furniture Operations</Typography.Title>
|
|
||||||
<Typography.Paragraph type="secondary">
|
|
||||||
Protected furniture workflow shell with Ant Design components and resilient auth-aware UX.
|
|
||||||
</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="/availability"
|
<span className="badge">tenant: {session.profile.tenantId}</span>
|
||||||
element={
|
<span className="badge">provider: {providerLabel(session.profile.provider)}</span>
|
||||||
<Card title="Availability Lookup">
|
<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={furnitureId}
|
<button type="button" className="warn" onClick={() => void session.logout()}>
|
||||||
onChange={(event) => setFurnitureId(event.target.value)}
|
Logout
|
||||||
placeholder="Furniture Id"
|
</button>
|
||||||
style={{ width: 260 }}
|
</section>
|
||||||
/>
|
|
||||||
<Button type="primary" loading={loadingAvailability} onClick={() => void loadAvailability()}>
|
<section className="card tabs" aria-label="route-shell">
|
||||||
Load Availability
|
<button
|
||||||
</Button>
|
type="button"
|
||||||
</Space>
|
className={route === 'overview' ? 'active' : undefined}
|
||||||
{availability ? (
|
onClick={() => setRoute('overview')}
|
||||||
<Descriptions bordered size="small" column={1}>
|
>
|
||||||
<Descriptions.Item label="Furniture Id">{availability.furnitureId}</Descriptions.Item>
|
Availability
|
||||||
<Descriptions.Item label="Display Name">{availability.displayName}</Descriptions.Item>
|
</button>
|
||||||
<Descriptions.Item label="Quantity Available">{availability.quantityAvailable}</Descriptions.Item>
|
<button
|
||||||
</Descriptions>
|
type="button"
|
||||||
) : (
|
className={route === 'actions' ? 'active' : undefined}
|
||||||
<Empty description="No availability loaded." />
|
onClick={() => setRoute('actions')}
|
||||||
)}
|
>
|
||||||
<Typography.Title level={5}>Recent Lookups</Typography.Title>
|
Quick Actions
|
||||||
<Table<FurnitureAvailabilityResponse>
|
</button>
|
||||||
pagination={false}
|
</section>
|
||||||
rowKey={(record) => record.furnitureId}
|
|
||||||
dataSource={history}
|
{session.error && <div className="alert">{session.error}</div>}
|
||||||
columns={[
|
{error && <div className="alert">{error}</div>}
|
||||||
{ title: 'Furniture Id', dataIndex: 'furnitureId' },
|
|
||||||
{ title: 'Display Name', dataIndex: 'displayName' },
|
{route === 'overview' && (
|
||||||
{ title: 'Quantity', dataIndex: 'quantityAvailable' },
|
<section className="card col">
|
||||||
{
|
<div className="row">
|
||||||
title: 'Action',
|
<label className="col">
|
||||||
render: (_, record) => (
|
Furniture Id
|
||||||
<Button
|
<input value={furnitureId} onChange={(event) => setFurnitureId(event.target.value)} />
|
||||||
size="small"
|
</label>
|
||||||
onClick={() => {
|
<button type="button" onClick={() => void loadAvailability()} disabled={loading}>
|
||||||
setFurnitureId(record.furnitureId);
|
{loading ? 'Loading...' : 'Load Availability'}
|
||||||
void loadAvailability(record.furnitureId);
|
</button>
|
||||||
}}
|
</div>
|
||||||
>
|
<pre>{JSON.stringify(availability, null, 2)}</pre>
|
||||||
Reuse
|
</section>
|
||||||
</Button>
|
)}
|
||||||
)
|
|
||||||
}
|
{route === 'actions' && (
|
||||||
]}
|
<section className="card col">
|
||||||
/>
|
<strong>Recent lookups</strong>
|
||||||
</Space>
|
<div className="row">
|
||||||
</Card>
|
{history.length === 0 && <span className="muted">No lookups yet.</span>}
|
||||||
}
|
{history.map((item) => (
|
||||||
/>
|
<button
|
||||||
<Route
|
key={item.furnitureId}
|
||||||
path="/reservations"
|
type="button"
|
||||||
element={
|
className="ghost"
|
||||||
<Card title="Reservation Drafting">
|
onClick={() => {
|
||||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
setFurnitureId(item.furnitureId);
|
||||||
<Typography.Paragraph type="secondary">
|
void loadAvailability(item.furnitureId);
|
||||||
Creates local reservation projections to validate UX while backend reservation endpoints are staged.
|
}}
|
||||||
</Typography.Paragraph>
|
>
|
||||||
<Form layout="vertical" onFinish={(values) => void createReservation(values)}>
|
{item.furnitureId} ({item.quantityAvailable})
|
||||||
<Form.Item label="Customer Name" name="customerName" rules={[{ required: true }]}>
|
</button>
|
||||||
<Input placeholder="Customer name" />
|
))}
|
||||||
</Form.Item>
|
</div>
|
||||||
<Form.Item
|
<p className="muted">Use quick actions to repeat common availability checks.</p>
|
||||||
label="Quantity"
|
</section>
|
||||||
name="quantity"
|
)}
|
||||||
initialValue={1}
|
</main>
|
||||||
rules={[{ required: true, type: 'number', min: 1 }]}
|
);
|
||||||
>
|
}
|
||||||
<InputNumber min={1} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
function LoginCard() {
|
||||||
<Button type="primary" htmlType="submit" loading={creatingReservation}>
|
const session = useSessionContext();
|
||||||
Create Reservation
|
const [subjectId, setSubjectId] = useState('demo-user');
|
||||||
</Button>
|
const [tenantId, setTenantId] = useState('demo-tenant');
|
||||||
</Form>
|
const [provider, setProvider] = useState<IdentityProvider>(0);
|
||||||
<Table<ReservationRecord>
|
const [externalToken, setExternalToken] = useState('');
|
||||||
pagination={false}
|
const [error, setError] = useState<string | null>(null);
|
||||||
dataSource={reservations}
|
const [submitting, setSubmitting] = useState(false);
|
||||||
columns={[
|
|
||||||
{ title: 'Reservation Id', dataIndex: 'reservationId' },
|
const onSubmit = async () => {
|
||||||
{ title: 'Furniture Id', dataIndex: 'furnitureId' },
|
setSubmitting(true);
|
||||||
{ title: 'Customer', dataIndex: 'customerName' },
|
setError(null);
|
||||||
{ title: 'Quantity', dataIndex: 'quantity' },
|
try {
|
||||||
{
|
await session.login({ subjectId, tenantId, provider, externalToken });
|
||||||
title: 'Status',
|
} catch (err) {
|
||||||
render: (_, record) => <Tag color={record.status === 'confirmed' ? 'green' : 'gold'}>{record.status}</Tag>
|
setError(err instanceof Error ? err.message : 'Login failed.');
|
||||||
},
|
} finally {
|
||||||
{ title: 'Created At', dataIndex: 'createdAt' }
|
setSubmitting(false);
|
||||||
]}
|
}
|
||||||
/>
|
};
|
||||||
</Space>
|
|
||||||
</Card>
|
return (
|
||||||
}
|
<section className="card col">
|
||||||
/>
|
<div className="grid">
|
||||||
<Route
|
<label className="col">
|
||||||
path="/session"
|
Subject Id
|
||||||
element={
|
<input value={subjectId} onChange={(event) => setSubjectId(event.target.value)} />
|
||||||
<Card title="Session Details">
|
</label>
|
||||||
<pre>{JSON.stringify(session.profile, null, 2)}</pre>
|
<label className="col">
|
||||||
</Card>
|
Tenant Id
|
||||||
}
|
<input value={tenantId} onChange={(event) => setTenantId(event.target.value)} />
|
||||||
/>
|
</label>
|
||||||
<Route path="/" element={<Navigate to="/availability" replace />} />
|
<label className="col">
|
||||||
<Route path="*" element={<Navigate to="/availability" replace />} />
|
Provider
|
||||||
</Routes>
|
<select value={String(provider)} onChange={(event) => setProvider(Number(event.target.value) as IdentityProvider)}>
|
||||||
</Layout.Content>
|
<option value="0">Internal JWT</option>
|
||||||
</Layout>
|
<option value="1">Azure AD (simulated)</option>
|
||||||
</Layout>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,14 +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 runtime defaults for return URL and tenant when present', () => {
|
|
||||||
window.__APP_CONFIG__ = {
|
|
||||||
THALOS_DEFAULT_RETURN_URL: 'https://furniture-display-demo.dream-views.com/availability',
|
|
||||||
THALOS_DEFAULT_TENANT_ID: 'tenant-1'
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(getThalosDefaultReturnUrl()).toBe('https://furniture-display-demo.dream-views.com/availability');
|
|
||||||
expect(getThalosDefaultTenantId()).toBe('tenant-1');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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}/availability`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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' });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
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://furniture-display-demo.dream-views.com/availability');
|
|
||||||
expect(url).toContain('/api/identity/oidc/google/start');
|
|
||||||
expect(url).toContain('tenantId=demo-tenant');
|
|
||||||
expect(url).toContain(encodeURIComponent('https://furniture-display-demo.dream-views.com/availability'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to default return URL when protocol is invalid', () => {
|
|
||||||
window.__APP_CONFIG__ = {
|
|
||||||
THALOS_AUTH_BASE_URL: 'https://auth.dream-views.com',
|
|
||||||
THALOS_DEFAULT_RETURN_URL: 'https://furniture-display-demo.dream-views.com/availability'
|
|
||||||
};
|
|
||||||
|
|
||||||
const url = buildGoogleOidcStartUrl('javascript:alert(1)');
|
|
||||||
expect(url).toContain(encodeURIComponent('https://furniture-display-demo.dream-views.com/availability'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import 'antd/dist/reset.css';
|
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
|
|||||||
150
src/styles.css
150
src/styles.css
@ -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 {
|
||||||
|
|||||||
@ -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;
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user