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:
@@ -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")
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+25
-14
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
</button>
|
||||
{/if}
|
||||
{#if canSaveCards}
|
||||
{#if authStore.currentUser?.role !== 'admin' && canSaveCards}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
|
||||
@@ -2405,96 +2454,6 @@
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Two-Factor Authentication -->
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-semibold">Two-Factor Authentication</h3>
|
||||
<p class="mb-3 text-sm text-gray-600">
|
||||
Protect online card payments with a one-time verification code
|
||||
</p>
|
||||
|
||||
{#if authStore.currentUser?.twoFactorEnabled}
|
||||
<div class="rounded-lg border p-3">
|
||||
<div class="text-sm font-medium">
|
||||
Enabled
|
||||
{#if authStore.currentUser?.twoFactorMethod}
|
||||
({authStore.currentUser.twoFactorMethod === 'email' ? 'Email' : 'SMS'})
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
A verification code is required for online card payments
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="mt-3"
|
||||
disabled={twoFADisabling}
|
||||
onclick={disableTwoFA}
|
||||
>
|
||||
{twoFADisabling ? 'Disabling...' : 'Disable'}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
{#if authStore.currentUser?.twoFactorRequired}
|
||||
<div
|
||||
class="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800"
|
||||
>
|
||||
You must enable 2FA to use online card payments.
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
2FA is optional right now (REQUIRE_2FA is off)
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if twoFASetupPending}
|
||||
{#if twoFADevCode}
|
||||
<p class="mb-2 text-xs text-gray-500">
|
||||
Dev code: <strong>{twoFADevCode}</strong>
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength={6}
|
||||
placeholder="6-digit code"
|
||||
bind:value={twoFACode}
|
||||
/>
|
||||
<Button disabled={twoFAVerifying} onclick={verifyTwoFASetup}>
|
||||
{twoFAVerifying ? 'Verifying...' : 'Verify'}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="twofa-method"
|
||||
value="email"
|
||||
bind:group={twoFAMethod}
|
||||
class="accent-fuchsia-600"
|
||||
/>
|
||||
Email
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="twofa-method"
|
||||
value="sms"
|
||||
bind:group={twoFAMethod}
|
||||
class="accent-fuchsia-600"
|
||||
/>
|
||||
SMS
|
||||
</label>
|
||||
</div>
|
||||
<Button class="mt-3" disabled={twoFASettingUp} onclick={startTwoFASetup}>
|
||||
{twoFASettingUp ? 'Sending...' : 'Enable'}
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Log Out Button -->
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-semibold">Session</h3>
|
||||
@@ -2619,12 +2578,114 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
{/if}
|
||||
</div>
|
||||
<Separator />
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-semibold">Policies</h3>
|
||||
<!-- Two-Factor Authentication (visible to all roles; the notification
|
||||
preferences above are the role-gated part of this area) -->
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-semibold">Two-Factor Authentication</h3>
|
||||
<p class="mb-3 text-sm text-gray-600">
|
||||
Protect online card payments with a one-time verification code
|
||||
</p>
|
||||
|
||||
{#if authStore.currentUser?.twoFactorEnabled}
|
||||
<div class="mb-3 rounded-lg border p-3">
|
||||
<div class="text-sm font-medium">
|
||||
Enabled
|
||||
{#if authStore.currentUser?.twoFactorMethod}
|
||||
({authStore.currentUser.twoFactorMethod === 'email' ? 'Email' : 'SMS'})
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
A verification code is required for online card payments
|
||||
</div>
|
||||
</div>
|
||||
{:else if authStore.currentUser?.twoFactorRequired}
|
||||
<div
|
||||
class="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800"
|
||||
>
|
||||
You must enable 2FA to use online card payments.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">Email</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
Receive your verification code by email
|
||||
</div>
|
||||
</div>
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer sr-only"
|
||||
checked={selectedTwoFA === 'email'}
|
||||
onchange={() => {
|
||||
selectedTwoFA = selectedTwoFA === 'email' ? 'none' : 'email';
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">SMS</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
Receive your verification code by text message
|
||||
</div>
|
||||
</div>
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer sr-only"
|
||||
checked={selectedTwoFA === 'sms'}
|
||||
onchange={() => {
|
||||
selectedTwoFA = selectedTwoFA === 'sms' ? 'none' : 'sms';
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
class="peer h-5 w-9 rounded-full border border-gray-200 bg-gray-200 peer-checked:bg-fuchsia-300 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if twoFASetupPending}
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength={6}
|
||||
placeholder="6-digit code"
|
||||
bind:value={twoFACode}
|
||||
/>
|
||||
<Button disabled={twoFAVerifying} onclick={verifyTwoFASetup}>
|
||||
{twoFAVerifying ? 'Verifying...' : 'Verify'}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if twoFADirty && !twoFASetupPending}
|
||||
<Button
|
||||
class="mt-3"
|
||||
disabled={twoFASettingUp || twoFADisabling}
|
||||
onclick={applyTwoFA}
|
||||
>
|
||||
{twoFASettingUp ? 'Sending...' : 'Apply'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-semibold">Policies</h3>
|
||||
<p class="mb-3 text-sm text-gray-600">
|
||||
View our cancellation, deposit, and no-show policies
|
||||
</p>
|
||||
@@ -2776,7 +2837,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if canSaveCards}
|
||||
{#if authStore.currentUser?.role !== 'admin' && canSaveCards}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-md px-2 py-2 text-xs font-medium transition-colors {activeTab ===
|
||||
@@ -2991,6 +3052,25 @@
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<!-- Disable Two-Factor Authentication Warning -->
|
||||
<AlertDialog.Root bind:open={showDisableTwoFADialog}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Disable two-factor authentication?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Disabling two-factor authentication means online card payments will be blocked
|
||||
while 2FA is required. You can re-enable it at any time.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={confirmDisableTwoFA} class="bg-red-600 hover:bg-red-700">
|
||||
Disable
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
{/if}
|
||||
|
||||
<!-- User Booking Modal -->
|
||||
|
||||
@@ -451,5 +451,6 @@
|
||||
bind:open={showBookingModal}
|
||||
bookingId={selectedBookingId ?? ''}
|
||||
onReschedule={handleReschedule}
|
||||
{openUserModal}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -189,7 +189,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<BookingModal bind:open={showBookingModal} bookingId={selectedBookingId ?? ''} />
|
||||
<BookingModal
|
||||
bind:open={showBookingModal}
|
||||
bookingId={selectedBookingId ?? ''}
|
||||
{openUserModal}
|
||||
/>
|
||||
|
||||
<EditBookingModal
|
||||
bind:open={showEditBookingModal}
|
||||
|
||||
@@ -228,12 +228,14 @@ CREATE TABLE users (
|
||||
-- infrastructure: two_factor_enabled is the source of truth for the
|
||||
-- online-card-payment gate; two_factor_method records the chosen delivery
|
||||
-- channel ('email' | 'sms'); pending_* hold the in-flight verification code
|
||||
-- (hashed) and its expiry. Set REQUIRE_2FA=false (or the dev build tag) to
|
||||
-- disable all 2FA requirements for local testing.
|
||||
-- (hashed) and its expiry; two_factor_last_used_at records the most recent
|
||||
-- successful verification (admin recovery view). Set REQUIRE_2FA=false (or
|
||||
-- the dev build tag) to disable all 2FA requirements for local testing.
|
||||
two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
two_factor_method TEXT CHECK (two_factor_method IN ('email', 'sms') OR two_factor_method IS NULL),
|
||||
two_factor_pending_code_hash TEXT,
|
||||
two_factor_pending_code_expires TIMESTAMPTZ,
|
||||
two_factor_last_used_at TIMESTAMPTZ,
|
||||
-- staff fields
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user