The first accounts in this project's history have no password. Several things that were true stop being true, and one check written a month ago finally becomes reachable. Setting a first password and changing an existing one stay one route. A customer who signed up with Google cannot supply a value that was never set, so asking for one is a dead end; what authorises the change is the session they are already holding, which is what authorises every other setting on the account page. Two routes would be two places to get the guard wrong, and the one that would be forgotten is whichever is not on the path exercised by hand. The branch reads the stored hash rather than anything the caller sends, so a request cannot talk its way into the first-password case by omitting a field — there is a test for exactly that. Changing the email address is refused instead, and the asymmetry is the point. Setting a first password changes a credential the customer already controls. Changing the address changes where recovery goes, and whoever holds the new one can reset the password and own the account outright. That is why the route has always demanded more than a live session, and dropping the demand for the accounts that cannot meet it would remove the protection from exactly the ones that need it. The message says the real thing and names the way out, rather than claiming a password was wrong when there is none. Login is left exactly as it was. Answering "this account has no password" to a submitted address would turn the form into an oracle for which customers use Google, so it keeps the single refusal and the account page is where a signed-in customer learns what they have. Two tests pin that, including the one where both the supplied password and the stored hash are empty — the combination most tempting to call a match, and the one that would let anyone sign in as any Google-only customer. Deletion needed nothing, because it never asked for a password. That corrects what #332 recorded, and there is now a test so it stays true. The passkey lockout guard runs for the first time. It was written in #40 against the condition rather than the schema and has been unreachable ever since, because password_hash was NOT NULL. Three tests exercise it now: refused when it is the only way in, allowed when a second passkey remains, allowed once a password has been set. Two things about password reset were worth checking rather than assuming, and both turn out to be right as they stand. A customer who never had a password can still reset one, which is what somebody reaching for "forgot password" was asking for. And a reset still removes every passkey, per #42, because nothing about that path identifies who asked. What it does not do is sever the Google identity, and that asymmetry is deliberate: a passkey is a credential this shop issued and can revoke, while a Google identity is one Google holds, and cutting it would leave the customer unable to use the button they signed up with for no gain — whoever completed the reset controls the mailbox either way. The account page is told whether a password exists, and nothing more. Offering to change a password to somebody who has never had one is a dead end; saying nothing leaves them unable to see a credential they are entitled to manage. So the panel is titled for what it does for this customer, the current-password field is absent rather than disabled, and the confirmation says they can now sign in with it as well as with Google. 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 #344 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
311 lines
11 KiB
TypeScript
Executable File
311 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;
|
|
/**
|
|
* Whether this account has a password at all (#344).
|
|
*
|
|
* False for anyone who signed up with Google. The account page reads it to
|
|
* decide between offering to change a password and offering to set a first
|
|
* one, which are different things to somebody who has never had one.
|
|
*/
|
|
has_password: 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');
|
|
}
|
|
});
|
|
}
|