From 6d00c3004f791a6f41aa1dcdb9fddf30167dfa3e Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Fri, 14 Aug 2026 15:04:42 +0100 Subject: [PATCH] fix: convert admin PaymentModal to shared useTwoFactorCodeForSavedCard composable (verification round FAIL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/payments/PaymentModal.svelte | 74 ++++++------------- 1 file changed, 21 insertions(+), 53 deletions(-) diff --git a/frontend/src/lib/components/payments/PaymentModal.svelte b/frontend/src/lib/components/payments/PaymentModal.svelte index b1aec5e..e29bcb0 100644 --- a/frontend/src/lib/components/payments/PaymentModal.svelte +++ b/frontend/src/lib/components/payments/PaymentModal.svelte @@ -12,13 +12,13 @@ campaignDiscountPence, isSavedCardVerificationRequired, isTwoFactorVerificationGateFailure, - requestNewTwoFactorCode, sanitizeDecimalInput, SAVED_CARD_VERIFICATION_MESSAGE, submitPaymentWithRetry } from '$lib/square/square'; import { authStore } from '$lib/stores/auth.svelte'; import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte'; + import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte'; import { generateUUID } from '$lib/utils/uuid'; const LOYALTY_DISCOUNT_RATE = 0.1; @@ -80,54 +80,22 @@ const stamps = $derived(booking.user?.loyalty_stamps ?? 0); let useLoyalty = $state(false); - // B6/B10: verification code for the customer's saved-card charge, collected - // on the saved-card screen. Kept populated across retries so an - // invalid/expired code can be corrected without re-typing it. The admin + // B6/B10: charging a customer's saved card requires the customer's current + // 2FA verification code when the backend enforces the gate. Shared + // 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 - // irrelevant to the backend gate. - let twoFactorCode = $state(''); - // Set true when a charge 403s for a missing code — reveals the input even - // if the customer's 2FA flag is unknown/unset. - 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; - } - } + // irrelevant to the backend gate, so `enabled` is always true. + const twoFactor = useTwoFactorCodeForSavedCard({ + enabled: () => true, + gateActive: () => twoFactorEnforced && customerTwoFactorEnabled + }); // 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 // operator can type the customer's code without an extra click. $effect(() => { - if (status === 'saved-card-selecting' && show2FACodeInput) { + if (status === 'saved-card-selecting' && twoFactor.showInput) { tick().then(() => document.getElementById('two-factor-code')?.focus()); } }); @@ -822,7 +790,7 @@ payment_type: 'full', payment_method: 'saved_card', saved_card_id: selectedSavedCardId, - ...(show2FACodeInput ? { verification_code: twoFactorCode } : {}), + ...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}), idempotency_key: savedCardIdempotencyKey }) }) @@ -850,8 +818,8 @@ // charge gets a fresh UUID and can't be deduped against this one. savedCardIdempotencyKey = ''; savedCardKeyedAmount = 0; - twoFactorCode = ''; - reveal2FACodeInput = false; + twoFactor.setCode(''); + twoFactor.reveal = false; toast.success('Saved card payment successful'); onComplete(paymentResult); } catch (_err) { @@ -866,7 +834,7 @@ // B6/B10: a 2FA verification-gate rejection (missing/invalid/expired // code, brute-force lockout) is recoverable — keep the code populated // 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; toast.error(msg); } finally { @@ -1536,8 +1504,8 @@ - - {#if show2FACodeInput} + + {#if twoFactor.showInput}

Enter the customer's verification code — not your own. The customer can request a fresh code from their account. @@ -1546,9 +1514,9 @@ variant="outline" size="sm" class="w-full" - loading={requesting2FACode} - disabled={requesting2FACode} - onclick={handleRequestNew2FACode} + loading={twoFactor.requesting} + disabled={twoFactor.requesting} + onclick={twoFactor.requestNewCode} > Request a new code @@ -1559,7 +1527,7 @@