fix: reset the header and return home when a customer logs out (#21)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Card, Typography, Switch, Button, Table, Modal, message, Space, Divider } from 'antd';
|
import { Card, Typography, Switch, Button, Table, Modal, message, Space, Divider } from 'antd';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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';
|
import { useCustomerAuth } from './CustomerAuthContext';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
export default function Account() {
|
export default function Account() {
|
||||||
const { customer, loading, refresh } = useCustomerAuth();
|
const { customer, loading, refresh, logout } = useCustomerAuth();
|
||||||
const [orders, setOrders] = useState<OrderHistoryItem[]>([]);
|
const [orders, setOrders] = useState<OrderHistoryItem[]>([]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -28,8 +28,15 @@ export default function Account() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await logoutCustomer();
|
try {
|
||||||
navigate('/');
|
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() {
|
function handleDelete() {
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||||
import { Customer, fetchMe } from './customerApi';
|
import { Customer, fetchMe, logoutCustomer } from './customerApi';
|
||||||
|
|
||||||
interface CustomerAuthValue {
|
interface CustomerAuthValue {
|
||||||
customer: Customer | null;
|
customer: Customer | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
refresh: () => void;
|
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() {
|
export function useCustomerAuth() {
|
||||||
return useContext(CustomerAuthContext);
|
return useContext(CustomerAuthContext);
|
||||||
@@ -24,8 +30,22 @@ export function CustomerAuthProvider({ children }: { children: React.ReactNode }
|
|||||||
|
|
||||||
useEffect(() => { refresh(); }, [refresh]);
|
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 (
|
return (
|
||||||
<CustomerAuthContext.Provider value={{ customer, loading, refresh }}>
|
<CustomerAuthContext.Provider value={{ customer, loading, refresh, logout }}>
|
||||||
{children}
|
{children}
|
||||||
</CustomerAuthContext.Provider>
|
</CustomerAuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,7 +49,16 @@ export function verifyEmail(token: string): Promise<{ status: string }> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function logoutCustomer(): Promise<void> {
|
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> {
|
export function fetchMe(): Promise<Customer | null> {
|
||||||
|
|||||||
@@ -35,4 +35,73 @@ test.describe('Customer accounts', () => {
|
|||||||
await page.getByRole('button', { name: 'Log in' }).click();
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
await expect(page.getByText('invalid email or password')).toBeVisible();
|
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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user