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 }}
+ />
+
+ } loading={loadingChanges} onClick={() => void refreshChanges()}>
+ Load Recent Changes
+
+
+ {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) }
+ ]}
+ />
+
+
}
/>
-
+
+
- {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);
}