diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index 89c15d1..00e5c48 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -969,7 +969,11 @@ ${hasVAT ? `

VAT is included at ${biz?.default_vat_rate ?? 20}

- + (showTipModal = false)} + />
diff --git a/frontend/src/lib/components/admin/TillPurchases.svelte b/frontend/src/lib/components/admin/TillPurchases.svelte index 0d381ec..18d6ea8 100644 --- a/frontend/src/lib/components/admin/TillPurchases.svelte +++ b/frontend/src/lib/components/admin/TillPurchases.svelte @@ -16,16 +16,20 @@ isTwoFactorVerificationGateFailure, isVerificationRequiredSignal, runSavedCardSCAProactively, - shouldFallbackTo2FA, - SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE, + scaFallbackConsentFields, + SCA_REFUSAL_MESSAGE_TILL, + shouldShowSCARefusal, submitPaymentWithRetry, + tokenizeSavedCardWithVerification, adminRequestNewTwoFactorCode, requestNewTwoFactorCode, PAYMENT_METHOD_SAVED_CARD, - VERIFICATION_REQUIRED_MESSAGE + VERIFICATION_REQUIRED_MESSAGE, + type SavedCardVerificationResult } from '$lib/square/square'; import { authStore } from '$lib/stores/auth.svelte'; import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte'; + import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte'; type CartItem = { id: string; @@ -34,7 +38,8 @@ qty: number; }; - type TillPaymentMethod = 'cash' | 'card_machine' | 'online_square' | (typeof PAYMENT_METHOD_SAVED_CARD); + type TillPaymentMethod = + 'cash' | 'card_machine' | 'online_square' | typeof PAYMENT_METHOD_SAVED_CARD; const PAYMENT_METHODS: Array<{ key: TillPaymentMethod; label: string }> = [ { key: 'cash', label: 'Cash' }, @@ -141,9 +146,9 @@ // irrelevant to the backend gate, so `enabled` is always true. const twoFactorEnforced = $derived(!!authStore.currentUser?.twoFactorRequired); let customerTwoFactorEnabled = $state(false); - // Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA - // from backup to the only available gate (scaAvailable → false); every other - // outcome keeps SCA primary for the next retry. + // Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the + // C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA + // fallback); every other outcome keeps SCA primary for the next retry. let lastSCAOutcome = $state(''); // True while the saved-card 3DS challenge is open and the CUSTOMER must // approve it in their banking app — drives the "waiting for approval" panel. @@ -152,7 +157,9 @@ enabled: () => true, gateActive: () => twoFactorEnforced && customerTwoFactorEnabled && paymentMethod === PAYMENT_METHOD_SAVED_CARD, - scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome), + // C6 SCA-only posture: SCA is ALWAYS the authorisation — the code input + // only ever surfaces via a backend gate rejection (defensive/opt-in). + scaAvailable: () => true, mint: () => selectedCustomer?.id ? adminRequestNewTwoFactorCode(selectedCustomer.id) @@ -348,7 +355,10 @@ ); return; } - if (paymentMethod === PAYMENT_METHOD_SAVED_CARD && (!selectedCustomer || !selectedSavedCardId)) { + if ( + paymentMethod === PAYMENT_METHOD_SAVED_CARD && + (!selectedCustomer || !selectedSavedCardId) + ) { toast.error('Select a customer and a saved card before charging'); return; } @@ -360,37 +370,84 @@ // One sale per cart line × quantity — each till sale funds its own // gift card (the backend only accepts item_type 'gift_card'). const saleBodies: Record[] = []; - for (const item of cart) { - for (let i = 0; i < item.qty; i++) { - const body: Record = { - item_type: 'gift_card', - action: 'create', - amount: item.price, - payment_method: paymentMethod, - idempotency_key: idempotencyKeyFor(item, i) - }; - if (paymentMethod === PAYMENT_METHOD_SAVED_CARD) { - body.user_id = selectedCustomer?.id; - body.user_saved_card_id = selectedSavedCardId; - // B6/B10: the backend requires the CARD OWNER's current 2FA - // verification code when the gate is enforced. - if (twoFactor.showInput) body.verification_code = twoFactor.code; - } else if (paymentMethod === 'online_square') { - if (!onlineSquareCardInput) { - throw new Error('Card form is not ready — please wait a moment and try again'); - } - // SCA verification amount must match the sale amount (pence). - const tokenized = await onlineSquareCardInput.tokenizeWithVerification( - Math.round(item.price * 100) - ); - body.card_token = tokenized.nonce; - if (tokenized.verificationToken) { - body.verification_token = tokenized.verificationToken; + // M10: proactive saved-card (ccof) SCA. Run the client-side challenge + // for every sale line BEFORE the first charge so a naked ccof till + // charge is never sent to the backend (mirrors PaymentModal/UserPaymentModal + // running SCA at charge init). Each line binds its token to its own + // amount. 'challenge-cancelled'/'sca-failed' abort the whole sale + // (retryable); 'sca-unavailable' aborts before any charge and surfaces + // the C6 refusal notice (no 2FA fallback). + let scaAborted = false; + awaitingSCA = true; + try { + for (const item of cart) { + for (let i = 0; i < item.qty; i++) { + const body: Record = { + item_type: 'gift_card', + action: 'create', + amount: item.price, + payment_method: paymentMethod, + idempotency_key: idempotencyKeyFor(item, i) + }; + if (paymentMethod === PAYMENT_METHOD_SAVED_CARD) { + body.user_id = selectedCustomer?.id; + body.user_saved_card_id = selectedSavedCardId; + const squareCardId = savedCards.find( + (c) => c.id === selectedSavedCardId + )?.square_card_id; + const sca = await runSavedCardSCAProactively({ + // The till body carries the amount in POUNDS (the + // backend multiplies by 100); the SCA challenge binds + // to pence, so convert for the challenge. + amountPence: Math.round(item.price * 100), + squareCardId: squareCardId ?? '', + buyer: { email: selectedCustomer?.email }, + onOutcome: (o) => (lastSCAOutcome = o) + }); + if (sca.outcome === 'challenge-cancelled' || sca.outcome === 'sca-failed') { + throw new Error(CARD_VERIFICATION_RETRY_MESSAGE); + } + if (sca.outcome === 'sca-unavailable') { + // C6: SCA genuinely can't run — abort the whole sale + // BEFORE any charge is submitted; the refusal notice + // is shown above the Charge button (no 2FA fallback). + twoFactor.declineConsent(); + twoFactor.reveal = false; + scaAborted = true; + break; + } + // C1: the SCA tokenize-result token is the charge SOURCE + // (new_card_token) alongside the saved-card ref — never + // the legacy verification_token. + if (sca.verificationToken) body.new_card_token = sca.verificationToken; + // B6/B10: the backend requires the CARD OWNER's current 2FA + // verification code when the gate is enforced and no SCA + // token authorises the charge. + if (twoFactor.showInput && !sca.verificationToken) { + body.verification_code = twoFactor.code; + } + Object.assign(body, scaFallbackConsentFields(twoFactor.consentAccepted)); + } else if (paymentMethod === 'online_square') { + if (!onlineSquareCardInput) { + throw new Error('Card form is not ready — please wait a moment and try again'); + } + // SCA verification amount must match the sale amount (pence). + const tokenized = await onlineSquareCardInput.tokenizeWithVerification( + Math.round(item.price * 100) + ); + body.card_token = tokenized.nonce; + if (tokenized.verificationToken) { + body.verification_token = tokenized.verificationToken; + } } + saleBodies.push(body); } - saleBodies.push(body); + if (scaAborted) break; } + } finally { + awaitingSCA = false; } + if (scaAborted) return; for (const body of saleBodies) { const res = await submitPaymentWithRetry( @@ -404,7 +461,8 @@ // code at the backend gate — a 503 auto-retry would re-send a // dead code and self-defeat. { - verificationCodeGated: paymentMethod === PAYMENT_METHOD_SAVED_CARD && twoFactor.showInput + verificationCodeGated: + paymentMethod === PAYMENT_METHOD_SAVED_CARD && twoFactor.showInput } ); if (!res.ok) { @@ -438,10 +496,27 @@ twoFactor.reveal = false; } catch (err) { const msg = err instanceof Error ? err.message : 'Sale failed'; + // C6: a sca-unavailable refusal is communicated by the refusal dialog + // above the Charge button — don't duplicate it in the error panel. + if (shouldShowSCARefusal(lastSCAOutcome)) { + paymentError = null; + return; + } + const bodyText = (err as { bodyText?: string })?.bodyText ?? ''; // 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 sale can be retried with a fresh code. if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true; + // M13: a verification-required rejection means the backend did NOT + // accept the fallback code (SCA-only posture / invalid token) — + // withdraw consent so the code input never reappears and the error + // surfaces clearly instead of looping on 2FA. + if ( + msg === VERIFICATION_REQUIRED_MESSAGE || + isVerificationRequiredSignal(responseStatus, bodyText) + ) { + twoFactor.declineConsent(); + } paymentError = msg; toast.error(msg); } finally { @@ -452,13 +527,16 @@ /** * Saved-card (ccof) SCA challenge, run when a till sale line came back 402 - * with the verification-required signal. The CUSTOMER approves the 3DS - * challenge in their banking app; the operator's screen shows the waiting - * state. 'verified' retries the SAME sale line with the fresh verification_token - * and its SAME cached idempotency key (never regenerated here); 'sca-unavailable' - * demotes 2FA from backup to the available gate; 'challenge-cancelled' / - * 'sca-failed' keep the pending row retryable (the idempotency key stays - * cached). Throws to stop the whole sale on any non-verified outcome. + * with the verification-required signal (the proactive token was stale or + * expired between tokenize and charge — the first attempt now always runs + * proactive SCA, so this is the defensive path). The CUSTOMER approves the + * 3DS challenge in their banking app; the operator's screen shows the waiting + * state. 'verified' retries the SAME sale line with the fresh tokenize-result + * token as new_card_token and its SAME cached idempotency key (never + * regenerated here); 'sca-unavailable' refuses the sale (C6 — the refusal + * dialog is driven by lastSCAOutcome); 'challenge-cancelled' / 'sca-failed' + * keep the pending row retryable (the idempotency key stays cached). Throws + * to stop the whole sale on any non-verified outcome. */ async function runTillSavedCardSCA(body: Record): Promise { const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id; @@ -469,8 +547,8 @@ try { if (!squareCardId) { lastSCAOutcome = 'sca-unavailable'; - twoFactor.reveal = true; - throw new Error(VERIFICATION_REQUIRED_MESSAGE); + twoFactor.declineConsent(); + throw new Error(SCA_REFUSAL_MESSAGE_TILL); } let result: SavedCardVerificationResult; try { @@ -479,7 +557,7 @@ }); } catch (err) { lastSCAOutcome = 'sca-unavailable'; - twoFactor.reveal = true; + twoFactor.declineConsent(); throw err; } lastSCAOutcome = result.outcome; @@ -490,19 +568,22 @@ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...body, - verification_token: result.verificationToken + // C1: the SCA tokenize-result token is the charge SOURCE. + new_card_token: result.verificationToken }) }) ); if (!retry.ok) { const errText = await retry.text(); - throw new Error(extractErrorMessage(errText) || 'Till sale failed'); + const err = new Error(extractErrorMessage(errText) || 'Till sale failed'); + (err as { bodyText?: string }).bodyText = errText; + throw err; } return; } - twoFactor.reveal = true; + twoFactor.declineConsent(); if (result.outcome === 'sca-unavailable') { - throw new Error(`${VERIFICATION_REQUIRED_MESSAGE} ${SCA_UNAVAILABLE_2FA_FALLBACK_MESSAGE}`); + throw new Error(SCA_REFUSAL_MESSAGE_TILL); } throw new Error(CARD_VERIFICATION_RETRY_MESSAGE); } finally { @@ -898,6 +979,18 @@ {/if} + + { + lastSCAOutcome = ''; + paymentError = null; + }} + /> + @@ -909,8 +1002,7 @@ {#if twoFactor.showInput} + + +{/if} diff --git a/frontend/src/lib/components/payments/SquareCardInput.svelte b/frontend/src/lib/components/payments/SquareCardInput.svelte index 4773c00..690bec0 100644 --- a/frontend/src/lib/components/payments/SquareCardInput.svelte +++ b/frontend/src/lib/components/payments/SquareCardInput.svelte @@ -3,9 +3,8 @@ getSquarePayments, isSquareConfigured, isSquareMock, - parseTokenizeVerificationResult, type SquareTokenizeResult, - type SquareVerificationContact + type SquareVerificationContact as SquareContactType } from '$lib/square/square'; /** Re-exported for the payment surfaces that import these from this @@ -18,10 +17,8 @@ /** Re-exported from square.ts — the single shared home of the saved-card SCA * logic (tokenizer + the proactive runner). Kept here so the six payment * surfaces' existing imports stay unchanged. */ - export { - tokenizeSavedCardWithVerification, - type SquareVerificationContact - } from '$lib/square/square'; + export { tokenizeSavedCardWithVerification } from '$lib/square/square'; + export type { SquareVerificationContact } from '$lib/square/square'; /** Result of a tokenize-with-verification call. */ export interface TokenizeWithVerificationResult { @@ -31,10 +28,10 @@ /** Square Web Payments `card.tokenize()` verificationDetails shape. */ interface SquareVerificationDetails { - amount: string; - billingContact?: SquareVerificationContact; + amount?: string; + billingContact?: SquareContactType; intent: string; - currencyCode: string; + currencyCode?: string; customerInitiated: boolean; sellerKeyedIn: boolean; } @@ -188,9 +185,10 @@ * Authentication for most online payments — without verificationDetails, * Square rejects in-scope cards with CARD_DECLINED_VERIFICATION_REQUIRED. * - * Returns BOTH the card nonce and the verification token, which the caller - * must send to the backend as `verification_token` alongside - * `new_card_token`/`card_token`. + * In the CURRENT SDK the SCA-verified tokenize-result (`result.token`) is + * the one-time source for the charge — there is no separate verification + * token (that nested shape only came from the deprecated verifyBuyer() + * flow), so the caller sends the returned `nonce` as the charge source. * * @param amount The amount that WILL be charged, in pence (minor units). * Square requires this to match the eventual payment amount. @@ -209,7 +207,7 @@ */ export async function tokenizeWithVerification( amount: number, - contact?: SquareVerificationContact, + contact?: SquareContactType, saveCard: boolean = false ): Promise { if (isSquareMock()) { @@ -258,6 +256,49 @@ .join(', ') || 'Card details are incomplete'; throw new Error(detail); } + + /** + * Tokenizes the entered card for SAVE-ONLY (the account "Add a card" + * flow): intent `STORE`, no amount, no currencyCode — Square's current + * card-on-file save contract. The SCA challenge runs at tokenization, so + * the returned token is the SCA-verified token the backend stores as the + * card's source (M11: the 2FA gate must not fire for an SCA-authorised + * save). Pass the billing contact we already hold when available. + */ + export async function tokenizeForStore(contact?: SquareContactType): Promise<{ token: string }> { + if (isSquareMock()) { + if (!mockForm) { + throw new Error('Card form is not ready — please wait a moment and try again'); + } + return mockForm.tokenizeForStore(contact); + } + const card = cardInstance as { + tokenize: (verificationDetails: SquareVerificationDetails) => Promise; + } | null; + if (!card) { + throw new Error('Card form is not ready — please wait a moment and try again'); + } + const verificationDetails: SquareVerificationDetails = { + // STORE intent — no amount: saving a card does not charge, so there + // is no amount to bind the SCA challenge to. + intent: 'STORE', + customerInitiated: true, + sellerKeyedIn: false + }; + if (contact && (contact.givenName || contact.familyName || contact.email)) { + verificationDetails.billingContact = contact; + } + const result = await card.tokenize(verificationDetails); + if (result.status === 'OK' && result.token) { + return { token: result.token }; + } + const detail = + result.errors + ?.map((e) => e.message || e.code) + .filter(Boolean) + .join(', ') || 'Card details are incomplete'; + throw new Error(detail); + } {#if isSquareMock()} diff --git a/frontend/src/lib/components/payments/TipPayment.svelte b/frontend/src/lib/components/payments/TipPayment.svelte index 940f4cf..3da1d3f 100644 --- a/frontend/src/lib/components/payments/TipPayment.svelte +++ b/frontend/src/lib/components/payments/TipPayment.svelte @@ -23,11 +23,13 @@ isVerificationRequiredSignal, runSavedCardSCAProactively, sanitizeDecimalInput, - shouldFallbackTo2FA, + scaFallbackConsentFields, + shouldShowSCARefusal, submitPaymentWithRetry, VERIFICATION_REQUIRED_MESSAGE } from '$lib/square/square'; import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte'; + import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte'; // Shared tip-payment UI used by /tip, /pay-tip/[id] and the account // booking-modal tip dialog. The routes resolve the booking (most-recent past @@ -36,7 +38,9 @@ // idempotency-key derivation, submitTip, success and error handling — lives // in ONE place so the three surfaces can't diverge. When embedded in a modal // (UserBookingModal), `onSuccess` lets the host close itself and refresh - // instead of navigating home; standalone pages omit it. + // instead of navigating home; standalone pages omit it. `onCancel` lets a + // modal host close itself when the customer chooses "Cancel and pay later" + // on the SCA-unavailable consent notice. type BookingService = { service_id: string; booking_id: string; @@ -65,7 +69,11 @@ payments?: Payment[]; }; - const { booking, onSuccess }: { booking: Booking; onSuccess?: () => void } = $props(); + const { + booking, + onSuccess, + onCancel + }: { booking: Booking; onSuccess?: () => void; onCancel?: () => void } = $props(); let paymentState = $state<'idle' | 'processing' | 'success' | 'error'>('idle'); // Synchronous double-click guard for submitTip. paymentState is only set to @@ -118,9 +126,9 @@ // new code" handler) — see $lib/stores/twoFactorCode.svelte.ts. const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode); const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled); - // Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA - // from backup to the only available gate (scaAvailable → false); every other - // outcome keeps SCA primary for the next retry. + // Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the + // C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA + // fallback); every other outcome keeps SCA primary for the next retry. let lastSCAOutcome = $state(''); // True while the proactive saved-card SCA challenge is in flight (the buyer // approves in their banking app) — drives the "approve in banking app" panel. @@ -132,7 +140,9 @@ const twoFactor = useTwoFactorCodeForSavedCard({ enabled: () => twoFactorEnabled, gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard), - scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome) + // C6 SCA-only posture: SCA is ALWAYS the authorisation — the code input + // only ever surfaces via a backend gate rejection (defensive/opt-in). + scaAvailable: () => true }); const isCardValid = $derived(cardSelectionValid); @@ -334,6 +344,15 @@ toast.error(tipError); return; } + if (proactive.outcome === 'sca-unavailable') { + // C6: SCA genuinely can't run. Abort this attempt BEFORE + // any charge is submitted and surface the refusal notice — + // there is NO 2FA fallback; the tip is paid online later. + twoFactor.declineConsent(); + twoFactor.reveal = false; + paymentState = 'idle'; + return; + } if (proactive.verificationToken) verificationToken = proactive.verificationToken; } finally { waitingForSCA = false; @@ -344,8 +363,14 @@ idempotency_key: tipIdempotencyKey, ...(selectedCardId ? { card_id: selectedCardId } : {}), ...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}), - ...(verificationToken ? { verification_token: verificationToken } : {}), - ...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}) + // C1: the SCA tokenize-result token for a saved card is the + // charge SOURCE (new_card_token) alongside the card ref — + // never the legacy verification_token. + ...(verificationToken ? { new_card_token: verificationToken } : {}), + ...(twoFactor.showInput && !verificationToken + ? { verification_code: twoFactor.code } + : {}), + ...scaFallbackConsentFields(twoFactor.consentAccepted) }; const response = await submitPaymentWithRetry( @@ -398,6 +423,11 @@ const bodyText = (err as { bodyText?: string })?.bodyText ?? ''; const verificationFailure = isVerificationRequiredSignal(responseStatus, bodyText); if (verificationFailure) { + // M13: a verification-required 402 means the backend did NOT + // accept the fallback code (SCA-only posture / invalid token) + // — withdraw consent so the code input never reappears and + // the user sees the SCA guidance instead of looping on 2FA. + twoFactor.declineConsent(); errorMessage = VERIFICATION_REQUIRED_MESSAGE; } // B6/B10: a 2FA verification-gate rejection (missing/invalid/expired @@ -574,6 +604,18 @@ onValidityChange={(v) => (cardSelectionValid = v)} /> + + { + lastSCAOutcome = ''; + tipError = null; + onCancel?.(); + }} + /> + twoFactorEnabled, gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard), - scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome) + // C6 SCA-only posture: SCA is ALWAYS the authorisation — the code input + // only ever surfaces via a backend gate rejection (defensive/opt-in). + scaAvailable: () => true }); type PaymentStatus = 'idle' | 'processing' | 'success' | 'error'; @@ -518,6 +522,15 @@ toast.error(error); return; } + if (proactive.outcome === 'sca-unavailable') { + // C6: SCA genuinely can't run. Abort this attempt BEFORE any + // charge is submitted and surface the refusal notice — there + // is NO 2FA fallback; the user pays online later. + twoFactor.declineConsent(); + twoFactor.reveal = false; + status = 'idle'; + return; + } if (proactive.verificationToken) verificationToken = proactive.verificationToken; } finally { waitingForSCA = false; @@ -562,8 +575,14 @@ ...(confirmOverflowTip ? { confirm_overflow_tip: true } : {}), ...(cardId ? { card_id: cardId } : {}), ...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}), - ...(verificationToken ? { verification_token: verificationToken } : {}), - ...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}), + // C1: the SCA tokenize-result token for a saved card is + // the charge SOURCE (new_card_token) alongside the + // card ref — never the legacy verification_token. + ...(verificationToken ? { new_card_token: verificationToken } : {}), + ...(twoFactor.showInput && !verificationToken + ? { verification_code: twoFactor.code } + : {}), + ...scaFallbackConsentFields(twoFactor.consentAccepted), idempotency_key: payIdempotencyKey }) }), @@ -659,6 +678,11 @@ const bodyText = (_err as { bodyText?: string })?.bodyText ?? ''; const verificationFailure = isVerificationRequiredSignal(responseStatus, bodyText); if (verificationFailure) { + // M13: a verification-required 402 means the backend did NOT + // accept the fallback code (SCA-only posture / invalid token) — + // withdraw consent so the code input never reappears and the + // user sees the SCA guidance instead of looping on 2FA. + twoFactor.declineConsent(); msg = VERIFICATION_REQUIRED_MESSAGE; } // B6/B10: a 2FA verification-gate rejection (missing/invalid/expired @@ -1077,6 +1101,17 @@ {/if} {/if} + + { + lastSCAOutcome = ''; + handleClose(); + }} + /> +