fix(privacy): separate analytics consent from email consent (#56)
The previous commit widened the marketing consent sentence to cover the Brevo tracker, so one checkbox carried both purposes. That is the specific pattern GDPR rejects: consent has to be granular, and current EDPB guidance treats bundling tracking consent with subscription consent as invalid because the customer cannot accept one purpose and refuse the other. Quebec's Law 25 s.8.1 is stricter again — profiling technology has to be off until the person switches it on, with no pre-ticked box and no consent inherited from agreeing to something else. Building to both standards was the decision, since the storefront is publicly reachable and anyone can register. So the marketing sentence is restored to exactly what it was, which leaves every existing email consent valid and untouched, and analytics gets its own column, its own sentence, its own checkbox at registration, its own toggle in the account page and its own endpoint. A customer can now hold either, both, or neither, and withdrawing one does not disturb the other. The migration defaults analytics_consent to false, which is both the honest answer — none of the existing customers was ever asked — and what Law 25 requires. Nothing about this change opts anybody in. Two details that are compliance requirements rather than wording preferences. The sentence names Brevo instead of saying "our email provider", because informed consent means the customer can tell who receives their data and a description they cannot act on is not disclosure. And the account toggle is as prominent and as easy to switch off as it is to switch on, because withdrawal has to be as easy as consenting. The analytics endpoint is separate from the marketing one rather than a second field on it, so that a single call cannot change an answer the customer did not touch — the bundling problem moved from the form into the API. The unit tests now assert the two consents stay apart in both directions, including that the marketing sentence still says nothing about tracking, because re-bundling them would otherwise pass silently and is the mistake this project already made once. Verified: backend tsc clean, both lint suites 0 errors with no new warnings, 478 unit tests passing across 33 suites, frontend production build green. Not verified: the migration has not been run against a database, and integration and e2e need a Node this machine does not have active. None of this is legal advice and the wording is worth a lawyer's eye before it ships. Refs #56 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ac3f6e91f5
commit
955049eac9
@@ -0,0 +1,39 @@
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- Consent to the Brevo tracker, separate from marketing_consent (#56).
|
||||
--
|
||||
-- Separate because GDPR requires consent to be granular: email marketing
|
||||
-- and behavioural tracking are two purposes with two recipients, and
|
||||
-- current EDPB guidance treats bundling tracking consent with subscription
|
||||
-- consent as invalid. Quebec's Law 25 s.8.1 goes further and requires
|
||||
-- profiling technology to be off until the person switches it on.
|
||||
--
|
||||
-- DEFAULT FALSE is the part that must not be changed. Every existing
|
||||
-- customer arrives at false, which is both the honest answer — none of them
|
||||
-- were ever asked — and what Law 25 requires. A default of true would
|
||||
-- silently opt in the entire customer base to something nobody agreed to.
|
||||
ALTER TABLE customers
|
||||
ADD COLUMN IF NOT EXISTS analytics_consent BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- When they agreed, and to exactly what wording. Same shape and same
|
||||
-- reasoning as the marketing_consent pair: the stored sentence is what
|
||||
-- makes the record say what the customer actually saw, so re-wording the
|
||||
-- consent later cannot retroactively broaden anyone's.
|
||||
--
|
||||
-- Both nullable: a customer who has never consented has no date and no
|
||||
-- text, and inventing either would be a false record of consent.
|
||||
ALTER TABLE customers
|
||||
ADD COLUMN IF NOT EXISTS analytics_consent_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE customers
|
||||
ADD COLUMN IF NOT EXISTS analytics_consent_text TEXT;
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`
|
||||
ALTER TABLE customers DROP COLUMN IF EXISTS analytics_consent_text;
|
||||
ALTER TABLE customers DROP COLUMN IF EXISTS analytics_consent_at;
|
||||
ALTER TABLE customers DROP COLUMN IF EXISTS analytics_consent;
|
||||
`);
|
||||
};
|
||||
@@ -8,7 +8,7 @@ import { sendMail } from '../mailer';
|
||||
import { renderTemplate, greeting, formatDuration } from '../emailTemplates';
|
||||
import { getSettings } from '../adminSettings';
|
||||
import { loadStoredTemplate } from './adminEmailTemplates';
|
||||
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
|
||||
import { ANALYTICS_CONSENT_TEXT, MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
|
||||
import { ItemStatus } from '../types';
|
||||
import { FAVORITE_ALERTS_CONSENT_TEXT } from '../favoriteAlerts';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
@@ -109,6 +109,12 @@ interface CustomerRecord extends CustomerRow {
|
||||
unsubscribe_token: string;
|
||||
marketing_consent_at: Date | null;
|
||||
marketing_consent_text: string | null;
|
||||
// A separate purpose from marketing, so a separate column, timestamp and
|
||||
// stored wording rather than a second meaning layered onto the pair above.
|
||||
// False for every customer the migration touched: none of them was asked.
|
||||
analytics_consent: boolean;
|
||||
analytics_consent_at: Date | null;
|
||||
analytics_consent_text: string | null;
|
||||
favorite_alerts_at: Date | null;
|
||||
favorite_alerts_text: string | null;
|
||||
}
|
||||
@@ -177,19 +183,21 @@ interface CustomerOrderRow {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this customer has agreed to the *current* consent wording, which is
|
||||
* Whether this customer has agreed to the *current* analytics wording, which is
|
||||
* the only thing that authorises the Brevo tracker (#56).
|
||||
*
|
||||
* Not the same question as `marketing_consent`. That flag says the customer
|
||||
* agreed to something; `marketing_consent_text` says what. The sentence was
|
||||
* widened to cover analytics, so a customer who consented to the older wording
|
||||
* agreed to emails and nothing more — they keep receiving email and are not
|
||||
* tracked until they re-consent to the current text through the account page.
|
||||
* Reads the analytics columns and nothing else. It must never consult
|
||||
* `marketing_consent`: those are two purposes with two recipients, and GDPR
|
||||
* requires consent to be granular — a customer who wants the emails and not the
|
||||
* tracking has to be able to have exactly that. Quebec's Law 25 s.8.1 is
|
||||
* stricter again and requires this to be off until the customer switches it on,
|
||||
* which is why the column defaults to false.
|
||||
*
|
||||
* Comparing the stored string is the point rather than an implementation
|
||||
* detail: it is why the wording is recorded per customer at all. A boolean on
|
||||
* its own could not tell these two populations apart, and assuming they are the
|
||||
* same is exactly the retroactive widening this avoids.
|
||||
* detail. The flag says a customer agreed to something; the text says what. If
|
||||
* the sentence is ever re-worded, everyone who agreed to the previous one stops
|
||||
* qualifying and is asked again, rather than being silently carried into a
|
||||
* broader agreement they never saw.
|
||||
*
|
||||
* Computed here rather than stored, so it can never drift from the constant.
|
||||
*
|
||||
@@ -198,9 +206,9 @@ interface CustomerOrderRow {
|
||||
* nothing else, and a test should not have to invent a customer to state it.
|
||||
*/
|
||||
export function analyticsConsent(
|
||||
c: Pick<CustomerRecord, 'marketing_consent' | 'marketing_consent_text'>
|
||||
c: Pick<CustomerRecord, 'analytics_consent' | 'analytics_consent_text'>
|
||||
): boolean {
|
||||
return c.marketing_consent && c.marketing_consent_text === MARKETING_CONSENT_TEXT;
|
||||
return c.analytics_consent && c.analytics_consent_text === ANALYTICS_CONSENT_TEXT;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,8 +224,8 @@ function publicCustomer(c: CustomerRecord) {
|
||||
last_name: c.last_name,
|
||||
email_verified: c.email_verified,
|
||||
marketing_consent: c.marketing_consent,
|
||||
// Deliberately separate from marketing_consent: the two disagree for every
|
||||
// customer who consented before the wording was widened.
|
||||
// Its own purpose, its own answer. A customer can have either, both, or
|
||||
// neither, and the UI has to be able to show that honestly.
|
||||
analytics_consent: analyticsConsent(c),
|
||||
favorite_alerts: c.favorite_alerts,
|
||||
created_at: c.created_at
|
||||
@@ -225,7 +233,7 @@ function publicCustomer(c: CustomerRecord) {
|
||||
}
|
||||
|
||||
router.post('/register', asyncRoute(async (req: Request, res: Response) => {
|
||||
const { email, password, firstName, lastName, marketingConsent } = req.body;
|
||||
const { email, password, firstName, lastName, marketingConsent, analyticsConsent: analyticsConsentGiven } = req.body;
|
||||
if (!email || !isValidEmail(String(email)) || !password || String(password).length < 8) {
|
||||
return res.status(400).json({ error: 'valid email and password (min 8 chars) required' });
|
||||
}
|
||||
@@ -246,13 +254,19 @@ router.post('/register', asyncRoute(async (req: Request, res: Response) => {
|
||||
const passwordHash = await bcrypt.hash(password, PASSWORD_HASH_ROUNDS);
|
||||
const unsubscribeToken = crypto.randomBytes(16).toString('hex');
|
||||
const consent = !!marketingConsent;
|
||||
// Read independently of marketingConsent, and absent means false. A client
|
||||
// that sends neither, or only the marketing one, registers a customer who is
|
||||
// not tracked — which is the right answer for a request that never carried an
|
||||
// analytics answer at all.
|
||||
const analytics = !!analyticsConsentGiven;
|
||||
|
||||
const { rows } = await pool.query<CustomerRecord>(
|
||||
`INSERT INTO customers (email, password_hash, first_name, last_name, marketing_consent, marketing_consent_at, marketing_consent_text, unsubscribe_token)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
`INSERT INTO customers (email, password_hash, first_name, last_name, marketing_consent, marketing_consent_at, marketing_consent_text, analytics_consent, analytics_consent_at, analytics_consent_text, unsubscribe_token)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
|
||||
[
|
||||
normalizedEmail, passwordHash, first, last,
|
||||
consent, consent ? new Date() : null, consent ? MARKETING_CONSENT_TEXT : null,
|
||||
analytics, analytics ? new Date() : null, analytics ? ANALYTICS_CONSENT_TEXT : null,
|
||||
unsubscribeToken
|
||||
]
|
||||
);
|
||||
@@ -593,6 +607,28 @@ router.post('/me/consent', requireCustomer, asyncRoute(async (req: Request, res:
|
||||
res.status(204).end();
|
||||
}));
|
||||
|
||||
/**
|
||||
* Analytics consent, on its own route rather than as a second field on
|
||||
* `/me/consent` (#56).
|
||||
*
|
||||
* Separate because the two are separate purposes and must be separately
|
||||
* refusable. One endpoint taking both would make it possible for a single call
|
||||
* to change an answer the customer did not touch — which is the bundling
|
||||
* problem again, moved from the form into the API.
|
||||
*
|
||||
* Withdrawal writes the reason rather than the consent sentence, so the stored
|
||||
* text never claims agreement to something that was declined. Same convention
|
||||
* as marketing consent above.
|
||||
*/
|
||||
router.post('/me/analytics-consent', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const consent = !!req.body.analyticsConsent;
|
||||
await pool.query(
|
||||
`UPDATE customers SET analytics_consent = $1, analytics_consent_at = now(), analytics_consent_text = $2 WHERE id = $3`,
|
||||
[consent, consent ? ANALYTICS_CONSENT_TEXT : 'Withdrew analytics consent via account settings', req.customerId]
|
||||
);
|
||||
res.status(204).end();
|
||||
}));
|
||||
|
||||
router.get('/me/orders', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query<CustomerOrderRow>(
|
||||
`SELECT o.id, o.processor, o.amount_cents, o.status, o.created_at, i.name AS item_name
|
||||
|
||||
+27
-12
@@ -72,22 +72,37 @@ export function tagColorFor(name: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Widened for #56 to cover analytics as well as email.
|
||||
* Email marketing only. Deliberately says nothing about tracking.
|
||||
*
|
||||
* The previous wording named only emails. Gating the Brevo tracker on
|
||||
* `marketing_consent` while that sentence was the thing customers agreed to
|
||||
* would have treated "email me about new items" as authorisation to send their
|
||||
* browsing to a third party, which it plainly did not say.
|
||||
* This was briefly widened during #56 to cover analytics as well, and that was
|
||||
* wrong: GDPR requires consent to be granular, and current EDPB guidance treats
|
||||
* bundling tracking consent with subscription consent as invalid because the
|
||||
* customer cannot accept one purpose and refuse the other. Quebec's Law 25 is
|
||||
* stricter still. Analytics has its own sentence and its own column below.
|
||||
*
|
||||
* Changing the sentence does not retroactively widen anyone's consent, because
|
||||
* `marketing_consent_text` records what each customer was actually shown.
|
||||
* Everyone who agreed to the old wording keeps their email consent and is not
|
||||
* tracked; see `analyticsConsent` in routes/customers.ts, which is what the
|
||||
* tracker is gated on. That is the whole reason this string is stored per
|
||||
* customer rather than assumed.
|
||||
* Left exactly as it was so that every existing consent record stays valid and
|
||||
* untouched — nobody has to be re-asked for something they already agreed to.
|
||||
*/
|
||||
export const MARKETING_CONSENT_TEXT =
|
||||
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs, and I agree that what I browse and buy here may be shared with our email provider to help choose what they contain. I can unsubscribe at any time.';
|
||||
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
||||
|
||||
/**
|
||||
* Consent to the Brevo tracker (#56). Separate from marketing consent, and
|
||||
* separately refusable, because they are two purposes with two recipients.
|
||||
*
|
||||
* Names Brevo rather than saying "our email provider": informed consent means
|
||||
* the customer can tell who receives their data, and a description they cannot
|
||||
* act on is not disclosure. Says what is shared and why, states that it is
|
||||
* optional and independent of the emails, and states that it can be turned off
|
||||
* — withdrawal has to be as easy as giving it.
|
||||
*
|
||||
* Stored verbatim in `analytics_consent_text` for the same reason the marketing
|
||||
* sentence is: a record of consent that does not say what was consented to
|
||||
* cannot be audited, and re-wording this later must not silently broaden
|
||||
* anybody's agreement.
|
||||
*/
|
||||
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.';
|
||||
|
||||
/**
|
||||
* Strips trailing slashes so a base URL can be joined with a stored path.
|
||||
|
||||
@@ -1,63 +1,93 @@
|
||||
import { analyticsConsent } from '../../src/routes/customers';
|
||||
import { MARKETING_CONSENT_TEXT } from '../../src/utils';
|
||||
import { ANALYTICS_CONSENT_TEXT, MARKETING_CONSENT_TEXT } from '../../src/utils';
|
||||
|
||||
/**
|
||||
* The rule this file exists for: agreeing to the *old* consent wording does not
|
||||
* authorise the Brevo tracker (#56).
|
||||
* The rule this file exists for: the Brevo tracker runs only for a customer who
|
||||
* agreed to the *analytics* sentence, and marketing consent has nothing to do
|
||||
* with it (#56).
|
||||
*
|
||||
* The sentence customers agree to was widened to mention analytics. Everyone
|
||||
* who consented before that agreed to a sentence about email and nothing else,
|
||||
* and `marketing_consent` alone cannot tell the two populations apart — which
|
||||
* is exactly why the wording is stored per customer. Getting this wrong would
|
||||
* silently track people who never agreed to it, and would do so invisibly,
|
||||
* because the flag they set really is `true`.
|
||||
* Both halves matter and both are compliance requirements rather than taste.
|
||||
* GDPR requires consent to be granular — email and tracking are separate
|
||||
* purposes with separate recipients, and current EDPB guidance treats bundling
|
||||
* them as invalid. Quebec's Law 25 s.8.1 requires profiling to be off until the
|
||||
* person switches it on, which is why the column defaults to false.
|
||||
*
|
||||
* The failure mode is silent: reading the wrong flag tracks people whose
|
||||
* marketing consent really is `true` and who never agreed to any of this.
|
||||
*/
|
||||
describe('analyticsConsent', () => {
|
||||
const OLD_WORDING =
|
||||
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
||||
|
||||
it('is true for a customer who agreed to the current wording', () => {
|
||||
it('is true for a customer who agreed to the current analytics wording', () => {
|
||||
expect(
|
||||
analyticsConsent({ marketing_consent: true, marketing_consent_text: MARKETING_CONSENT_TEXT })
|
||||
analyticsConsent({ analytics_consent: true, analytics_consent_text: ANALYTICS_CONSENT_TEXT })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a customer who agreed to the previous, email-only wording', () => {
|
||||
// The case the whole mechanism exists for. They consented, and their
|
||||
// consent does not cover this.
|
||||
it('is false when analytics consent was never given', () => {
|
||||
// Where the migration leaves every existing customer, and where Law 25
|
||||
// requires a new one to start.
|
||||
expect(
|
||||
analyticsConsent({ marketing_consent: true, marketing_consent_text: OLD_WORDING })
|
||||
analyticsConsent({ analytics_consent: false, analytics_consent_text: null })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when consent was never given, whatever text is stored', () => {
|
||||
it('is false when the stored wording is not the current one', () => {
|
||||
// Re-wording the sentence re-asks rather than assuming. Anyone who agreed
|
||||
// to a previous version stops qualifying until they agree to this one.
|
||||
expect(
|
||||
analyticsConsent({ marketing_consent: false, marketing_consent_text: MARKETING_CONSENT_TEXT })
|
||||
analyticsConsent({ analytics_consent: true, analytics_consent_text: 'some older sentence' })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when no wording was recorded at all', () => {
|
||||
// Withdrawal writes a sentinel rather than the consent text, and older rows
|
||||
// may predate the column being populated. Neither is agreement.
|
||||
it('is false when the flag is set but no wording was recorded', () => {
|
||||
expect(
|
||||
analyticsConsent({ marketing_consent: true, marketing_consent_text: null })
|
||||
analyticsConsent({ analytics_consent: true, analytics_consent_text: null })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not accept a near-miss, so a reworded sentence re-asks rather than assumes', () => {
|
||||
it('is false when withdrawal wrote its reason rather than the consent text', () => {
|
||||
expect(
|
||||
analyticsConsent({
|
||||
marketing_consent: true,
|
||||
marketing_consent_text: `${MARKETING_CONSENT_TEXT} `
|
||||
analytics_consent: false,
|
||||
analytics_consent_text: 'Withdrew analytics consent via account settings'
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('guards the wording itself: the current text must mention what is shared', () => {
|
||||
// Not a tautology — it fails if someone narrows the sentence back to email
|
||||
// while leaving the tracker gated on it, which would put this project back
|
||||
// in the position #56 was filed to get it out of.
|
||||
expect(MARKETING_CONSENT_TEXT).toContain('browse');
|
||||
expect(MARKETING_CONSENT_TEXT).not.toBe(OLD_WORDING);
|
||||
it('does not accept a near-miss', () => {
|
||||
expect(
|
||||
analyticsConsent({
|
||||
analytics_consent: true,
|
||||
analytics_consent_text: `${ANALYTICS_CONSENT_TEXT} `
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
describe('the two consents stay separate', () => {
|
||||
// Not a tautology. These fail if anyone re-bundles the purposes — either by
|
||||
// folding tracking back into the marketing sentence, or by pointing this
|
||||
// function at the marketing columns — which is the specific pattern GDPR
|
||||
// and Law 25 both reject, and which this project shipped once already
|
||||
// before it was caught.
|
||||
it('marketing consent alone does not authorise tracking', () => {
|
||||
expect(
|
||||
analyticsConsent({ analytics_consent: false, analytics_consent_text: MARKETING_CONSENT_TEXT })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('the marketing sentence says nothing about tracking', () => {
|
||||
expect(MARKETING_CONSENT_TEXT).not.toContain('browse');
|
||||
expect(MARKETING_CONSENT_TEXT).not.toContain('Brevo');
|
||||
});
|
||||
|
||||
it('the analytics sentence names the recipient and says it is optional', () => {
|
||||
// Informed consent means the customer can tell who receives their data;
|
||||
// "our email provider" is not something they can act on.
|
||||
expect(ANALYTICS_CONSENT_TEXT).toContain('Brevo');
|
||||
expect(ANALYTICS_CONSENT_TEXT).toContain('optional');
|
||||
});
|
||||
|
||||
it('the two sentences are not the same string', () => {
|
||||
expect(ANALYTICS_CONSENT_TEXT).not.toBe(MARKETING_CONSENT_TEXT);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import message from 'antd/es/message';
|
||||
import Space from 'antd/es/space';
|
||||
import Divider from 'antd/es/divider';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { updateConsent, exportMyData, deleteMyAccount, resendVerificationEmail } from './customerApi';
|
||||
import { updateConsent, updateAnalyticsConsent, exportMyData, deleteMyAccount, resendVerificationEmail } from './customerApi';
|
||||
import { setFavoriteAlerts } from './favoritesApi';
|
||||
import { useCustomerAuth } from './CustomerAuthContext';
|
||||
import AccountDetails from './AccountDetails';
|
||||
@@ -62,6 +62,14 @@ export default function Account({ onClose }: Props) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
// Its own handler and its own endpoint. Withdrawing this must not disturb the
|
||||
// email consent, and must be exactly as easy as giving it (#56).
|
||||
async function handleAnalyticsConsentToggle(checked: boolean) {
|
||||
await updateAnalyticsConsent(checked);
|
||||
message.success(checked ? 'Thanks — this helps us send you relevant emails' : 'Turned off — we will stop sharing your activity');
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await logout();
|
||||
@@ -146,6 +154,24 @@ export default function Account({ onClose }: Props) {
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Analytics, and a third independent consent (#56). Named as sharing
|
||||
with Brevo rather than as "analytics", because the customer cannot
|
||||
weigh a decision described in a word that hides who receives the
|
||||
data. The subtext restates that it is optional, since this is the
|
||||
control that has to make withdrawal as easy as consenting. */}
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Space align="center">
|
||||
<Switch checked={customer.analytics_consent} onChange={handleAnalyticsConsentToggle} />
|
||||
<Text>Share what I browse and buy with Brevo, to make emails relevant</Text>
|
||||
</Space>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Optional, and independent of the emails above. Turning it off stops any further
|
||||
activity being shared.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
<Space wrap>
|
||||
{/* Order history is a page of its own now. The link stays here because
|
||||
|
||||
@@ -20,13 +20,17 @@ export type AuthMode = 'register' | 'login';
|
||||
// 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.
|
||||
//
|
||||
// Widened for #56 so it covers the Brevo tracker as well as email. The tracker
|
||||
// is gated on whether a customer's *stored* copy of this sentence matches the
|
||||
// current one, so changing it here does not retroactively widen the consent of
|
||||
// anyone who agreed to the previous wording.
|
||||
export const MARKETING_CONSENT_TEXT =
|
||||
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs, and I agree that what I browse and buy here may be shared with our email provider to help choose what they contain. I can unsubscribe at any time.';
|
||||
'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;
|
||||
@@ -81,7 +85,7 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce
|
||||
layout="vertical"
|
||||
onFinish={(values) =>
|
||||
submit(() =>
|
||||
registerCustomer(values.email, values.password, values.firstName, values.lastName, !!values.marketingConsent)
|
||||
registerCustomer(values.email, values.password, values.firstName, values.lastName, !!values.marketingConsent, !!values.analyticsConsent)
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -112,6 +116,14 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce
|
||||
<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>
|
||||
|
||||
@@ -30,18 +30,25 @@ export default function PrivacyPolicy() {
|
||||
|
||||
<Title level={4}>Analytics and tracking</Title>
|
||||
<Paragraph>
|
||||
If — and only if — you have opted in to marketing, we share what you browse and buy on
|
||||
this site with Brevo, our email provider, so the emails we send you are about things you
|
||||
are actually interested in. That covers the pages you visit here, items you add to your
|
||||
cart or favorite, and completed orders, linked to your email address.
|
||||
If — and only if — you have separately opted in to it, we share what you browse and buy on
|
||||
this site with <strong>Brevo</strong>, the service that sends our emails, so that what
|
||||
they contain is relevant to you. That covers the pages you visit here, items you add to
|
||||
your cart or favorite, and completed orders, linked to your email address.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
If you have not opted in, this does not happen: nothing is loaded, nothing is sent, and no
|
||||
tracking cookie is set. The same is true if you are not signed in — we do not track
|
||||
visitors who do not have an account. Withdrawing consent from your account page stops any
|
||||
further activity being sent. Note that anything already shared with Brevo before you
|
||||
withdrew remains with them, and a tracking cookie set earlier in your visit stays in your
|
||||
browser until you close the tab or clear it.
|
||||
<strong>This is a separate choice from receiving the emails themselves.</strong> You can
|
||||
have the emails without it, or turn it off and keep receiving them. It is off unless you
|
||||
switch it on — we never enable it by default, and never as a side effect of subscribing to
|
||||
anything else. Both choices live in your account settings, and turning either off is as
|
||||
easy as turning it on.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
If you have not opted in, none of this happens: no tracking script is loaded, nothing is
|
||||
sent, and no tracking cookie is set. The same is true if you are not signed in — we do not
|
||||
track visitors who do not have an account. Turning it off stops any further activity being
|
||||
shared. Two limits worth being plain about: anything already shared with Brevo before you
|
||||
turned it off remains with them, and a tracking cookie set earlier in your visit stays in
|
||||
your browser until you close the tab or clear it.
|
||||
</Paragraph>
|
||||
|
||||
<Title level={4}>Your rights</Title>
|
||||
|
||||
@@ -43,12 +43,16 @@ export function registerCustomer(
|
||||
password: string,
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
marketingConsent: boolean
|
||||
marketingConsent: boolean,
|
||||
// Separate argument rather than folded into the one above: they are separate
|
||||
// consents and the caller has to be able to send one true and the other
|
||||
// false. The server treats an absent value as false (#56).
|
||||
analyticsConsent: boolean
|
||||
): Promise<Customer> {
|
||||
return fetch('/api/customers/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, firstName, lastName, marketingConsent })
|
||||
body: JSON.stringify({ email, password, firstName, lastName, marketingConsent, analyticsConsent })
|
||||
}).then(res => handle<Customer>(res));
|
||||
}
|
||||
|
||||
@@ -93,6 +97,21 @@ export function updateConsent(marketingConsent: boolean): Promise<void> {
|
||||
}).then(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Its own endpoint, not a second field on updateConsent (#56).
|
||||
*
|
||||
* Withdrawal has to be as easy as giving consent, and it has to be possible to
|
||||
* withdraw one without touching the other. A combined call would make it easy
|
||||
* to send a stale value for the answer the customer did not change.
|
||||
*/
|
||||
export function updateAnalyticsConsent(analyticsConsent: boolean): Promise<void> {
|
||||
return fetch('/api/customers/me/analytics-consent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ analyticsConsent })
|
||||
}).then(() => undefined);
|
||||
}
|
||||
|
||||
export function fetchMyOrders(): Promise<OrderHistoryItem[]> {
|
||||
return fetch('/api/customers/me/orders').then(res => handle<OrderHistoryItem[]>(res));
|
||||
}
|
||||
|
||||
@@ -27,14 +27,19 @@ test.describe('Customer accounts', () => {
|
||||
// customer actually saw. Three different wordings were in circulation
|
||||
// before the sign-in form was shared between the routes and the cart
|
||||
// prompt, and none of them matched what was stored.
|
||||
// Widened in #56 to cover the Brevo tracker as well as email. The literal is
|
||||
// repeated here rather than imported on purpose: importing it from the app
|
||||
// would make this assert that a constant equals itself, and the drift it
|
||||
// guards against is exactly the rendered label parting from the stored
|
||||
// string.
|
||||
const consent =
|
||||
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs, and I agree that what I browse and buy here may be shared with our email provider to help choose what they contain. I can unsubscribe at any time.';
|
||||
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
||||
await expect(authModal.registerDialog).toContainText(consent);
|
||||
|
||||
// The analytics consent is a second, separate sentence and a second
|
||||
// checkbox (#56). Asserted here for the same reason as the one above — it
|
||||
// is stored verbatim, so the rendered label parting from the stored string
|
||||
// defeats the record — and because the two being separate is the thing
|
||||
// that makes the consent granular. Folding them back into one control
|
||||
// would still pass the assertion above and would still be wrong.
|
||||
const analytics =
|
||||
'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.';
|
||||
await expect(authModal.registerDialog).toContainText(analytics);
|
||||
});
|
||||
|
||||
// Drives the form rather than taking the `customer` fixture: this test is
|
||||
|
||||
Reference in New Issue
Block a user