fix: admin-scoped 2FA mint targets the CUSTOMER — user authentication for saved cards, never the admin

The till and admin payment modal 'Request a new code' buttons previously called
the session-scoped POST /api/user/2fa/code, which mints a code for the ADMIN's
session — a code that can never satisfy the card-owner gate and is delivered to
the admin's log line, not the customer.

- New POST /api/admin/users/{id}/2fa/code (AdminSendVerificationCodeHandler,
  RequireAdmin + per-user limiter): mints/reuses a code for the TARGET user
  (the card owner/customer), keyed to the CUSTOMER's userID so the [2FA]
  delivery log carries the customer's ID — the customer, never the admin, is
  the authentication subject for their card
- Shared useTwoFactorCodeForSavedCard composable gains an optional mint()
  option; admin surfaces (PaymentModal, TillPurchases) pass the customer-scoped
  mint, customer surfaces keep the session default
- Frontend: adminRequestNewTwoFactorCode(userID) in square.ts; PaymentModal
  mints for booking.user_id, TillPurchases for selectedCustomer.id
- Tests: admin mint keys the code to the customer's userID (log line contains
  customer ID, NOT the admin ID) + pending hash persisted for the customer;
  unknown target user 404s

Backend 26/26 packages; frontend 72/72 + build clean.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 6d00c3004f
commit 03d85c6d13
7 changed files with 183 additions and 11 deletions
+30
View File
@@ -179,6 +179,36 @@ export async function requestNewTwoFactorCode(): Promise<TwoFactorCodeRequestRes
}
}
/** Admin-scoped 2FA mint: requests a fresh code FOR the given customer (the
* card owner) at the till/admin payment modal. The backend keys the mint to
* the CUSTOMER's userID, so the code is delivered to the customer and can
* satisfy the card-owner gate — the admin's session never receives or
* authenticates the customer's card. */
export async function adminRequestNewTwoFactorCode(userID: string): Promise<TwoFactorCodeRequestResult> {
const headers: Record<string, string> = {};
if (typeof localStorage !== 'undefined') {
const token = localStorage.getItem('authToken');
if (token) headers['Authorization'] = `Bearer ${token}`;
}
try {
const response = await fetch(`/api/admin/users/${encodeURIComponent(userID)}/2fa/code`, { method: 'POST', headers });
if (response.ok) {
const data = (await response.json().catch(() => null)) as { message?: unknown } | null;
const message =
typeof data?.message === 'string' ? data.message : 'A new verification code has been sent.';
return { status: response.status, ok: true, message };
}
const body = await response.text();
return {
status: response.status,
ok: false,
message: extractServerErrorMessage(body) || 'Failed to request a new verification code'
};
} catch {
return { status: 0, ok: false, message: 'Network error requesting a new code' };
}
}
/** Minimal `{"error"|"message": "..."}` extractor for the 2FA code-request
* endpoint bodies (429/503), kept inline so square.ts stays import-free for
* the vitest suite. */