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:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user