Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db62737da6 | ||
|
|
828ee62bef |
@@ -0,0 +1,105 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { pool } from '../db';
|
||||||
|
import type { GoogleIdentity } from './oauth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creating a customer from a Google identity (#342).
|
||||||
|
*
|
||||||
|
* ## What this deliberately does not do
|
||||||
|
*
|
||||||
|
* It refuses 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 #343 rather than falling out of an INSERT here.
|
||||||
|
* Refusing is the safe half of that decision and the only one available until
|
||||||
|
* the policy is written down.
|
||||||
|
*
|
||||||
|
* ## Consent, which is the actual problem in this issue
|
||||||
|
*
|
||||||
|
* Registration captures two consents and stores their wording verbatim, and
|
||||||
|
* marketing consent must start unticked (#56). A customer arriving through
|
||||||
|
* Google has never seen those checkboxes and **cannot have**: the redirect to
|
||||||
|
* Google happens before anyone knows whether they are new.
|
||||||
|
*
|
||||||
|
* So the account is created with both false and no stored wording, which is
|
||||||
|
* legally correct — nobody has agreed to anything, and nothing is recorded as
|
||||||
|
* though they had. What makes it honest rather than merely lawful is that the
|
||||||
|
* customer is then asked, on a step that shows the same two sentences, through
|
||||||
|
* the same endpoints registration uses. That is what keeps the stored text
|
||||||
|
* byte-identical, which is the whole point of storing it.
|
||||||
|
*
|
||||||
|
* Skipping that step is allowed and leaves both false. A consent nobody gave is
|
||||||
|
* the correct default and a perfectly fine resting state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SignUpOutcome =
|
||||||
|
| { kind: 'created'; customerId: number }
|
||||||
|
/** The address is already an account's. #343 decides whether to link. */
|
||||||
|
| { kind: 'email-taken' };
|
||||||
|
|
||||||
|
interface IdRow {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCustomerFromGoogle(identity: GoogleIdentity): Promise<SignUpOutcome> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
const { rows: existing } = await client.query<IdRow>(
|
||||||
|
`SELECT id FROM customers WHERE email = $1`,
|
||||||
|
[identity.email]
|
||||||
|
);
|
||||||
|
if (existing.length) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return { kind: 'email-taken' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows } = await client.query<IdRow>(
|
||||||
|
`INSERT INTO customers (email, password_hash, first_name, last_name, email_verified, unsubscribe_token)
|
||||||
|
VALUES ($1, NULL, $2, $3, $4, $5)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
identity.email,
|
||||||
|
// Hints rather than requirements. Registration demands both names
|
||||||
|
// because every email greets by first name, but Google may return
|
||||||
|
// neither and refusing the sign-in over it would be absurd — the
|
||||||
|
// greeting already has a fallback for exactly this.
|
||||||
|
identity.firstName,
|
||||||
|
identity.lastName,
|
||||||
|
// Only on Google's word, never assumed. An unverified assertion is
|
||||||
|
// worth nothing, and the caller sends the usual confirmation email when
|
||||||
|
// this is false.
|
||||||
|
identity.emailVerified,
|
||||||
|
crypto.randomBytes(16).toString('hex')
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// The INSERT above has a RETURNING clause, so no row means the statement
|
||||||
|
// did not do what it says.
|
||||||
|
const customer = rows[0];
|
||||||
|
if (!customer) throw new Error('the customer INSERT returned no row');
|
||||||
|
|
||||||
|
// In the same transaction, deliberately. A customer row with no identity is
|
||||||
|
// an account nobody can sign in to and nobody can recover, because it has
|
||||||
|
// no password either — the worst possible thing to leave behind.
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO customer_identities (customer_id, provider, provider_sub, last_used_at)
|
||||||
|
VALUES ($1, 'google', $2, now())`,
|
||||||
|
[customer.id, identity.sub]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return { kind: 'created', customerId: customer.id };
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
// Two sign-ins racing for the same brand-new address. The SELECT above
|
||||||
|
// cannot see the other transaction's uncommitted row, so the unique index
|
||||||
|
// is what actually holds — and losing that race means the account now
|
||||||
|
// exists, which is 'email-taken' rather than an error.
|
||||||
|
if ((err as { code?: string }).code === '23505') {
|
||||||
|
return { kind: 'email-taken' };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ import { asyncRoute } from '../asyncRoute';
|
|||||||
import { signIn } from '../customerSession';
|
import { signIn } from '../customerSession';
|
||||||
import { googleConfig } from '../google/config';
|
import { googleConfig } from '../google/config';
|
||||||
import { newAttempt, authorizationUrl, exchangeCode, verifiedIdentity } from '../google/oauth';
|
import { newAttempt, authorizationUrl, exchangeCode, verifiedIdentity } from '../google/oauth';
|
||||||
|
import type { GoogleIdentity } from '../google/oauth';
|
||||||
|
import { createCustomerFromGoogle } from '../google/newCustomer';
|
||||||
|
import { issueVerificationEmail } from '../customerVerification';
|
||||||
import type { AttemptSecrets } from '../google/oauth';
|
import type { AttemptSecrets } from '../google/oauth';
|
||||||
import { googleSignInLimiter } from '../rateLimit';
|
import { googleSignInLimiter } from '../rateLimit';
|
||||||
import { safeReturnTo } from '../google/returnTo';
|
import { safeReturnTo } from '../google/returnTo';
|
||||||
@@ -18,12 +21,16 @@ const router = Router();
|
|||||||
* and mounted at `/api/auth/google`, away from `/api/customers`, because it is
|
* and mounted at `/api/auth/google`, away from `/api/customers`, because it is
|
||||||
* the first route in this application that a third party redirects into.
|
* the first route in this application that a third party redirects into.
|
||||||
*
|
*
|
||||||
* ## What this phase does and does not do
|
* ## What this does and does not do
|
||||||
*
|
*
|
||||||
* It signs in a customer whose Google identity is **already linked**. A
|
* It signs in a customer whose Google identity is already linked, and creates
|
||||||
* successful sign-in by somebody with no identity row does nothing yet: account
|
* an account for one nobody here has seen (#342).
|
||||||
* creation is #342 and the linking policy is #343, and holding them back keeps
|
*
|
||||||
* this change about the protocol alone.
|
* It refuses 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 #343 rather than falling out of an INSERT.
|
||||||
|
* Refusing is the safe half of that decision and the only half available until
|
||||||
|
* the policy is written down.
|
||||||
*
|
*
|
||||||
* ## The cookie, and why it is the whole security of the callback
|
* ## The cookie, and why it is the whole security of the callback
|
||||||
*
|
*
|
||||||
@@ -62,6 +69,15 @@ interface IdentityRow {
|
|||||||
*/
|
*/
|
||||||
const FAILURE_PATH = '/login?auth=google-failed';
|
const FAILURE_PATH = '/login?auth=google-failed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a customer who has just been created lands.
|
||||||
|
*
|
||||||
|
* A route rather than a flag on the storefront, so it is a page with an address
|
||||||
|
* — reachable again, linkable from the account page later, and rendered by the
|
||||||
|
* same modal-route machinery every other auth screen uses.
|
||||||
|
*/
|
||||||
|
const WELCOME_PATH = '/welcome';
|
||||||
|
|
||||||
function setAttemptCookie(res: Response, attempt: Attempt): void {
|
function setAttemptCookie(res: Response, attempt: Attempt): void {
|
||||||
res.cookie(ATTEMPT_COOKIE, Buffer.from(JSON.stringify(attempt)).toString('base64url'), {
|
res.cookie(ATTEMPT_COOKIE, Buffer.from(JSON.stringify(attempt)).toString('base64url'), {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -112,6 +128,49 @@ function secretsMatch(a: string, b: string): boolean {
|
|||||||
return crypto.timingSafeEqual(digest(a), digest(b));
|
return crypto.timingSafeEqual(digest(a), digest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an account for a Google identity nobody here has seen, and signs in.
|
||||||
|
*
|
||||||
|
* A named function rather than an inline block for the reason
|
||||||
|
* `routesAreWrapped.test.ts` cares about, and because the callback is already
|
||||||
|
* the longest handler in this file.
|
||||||
|
*
|
||||||
|
* The return path is deliberately dropped for a brand-new customer, who lands
|
||||||
|
* on the consent step instead. That step is worth interrupting for: it is the
|
||||||
|
* only moment the two consent sentences can honestly be shown, because the
|
||||||
|
* redirect to Google happened before anyone knew this person was new.
|
||||||
|
*
|
||||||
|
* Carrying the path through as a query parameter was the alternative, and it
|
||||||
|
* was rejected. The consent page would then have to redirect somewhere a URL
|
||||||
|
* told it to, which is the open-redirect question `safeReturnTo` already
|
||||||
|
* answers 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 the cheaper of the two.
|
||||||
|
*/
|
||||||
|
async function signUp(res: Response, identity: GoogleIdentity): Promise<void> {
|
||||||
|
const outcome = await createCustomerFromGoogle(identity);
|
||||||
|
|
||||||
|
if (outcome.kind === 'email-taken') {
|
||||||
|
// An account already uses this address, and joining them is #343. Refusing
|
||||||
|
// is the safe half of that decision: linking on an address is exactly the
|
||||||
|
// takeover path the policy exists to reason about carefully.
|
||||||
|
console.warn('[google] refused a sign-up: that address already has an account');
|
||||||
|
res.redirect(FAILURE_PATH);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only when Google did not vouch for the address. When it did, the customer
|
||||||
|
// has already demonstrated they receive mail there — which is precisely what
|
||||||
|
// the confirmation email exists to establish — so sending one would ask them
|
||||||
|
// to do a thing that is done.
|
||||||
|
if (!identity.emailVerified) {
|
||||||
|
await issueVerificationEmail(outcome.customerId, identity.email, identity.firstName, identity.lastName);
|
||||||
|
}
|
||||||
|
|
||||||
|
await signIn(res, outcome.customerId);
|
||||||
|
res.redirect(WELCOME_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/start',
|
'/start',
|
||||||
googleSignInLimiter,
|
googleSignInLimiter,
|
||||||
@@ -178,11 +237,11 @@ router.get(
|
|||||||
);
|
);
|
||||||
const linked = rows[0];
|
const linked = rows[0];
|
||||||
|
|
||||||
// No identity row means a customer this shop has never seen through Google.
|
// Nobody this shop has seen through Google before. Either they are new, or
|
||||||
// Creating one is #342 and linking to an existing account is #343; until
|
// they already have an account under this address — and joining those two
|
||||||
// those land there is nothing to do, and doing nothing must not look like a
|
// is linking, which is #343 and is refused here until its policy is
|
||||||
// protocol failure.
|
// written down rather than falling out of an INSERT.
|
||||||
if (!linked) return res.redirect(FAILURE_PATH);
|
if (!linked) return signUp(res, identity);
|
||||||
|
|
||||||
// Refused here as well as on the password and passkey paths. Enforcing it
|
// Refused here as well as on the password and passkey paths. Enforcing it
|
||||||
// on some routes and not others is how a disabled account keeps a way in,
|
// on some routes and not others is how a disabled account keeps a way in,
|
||||||
@@ -208,6 +267,6 @@ router.get(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** Exported for the tests; nothing else needs the cookie's name. */
|
/** Exported for the tests; nothing else needs the cookie's name. */
|
||||||
export { ATTEMPT_COOKIE, ATTEMPT_TTL_MS, FAILURE_PATH };
|
export { ATTEMPT_COOKIE, ATTEMPT_TTL_MS, FAILURE_PATH, WELCOME_PATH };
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ describe('GET /api/auth/google/callback', () => {
|
|||||||
expect(res.headers['set-cookie'] ?? []).not.toContainEqual(expect.stringContaining('rd_session='));
|
expect(res.headers['set-cookie'] ?? []).not.toContainEqual(expect.stringContaining('rd_session='));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not sign in an unlinked Google account, and creates nothing', async () => {
|
it('refuses when the address already belongs to an account, and creates nothing', async () => {
|
||||||
await createCustomer('unlinked@example.com');
|
await createCustomer('unlinked@example.com');
|
||||||
const { cookie, state, nonce } = await startSignIn();
|
const { cookie, state, nonce } = await startSignIn();
|
||||||
respondWithToken(idToken(claimsFor(nonce, { email: 'unlinked@example.com' })));
|
respondWithToken(idToken(claimsFor(nonce, { email: 'unlinked@example.com' })));
|
||||||
@@ -345,8 +345,9 @@ describe('GET /api/auth/google/callback', () => {
|
|||||||
.query({ code: 'an-auth-code', state })
|
.query({ code: 'an-auth-code', state })
|
||||||
.set('Cookie', cookie);
|
.set('Cookie', cookie);
|
||||||
|
|
||||||
// Account creation is #342 and linking is #343. Matching on the address
|
// Joining those two accounts is linking, which is #343. Doing it here on
|
||||||
// here would be the takeover path both of those exist to decide carefully.
|
// the strength of a matching address is the takeover path that decision
|
||||||
|
// exists to reason about carefully.
|
||||||
expect(res.headers.location).toBe('/login?auth=google-failed');
|
expect(res.headers.location).toBe('/login?auth=google-failed');
|
||||||
const { rows } = await pool.query<{ n: number }>(
|
const { rows } = await pool.query<{ n: number }>(
|
||||||
`SELECT count(*)::int AS n FROM customer_identities`
|
`SELECT count(*)::int AS n FROM customer_identities`
|
||||||
@@ -354,3 +355,185 @@ describe('GET /api/auth/google/callback', () => {
|
|||||||
expect(requireRow(rows, 'a count of identities').n).toBe(0);
|
expect(requireRow(rows, 'a count of identities').n).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #342. A Google account nobody here has seen becomes a customer.
|
||||||
|
*
|
||||||
|
* The consent behaviour is most of what these assert, because it is the part
|
||||||
|
* that is easy to get quietly wrong: an account created with a consent nobody
|
||||||
|
* gave, or with wording that does not match what the customer was shown, is
|
||||||
|
* still an account that works.
|
||||||
|
*/
|
||||||
|
describe('signing up with Google', () => {
|
||||||
|
async function signUpWith(overrides: Record<string, unknown> = {}) {
|
||||||
|
const { cookie, state, nonce } = await startSignIn();
|
||||||
|
respondWithToken(idToken(claimsFor(nonce, overrides)));
|
||||||
|
return request(app)
|
||||||
|
.get('/api/auth/google/callback')
|
||||||
|
.query({ code: 'an-auth-code', state })
|
||||||
|
.set('Cookie', cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function customerBy(email: string) {
|
||||||
|
const { rows } = await pool.query<{
|
||||||
|
id: number;
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
|
email_verified: boolean;
|
||||||
|
password_hash: string | null;
|
||||||
|
marketing_consent: boolean;
|
||||||
|
marketing_consent_text: string | null;
|
||||||
|
analytics_consent: boolean;
|
||||||
|
analytics_consent_text: string | null;
|
||||||
|
}>(`SELECT * FROM customers WHERE email = $1`, [email]);
|
||||||
|
return requireRow(rows, `the customer for ${email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countOf(table: 'customers' | 'customer_identities'): Promise<number> {
|
||||||
|
const { rows } = await pool.query<{ n: number }>(`SELECT count(*)::int AS n FROM ${table}`);
|
||||||
|
return requireRow(rows, `a count of ${table}`).n;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verificationTokens(customerId: number): Promise<number> {
|
||||||
|
const { rows } = await pool.query<{ n: number }>(
|
||||||
|
`SELECT count(*)::int AS n FROM customer_tokens WHERE customer_id = $1 AND kind = 'verify_email'`,
|
||||||
|
[customerId]
|
||||||
|
);
|
||||||
|
return requireRow(rows, 'a count of verification tokens').n;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates a customer and an identity, and signs them in', async () => {
|
||||||
|
const res = await signUpWith({ email: 'brandnew@example.com' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
const customer = await customerBy('brandnew@example.com');
|
||||||
|
const { rows } = await pool.query<{ customer_id: number }>(
|
||||||
|
`SELECT customer_id FROM customer_identities WHERE provider_sub = $1`,
|
||||||
|
[SUB]
|
||||||
|
);
|
||||||
|
expect(requireRow(rows, 'the new identity').customer_id).toBe(customer.id);
|
||||||
|
expect(res.headers['set-cookie'] as unknown as string[]).toContainEqual(
|
||||||
|
expect.stringContaining('rd_session=')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lands the new customer on the consent step rather than the storefront', async () => {
|
||||||
|
// The one moment the two consent sentences can honestly be shown: the
|
||||||
|
// redirect to Google happened before anyone knew this person was new.
|
||||||
|
const res = await signUpWith({ email: 'consentstep@example.com' });
|
||||||
|
|
||||||
|
expect(res.headers.location).toBe('/welcome');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the account with no password at all', async () => {
|
||||||
|
await signUpWith({ email: 'nopassword@example.com' });
|
||||||
|
|
||||||
|
// The first accounts in this project's history without one. #344 is where
|
||||||
|
// the routes that assumed otherwise learn to cope.
|
||||||
|
expect((await customerBy('nopassword@example.com')).password_hash).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives both consents as false, with no stored wording', async () => {
|
||||||
|
await signUpWith({ email: 'noconsent@example.com' });
|
||||||
|
const customer = await customerBy('noconsent@example.com');
|
||||||
|
|
||||||
|
// Nobody agreed to anything, so nothing is recorded as though they had. A
|
||||||
|
// stored wording against a false consent would be a record of a
|
||||||
|
// conversation that never happened.
|
||||||
|
expect(customer.marketing_consent).toBe(false);
|
||||||
|
expect(customer.analytics_consent).toBe(false);
|
||||||
|
expect(customer.marketing_consent_text).toBeNull();
|
||||||
|
expect(customer.analytics_consent_text).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the names from the Google profile', async () => {
|
||||||
|
await signUpWith({ email: 'named@example.com' });
|
||||||
|
const customer = await customerBy('named@example.com');
|
||||||
|
|
||||||
|
expect(customer.first_name).toBe('Test');
|
||||||
|
expect(customer.last_name).toBe('Customer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the account anyway when Google sends no names', async () => {
|
||||||
|
// Registration demands both because every email greets by first name, but
|
||||||
|
// Google may return neither and refusing over it would be absurd — the
|
||||||
|
// greeting already has a fallback for exactly this.
|
||||||
|
const { cookie, state, nonce } = await startSignIn();
|
||||||
|
const claims = claimsFor(nonce, { email: 'nameless@example.com' }) as Record<string, unknown>;
|
||||||
|
delete claims.given_name;
|
||||||
|
delete claims.family_name;
|
||||||
|
respondWithToken(idToken(claims));
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.get('/api/auth/google/callback')
|
||||||
|
.query({ code: 'an-auth-code', state })
|
||||||
|
.set('Cookie', cookie);
|
||||||
|
|
||||||
|
const customer = await customerBy('nameless@example.com');
|
||||||
|
expect(customer.first_name).toBeNull();
|
||||||
|
expect(customer.last_name).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the verified address', () => {
|
||||||
|
it('is marked verified, and sends no confirmation email, when Google vouches', async () => {
|
||||||
|
await signUpWith({ email: 'vouched@example.com', email_verified: true });
|
||||||
|
const customer = await customerBy('vouched@example.com');
|
||||||
|
|
||||||
|
expect(customer.email_verified).toBe(true);
|
||||||
|
// The confirmation email exists to prove the customer receives mail at
|
||||||
|
// the address. Google has just proved exactly that, so sending one would
|
||||||
|
// ask them to do a thing that is already done.
|
||||||
|
expect(await verificationTokens(customer.id)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is unverified, and does send one, when Google does not', async () => {
|
||||||
|
await signUpWith({ email: 'unvouched@example.com', email_verified: false });
|
||||||
|
const customer = await customerBy('unvouched@example.com');
|
||||||
|
|
||||||
|
// An unverified assertion is worth nothing, so this account goes through
|
||||||
|
// the ordinary confirmation exactly as a password sign-up would.
|
||||||
|
expect(customer.email_verified).toBe(false);
|
||||||
|
expect(await verificationTokens(customer.id)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reaches the same customer on a second sign-in, not a second account', async () => {
|
||||||
|
await signUpWith({ email: 'returning@example.com' });
|
||||||
|
const first = await customerBy('returning@example.com');
|
||||||
|
|
||||||
|
const second = await signUpWith({ email: 'returning@example.com' });
|
||||||
|
|
||||||
|
// Signed in, and back to the storefront rather than the consent step —
|
||||||
|
// which is shown once, to somebody who has just been created.
|
||||||
|
expect(second.headers.location).toBe('/');
|
||||||
|
expect(await countOf('customers')).toBe(1);
|
||||||
|
expect((await customerBy('returning@example.com')).id).toBe(first.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reaches the same customer even after they change their Google address', async () => {
|
||||||
|
await signUpWith({ email: 'was@example.com' });
|
||||||
|
const original = await customerBy('was@example.com');
|
||||||
|
|
||||||
|
// Matched on the subject, which is the whole reason that column exists. An
|
||||||
|
// email match would have created a second account here — and an address
|
||||||
|
// that had since been reassigned would have handed this one to a stranger.
|
||||||
|
const second = await signUpWith({ email: 'now@example.com' });
|
||||||
|
|
||||||
|
expect(second.headers.location).toBe('/');
|
||||||
|
expect(await countOf('customers')).toBe(1);
|
||||||
|
expect((await customerBy('was@example.com')).id).toBe(original.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still refuses a disabled account, which a sign-up must not route around', async () => {
|
||||||
|
const customerId = await createCustomer('blocked@example.com');
|
||||||
|
await linkGoogle(customerId, SUB);
|
||||||
|
await pool.query(`UPDATE customers SET disabled_at = now() WHERE id = $1`, [customerId]);
|
||||||
|
|
||||||
|
const res = await signUpWith({ email: 'blocked@example.com' });
|
||||||
|
|
||||||
|
// The identity exists, so this takes the sign-in path and is refused
|
||||||
|
// there. No second account is created as a way around it.
|
||||||
|
expect(res.headers.location).toBe('/login?auth=google-failed');
|
||||||
|
expect(await countOf('customers')).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import ErrorFallback from './components/ErrorFallback';
|
|||||||
import DevThrow from './components/DevThrow';
|
import DevThrow from './components/DevThrow';
|
||||||
import Admin from './admin/Admin';
|
import Admin from './admin/Admin';
|
||||||
import AuthRouteModal from './customer/AuthRouteModal';
|
import AuthRouteModal from './customer/AuthRouteModal';
|
||||||
|
import Welcome from './customer/Welcome';
|
||||||
import Account from './customer/Account';
|
import Account from './customer/Account';
|
||||||
import PrivacyPolicy from './customer/PrivacyPolicy';
|
import PrivacyPolicy from './customer/PrivacyPolicy';
|
||||||
import Submit from './intake/Submit';
|
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,
|
// 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
|
// refreshable, and closed by the browser's Back button — while never being
|
||||||
// somewhere with no way out.
|
// 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
|
// Respects the OS-level "reduce motion" accessibility setting by turning off
|
||||||
// antd's transitions. Beyond the accessibility win, animated popups are a
|
// antd's transitions. Beyond the accessibility win, animated popups are a
|
||||||
@@ -200,6 +201,10 @@ function AppRoutes() {
|
|||||||
{modalPath === '/forgot-password' && (
|
{modalPath === '/forgot-password' && (
|
||||||
<ForgotPassword onClose={closeModal} onBackToSignIn={() => goWithinAuth('/login')} />
|
<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' && (
|
{modalPath === '/reset-password' && (
|
||||||
<ResetPassword
|
<ResetPassword
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
|
|||||||
Reference in New Issue
Block a user