Files
redefined-designs/frontend/src/customer/AuthForm.tsx
T
synAdminandClaude Opus 5 2c6ac4d2be
Linting / lint (pull_request) Successful in 3m49s
SonarQube Analysis / sonarqube (pull_request) Failing after 30m35s
feat(auth): offer Google sign-in on the login form (#345)
The last of the six, and the first a customer can see. The auth form is the single sign-in implementation rendered by both the route modal and the cart prompt, so the button goes in one place and appears in both.

Below the passkey button, which is below the password form. The order is deliberate and it is not about preference: a passkey is already on the device in front of the customer, while Google is a round trip to somebody else's site, and passwords are how every existing customer signs in. Each step down that list asks more of the person using it.

Absent rather than disabled where it is not configured, which is the same call #41 made for a browser without WebAuthn. It matters more here, because being unconfigured is the normal state rather than the exception: local development has no credentials, and QA cannot have any until #313. The storefront advertises a boolean through the existing public config, never the client id — the browser has no use for one, since the whole flow is a redirect the server builds.

Google's mark is inlined as SVG with their published colours and geometry. A hand-drawn approximation of somebody else's trademark is a compliance problem rather than a style choice, and a second origin on the sign-in path is a second thing that can be down.

The button is a navigation rather than a fetch, which makes it unlike every other control on that form. The flow leaves the application entirely, so there is no promise to await and no error to catch — the callback decides and redirects.

Where to return to is supplied by the caller, because only the caller knows. The route modal renders over a backdrop location and its own path is /login, so reading the current URL there would send the customer back to the form they just left; the router builds it from the backdrop instead. The cart prompt uses the page it interrupted. It cannot resume the interrupted action the way onSuccess does — the redirect leaves the app — so the customer lands back on the page and presses the button again.

That value is validated on the server and not in the browser. It has to be, since anyone can type the URL, and doing it in one place beats doing it twice in two languages.

The end-to-end test asserts the button is ABSENT, which is the behaviour local and QA actually have, and then signs in with the password form to show that its absence changes nothing. That is the point of putting the alternatives below rather than above.

docs/ops/google-sign-in.md records what has to be true outside the repository: the seven sections of the Google Auth Platform, the three scopes that keep publishing out of a verification review, the cutover checklist for #313, and the production smoke test. It states plainly that QA on the Synology hostname is impossible rather than merely unconfigured, because Google will not accept a redirect URI whose domain nobody can prove they own — the same wall #285 hit with Cloudflare.

The failure that document warns about hardest is leaving the consent screen in Testing. Only listed test users can then sign in, the refusal happens on Google's own page, and nothing reaches the storefront at all — so a customer reports a broken button and the logs are silent.

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 and end-to-end suites need a database this machine has no Docker for.

Closes #345

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 15:13:18 -05:00

307 lines
14 KiB
TypeScript

import { useEffect, 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';
import GoogleSignInButton from './GoogleSignInButton';
import { fetchConfig } from '../api';
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;
/**
* Where a Google sign-in should return the customer (#345).
*
* Supplied by the caller because only the caller knows: the route modal has a
* page behind it, and the cart prompt has the page it interrupted. An OAuth
* redirect leaves the application entirely, so this cannot be recovered
* afterwards the way onSuccess recovers it for every other path.
*/
returnTo?: string;
}>;
/**
* 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, returnTo = '/' }: 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();
// Whether this environment has Google credentials at all. Fetched rather than
// built in, because one image serves every environment — and false is the
// right starting value: a button that appears a moment late is better than
// one that appears and then vanishes.
const [googleEnabled, setGoogleEnabled] = useState(false);
useEffect(() => {
fetchConfig()
.then((config) => setGoogleEnabled(config.googleSignIn))
// Silent, and the button simply never appears. The password form behind
// it works regardless, which is the whole reason it is below rather than
// above.
.catch(() => setGoogleEnabled(false));
}, []);
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 || googleEnabled) && (
<Divider plain style={{ marginBlock: 16 }}>
<Text type="secondary" style={{ fontSize: 12 }}>or</Text>
</Divider>
)}
{canUsePasskeys && (
<>
<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>
</>
)}
{/* Below the passkey button, which is below the password form.
The order is deliberate and it is not about preference: a
passkey is already on the device in front of the customer,
while Google is a round trip to somebody else's site. Absent
rather than disabled where it is not configured, for the
same reason as the one above (#345). */}
{googleEnabled && (
<div style={{ marginTop: canUsePasskeys ? 16 : 0 }}>
<GoogleSignInButton returnTo={returnTo} />
</div>
)}
</Form>
)
}
]}
/>
</>
);
}