Merge pull request 'fix: reset the header and return home when a customer logs out (#21)' (#28) from fix/logout-customer-reset into main
Reviewed-on: #28
This commit was merged in pull request #28.
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