diff --git a/frontend/src/customer/VerifyEmail.tsx b/frontend/src/customer/VerifyEmail.tsx new file mode 100644 index 0000000..e57a058 --- /dev/null +++ b/frontend/src/customer/VerifyEmail.tsx @@ -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('verifying'); + const [error, setError] = useState(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 ( +
+ + Email verification + + {status === 'verifying' && ( + + + Verifying your email address… + + )} + + {status === 'verified' && ( + <> + + + + + + )} + + {status === 'failed' && ( + <> + + + Verification links expire 24 hours after the account is created. If yours has + expired, the link can no longer be used. + + + + + + )} + +
+ ); +} diff --git a/frontend/src/customer/customerApi.ts b/frontend/src/customer/customerApi.ts index 48145ba..cd680b1 100755 --- a/frontend/src/customer/customerApi.ts +++ b/frontend/src/customer/customerApi.ts @@ -40,6 +40,14 @@ export function loginCustomer(email: string, password: string): Promise handle(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 { return fetch('/api/customers/logout', { method: 'POST' }).then(() => undefined); } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 886c7e0..ce7ba83 100755 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -9,6 +9,7 @@ import Login from './customer/Login'; import Register from './customer/Register'; import Account from './customer/Account'; import PrivacyPolicy from './customer/PrivacyPolicy'; +import VerifyEmail from './customer/VerifyEmail'; import Cart from './cart/Cart'; import { CustomerAuthProvider } from './customer/CustomerAuthContext'; import { CartProvider } from './cart/CartContext'; @@ -33,6 +34,7 @@ function Root() { } /> } /> } /> + } /> diff --git a/frontend/tests/e2e/verify-email.spec.ts b/frontend/tests/e2e/verify-email.spec.ts new file mode 100644 index 0000000..b6762d5 --- /dev/null +++ b/frontend/tests/e2e/verify-email.spec.ts @@ -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(); + }); +});