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(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) { 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 && ( )} {error && } { // 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: (
submit(() => registerCustomer(values.email, values.password, values.firstName, values.lastName, !!values.marketingConsent, !!values.analyticsConsent) ) } > {MARKETING_CONSENT_TEXT} {/* 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. */} {ANALYTICS_CONSENT_TEXT} {/* 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{' '} Privacy Policy.
) }, { key: 'login', label: 'Log In', children: (
submit(() => loginCustomer(values.email, values.password))} > {/* 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) && ( or )} {canUsePasskeys && ( <> {/* 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. )} {/* 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 && (
)}
) } ]} /> ); }