The issue calls this the smallest one in the project and the one that makes the rest usable, and that is right: registering a passkey with no way to see or remove it is worse than not offering passkeys at all. Revocation is the row going away. #39 looks a credential up by id on every sign-in, so a deleted one is refused immediately and by construction rather than by a flag something has to remember to check. The delete is scoped to the signed-in customer in the same statement that removes the row, because a credential id is not a secret and the WHERE clause is the only thing making this safe. Reading first and deleting after would leave a window. Both "no such credential" and "not yours" answer 404. The second is the interesting case, and saying so would confirm that some other customer holds that id. The lockout check is written even though it cannot fire. password_hash is NOT NULL, so every customer has a password and removing every passkey still leaves a way in. The issue asks for the check anyway and that is the right call, because it is written against the condition rather than against today's schema — it starts holding on its own the moment the condition changes. #332 is what changes it: social sign-in makes password_hash nullable and creates the first customers with no password, and at that point this branch starts running for real. The list returns name, added and last used, and nothing else. No public key, no credential id, no counter — the customer cannot act on any of them, and the credential id is the one value that identifies an authenticator to anyone holding it. Last used is what actually tells two entries apart when the names are similar: someone about to revoke one needs to know which device they are cutting off, and a creation date does not answer that. The whole ceremony lives in customerApi rather than the component, because it is one operation: options from the server, an attestation from the browser, verification back at the server. A component holding that intermediate state could leave a challenge issued and never answered. Dismissing the browser's prompt rejects, and that is a cancellation rather than a failure. Reporting it as an error would tell a customer something went wrong when they simply changed their mind, so NotAllowedError and AbortError are swallowed and everything else is shown. The server's message on a refused revoke is shown as it arrives too — it says what to do about the last way in, and a generic message would strand the customer on a button that just does not work. The section renders nothing where WebAuthn is unavailable, rather than offering a button that cannot work. Same rule #41 applies to the login form. Verified: tsc clean for src and tests in both workspaces, backend lint at the seven pre-existing warnings with none added, frontend lint clean, 521 unit tests across 36 suites, frontend build green. Not verified: the registration ceremony needs a browser and a real authenticator, which per #41 is a standing limitation of this feature. What CI can prove is the list and revoke endpoints, which are ordinary routes. Closes #40 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
194 lines
7.6 KiB
TypeScript
Executable File
194 lines
7.6 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';
|
|
|
|
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 />
|
|
|
|
<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>
|
|
);
|
|
}
|