diff --git a/backend/handlers/user/admin_twofa_test.go b/backend/handlers/user/admin_twofa_test.go new file mode 100644 index 0000000..2497885 --- /dev/null +++ b/backend/handlers/user/admin_twofa_test.go @@ -0,0 +1,193 @@ +//go:build test + +package user + +// Tests for admin 2FA management: the 2FA fields exposed by +// GET /api/admin/users/{id} (AdminUserDetail) and the admin-only recovery route +// POST /api/admin/users/{id}/2fa/remove (AdminRemoveUser2FAHandler), plus the +// two_factor_last_used_at stamping on successful verification. Sequential only +// (no t.Parallel): the enforced-mode test flips process-global env vars, and +// the package shares db.Conn state. + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "crussell/clock" + "crussell/db" + "crussell/mw" + "crussell/testutils" + "crussell/testutils/fixtures" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/require" +) + +// makeAdmin2FARequest builds a request with the given role in context (admin +// for the happy paths, verified_email for the RequireAdmin gate test) and the +// {id} route param parsed from the path (extractAdminUserID, from +// admin_handlers_test.go). +func makeAdmin2FARequest(handler http.Handler, method, path, role string, ctx context.Context) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, nil) + rctx := chi.NewRouteContext() + if id, ok := extractAdminUserID(path); ok { + rctx.URLParams.Add("id", id) + } + ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) + ctx = context.WithValue(ctx, mw.UserIDKey, "admin-test-id") + ctx = context.WithValue(ctx, mw.UserRoleKey, role) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w +} + +// seedUser2FATurnedOn enables 2FA for a user with a method, a pending code, and +// a last-used stamp so admin view/remove tests start from a fully-populated row. +func seedUser2FATurnedOn(t *testing.T, ctx context.Context, q db.Querier, userID string) { + t.Helper() + _, err := q.Exec(ctx, ` + UPDATE users + SET two_factor_enabled = true, + two_factor_method = 'sms', + two_factor_pending_code_hash = 'abcdef', + two_factor_pending_code_expires = $2, + two_factor_last_used_at = $3 + WHERE id = $1 + `, userID, clock.Now().Add(5*time.Minute), clock.Now().Add(-24*time.Hour)) + require.NoError(t, err) +} + +// TestAdminUsers_Get_IncludesTwoFAState verifies GET /api/admin/users/{id} +// exposes two_factor_enabled, two_factor_method and two_factor_last_used_at. +func TestAdminUsers_Get_IncludesTwoFAState(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + seedUser2FATurnedOn(t, ctx, tx, userID) + + handler := http.HandlerFunc(GetAdminUserHandler) + w := makeAdmin2FARequest(handler, http.MethodGet, "/api/admin/users/"+userID, "admin", ctx) + require.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Equal(t, true, resp["twoFactorEnabled"]) + require.Equal(t, "sms", resp["twoFactorMethod"]) + lastUsed, ok := resp["twoFactorLastUsedAt"].(string) + require.True(t, ok, "twoFactorLastUsedAt should serialize as a string") + require.NotEmpty(t, lastUsed) +} + +// TestAdminUsers_Get_TwoFADisabledIsFalse verifies the 2FA fields serialize +// sanely for a user who never enabled 2FA (false, nil method/last-used). +func TestAdminUsers_Get_TwoFADisabledIsFalse(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + + handler := http.HandlerFunc(GetAdminUserHandler) + w := makeAdmin2FARequest(handler, http.MethodGet, "/api/admin/users/"+userID, "admin", ctx) + require.Equal(t, http.StatusOK, w.Code) + + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Equal(t, false, resp["twoFactorEnabled"]) + _, hasMethod := resp["twoFactorMethod"] + require.False(t, hasMethod, "twoFactorMethod should be omitted when nil") + _, hasLastUsed := resp["twoFactorLastUsedAt"] + require.False(t, hasLastUsed, "twoFactorLastUsedAt should be omitted when nil") +} + +// TestAdminUsers_Remove2FA_ClearsAllColumns verifies the admin recovery route +// clears the enabled flag, method, pending code fields and last-used stamp. +func TestAdminUsers_Remove2FA_ClearsAllColumns(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + seedUser2FATurnedOn(t, ctx, tx, userID) + + handler := http.HandlerFunc(AdminRemoveUser2FAHandler) + w := makeAdmin2FARequest(handler, http.MethodPost, "/api/admin/users/"+userID+"/2fa/remove", "admin", ctx) + require.Equal(t, http.StatusOK, w.Code) + + var enabled bool + var method, pendingHash sql.NullString + var pendingExpires, lastUsed sql.NullTime + err = tx.QueryRow(ctx, ` + SELECT two_factor_enabled, two_factor_method, two_factor_pending_code_hash, + two_factor_pending_code_expires, two_factor_last_used_at + FROM users WHERE id = $1 + `, userID).Scan(&enabled, &method, &pendingHash, &pendingExpires, &lastUsed) + require.NoError(t, err) + require.False(t, enabled, "two_factor_enabled should be false after admin removal") + require.False(t, method.Valid, "two_factor_method should be NULL after admin removal") + require.False(t, pendingHash.Valid, "two_factor_pending_code_hash should be NULL after admin removal") + require.False(t, pendingExpires.Valid, "two_factor_pending_code_expires should be NULL after admin removal") + require.False(t, lastUsed.Valid, "two_factor_last_used_at should be NULL after admin removal") +} + +// TestAdminUsers_Remove2FA_UnknownUser_NotFound verifies a valid-format ID that +// matches no user row returns 404. +func TestAdminUsers_Remove2FA_UnknownUser_NotFound(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(AdminRemoveUser2FAHandler) + w := makeAdmin2FARequest(handler, http.MethodPost, "/api/admin/users/000000000000/2fa/remove", "admin", ctx) + require.Equal(t, http.StatusNotFound, w.Code) +} + +// TestAdminUsers_Remove2FA_InvalidID_NotFound verifies a malformed ID is +// rejected before any query runs. +func TestAdminUsers_Remove2FA_InvalidID_NotFound(t *testing.T) { + ctx, _ := testutils.SetupTestTx(t) + + handler := http.HandlerFunc(AdminRemoveUser2FAHandler) + w := makeAdmin2FARequest(handler, http.MethodPost, "/api/admin/users/nothex/2fa/remove", "admin", ctx) + require.Equal(t, http.StatusNotFound, w.Code) +} + +// TestAdminUsers_Remove2FA_NonAdmin_Forbidden verifies the route is admin-gated: +// a non-admin role gets 403 from RequireAdmin before the handler runs. +func TestAdminUsers_Remove2FA_NonAdmin_Forbidden(t *testing.T) { + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + seedUser2FATurnedOn(t, ctx, tx, userID) + + handler := mw.RequireAdmin(http.HandlerFunc(AdminRemoveUser2FAHandler)) + w := makeAdmin2FARequest(handler, http.MethodPost, "/api/admin/users/"+userID+"/2fa/remove", "verified_email", ctx) + require.Equal(t, http.StatusForbidden, w.Code) + + // The user's 2FA must be untouched by the rejected request. + var enabled bool + err = tx.QueryRow(ctx, `SELECT two_factor_enabled FROM users WHERE id = $1`, userID).Scan(&enabled) + require.NoError(t, err) + require.True(t, enabled, "2FA should remain enabled after a 403") +} + +// TestTwoFAVerify_Enforced_UpdatesLastUsedAt verifies a successful enforced-mode +// code check stamps two_factor_last_used_at. +func TestTwoFAVerify_Enforced_UpdatesLastUsedAt(t *testing.T) { + twofaEnvEnforced(t) + ctx, tx := testutils.SetupTestTx(t) + userID, err := fixtures.CreateTestUser(tx) + require.NoError(t, err) + + seedPendingTwoFA(t, ctx, tx, userID, "123456") + + w := performUser2FARequest(t, VerifyTwoFAHandler, ctx, http.MethodPost, "/api/user/2fa/verify", TwoFAVerifyRequest{Code: "123456"}, userID) + require.Equal(t, http.StatusOK, w.Code) + + var enabled bool + var lastUsed sql.NullTime + err = tx.QueryRow(ctx, `SELECT two_factor_enabled, two_factor_last_used_at FROM users WHERE id = $1`, userID).Scan(&enabled, &lastUsed) + require.NoError(t, err) + require.True(t, enabled) + require.True(t, lastUsed.Valid, "two_factor_last_used_at should be set after a successful verify") +} diff --git a/backend/handlers/user/profile.go b/backend/handlers/user/profile.go index 498c79c..479598f 100644 --- a/backend/handlers/user/profile.go +++ b/backend/handlers/user/profile.go @@ -112,6 +112,11 @@ type AdminUserDetail struct { // Name change history PreviousFirstName *string `json:"previousFirstName,omitempty"` PreviousLastName *string `json:"previousLastName,omitempty"` + + // Two-factor authentication state (admin recovery view) + TwoFactorEnabled bool `json:"twoFactorEnabled"` + TwoFactorMethod *string `json:"twoFactorMethod,omitempty"` + TwoFactorLastUsedAt *string `json:"twoFactorLastUsedAt,omitempty"` } type SocialLogin struct { @@ -420,7 +425,8 @@ func GetAdminUserHandler(w http.ResponseWriter, r *http.Request) { privacy_policy_and_terms_consent, policy_consent_updated_at::text, data_retention_consent, - data_consent_updated_at::text + data_consent_updated_at::text, + two_factor_enabled, two_factor_method, two_factor_last_used_at::text FROM users WHERE id = $1 `, userID).Scan( @@ -430,6 +436,7 @@ func GetAdminUserHandler(w http.ResponseWriter, r *http.Request) { &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, &user.Notes, &user.PrivacyPolicyConsent, &user.PolicyConsentUpdatedAt, &user.DataRetentionConsent, &user.DataConsentUpdatedAt, + &user.TwoFactorEnabled, &user.TwoFactorMethod, &user.TwoFactorLastUsedAt, ) if err != nil { diff --git a/backend/handlers/user/twofa.go b/backend/handlers/user/twofa.go index 9017e8b..64e6a3d 100644 --- a/backend/handlers/user/twofa.go +++ b/backend/handlers/user/twofa.go @@ -21,7 +21,10 @@ import ( "crussell/clock" "crussell/db" "crussell/handlers/payments" + "crussell/internal/validators" "crussell/mw" + + "github.com/go-chi/chi/v5" ) // twoFARequired reports whether 2FA enforcement is active in this deployment. @@ -544,14 +547,17 @@ func VerifyTwoFAHandler(w http.ResponseWriter, r *http.Request) { writeTwoFAEnabled(w) } -// enableTwoFA persists two_factor_enabled=true and clears the pending code -// fields (the method was set during setup). +// enableTwoFA persists two_factor_enabled=true, records the last successful +// verification in two_factor_last_used_at, and clears the pending code fields +// (the method was set during setup). The last-used stamp is written here so it +// covers both the enforced verify path and the dev bypass. func enableTwoFA(r *http.Request, userID string) error { _, err := db.Conn.Exec(r.Context(), ` UPDATE users SET two_factor_enabled = true, two_factor_pending_code_hash = NULL, - two_factor_pending_code_expires = NULL + two_factor_pending_code_expires = NULL, + two_factor_last_used_at = NOW() WHERE id = $1 `, userID) return err @@ -714,3 +720,60 @@ func disableTwoFA(r *http.Request, userID string) error { `, userID) return err } + +// twoFADeleteAttempts removes a user's attempt-map entry entirely, unlike +// twoFAResetAttempts which only zeroes the count in place. The admin 2FA +// removal flow uses it so any lingering lockout/counter/mint-cooldown state is +// dropped wholesale and a re-setup starts from a clean slate. +func twoFADeleteAttempts(userID string) { + twoFAAttemptMapMu.Lock() + defer twoFAAttemptMapMu.Unlock() + delete(twoFAAttemptMap, userID) +} + +// removeUser2FA clears all 2FA state for a user: the enabled flag, method, +// pending code fields, and the last-used timestamp. It reports whether a user +// row was actually affected (false means the user does not exist). +func removeUser2FA(r *http.Request, userID string) (bool, error) { + tag, err := db.Conn.Exec(r.Context(), ` + UPDATE users + SET two_factor_enabled = false, + two_factor_method = NULL, + two_factor_pending_code_hash = NULL, + two_factor_pending_code_expires = NULL, + two_factor_last_used_at = NULL + WHERE id = $1 + `, userID) + if err != nil { + return false, err + } + return tag.RowsAffected() > 0, nil +} + +// POST /api/admin/users/{id}/2fa/remove +// Admin-only recovery route for when a user loses access to their 2FA device: +// forcibly disables the user's 2FA without requiring their code (bypassing the +// user-facing disable flow's re-verification). The route is mounted inside the +// admin-gated /admin/users group so RequireAdmin runs first. Mirrors +// disableTwoFA's column clearing plus two_factor_last_used_at, and drops the +// user's in-memory attempt/lockout state. A log line records the admin action. +func AdminRemoveUser2FAHandler(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "id") + if userID == "" || !validators.IsValidID(userID) { + http.Error(w, "user not found", http.StatusNotFound) + return + } + affected, err := removeUser2FA(r, userID) + if err != nil { + log.Printf("failed to remove 2FA for user %s: %v", userID, err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + if !affected { + http.Error(w, "user not found", http.StatusNotFound) + return + } + twoFADeleteAttempts(userID) + log.Printf("[2FA] admin removed 2FA for user %s", userID) + w.WriteHeader(http.StatusOK) +} diff --git a/backend/main.go b/backend/main.go index 719fa27..f624268 100644 --- a/backend/main.go +++ b/backend/main.go @@ -586,6 +586,7 @@ func main() { r.Post("/{id}/patch-tests", user.AddPatchTestHandler) r.Get("/{id}/giftcard-balance", payments.GetUserGiftCardBalanceAdmin) r.Get("/{id}/payment-methods", payments.AdminGetUserPaymentMethods) + r.Post("/{id}/2fa/remove", user.AdminRemoveUser2FAHandler) }) r.Route("/admin/today", func(r chi.Router) { diff --git a/backend/mw/ratelimit.go b/backend/mw/ratelimit.go index dd64b53..b30a77e 100644 --- a/backend/mw/ratelimit.go +++ b/backend/mw/ratelimit.go @@ -1,3 +1,5 @@ +//go:build !dev || test + package mw import ( @@ -6,6 +8,8 @@ import ( "net" "net/http" "time" + + "github.com/go-chi/chi/v5/middleware" ) func NewRateLimiter(limit int, window time.Duration) *RateLimiter { @@ -103,13 +107,7 @@ func (prl *ProgressiveRateLimiter) Check(ip string) (delayMs int) { func ProgressiveRateLimit(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ip := r.Header.Get("CF-Connecting-IP") - if ip == "" { - ip, _, _ = net.SplitHostPort(r.RemoteAddr) - if ip == "" { - ip = r.RemoteAddr - } - } + ip := clientIP(r) delay := globalProgressiveLimiter.Check(ip) if delay > 0 { @@ -126,13 +124,7 @@ func RateLimit(limit int, window time.Duration) func(http.Handler) http.Handler limiter := NewRateLimiter(limit, window) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ip := r.Header.Get("CF-Connecting-IP") - if ip == "" { - ip, _, _ = net.SplitHostPort(r.RemoteAddr) - if ip == "" { - ip = r.RemoteAddr - } - } + ip := clientIP(r) if !limiter.Allow(ip) { RespondJSON(w, http.StatusTooManyRequests, map[string]string{"error": "Rate limit exceeded"}) @@ -143,3 +135,22 @@ func RateLimit(limit int, window time.Duration) func(http.Handler) http.Handler }) } } + +// clientIP derives the per-client rate-limit key. Priority: +// 1. CF-Connecting-IP header (set only when Cloudflare is the edge; +// nginx never sets it, so it cannot be spoofed through our proxy) +// 2. middleware.GetClientIP(r.Context()) — the X-Real-IP value nginx sets, +// captured by middleware.ClientIPFromHeader("X-Real-IP") in main.go +// 3. net.SplitHostPort(r.RemoteAddr) / r.RemoteAddr fallback +func clientIP(r *http.Request) string { + if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { + return ip + } + if ip := middleware.GetClientIP(r.Context()); ip != "" { + return ip + } + if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil && ip != "" { + return ip + } + return r.RemoteAddr +} diff --git a/backend/mw/ratelimit_dev.go b/backend/mw/ratelimit_dev.go new file mode 100644 index 0000000..d5fa080 --- /dev/null +++ b/backend/mw/ratelimit_dev.go @@ -0,0 +1,36 @@ +//go:build dev && !test + +// No-op rate limiting for pure dev builds (-tags dev): the dev seed performs +// more logins/requests than the real limiters allow in a minute, which would +// block local development. Tests always build with the `test` tag, so the real +// implementation in ratelimit.go is used there. The types, registration, and +// Cleanup methods come from tag-free ratelimit_shared.go. + +package mw + +import ( + "net/http" + "time" +) + +func NewRateLimiter(limit int, window time.Duration) *RateLimiter { + return &RateLimiter{} +} + +func NewProgressiveRateLimiter() *ProgressiveRateLimiter { + return &ProgressiveRateLimiter{} +} + +func ProgressiveRateLimit(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r) + }) +} + +func RateLimit(limit int, window time.Duration) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r) + }) + } +} diff --git a/frontend/src/lib/components/admin/BookingModal.svelte b/frontend/src/lib/components/admin/BookingModal.svelte index 47f5b28..c321ae6 100644 --- a/frontend/src/lib/components/admin/BookingModal.svelte +++ b/frontend/src/lib/components/admin/BookingModal.svelte @@ -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(null); let showApprovalModal = $state(false); @@ -455,13 +456,29 @@ /> {/if}
+ {#if selectedBooking.user} + {@const customer = selectedBooking.user} + {@const userName = formatUserName( + customer.full_name || '—', + customer.previous_first_name, + customer.previous_last_name + )}
- {formatUserName( - selectedBooking.user?.full_name || '—', - selectedBooking.user?.previous_first_name, - selectedBooking.user?.previous_last_name - )} + {#if openUserModal} + + {:else} + {userName} + {/if}
+ {:else} +
+ {/if} {#if selectedBooking.user?.date_of_birth}
{calculateAge(selectedBooking.user.date_of_birth)} years old diff --git a/frontend/src/lib/components/admin/UserModal.svelte b/frontend/src/lib/components/admin/UserModal.svelte index 0c9ac59..789744d 100644 --- a/frontend/src/lib/components/admin/UserModal.svelte +++ b/frontend/src/lib/components/admin/UserModal.svelte @@ -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(null); let loadingRelationship = $state(false); let giftCardBalance = $state(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; + } + } @@ -600,6 +632,45 @@
+ +
+

+ Two-Factor Authentication +

+ {#if selectedUser.twoFactorEnabled} +
+
+
+ + Enabled + +
+
+ Method: {selectedUser.twoFactorMethod || '—'} +
+ {#if selectedUser.twoFactorLastUsedAt} +
+ Last used: {formatDateTime(selectedUser.twoFactorLastUsedAt)} +
+ {/if} +
+ +
+ {:else} +

+ Two-factor authentication is disabled for this user. +

+ {/if} +
+ {#if hasEligiblePatchTests}
@@ -647,3 +718,33 @@ }} /> {/if} + +{#if selectedUser} + + + + Remove two-factor authentication? + + 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. + + + + Cancel + + {removing2FA ? 'Removing…' : 'Remove 2FA'} + + + + +{/if} diff --git a/frontend/src/lib/components/payments/PaymentModal.svelte b/frontend/src/lib/components/payments/PaymentModal.svelte index 157bd28..f5a866d 100644 --- a/frontend/src/lib/components/payments/PaymentModal.svelte +++ b/frontend/src/lib/components/payments/PaymentModal.svelte @@ -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 @@
- Total -
- {#if discountSum > 0.01} - {formatCurrency(subtotal)} - {/if} - {formatCurrency(totalDue)} -
+ {#if tipEnabled} + Subtotal (pre-tip) +
+ {#if discountSum > 0.01} + {formatCurrency(subtotal)} + {/if} + {formatCurrency(netTotal)} +
+ {:else} + Total +
+ {#if discountSum > 0.01} + {formatCurrency(subtotal)} + {/if} + {formatCurrency(totalDue)} +
+ {/if}
{#if nothingToCharge} @@ -909,13 +924,19 @@ {/if} {#if tipEnabled} -
- - Total with Tip ({tipDisplay}) - - - {formatCurrency(totalWithTip)} - +
+
+ + Total with Tip ({tipDisplay}) + + + {formatCurrency(totalWithTip)} + +
+
+ Tip amount + +{formatCurrency(tipDelta)} +
{/if} @@ -1071,8 +1092,13 @@ {:else if status === 'selecting'}
- Total - {formatCurrency(totalDue)} + {#if tipEnabled} + Subtotal (pre-tip) + {formatCurrency(netTotal)} + {:else} + Total + {formatCurrency(totalDue)} + {/if}
@@ -1107,13 +1133,19 @@
{#if tipEnabled} -
- - Total with Tip ({tipDisplay}) - - - {formatCurrency(totalWithTip)} - +
+
+ + Total with Tip ({tipDisplay}) + + + {formatCurrency(totalWithTip)} + +
+
+ Tip amount + +{formatCurrency(tipDelta)} +
{/if} diff --git a/frontend/src/lib/components/today/CurrentAppointment.svelte b/frontend/src/lib/components/today/CurrentAppointment.svelte index b9bc0c0..a767f72 100644 --- a/frontend/src/lib/components/today/CurrentAppointment.svelte +++ b/frontend/src/lib/components/today/CurrentAppointment.svelte @@ -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 ?? '') + ); Cancel - + + + + + Payment + + {/if}
diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index 97e0dab..167bb78 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -556,6 +556,36 @@ let twoFASettingUp = $state(false); let twoFAVerifying = $state(false); let twoFADisabling = $state(false); + let selectedTwoFA = $state<'none' | 'email' | 'sms'>('none'); + let showDisableTwoFADialog = $state(false); + + // Locally-selected 2FA state, behaving as a radio group: none, email, or sms — + // never both. Initialised from the saved profile state so the toggles reflect + // reality on load and resync after every setup/verify/disable round-trip + // (authStore.refreshProfile). + $effect(() => { + const user = authStore.currentUser; + if ( + user?.twoFactorEnabled && + (user.twoFactorMethod === 'email' || user.twoFactorMethod === 'sms') + ) { + selectedTwoFA = user.twoFactorMethod; + } else { + selectedTwoFA = 'none'; + } + }); + + // The user's saved 2FA state, derived from the profile + const savedTwoFAState: 'none' | 'email' | 'sms' = $derived( + authStore.currentUser?.twoFactorEnabled && + (authStore.currentUser.twoFactorMethod === 'email' || + authStore.currentUser.twoFactorMethod === 'sms') + ? authStore.currentUser.twoFactorMethod + : 'none' + ); + + // True only while the locally-selected state differs from the user's saved choices + const twoFADirty = $derived(selectedTwoFA !== savedTwoFAState); async function startTwoFASetup() { twoFASettingUp = true; @@ -631,6 +661,25 @@ } } + // Apply the locally-selected 2FA state. A method selected runs the existing + // setup+verify flow; both toggles off disables 2FA (guarded by the payment-rules + // warning dialog when 2FA is currently enabled). + async function applyTwoFA() { + if (selectedTwoFA === 'none') { + if (authStore.currentUser?.twoFactorEnabled) { + showDisableTwoFADialog = true; + } + return; + } + twoFAMethod = selectedTwoFA; + await startTwoFASetup(); + } + + async function confirmDisableTwoFA() { + showDisableTwoFADialog = false; + await disableTwoFA(); + } + // Image cropper state let cropDialogOpen = $state(false); let cropImageUrl = $state(''); @@ -1399,7 +1448,7 @@ Referral {/if} - {#if canSaveCards} + {#if authStore.currentUser?.role !== 'admin' && canSaveCards} -
- {:else} - {#if authStore.currentUser?.twoFactorRequired} -
- You must enable 2FA to use online card payments. -
- {:else} -

- 2FA is optional right now (REQUIRE_2FA is off) -

- {/if} - - {#if twoFASetupPending} - {#if twoFADevCode} -

- Dev code: {twoFADevCode} -

- {/if} -
- - -
- {:else} -
- - -
- - {/if} - {/if} - - - -

Session

@@ -2619,12 +2578,114 @@
- - - {/if} + + + {/if} -
-

Policies

+ +
+

Two-Factor Authentication

+

+ Protect online card payments with a one-time verification code +

+ + {#if authStore.currentUser?.twoFactorEnabled} +
+
+ Enabled + {#if authStore.currentUser?.twoFactorMethod} + ({authStore.currentUser.twoFactorMethod === 'email' ? 'Email' : 'SMS'}) + {/if} +
+
+ A verification code is required for online card payments +
+
+ {:else if authStore.currentUser?.twoFactorRequired} +
+ You must enable 2FA to use online card payments. +
+ {/if} + +
+
+
+
Email
+
+ Receive your verification code by email +
+
+ +
+ +
+
+
SMS
+
+ Receive your verification code by text message +
+
+ +
+
+ + {#if twoFASetupPending} +
+ + +
+ {/if} + + {#if twoFADirty && !twoFASetupPending} + + {/if} +
+ + + +
+

Policies

View our cancellation, deposit, and no-show policies

@@ -2776,7 +2837,7 @@ {/if} - {#if canSaveCards} + {#if authStore.currentUser?.role !== 'admin' && canSaveCards}
- +