From dd516219561e30a3b1f7ce7462b0cd6ceac1ab42 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Mon, 17 Aug 2026 13:55:36 -0500 Subject: [PATCH] fix: reset the header and return home when a customer logs out (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logging out destroyed the server session and navigated home, but never told CustomerAuthContext, so `customer` stayed in React state and the header kept offering "My Account" instead of "Log in" and "Sign up". A reload appeared to fix it, because fetchMe then returned null, which is why the symptom looked intermittent. The cart badge had the same cause: CartContext only clears its items once `customer` goes null. Logout now lives on the auth context, which clears `customer` itself rather than triggering a refetch — a refetch would leave a window where the session is gone but the UI still shows the customer signed in. logoutCustomer also ignored res.ok. A failed logout leaves the session cookie valid, so reporting success signed the customer back in on their next reload. It now rejects, and the account page reports the failure and stays put instead of pretending. Logout is not routed through handle() because the endpoint answers 204 with no body. Navigation uses replace, so Back no longer returns to the account page, which would only bounce to /login now the session is gone. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/customer/Account.tsx | 15 ++-- frontend/src/customer/CustomerAuthContext.tsx | 26 ++++++- frontend/src/customer/customerApi.ts | 11 ++- frontend/tests/e2e/auth.spec.ts | 69 +++++++++++++++++++ 4 files changed, 113 insertions(+), 8 deletions(-) diff --git a/frontend/src/customer/Account.tsx b/frontend/src/customer/Account.tsx index 6ea7181..3a0571c 100755 --- a/frontend/src/customer/Account.tsx +++ b/frontend/src/customer/Account.tsx @@ -1,13 +1,13 @@ import { useEffect, useState } from 'react'; import { Card, Typography, Switch, Button, Table, Modal, message, Space, Divider } from 'antd'; import { useNavigate } from 'react-router-dom'; -import { fetchMyOrders, OrderHistoryItem, updateConsent, exportMyData, deleteMyAccount, logoutCustomer } from './customerApi'; +import { fetchMyOrders, OrderHistoryItem, updateConsent, exportMyData, deleteMyAccount } from './customerApi'; import { useCustomerAuth } from './CustomerAuthContext'; const { Title, Text } = Typography; export default function Account() { - const { customer, loading, refresh } = useCustomerAuth(); + const { customer, loading, refresh, logout } = useCustomerAuth(); const [orders, setOrders] = useState([]); const navigate = useNavigate(); @@ -28,8 +28,15 @@ export default function Account() { } async function handleLogout() { - await logoutCustomer(); - navigate('/'); + try { + await logout(); + } catch (err) { + message.error(`Couldn't log out — ${(err as Error).message}`); + return; + } + // replace, so Back doesn't return to the account page — which would only + // bounce to /login now that the session is gone. + navigate('/', { replace: true }); } function handleDelete() { diff --git a/frontend/src/customer/CustomerAuthContext.tsx b/frontend/src/customer/CustomerAuthContext.tsx index cdff708..4f69f4d 100755 --- a/frontend/src/customer/CustomerAuthContext.tsx +++ b/frontend/src/customer/CustomerAuthContext.tsx @@ -1,13 +1,19 @@ import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'; -import { Customer, fetchMe } from './customerApi'; +import { Customer, fetchMe, logoutCustomer } from './customerApi'; interface CustomerAuthValue { customer: Customer | null; loading: boolean; refresh: () => void; + logout: () => Promise; } -const CustomerAuthContext = createContext({ customer: null, loading: true, refresh: () => {} }); +const CustomerAuthContext = createContext({ + customer: null, + loading: true, + refresh: () => {}, + logout: async () => {} +}); export function useCustomerAuth() { return useContext(CustomerAuthContext); @@ -24,8 +30,22 @@ export function CustomerAuthProvider({ children }: { children: React.ReactNode } useEffect(() => { refresh(); }, [refresh]); + // Logging out has to clear the context, not just call the endpoint — + // otherwise `customer` stays set and the header keeps offering "My Account" + // until something happens to refetch. Clearing here rather than calling + // refresh() avoids a window where the session is gone but the UI still shows + // the customer as signed in. + // + // Rejects if the server did not accept the logout, leaving the customer + // signed in, because the session cookie is still valid in that case. + const logout = useCallback(async () => { + await logoutCustomer(); + setCustomer(null); + setLoading(false); + }, []); + return ( - + {children} ); diff --git a/frontend/src/customer/customerApi.ts b/frontend/src/customer/customerApi.ts index cd680b1..84697a7 100755 --- a/frontend/src/customer/customerApi.ts +++ b/frontend/src/customer/customerApi.ts @@ -49,7 +49,16 @@ export function verifyEmail(token: string): Promise<{ status: string }> { } export function logoutCustomer(): Promise { - return fetch('/api/customers/logout', { method: 'POST' }).then(() => undefined); + // Not routed through handle(): logout answers 204 with no body, so parsing + // JSON would throw on success. The failure case still has to reject — the + // session cookie survives a failed logout, so reporting success would leave + // the customer logged in and silently signed back in on their next reload. + return fetch('/api/customers/logout', { method: 'POST' }).then(async (res) => { + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'Request failed'); + } + }); } export function fetchMe(): Promise { diff --git a/frontend/tests/e2e/auth.spec.ts b/frontend/tests/e2e/auth.spec.ts index df21618..7fd81bd 100755 --- a/frontend/tests/e2e/auth.spec.ts +++ b/frontend/tests/e2e/auth.spec.ts @@ -35,4 +35,73 @@ test.describe('Customer accounts', () => { await page.getByRole('button', { name: 'Log in' }).click(); await expect(page.getByText('invalid email or password')).toBeVisible(); }); + + test('logging out returns to the home page and resets the header', async ({ page }) => { + const email = uniqueEmail(); + await page.goto('/register'); + await page.getByRole('textbox', { name: 'Email' }).fill(email); + await page.getByLabel('Password').fill('supersecret123'); + await page.getByRole('button', { name: 'Create account' }).click(); + await expect(page).toHaveURL(/\/account/); + + await page.getByRole('button', { name: 'Log out' }).click(); + + await expect(page).toHaveURL(/\/$/); + await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Sign up' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'My Account' })).toBeHidden(); + }); + + test('the logged-out header survives a reload', async ({ page }) => { + const email = uniqueEmail(); + await page.goto('/register'); + await page.getByRole('textbox', { name: 'Email' }).fill(email); + await page.getByLabel('Password').fill('supersecret123'); + await page.getByRole('button', { name: 'Create account' }).click(); + await expect(page).toHaveURL(/\/account/); + + await page.getByRole('button', { name: 'Log out' }).click(); + await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible(); + + // Proves the server session was actually destroyed, rather than the header + // merely being repainted from stale client state. + await page.reload(); + await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'My Account' })).toBeHidden(); + }); + + test('logging out does not leave the account page on the back stack', async ({ page }) => { + const email = uniqueEmail(); + await page.goto('/register'); + await page.getByRole('textbox', { name: 'Email' }).fill(email); + await page.getByLabel('Password').fill('supersecret123'); + await page.getByRole('button', { name: 'Create account' }).click(); + await expect(page).toHaveURL(/\/account/); + + await page.getByRole('button', { name: 'Log out' }).click(); + await expect(page).toHaveURL(/\/$/); + + await page.goBack(); + await expect(page).not.toHaveURL(/\/account/); + }); + + test('a failed logout says so instead of appearing to succeed', async ({ page }) => { + const email = uniqueEmail(); + await page.goto('/register'); + await page.getByRole('textbox', { name: 'Email' }).fill(email); + await page.getByLabel('Password').fill('supersecret123'); + await page.getByRole('button', { name: 'Create account' }).click(); + await expect(page).toHaveURL(/\/account/); + + await page.route('**/api/customers/logout', (route) => + route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"internal error"}' }) + ); + + await page.getByRole('button', { name: 'Log out' }).click(); + + // The session cookie is still valid, so pretending to be logged out would + // silently log the customer back in on their next reload. + await expect(page.getByText(/couldn't log out/i)).toBeVisible(); + await expect(page).toHaveURL(/\/account/); + }); }); -- 2.54.0