diff --git a/docs/architecture/frontend-boundary.md b/docs/architecture/frontend-boundary.md index ea754a7..8f211c8 100644 --- a/docs/architecture/frontend-boundary.md +++ b/docs/architecture/frontend-boundary.md @@ -4,6 +4,22 @@ - 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 + +- `POST /api/identity/session/login` +- `POST /api/identity/session/refresh` +- `POST /api/identity/session/logout` +- `GET /api/identity/session/me` + +## UI Workflow Coverage + +- Session login +- Session me/profile inspection +- Session refresh +- Session logout 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 84d6510..0f74e63 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,31 +1,68 @@ 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/sessionApi', () => ({ + getSessionMe: vi.fn(), + loginSession: vi.fn(), + refreshSession: vi.fn(), + logoutSession: vi.fn() +})); vi.mock('./api/dashboardApi', () => ({ loadDashboard: vi.fn() })); import { loadDashboard } from './api/dashboardApi'; +import { getSessionMe, loginSession } from './api/sessionApi'; import App from './App'; -describe('App', () => { - it('renders baseline page', () => { - render(); - - expect(screen.getByRole('heading', { name: 'Thalos Web' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Load' })).toBeInTheDocument(); +describe('Thalos App', () => { + beforeEach(() => { + vi.mocked(loadDashboard).mockReset(); + vi.mocked(getSessionMe).mockReset(); + vi.mocked(loginSession).mockReset(); }); - it('loads dashboard data when user clicks load', async () => { - vi.mocked(loadDashboard).mockResolvedValue({ summary: 'ok' }); + it('renders login gate and signs in through Thalos session endpoint', async () => { + vi.mocked(getSessionMe) + .mockResolvedValueOnce({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 0 }) + .mockResolvedValueOnce({ isAuthenticated: true, subjectId: 'demo-user', tenantId: 'demo-tenant', provider: 0 }); + render(); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'ctx stage28' } }); - fireEvent.click(screen.getByRole('button', { name: 'Load' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument()); + + fireEvent.change(screen.getByLabelText('Subject Id'), { target: { value: 'alice' } }); + fireEvent.click(screen.getByRole('button', { name: 'Sign In' })); await waitFor(() => { - expect(loadDashboard).toHaveBeenCalledWith('ctx stage28'); - expect(screen.getByText(/summary/)).toBeInTheDocument(); + expect(loginSession).toHaveBeenCalledTimes(1); + expect(screen.getByText(/subject: demo-user/i)).toBeInTheDocument(); + }); + }); + + it('loads protected session snapshot through dashboard adapter', async () => { + vi.mocked(getSessionMe).mockResolvedValue({ + isAuthenticated: true, + subjectId: 'demo-user', + tenantId: 'demo-tenant', + provider: 0 + }); + vi.mocked(loadDashboard).mockResolvedValue({ + isAuthenticated: true, + subjectId: 'demo-user', + tenantId: 'demo-tenant', + provider: 0 + }); + + render(); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Reload Snapshot' })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: 'Reload Snapshot' })); + + await waitFor(() => { + expect(loadDashboard).toHaveBeenCalledTimes(1); + expect(screen.getByText(/demo-user/)).toBeInTheDocument(); }); }); }); diff --git a/src/App.tsx b/src/App.tsx index d270792..b34803b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,41 +1,191 @@ import { useState } from 'react'; import { loadDashboard } from './api/dashboardApi'; +import { SessionProvider, useSessionContext } from './auth/sessionContext'; +import type { IdentityProvider } from './api/sessionApi'; function App() { - const [contextId, setContextId] = useState('demo-context'); - const [payload, setPayload] = useState(null); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); + return ( + + + + ); +} - const onLoad = async (): Promise => { - setLoading(true); +type RouteKey = 'overview' | 'session'; + +function ThalosShell() { + const session = useSessionContext(); + const [route, setRoute] = useState('overview'); + const [snapshot, setSnapshot] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const onReloadSnapshot = async () => { + setBusy(true); setError(null); try { - const response = await loadDashboard(contextId); - setPayload(response); + const payload = await loadDashboard(); + setSnapshot(payload); } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown request error'; - setError(message); - setPayload(null); + setError(err instanceof Error ? err.message : 'Failed to reload session snapshot.'); } finally { - setLoading(false); + setBusy(false); } }; + if (session.status === 'loading') { + return ( +
+

