feat(auth): create an account from a Google identity, then ask about consent (#342)

A Google account nobody here has seen now becomes a customer. The OAuth part of this was the easy half; the problem worth the issue is consent.

Registration asks for two consents and stores their wording verbatim, and marketing consent must start unticked. Somebody arriving through Google has never seen those checkboxes and could not have, because the redirect happened before anyone knew whether they were new.

Creating the account with both false is legally correct: nobody agreed to anything, and nothing is recorded as though they had. There is no stored wording either, because a wording saved against a false consent is a record of a conversation that never happened. But stopping there would mean a Google sign-up is never asked at all, and a silent no is still a decision made on somebody else's behalf.

So the account is created, the customer is signed in, and they land on a step that shows the same two sentences with the same two unticked boxes. It saves through the endpoints registration already uses, which is what keeps the stored text byte-identical rather than merely similar. Not now is offered as an equal option, because consent has to be as easy to withhold as to give, and both can be changed later from the account page.

The wording on that screen is imported from the shared constants rather than retyped. Three different wordings were already in circulation once before that was shared, and the record is meant to say what the customer actually saw.

The return path is deliberately dropped for a new customer, who lands on the consent step instead. Carrying it through as a query parameter was the alternative and was rejected: the consent page would then redirect somewhere a URL told it to, which is the open-redirect question already answered on the server, asked a second time in a second language on a page an attacker can link to directly. One new customer occasionally landing on the storefront rather than back at their cart is much the cheaper of the two.

The customer and the identity are inserted in one transaction. A customer row with no identity is an account nobody can sign in to and nobody can recover, because it has no password either.

Signing up is refused when the address already belongs to a customer. Joining those two accounts is linking, it is the most security-sensitive decision in this project, and it belongs to the next issue rather than falling out of an INSERT here. Refusing is the safe half of that decision and the only half available until the policy is written down. The unique index rather than the preceding SELECT is what actually holds when two sign-ins race, so losing that race is treated as the address being taken rather than as an error.

Google's assertion about the address is taken only when it is the boolean true. When it holds, the account is marked verified and no confirmation email is sent, because that email exists to prove the customer receives mail at the address and Google has just proved exactly that. When it does not, the account is unverified and goes through the ordinary confirmation, because an unverified assertion is worth nothing.

Names from the profile are hints. Registration demands both because every email greets by first name, but Google may return neither and refusing a sign-in over it would be absurd — the greeting already has a fallback for exactly this case.

The tests worth reading are the two about a returning customer. One signs in again and reaches the same account; the other changes their Google address first and still reaches it. That second one is the whole reason the identity is keyed on the subject claim: an email match would have created a second account there, and an address that had since been reassigned would have handed the first one to a stranger.

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 #342

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
synAdmin
2026-09-10 10:11:16 -05:00
committed by bermudalamb
co-authored by Claude Opus 5
parent 79a2b606ea
commit 25078417e5
5 changed files with 481 additions and 15 deletions
+114
View File
@@ -0,0 +1,114 @@
import { useState } from 'react';
import Modal from 'antd/es/modal';
import Checkbox from 'antd/es/checkbox';
import Button from 'antd/es/button';
import Space from 'antd/es/space';
import Alert from 'antd/es/alert';
import Typography from 'antd/es/typography';
import { updateConsent, updateAnalyticsConsent } from './customerApi';
import { MARKETING_CONSENT_TEXT, ANALYTICS_CONSENT_TEXT } from './AuthForm';
const { Paragraph, Title } = Typography;
type Props = Readonly<{ onClose: () => void }>;
/**
* The consent step a customer sees once, right after signing up with Google (#342).
*
* ## Why this screen has to exist
*
* Registration asks for two consents and stores their wording verbatim, and
* marketing consent must start unticked (#56). Somebody who arrived through
* Google has never seen those checkboxes and could not have: the redirect
* happened before anyone knew whether they were new.
*
* Their account is created with both false, which is legally correct — nobody
* agreed to anything and nothing is recorded as though they had. But leaving it
* there would mean a Google sign-up is never asked at all, and a silent no is
* still a decision made on someone else's behalf.
*
* ## Why the wording is imported rather than written here
*
* These two constants are the same strings the server stores against the
* consent. The record is meant to say what the customer actually saw, so a
* second copy of the sentence that drifted by a word would quietly defeat that.
* Three wordings were already in circulation once before this was shared.
*
* ## Why skipping is a real option, not a soft refusal
*
* Consent has to be as easy to withhold as to give. "Not now" leaves both false
* and closes, and nothing is sent. Both can be changed later from the account
* page, which is where a customer who changes their mind will look.
*/
export default function Welcome({ onClose }: Props) {
const [marketing, setMarketing] = useState(false);
const [analytics, setAnalytics] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function save() {
setSaving(true);
setError(null);
try {
// Two calls to two endpoints, which is the point rather than an
// inefficiency: they are separate consents with separate purposes, and
// the server stores the wording for each independently.
await updateConsent(marketing);
await updateAnalyticsConsent(analytics);
onClose();
} catch (err) {
// The account exists and they are signed in either way, so this is not a
// failure to recover from — only a preference that did not save.
setError(`Those preferences didn't save — ${(err as Error).message}. You can set them on your account page.`);
} finally {
setSaving(false);
}
}
return (
<Modal
title="Welcome to Redefined Designs"
open
onCancel={onClose}
footer={null}
style={{ maxWidth: 'calc(100vw - 32px)' }}
destroyOnHidden
>
<Paragraph type="secondary">
Your account is ready and you are signed in. Two optional things, and you can change
either of them later on your account page.
</Paragraph>
{error && <Alert type="warning" showIcon message={error} style={{ marginBottom: 16 }} />}
<Space direction="vertical" size="middle" style={{ display: 'flex' }}>
<Checkbox checked={marketing} onChange={(e) => setMarketing(e.target.checked)}>
{MARKETING_CONSENT_TEXT}
</Checkbox>
{/* Its own checkbox and independently refusable. Someone has to be able
to take the emails and refuse the tracking, or the consent is not
granular and is not valid. Unticked, and never pre-ticked: Quebec's
Law 25 requires profiling to be off until the person switches it on. */}
<Checkbox checked={analytics} onChange={(e) => setAnalytics(e.target.checked)}>
{ANALYTICS_CONSENT_TEXT}
</Checkbox>
</Space>
<Space style={{ marginTop: 24 }}>
<Button type="primary" loading={saving} onClick={save}>
Save preferences
</Button>
{/* As prominent as it needs to be. Withholding consent has to be as
easy as giving it, and a "Not now" hidden in small print is the
pattern that makes a consent invalid. */}
<Button onClick={onClose} disabled={saving}>
Not now
</Button>
</Space>
<Title level={5} style={{ marginTop: 24, fontSize: 13, opacity: 0.65 }}>
Leaving both unticked is fine we will not email you or share what you browse.
</Title>
</Modal>
);
}
+6 -1
View File
@@ -13,6 +13,7 @@ import ErrorFallback from './components/ErrorFallback';
import DevThrow from './components/DevThrow';
import Admin from './admin/Admin';
import AuthRouteModal from './customer/AuthRouteModal';
import Welcome from './customer/Welcome';
import Account from './customer/Account';
import PrivacyPolicy from './customer/PrivacyPolicy';
import Submit from './intake/Submit';
@@ -45,7 +46,7 @@ const STOREFRONT_BACKDROP: Partial<Location> = { pathname: '/', search: '', hash
// than as a page of their own. Each stays a real, linkable URL — bookmarkable,
// refreshable, and closed by the browser's Back button — while never being
// somewhere with no way out.
const MODAL_ROUTES = ['/account', '/login', '/register', '/forgot-password', '/reset-password'];
const MODAL_ROUTES = ['/account', '/login', '/register', '/forgot-password', '/reset-password', '/welcome'];
// Respects the OS-level "reduce motion" accessibility setting by turning off
// antd's transitions. Beyond the accessibility win, animated popups are a
@@ -200,6 +201,10 @@ function AppRoutes() {
{modalPath === '/forgot-password' && (
<ForgotPassword onClose={closeModal} onBackToSignIn={() => goWithinAuth('/login')} />
)}
{/* One-time, right after a Google sign-up (#342). A route rather than
a flag so it has an address and uses the same modal machinery as
every other auth screen. */}
{modalPath === '/welcome' && <Welcome onClose={closeModal} />}
{modalPath === '/reset-password' && (
<ResetPassword
onClose={closeModal}