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 { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
import { delay, http, HttpResponse } from 'msw'
|
import { delay, http, HttpResponse } from 'msw'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { server } from '../test/server'
|
import { server } from '../test/server'
|
||||||
|
|
@ -7,7 +8,9 @@ import { useAuth } from './useAuth'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal context-consumer used purely to surface AuthProvider's state for
|
* 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() {
|
function AuthProbe() {
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
|
|
@ -16,6 +19,7 @@ function AuthProbe() {
|
||||||
<div>
|
<div>
|
||||||
<div data-testid="status">{auth.status}</div>
|
<div data-testid="status">{auth.status}</div>
|
||||||
<div data-testid="user-email">{auth.user?.email ?? ''}</div>
|
<div data-testid="user-email">{auth.user?.email ?? ''}</div>
|
||||||
|
<button onClick={() => void auth.logout()}>Log Out</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -73,4 +77,59 @@ describe('AuthProvider', () => {
|
||||||
|
|
||||||
expect(await screen.findByText('authenticated')).toBeInTheDocument()
|
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')
|
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.
|
* the caller can surface it. `username` is the user's email.
|
||||||
*/
|
*/
|
||||||
login: (username: string, password: string) => Promise<void>
|
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)
|
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 Button from '../components/ui/Button'
|
||||||
import DigitalProgressBar from '../components/ui/DigitalProgressBar'
|
import DigitalProgressBar from '../components/ui/DigitalProgressBar'
|
||||||
import Panel from '../components/ui/Panel'
|
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.
|
// frontend feature tickets (#53–#56); for now it shows the primitives + theme.
|
||||||
export default function ScenariosPage() {
|
export default function ScenariosPage() {
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AuthedLayout>
|
||||||
<Panel className="mx-auto max-w-md space-y-4">
|
<Panel className="mx-auto max-w-md space-y-4">
|
||||||
<h2 className="text-primary font-bold uppercase tracking-wider">
|
<h2 className="text-primary font-bold uppercase tracking-wider">
|
||||||
Emergency Fund
|
Emergency Fund
|
||||||
|
|
@ -16,6 +16,6 @@ export default function ScenariosPage() {
|
||||||
<p className="font-digital text-primary text-xl">$750 / $1000</p>
|
<p className="font-digital text-primary text-xl">$750 / $1000</p>
|
||||||
<Button glow>+ Add Bucket</Button>
|
<Button glow>+ Add Bucket</Button>
|
||||||
</Panel>
|
</Panel>
|
||||||
</AppLayout>
|
</AuthedLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue