fix: rate-limit per-IP keying + dev no-op, admin 2FA recovery, 2FA account UI, admin modals, tip totals

Rate limiting (backend):
- RateLimit/ProgressiveRateLimit now derive the per-client key from
  CF-Connecting-IP, then chi's GetClientIP (the X-Real-IP value nginx sets at
  main.go:323), then RemoteAddr. Previously only CF-Connecting-IP/RemoteAddr
  were used, so behind the Docker nginx every client shared ONE bucket per
  limiter — 10 logins/min site-wide blocked all users (the reported
  'Error: Rate limit exceeded' after seeding was the login 10/min bucket
  tripped by the seed's 11 logins, all keyed 127.0.0.1 in dev).
- Real implementation is now //go:build !dev || test; new
  mw/ratelimit_dev.go (//go:build dev && !test) is a no-op passthrough, so
  'go run -tags dev' (the dev harness) never rate-limits dev/seeding traffic,
  while production and tests (-tags test,dev) keep the real limiter. The docs
  (Technical Manual) already claimed dev no-op behaviour — the code now
  matches. NewProgressiveRateLimiter is provided in the no-op build because
  tag-free ratelimit_shared.go:104 initializes the global at package init.

Admin 2FA management (backend):
- users.two_factor_last_used_at TIMESTAMPTZ column (init-script, fresh-DB).
- AdminUserDetail now returns twoFactorEnabled/twoFactorMethod/
  twoFactorLastUsedAt.
- New POST /api/admin/users/{id}/2fa/remove (admin-only): clears all 5 2FA
  columns + drops the user's in-memory attempt/lockout state — an admin
  recovery path when a user loses 2FA access.
- two_factor_last_used_at updated on every successful 2FA verification.

Account page (/account):
- 2FA section moved under the Notifications heading, visible to all roles;
  Email/SMS toggles (Notifications styling) acting as a radio group with
  'none' state; Apply button only when the selection differs from saved;
  unselecting shows a payment-rules warning dialog; the dev-comment
  '2FA is optional right now (REQUIRE_2FA is off)' and the 'Dev code:' debug
  line are removed.
- Cards tab hidden from admin role.

Admin modals:
- User Details modal: new 'Two-Factor Authentication' section above Patch
  Tests showing Enabled/Disabled, method, last-used timestamp, and a Remove
  2FA button with a confirmation dialog (POST to the admin endpoint, refetch
  on success).
- Booking Details modal: the customer's name now links to their User Details
  modal (optional openUserModal prop threaded through admin/+page and
  today/+page; other call sites unaffected).

Take Payment + /today:
- PaymentModal shows pre-tip (netTotal) and post-tip (totalWithTip) totals
  with a tip-amount delta row only when a tip is selected; zero-tip flow
  unchanged.
- The /today Payment button is hidden unless the booking is in_progress or
  completed, matching the backend gate (was shown for confirmed/pending
  bookings, producing the 'Booking must be in_progress or completed' error).

Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok
incl. new admin 2FA tests + mw tests), go build ./... and -tags dev both
compile, go vet clean, svelte-check 0 errors 0 warnings, env-docs gate OK,
docker compose config valid.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 3894f53778
commit 6691cd5657
14 changed files with 725 additions and 169 deletions
@@ -22,9 +22,10 @@
bookingId: string;
onReschedule?: () => void;
onChanged?: () => void;
openUserModal?: (userId: string) => void;
}
let { open = $bindable(), bookingId, onReschedule, onChanged }: Props = $props();
let { open = $bindable(), bookingId, onReschedule, onChanged, openUserModal }: Props = $props();
let selectedBooking = $state<Booking | null>(null);
let showApprovalModal = $state(false);
@@ -455,13 +456,29 @@
/>
{/if}
<div>
{#if selectedBooking.user}
{@const customer = selectedBooking.user}
{@const userName = formatUserName(
customer.full_name || '—',
customer.previous_first_name,
customer.previous_last_name
)}
<div class="text-lg font-semibold">
{formatUserName(
selectedBooking.user?.full_name || '—',
selectedBooking.user?.previous_first_name,
selectedBooking.user?.previous_last_name
)}
{#if openUserModal}
<button
type="button"
class="cursor-pointer text-blue-600 hover:text-blue-800 hover:underline"
onclick={() => openUserModal(customer.id)}
>
{userName}
</button>
{:else}
{userName}
{/if}
</div>
{:else}
<div class="text-lg font-semibold"></div>
{/if}
{#if selectedBooking.user?.date_of_birth}
<div class="text-sm text-gray-500">
{calculateAge(selectedBooking.user.date_of_birth)} years old
@@ -4,11 +4,12 @@
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { apiFetch } from '$lib/utils/api';
import * as Modal from '$lib/components/ui/dialog';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { Button } from '$lib/components/ui/button';
import PatchTestModal from './PatchTestModal.svelte';
import { formatUserName } from '$lib/utils/nameDisplay';
import { parseWallClockDate } from '$lib/utils/timeSlots';
import { range } from '$lib/utils/format';
import { formatDateTime, range } from '$lib/utils/format';
interface Props {
open: boolean;
@@ -48,6 +49,9 @@
socialLogins?: SocialLogin[];
previousFirstName?: string;
previousLastName?: string;
twoFactorEnabled: boolean;
twoFactorMethod?: string;
twoFactorLastUsedAt?: string;
};
type Booking = {
@@ -101,6 +105,8 @@
let customerRelationship = $state<CustomerRelationship | null>(null);
let loadingRelationship = $state(false);
let giftCardBalance = $state<number | null>(null);
let showRemove2FAConfirm = $state(false);
let removing2FA = $state(false);
async function fetchUserDetails() {
if (!userId) return;
@@ -261,6 +267,32 @@
function handleOpenBooking(bookingId: string) {
openBookingModal(bookingId);
}
async function handleRemove2FA() {
if (!selectedUser) return;
removing2FA = true;
try {
const response = await apiFetch(`/api/admin/users/${selectedUser.id}/2fa/remove`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
toast.success('Two-factor authentication removed');
showRemove2FAConfirm = false;
await fetchUserDetails();
} else {
const text = await response.text();
toast.error('Failed to remove two-factor authentication: ' + extractErrorMessage(text));
}
} catch (err) {
console.error('Error removing two-factor authentication:', err);
toast.error('Network error removing two-factor authentication');
} finally {
removing2FA = false;
}
}
</script>
<Modal.Root bind:open>
@@ -600,6 +632,45 @@
</div>
</div>
<!-- Two-Factor Authentication -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Two-Factor Authentication
</h3>
{#if selectedUser.twoFactorEnabled}
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div>
<span
class="inline-flex items-center rounded-full bg-emerald-100 px-3 py-1 text-sm font-medium text-emerald-800"
>
Enabled
</span>
</div>
<div class="text-sm text-gray-600">
Method: {selectedUser.twoFactorMethod || '—'}
</div>
{#if selectedUser.twoFactorLastUsedAt}
<div class="text-sm text-gray-600">
Last used: {formatDateTime(selectedUser.twoFactorLastUsedAt)}
</div>
{/if}
</div>
<Button
variant="outline"
class="text-red-600 hover:bg-red-50 hover:text-red-700"
onclick={() => (showRemove2FAConfirm = true)}
>
Remove 2FA
</Button>
</div>
{:else}
<p class="text-sm text-gray-600">
Two-factor authentication is disabled for this user.
</p>
{/if}
</div>
{#if hasEligiblePatchTests}
<!-- Patch Test Actions -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
@@ -647,3 +718,33 @@
}}
/>
{/if}
{#if selectedUser}
<AlertDialog.Root bind:open={showRemove2FAConfirm}>
<AlertDialog.Content class="z-60">
<AlertDialog.Header>
<AlertDialog.Title>Remove two-factor authentication?</AlertDialog.Title>
<AlertDialog.Description>
Remove two-factor authentication for
{formatUserName(
selectedUser.fullName,
selectedUser.previousFirstName,
selectedUser.previousLastName
)}
? They will no longer need a 2FA code for card payments. Use this only when the user has
lost access to their 2FA method.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action
onclick={handleRemove2FA}
disabled={removing2FA}
class="bg-red-600 hover:bg-red-700"
>
{removing2FA ? 'Removing…' : 'Remove 2FA'}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
{/if}
@@ -243,6 +243,9 @@
const totalDue = $derived(tipEnabled ? totalWithTip : netTotal);
// The amount added on top of the pre-tip total when a tip is selected.
const tipDelta = $derived(tipEnabled ? totalWithTip - netTotal : 0);
// True when there is genuinely nothing to charge — the booking is fully
// covered by discounts (and no tip is being added). Payment entry is
// disabled in that state; the handlers also guard defensively.
@@ -891,15 +894,27 @@
<div
class="flex items-center justify-between rounded-md border border-gray-200 bg-white p-4"
>
<span class="text-base font-semibold text-gray-700">Total</span>
<div class="flex items-baseline gap-2.5">
{#if discountSum > 0.01}
<span class="text-sm font-medium text-gray-400 line-through"
>{formatCurrency(subtotal)}</span
>
{/if}
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
</div>
{#if tipEnabled}
<span class="text-base font-semibold text-gray-700">Subtotal (pre-tip)</span>
<div class="flex items-baseline gap-2.5">
{#if discountSum > 0.01}
<span class="text-sm font-medium text-gray-400 line-through"
>{formatCurrency(subtotal)}</span
>
{/if}
<span class="text-xl font-bold text-gray-900">{formatCurrency(netTotal)}</span>
</div>
{:else}
<span class="text-base font-semibold text-gray-700">Total</span>
<div class="flex items-baseline gap-2.5">
{#if discountSum > 0.01}
<span class="text-sm font-medium text-gray-400 line-through"
>{formatCurrency(subtotal)}</span
>
{/if}
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
</div>
{/if}
</div>
{#if nothingToCharge}
@@ -909,13 +924,19 @@
{/if}
{#if tipEnabled}
<div class="flex justify-between rounded-md border border-green-200 bg-green-50 p-3">
<span class="text-sm font-medium text-green-800">
Total with Tip ({tipDisplay})
</span>
<span class="text-lg font-bold text-green-800">
{formatCurrency(totalWithTip)}
</span>
<div class="rounded-md border border-green-200 bg-green-50 p-3">
<div class="flex justify-between">
<span class="text-sm font-medium text-green-800">
Total with Tip ({tipDisplay})
</span>
<span class="text-lg font-bold text-green-800">
{formatCurrency(totalWithTip)}
</span>
</div>
<div class="mt-1 flex justify-between text-xs font-medium text-green-700">
<span>Tip amount</span>
<span>+{formatCurrency(tipDelta)}</span>
</div>
</div>
{/if}
@@ -1071,8 +1092,13 @@
{:else if status === 'selecting'}
<div class="space-y-4">
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
<span class="text-base font-semibold text-gray-700">Total</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
{#if tipEnabled}
<span class="text-base font-semibold text-gray-700">Subtotal (pre-tip)</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(netTotal)}</span>
{:else}
<span class="text-base font-semibold text-gray-700">Total</span>
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
{/if}
</div>
<div class="space-y-3">
@@ -1107,13 +1133,19 @@
</div>
{#if tipEnabled}
<div class="flex justify-between rounded-md border border-green-200 bg-green-50 p-3">
<span class="text-sm font-medium text-green-800">
Total with Tip ({tipDisplay})
</span>
<span class="text-lg font-bold text-green-800">
{formatCurrency(totalWithTip)}
</span>
<div class="rounded-md border border-green-200 bg-green-50 p-3">
<div class="flex justify-between">
<span class="text-sm font-medium text-green-800">
Total with Tip ({tipDisplay})
</span>
<span class="text-lg font-bold text-green-800">
{formatCurrency(totalWithTip)}
</span>
</div>
<div class="mt-1 flex justify-between text-xs font-medium text-green-700">
<span>Tip amount</span>
<span>+{formatCurrency(tipDelta)}</span>
</div>
</div>
{/if}
@@ -286,6 +286,12 @@
}
const activeAppointment = $derived(currentAppointment || nextAppointment);
// The backend only accepts payments for bookings that have started or are
// already complete — hide the button entirely before the booking starts.
const canTakePayment = $derived(
['in_progress', 'completed'].includes(activeAppointment?.status ?? '')
);
</script>
<Card.Root
@@ -815,26 +821,28 @@
</svg>
Cancel
</Button>
<Button
size="sm"
onclick={handleTakePayment}
class="col-span-2 bg-green-600 hover:bg-green-700"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
{#if canTakePayment}
<Button
size="sm"
onclick={handleTakePayment}
class="col-span-2 bg-green-600 hover:bg-green-700"
>
<path d="M4 4a2 2 0 00-2 2v1h16V6a2 2 0 00-2-2H4z" />
<path
fill-rule="evenodd"
d="M18 9H2v5a2 2 0 002 2h12a2 2 0 002-2V9zM4 13a1 1 0 011-1h1a1 1 0 110 2H5a1 1 0 01-1-1zm5-1a1 1 0 100 2h1a1 1 0 100-2H9z"
clip-rule="evenodd"
/>
</svg>
Payment
</Button>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M4 4a2 2 0 00-2 2v1h16V6a2 2 0 00-2-2H4z" />
<path
fill-rule="evenodd"
d="M18 9H2v5a2 2 0 002 2h12a2 2 0 002-2V9zM4 13a1 1 0 011-1h1a1 1 0 110 2H5a1 1 0 01-1-1zm5-1a1 1 0 100 2h1a1 1 0 100-2H9z"
clip-rule="evenodd"
/>
</svg>
Payment
</Button>
{/if}
</div>
</div>
</div>