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>
122 lines
4.6 KiB
TypeScript
122 lines
4.6 KiB
TypeScript
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>
|
|
);
|
|
}
|