fix: convert admin PaymentModal to shared useTwoFactorCodeForSavedCard composable (verification round FAIL)

The verification of 62adccd found the 2FA composable conversion was incomplete:
PaymentModal.svelte (admin Take Payment on the today page — a live saved-card
charge surface) still re-implemented the 2FA gate inline while the composable's
own doc listed it as one of the six surfaces. This completes the refactor:

- Removed inline twoFactorCode/reveal2FACodeInput/show2FACodeInput/
  missing2FACode/requesting2FACode/handleRequestNew2FACode state (74 -> 21 net
  lines) and the now-unused requestNewTwoFactorCode import
- Composable call mirrors the TillPurchases admin reference: enabled() => true
  (admin supplies the CUSTOMER's code), gateActive() => twoFactorEnforced &&
  customerTwoFactorEnabled (byte-identical semantics)
- Rewired request body, success handler, 403 self-heal, focus effect, and the
  TwoFactorCodeInput/request-button/Pay-button bindings to the composable
- Zero inline gate patterns remain in the payments components dir

Frontend 72/72 tests + build + eslint clean; backend 26/26 packages.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent a6a4683b74
commit 6d00c3004f
@@ -12,13 +12,13 @@
campaignDiscountPence, campaignDiscountPence,
isSavedCardVerificationRequired, isSavedCardVerificationRequired,
isTwoFactorVerificationGateFailure, isTwoFactorVerificationGateFailure,
requestNewTwoFactorCode,
sanitizeDecimalInput, sanitizeDecimalInput,
SAVED_CARD_VERIFICATION_MESSAGE, SAVED_CARD_VERIFICATION_MESSAGE,
submitPaymentWithRetry submitPaymentWithRetry
} from '$lib/square/square'; } from '$lib/square/square';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte'; import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import { generateUUID } from '$lib/utils/uuid'; import { generateUUID } from '$lib/utils/uuid';
const LOYALTY_DISCOUNT_RATE = 0.1; const LOYALTY_DISCOUNT_RATE = 0.1;
@@ -80,54 +80,22 @@
const stamps = $derived(booking.user?.loyalty_stamps ?? 0); const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
let useLoyalty = $state(false); let useLoyalty = $state(false);
// B6/B10: verification code for the customer's saved-card charge, collected // B6/B10: charging a customer's saved card requires the customer's current
// on the saved-card screen. Kept populated across retries so an // 2FA verification code when the backend enforces the gate. Shared
// invalid/expired code can be corrected without re-typing it. The admin // verification-code state (code, reveal, show/missing derivations, "Request
// a new code" handler) — see $lib/stores/twoFactorCode.svelte.ts. The admin
// always supplies the CUSTOMER's code — the admin's own 2FA flag is // always supplies the CUSTOMER's code — the admin's own 2FA flag is
// irrelevant to the backend gate. // irrelevant to the backend gate, so `enabled` is always true.
let twoFactorCode = $state(''); const twoFactor = useTwoFactorCodeForSavedCard({
// Set true when a charge 403s for a missing code — reveals the input even enabled: () => true,
// if the customer's 2FA flag is unknown/unset. gateActive: () => twoFactorEnforced && customerTwoFactorEnabled
let reveal2FACodeInput = $state(false); });
const show2FACodeInput = $derived(
reveal2FACodeInput || (twoFactorEnforced && customerTwoFactorEnabled)
);
const missing2FACode = $derived(show2FACodeInput && twoFactorCode.trim() === '');
// POST /api/user/2fa/code mint state for the "Request a new code" button on
// the saved-card screen. NOTE: this mints for the SIGNED-IN session (the
// admin), which cannot authorize the customer's charge — the backend agent
// coordinating POST /api/user/2fa/code should also add an admin-scoped mint
// (e.g. /api/admin/users/{id}/2fa/code) for the operator to mint the
// customer's code; until then the button exercises the cooldown/429/503 UX.
let requesting2FACode = $state(false);
async function handleRequestNew2FACode() {
if (requesting2FACode) return;
requesting2FACode = true;
try {
const result = await requestNewTwoFactorCode();
if (result.ok) {
twoFactorCode = '';
toast.success(result.message);
} else if (result.status === 429) {
toast.error(result.message || 'Too many requests. Wait before requesting a new code.');
} else if (result.status === 503) {
toast.error(
result.message || 'Verification codes are unavailable right now. Try again later.'
);
} else {
toast.error(result.message);
}
} finally {
requesting2FACode = false;
}
}
// Focus the verification-code input whenever the saved-card screen shows it // Focus the verification-code input whenever the saved-card screen shows it
// (auto-show for a 2FA-enabled customer, or the 403 self-heal reveal) so the // (auto-show for a 2FA-enabled customer, or the 403 self-heal reveal) so the
// operator can type the customer's code without an extra click. // operator can type the customer's code without an extra click.
$effect(() => { $effect(() => {
if (status === 'saved-card-selecting' && show2FACodeInput) { if (status === 'saved-card-selecting' && twoFactor.showInput) {
tick().then(() => document.getElementById('two-factor-code')?.focus()); tick().then(() => document.getElementById('two-factor-code')?.focus());
} }
}); });
@@ -822,7 +790,7 @@
payment_type: 'full', payment_type: 'full',
payment_method: 'saved_card', payment_method: 'saved_card',
saved_card_id: selectedSavedCardId, saved_card_id: selectedSavedCardId,
...(show2FACodeInput ? { verification_code: twoFactorCode } : {}), ...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
idempotency_key: savedCardIdempotencyKey idempotency_key: savedCardIdempotencyKey
}) })
}) })
@@ -850,8 +818,8 @@
// charge gets a fresh UUID and can't be deduped against this one. // charge gets a fresh UUID and can't be deduped against this one.
savedCardIdempotencyKey = ''; savedCardIdempotencyKey = '';
savedCardKeyedAmount = 0; savedCardKeyedAmount = 0;
twoFactorCode = ''; twoFactor.setCode('');
reveal2FACodeInput = false; twoFactor.reveal = false;
toast.success('Saved card payment successful'); toast.success('Saved card payment successful');
onComplete(paymentResult); onComplete(paymentResult);
} catch (_err) { } catch (_err) {
@@ -866,7 +834,7 @@
// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired // B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
// code, brute-force lockout) is recoverable — keep the code populated // code, brute-force lockout) is recoverable — keep the code populated
// and reveal the input so the charge can be retried with a fresh code. // and reveal the input so the charge can be retried with a fresh code.
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) reveal2FACodeInput = true; if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
error = msg; error = msg;
toast.error(msg); toast.error(msg);
} finally { } finally {
@@ -1536,8 +1504,8 @@
<!-- B6/B10: saved-card charges require the customer's current 2FA <!-- B6/B10: saved-card charges require the customer's current 2FA
verification code when the backend enforces the gate. --> verification code when the backend enforces the gate. -->
<TwoFactorCodeInput bind:code={twoFactorCode} showInput={show2FACodeInput} enabled={true} /> <TwoFactorCodeInput bind:code={twoFactor.code} showInput={twoFactor.showInput} enabled={true} />
{#if show2FACodeInput} {#if twoFactor.showInput}
<p class="mt-1 text-xs text-gray-500"> <p class="mt-1 text-xs text-gray-500">
Enter the customer's verification code — not your own. The customer can request a fresh Enter the customer's verification code — not your own. The customer can request a fresh
code from their account. code from their account.
@@ -1546,9 +1514,9 @@
variant="outline" variant="outline"
size="sm" size="sm"
class="w-full" class="w-full"
loading={requesting2FACode} loading={twoFactor.requesting}
disabled={requesting2FACode} disabled={twoFactor.requesting}
onclick={handleRequestNew2FACode} onclick={twoFactor.requestNewCode}
> >
Request a new code Request a new code
</Button> </Button>
@@ -1559,7 +1527,7 @@
<Button <Button
onclick={handleSavedCardPayment} onclick={handleSavedCardPayment}
class="flex-1" class="flex-1"
disabled={!selectedSavedCardId || nothingToCharge || missing2FACode} disabled={!selectedSavedCardId || nothingToCharge || twoFactor.missing}
> >
Charge Saved Card Charge Saved Card
</Button> </Button>