Files
redefined-designs/frontend/src/customer/Account.tsx
T
synAdminandClaude Opus 5 44df0bd2d9
Linting / lint (pull_request) Successful in 3m52s
SonarQube Analysis / sonarqube (pull_request) Failing after 29m7s
feat(auth): link a Google identity to an account that already exists (#343)
The smallest change in this feature and the one to read most carefully. It is the point where somebody who has proved nothing to this shop is handed an account belonging to somebody who did.

The rule is one line at the top of linkIdentity.ts: link only when Google asserts the address is verified, and refuse otherwise. Everything below it is bookkeeping.

That is defensible for Google specifically, and the reasoning is worth stating rather than assuming. Google asserting the address means whoever completed the sign-in demonstrably controls the mailbox, and that mailbox is already the root of trust for every other route into the account — it is where a password reset goes, and following a reset link takes the account over completely. So linking on it grants nothing that was not already reachable, and it spares the customer who came to Google precisely because they forgot their password.

Never on an unverified address. That is not a weaker version of the same thing; it is an account takeover with extra steps, because the assertion would be one nobody checked. There is a test for the specific trap: the string "false" is truthy, and if that check ever becomes a truthiness test then every unverified Google account links to whatever account holds its address.

The order matters and is an order rather than a set of independent checks. The identity lookup runs first and nothing else is consulted when it matches, which is why an identity that has signed in before keeps working after the address changes on either side. There is a test where a second customer has since taken the address the Google account reports, and the sign-in correctly reaches the first.

Linking to a disabled account is refused, and the reason is not obvious. Linking and then refusing the session would leave the identity attached, so the next attempt would take the sign-in path instead — turning a disabled account into one that is merely inconvenient to reach.

The refusal gets its own destination rather than the generic failure. It is the one refusal in this flow a customer can act on: they have an account and simply cannot reach it this way, so the login form now says to use the password they already have. That reveals nothing, because they arrived holding a Google account for that address — being told the address has an account here tells them only about themselves.

Deciding this in newCustomer.ts, where the unique constraint already fires, was the shape to avoid. An account must never be handed over as a side effect of an INSERT failing, so that module reports the address is taken and stops, and the policy lives somewhere it can be read on its own.

Automatic linking is defensible but it is not obvious, so the account page now shows it. A customer who signed up with a password and later used Google has had two credentials joined without being asked, and a silent link is indistinguishable from a bug when they later wonder why the password is no longer needed. It sits beside the passkeys for the reason that list exists at all: a customer cannot manage credentials they cannot see. The endpoint never returns the provider subject, which is the same reasoning that keeps credential ids out of the passkey list.

No unlinking. Removing the only way into an account is the question #344 settles, and offering that button before the check runs would be the fastest possible way to lock somebody out of their own orders.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:18:05 -05:00

197 lines
7.7 KiB
TypeScript
Executable File

import { useEffect, useState } from 'react';
import Typography from 'antd/es/typography';
import Switch from 'antd/es/switch';
import Button from 'antd/es/button';
import Modal from 'antd/es/modal';
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, updateAnalyticsConsent, exportMyData, deleteMyAccount, resendVerificationEmail } from './customerApi';
import { setFavoriteAlerts } from './favoritesApi';
import { useCustomerAuth } from './CustomerAuthContext';
import AccountDetails from './AccountDetails';
import Passkeys from './Passkeys';
import ConnectedAccounts from './ConnectedAccounts';
const { Text } = Typography;
type Props = Readonly<{
// Supplied by the route, which decides where closing lands: back to the page
// the customer came from, or to the storefront when they arrived directly.
onClose: () => void;
}>;
export default function Account({ onClose }: Props) {
const { customer, loading, refresh, logout } = useCustomerAuth();
const navigate = useNavigate();
const [resending, setResending] = useState(false);
useEffect(() => {
if (!loading && !customer) navigate('/login');
}, [loading, customer, navigate]);
if (!customer) return null;
async function handleResendVerification() {
setResending(true);
try {
await resendVerificationEmail();
message.success('Sent. Check your inbox, and your spam folder.');
} catch (err) {
// Shown as it arrives: the rate limit's message says the mail probably
// did send and where to look, which a generic failure would throw away.
message.error((err as Error).message);
} finally {
setResending(false);
}
}
async function handleFavoriteAlertsToggle(checked: boolean) {
try {
await setFavoriteAlerts(checked);
} catch (err) {
message.error((err as Error).message);
return;
}
refresh();
message.success(checked ? 'We will email you when a favorite sells' : 'Turned off');
}
async function handleConsentToggle(checked: boolean) {
await updateConsent(checked);
message.success(checked ? 'Subscribed to emails' : 'Unsubscribed from emails');
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();
} catch (err) {
message.error(`Couldn't log out — ${(err as Error).message}`);
return;
}
// replace, so Back doesn't return to the account page — which would only
// bounce to /login now that the session is gone.
navigate('/', { replace: true });
}
function handleDelete() {
Modal.confirm({
title: 'Delete your account?',
content: 'This permanently removes your account and personal data. Your past orders are kept for accounting purposes but disconnected from your identity. This cannot be undone.',
okText: 'Delete my account',
okButtonProps: { danger: true },
onOk: async () => {
await deleteMyAccount();
// The storefront is rendered behind this modal, so without clearing the
// session it goes on showing "My Account" and hiding Sign up for an
// account that no longer exists — visibly stale, not merely stale in
// state. replace, so Back cannot return to /account and bounce to
// /login.
refresh();
message.success('Account deleted');
navigate('/', { replace: true });
}
});
}
return (
<Modal
open
// The account view is a place a customer can be sent by an email link or
// a bookmark, so it is titled and closable rather than relying on the
// page behind it to say where they are.
title="My Account"
onCancel={onClose}
footer={null}
width={700}
// The view holds profile, two consents, order history, and the account
// controls, which is taller than a phone. Capping the body and letting it
// scroll keeps the title and close control in reach instead of pushing
// them off-screen.
style={{ maxWidth: 'calc(100vw - 32px)', top: 24 }}
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}
destroyOnHidden
>
<div>
<Text>{customer.email}</Text>
{/* The button only exists while there is something to verify. Offering
it on a verified account would be a control whose only outcome is a
refusal. */}
{!customer.email_verified && (
<div style={{ marginTop: 8 }}>
<Text type="warning">Email not verified check your inbox for a verification link.</Text>
<div style={{ marginTop: 8 }}>
<Button size="small" loading={resending} onClick={handleResendVerification}>
Send it again
</Button>
</div>
</div>
)}
<Divider />
<AccountDetails customer={customer} onChanged={refresh} />
<Divider />
<Space align="center">
<Switch aria-label="Receive emails about new items" checked={customer.marketing_consent} onChange={handleConsentToggle} />
<Text>Receive emails about new items</Text>
</Space>
{/* A separate consent from marketing above, and shown separately so a
customer can hold one without the other. */}
<div style={{ marginTop: 12 }}>
<Space align="center">
<Switch aria-label="Email me when an item I favorited is sold" checked={customer.favorite_alerts} onChange={handleFavoriteAlertsToggle} />
<Text>Email me when an item I favorited is sold</Text>
</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 aria-label="Share what I browse and buy with Brevo" 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 />
{/* Renders nothing where WebAuthn is unavailable, so a browser that
cannot do this is not offered a button that fails (#40). */}
<Passkeys />
<ConnectedAccounts />
<Divider />
<Space wrap>
{/* Order history is a page of its own now. The link stays here because
this is where a customer looks for it. */}
<Button onClick={() => navigate('/orders')}>View order history</Button>
<Button onClick={exportMyData}>Download my data</Button>
<Button onClick={handleLogout}>Log out</Button>
<Button danger onClick={handleDelete}>Delete my account</Button>
</Space>
</div>
</Modal>
);
}