fix: add the missing /verify-email page #20
@@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
import Card from 'antd/lib/card';
|
||||||
|
import Typography from 'antd/lib/typography';
|
||||||
|
import Alert from 'antd/lib/alert';
|
||||||
|
import Button from 'antd/lib/button';
|
||||||
|
import Spin from 'antd/lib/spin';
|
||||||
|
import { verifyEmail } from './customerApi';
|
||||||
|
import { useCustomerAuth } from './CustomerAuthContext';
|
||||||
|
|
||||||
|
const { Title, Paragraph } = Typography;
|
||||||
|
|
||||||
|
type Status = 'verifying' | 'verified' | 'failed';
|
||||||
|
|
||||||
|
export default function VerifyEmail() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
const [status, setStatus] = useState<Status>('verifying');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { refresh } = useCustomerAuth();
|
||||||
|
|
||||||
|
// The backend deletes the token once it succeeds, so a second request for the
|
||||||
|
// same token comes back as "invalid or expired" and would overwrite the success
|
||||||
|
// state with an error. StrictMode runs effects twice in dev, which is exactly
|
||||||
|
// that scenario, so pin the request to a single firing.
|
||||||
|
const requested = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (requested.current) return;
|
||||||
|
requested.current = true;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
setStatus('failed');
|
||||||
|
setError('This link is missing its verification token.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyEmail(token)
|
||||||
|
.then(() => {
|
||||||
|
setStatus('verified');
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
setStatus('failed');
|
||||||
|
setError(err.message);
|
||||||
|
});
|
||||||
|
}, [token, refresh]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 420, margin: '48px auto', padding: '0 16px' }}>
|
||||||
|
<Card>
|
||||||
|
<Title level={3}>Email verification</Title>
|
||||||
|
|
||||||
|
{status === 'verifying' && (
|
||||||
|
<Paragraph>
|
||||||
|
<Spin style={{ marginRight: 12 }} />
|
||||||
|
Verifying your email address…
|
||||||
|
</Paragraph>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'verified' && (
|
||||||
|
<>
|
||||||
|
<Alert type="success" message="Your email address is verified." style={{ marginBottom: 16 }} />
|
||||||
|
<Link to="/account">
|
||||||
|
<Button type="primary" block>Go to my account</Button>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'failed' && (
|
||||||
|
<>
|
||||||
|
<Alert type="error" message={error || 'Verification failed.'} style={{ marginBottom: 16 }} />
|
||||||
|
<Paragraph type="secondary">
|
||||||
|
Verification links expire 24 hours after the account is created. If yours has
|
||||||
|
expired, the link can no longer be used.
|
||||||
|
</Paragraph>
|
||||||
|
<Link to="/account">
|
||||||
|
<Button block>Go to my account</Button>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,6 +40,14 @@ export function loginCustomer(email: string, password: string): Promise<Customer
|
|||||||
}).then(res => handle<Customer>(res));
|
}).then(res => handle<Customer>(res));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function verifyEmail(token: string): Promise<{ status: string }> {
|
||||||
|
return fetch('/api/customers/verify-email', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token })
|
||||||
|
}).then(res => handle<{ status: string }>(res));
|
||||||
|
}
|
||||||
|
|
||||||
export function logoutCustomer(): Promise<void> {
|
export function logoutCustomer(): Promise<void> {
|
||||||
return fetch('/api/customers/logout', { method: 'POST' }).then(() => undefined);
|
return fetch('/api/customers/logout', { method: 'POST' }).then(() => undefined);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Login from './customer/Login';
|
|||||||
import Register from './customer/Register';
|
import Register from './customer/Register';
|
||||||
import Account from './customer/Account';
|
import Account from './customer/Account';
|
||||||
import PrivacyPolicy from './customer/PrivacyPolicy';
|
import PrivacyPolicy from './customer/PrivacyPolicy';
|
||||||
|
import VerifyEmail from './customer/VerifyEmail';
|
||||||
import Cart from './cart/Cart';
|
import Cart from './cart/Cart';
|
||||||
import { CustomerAuthProvider } from './customer/CustomerAuthContext';
|
import { CustomerAuthProvider } from './customer/CustomerAuthContext';
|
||||||
import { CartProvider } from './cart/CartContext';
|
import { CartProvider } from './cart/CartContext';
|
||||||
@@ -33,6 +34,7 @@ function Root() {
|
|||||||
<Route path="/account" element={<Account />} />
|
<Route path="/account" element={<Account />} />
|
||||||
<Route path="/cart" element={<Cart />} />
|
<Route path="/cart" element={<Cart />} />
|
||||||
<Route path="/privacy" element={<PrivacyPolicy />} />
|
<Route path="/privacy" element={<PrivacyPolicy />} />
|
||||||
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// The /verify-email route was missing entirely, so the link in every verification
|
||||||
|
// email rendered a blank page. These cover the route existing and reporting an
|
||||||
|
// outcome; a happy-path test would need a real token out of the database.
|
||||||
|
test.describe('Email verification', () => {
|
||||||
|
test('reports an invalid token instead of rendering nothing', async ({ page }) => {
|
||||||
|
await page.goto('/verify-email?token=not-a-real-token');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Email verification' })).toBeVisible();
|
||||||
|
await expect(page.getByText('invalid or expired token')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports a link with no token at all', async ({ page }) => {
|
||||||
|
await page.goto('/verify-email');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Email verification' })).toBeVisible();
|
||||||
|
await expect(page.getByText('This link is missing its verification token.')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user