fix: review round — B1 clock-skew tolerance + re-poll escalation, refresh-token access-token revocation, shared 2FA composable, per-package-DB test alignment
Three fresh reviews (money/security/dup-mod) cross-validated findings: - MEDIUM: B1 'new charge' discrimination adds a lower-bound tolerance (replayRescueLowerBoundSkew) so a retained-key replay of the ORIGINAL charge (DB clock ahead of Square) is never auto-refunded; ambiguous margins leave PENDING + CRITICAL - MEDIUM: B1 re-poll escalates after stalePendingB1RefundAge (48h) — FAILED/REJECTED refunds go terminal (fail parent, claw back till-sale funding, CRITICAL notification); no more unbounded re-polling / stranded parents without webhooks - DRIFT-REAL: processManualPaymentGroup now checks PENDING/FAILED/REJECTED on the synchronous refund response (mirrors processChargeGroup/manual handler) — no more premature 'completed' - HIGH: refresh-token family kill now also invalidates the attacker's freshly-minted ACCESS token — access tokens carry a family_id claim and VerifyToken rejects tokens whose family was deleted (GenerateTokenForFamily + family-alive check); 30s grace window for concurrent two-tab refresh (no false theft alert) - LOW: 2FA mint endpoint returns remaining_seconds; in-memory 2FA counters documented; 90-day refresh expiry single-sourced (RefreshTokenLifetime + make_interval) - Dup/mod: NEW shared useTwoFactorCodeForSavedCard Svelte composable replaces 6 surface copies of the 2FA gate logic (Request-a-new-code added to BookingFlow + TillPurchases); account page adopts generateUUID - Test architecture: removed t.Parallel() from 8 global-SquareClient-swapping tests per Testing Architecture doc line 89 (B1 flaky-test lesson) — fixes within-package race - SQL alias pence rename (total_cents/paid_cents -> total_pence/paid_pence) 26/26 backend packages; 72/72 frontend tests + build; env-docs 41/41.
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||
|
||||
type CartItem = {
|
||||
id: string;
|
||||
@@ -112,22 +113,16 @@
|
||||
// gate. The backend keys on the CARD OWNER (not the admin), so the input is
|
||||
// surfaced whenever the gate is enforced — the operator relays the
|
||||
// customer's code. Cash, card machine, and online (new-card nonce) payments
|
||||
// are unaffected.
|
||||
// are unaffected. Shared two-factor-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, so `enabled` is always true.
|
||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||
|
||||
// B6/B10: verification code for the customer's saved-card till charge,
|
||||
// collected on the saved-card payment screen. Kept populated across retries
|
||||
// so an invalid/expired code can be corrected without re-typing it. 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 session user's flag is unset.
|
||||
let reveal2FACodeInput = $state(false);
|
||||
const show2FACodeInput = $derived(
|
||||
reveal2FACodeInput || (savedCardChargeRequires2FACode && paymentMethod === 'saved_card')
|
||||
);
|
||||
const missing2FACode = $derived(show2FACodeInput && twoFactorCode.trim() === '');
|
||||
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => true,
|
||||
gateActive: () => savedCardChargeRequires2FACode && paymentMethod === 'saved_card'
|
||||
});
|
||||
|
||||
// The saved-card option is hidden outright unless a customer is selected
|
||||
// AND has at least one currently-valid card on file.
|
||||
@@ -327,7 +322,7 @@
|
||||
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 (show2FACodeInput) body.verification_code = twoFactorCode;
|
||||
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');
|
||||
@@ -367,14 +362,14 @@
|
||||
toast.success('Sale complete');
|
||||
cart = [];
|
||||
idempotencyKeys.clear();
|
||||
twoFactorCode = '';
|
||||
reveal2FACodeInput = false;
|
||||
twoFactor.setCode('');
|
||||
twoFactor.reveal = false;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Sale failed';
|
||||
// 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)) reveal2FACodeInput = true;
|
||||
if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
|
||||
paymentError = msg;
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
@@ -775,7 +770,23 @@
|
||||
<!-- B6/B10: saved-card till charges require the customer's
|
||||
current 2FA 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 twoFactor.showInput}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
loading={twoFactor.requesting}
|
||||
disabled={twoFactor.requesting}
|
||||
onclick={twoFactor.requestNewCode}
|
||||
>
|
||||
Request a new code
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -798,7 +809,7 @@
|
||||
loading={processing}
|
||||
disabled={!canCharge ||
|
||||
processing ||
|
||||
missing2FACode ||
|
||||
twoFactor.missing ||
|
||||
(paymentMethod === 'online_square' && !onlineSquareCardReady) ||
|
||||
(paymentMethod === 'saved_card' && !selectedSavedCardId)}
|
||||
>
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
} from '$lib/square/square';
|
||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
|
||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||
import {
|
||||
formatLocalDateTime,
|
||||
getLondonTodayCalendarDate,
|
||||
@@ -149,27 +150,16 @@
|
||||
// B6/B10: saved-card deposits (and saving a new card for reuse) require the
|
||||
// customer's current 2FA verification code whenever the backend enforces the
|
||||
// gate. The input is surfaced at the charge step; the new-card (nonce) path
|
||||
// keeps its own SCA via Square tokenizeWithVerification.
|
||||
// keeps its own SCA via Square tokenizeWithVerification. Shared
|
||||
// two-factor-code state (code, reveal, show/missing derivations, "Request a
|
||||
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
|
||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
||||
|
||||
// B6/B10: verification code for a saved-card deposit / new-card save. The
|
||||
// backend requires the card owner's CURRENT one-time code when 2FA is
|
||||
// enforced (delivered via the server log / email-SMS channel and relayed by
|
||||
// the operator). Kept populated across retries so an invalid/expired code can
|
||||
// be corrected without re-typing it.
|
||||
let depositTwoFactorCode = $state('');
|
||||
// Set true when a charge 403s for a missing code: the backend keys on the
|
||||
// CARD OWNER, so even a session user whose own flag is unset must be able to
|
||||
// enter the code.
|
||||
let reveal2FACodeInput = $state(false);
|
||||
const show2FACodeInput = $derived(
|
||||
reveal2FACodeInput ||
|
||||
(savedCardChargeRequires2FACode && (selectedPaymentMethod !== '' || depositSaveCard))
|
||||
);
|
||||
const missing2FACode = $derived(
|
||||
show2FACodeInput && twoFactorEnabled && depositTwoFactorCode.trim() === ''
|
||||
);
|
||||
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => twoFactorEnabled,
|
||||
gateActive: () =>
|
||||
savedCardChargeRequires2FACode && (selectedPaymentMethod !== '' || depositSaveCard)
|
||||
});
|
||||
|
||||
const depositCardFormValid = $derived(paymentCardSelectionValid);
|
||||
|
||||
@@ -431,7 +421,7 @@
|
||||
...(selectedPaymentMethod ? { card_id: selectedPaymentMethod } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: depositSaveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
...(show2FACodeInput ? { verification_code: depositTwoFactorCode } : {})
|
||||
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {})
|
||||
};
|
||||
|
||||
paymentAttempted = true;
|
||||
@@ -502,8 +492,8 @@
|
||||
depositTokenizedAt = 0;
|
||||
depositTokenizedForSaveCard = false;
|
||||
depositSaveCard = false;
|
||||
depositTwoFactorCode = '';
|
||||
reveal2FACodeInput = false;
|
||||
twoFactor.setCode('');
|
||||
twoFactor.reveal = false;
|
||||
overflowConfirm = null;
|
||||
// Immutable update — avoid mutating the existing object so
|
||||
// concurrent renders (e.g. a stale fetch) can't observe partial
|
||||
@@ -524,7 +514,7 @@
|
||||
// code, brute-force lockout) is recoverable — keep the code populated
|
||||
// and reveal the input so the deposit can be retried with a fresh code.
|
||||
if (isTwoFactorVerificationGateFailure(response.status, extractErrorMessage(text))) {
|
||||
reveal2FACodeInput = true;
|
||||
twoFactor.reveal = true;
|
||||
}
|
||||
// Pre-start overpayment guard on stale booking data: park the rejected
|
||||
// request (body + amount) and surface the Confirm/Cancel prompt instead
|
||||
@@ -2661,44 +2651,56 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="mb-6">
|
||||
<CardSelection
|
||||
bind:this={paymentCardSelection}
|
||||
cards={[]}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId={selectedPaymentMethod}
|
||||
bind:saveCard={depositSaveCard}
|
||||
onValidityChange={(v) => (paymentCardSelectionValid = v)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="mb-6">
|
||||
<CardSelection
|
||||
bind:this={paymentCardSelection}
|
||||
cards={[]}
|
||||
{canSaveCards}
|
||||
bind:selectedCardId={selectedPaymentMethod}
|
||||
bind:saveCard={depositSaveCard}
|
||||
onValidityChange={(v) => (paymentCardSelectionValid = v)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- B6/B10: saved-card deposits require the customer's
|
||||
<!-- B6/B10: saved-card deposits require the customer's
|
||||
current 2FA verification code when the backend
|
||||
enforces the gate. -->
|
||||
<div class="mb-6">
|
||||
<TwoFactorCodeInput
|
||||
bind:code={depositTwoFactorCode}
|
||||
showInput={show2FACodeInput}
|
||||
enabled={twoFactorEnabled}
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<TwoFactorCodeInput
|
||||
bind:code={twoFactor.code}
|
||||
showInput={twoFactor.showInput}
|
||||
enabled={twoFactorEnabled}
|
||||
/>
|
||||
{#if twoFactor.showInput && twoFactorEnabled}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
loading={twoFactor.requesting}
|
||||
disabled={twoFactor.requesting}
|
||||
onclick={twoFactor.requestNewCode}
|
||||
>
|
||||
Request a new code
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between border-t pt-4">
|
||||
<Button variant="ghost" onclick={prevStep} disabled={isProcessingPayment}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isProcessingPayment || !depositCardFormValid || missing2FACode}
|
||||
onclick={() => processPayment(calculateDepositAmount())}
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
{isProcessingPayment
|
||||
? 'Processing...'
|
||||
: `Pay Deposit £${calculateDepositAmount()}`}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-t pt-4">
|
||||
<Button variant="ghost" onclick={prevStep} disabled={isProcessingPayment}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isProcessingPayment || !depositCardFormValid || twoFactor.missing}
|
||||
onclick={() => processPayment(calculateDepositAmount())}
|
||||
class="bg-primary text-primary-foreground"
|
||||
>
|
||||
{isProcessingPayment
|
||||
? 'Processing...'
|
||||
: `Pay Deposit £${calculateDepositAmount()}`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-center text-xs text-gray-500">
|
||||
Secure payment powered by Square
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
import CardSelection from '$lib/components/payments/CardSelection.svelte';
|
||||
import { savedCardsStore, type SavedCard } from '$lib/stores/savedCards.svelte';
|
||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
@@ -17,7 +18,6 @@
|
||||
isNonceStale,
|
||||
isSavedCardVerificationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
requestNewTwoFactorCode,
|
||||
sanitizeDecimalInput,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
@@ -108,51 +108,15 @@
|
||||
// B6/B10: saved-card tips (and saving a new card for reuse) require the
|
||||
// customer's current 2FA verification code whenever the backend enforces the
|
||||
// gate. The input is surfaced at the charge step; the new-card (nonce) path
|
||||
// keeps its own SCA via Square tokenizeWithVerification.
|
||||
// keeps its own SCA via Square tokenizeWithVerification. Shared
|
||||
// two-factor-code state (code, reveal, show/missing derivations, "Request a
|
||||
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
|
||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
||||
|
||||
// B6/B10: verification code for a saved-card tip / new-card save. The backend
|
||||
// requires the card owner's CURRENT one-time code when 2FA is enforced
|
||||
// (delivered via the server log / email-SMS channel and relayed by the
|
||||
// operator). Kept populated across retries so an invalid/expired code can be
|
||||
// corrected without re-typing it.
|
||||
let twoFactorCode = $state('');
|
||||
// Set true when a charge 403s for a missing code: the backend keys on the
|
||||
// CARD OWNER, so even a session user whose own flag is unset must be able to
|
||||
// enter the code.
|
||||
let reveal2FACodeInput = $state(false);
|
||||
const show2FACodeInput = $derived(
|
||||
reveal2FACodeInput || (savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard))
|
||||
);
|
||||
const missing2FACode = $derived(
|
||||
show2FACodeInput && twoFactorEnabled && twoFactorCode.trim() === ''
|
||||
);
|
||||
|
||||
// POST /api/user/2fa/code mint state for the "Request a new code" button
|
||||
// (session user = card owner, so a minted code authorizes their charge).
|
||||
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;
|
||||
}
|
||||
}
|
||||
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => twoFactorEnabled,
|
||||
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard)
|
||||
});
|
||||
|
||||
const isCardValid = $derived(cardSelectionValid);
|
||||
|
||||
@@ -335,7 +299,7 @@
|
||||
...(selectedCardId ? { card_id: selectedCardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
...(show2FACodeInput ? { verification_code: twoFactorCode } : {})
|
||||
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {})
|
||||
};
|
||||
|
||||
const response = await submitPaymentWithRetry(() =>
|
||||
@@ -361,8 +325,8 @@
|
||||
tipTokenAmount = 0;
|
||||
tipTokenizedAt = 0;
|
||||
tipTokenizedForSaveCard = false;
|
||||
twoFactorCode = '';
|
||||
reveal2FACodeInput = false;
|
||||
twoFactor.setCode('');
|
||||
twoFactor.reveal = false;
|
||||
toast.success('Thank you for your tip!');
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
@@ -379,7 +343,7 @@
|
||||
// code, brute-force lockout) is recoverable — keep the code populated
|
||||
// and reveal the input so the tip can be retried with a fresh code.
|
||||
if (isTwoFactorVerificationGateFailure(responseStatus, errorMessage)) {
|
||||
reveal2FACodeInput = true;
|
||||
twoFactor.reveal = true;
|
||||
}
|
||||
toast.error(errorMessage);
|
||||
// A definitive charge failure (e.g. declined card) consumes the nonce
|
||||
@@ -539,18 +503,18 @@
|
||||
<!-- B6/B10: saved-card tips require the customer's current 2FA
|
||||
verification code when the backend enforces the gate. -->
|
||||
<TwoFactorCodeInput
|
||||
bind:code={twoFactorCode}
|
||||
showInput={show2FACodeInput}
|
||||
bind:code={twoFactor.code}
|
||||
showInput={twoFactor.showInput}
|
||||
enabled={twoFactorEnabled}
|
||||
/>
|
||||
{#if show2FACodeInput && twoFactorEnabled}
|
||||
{#if twoFactor.showInput && twoFactorEnabled}
|
||||
<Button
|
||||
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
|
||||
</Button>
|
||||
@@ -569,7 +533,7 @@
|
||||
<Button
|
||||
class="w-full"
|
||||
size="lg"
|
||||
disabled={tipAmount <= 0 || !isCardValid || paymentState === 'processing' || missing2FACode}
|
||||
disabled={tipAmount <= 0 || !isCardValid || paymentState === 'processing' || twoFactor.missing}
|
||||
loading={paymentState === 'processing'}
|
||||
onclick={submitTip}
|
||||
>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { savedCardsStore } from '$lib/stores/savedCards.svelte';
|
||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
import {
|
||||
@@ -20,7 +21,6 @@
|
||||
isOverflowTipConfirmationRequired,
|
||||
isSavedCardVerificationRequired,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
requestNewTwoFactorCode,
|
||||
sanitizeDecimalInput,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
@@ -51,60 +51,21 @@
|
||||
// B6/B10: saved-card charges (and saving a new card for reuse) require the
|
||||
// customer's current 2FA verification code whenever the backend enforces the
|
||||
// gate. The input is surfaced at the charge step; the new-card (nonce) path
|
||||
// keeps its own SCA via Square tokenizeWithVerification.
|
||||
// keeps its own SCA via Square tokenizeWithVerification. Shared
|
||||
// two-factor-code state (code, reveal, show/missing derivations, "Request a
|
||||
// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
|
||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||
const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
||||
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => twoFactorEnabled,
|
||||
gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard)
|
||||
});
|
||||
|
||||
type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';
|
||||
|
||||
let status = $state<PaymentStatus>('idle');
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// B6/B10: the verification code for a saved-card charge / new-card save. The
|
||||
// backend requires the card owner's CURRENT one-time code when 2FA is
|
||||
// enforced (delivered via the server log / email-SMS channel and relayed by
|
||||
// the operator). Kept populated across retries so an invalid/expired code can
|
||||
// be corrected without re-typing it.
|
||||
let twoFactorCode = $state('');
|
||||
// Set true when a charge 403s for a missing code: the backend keys on the
|
||||
// CARD OWNER, so even a session user whose own flag is unset must be able to
|
||||
// enter the code. Revealing the input makes the failure recoverable.
|
||||
let reveal2FACodeInput = $state(false);
|
||||
|
||||
// Show the code input whenever the pending charge hits the backend's 2FA
|
||||
// gate: charging a saved card OR saving the new card for reuse.
|
||||
const show2FACodeInput = $derived(
|
||||
reveal2FACodeInput || (savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard))
|
||||
);
|
||||
const missing2FACode = $derived(
|
||||
show2FACodeInput && twoFactorEnabled && twoFactorCode.trim() === ''
|
||||
);
|
||||
|
||||
// POST /api/user/2fa/code mint state for the "Request a new code" button
|
||||
// (session user = card owner, so a minted code authorizes their charge).
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Cached idempotency key per payment attempt (amount + type + card): reused
|
||||
// on retry so a lost-response retry dedups instead of double-charging,
|
||||
// regenerated when any of those change. Matches the tip-flow pattern.
|
||||
@@ -542,7 +503,7 @@
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
...(show2FACodeInput ? { verification_code: twoFactorCode } : {}),
|
||||
...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
|
||||
idempotency_key: payIdempotencyKey
|
||||
})
|
||||
})
|
||||
@@ -593,8 +554,8 @@
|
||||
newCardTokenAmount = 0;
|
||||
newCardTokenizedAt = 0;
|
||||
newCardTokenizedForSaveCard = false;
|
||||
twoFactorCode = '';
|
||||
reveal2FACodeInput = false;
|
||||
twoFactor.setCode('');
|
||||
twoFactor.reveal = false;
|
||||
paymentResult = {
|
||||
id: data.id,
|
||||
amount: data.amount,
|
||||
@@ -619,7 +580,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(verificationFailure ? msg : `${msg}. Please try again or use another card.`);
|
||||
// A definitive charge failure consumes the nonce + SCA verification
|
||||
@@ -1023,18 +984,18 @@
|
||||
<!-- B6/B10: saved-card charges require the customer's current 2FA
|
||||
verification code when the backend enforces the gate. -->
|
||||
<TwoFactorCodeInput
|
||||
bind:code={twoFactorCode}
|
||||
showInput={show2FACodeInput}
|
||||
bind:code={twoFactor.code}
|
||||
showInput={twoFactor.showInput}
|
||||
enabled={twoFactorEnabled}
|
||||
/>
|
||||
{#if show2FACodeInput && twoFactorEnabled}
|
||||
{#if twoFactor.showInput && twoFactorEnabled}
|
||||
<Button
|
||||
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
|
||||
</Button>
|
||||
@@ -1084,7 +1045,7 @@
|
||||
onclick={() => (paymentType === 'deposit' ? handlePayDeposit() : handlePayFull())}
|
||||
class="w-full"
|
||||
loading={status === 'processing'}
|
||||
disabled={payButtonDisabled || missing2FACode}
|
||||
disabled={payButtonDisabled || twoFactor.missing}
|
||||
>
|
||||
{#if paymentType === 'deposit'}
|
||||
Pay Deposit ({formatCurrency(
|
||||
@@ -1165,7 +1126,7 @@
|
||||
onclick={() => (paymentType === 'partial' ? handlePayPartial() : handlePayFull())}
|
||||
class="w-full"
|
||||
loading={status === 'processing'}
|
||||
disabled={payButtonDisabled || missing2FACode}
|
||||
disabled={payButtonDisabled || twoFactor.missing}
|
||||
>
|
||||
{#if paymentType === 'partial'}
|
||||
Pay {partialAmountValid
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// src/lib/stores/twoFactorCode.svelte.ts
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { requestNewTwoFactorCode } from '$lib/square/square';
|
||||
|
||||
/**
|
||||
* Shared 2FA verification-code state for the saved-card charge surfaces.
|
||||
*
|
||||
* B6/B10: the backend's requireTwoFactorForCardAccess gate requires the CARD
|
||||
* OWNER's current one-time verification code on every saved-card charge in an
|
||||
* enforced environment. This composable owns the whole verification-code UX —
|
||||
* the code itself, the reveal flag (a charge that 403s for a missing code
|
||||
* reveals the input even when the session profile's 2FA flag is stale), the
|
||||
* show/missing derivations and the "Request a new code" handler — so the six
|
||||
* payment surfaces (booking modal, tip, account gift-card, booking-flow
|
||||
* deposit, admin till and admin payment modal) can't drift on any of them.
|
||||
*
|
||||
* Each surface supplies its own predicates:
|
||||
* - `enabled()` — whether the session user's own 2FA is active (customer
|
||||
* surfaces: `!!authStore.currentUser?.twoFactorEnabled`).
|
||||
* Admin surfaces (till, admin payment modal) return true:
|
||||
* the operator always supplies the CUSTOMER's code, so the
|
||||
* session user's own flag is irrelevant to the gate.
|
||||
* - `gateActive()` — whether the pending charge hits the 2FA gate: a saved
|
||||
* card is selected, or a new card is being saved for reuse.
|
||||
* The surface passes its exact gate expression so each
|
||||
* surface's gate semantics are preserved verbatim.
|
||||
*/
|
||||
export function useTwoFactorCodeForSavedCard(options: {
|
||||
enabled: () => boolean;
|
||||
gateActive: () => boolean;
|
||||
}) {
|
||||
// Kept populated across retries so an invalid/expired code can be corrected
|
||||
// without re-typing it.
|
||||
let code = $state('');
|
||||
// Set true when a charge 403s for a missing code: the backend keys on the
|
||||
// CARD OWNER, so even a session user whose own flag is unset must be able
|
||||
// to enter the code. Revealing the input makes the failure recoverable.
|
||||
let reveal = $state(false);
|
||||
// POST /api/user/2fa/code mint state for the "Request a new code" button
|
||||
// (session user = card owner, so a minted code authorizes their charge).
|
||||
let requesting = $state(false);
|
||||
|
||||
// Show the code input whenever the pending charge hits the backend's 2FA
|
||||
// gate: charging a saved card OR saving the new card for reuse.
|
||||
const showInput = $derived(reveal || options.gateActive());
|
||||
const missing = $derived(showInput && options.enabled() && code.trim() === '');
|
||||
|
||||
async function requestNewCode() {
|
||||
if (requesting) return;
|
||||
requesting = true;
|
||||
try {
|
||||
const result = await requestNewTwoFactorCode();
|
||||
if (result.ok) {
|
||||
code = '';
|
||||
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 {
|
||||
requesting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setCode(value: string) {
|
||||
code = value;
|
||||
}
|
||||
|
||||
return {
|
||||
get code() {
|
||||
return code;
|
||||
},
|
||||
set code(value: string) {
|
||||
code = value;
|
||||
},
|
||||
setCode,
|
||||
get reveal() {
|
||||
return reveal;
|
||||
},
|
||||
set reveal(value: boolean) {
|
||||
reveal = value;
|
||||
},
|
||||
get showInput() {
|
||||
return showInput;
|
||||
},
|
||||
get missing() {
|
||||
return missing;
|
||||
},
|
||||
get requesting() {
|
||||
return requesting;
|
||||
},
|
||||
requestNewCode
|
||||
};
|
||||
}
|
||||
@@ -15,10 +15,11 @@
|
||||
isSavedCardVerificationRequired,
|
||||
isSquareConfigured,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
requestNewTwoFactorCode,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||
import { generateUUID } from '$lib/utils/uuid';
|
||||
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
|
||||
@@ -251,50 +252,15 @@
|
||||
|
||||
// B6/B10: gift-card buys charge a saved card (or save a new card for reuse)
|
||||
// whenever the backend enforces the 2FA gate — the CARD OWNER's current
|
||||
// verification code must be carried on the charge. Mirrors the customer
|
||||
// surface (UserPaymentModal). Kept populated across retries so an
|
||||
// invalid/expired code can be corrected without re-typing it.
|
||||
let buyTwoFactorCode = $state('');
|
||||
// Set true when a charge 403s for a missing code — reveals the input even
|
||||
// if the session profile's 2FA flag is stale, making the failure
|
||||
// recoverable.
|
||||
let buyReveal2FACodeInput = $state(false);
|
||||
// verification code must be carried on the charge. Shared two-factor-code
|
||||
// state (code, reveal, show/missing derivations, "Request a new code"
|
||||
// handler) — see $lib/stores/twoFactorCode.svelte.ts.
|
||||
const buyTwoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
|
||||
const buySavedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||
// Show the code input whenever the pending charge hits the backend's 2FA
|
||||
// gate: charging a saved card OR saving the new card for reuse.
|
||||
const buyShow2FACodeInput = $derived(
|
||||
buyReveal2FACodeInput ||
|
||||
(buySavedCardChargeRequires2FACode && (buySelectedCard !== '' || buySaveCard))
|
||||
);
|
||||
const buyMissing2FACode = $derived(
|
||||
buyShow2FACodeInput && buyTwoFactorEnabled && buyTwoFactorCode.trim() === ''
|
||||
);
|
||||
|
||||
// POST /api/user/2fa/code mint state for the "Request a new code" button
|
||||
// (session user = card owner, so a minted code authorizes their charge).
|
||||
let buyRequesting2FACode = $state(false);
|
||||
async function handleBuyRequestNew2FACode() {
|
||||
if (buyRequesting2FACode) return;
|
||||
buyRequesting2FACode = true;
|
||||
try {
|
||||
const result = await requestNewTwoFactorCode();
|
||||
if (result.ok) {
|
||||
buyTwoFactorCode = '';
|
||||
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 {
|
||||
buyRequesting2FACode = false;
|
||||
}
|
||||
}
|
||||
const buyTwoFactor = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => buyTwoFactorEnabled,
|
||||
gateActive: () => buySavedCardChargeRequires2FACode && (buySelectedCard !== '' || buySaveCard)
|
||||
});
|
||||
|
||||
// Client-side mirror of the £500/day online purchase cap. The backend is
|
||||
// authoritative — this counter only reflects confirmed purchases made in
|
||||
@@ -496,7 +462,7 @@
|
||||
// (where the charge actually landed) would double-charge.
|
||||
const cardKey = cardId || 'new-card';
|
||||
if (!buyIdempotencyKey || buyKeyedAmount !== buyAmount || buyKeyedCard !== cardKey) {
|
||||
buyIdempotencyKey = generateIdempotencyKey();
|
||||
buyIdempotencyKey = generateUUID();
|
||||
buyKeyedAmount = buyAmount;
|
||||
buyKeyedCard = cardKey;
|
||||
}
|
||||
@@ -512,7 +478,7 @@
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: buySaveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
...(buyShow2FACodeInput ? { verification_code: buyTwoFactorCode } : {}),
|
||||
...(buyTwoFactor.showInput ? { verification_code: buyTwoFactor.code } : {}),
|
||||
idempotency_key: buyIdempotencyKey
|
||||
})
|
||||
})
|
||||
@@ -533,8 +499,8 @@
|
||||
buyTokenAmount = 0;
|
||||
buyTokenizedAt = 0;
|
||||
buyTokenizedForSaveCard = false;
|
||||
buyTwoFactorCode = '';
|
||||
buyReveal2FACodeInput = false;
|
||||
buyTwoFactor.setCode('');
|
||||
buyTwoFactor.reveal = false;
|
||||
await fetchGiftCardBalance();
|
||||
} else {
|
||||
// Capture the status BEFORE consuming the body — the saved-card
|
||||
@@ -553,7 +519,7 @@
|
||||
? SAVED_CARD_VERIFICATION_MESSAGE
|
||||
: extractErrorMessage(errText) || 'Failed to purchase gift card';
|
||||
if (isTwoFactorVerificationGateFailure(status, buyErrMsg)) {
|
||||
buyReveal2FACodeInput = true;
|
||||
buyTwoFactor.reveal = true;
|
||||
}
|
||||
toast.error(buyErrMsg);
|
||||
// A definitive charge failure (e.g. declined card) consumes the
|
||||
@@ -670,24 +636,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
function generateIdempotencyKey(): string {
|
||||
const array = new Uint8Array(16);
|
||||
if (typeof window !== 'undefined' && window.crypto) {
|
||||
window.crypto.getRandomValues(array);
|
||||
} else {
|
||||
for (let i = 0; i < 16; i++) array[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
array[6] = (array[6] & 0x0f) | 0x40;
|
||||
array[8] = (array[8] & 0x3f) | 0x80;
|
||||
return [...array]
|
||||
.map((b, i) => {
|
||||
const hex = b.toString(16).padStart(2, '0');
|
||||
if (i === 4 || i === 6 || i === 8 || i === 10) return '-' + hex;
|
||||
return hex;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function fetchNotifPrefs() {
|
||||
try {
|
||||
const res = await apiFetch('/api/user/notification-preferences');
|
||||
@@ -2633,18 +2581,18 @@
|
||||
<!-- B6/B10: saved-card gift-card charges require the card owner's
|
||||
current 2FA verification code when the backend enforces the gate. -->
|
||||
<TwoFactorCodeInput
|
||||
bind:code={buyTwoFactorCode}
|
||||
showInput={buyShow2FACodeInput}
|
||||
bind:code={buyTwoFactor.code}
|
||||
showInput={buyTwoFactor.showInput}
|
||||
enabled={buyTwoFactorEnabled}
|
||||
/>
|
||||
{#if buyShow2FACodeInput && buyTwoFactorEnabled}
|
||||
{#if buyTwoFactor.showInput && buyTwoFactorEnabled}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
loading={buyRequesting2FACode}
|
||||
disabled={buyRequesting2FACode}
|
||||
onclick={handleBuyRequestNew2FACode}
|
||||
loading={buyTwoFactor.requesting}
|
||||
disabled={buyTwoFactor.requesting}
|
||||
onclick={buyTwoFactor.requestNewCode}
|
||||
>
|
||||
Request a new code
|
||||
</Button>
|
||||
@@ -2655,7 +2603,7 @@
|
||||
onclick={buyGiftCard}
|
||||
disabled={buyingGiftCard ||
|
||||
!isBuyCardValid ||
|
||||
buyMissing2FACode ||
|
||||
buyTwoFactor.missing ||
|
||||
buyDailyTotal + buyAmount > DAILY_GIFT_CARD_BUY_LIMIT}
|
||||
class="mt-2 w-full"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user