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 @@
-
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 @@