feat(restaurant-admin-web): deepen admin control workflows

This commit is contained in:
José René White Enciso 2026-03-31 17:43:46 -06:00
parent 5bc804097f
commit 745b60ba9f
7 changed files with 334 additions and 52 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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(<App />);
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(<App />);
@ -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(<App />);
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();
});
});

View File

@ -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<RestaurantAdminConfigResponse | null>(null);
const [changesPayload, setChangesPayload] = useState<RecentAdminChangesResponse | null>(null);
const [windowResponse, setWindowResponse] = useState<SetServiceWindowResponse | null>(null);
const [windowHistory, setWindowHistory] = useState<SetServiceWindowResponse[]>([]);
const [globalError, setGlobalError] = useState<string | null>(null);
const [workflowState, setWorkflowState] = useState<WorkflowState>({ 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() {
</Button>
}
/>
{workflowState.sessionExpired && (
<Alert
type="warning"
showIcon
message="Session expired"
description="Sign in again through the central auth host before retrying control-plane changes."
/>
)}
{session.error && <Alert type="error" showIcon message={session.error} />}
</main>
);
@ -179,45 +241,124 @@ function RestaurantAdminShell() {
<Layout.Content className="content">
<Typography.Title level={3}>Restaurant Administration</Typography.Title>
<Typography.Paragraph type="secondary">
Protected control-plane workflows for restaurant configuration and service windows.
Protected control-plane workflows for configuration snapshots, change history, and service-window management.
</Typography.Paragraph>
{session.error && <Alert className="stack-gap" type="warning" showIcon message={session.error} />}
{globalError && <Alert className="stack-gap" type="error" showIcon message={globalError} />}
{workflowState.error && <Alert className="stack-gap" type="error" showIcon message={workflowState.error} />}
{workflowState.sessionExpired && (
<Alert
className="stack-gap"
type="warning"
showIcon
message="Session expired"
description="Refresh your session or sign in again before retrying admin operations."
action={
<Space>
<Button size="small" onClick={() => void session.refresh()}>
Refresh Session
</Button>
<Button size="small" type="primary" href={loginUrl}>
Reauthenticate
</Button>
</Space>
}
/>
)}
<Routes>
<Route
path="/config"
element={
<Card title="Configuration">
<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={loadingConfig} onClick={() => void loadConfig()}>
Load Config
</Button>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card title="Configuration Snapshot">
<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={loadingConfig} onClick={() => void loadConfig()}>
Load Config
</Button>
<Button icon={<HistoryOutlined />} loading={loadingChanges} onClick={() => void refreshChanges()}>
Load Recent Changes
</Button>
</Space>
{configPayload ? (
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Context Id">{configPayload.contextId}</Descriptions.Item>
<Descriptions.Item label="Version">{configPayload.version}</Descriptions.Item>
<Descriptions.Item label="Summary">{configPayload.summary}</Descriptions.Item>
</Descriptions>
) : (
<Empty description="Load a configuration snapshot to inspect flags and service windows." />
)}
</Space>
{configPayload ? (
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Context Id">{configPayload.contextId}</Descriptions.Item>
<Descriptions.Item label="Summary">{configPayload.summary}</Descriptions.Item>
</Descriptions>
) : (
<Typography.Text type="secondary">No admin configuration loaded.</Typography.Text>
)}
</Space>
</Card>
</Card>
<Card title="Feature Flags">
<Table<FeatureFlagState>
pagination={false}
rowKey={(record) => record.key}
dataSource={configPayload?.featureFlags ?? []}
locale={{ emptyText: 'No feature flags loaded.' }}
columns={[
{ title: 'Key', dataIndex: 'key' },
{
title: 'Enabled',
render: (_, record) => (
<Tag color={record.enabled ? 'green' : 'default'}>{record.enabled ? 'Enabled' : 'Disabled'}</Tag>
)
}
]}
/>
</Card>
<Card title="Service Windows">
<Table<ServiceWindow>
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) => (
<Tag color={record.isClosed ? 'red' : 'green'}>{record.isClosed ? 'Closed' : 'Open'}</Tag>
)
}
]}
/>
</Card>
<Card title="Recent Changes">
<Table<ConfigChange>
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) }
]}
/>
</Card>
</Space>
}
/>
<Route
path="/window"
element={
<Card title="Service Window">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card title="Service Window Action">
<Form<ServiceWindowFormValues>
layout="vertical"
initialValues={{
@ -248,19 +389,35 @@ function RestaurantAdminShell() {
Apply Service Window
</Button>
</Form>
{windowResponse && (
</Card>
<Card title="Latest Result">
{windowResponse ? (
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="Context Id">{windowResponse.contextId}</Descriptions.Item>
<Descriptions.Item label="Applied">{String(windowResponse.applied)}</Descriptions.Item>
<Descriptions.Item label="Message">{windowResponse.message}</Descriptions.Item>
<Descriptions.Item label="Day">{dayLabel(windowResponse.serviceWindow.day)}</Descriptions.Item>
<Descriptions.Item label="Open At">{windowResponse.serviceWindow.openAt}</Descriptions.Item>
<Descriptions.Item label="Close At">{windowResponse.serviceWindow.closeAt}</Descriptions.Item>
</Descriptions>
) : (
<Empty description="Submit a service window change to inspect the latest applied snapshot." />
)}
</Card>
<Card title="Recent Service Window Actions">
<Table<SetServiceWindowResponse>
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) => <Tag color={record.applied ? 'green' : 'red'}>{String(record.applied)}</Tag>
@ -268,8 +425,8 @@ function RestaurantAdminShell() {
{ title: 'Message', dataIndex: 'message' }
]}
/>
</Space>
</Card>
</Card>
</Space>
}
/>
<Route
@ -289,6 +446,14 @@ function RestaurantAdminShell() {
);
}
function dayLabel(day: number): string {
return dayOptions.find((option) => 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';

View File

@ -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 });

View File

@ -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<RestaurantAdminConfigResponse> {
return getJson<RestaurantAdminConfigResponse>(`/api/restaurant/admin/config?contextId=${encodeURIComponent(contextId)}`);
}
export async function loadRecentChanges(contextId: string): Promise<RecentAdminChangesResponse> {
return getJson<RecentAdminChangesResponse>(`/api/restaurant/admin/changes?contextId=${encodeURIComponent(contextId)}`);
}
export async function setServiceWindow(request: SetServiceWindowRequest): Promise<SetServiceWindowResponse> {
return postJson<SetServiceWindowResponse>('/api/restaurant/admin/service-window', request);
}