From 9ab8fd9fc935b5395b00f7855720445c76fe5185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ren=C3=A9=20White=20Enciso?= Date: Sun, 8 Mar 2026 16:23:00 -0600 Subject: [PATCH] feat(web): add protected session shell and mvp workflows --- docs/architecture/frontend-boundary.md | 18 +- docs/runbooks/testing.md | 6 +- src/App.test.tsx | 69 +++++-- src/App.tsx | 261 +++++++++++++++++++++++-- src/api/dashboardApi.test.ts | 35 +++- src/api/dashboardApi.ts | 30 ++- src/api/sessionApi.ts | 8 +- src/auth/sessionContext.tsx | 120 ++++++++++++ src/styles.css | 25 +++ 9 files changed, 520 insertions(+), 52 deletions(-) create mode 100644 src/auth/sessionContext.tsx diff --git a/docs/architecture/frontend-boundary.md b/docs/architecture/frontend-boundary.md index ea754a7..2d5b030 100644 --- a/docs/architecture/frontend-boundary.md +++ b/docs/architecture/frontend-boundary.md @@ -4,6 +4,18 @@ - Frontend data access flows through `src/api/*` adapter modules. - The UI does not access DAL or internal services directly. - Route shell and protected sections are session-aware via Thalos session endpoints. -- Runtime base URLs: - - `API_BASE_URL` for business BFF calls. - - `THALOS_AUTH_BASE_URL` for session login/refresh/logout/me. + +## Runtime Base URLs + +- `API_BASE_URL` for business BFF calls. +- `THALOS_AUTH_BASE_URL` for session login/refresh/logout/me. + +## Protected Workflow Endpoints + +- `GET /api/customer/orders/status?contextId=...` +- `POST /api/customer/orders` + +## UI Workflow Coverage + +- Customer order status lookup +- Customer order submission diff --git a/docs/runbooks/testing.md b/docs/runbooks/testing.md index cadd554..f9d822b 100644 --- a/docs/runbooks/testing.md +++ b/docs/runbooks/testing.md @@ -14,9 +14,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 contract generation -- `src/App.test.tsx`: render baseline and mocked load flow +- `src/api/client.test.ts`: runtime-config precedence and fallback behavior. +- `src/api/dashboardApi.test.ts`: endpoint path/query composition and payload mapping. +- `src/App.test.tsx`: protected-route render and workflow trigger behavior. ## Notes diff --git a/src/App.test.tsx b/src/App.test.tsx index e26ab9a..dfdf8bd 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,31 +1,66 @@ 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', () => ({ - loadDashboard: vi.fn() +vi.mock('./api/sessionApi', () => ({ + getSessionMe: vi.fn(), + loginSession: vi.fn(), + refreshSession: vi.fn(), + logoutSession: vi.fn() })); -import { loadDashboard } from './api/dashboardApi'; +vi.mock('./api/dashboardApi', () => ({ + loadDashboard: vi.fn(), + submitCustomerOrder: vi.fn() +})); + +import { loadDashboard, submitCustomerOrder } from './api/dashboardApi'; +import { getSessionMe } from './api/sessionApi'; import App from './App'; -describe('App', () => { - it('renders baseline page', () => { - render(); - - expect(screen.getByRole('heading', { name: 'Customer Orders Web' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Load' })).toBeInTheDocument(); +describe('Customer Orders App', () => { + beforeEach(() => { + vi.mocked(loadDashboard).mockReset(); + vi.mocked(submitCustomerOrder).mockReset(); + vi.mocked(getSessionMe).mockReset(); }); - it('loads dashboard data when user clicks load', async () => { - vi.mocked(loadDashboard).mockResolvedValue({ summary: 'ok' }); + it('loads order status for authenticated users', async () => { + vi.mocked(getSessionMe).mockResolvedValue({ + isAuthenticated: true, + subjectId: 'demo-user', + tenantId: 'demo-tenant', + provider: 0 + }); + vi.mocked(loadDashboard).mockResolvedValue({ contextId: 'demo-context', summary: 'open' }); + render(); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'ctx stage28' } }); - fireEvent.click(screen.getByRole('button', { name: 'Load' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Load Status' })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: 'Load Status' })); - await waitFor(() => { - expect(loadDashboard).toHaveBeenCalledWith('ctx stage28'); - expect(screen.getByText(/summary/)).toBeInTheDocument(); + await waitFor(() => expect(loadDashboard).toHaveBeenCalledWith('demo-context')); + }); + + it('submits customer order from action route', async () => { + vi.mocked(getSessionMe).mockResolvedValue({ + isAuthenticated: true, + subjectId: 'demo-user', + tenantId: 'demo-tenant', + provider: 0 }); + vi.mocked(submitCustomerOrder).mockResolvedValue({ + contextId: 'demo-context', + orderId: 'CO-1001', + accepted: true, + summary: 'ok' + }); + + render(); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Submit Order' })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: 'Submit Order' })); + fireEvent.click(screen.getByRole('button', { name: 'Submit Customer Order' })); + + await waitFor(() => expect(submitCustomerOrder).toHaveBeenCalledTimes(1)); }); }); diff --git a/src/App.tsx b/src/App.tsx index 014d78a..2c230ab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,41 +1,270 @@ import { useState } from 'react'; -import { loadDashboard } from './api/dashboardApi'; +import { + loadDashboard, + submitCustomerOrder, + type CustomerOrderStatusResponse, + type SubmitCustomerOrderRequest, + type SubmitCustomerOrderResponse +} from './api/dashboardApi'; +import { SessionProvider, useSessionContext } from './auth/sessionContext'; +import type { IdentityProvider } from './api/sessionApi'; + +type RouteKey = 'overview' | 'actions'; function App() { + return ( + + + + ); +} + +function CustomerOrdersShell() { + const session = useSessionContext(); + const [route, setRoute] = useState('overview'); + const [contextId, setContextId] = useState('demo-context'); - const [payload, setPayload] = useState(null); + const [statusPayload, setStatusPayload] = useState(null); + + const [orderRequest, setOrderRequest] = useState({ + contextId: 'demo-context', + orderId: 'CO-1001', + tableId: 'T-08', + guestCount: 2, + itemIds: ['ITEM-101', 'ITEM-202'] + }); + const [itemIdsInput, setItemIdsInput] = useState('ITEM-101,ITEM-202'); + const [orderResponse, setOrderResponse] = useState(null); + const [error, setError] = useState(null); const [loading, setLoading] = useState(false); - const onLoad = async (): Promise => { + const loadStatus = async () => { setLoading(true); setError(null); try { - const response = await loadDashboard(contextId); - setPayload(response); + const payload = await loadDashboard(contextId); + setStatusPayload(payload); + setOrderRequest((previous) => ({ ...previous, contextId })); } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown request error'; - setError(message); - setPayload(null); + setError(err instanceof Error ? err.message : 'Failed to load customer order status.'); } finally { setLoading(false); } }; + const submitOrder = async () => { + setLoading(true); + setError(null); + try { + const payload = await submitCustomerOrder({ + ...orderRequest, + itemIds: itemIdsInput + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0) + }); + setOrderResponse(payload); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to submit customer order.'); + } finally { + setLoading(false); + } + }; + + if (session.status === 'loading') { + return ( +
+

