fix(privacy): separate analytics consent from email consent (#56)
Linting / lint (pull_request) Successful in 3m0s
SonarQube Analysis / sonarqube (pull_request) Failing after 26m24s

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:
synAdmin
2026-09-08 11:58:20 -05:00
co-authored by Claude Opus 5
parent ac3f6e91f5
commit 955049eac9
9 changed files with 277 additions and 88 deletions
+27 -1
View File
@@ -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
+19 -7
View File
@@ -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>
+17 -10
View File
@@ -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>
+21 -2
View File
@@ -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));
}