Files
redefined-designs/frontend/src/customer/AuthForm.tsx
T
synAdminandClaude Opus 5 44df0bd2d9
Linting / lint (pull_request) Successful in 3m52s
SonarQube Analysis / sonarqube (pull_request) Failing after 29m7s
feat(auth): link a Google identity to an account that already exists (#343)
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>
2026-09-10 10:18:05 -05:00

269 lines
12 KiB
TypeScript

import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import Form from 'antd/es/form';
import Input from 'antd/es/input';
import Button from 'antd/es/button';
import Checkbox from 'antd/es/checkbox';
import Tabs from 'antd/es/tabs';
import Alert from 'antd/es/alert';
import Typography from 'antd/es/typography';
import Divider from 'antd/es/divider';
import { registerCustomer, loginCustomer, signInWithPasskey, passkeysSupported } from './customerApi';
import { useCustomerAuth } from './CustomerAuthContext';
const { Text } = Typography;
export type AuthMode = 'register' | 'login';
// Must stay identical to MARKETING_CONSENT_TEXT in backend/src/utils.ts, which
// is what gets stored verbatim against the customer's consent record. The point
// of storing it is that the record says what the customer actually saw, so a
// label that differs from the stored string defeats the whole mechanism. Before
// this was shared there were three wordings in play — this one, a shorter one in
// the cart prompt, and the string the server actually recorded — and none of
// them matched.
export const MARKETING_CONSENT_TEXT =
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
// Analytics consent (#56). A second sentence and a second checkbox rather than
// wording folded into the one above, because GDPR requires consent to be
// granular: someone must be able to take the emails and refuse the tracking.
// Must stay identical to ANALYTICS_CONSENT_TEXT in backend/src/utils.ts, which
// is what gets stored verbatim — same rule, and same failure mode, as the
// marketing sentence.
export const ANALYTICS_CONSENT_TEXT =
'I agree that what I browse and buy on this site may be shared with Brevo, the service that sends our emails, so that what they contain is relevant to me. This is optional, separate from receiving the emails themselves, and I can turn it off at any time.';
type Props = Readonly<{
mode: AuthMode;
onModeChange: (mode: AuthMode) => void;
onForgotPassword: () => void;
// Called once the session exists. What that means differs by caller: the
// route closes back to the page behind it, while the cart and favorite
// prompts resume the action the customer was interrupted doing.
onSuccess: () => void;
}>;
/**
* What a Google sign-in that ended badly wants the login form to say (#343).
*
* Read from the query string because the callback is a redirect: it cannot
* return a body, and the customer's browser arrives here having been sent by
* Google. A parameter is the only channel there is.
*
* `google-use-password` is the interesting one. It means the customer has an
* account and simply cannot reach it this way, which is the single refusal in
* this flow they can act on — so it says what to do rather than what failed.
*
* It reveals nothing they did not already supply. They arrived holding a Google
* account for this address, so being told the address has an account here tells
* them only about themselves.
*/
function googleNotice(reason: string | null): string | null {
if (reason === 'google-use-password') {
return 'You already have an account with this email address. Log in with your password below.';
}
if (reason === 'google-failed') {
return 'That Google sign-in did not work. You can log in with your password instead.';
}
return null;
}
// The one implementation of signing in and registering. It was previously
// written twice — once as the /login and /register pages, once inside the
// prompt shown when a signed-out visitor adds to the cart — which had already
// drifted in consent wording and in which links each offered.
export default function AuthForm({ mode, onModeChange, onForgotPassword, onSuccess }: Props) {
const [searchParams] = useSearchParams();
const notice = googleNotice(searchParams.get('auth'));
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Separate from `loading`, so the password button does not sit disabled and
// spinning while the browser's passkey prompt is open. The whole requirement
// is that a dismissed prompt leaves a usable password form behind it.
const [passkeyLoading, setPasskeyLoading] = useState(false);
const { refresh } = useCustomerAuth();
// Read once at render rather than per click: a browser either implements
// WebAuthn or it does not, and this decides whether the control exists at all
// rather than whether pressing it works.
const canUsePasskeys = passkeysSupported();
async function submit(action: () => Promise<unknown>) {
setLoading(true);
setError(null);
try {
await action();
refresh();
onSuccess();
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}
/**
* Sign in with a passkey (#41).
*
* Not routed through `submit`, because the two differ in the one place that
* matters: dismissing the browser's prompt rejects, and that is a
* cancellation rather than a failure. Showing an error there would tell a
* customer something went wrong when they changed their mind, and would leave
* a red alert sitting above a password form that is working perfectly.
*
* Every other outcome clears back to the password form rather than a dead
* end. The server answers every refusal identically — no such credential, a
* disabled account, a bad assertion — so this cannot say whether an account
* exists, and neither can the copy here.
*/
async function signInWithAPasskey() {
setPasskeyLoading(true);
setError(null);
try {
await signInWithPasskey();
refresh();
onSuccess();
} catch (err) {
const name = (err as { name?: string }).name;
if (name === 'NotAllowedError' || name === 'AbortError') return;
setError('That passkey did not work. You can log in with your password instead.');
} finally {
setPasskeyLoading(false);
}
}
return (
<>
{/* The notice sits above the tabs and below any live error, because it
describes how the customer arrived rather than what they just did. An
error from this form supersedes it. */}
{!error && notice && (
<Alert type="info" showIcon message={notice} style={{ marginBottom: 16 }} />
)}
{error && <Alert type="error" showIcon message={error} style={{ marginBottom: 16 }} />}
<Tabs
activeKey={mode}
onChange={(key) => {
// A stale error from the other tab would read as though it applied to
// the form now on screen.
setError(null);
onModeChange(key as AuthMode);
}}
items={[
{
key: 'register',
label: 'Create Account',
children: (
<Form
layout="vertical"
onFinish={(values) =>
submit(() =>
registerCustomer(values.email, values.password, values.firstName, values.lastName, !!values.marketingConsent, !!values.analyticsConsent)
)
}
>
<Form.Item
name="firstName"
label="First name"
rules={[{ required: true, message: 'First name is required' }]}
>
<Input autoComplete="given-name" />
</Form.Item>
<Form.Item
name="lastName"
label="Last name"
rules={[{ required: true, message: 'Last name is required' }]}
>
<Input autoComplete="family-name" />
</Form.Item>
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input autoComplete="email" />
</Form.Item>
<Form.Item
name="password"
label="Password"
rules={[{ required: true, min: 8, message: 'At least 8 characters' }]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item name="marketingConsent" valuePropName="checked" initialValue={false}>
<Checkbox>{MARKETING_CONSENT_TEXT}</Checkbox>
</Form.Item>
{/* Its own checkbox, and independent of the one above: someone
has to be able to take the emails and refuse the tracking,
or the consent is not granular and is not valid. Unchecked
by default and never pre-ticked — Quebec's Law 25 requires
profiling to be off until the person switches it on. */}
<Form.Item name="analyticsConsent" valuePropName="checked" initialValue={false}>
<Checkbox>{ANALYTICS_CONSENT_TEXT}</Checkbox>
</Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
Create account
</Button>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}>
{/* Opens in a new tab deliberately: following it in place would
discard a part-filled signup form, and /privacy has no way
back of its own yet (#52). */}
By creating an account you agree to our{' '}
<a href="/privacy" target="_blank" rel="noopener noreferrer">Privacy Policy</a>.
</Text>
</Form>
)
},
{
key: 'login',
label: 'Log In',
children: (
<Form
layout="vertical"
onFinish={(values) => submit(() => loginCustomer(values.email, values.password))}
>
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input autoComplete="email" />
</Form.Item>
<Form.Item name="password" label="Password" rules={[{ required: true }]}>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
Log in
</Button>
<Button type="link" style={{ paddingInline: 0, marginTop: 8 }} onClick={onForgotPassword}>
Forgot password?
</Button>
{/* Below the password form, not above it. Passwords are how
every existing customer signs in, and a passkey is the
alternative — putting it first would demote the path that
works for everyone. Absent entirely where WebAuthn is not
available, rather than shown disabled: a greyed button
invites a customer to wonder what they are missing (#41). */}
{canUsePasskeys && (
<>
<Divider plain style={{ marginBlock: 16 }}>
<Text type="secondary" style={{ fontSize: 12 }}>or</Text>
</Divider>
<Button
block
loading={passkeyLoading}
onClick={signInWithAPasskey}
>
Sign in with a passkey
</Button>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
{/* Says what it needs rather than naming the standard.
"WebAuthn" means nothing to a customer, and the thing
they recognise is the gesture their device asks for. */}
Use your fingerprint, face or screen lock.
</Text>
</>
)}
</Form>
)
}
]}
/>
</>
);
}