fix: add the missing /verify-email page
SonarQube Analysis / sonarqube (pull_request) Successful in 3m42s
Tests / backend-unit (pull_request) Successful in 59s
Tests / backend-integration (pull_request) Successful in 1m30s
Tests / frontend-e2e (pull_request) Failing after 5m21s

Verification emails linked to /verify-email?token=..., but no such route
existed in main.tsx and nothing in the frontend ever called
POST /api/customers/verify-email. The SPA catch-all served index.html, no
route matched, and the page rendered blank -- so the token was never
redeemed and accounts stayed unverified forever.

The gap was invisible until SMTP was configured, because no verification
email had ever actually been delivered.

Adds VerifyEmail.tsx (verifying / verified / failed states), a verifyEmail
call in customerApi, and the route. The request is pinned to a single
firing via a ref: the endpoint deletes the token on success, so StrictMode's
double effect invocation in dev would otherwise overwrite the success state
with "invalid or expired token".

Verified end to end against a local stack: a real token flips
customers.email_verified to true and is consumed from customer_tokens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-15 18:16:32 -05:00
co-authored by Claude Opus 5
parent e7fb7271f0
commit b7c8c37447
4 changed files with 113 additions and 0 deletions
+85
View File
@@ -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&hellip;
</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>
);
}
+8
View File
@@ -40,6 +40,14 @@ export function loginCustomer(email: string, password: string): Promise<Customer
}).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> {
return fetch('/api/customers/logout', { method: 'POST' }).then(() => undefined);
}