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() {