diff --git a/docs/architecture/frontend-boundary.md b/docs/architecture/frontend-boundary.md index efab264..9fe65e2 100644 --- a/docs/architecture/frontend-boundary.md +++ b/docs/architecture/frontend-boundary.md @@ -16,10 +16,12 @@ ## Protected Workflow Endpoints - `GET /api/restaurant/admin/config?contextId=...` +- `GET /api/restaurant/admin/changes?contextId=...` - `POST /api/restaurant/admin/service-window` ## UI Workflow Coverage -- Restaurant config lookup -- Service-window management +- Restaurant config snapshot lookup with version, feature flags, and service windows +- Recent configuration change inspection +- Service-window management with latest applied snapshot and local action history - Protected route shell for config, service-window, and session inspection diff --git a/docs/runbooks/local-development.md b/docs/runbooks/local-development.md index f4aeb2c..7317424 100644 --- a/docs/runbooks/local-development.md +++ b/docs/runbooks/local-development.md @@ -21,6 +21,7 @@ npm run dev - Login is executed via central Thalos OIDC start endpoint. - Business calls are gated behind session checks. - Session cookies are sent with `credentials: include`. +- Unauthorized control-plane calls surface session-expired guidance and direct the operator back to the central auth host. ## Build diff --git a/docs/runbooks/testing.md b/docs/runbooks/testing.md index d18e147..3f5522b 100644 --- a/docs/runbooks/testing.md +++ b/docs/runbooks/testing.md @@ -15,9 +15,9 @@ npm run test:ci ## Coverage Scope - `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`: config, recent-change, and service-window endpoint mapping. - `src/auth/oidcLogin.test.ts`: OIDC start-url generation and safe return-url fallback. -- `src/App.test.tsx`: central login screen, protected config flow, and service-window workflow. +- `src/App.test.tsx`: central login screen, protected admin snapshot flow, service-window workflow, and reauthentication guidance. ## Notes diff --git a/src/App.test.tsx b/src/App.test.tsx index 8be5d57..992307d 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -10,16 +10,19 @@ vi.mock('./api/sessionApi', () => ({ vi.mock('./api/dashboardApi', () => ({ loadDashboard: vi.fn(), + loadRecentChanges: vi.fn(), setServiceWindow: vi.fn() })); -import { loadDashboard, setServiceWindow } from './api/dashboardApi'; +import { loadDashboard, loadRecentChanges, setServiceWindow } from './api/dashboardApi'; +import { ApiError } from './api/client'; import { getSessionMe } from './api/sessionApi'; import App from './App'; describe('Restaurant Admin App', () => { beforeEach(() => { vi.mocked(loadDashboard).mockReset(); + vi.mocked(loadRecentChanges).mockReset(); vi.mocked(setServiceWindow).mockReset(); vi.mocked(getSessionMe).mockReset(); window.__APP_CONFIG__ = { @@ -42,34 +45,79 @@ describe('Restaurant Admin App', () => { expect(link.href).toContain('tenantId=demo-tenant'); }); - it('loads admin config for authenticated users', async () => { + it('loads admin config and recent changes for authenticated users', async () => { vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: true, subjectId: 'demo-user', tenantId: 'demo-tenant', - provider: 0 + provider: 2 + }); + vi.mocked(loadDashboard).mockResolvedValue({ + contextId: 'demo-context', + summary: 'configured', + version: 'v2', + featureFlags: [{ key: 'pos.closeout.preview', enabled: true }], + serviceWindows: [{ day: 1, openAt: '08:00:00', closeAt: '22:00:00', isClosed: false }], + recentChanges: [] + }); + vi.mocked(loadRecentChanges).mockResolvedValue({ + contextId: 'demo-context', + summary: 'changes', + version: 'v2', + recentChanges: [ + { + changeId: 'CFG-100', + category: 'feature-flag', + description: 'Enabled POS closeout preview mode.', + updatedBy: 'ops-lead', + updatedAtUtc: '2026-03-31T12:00:00Z' + } + ] }); - vi.mocked(loadDashboard).mockResolvedValue({ contextId: 'demo-context', summary: 'configured' }); render(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Load Config' })).toBeInTheDocument()); - fireEvent.click(screen.getByRole('button', { name: 'Load Config' })); + await waitFor(() => expect(screen.getByRole('button', { name: /Load Config/ })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /Load Config/ })); + fireEvent.click(screen.getByRole('button', { name: /Load Recent Changes/ })); await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context')); + await waitFor(() => expect(loadRecentChanges).toHaveBeenCalledWith('demo-context')); + expect(await screen.findByText('pos.closeout.preview')).toBeInTheDocument(); + expect(await screen.findByText('CFG-100')).toBeInTheDocument(); }); - it('applies service window from action route', async () => { + it('applies service window and shows the latest applied snapshot', async () => { vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: true, subjectId: 'demo-user', tenantId: 'demo-tenant', - provider: 0 + provider: 2 + }); + vi.mocked(loadDashboard).mockResolvedValue({ + contextId: 'demo-context', + summary: 'configured', + version: 'v2', + featureFlags: [], + serviceWindows: [], + recentChanges: [] + }); + vi.mocked(loadRecentChanges).mockResolvedValue({ + contextId: 'demo-context', + summary: 'changes', + version: 'v2', + recentChanges: [] }); vi.mocked(setServiceWindow).mockResolvedValue({ contextId: 'demo-context', applied: true, - message: 'applied' + message: 'Service window updated by admin-operator.', + serviceWindow: { + day: 1, + openAt: '08:00:00', + closeAt: '22:00:00', + isClosed: false + } }); render(); @@ -79,5 +127,27 @@ describe('Restaurant Admin App', () => { fireEvent.click(screen.getByRole('button', { name: 'Apply Service Window' })); await waitFor(() => expect(setServiceWindow).toHaveBeenCalledTimes(1)); + expect(await screen.findAllByText('Service window updated by admin-operator.')).toHaveLength(2); + expect((await screen.findAllByText('Monday')).length).toBeGreaterThanOrEqual(2); + }); + + it('shows reauthentication guidance when the session expires', async () => { + vi.mocked(getSessionMe) + .mockResolvedValueOnce({ + isAuthenticated: true, + subjectId: 'demo-user', + tenantId: 'demo-tenant', + provider: 2 + }) + .mockResolvedValueOnce({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 2 }); + vi.mocked(loadDashboard).mockRejectedValue(new ApiError(401, 'Unauthorized request.', 'unauthorized', 'corr-401')); + + render(); + + await waitFor(() => expect(screen.getByRole('button', { name: /Load Config/ })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /Load Config/ })); + + await waitFor(() => expect(screen.getByText('Authentication Required')).toBeInTheDocument()); + expect(screen.getByText('Session expired')).toBeInTheDocument(); }); }); diff --git a/src/App.tsx b/src/App.tsx index d49adbf..b2792a0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,16 @@ -import { ControlOutlined, DeploymentUnitOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons'; +import { + ControlOutlined, + DeploymentUnitOutlined, + HistoryOutlined, + ReloadOutlined, + SettingOutlined +} from '@ant-design/icons'; import { Alert, Button, Card, Descriptions, + Empty, Form, Input, Layout, @@ -17,13 +24,19 @@ import { TimePicker, Typography } from 'antd'; -import dayjs, { Dayjs } from 'dayjs'; +import dayjs, { type Dayjs } from 'dayjs'; import { type ReactNode, useMemo, useState } from 'react'; import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; +import { ApiError } from './api/client'; import { loadDashboard, + loadRecentChanges, setServiceWindow, + type ConfigChange, + type FeatureFlagState, + type RecentAdminChangesResponse, type RestaurantAdminConfigResponse, + type ServiceWindow, type SetServiceWindowRequest, type SetServiceWindowResponse } from './api/dashboardApi'; @@ -33,6 +46,11 @@ import { SessionProvider, useSessionContext } from './auth/sessionContext'; type AppRoute = '/config' | '/window' | '/session'; +type WorkflowState = { + error: string | null; + sessionExpired: boolean; +}; + type ServiceWindowFormValues = { contextId: string; day: number; @@ -74,10 +92,12 @@ function RestaurantAdminShell() { const [contextId, setContextId] = useState('demo-context'); const [configPayload, setConfigPayload] = useState(null); + const [changesPayload, setChangesPayload] = useState(null); const [windowResponse, setWindowResponse] = useState(null); const [windowHistory, setWindowHistory] = useState([]); - const [globalError, setGlobalError] = useState(null); + const [workflowState, setWorkflowState] = useState({ error: null, sessionExpired: false }); const [loadingConfig, setLoadingConfig] = useState(false); + const [loadingChanges, setLoadingChanges] = useState(false); const [submittingWindow, setSubmittingWindow] = useState(false); const loginUrl = useMemo(() => buildGoogleOidcStartUrl(window.location.href), []); @@ -86,22 +106,55 @@ function RestaurantAdminShell() { return candidate?.key ?? '/config'; }, [location.pathname]); + const recentChanges = changesPayload?.recentChanges ?? configPayload?.recentChanges ?? []; + + const clearWorkflowError = () => setWorkflowState({ error: null, sessionExpired: false }); + + const handleWorkflowFailure = async (err: unknown, fallbackMessage: string) => { + if (err instanceof ApiError && err.status === 401) { + setWorkflowState({ + error: 'Your session expired. Sign in again to continue restaurant administration work.', + sessionExpired: true + }); + await session.revalidate(); + return; + } + + setWorkflowState({ + error: err instanceof Error ? err.message : fallbackMessage, + sessionExpired: false + }); + }; + const loadConfig = async () => { setLoadingConfig(true); - setGlobalError(null); + clearWorkflowError(); try { const payload = await loadDashboard(contextId); setConfigPayload(payload); } catch (err) { - setGlobalError(err instanceof Error ? err.message : 'Failed to load restaurant admin configuration.'); + await handleWorkflowFailure(err, 'Failed to load restaurant admin configuration.'); } finally { setLoadingConfig(false); } }; + const refreshChanges = async () => { + setLoadingChanges(true); + clearWorkflowError(); + try { + const payload = await loadRecentChanges(contextId); + setChangesPayload(payload); + } catch (err) { + await handleWorkflowFailure(err, 'Failed to load recent configuration changes.'); + } finally { + setLoadingChanges(false); + } + }; + const applyServiceWindow = async (values: ServiceWindowFormValues) => { setSubmittingWindow(true); - setGlobalError(null); + clearWorkflowError(); const request: SetServiceWindowRequest = { contextId: values.contextId, @@ -114,10 +167,11 @@ function RestaurantAdminShell() { try { const payload = await setServiceWindow(request); setWindowResponse(payload); - // Keep recent responses bounded so the session view stays readable over long demos. setWindowHistory((previous) => [payload, ...previous].slice(0, 8)); + await loadConfig(); + await refreshChanges(); } catch (err) { - setGlobalError(err instanceof Error ? err.message : 'Failed to set service window.'); + await handleWorkflowFailure(err, 'Failed to set service window.'); } finally { setSubmittingWindow(false); } @@ -144,6 +198,14 @@ function RestaurantAdminShell() { } /> + {workflowState.sessionExpired && ( + + )} {session.error && } ); @@ -179,45 +241,124 @@ function RestaurantAdminShell() { Restaurant Administration - Protected control-plane workflows for restaurant configuration and service windows. + Protected control-plane workflows for configuration snapshots, change history, and service-window management. {session.error && } - {globalError && } + {workflowState.error && } + {workflowState.sessionExpired && ( + + + + + } + /> + )} - - - setContextId(event.target.value)} - placeholder="Context Id" - style={{ width: 280 }} - /> - + + + + + setContextId(event.target.value)} + placeholder="Context Id" + style={{ width: 280 }} + /> + + + + {configPayload ? ( + + {configPayload.contextId} + {configPayload.version} + {configPayload.summary} + + ) : ( + + )} - {configPayload ? ( - - {configPayload.contextId} - {configPayload.summary} - - ) : ( - No admin configuration loaded. - )} - - + + + + + pagination={false} + rowKey={(record) => record.key} + dataSource={configPayload?.featureFlags ?? []} + locale={{ emptyText: 'No feature flags loaded.' }} + columns={[ + { title: 'Key', dataIndex: 'key' }, + { + title: 'Enabled', + render: (_, record) => ( + {record.enabled ? 'Enabled' : 'Disabled'} + ) + } + ]} + /> + + + + + pagination={false} + rowKey={(record) => `${record.day}-${record.openAt}-${record.closeAt}`} + dataSource={configPayload?.serviceWindows ?? []} + locale={{ emptyText: 'No service windows loaded.' }} + columns={[ + { title: 'Day', render: (_, record) => dayLabel(record.day) }, + { title: 'Open At', dataIndex: 'openAt' }, + { title: 'Close At', dataIndex: 'closeAt' }, + { + title: 'Status', + render: (_, record) => ( + {record.isClosed ? 'Closed' : 'Open'} + ) + } + ]} + /> + + + + + pagination={false} + rowKey={(record) => record.changeId} + dataSource={recentChanges} + locale={{ emptyText: 'No recent changes loaded.' }} + columns={[ + { title: 'Change Id', dataIndex: 'changeId' }, + { title: 'Category', dataIndex: 'category' }, + { title: 'Description', dataIndex: 'description' }, + { title: 'Updated By', dataIndex: 'updatedBy' }, + { title: 'Updated At', render: (_, record) => formatUtc(record.updatedAtUtc) } + ]} + /> + + } /> - + + layout="vertical" initialValues={{ @@ -248,19 +389,35 @@ function RestaurantAdminShell() { Apply Service Window - {windowResponse && ( + + + + {windowResponse ? ( {windowResponse.contextId} {String(windowResponse.applied)} {windowResponse.message} + {dayLabel(windowResponse.serviceWindow.day)} + {windowResponse.serviceWindow.openAt} + {windowResponse.serviceWindow.closeAt} + ) : ( + )} + + + pagination={false} - rowKey={(record) => `${record.contextId}-${record.message}-${record.applied ? '1' : '0'}`} + rowKey={(record) => + `${record.contextId}-${record.message}-${record.serviceWindow.day}-${record.serviceWindow.openAt}` + } dataSource={windowHistory} + locale={{ emptyText: 'No service window actions submitted yet.' }} columns={[ { title: 'Context Id', dataIndex: 'contextId' }, + { title: 'Day', render: (_, record) => dayLabel(record.serviceWindow.day) }, + { title: 'Window', render: (_, record) => `${record.serviceWindow.openAt} - ${record.serviceWindow.closeAt}` }, { title: 'Applied', render: (_, record) => {String(record.applied)} @@ -268,8 +425,8 @@ function RestaurantAdminShell() { { title: 'Message', dataIndex: 'message' } ]} /> - - + + } /> option.value === day)?.label ?? String(day); +} + +function formatUtc(value: string): string { + return new Date(value).toLocaleString(); +} + function providerLabel(provider: IdentityProvider): string { if (provider === 0 || provider === '0' || provider === 'InternalJwt') { return 'Internal JWT'; diff --git a/src/api/dashboardApi.test.ts b/src/api/dashboardApi.test.ts index c40d5ae..ff986e8 100644 --- a/src/api/dashboardApi.test.ts +++ b/src/api/dashboardApi.test.ts @@ -6,7 +6,7 @@ vi.mock('./client', () => ({ })); import { getJson, postJson } from './client'; -import { loadDashboard, setServiceWindow } from './dashboardApi'; +import { loadDashboard, loadRecentChanges, setServiceWindow } from './dashboardApi'; describe('restaurant admin dashboard api', () => { it('builds encoded config endpoint path', async () => { @@ -17,6 +17,14 @@ describe('restaurant admin dashboard api', () => { expect(getJson).toHaveBeenCalledWith('/api/restaurant/admin/config?contextId=ctx%20admin%2F1'); }); + it('builds encoded recent changes endpoint path', async () => { + vi.mocked(getJson).mockResolvedValue({ ok: true }); + + await loadRecentChanges('ctx admin/1'); + + expect(getJson).toHaveBeenCalledWith('/api/restaurant/admin/changes?contextId=ctx%20admin%2F1'); + }); + it('posts service window payload', async () => { vi.mocked(postJson).mockResolvedValue({ applied: true }); diff --git a/src/api/dashboardApi.ts b/src/api/dashboardApi.ts index 0ea9e22..626f456 100644 --- a/src/api/dashboardApi.ts +++ b/src/api/dashboardApi.ts @@ -1,8 +1,39 @@ import { getJson, postJson } from './client'; +export type FeatureFlagState = { + key: string; + enabled: boolean; +}; + +export type ServiceWindow = { + day: number; + openAt: string; + closeAt: string; + isClosed: boolean; +}; + +export type ConfigChange = { + changeId: string; + category: string; + description: string; + updatedBy: string; + updatedAtUtc: string; +}; + export type RestaurantAdminConfigResponse = { contextId: string; summary: string; + version: string; + featureFlags: FeatureFlagState[]; + serviceWindows: ServiceWindow[]; + recentChanges: ConfigChange[]; +}; + +export type RecentAdminChangesResponse = { + contextId: string; + summary: string; + version: string; + recentChanges: ConfigChange[]; }; export type SetServiceWindowRequest = { @@ -17,12 +48,17 @@ export type SetServiceWindowResponse = { contextId: string; applied: boolean; message: string; + serviceWindow: ServiceWindow; }; export async function loadDashboard(contextId: string): Promise { return getJson(`/api/restaurant/admin/config?contextId=${encodeURIComponent(contextId)}`); } +export async function loadRecentChanges(contextId: string): Promise { + return getJson(`/api/restaurant/admin/changes?contextId=${encodeURIComponent(contextId)}`); +} + export async function setServiceWindow(request: SetServiceWindowRequest): Promise { return postJson('/api/restaurant/admin/service-window', request); }