feat(web): add protected session shell and mvp workflows

This commit is contained in:
José René White Enciso 2026-03-08 16:23:00 -06:00
parent d3ea144cc4
commit fcfa4e890a
9 changed files with 532 additions and 52 deletions

View File

@ -4,6 +4,18 @@
- 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 and protected sections are session-aware via Thalos session endpoints. - Route shell and protected sections are session-aware via Thalos session endpoints.
- Runtime base URLs:
- `API_BASE_URL` for business BFF calls. ## Runtime Base URLs
- `THALOS_AUTH_BASE_URL` for session login/refresh/logout/me.
- `API_BASE_URL` for business BFF calls.
- `THALOS_AUTH_BASE_URL` for session login/refresh/logout/me.
## Protected Workflow Endpoints
- `GET /api/restaurant/admin/config?contextId=...`
- `POST /api/restaurant/admin/service-window`
## UI Workflow Coverage
- Restaurant admin config lookup
- Service window updates

View File

@ -14,9 +14,9 @@ npm run test:ci
## Coverage Scope ## Coverage Scope
- `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 contract generation - `src/api/dashboardApi.test.ts`: endpoint path/query composition and payload mapping.
- `src/App.test.tsx`: render baseline and mocked load flow - `src/App.test.tsx`: protected-route render and workflow trigger behavior.
## Notes ## Notes

View File

@ -1,31 +1,65 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./api/dashboardApi', () => ({ vi.mock('./api/sessionApi', () => ({
loadDashboard: vi.fn() getSessionMe: vi.fn(),
loginSession: vi.fn(),
refreshSession: vi.fn(),
logoutSession: vi.fn()
})); }));
import { loadDashboard } from './api/dashboardApi'; vi.mock('./api/dashboardApi', () => ({
loadDashboard: vi.fn(),
setServiceWindow: vi.fn()
}));
import { loadDashboard, setServiceWindow } from './api/dashboardApi';
import { getSessionMe } from './api/sessionApi';
import App from './App'; import App from './App';
describe('App', () => { describe('Restaurant Admin App', () => {
it('renders baseline page', () => { beforeEach(() => {
render(<App />); vi.mocked(loadDashboard).mockReset();
vi.mocked(setServiceWindow).mockReset();
expect(screen.getByRole('heading', { name: 'Restaurant Admin Web' })).toBeInTheDocument(); vi.mocked(getSessionMe).mockReset();
expect(screen.getByRole('button', { name: 'Load' })).toBeInTheDocument();
}); });
it('loads dashboard data when user clicks load', async () => { it('loads admin config for authenticated users', async () => {
vi.mocked(loadDashboard).mockResolvedValue({ summary: 'ok' }); vi.mocked(getSessionMe).mockResolvedValue({
isAuthenticated: true,
subjectId: 'demo-user',
tenantId: 'demo-tenant',
provider: 0
});
vi.mocked(loadDashboard).mockResolvedValue({ contextId: 'demo-context', summary: 'config' });
render(<App />); render(<App />);
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'ctx stage28' } }); await waitFor(() => expect(screen.getByRole('button', { name: 'Load Config' })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Load' })); fireEvent.click(screen.getByRole('button', { name: 'Load Config' }));
await waitFor(() => { await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context'));
expect(loadDashboard).toHaveBeenCalledWith('ctx stage28'); });
expect(screen.getByText(/summary/)).toBeInTheDocument();
it('applies service window from action route', async () => {
vi.mocked(getSessionMe).mockResolvedValue({
isAuthenticated: true,
subjectId: 'demo-user',
tenantId: 'demo-tenant',
provider: 0
}); });
vi.mocked(setServiceWindow).mockResolvedValue({
contextId: 'demo-context',
applied: true,
message: 'ok'
});
render(<App />);
await waitFor(() => expect(screen.getByRole('button', { name: 'Service Window' })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Service Window' }));
fireEvent.click(screen.getByRole('button', { name: 'Apply Service Window' }));
await waitFor(() => expect(setServiceWindow).toHaveBeenCalledTimes(1));
}); });
}); });

View File

@ -1,41 +1,284 @@
import { useState } from 'react'; import { useState } from 'react';
import { loadDashboard } from './api/dashboardApi'; import {
loadDashboard,
setServiceWindow,
type RestaurantAdminConfigResponse,
type SetServiceWindowRequest,
type SetServiceWindowResponse
} from './api/dashboardApi';
import { SessionProvider, useSessionContext } from './auth/sessionContext';
import type { IdentityProvider } from './api/sessionApi';
type RouteKey = 'overview' | 'actions';
function App() { function App() {
return (
<SessionProvider>
<RestaurantAdminShell />
</SessionProvider>
);
}
function RestaurantAdminShell() {
const session = useSessionContext();
const [route, setRoute] = useState<RouteKey>('overview');
const [contextId, setContextId] = useState('demo-context'); const [contextId, setContextId] = useState('demo-context');
const [payload, setPayload] = useState<unknown>(null); const [configPayload, setConfigPayload] = useState<RestaurantAdminConfigResponse | null>(null);
const [windowRequest, setWindowRequest] = useState<SetServiceWindowRequest>({
contextId: 'demo-context',
day: 1,
openAt: '08:00:00',
closeAt: '22:00:00',
updatedBy: 'admin-operator'
});
const [windowResponse, setWindowResponse] = useState<SetServiceWindowResponse | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const onLoad = async (): Promise<void> => { const loadConfig = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const response = await loadDashboard(contextId); const payload = await loadDashboard(contextId);
setPayload(response); setConfigPayload(payload);
setWindowRequest((previous) => ({ ...previous, contextId }));
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Unknown request error'; setError(err instanceof Error ? err.message : 'Failed to load restaurant admin configuration.');
setError(message);
setPayload(null);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const applyServiceWindow = async () => {
setLoading(true);
setError(null);
try {
const payload = await setServiceWindow(windowRequest);
setWindowResponse(payload);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to set service window.');
} finally {
setLoading(false);
}
};
if (session.status === 'loading') {
return (
<main className="app">
<h1>Restaurant Admin Web</h1>
<p className="muted">Restoring session...</p>
</main>
);
}
if (session.status !== 'authenticated' || !session.profile) {
return (
<main className="app">
<h1>Restaurant Admin Web</h1>
<p className="muted">Sign in with Thalos to access protected routes.</p>
{session.error && <div className="alert">{session.error}</div>}
<LoginCard />
</main>
);
}
return ( return (
<main className="app"> <main className="app">
<h1>Restaurant Admin Web</h1> <h1>Restaurant Admin Web</h1>
<p>React baseline wired to its corresponding BFF via an API adapter module.</p> <p className="muted">Control-plane configuration and service-window management MVP workflows.</p>
<div className="row">
<input value={contextId} onChange={(event) => setContextId(event.target.value)} /> <section className="card row">
<button type="button" onClick={onLoad} disabled={loading}> <span className="badge">subject: {session.profile.subjectId}</span>
{loading ? 'Loading...' : 'Load'} <span className="badge">tenant: {session.profile.tenantId}</span>
<span className="badge">provider: {providerLabel(session.profile.provider)}</span>
<span className="spacer" />
<button type="button" className="secondary" onClick={() => void session.refresh()}>
Refresh Session
</button> </button>
</div> <button type="button" className="warn" onClick={() => void session.logout()}>
{error && <p>{error}</p>} Logout
<pre>{JSON.stringify(payload, null, 2)}</pre> </button>
</section>
<section className="card tabs" aria-label="route-shell">
<button
type="button"
className={route === 'overview' ? 'active' : undefined}
onClick={() => setRoute('overview')}
>
Config
</button>
<button
type="button"
className={route === 'actions' ? 'active' : undefined}
onClick={() => setRoute('actions')}
>
Service Window
</button>
</section>
{session.error && <div className="alert">{session.error}</div>}
{error && <div className="alert">{error}</div>}
{route === 'overview' && (
<section className="card col">
<div className="row">
<label className="col">
Context Id
<input value={contextId} onChange={(event) => setContextId(event.target.value)} />
</label>
<button type="button" onClick={() => void loadConfig()} disabled={loading}>
{loading ? 'Loading...' : 'Load Config'}
</button>
</div>
<pre>{JSON.stringify(configPayload, null, 2)}</pre>
</section>
)}
{route === 'actions' && (
<section className="card col">
<div className="grid">
<label className="col">
Context Id
<input
value={windowRequest.contextId}
onChange={(event) => setWindowRequest((previous) => ({ ...previous, contextId: event.target.value }))}
/>
</label>
<label className="col">
Day Of Week
<select
value={windowRequest.day}
onChange={(event) =>
setWindowRequest((previous) => ({
...previous,
day: Number(event.target.value)
}))
}
>
<option value={0}>Sunday</option>
<option value={1}>Monday</option>
<option value={2}>Tuesday</option>
<option value={3}>Wednesday</option>
<option value={4}>Thursday</option>
<option value={5}>Friday</option>
<option value={6}>Saturday</option>
</select>
</label>
<label className="col">
Open At
<input
type="time"
value={windowRequest.openAt.slice(0, 5)}
onChange={(event) =>
setWindowRequest((previous) => ({
...previous,
openAt: `${event.target.value}:00`
}))
}
/>
</label>
<label className="col">
Close At
<input
type="time"
value={windowRequest.closeAt.slice(0, 5)}
onChange={(event) =>
setWindowRequest((previous) => ({
...previous,
closeAt: `${event.target.value}:00`
}))
}
/>
</label>
<label className="col">
Updated By
<input
value={windowRequest.updatedBy}
onChange={(event) => setWindowRequest((previous) => ({ ...previous, updatedBy: event.target.value }))}
/>
</label>
</div>
<button type="button" onClick={() => void applyServiceWindow()} disabled={loading}>
{loading ? 'Applying...' : 'Apply Service Window'}
</button>
<pre>{JSON.stringify(windowResponse, null, 2)}</pre>
</section>
)}
</main> </main>
); );
} }
function LoginCard() {
const session = useSessionContext();
const [subjectId, setSubjectId] = useState('demo-user');
const [tenantId, setTenantId] = useState('demo-tenant');
const [provider, setProvider] = useState<IdentityProvider>(0);
const [externalToken, setExternalToken] = useState('');
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const onSubmit = async () => {
setSubmitting(true);
setError(null);
try {
await session.login({ subjectId, tenantId, provider, externalToken });
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed.');
} finally {
setSubmitting(false);
}
};
return (
<section className="card col">
<div className="grid">
<label className="col">
Subject Id
<input value={subjectId} onChange={(event) => setSubjectId(event.target.value)} />
</label>
<label className="col">
Tenant Id
<input value={tenantId} onChange={(event) => setTenantId(event.target.value)} />
</label>
<label className="col">
Provider
<select value={String(provider)} onChange={(event) => setProvider(Number(event.target.value) as IdentityProvider)}>
<option value="0">Internal JWT</option>
<option value="1">Azure AD (simulated)</option>
<option value="2">Google (simulated)</option>
</select>
</label>
</div>
<label className="col">
External Token (optional)
<input value={externalToken} onChange={(event) => setExternalToken(event.target.value)} />
</label>
<button type="button" onClick={() => void onSubmit()} disabled={submitting}>
{submitting ? 'Signing In...' : 'Sign In'}
</button>
{error && <div className="alert">{error}</div>}
</section>
);
}
function providerLabel(provider: IdentityProvider): string {
if (provider === 0 || provider === '0' || provider === 'InternalJwt') {
return 'Internal JWT';
}
if (provider === 1 || provider === '1' || provider === 'AzureAd') {
return 'Azure AD';
}
if (provider === 2 || provider === '2' || provider === 'Google') {
return 'Google';
}
return String(provider);
}
export default App; export default App;

View File

@ -1,18 +1,39 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
vi.mock('./client', () => ({ vi.mock('./client', () => ({
getJson: vi.fn() getJson: vi.fn(),
postJson: vi.fn()
})); }));
import { getJson } from './client'; import { getJson, postJson } from './client';
import { loadDashboard } from './dashboardApi'; import { loadDashboard, setServiceWindow } from './dashboardApi';
describe('loadDashboard', () => { describe('restaurant admin dashboard api', () => {
it('builds encoded endpoint path and delegates to getJson', async () => { it('builds encoded config endpoint path', async () => {
vi.mocked(getJson).mockResolvedValue({ ok: true }); vi.mocked(getJson).mockResolvedValue({ ok: true });
await loadDashboard('demo context/1'); await loadDashboard('ctx admin/1');
expect(getJson).toHaveBeenCalledWith('/api/restaurant/admin/config?contextId=demo%20context%2F1'); expect(getJson).toHaveBeenCalledWith('/api/restaurant/admin/config?contextId=ctx%20admin%2F1');
});
it('posts service window payload', async () => {
vi.mocked(postJson).mockResolvedValue({ applied: true });
await setServiceWindow({
contextId: 'ctx',
day: 1,
openAt: '08:00:00',
closeAt: '22:00:00',
updatedBy: 'admin'
});
expect(postJson).toHaveBeenCalledWith('/api/restaurant/admin/service-window', {
contextId: 'ctx',
day: 1,
openAt: '08:00:00',
closeAt: '22:00:00',
updatedBy: 'admin'
});
}); });
}); });

View File

@ -1,5 +1,28 @@
import { getJson } from './client'; import { getJson, postJson } from './client';
export async function loadDashboard(contextId: string): Promise<unknown> { export type RestaurantAdminConfigResponse = {
return getJson(`/api/restaurant/admin/config?contextId=${encodeURIComponent(contextId)}`); contextId: string;
summary: string;
};
export type SetServiceWindowRequest = {
contextId: string;
day: number;
openAt: string;
closeAt: string;
updatedBy: string;
};
export type SetServiceWindowResponse = {
contextId: string;
applied: boolean;
message: string;
};
export async function loadDashboard(contextId: string): Promise<RestaurantAdminConfigResponse> {
return getJson<RestaurantAdminConfigResponse>(`/api/restaurant/admin/config?contextId=${encodeURIComponent(contextId)}`);
}
export async function setServiceWindow(request: SetServiceWindowRequest): Promise<SetServiceWindowResponse> {
return postJson<SetServiceWindowResponse>('/api/restaurant/admin/service-window', request);
} }

View File

@ -1,24 +1,26 @@
import { getJson, getThalosAuthBaseUrl, postJson, postNoContent } from './client'; import { getJson, getThalosAuthBaseUrl, postJson, postNoContent } from './client';
export type IdentityProvider = 0 | 1 | 2 | string | number;
export type SessionProfile = { export type SessionProfile = {
isAuthenticated: boolean; isAuthenticated: boolean;
subjectId: string; subjectId: string;
tenantId: string; tenantId: string;
provider: string; provider: IdentityProvider;
}; };
export type SessionLoginRequest = { export type SessionLoginRequest = {
subjectId: string; subjectId: string;
tenantId: string; tenantId: string;
correlationId: string; correlationId: string;
provider: string; provider: IdentityProvider;
externalToken: string; externalToken: string;
}; };
export type SessionLoginResponse = { export type SessionLoginResponse = {
subjectId: string; subjectId: string;
tenantId: string; tenantId: string;
provider: string; provider: IdentityProvider;
expiresInSeconds: number; expiresInSeconds: number;
}; };

120
src/auth/sessionContext.tsx Normal file
View File

@ -0,0 +1,120 @@
import { createContext, type PropsWithChildren, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { ApiError } from '../api/client';
import {
getSessionMe,
loginSession,
logoutSession,
refreshSession,
type SessionLoginRequest,
type SessionProfile
} from '../api/sessionApi';
export type SessionStatus = 'loading' | 'authenticated' | 'unauthenticated';
type SessionContextValue = {
status: SessionStatus;
profile: SessionProfile | null;
error: string | null;
login: (request: Omit<SessionLoginRequest, 'correlationId'> & { correlationId?: string }) => Promise<void>;
refresh: () => Promise<void>;
logout: () => Promise<void>;
revalidate: () => Promise<void>;
};
const SessionContext = createContext<SessionContextValue | undefined>(undefined);
export function SessionProvider({ children }: PropsWithChildren) {
const [status, setStatus] = useState<SessionStatus>('loading');
const [profile, setProfile] = useState<SessionProfile | null>(null);
const [error, setError] = useState<string | null>(null);
const revalidate = useCallback(async () => {
try {
const me = await getSessionMe();
if (me.isAuthenticated) {
setProfile(me);
setStatus('authenticated');
} else {
setProfile(null);
setStatus('unauthenticated');
}
setError(null);
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
setProfile(null);
setStatus('unauthenticated');
setError(null);
return;
}
const message = err instanceof Error ? err.message : 'Session validation failed.';
setProfile(null);
setStatus('unauthenticated');
setError(message);
}
}, []);
useEffect(() => {
void revalidate();
}, [revalidate]);
const login = useCallback<SessionContextValue['login']>(
async (request) => {
setError(null);
await loginSession({
...request,
correlationId: request.correlationId && request.correlationId.length > 0 ? request.correlationId : createCorrelationId()
});
await revalidate();
},
[revalidate]
);
const refresh = useCallback(async () => {
setError(null);
await refreshSession();
await revalidate();
}, [revalidate]);
const logout = useCallback(async () => {
setError(null);
try {
await logoutSession();
} finally {
setProfile(null);
setStatus('unauthenticated');
}
}, []);
const value = useMemo<SessionContextValue>(
() => ({
status,
profile,
error,
login,
refresh,
logout,
revalidate
}),
[status, profile, error, login, refresh, logout, revalidate]
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
}
export function useSessionContext(): SessionContextValue {
const value = useContext(SessionContext);
if (!value) {
throw new Error('useSessionContext must be used within SessionProvider.');
}
return value;
}
function createCorrelationId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `corr-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}

View File

@ -77,6 +77,11 @@ button.warn {
background: #b91c1c; background: #b91c1c;
} }
button.ghost {
background: #e2e8f0;
color: #0f172a;
}
button:disabled { button:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.6; opacity: 0.6;
@ -110,6 +115,26 @@ button:disabled {
font-weight: 600; 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 {
margin: 0; margin: 0;
background: #0f172a; background: #0f172a;