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
@@ -8,11 +8,13 @@
import { apiFetch } from '$lib/utils/api';
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import {
isSquareConfigured,
isTwoFactorVerificationGateFailure,
submitPaymentWithRetry
} from '$lib/square/square';
import {
isSquareConfigured,
isTwoFactorVerificationGateFailure,
submitPaymentWithRetry,
adminRequestNewTwoFactorCode,
requestNewTwoFactorCode
} from '$lib/square/square';
import { authStore } from '$lib/stores/auth.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
@@ -121,7 +123,9 @@
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => savedCardChargeRequires2FACode && paymentMethod === 'saved_card'
gateActive: () => savedCardChargeRequires2FACode && paymentMethod === 'saved_card',
mint: () =>
selectedCustomer?.id ? adminRequestNewTwoFactorCode(selectedCustomer.id) : requestNewTwoFactorCode()
});
// The saved-card option is hidden outright unless a customer is selected
@@ -14,7 +14,9 @@
isTwoFactorVerificationGateFailure,
sanitizeDecimalInput,
SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry
submitPaymentWithRetry,
adminRequestNewTwoFactorCode,
requestNewTwoFactorCode
} from '$lib/square/square';
import { authStore } from '$lib/stores/auth.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
@@ -88,7 +90,11 @@
// irrelevant to the backend gate, so `enabled` is always true.
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => twoFactorEnforced && customerTwoFactorEnabled
gateActive: () => twoFactorEnforced && customerTwoFactorEnabled,
mint: () => {
const customerID = booking.user_id ?? booking.user?.id;
return customerID ? adminRequestNewTwoFactorCode(customerID) : requestNewTwoFactorCode();
}
});
// Focus the verification-code input whenever the saved-card screen shows it