Merge pull request 'fix: reset the header and return home when a customer logs out (#21)' (#28) from fix/logout-customer-reset into main
SonarQube Analysis / sonarqube (push) Successful in 3m9s
Tests / backend-unit (push) Successful in 57s
Tests / backend-integration (push) Failing after 1m27s
Tests / frontend-e2e (push) Failing after 6m56s

Reviewed-on: #28
This commit was merged in pull request #28.
This commit is contained in:
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 { 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() {
+23 -3
View File
@@ -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>
); );
+10 -1
View File
@@ -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> {
+69
View File
@@ -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/);
});
}); });