feat(admin): move a customer's account to an address they can reach (#337)
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:
co-authored by
Claude Opus 5
parent
20554a4f1d
commit
90e372d6bd
@@ -1,6 +1,12 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db';
|
||||
import { pool, requireRow } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { isValidEmail, readId } from '../utils';
|
||||
import { sendMail } from '../mailer';
|
||||
import { renderTemplate, greeting } from '../emailTemplates';
|
||||
import { getSettings } from '../adminSettings';
|
||||
import { loadStoredTemplate } from './adminEmailTemplates';
|
||||
import { issueVerificationEmail } from '../customerVerification';
|
||||
|
||||
/**
|
||||
* Row shapes for the reads here, kept in step with their SQL by hand.
|
||||
@@ -67,6 +73,15 @@ interface IdRow {
|
||||
id: number;
|
||||
}
|
||||
|
||||
/** One recorded admin-initiated address change (#337). */
|
||||
interface EmailChangeRow {
|
||||
id: number;
|
||||
previous_email: string;
|
||||
new_email: string;
|
||||
reason: string;
|
||||
changed_at: Date;
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
|
||||
@@ -205,6 +220,173 @@ router.post('/:id/reserved/:itemId/release', asyncRoute(async (req: Request, res
|
||||
}
|
||||
}));
|
||||
|
||||
/** The reason the operator typed, or null if it is not usable as one. */
|
||||
function readReason(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
// A length floor rather than merely non-empty. The record exists to
|
||||
// distinguish a verified recovery from a takeover afterwards, and "ok" cannot
|
||||
// do that — but no floor high enough to be gamed is worth having either, so
|
||||
// this asks for a sentence and trusts the person writing it.
|
||||
if (trimmed.length < 10) return null;
|
||||
// Bounded because it is free text going into a TEXT column from a form.
|
||||
return trimmed.slice(0, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moving an account to an address its owner can actually reach (#337).
|
||||
*
|
||||
* This is the third step of the only recovery route a customer who has lost
|
||||
* their mailbox has, and there is deliberately no self-service equivalent: the
|
||||
* email address is the root of trust for every other route, this shop holds no
|
||||
* second proof of identity, and anything invented to fill that gap would be a
|
||||
* weaker credential than the one it replaced. So the route is manual, and
|
||||
* `docs/ops/account-recovery.md` describes the verification that has to happen
|
||||
* before this endpoint is called.
|
||||
*
|
||||
* The uncomfortable part, stated plainly: this operation and an account takeover
|
||||
* are the same operation. They differ only in whether the verification was
|
||||
* sound, and nothing here can check that. What this can do is make the change
|
||||
* recorded, announced, and reversible in its effects — which is what everything
|
||||
* below is for.
|
||||
*
|
||||
* No current-password check, unlike the customer's own change. There is no
|
||||
* password to ask for; the whole premise is that the person asking cannot prove
|
||||
* anything the system can verify. The admin gate is the only authorisation, and
|
||||
* the operator's judgement is the only verification.
|
||||
*/
|
||||
router.put('/:id/email', asyncRoute(async (req: Request, res: Response) => {
|
||||
const id = readId(req.params.id);
|
||||
if (id === null) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const { email, reason } = req.body ?? {};
|
||||
|
||||
const normalized = String(email ?? '').toLowerCase().trim();
|
||||
if (!normalized || !isValidEmail(normalized)) {
|
||||
return res.status(400).json({ error: 'a valid email is required' });
|
||||
}
|
||||
|
||||
const stated = readReason(reason);
|
||||
if (stated === null) {
|
||||
return res.status(400).json({
|
||||
error: 'say why this account is being moved — a sentence naming how the customer was verified'
|
||||
});
|
||||
}
|
||||
|
||||
const { rows } = await pool.query<{ id: number; email: string; first_name: string | null; last_name: string | null }>(
|
||||
`SELECT id, email, first_name, last_name FROM customers WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
const customer = rows[0];
|
||||
if (!customer) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
if (normalized === customer.email) {
|
||||
return res.status(400).json({ error: 'that is already this customer’s email address' });
|
||||
}
|
||||
|
||||
const { rows: taken } = await pool.query<IdRow>(`SELECT id FROM customers WHERE email = $1`, [normalized]);
|
||||
if (taken.length) {
|
||||
return res.status(409).json({ error: 'another account already uses this email address' });
|
||||
}
|
||||
|
||||
// Captured before the update, because it is where the notice has to go and
|
||||
// the row will not be able to answer for it a moment from now.
|
||||
const previousEmail = customer.email;
|
||||
|
||||
let passkeysRemoved = 0;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
await client.query(
|
||||
// Unverified, exactly as the self-service change leaves it. Nobody has
|
||||
// demonstrated receiving mail at this address yet — a customer describing
|
||||
// it over the phone is not that, and it is the commonest way this goes
|
||||
// wrong harmlessly.
|
||||
`UPDATE customers SET email = $1, email_verified = false WHERE id = $2`,
|
||||
[normalized, id]
|
||||
);
|
||||
|
||||
// Everything the previous holder of this account had, on the reasoning #42
|
||||
// settled for password reset. An account being moved to a recovered address
|
||||
// is in the same position as one being recovered by reset, and the same
|
||||
// argument applies with more force: here somebody the system cannot
|
||||
// identify has asked for the change, so a session or a credential surviving
|
||||
// it would be one the new owner cannot see and cannot revoke.
|
||||
await client.query(`DELETE FROM customer_sessions WHERE customer_id = $1`, [id]);
|
||||
const removed = await client.query(`DELETE FROM customer_credentials WHERE customer_id = $1`, [id]);
|
||||
passkeysRemoved = removed.rowCount ?? 0;
|
||||
await client.query(`DELETE FROM webauthn_challenges WHERE customer_id = $1`, [id]);
|
||||
|
||||
// Reset links already sent are addressed to the old mailbox, which is the
|
||||
// one this change is taking away. Leaving them live would let whoever still
|
||||
// reads it take the account straight back.
|
||||
await client.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [id]);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO customer_email_changes (customer_id, previous_email, new_email, reason)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[id, previousEmail, normalized, stated]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
// Both sends happen after the row is written, never before, so a change that
|
||||
// failed cannot produce mail saying it succeeded.
|
||||
await issueVerificationEmail(id, normalized, customer.first_name, customer.last_name);
|
||||
|
||||
// To the address being replaced, which is the whole point. If the recovery
|
||||
// was sound this reaches nobody, and that costs nothing. If it was not, it
|
||||
// reaches the real owner — who is the only person who can say so, and the
|
||||
// only reason this endpoint is safe to have at all.
|
||||
const { greetingFormat, greetingFallback } = await getSettings();
|
||||
const notice = renderTemplate('emailChangedByAdmin', await loadStoredTemplate('emailChangedByAdmin'), {
|
||||
greeting: greeting(customer.first_name, greetingFormat, greetingFallback, customer.last_name),
|
||||
firstName: customer.first_name ?? '',
|
||||
lastName: customer.last_name ?? '',
|
||||
newEmail: normalized
|
||||
});
|
||||
sendMail(previousEmail, notice.subject, notice.html)
|
||||
.catch(err => console.error('admin email change notice send failed', err));
|
||||
|
||||
const { rows: updated } = await pool.query<CustomerDetailRow>(
|
||||
`SELECT id, email, nullif(btrim(concat_ws(' ', first_name, last_name)), '') AS name,
|
||||
email_verified, marketing_consent, marketing_consent_at, created_at
|
||||
FROM customers WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
customer: requireRow(updated, 'the customer after the admin email change'),
|
||||
previousEmail,
|
||||
// Reported so the operator can tell the customer what they will have to set
|
||||
// up again, and so a surprising number is visible at the moment it happens
|
||||
// rather than never.
|
||||
passkeysRemoved
|
||||
});
|
||||
}));
|
||||
|
||||
/** What has been done to this account's address, and why (#337). */
|
||||
router.get('/:id/email-changes', asyncRoute(async (req: Request, res: Response) => {
|
||||
const id = readId(req.params.id);
|
||||
if (id === null) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const { rows } = await pool.query<EmailChangeRow>(
|
||||
`SELECT id, previous_email, new_email, reason, changed_at
|
||||
FROM customer_email_changes
|
||||
WHERE customer_id = $1
|
||||
ORDER BY changed_at DESC`,
|
||||
[id]
|
||||
);
|
||||
res.json(rows);
|
||||
}));
|
||||
|
||||
router.get('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows: customerRows } = await pool.query<CustomerDetailRow>(
|
||||
`SELECT id, email, nullif(btrim(concat_ws(' ', first_name, last_name)), '') AS name,
|
||||
|
||||
@@ -16,46 +16,13 @@ import { passwordResetRequestLimiter, verificationResendLimiter } from '../rateL
|
||||
// Shared with passkey sign-in, so both paths establish a session identically
|
||||
// rather than in two places that merely agree today (#39).
|
||||
import { setSessionCookie, createSession } from '../customerSession';
|
||||
// Registration, a resend, the customer changing their own address and the shop
|
||||
// changing it for them all need the same three steps, and they now live in one
|
||||
// place for the same reason session creation does (#337).
|
||||
import { issueVerificationEmail } from '../customerVerification';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Registration, changing an address, and resending all need the same three
|
||||
// steps: supersede any outstanding link, mint a new one, send it. Written out
|
||||
// three times they would drift, and the step most likely to be forgotten is the
|
||||
// first — which is the one that matters, since an un-superseded link means an
|
||||
// older message in the inbox still verifies.
|
||||
//
|
||||
// Sending is fire-and-forget by the same rule the rest of this file follows:
|
||||
// the token row is written first, so a send that fails cannot leave a customer
|
||||
// believing a link exists that does not, only waiting for one that never came.
|
||||
async function issueVerificationEmail(
|
||||
customerId: number,
|
||||
email: string,
|
||||
firstName: string | null,
|
||||
lastName: string | null = null
|
||||
): Promise<void> {
|
||||
await pool.query(
|
||||
`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'verify_email'`,
|
||||
[customerId]
|
||||
);
|
||||
const { verifyTokenHours, greetingFormat, greetingFallback } = await getSettings();
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
await pool.query(
|
||||
`INSERT INTO customer_tokens (token, customer_id, kind, expires_at) VALUES ($1, $2, 'verify_email', $3)`,
|
||||
[token, customerId, new Date(Date.now() + verifyTokenHours * 60 * 60 * 1000)]
|
||||
);
|
||||
const verifyUrl = `${process.env.PUBLIC_URL}/verify-email?token=${token}`;
|
||||
const template = renderTemplate('verification', await loadStoredTemplate('verification'), {
|
||||
greeting: greeting(firstName, greetingFormat, greetingFallback, lastName),
|
||||
firstName: firstName ?? '',
|
||||
lastName: lastName ?? '',
|
||||
verifyUrl,
|
||||
expiresIn: formatDuration(verifyTokenHours)
|
||||
});
|
||||
sendMail(email, template.subject, template.html)
|
||||
.catch(err => console.error('verify email send failed', err));
|
||||
}
|
||||
|
||||
// setSessionCookie and createSession now live in ../customerSession, shared with
|
||||
// passkey sign-in. #39 requires that path to establish a session identically to
|
||||
// this one, and sharing the code is what makes that true rather than intended.
|
||||
|
||||
Reference in New Issue
Block a user