feat(admin): move a customer's account to an address they can reach (#337)
Linting / lint (pull_request) Successful in 3m37s
SonarQube Analysis / sonarqube (pull_request) Failing after 35m39s

The third step of the only recovery route a customer who has lost their mailbox has. The first two are contacting the shop and being verified against order history. The third had no implementation, so the answer was a hand-written database edit that left no record of who did it or why.

The thing to say plainly, because everything here follows from it: this operation and an account takeover are the same operation. They differ only in whether the verification was sound, and nothing in the software can check that. What the software can do is make the change recorded, announced, and complete in its effects.

Recorded. The endpoint refuses without a written reason, and the reason is stored against the account. That row is the only thing that tells a genuine recovery from a takeover afterwards, which is why a hand edit was never acceptable and why the field is required by the server rather than merely collected by the form. It is never shown to the customer: it is a note about how somebody was verified and can name things the customer should not be handed back.

There is no column for who did it. Admin access is one shared gate secret in front of a single operator, so such a column could only ever hold a constant, and a constant dressed up as an identity is worse than an honest absence.

Announced, to the address being replaced. If the recovery was sound that reaches nobody and costs nothing. If it was not, it reaches the real owner, who is the only person in the world who can say so, and that is the only reason this endpoint is safe to have at all. Its own template rather than the self-service one, because that copy says to contact us if you did not make this change, and here somebody already did — the sentence would be addressed to the customer who just did the thing it asks for, while the person who needs to act on it did nothing.

The new address is marked unverified and sent a confirmation link. Somebody reading an address out over the phone has not demonstrated they can receive mail at it, and that is the commonest way this goes wrong harmlessly.

Complete in its effects. The move signs the customer out everywhere, removes every passkey, and cancels reset links already sent. That is the conclusion #42 reached for password reset, and it applies here with more force: somebody the system cannot identify asked for this change, so a session or a credential surviving it is one the new owner cannot see and cannot revoke, and a reset link sitting in the mailbox being taken away would let whoever still reads it take the account straight back.

The password is left alone. What the customer lost was the mailbox, so demanding a new one adds a step for no gain.

The verification-email helper moved out of the customers route into its own module, for the reason session creation moved out for passkeys: two implementations that agree today are two that can be changed one at a time, and the one that gets forgotten is whichever the manual testing does not exercise. This path runs perhaps once a year, so it is exactly the one that would rot.

The admin drawer gains the action next to the address rather than among the account controls, because it is a thing done to that field by someone already looking at it. It leads with the warning instead of burying it. The history of moves sits on the same drawer and renders nothing at all for the overwhelming majority of customers, who have never been moved.

Verified: backend tsc clean for src and tests, 526 unit tests pass, lint clean apart from warnings that predate this branch; frontend tsc, lint and build clean. The integration suite needs a database this machine has no Docker for. It also cannot be proven by CI right now — run 917 has been hung since it started and 24 runs are queued behind it, which is the same hang #154 identifies as the source of the leftover Postgres containers.

