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/); + }); });