Files
redefined-designs/frontend/src/customer/AccountDetails.tsx
T
synAdminandClaude Opus 5 dcb3c7c91b
Linting / lint (pull_request) Successful in 2m55s
SonarQube Analysis / sonarqube (pull_request) Failing after 27m31s
feat(auth): the routes that assumed every customer has a password (#344)
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>
2026-09-10 14:06:49 -05:00

228 lines
9.2 KiB
TypeScript

import { useState } from 'react';
import Form from 'antd/es/form';
import Input from 'antd/es/input';
import Button from 'antd/es/button';
import Alert from 'antd/es/alert';
import Collapse from 'antd/es/collapse';
import Typography from 'antd/es/typography';
import message from 'antd/es/message';
import { Customer, updateMyName, changeMyPassword, changeMyEmail } from './customerApi';
const { Title, Paragraph } = Typography;
type Props = Readonly<{
customer: Customer;
// Re-reads the session so the rest of the account view stops showing the old
// name and the old verification state.
onChanged: () => void;
}>;
// Changing an email address or a password has a consequence the customer cannot
// see from the form: the new address needs verifying and the old one is told,
// and a password change signs other devices out. Both are stated above the
// fields rather than reported after the fact, so the surprise arrives while
// there is still a chance to back out.
export default function AccountDetails({ customer, onChanged }: Props) {
const [nameError, setNameError] = useState<string | null>(null);
const [emailError, setEmailError] = useState<string | null>(null);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [busy, setBusy] = useState<'name' | 'email' | 'password' | null>(null);
const [passwordForm] = Form.useForm();
const [emailForm] = Form.useForm();
// A customer who signed up with Google has none, which changes the wording,
// the button, and whether a current-password field exists at all (#344).
const hasPassword = customer.has_password;
async function saveName(values: { firstName: string; lastName: string }) {
setBusy('name');
setNameError(null);
try {
await updateMyName(values.firstName, values.lastName);
onChanged();
message.success('Name updated');
} catch (err) {
setNameError((err as Error).message);
} finally {
setBusy(null);
}
}
async function saveEmail(values: { emailPassword: string; newEmail: string }) {
setBusy('email');
setEmailError(null);
try {
await changeMyEmail(values.emailPassword, values.newEmail);
onChanged();
emailForm.resetFields();
message.success('Email changed. Check the new address for a verification link.');
} catch (err) {
setEmailError((err as Error).message);
} finally {
setBusy(null);
}
}
async function savePassword(values: { currentPassword?: string; newPassword: string }) {
setBusy('password');
setPasswordError(null);
try {
await changeMyPassword(values.currentPassword ?? '', values.newPassword);
// Clearing the fields matters more than anything else here, since they
// hold both passwords. Setting a first one does refresh, because
// has_password has just changed and this panel renders from it.
passwordForm.resetFields();
if (hasPassword) {
message.success('Password changed. Other devices have been signed out.');
} else {
onChanged();
message.success('Password set. You can now sign in with it as well as with Google.');
}
} catch (err) {
setPasswordError((err as Error).message);
} finally {
setBusy(null);
}
}
return (
<div>
<Title level={5}>Your details</Title>
{nameError && <Alert type="error" showIcon message={nameError} style={{ marginBottom: 16 }} />}
<Form
layout="vertical"
onFinish={saveName}
initialValues={{ firstName: customer.first_name ?? '', lastName: customer.last_name ?? '' }}
>
<Form.Item
name="firstName"
label="First name"
rules={[{ required: true, whitespace: true, message: 'First name is required' }]}
>
<Input autoComplete="given-name" />
</Form.Item>
<Form.Item
name="lastName"
label="Last name"
rules={[{ required: true, whitespace: true, message: 'Last name is required' }]}
>
<Input autoComplete="family-name" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={busy === 'name'}>
Save name
</Button>
</Form.Item>
</Form>
{/* Collapsed by default. Both are rare, deliberate actions, and leaving
them expanded would push order history and the account controls below
the fold for everyone who never uses them. */}
<Collapse
style={{ marginTop: 16 }}
items={[
{
key: 'email',
label: 'Change your email address',
children: (
<>
<Paragraph type="secondary">
Your new address needs verifying before it can be used to sign in or reset your
password. We will also tell {customer.email} that the address was changed.
</Paragraph>
{emailError && (
<Alert type="error" showIcon message={emailError} style={{ marginBottom: 16 }} />
)}
<Form layout="vertical" form={emailForm} onFinish={saveEmail}>
<Form.Item
name="newEmail"
label="New email address"
rules={[{ required: true, type: 'email', message: 'Enter a valid email address' }]}
>
<Input autoComplete="email" />
</Form.Item>
{/* Asked for because a live session alone is not enough to move
the address a password reset would be sent to. */}
<Form.Item
name="emailPassword"
label="Your password"
rules={[{ required: true, message: 'Your password is required' }]}
>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={busy === 'email'}>
Change email
</Button>
</Form.Item>
</Form>
</>
)
},
{
key: 'password',
// Named for what it is for this customer. Offering to change a
// password to somebody who signed up with Google and has never had
// one is a dead end (#344).
label: hasPassword ? 'Change your password' : 'Set a password',
children: (
<>
<Paragraph type="secondary">
{hasPassword
? 'Signing in elsewhere will end. You will stay signed in on this device.'
: 'You signed up without a password. Setting one gives you a second way in, alongside the accounts listed below.'}
</Paragraph>
{passwordError && (
<Alert type="error" showIcon message={passwordError} style={{ marginBottom: 16 }} />
)}
<Form layout="vertical" form={passwordForm} onFinish={savePassword}>
{/* Absent, not disabled, for an account that has none. The
server branches on the stored hash rather than on anything
sent, so there is nothing for this field to carry. */}
{hasPassword && (
<Form.Item
name="currentPassword"
label="Current password"
rules={[{ required: true, message: 'Your current password is required' }]}
>
<Input.Password autoComplete="current-password" />
</Form.Item>
)}
<Form.Item
name="newPassword"
label="New password"
rules={[{ required: true, min: 8, message: 'At least 8 characters' }]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item
name="confirmPassword"
label="Confirm new password"
dependencies={['newPassword']}
rules={[
{ required: true, message: 'Confirm the password' },
({ getFieldValue }) => ({
validator: (_, value) =>
!value || getFieldValue('newPassword') === value
? Promise.resolve()
: Promise.reject(new Error('The passwords do not match'))
})
]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={busy === 'password'}>
{hasPassword ? 'Change password' : 'Set password'}
</Button>
</Form.Item>
</Form>
</>
)
}
]}
/>
</div>
);
}