diff --git a/frontend/src/auth/AuthProvider.test.tsx b/frontend/src/auth/AuthProvider.test.tsx index 8bec3f4..7ecffc9 100644 --- a/frontend/src/auth/AuthProvider.test.tsx +++ b/frontend/src/auth/AuthProvider.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { delay, http, HttpResponse } from 'msw' import { describe, expect, it } from 'vitest' import { server } from '../test/server' @@ -7,7 +8,9 @@ import { useAuth } from './useAuth' /** * Minimal context-consumer used purely to surface AuthProvider's state for - * assertions. Not part of the production component tree. + * assertions. Not part of the production component tree. Includes a button + * wired to `logout()` so logout-driven state transitions can be exercised + * without going through a real layout component. */ function AuthProbe() { const auth = useAuth() @@ -16,6 +19,7 @@ function AuthProbe() {
{auth.status}
{auth.user?.email ?? ''}
+
) } @@ -73,4 +77,59 @@ describe('AuthProvider', () => { expect(await screen.findByText('authenticated')).toBeInTheDocument() }) + + it('logout clears the authenticated state', async () => { + server.use( + http.get('http://localhost/api/me', () => + HttpResponse.json({ id: 'u1', email: 'a@b.com' }), + ), + http.post( + 'http://localhost/api/logout', + () => new HttpResponse(null, { status: 204 }), + ), + ) + + const user = userEvent.setup() + + render( + + + , + ) + + expect(await screen.findByText('authenticated')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /log ?out/i })) + + expect(await screen.findByText('unauthenticated')).toBeInTheDocument() + expect(screen.getByTestId('user-email')).toHaveTextContent('') + }) + + it('clears the authenticated state even if the logout request fails', async () => { + server.use( + http.get('http://localhost/api/me', () => + HttpResponse.json({ id: 'u1', email: 'a@b.com' }), + ), + http.post( + 'http://localhost/api/logout', + () => new HttpResponse(null, { status: 500 }), + ), + ) + + const user = userEvent.setup() + + render( + + + , + ) + + expect(await screen.findByText('authenticated')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /log ?out/i })) + + // The user is logged out client-side regardless of the network outcome. + expect(await screen.findByText('unauthenticated')).toBeInTheDocument() + expect(screen.getByTestId('user-email')).toHaveTextContent('') + }) }) diff --git a/frontend/src/auth/AuthProvider.tsx b/frontend/src/auth/AuthProvider.tsx index c8cbb0d..60237a5 100644 --- a/frontend/src/auth/AuthProvider.tsx +++ b/frontend/src/auth/AuthProvider.tsx @@ -43,5 +43,24 @@ export function AuthProvider({ children }: { children: ReactNode }) { setStatus('authenticated') } - return {children} + async function logout(): Promise { + // The user's intent is to leave, so clear the context REGARDLESS of the + // network outcome — a failed request must not strand them "logged in" in + // the UI. Worst case the session cookie lingers, but it's httpOnly and the + // next request would 401 anyway. Clearing state makes RequireAuth redirect + // to /login. (The firewall logout returns 204 and ignores the body.) + try { + await api.postJson('/logout', {}) + } catch { + // Swallow — we log out client-side either way. + } + setUser(null) + setStatus('unauthenticated') + } + + return ( + + {children} + + ) } diff --git a/frontend/src/auth/authContext.ts b/frontend/src/auth/authContext.ts index 0e4850f..7f2f909 100644 --- a/frontend/src/auth/authContext.ts +++ b/frontend/src/auth/authContext.ts @@ -17,6 +17,11 @@ export interface AuthContextValue { * the caller can surface it. `username` is the user's email. */ login: (username: string, password: string) => Promise + /** + * End the session (POST /api/logout) and clear the context to + * `unauthenticated`. The RequireAuth guard then redirects to /login. + */ + logout: () => Promise } export const AuthContext = createContext(null) diff --git a/frontend/src/components/layout/AuthedLayout.test.tsx b/frontend/src/components/layout/AuthedLayout.test.tsx new file mode 100644 index 0000000..ed97537 --- /dev/null +++ b/frontend/src/components/layout/AuthedLayout.test.tsx @@ -0,0 +1,85 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { MemoryRouter, Route, Routes } from 'react-router' +import { describe, expect, it } from 'vitest' +import { AuthProvider } from '../../auth/AuthProvider' +import { RequireAuth } from '../../auth/guards' +import { server } from '../../test/server' +import AuthedLayout from './AuthedLayout' + +/** + * Route tree mirroring the real app skeleton: an auth-only root rendering a + * page built on AuthedLayout (with a SCENARIOS marker for its content), and a + * /login route with a marker so we can assert logout's redirect actually + * lands there. + */ +function AppWithAuthedLayout() { + return ( + + + LOGIN} /> + }> + +
SCENARIOS
+ + } + /> +
+
+
+ ) +} + +describe('AuthedLayout', () => { + it('renders a log out control', async () => { + server.use( + http.get('http://localhost/api/me', () => + HttpResponse.json({ id: 'u1', email: 'a@b.com' }), + ), + ) + + render( + + + , + ) + + expect(await screen.findByText('SCENARIOS')).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /log ?out|sign out/i }), + ).toBeInTheDocument() + }) + + it('logs out and the user ends up unauthenticated', async () => { + let logoutWasCalled = false + server.use( + http.get('http://localhost/api/me', () => + HttpResponse.json({ id: 'u1', email: 'a@b.com' }), + ), + http.post('http://localhost/api/logout', () => { + logoutWasCalled = true + return new HttpResponse(null, { status: 204 }) + }), + ) + + const user = userEvent.setup() + + render( + + + , + ) + + expect(await screen.findByText('SCENARIOS')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /log ?out|sign out/i })) + + expect(await screen.findByText('LOGIN')).toBeInTheDocument() + expect(screen.queryByText('SCENARIOS')).not.toBeInTheDocument() + expect(logoutWasCalled).toBe(true) + }) +}) diff --git a/frontend/src/components/layout/AuthedLayout.tsx b/frontend/src/components/layout/AuthedLayout.tsx new file mode 100644 index 0000000..00b34b4 --- /dev/null +++ b/frontend/src/components/layout/AuthedLayout.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react' +import { useAuth } from '../../auth/useAuth' +import Button from '../ui/Button' + +interface AuthedLayoutProps { + children: ReactNode +} + +/** + * Shell for authenticated pages: the BUCKETS header plus a log-out control. + * Logout clears the auth context, which makes the RequireAuth guard redirect + * the user to /login. + */ +export default function AuthedLayout({ children }: AuthedLayoutProps) { + const { logout } = useAuth() + + return ( +
+
+

+ BUCKETS +

+ +
+
{children}
+
+ ) +} diff --git a/frontend/src/pages/ScenariosPage.tsx b/frontend/src/pages/ScenariosPage.tsx index 7cf6785..544cc51 100644 --- a/frontend/src/pages/ScenariosPage.tsx +++ b/frontend/src/pages/ScenariosPage.tsx @@ -1,4 +1,4 @@ -import AppLayout from '../components/layout/AppLayout' +import AuthedLayout from '../components/layout/AuthedLayout' import Button from '../components/ui/Button' import DigitalProgressBar from '../components/ui/DigitalProgressBar' import Panel from '../components/ui/Panel' @@ -7,7 +7,7 @@ import Panel from '../components/ui/Panel' // frontend feature tickets (#53–#56); for now it shows the primitives + theme. export default function ScenariosPage() { return ( - +

Emergency Fund @@ -16,6 +16,6 @@ export default function ScenariosPage() {

$750 / $1000

- + ) }