Thalos Web

+

Restoring session...

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

Thalos Web

+

Login against Thalos session endpoints to access protected routes.

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

Thalos 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' && ( +
+
+ Session Snapshot + + +
+
{JSON.stringify(snapshot ?? session.profile, null, 2)}
+
+ )} + + {route === 'session' && }
); } +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 d427b3f..3b4532e 100644 --- a/src/api/dashboardApi.test.ts +++ b/src/api/dashboardApi.test.ts @@ -1,18 +1,55 @@ import { describe, expect, it, vi } from 'vitest'; vi.mock('./client', () => ({ - getJson: vi.fn() + getJson: vi.fn(), + postJson: vi.fn(), + postNoContent: vi.fn(), + getThalosAuthBaseUrl: vi.fn(() => 'http://thalos-auth') })); -import { getJson } from './client'; -import { loadDashboard } from './dashboardApi'; +import { getJson, postJson, postNoContent } from './client'; +import { loadDashboard, loginDashboardSession, logoutDashboardSession, refreshDashboardSession } from './dashboardApi'; -describe('loadDashboard', () => { - it('builds encoded endpoint path and delegates to getJson', async () => { - vi.mocked(getJson).mockResolvedValue({ ok: true }); +describe('thalos dashboard api', () => { + it('loads session snapshot from canonical me endpoint', async () => { + vi.mocked(getJson).mockResolvedValue({ isAuthenticated: true }); - await loadDashboard('demo context/1'); + await loadDashboard(); - expect(getJson).toHaveBeenCalledWith('/api/thalos/session/refresh?sessionId=demo%20context%2F1'); + expect(getJson).toHaveBeenCalledWith('/api/identity/session/me', 'http://thalos-auth'); + }); + + it('refreshes session through canonical refresh endpoint', async () => { + vi.mocked(postJson).mockResolvedValue({}); + + await refreshDashboardSession(); + + expect(postJson).toHaveBeenCalledWith('/api/identity/session/refresh', {}, 'http://thalos-auth'); + }); + + it('logs in with request payload and logs out with no-content endpoint', async () => { + vi.mocked(postJson).mockResolvedValue({}); + + await loginDashboardSession({ + subjectId: 'demo-user', + tenantId: 'demo-tenant', + correlationId: 'corr-1', + provider: 0, + externalToken: '' + }); + await logoutDashboardSession(); + + expect(postJson).toHaveBeenCalledWith( + '/api/identity/session/login', + { + subjectId: 'demo-user', + tenantId: 'demo-tenant', + correlationId: 'corr-1', + provider: 0, + externalToken: '' + }, + 'http://thalos-auth' + ); + expect(postNoContent).toHaveBeenCalledWith('/api/identity/session/logout', {}, 'http://thalos-auth'); }); }); diff --git a/src/api/dashboardApi.ts b/src/api/dashboardApi.ts index 596850f..61e8d9c 100644 --- a/src/api/dashboardApi.ts +++ b/src/api/dashboardApi.ts @@ -1,5 +1,20 @@ -import { getJson } from './client'; +import { getJson, getThalosAuthBaseUrl, postJson, postNoContent } from './client'; +import type { SessionLoginRequest, SessionLoginResponse, SessionProfile } from './sessionApi'; -export async function loadDashboard(contextId: string): Promise { - return getJson(`/api/thalos/session/refresh?sessionId=${encodeURIComponent(contextId)}`); +export type ThalosSessionSnapshot = SessionProfile; + +export async function loadDashboard(): Promise { + return getJson('/api/identity/session/me', getThalosAuthBaseUrl()); +} + +export async function refreshDashboardSession(): Promise { + return postJson('/api/identity/session/refresh', {}, getThalosAuthBaseUrl()); +} + +export async function loginDashboardSession(request: SessionLoginRequest): Promise { + return postJson('/api/identity/session/login', request, getThalosAuthBaseUrl()); +} + +export async function logoutDashboardSession(): Promise { + return postNoContent('/api/identity/session/logout', {}, getThalosAuthBaseUrl()); } 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;