fix: reset the header and return home when a customer logs out (#21) #28

Merged
bermudalamb merged 1 commits from fix/logout-customer-reset into main 2026-08-17 14:07:11 -05:00
4 changed files with 113 additions and 8 deletions
+11 -4
View File
@@ -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<OrderHistoryItem[]>([]);
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() {
+23 -3
View File
@@ -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<void>;
}
const CustomerAuthContext = createContext<CustomerAuthValue>({ customer: null, loading: true, refresh: () => {} });
const CustomerAuthContext = createContext<CustomerAuthValue>({
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 (
<CustomerAuthContext.Provider value={{ customer, loading, refresh }}>
<CustomerAuthContext.Provider value={{ customer, loading, refresh, logout }}>
{children}
</CustomerAuthContext.Provider>
);
+10 -1
View File
@@ -49,7 +49,16 @@ export function verifyEmail(token: string): Promise<{ status: string }> {
}
export function logoutCustomer(): Promise<void> {
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<Customer | null> {
+69
View File
@@ -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/);
});
});