Files
redefined-designs/frontend/src/customer/customerApi.ts
T
bermudalambandClaude Opus 5 b8549e9c72 feat: let a customer resend their own verification email (#110)
A verification email was sent once, at registration. If it was lost, filtered, or arrived after the 24-hour token had expired, the customer had no route back except registering again with a different address.

POST /api/customers/resend-verification mints a fresh token and sends the mail, and the account page grows a "Send it again" button beside the warning that was already there. The button only exists while the address is unverified: on a verified account it would be a control whose only outcome is a refusal, and the endpoint refuses that case rather than sending a pointless email.

The mint-token-and-send sequence now lives in one place. Registration and the email change already did the same three steps - supersede any outstanding token, mint a new one, send it - and this would have been a third copy. The step most likely to be dropped in a copy is the supersede, and it is the one that matters: without it an older message still sitting in the inbox goes on verifying.

Anything that makes the server send mail on request is an abuse vector, so this is rate limited to three an hour, keyed on the customer id. That is tighter than either existing limiter and sidesteps #84's IPv6 problem entirely, since a signed-in caller has an identity better than an address to count against and cannot escape the bucket by moving within a delegated prefix. The refusal says the mail probably did send and to check the spam folder, which is both more useful and more honest than a bare 429.

The claim that keying on customer id also solved test isolation was wrong, and the tests caught it. resetDb truncates with RESTART IDENTITY, so every integration test's first customer is id 1: three tests that each sent once left the fourth starting at its limit, and two tests failed on a 429 they never asked for. A "fresh customer per test" is a fresh row with a recycled identity. The limiter now has an explicit exported store the suite clears between tests, and the comment that claimed otherwise has been corrected rather than left to mislead the next reader.

Verification: seven integration tests covering the unauthenticated refusal, the send, the new token invalidating the old one - asserted through the endpoint that would honour the stale link rather than by counting rows - the new link actually verifying, the already-verified refusal, the allowance stopping the fourth send rather than merely reporting it, and one customer's exhausted allowance leaving another's intact. Three end-to-end tests for the button, its confirmation and the message on the fourth click. The 33 integration tests across the three suites this touched all pass, as do the 199 backend unit tests. tsc clean on both sides, ESLint no errors.

Closes #110
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 12:59:09 -05:00

158 lines
5.4 KiB
TypeScript
Executable File

export interface Customer {
id: number;
email: string;
// Nullable because customers who registered before these were required have
// neither. Registration demands both from anyone new.
first_name: string | null;
last_name: string | null;
email_verified: boolean;
marketing_consent: boolean;
favorite_alerts: boolean;
created_at: string;
}
export interface OrderHistoryItem {
id: number;
processor: string;
amount_cents: number;
status: string;
created_at: string;
item_name: string;
}
async function handle<T>(res: Response): Promise<T> {
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Request failed');
}
return res.json();
}
export function registerCustomer(
email: string,
password: string,
firstName: string,
lastName: string,
marketingConsent: boolean
): Promise<Customer> {
return fetch('/api/customers/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, firstName, lastName, marketingConsent })
}).then(res => handle<Customer>(res));
}
export function loginCustomer(email: string, password: string): Promise<Customer> {
return fetch('/api/customers/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
}).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> {
// 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> {
return fetch('/api/customers/me').then(res => (res.ok ? res.json() : null));
}
export function updateConsent(marketingConsent: boolean): Promise<void> {
return fetch('/api/customers/me/consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ marketingConsent })
}).then(() => undefined);
}
export function fetchMyOrders(): Promise<OrderHistoryItem[]> {
return fetch('/api/customers/me/orders').then(res => handle<OrderHistoryItem[]>(res));
}
export function deleteMyAccount(): Promise<void> {
return fetch('/api/customers/me', { method: 'DELETE' }).then(() => undefined);
}
export function exportMyData(): void {
window.location.href = '/api/customers/me/export';
}
export function requestPasswordReset(email: string): Promise<{ status: string }> {
return fetch('/api/customers/request-password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
}).then(res => handle<{ status: string }>(res));
}
export function resetPassword(token: string, password: string): Promise<Customer> {
return fetch('/api/customers/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password })
}).then(res => handle<Customer>(res));
}
export function updateMyName(firstName: string, lastName: string): Promise<Customer> {
return fetch('/api/customers/me', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ firstName, lastName })
}).then(res => handle<Customer>(res));
}
export function changeMyPassword(currentPassword: string, newPassword: string): Promise<void> {
// Answers 204 with no body, so handle() would throw parsing JSON on success.
// The failure case still has to reject: the server's message names which of
// the two passwords was wrong, and that is the only useful thing to show.
return fetch('/api/customers/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword })
}).then(async (res) => {
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Request failed');
}
});
}
export function changeMyEmail(currentPassword: string, email: string): Promise<Customer> {
return fetch('/api/customers/me/email', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, email })
}).then(res => handle<Customer>(res));
}
export function resendVerificationEmail(): Promise<void> {
// 204 on success, so handle() would throw parsing an empty body. The failure
// path must still reject: the server's message distinguishes "already
// verified" from the rate limit's "check your spam folder", and both are
// worth showing rather than replacing with something generic.
return fetch('/api/customers/resend-verification', { method: 'POST' }).then(async (res) => {
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Request failed');
}
});
}