52 - Add logout with split authed/guest layouts
This commit is contained in:
parent
bc4483821c
commit
f761f4a791
6 changed files with 201 additions and 5 deletions
|
|
@ -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('')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
85
frontend/src/components/layout/AuthedLayout.test.tsx
Normal file
85
frontend/src/components/layout/AuthedLayout.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
28
frontend/src/components/layout/AuthedLayout.tsx
Normal file
28
frontend/src/components/layout/AuthedLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue