The smallest change in this feature and the one to read most carefully. It is the point where somebody who has proved nothing to this shop is handed an account belonging to somebody who did. The rule is one line at the top of linkIdentity.ts: link only when Google asserts the address is verified, and refuse otherwise. Everything below it is bookkeeping. That is defensible for Google specifically, and the reasoning is worth stating rather than assuming. Google asserting the address means whoever completed the sign-in demonstrably controls the mailbox, and that mailbox is already the root of trust for every other route into the account — it is where a password reset goes, and following a reset link takes the account over completely. So linking on it grants nothing that was not already reachable, and it spares the customer who came to Google precisely because they forgot their password. Never on an unverified address. That is not a weaker version of the same thing; it is an account takeover with extra steps, because the assertion would be one nobody checked. There is a test for the specific trap: the string "false" is truthy, and if that check ever becomes a truthiness test then every unverified Google account links to whatever account holds its address. The order matters and is an order rather than a set of independent checks. The identity lookup runs first and nothing else is consulted when it matches, which is why an identity that has signed in before keeps working after the address changes on either side. There is a test where a second customer has since taken the address the Google account reports, and the sign-in correctly reaches the first. Linking to a disabled account is refused, and the reason is not obvious. Linking and then refusing the session would leave the identity attached, so the next attempt would take the sign-in path instead — turning a disabled account into one that is merely inconvenient to reach. The refusal gets its own destination rather than the generic failure. It is the one refusal in this flow a customer can act on: they have an account and simply cannot reach it this way, so the login form now says to use the password they already have. That reveals nothing, because they arrived holding a Google account for that address — being told the address has an account here tells them only about themselves. Deciding this in newCustomer.ts, where the unique constraint already fires, was the shape to avoid. An account must never be handed over as a side effect of an INSERT failing, so that module reports the address is taken and stops, and the policy lives somewhere it can be read on its own. Automatic linking is defensible but it is not obvious, so the account page now shows it. A customer who signed up with a password and later used Google has had two credentials joined without being asked, and a silent link is indistinguishable from a bug when they later wonder why the password is no longer needed. It sits beside the passkeys for the reason that list exists at all: a customer cannot manage credentials they cannot see. The endpoint never returns the provider subject, which is the same reasoning that keeps credential ids out of the passkey list. No unlinking. Removing the only way into an account is the question #344 settles, and offering that button before the check runs would be the fastest possible way to lock somebody out of their own orders. Verified: backend tsc clean for src and tests, 590 unit tests pass, lint at the seven warnings that predate this branch, frontend tsc, lint and build clean. The integration suite needs a database this machine has no Docker for. Closes #343 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
303 lines
11 KiB
TypeScript
Executable File
303 lines
11 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;
|
|
/**
|
|
* 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<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,
|
|
// 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<Customer> {
|
|
return fetch('/api/customers/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password, firstName, lastName, marketingConsent, analyticsConsent })
|
|
}).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);
|
|
}
|
|
|
|
/**
|
|
* 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<void> {
|
|
return fetch('/api/customers/me/analytics-consent', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ analyticsConsent })
|
|
}).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));
|
|
}
|
|
|
|
/**
|
|
* 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<PasswordResetResult> {
|
|
return fetch('/api/customers/reset-password', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ token, password })
|
|
}).then(res => handle<PasswordResetResult>(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));
|
|
}
|
|
|
|
/** 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<Identity[]> {
|
|
return fetch('/api/customers/me/identities').then(res => handle<Identity[]>(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<Passkey[]> {
|
|
return fetch('/api/customers/me/passkeys').then(res => handle<Passkey[]>(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<Passkey[]> {
|
|
const { startRegistration } = await import('@simplewebauthn/browser');
|
|
|
|
const optionsRes = await fetch('/api/customers/me/passkeys/register/begin', { method: 'POST' });
|
|
const options = await handle<Parameters<typeof startRegistration>[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<Customer> {
|
|
const { startAuthentication } = await import('@simplewebauthn/browser');
|
|
|
|
const optionsRes = await fetch('/api/customers/passkeys/login/begin', { method: 'POST' });
|
|
const options = await handle<Parameters<typeof startAuthentication>[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<Customer>(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<void> {
|
|
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<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');
|
|
}
|
|
});
|
|
}
|