Customer Orders Web

+

Restoring session...

+
+ ); + } + + if (session.status !== 'authenticated' || !session.profile) { + return ( +
+

Customer Orders Web

+

Sign in with Thalos to access protected routes.

+ {session.error &&
{session.error}
} + +
+ ); + } + return (

Customer Orders Web

-

React baseline wired to its corresponding BFF via an API adapter module.

-
- setContextId(event.target.value)} /> - -
- {error &&

{error}

} -
{JSON.stringify(payload, null, 2)}
+ + + +
+ + +
+ + {session.error &&
{session.error}
} + {error &&
{error}
} + + {route === 'overview' && ( +
+
+ + +
+
{JSON.stringify(statusPayload, null, 2)}
+
+ )} + + {route === 'actions' && ( +
+
+ + + + +
+ + +
{JSON.stringify(orderResponse, null, 2)}
+
+ )}
); } +function LoginCard() { + const session = useSessionContext(); + const [subjectId, setSubjectId] = useState('demo-user'); + const [tenantId, setTenantId] = useState('demo-tenant'); + const [provider, setProvider] = useState(0); + const [externalToken, setExternalToken] = useState(''); + const [error, setError] = useState(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 ( +
+
+ + + +
+ + + {error &&
{error}
} +
+ ); +} + +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; diff --git a/src/api/dashboardApi.test.ts b/src/api/dashboardApi.test.ts index c4c036c..8a8daaa 100644 --- a/src/api/dashboardApi.test.ts +++ b/src/api/dashboardApi.test.ts @@ -1,18 +1,39 @@ import { describe, expect, it, vi } from 'vitest'; vi.mock('./client', () => ({ - getJson: vi.fn() + getJson: vi.fn(), + postJson: vi.fn() })); -import { getJson } from './client'; -import { loadDashboard } from './dashboardApi'; +import { getJson, postJson } from './client'; +import { loadDashboard, submitCustomerOrder } from './dashboardApi'; -describe('loadDashboard', () => { - it('builds encoded endpoint path and delegates to getJson', async () => { +describe('customer orders dashboard api', () => { + it('builds encoded status endpoint path', async () => { vi.mocked(getJson).mockResolvedValue({ ok: true }); - await loadDashboard('demo context/1'); + await loadDashboard('ctx customer/1'); - expect(getJson).toHaveBeenCalledWith('/api/customer/orders/status?contextId=demo%20context%2F1'); + expect(getJson).toHaveBeenCalledWith('/api/customer/orders/status?contextId=ctx%20customer%2F1'); + }); + + it('posts customer order payload', async () => { + vi.mocked(postJson).mockResolvedValue({ accepted: true }); + + await submitCustomerOrder({ + contextId: 'ctx', + orderId: 'CO-1', + tableId: 'T-5', + guestCount: 3, + itemIds: ['A', 'B'] + }); + + expect(postJson).toHaveBeenCalledWith('/api/customer/orders', { + contextId: 'ctx', + orderId: 'CO-1', + tableId: 'T-5', + guestCount: 3, + itemIds: ['A', 'B'] + }); }); }); diff --git a/src/api/dashboardApi.ts b/src/api/dashboardApi.ts index 2eb14fb..86afc5e 100644 --- a/src/api/dashboardApi.ts +++ b/src/api/dashboardApi.ts @@ -1,5 +1,29 @@ -import { getJson } from './client'; +import { getJson, postJson } from './client'; -export async function loadDashboard(contextId: string): Promise { - return getJson(`/api/customer/orders/status?contextId=${encodeURIComponent(contextId)}`); +export type CustomerOrderStatusResponse = { + contextId: string; + summary: string; +}; + +export type SubmitCustomerOrderRequest = { + contextId: string; + orderId: string; + tableId: string; + guestCount: number; + itemIds: string[]; +}; + +export type SubmitCustomerOrderResponse = { + contextId: string; + orderId: string; + accepted: boolean; + summary: string; +}; + +export async function loadDashboard(contextId: string): Promise { + return getJson(`/api/customer/orders/status?contextId=${encodeURIComponent(contextId)}`); +} + +export async function submitCustomerOrder(request: SubmitCustomerOrderRequest): Promise { + return postJson('/api/customer/orders', request); } diff --git a/src/api/sessionApi.ts b/src/api/sessionApi.ts index 5f90f70..8770696 100644 --- a/src/api/sessionApi.ts +++ b/src/api/sessionApi.ts @@ -1,24 +1,26 @@ import { getJson, getThalosAuthBaseUrl, postJson, postNoContent } from './client'; +export type IdentityProvider = 0 | 1 | 2 | string | number; + export type SessionProfile = { isAuthenticated: boolean; subjectId: string; tenantId: string; - provider: string; + provider: IdentityProvider; }; export type SessionLoginRequest = { subjectId: string; tenantId: string; correlationId: string; - provider: string; + provider: IdentityProvider; externalToken: string; }; export type SessionLoginResponse = { subjectId: string; tenantId: string; - provider: string; + provider: IdentityProvider; expiresInSeconds: number; }; diff --git a/src/auth/sessionContext.tsx b/src/auth/sessionContext.tsx new file mode 100644 index 0000000..ba7a7b6 --- /dev/null +++ b/src/auth/sessionContext.tsx @@ -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 & { correlationId?: string }) => Promise; + refresh: () => Promise; + logout: () => Promise; + revalidate: () => Promise; +}; + +const SessionContext = createContext(undefined); + +export function SessionProvider({ children }: PropsWithChildren) { + const [status, setStatus] = useState('loading'); + const [profile, setProfile] = useState(null); + const [error, setError] = useState(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( + 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( + () => ({ + status, + profile, + error, + login, + refresh, + logout, + revalidate + }), + [status, profile, error, login, refresh, logout, revalidate] + ); + + return {children}; +} + +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)}`; +} diff --git a/src/styles.css b/src/styles.css index eb74e06..eed8737 100644 --- a/src/styles.css +++ b/src/styles.css @@ -77,6 +77,11 @@ button.warn { background: #b91c1c; } +button.ghost { + background: #e2e8f0; + color: #0f172a; +} + button:disabled { cursor: not-allowed; opacity: 0.6; @@ -110,6 +115,26 @@ button:disabled { 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 { margin: 0; background: #0f172a;