52 - Add logout with split authed/guest layouts

This commit is contained in:
myrmidex 2026-06-28 17:11:50 +02:00
parent bc4483821c
commit f761f4a791
6 changed files with 201 additions and 5 deletions

View file

@ -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() {
<div>
<div data-testid="status">{auth.status}</div>
<div data-testid="user-email">{auth.user?.email ?? ''}</div>
<button onClick={() => void auth.logout()}>Log Out</button>
</div>
)
}
@ -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(
<AuthProvider>
<AuthProbe />
</AuthProvider>,
)
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(
<AuthProvider>
<AuthProbe />
</AuthProvider>,
)
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('')
})
})

View file

@ -43,5 +43,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setStatus('authenticated')
}
return <AuthContext value={{ status, user, login }}>{children}</AuthContext>
async function logout(): Promise<void> {
// 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 (
<AuthContext value={{ status, user, login, logout }}>
{children}
</AuthContext>
)
}

View file

@ -17,6 +17,11 @@ export interface AuthContextValue {
* the caller can surface it. `username` is the user's email.
*/
login: (username: string, password: string) => Promise<void>
/**
* End the session (POST /api/logout) and clear the context to
* `unauthenticated`. The RequireAuth guard then redirects to /login.
*/
logout: () => Promise<void>
}
export const AuthContext = createContext<AuthContextValue | null>(null)

View file

@ -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 (
<AuthProvider>
<Routes>
<Route path="/login" element={<div>LOGIN</div>} />
<Route element={<RequireAuth />}>
<Route
path="/"
element={
<AuthedLayout>
<div>SCENARIOS</div>
</AuthedLayout>
}
/>
</Route>
</Routes>
</AuthProvider>
)
}
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(
<MemoryRouter initialEntries={['/']}>
<AppWithAuthedLayout />
</MemoryRouter>,
)
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(
<MemoryRouter initialEntries={['/']}>
<AppWithAuthedLayout />
</MemoryRouter>,
)
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)
})
})

View file

@ -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 (
<div className="bg-background text-foreground min-h-screen font-mono">
<header className="glow-red border-primary flex items-center justify-between border-b-4 px-6 py-4">
<h1 className="text-primary text-2xl font-bold uppercase tracking-widest">
BUCKETS
</h1>
<Button onClick={() => void logout()}>Log Out</Button>
</header>
<main className="p-6">{children}</main>
</div>
)
}

View file

@ -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 (
<AppLayout>
<AuthedLayout>
<Panel className="mx-auto max-w-md space-y-4">
<h2 className="text-primary font-bold uppercase tracking-wider">
Emergency Fund
@ -16,6 +16,6 @@ export default function ScenariosPage() {
<p className="font-digital text-primary text-xl">$750 / $1000</p>
<Button glow>+ Add Bucket</Button>
</Panel>
</AppLayout>
</AuthedLayout>
)
}