Closes #337

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
synAdmin
2026-09-09 16:36:34 -05:00
co-authored by Claude Opus 5
parent 20554a4f1d
commit 90e372d6bd
15 changed files with 900 additions and 57 deletions
+121
View File
@@ -0,0 +1,121 @@
import { useState } from 'react';
import Modal from 'antd/es/modal';
import Form from 'antd/es/form';
import Input from 'antd/es/input';
import Alert from 'antd/es/alert';
import Button from 'antd/es/button';
import Typography from 'antd/es/typography';
import message from 'antd/es/message';
import { changeCustomerEmail } from './adminCustomersApi';
const { Paragraph, Text } = Typography;
type Props = Readonly<{
customerId: number;
currentEmail: string;
open: boolean;
onClose: () => void;
/** Called after a successful change, so the drawer and the table can refresh. */
onChanged: () => void;
}>;
/**
* Moving a customer's account to an address they can reach (#337).
*
* The last step of the only recovery route available to someone who has lost
* their mailbox. There is deliberately no self-service equivalent, because the
* email address is the root of trust for every other route and this shop holds
* no second proof of identity.
*
* The form leads with what this costs rather than burying it, because the
* operator is about to make a decision on someone else's behalf and the
* consequences land on that person, not on them.
*/
export default function ChangeCustomerEmail({
customerId,
currentEmail,
open,
onClose,
onChanged
}: Props) {
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
async function submit(values: { email: string; reason: string }) {
setSaving(true);
try {
const result = await changeCustomerEmail(customerId, values.email, values.reason);
// Named rather than counted, because "moved to that address" is the fact
// the operator has to repeat back to the customer on the phone.
message.success(`Account moved to ${result.customer.email}`);
if (result.passkeysRemoved > 0) {
// Its own message and a long one. The customer will find their passkeys
// gone and needs to be told why while they are still on the line —
// finding out later looks like a second thing going wrong.
message.warning(
result.passkeysRemoved === 1
? 'Their saved passkey was removed. They will need to set it up again.'
: `Their ${result.passkeysRemoved} saved passkeys were removed. They will need to set them up again.`,
10
);
}
form.resetFields();
onChanged();
onClose();
} catch (err) {
message.error((err as Error).message);
} finally {
setSaving(false);
}
}
return (
<Modal
title="Move this account to a new email address"
open={open}
onCancel={onClose}
footer={null}
destroyOnHidden
style={{ maxWidth: 'calc(100vw - 32px)' }}
>
<Alert
type="warning"
showIcon
message="Verify the customer before doing this"
description="This is the same operation as an account takeover, and nothing here can tell the difference. Check their answers against the order history on the account first — items bought, dates, the shipping address on file."
style={{ marginBottom: 16 }}
/>
<Paragraph type="secondary">
Moving the account signs the customer out everywhere, removes any saved passkeys, and
cancels reset links already sent to <Text code>{currentEmail}</Text>. A notice goes to that
address, and a confirmation link goes to the new one.
</Paragraph>
<Form form={form} layout="vertical" onFinish={submit}>
<Form.Item
name="email"
label="New email address"
rules={[{ required: true, type: 'email', message: 'A valid email address is required' }]}
>
<Input autoComplete="off" placeholder="what they can actually reach" />
</Form.Item>
<Form.Item
name="reason"
label="How you verified them"
// The server requires this too, and refuses without it. Collected here
// as well so the refusal is not the first the operator hears of it.
rules={[{ required: true, min: 10, message: 'A sentence, not a word — this is the record' }]}
extra="Recorded against the account and never shown to the customer. This is what tells a genuine recovery from a takeover afterwards."
>
<Input.TextArea rows={3} placeholder="Named the last two items bought and the shipping address on file." />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" danger htmlType="submit" loading={saving} block>
Move the account
</Button>
</Form.Item>
</Form>
</Modal>
);
}
+106 -6
View File
@@ -12,12 +12,57 @@ import message from 'antd/es/message';
import type { ColumnsType } from 'antd/es/table';
import {
fetchCustomers, fetchCustomerDetail, fetchReservedItems, releaseReservedItem,
setCustomerDisabled,
CustomerSummary, CustomerDetail, ReservedItem
setCustomerDisabled, fetchCustomerEmailChanges,
CustomerSummary, CustomerDetail, ReservedItem, CustomerEmailChange
} from './adminCustomersApi';
import ChangeCustomerEmail from './ChangeCustomerEmail';
const { Title, Text } = Typography;
/**
* Every address this account has been moved between, and why (#337).
*
* Shown on the detail drawer rather than hidden behind a separate screen,
* because the moment it matters is the moment someone is looking at this
* customer wondering whether the account is in the right hands. Empty for
* almost every customer, so it renders nothing at all rather than an empty
* state that would appear on every drawer to say nothing happened.
*/
function EmailChangeHistory({ changes }: Readonly<{ changes: CustomerEmailChange[] }>) {
if (changes.length === 0) return null;
return (
<>
<Title level={5} style={{ marginTop: 24 }}>Address changes</Title>
<Table
rowKey="id"
size="small"
dataSource={changes}
pagination={false}
columns={[
{
title: 'When',
dataIndex: 'changed_at',
render: (v: string) => new Date(v).toLocaleString()
},
{
title: 'Moved',
key: 'moved',
render: (_, row: CustomerEmailChange) => (
<span style={{ fontSize: 12 }}>
{row.previous_email} {row.new_email}
</span>
)
},
// The whole reason the record exists, so it is not truncated behind a
// tooltip. A reader deciding whether a change was legitimate needs the
// sentence, not the first few words of it.
{ title: 'Reason', dataIndex: 'reason' }
]}
/>
</>
);
}
// The two halves of the disable/re-enable confirm, as components rather than
// branches inside the handler: every piece of copy differs between them, so
// one decision up front reads better than the same condition asked five times.
@@ -50,15 +95,32 @@ function ReEnableWarning() {
// branches, and every one of them counted toward Customers().
function CustomerDetailPanel({
detail,
loading
}: Readonly<{ detail: CustomerDetail | null; loading: boolean }>) {
loading,
emailChanges,
onChangeEmail
}: Readonly<{
detail: CustomerDetail | null;
loading: boolean;
emailChanges: CustomerEmailChange[];
onChangeEmail: () => void;
}>) {
if (loading || !detail) {
return <Spin />;
}
return (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="Email">{detail.customer.email}</Descriptions.Item>
<Descriptions.Item label="Email">
{detail.customer.email}
{/* Next to the address rather than among the account actions: this is
a thing done *to* this field, and it is reached by someone already
looking at it because a customer told them they cannot. */}
<div style={{ marginTop: 4 }}>
<Button size="small" onClick={onChangeEmail} style={{ paddingInline: 0 }} type="link">
Move to a new address
</Button>
</div>
</Descriptions.Item>
<Descriptions.Item label="Verified">
<Tag color={detail.customer.email_verified ? 'green' : 'default'}>
{detail.customer.email_verified ? 'Verified' : 'Unverified'}
@@ -101,6 +163,8 @@ function CustomerDetailPanel({
]}
/>
)}
<EmailChangeHistory changes={emailChanges} />
</>
);
}
@@ -284,6 +348,8 @@ export default function Customers() {
const [reservedLoading, setReservedLoading] = useState(false);
const [releasing, setReleasing] = useState<number | null>(null);
const [togglingId, setTogglingId] = useState<number | null>(null);
const [emailChanges, setEmailChanges] = useState<CustomerEmailChange[]>([]);
const [movingEmail, setMovingEmail] = useState(false);
function load() {
return fetchCustomers()
@@ -298,9 +364,21 @@ export default function Customers() {
async function openDetail(id: number) {
setDrawerOpen(true);
setDetailLoading(true);
// Cleared rather than left standing: the drawer is reused for every row, and
// one customer's address history showing under another's name is the worst
// possible thing for this particular table to get wrong.
setEmailChanges([]);
const data = await fetchCustomerDetail(id);
setDetail(data);
setDetailLoading(false);
// After the detail, and allowed to fail on its own. This is a rare extra
// rather than part of the record, so a drawer that opens without it beats
// one that does not open at all.
try {
setEmailChanges(await fetchCustomerEmailChanges(id));
} catch {
setEmailChanges([]);
}
}
async function openReserved(customer: CustomerSummary) {
@@ -428,9 +506,31 @@ export default function Customers() {
onClose={() => { setDrawerOpen(false); setDetail(null); }}
width={480}
>
<CustomerDetailPanel detail={detail} loading={detailLoading} />
<CustomerDetailPanel
detail={detail}
loading={detailLoading}
emailChanges={emailChanges}
onChangeEmail={() => setMovingEmail(true)}
/>
</Drawer>
{/* Mounted only with a customer in hand, so the modal cannot be opened
against a drawer that has since been closed and emptied. */}
{detail && (
<ChangeCustomerEmail
customerId={detail.customer.id}
currentEmail={detail.customer.email}
open={movingEmail}
onClose={() => setMovingEmail(false)}
onChanged={() => {
// Both, and in this order. The drawer is what the operator is
// looking at, and the table behind it still shows the old address.
void openDetail(detail.customer.id);
void load();
}}
/>
)}
<Modal
title={reservedFor ? `Items reserved by ${reservedFor.email}` : 'Reserved items'}
open={reservedFor !== null}
+48
View File
@@ -43,6 +43,22 @@ export interface CustomerDetail {
orders: CustomerOrder[];
}
/** One recorded admin-initiated address change (#337). */
export interface CustomerEmailChange {
id: number;
previous_email: string;
new_email: string;
reason: string;
changed_at: string;
}
export interface EmailChangeResult {
customer: CustomerDetail['customer'];
previousEmail: string;
/** Removed by the change, and worth telling the customer about. */
passkeysRemoved: number;
}
export async function fetchCustomers(): Promise<CustomerSummary[]> {
const res = await fetch('/api/admin/customers');
return res.json();
@@ -71,6 +87,38 @@ export async function releaseReservedItem(customerId: number, itemId: number): P
}
}
/**
* Moves an account to an address its owner can reach (#337).
*
* The reason is required by the server, not merely collected by the form. This
* operation and an account takeover are the same operation, and the recorded
* reason is the only thing that tells them apart afterwards.
*/
export async function changeCustomerEmail(
customerId: number,
email: string,
reason: string
): Promise<EmailChangeResult> {
const res = await fetch(`/api/admin/customers/${customerId}/email`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, reason })
});
// Reporting success for a change that failed would leave the operator telling
// a customer to check an inbox nothing was sent to.
if (!res.ok) {
const detail = await res.json().catch(() => ({}));
throw new Error(detail.error || 'failed to change the email address');
}
return res.json();
}
export async function fetchCustomerEmailChanges(customerId: number): Promise<CustomerEmailChange[]> {
const res = await fetch(`/api/admin/customers/${customerId}/email-changes`);
if (!res.ok) throw new Error('failed to load the address history');
return res.json();
}
export async function setCustomerDisabled(customerId: number, disabled: boolean): Promise<void> {
const res = await fetch(`/api/admin/customers/${customerId}/${disabled ? 'disable' : 'enable'}`, {
method: 'POST'