Files
redefined-designs/frontend/src/customer/customerApi.ts
T
bermudalambandClaude Opus 5 db7c61c89d
SonarQube Analysis / sonarqube (pull_request) Successful in 2m57s
Tests / backend-unit (pull_request) Successful in 53s
Tests / frontend-e2e (pull_request) Failing after 7m40s
Tests / backend-integration (pull_request) Failing after 3h14m41s
feat: customer password reset via email round-trip (#32)
Adds "Forgot password?" to the login page, a request page, and a reset
page reached by a one-hour, single-use token delivered by email. Reuses
customer_tokens with a new password_reset kind alongside verify_email.

The request endpoint always answers 200, whether or not the address has
an account, so it cannot be used to test addresses for membership. Note
/register still reveals existence through its 409 on a duplicate, so this
protection is currently partial; closing that is its own change.

Completing a reset deletes every session for that customer. A reset
prompted by a compromise has to evict the intruder, and leaving a 30-day
cookie alive would defeat the point. It also marks the address verified,
since receiving the mail is exactly what verification proves, and
supersedes any outstanding token so an older link in the inbox cannot be
resurrected.

Introduces the first rate limiting in the codebase, on the request
endpoint only. The limiter is keyed on caller *and* submitted address:
keying on IP alone would let one person lock out everyone behind the same
proxy, and everything arrives via Nginx Proxy Manager. Applying that same
limiter to the reset endpoint, which carries no address, collapsed every
caller into one shared bucket -- so that endpoint is deliberately
unlimited instead, protected by a 32-byte single-use token whose bcrypt
work only runs after the token matches.

The e2e tests read the issued token directly from Postgres rather than
through a test-support endpoint. An endpoint returning a reset token for
an arbitrary address is account takeover for every customer if it is ever
reachable, and an environment gate is thin protection against that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 17:36:19 -05:00

103 lines
3.4 KiB
TypeScript
Executable File

export interface Customer {
id: number;
email: string;
name: string | null;
email_verified: boolean;
marketing_consent: 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, name: string, marketingConsent: boolean): Promise<Customer> {
return fetch('/api/customers/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name, 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));
}