fix: admin-scoped 2FA mint targets the CUSTOMER — user authentication for saved cards, never the admin
The till and admin payment modal 'Request a new code' buttons previously called
the session-scoped POST /api/user/2fa/code, which mints a code for the ADMIN's
session — a code that can never satisfy the card-owner gate and is delivered to
the admin's log line, not the customer.
- New POST /api/admin/users/{id}/2fa/code (AdminSendVerificationCodeHandler,
RequireAdmin + per-user limiter): mints/reuses a code for the TARGET user
(the card owner/customer), keyed to the CUSTOMER's userID so the [2FA]
delivery log carries the customer's ID — the customer, never the admin, is
the authentication subject for their card
- Shared useTwoFactorCodeForSavedCard composable gains an optional mint()
option; admin surfaces (PaymentModal, TillPurchases) pass the customer-scoped
mint, customer surfaces keep the session default
- Frontend: adminRequestNewTwoFactorCode(userID) in square.ts; PaymentModal
mints for booking.user_id, TillPurchases for selectedCustomer.id
- Tests: admin mint keys the code to the customer's userID (log line contains
customer ID, NOT the admin ID) + pending hash persisted for the customer;
unknown target user 404s
Backend 26/26 packages; frontend 72/72 + build clean.
This commit is contained in:
@@ -8,11 +8,13 @@
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import SquareCardInput from '$lib/components/payments/SquareCardInput.svelte';
|
||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||
import {
|
||||
isSquareConfigured,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
submitPaymentWithRetry
|
||||
} from '$lib/square/square';
|
||||
import {
|
||||
isSquareConfigured,
|
||||
isTwoFactorVerificationGateFailure,
|
||||
submitPaymentWithRetry,
|
||||
adminRequestNewTwoFactorCode,
|
||||
requestNewTwoFactorCode
|
||||
} from '$lib/square/square';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
|
||||
|
||||
@@ -121,7 +123,9 @@
|
||||
const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
|
||||
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => true,
|
||||
gateActive: () => savedCardChargeRequires2FACode && paymentMethod === 'saved_card'
|
||||
gateActive: () => savedCardChargeRequires2FACode && paymentMethod === 'saved_card',
|
||||
mint: () =>
|
||||
selectedCustomer?.id ? adminRequestNewTwoFactorCode(selectedCustomer.id) : requestNewTwoFactorCode()
|
||||
});
|
||||
|
||||
// The saved-card option is hidden outright unless a customer is selected
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
isTwoFactorVerificationGateFailure,
|
||||
sanitizeDecimalInput,
|
||||
SAVED_CARD_VERIFICATION_MESSAGE,
|
||||
submitPaymentWithRetry
|
||||
submitPaymentWithRetry,
|
||||
adminRequestNewTwoFactorCode,
|
||||
requestNewTwoFactorCode
|
||||
} from '$lib/square/square';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
|
||||
@@ -88,7 +90,11 @@
|
||||
// irrelevant to the backend gate, so `enabled` is always true.
|
||||
const twoFactor = useTwoFactorCodeForSavedCard({
|
||||
enabled: () => true,
|
||||
gateActive: () => twoFactorEnforced && customerTwoFactorEnabled
|
||||
gateActive: () => twoFactorEnforced && customerTwoFactorEnabled,
|
||||
mint: () => {
|
||||
const customerID = booking.user_id ?? booking.user?.id;
|
||||
return customerID ? adminRequestNewTwoFactorCode(customerID) : requestNewTwoFactorCode();
|
||||
}
|
||||
});
|
||||
|
||||
// Focus the verification-code input whenever the saved-card screen shows it
|
||||
|
||||
@@ -179,6 +179,36 @@ export async function requestNewTwoFactorCode(): Promise<TwoFactorCodeRequestRes
|
||||
}
|
||||
}
|
||||
|
||||
/** Admin-scoped 2FA mint: requests a fresh code FOR the given customer (the
|
||||
* card owner) at the till/admin payment modal. The backend keys the mint to
|
||||
* the CUSTOMER's userID, so the code is delivered to the customer and can
|
||||
* satisfy the card-owner gate — the admin's session never receives or
|
||||
* authenticates the customer's card. */
|
||||
export async function adminRequestNewTwoFactorCode(userID: string): Promise<TwoFactorCodeRequestResult> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${encodeURIComponent(userID)}/2fa/code`, { method: 'POST', headers });
|
||||
if (response.ok) {
|
||||
const data = (await response.json().catch(() => null)) as { message?: unknown } | null;
|
||||
const message =
|
||||
typeof data?.message === 'string' ? data.message : 'A new verification code has been sent.';
|
||||
return { status: response.status, ok: true, message };
|
||||
}
|
||||
const body = await response.text();
|
||||
return {
|
||||
status: response.status,
|
||||
ok: false,
|
||||
message: extractServerErrorMessage(body) || 'Failed to request a new verification code'
|
||||
};
|
||||
} catch {
|
||||
return { status: 0, ok: false, message: 'Network error requesting a new code' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal `{"error"|"message": "..."}` extractor for the 2FA code-request
|
||||
* endpoint bodies (429/503), kept inline so square.ts stays import-free for
|
||||
* the vitest suite. */
|
||||
|
||||
@@ -24,10 +24,18 @@ import { requestNewTwoFactorCode } from '$lib/square/square';
|
||||
* 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.
|
||||
* - `mint()` — optional; the code-request call. Customer surfaces omit
|
||||
* it (defaults to the session-scoped /api/user/2fa/code:
|
||||
* session user == card owner). Admin surfaces MUST pass
|
||||
* () => adminRequestNewTwoFactorCode(customerUserID) so the
|
||||
* mint targets the CUSTOMER and the code is delivered to
|
||||
* them — the admin's session never authenticates the
|
||||
* customer's card.
|
||||
*/
|
||||
export function useTwoFactorCodeForSavedCard(options: {
|
||||
enabled: () => boolean;
|
||||
gateActive: () => boolean;
|
||||
mint?: () => ReturnType<typeof requestNewTwoFactorCode>;
|
||||
}) {
|
||||
// Kept populated across retries so an invalid/expired code can be corrected
|
||||
// without re-typing it.
|
||||
@@ -36,8 +44,7 @@ export function useTwoFactorCodeForSavedCard(options: {
|
||||
// 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).
|
||||
// Code-request state for the "Request a new code" button.
|
||||
let requesting = $state(false);
|
||||
|
||||
// Show the code input whenever the pending charge hits the backend's 2FA
|
||||
@@ -49,7 +56,8 @@ export function useTwoFactorCodeForSavedCard(options: {
|
||||
if (requesting) return;
|
||||
requesting = true;
|
||||
try {
|
||||
const result = await requestNewTwoFactorCode();
|
||||
const mint = options.mint ?? requestNewTwoFactorCode;
|
||||
const result = await mint();
|
||||
if (result.ok) {
|
||||
code = '';
|
||||
toast.success(result.message);
|
||||
|
||||
Reference in New Issue
Block a user