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; /** * Whether this customer agreed to the *current* consent wording, which is the * only thing that authorises the Brevo tracker (#56). * * Not a duplicate of marketing_consent: the two disagree for anyone who * consented before that sentence was widened to mention analytics. Computed * on the server from the wording stored against the customer — never derive * it here from marketing_consent, which is the mistake it exists to prevent. */ analytics_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(res: Response): Promise { 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, // Separate argument rather than folded into the one above: they are separate // consents and the caller has to be able to send one true and the other // false. The server treats an absent value as false (#56). analyticsConsent: boolean ): Promise { return fetch('/api/customers/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password, firstName, lastName, marketingConsent, analyticsConsent }) }).then(res => handle(res)); } export function loginCustomer(email: string, password: string): Promise { return fetch('/api/customers/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) }).then(res => 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 { // 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 { return fetch('/api/customers/me').then(res => (res.ok ? res.json() : null)); } export function updateConsent(marketingConsent: boolean): Promise { return fetch('/api/customers/me/consent', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ marketingConsent }) }).then(() => undefined); } /** * Its own endpoint, not a second field on updateConsent (#56). * * Withdrawal has to be as easy as giving consent, and it has to be possible to * withdraw one without touching the other. A combined call would make it easy * to send a stale value for the answer the customer did not change. */ export function updateAnalyticsConsent(analyticsConsent: boolean): Promise { return fetch('/api/customers/me/analytics-consent', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ analyticsConsent }) }).then(() => undefined); } export function fetchMyOrders(): Promise { return fetch('/api/customers/me/orders').then(res => handle(res)); } export function deleteMyAccount(): Promise { 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)); } /** * A completed reset, and how many passkeys it removed (#42). * * The count is part of the answer rather than something to look up afterwards: * the credentials are already gone by the time the form could go and ask, so * the only moment this can be reported is this one. */ export interface PasswordResetResult extends Customer { passkeysRemoved: number; } export function resetPassword(token: string, password: string): Promise { return fetch('/api/customers/reset-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password }) }).then(res => handle(res)); } export function updateMyName(firstName: string, lastName: string): Promise { return fetch('/api/customers/me', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ firstName, lastName }) }).then(res => handle(res)); } export function changeMyPassword(currentPassword: string, newPassword: string): Promise { // 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 { return fetch('/api/customers/me/email', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword, email }) }).then(res => handle(res)); } /** One identity provider this account can sign in with (#343). */ export interface Identity { provider: string; created_at: string; last_used_at: string | null; } export function fetchIdentities(): Promise { return fetch('/api/customers/me/identities').then(res => handle(res)); } /** A registered passkey, as the account page lists it (#40). */ export interface Passkey { id: number; name: string; created_at: string; last_used_at: string | null; } export function fetchPasskeys(): Promise { return fetch('/api/customers/me/passkeys').then(res => handle(res)); } /** * Registers a passkey on this device. * * Both halves of the ceremony live here rather than in the component, because * they are one operation: options come from the server, the browser turns them * into an attestation, and the server verifies it. A component holding the * intermediate state could leave a challenge issued and never answered. * * `startRegistration` is what prompts the customer. It throws when they dismiss * that prompt, which is a cancellation rather than a failure — the caller tells * them apart. */ export async function registerPasskey(name?: string): Promise { const { startRegistration } = await import('@simplewebauthn/browser'); const optionsRes = await fetch('/api/customers/me/passkeys/register/begin', { method: 'POST' }); const options = await handle[0]['optionsJSON']>(optionsRes); const attestation = await startRegistration({ optionsJSON: options }); const finishRes = await fetch('/api/customers/me/passkeys/register/finish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...attestation, name }) }); await handle<{ name: string }>(finishRes); // The fresh list rather than the one row, so the caller cannot render a list // that disagrees with the server about what was just added. return fetchPasskeys(); } /** * Signs in with a passkey (#41). * * Usernameless: nothing is sent to `begin`, and the browser offers whichever * accounts it holds. The customer never types an address, which is also why * this cannot leak whether one has an account — there is nothing to ask about. * * Rejects when the customer dismisses the prompt, which callers must treat as a * cancellation rather than a failure. */ export async function signInWithPasskey(): Promise { const { startAuthentication } = await import('@simplewebauthn/browser'); const optionsRes = await fetch('/api/customers/passkeys/login/begin', { method: 'POST' }); const options = await handle[0]['optionsJSON']>(optionsRes); const assertion = await startAuthentication({ optionsJSON: options }); const finishRes = await fetch('/api/customers/passkeys/login/finish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(assertion) }); return handle(finishRes); } /** * Whether this browser can do WebAuthn at all. * * Checked before offering the control rather than inside its handler, so a * browser that cannot do this is never shown a button that fails. Password * login stays the fallback in every case (#41). */ export function passkeysSupported(): boolean { return typeof window !== 'undefined' && typeof window.PublicKeyCredential === 'function'; } export function revokePasskey(id: number): Promise { return fetch(`/api/customers/me/passkeys/${id}`, { method: 'DELETE' }).then(async (res) => { // 204 on success, so handle() would throw on an empty body. The failure // message matters here — refusing to remove the last way in says what to do // about it, and replacing that with something generic would strand the // customer on a button that simply does not work. if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || 'Request failed'); } }); } export function resendVerificationEmail(): Promise { // 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'); } }); }