From 34ee4e0ae6c3316c45900ef1d175cb8cae53f8a5 Mon Sep 17 00:00:00 2001 From: Ellie Melton Date: Sun, 14 Jun 2026 02:22:59 +1000 Subject: [PATCH 01/10] Split admin dashboard into pages --- internal/httpapi/admin_web_handlers.go | 86 +++-- internal/httpapi/admin_web_session.go | 6 +- internal/httpapi/admin_web_test.go | 162 +++++++-- internal/httpapi/admin_web_view.go | 346 +++++++++++++++++-- internal/httpapi/routes.go | 3 + internal/httpapi/web/admin/static/styles.css | 2 +- internal/httpapi/web/admin/tailwind.css | 102 +++++- internal/httpapi/web/templates/admin.html | 206 ++++++++--- 8 files changed, 764 insertions(+), 149 deletions(-) diff --git a/internal/httpapi/admin_web_handlers.go b/internal/httpapi/admin_web_handlers.go index eda5d4a..0001fc1 100644 --- a/internal/httpapi/admin_web_handlers.go +++ b/internal/httpapi/admin_web_handlers.go @@ -12,6 +12,22 @@ import ( ) func (a *API) adminWebPage(w http.ResponseWriter, r *http.Request) { + a.adminWebServePage(w, r, adminWebPageOverview) +} + +func (a *API) adminWebAccountsPage(w http.ResponseWriter, r *http.Request) { + a.adminWebServePage(w, r, adminWebPageAccounts) +} + +func (a *API) adminWebIncidentsPage(w http.ResponseWriter, r *http.Request) { + a.adminWebServePage(w, r, adminWebPageIncidents) +} + +func (a *API) adminWebSettingsPage(w http.ResponseWriter, r *http.Request) { + a.adminWebServePage(w, r, adminWebPageSettings) +} + +func (a *API) adminWebServePage(w http.ResponseWriter, r *http.Request, page string) { setAdminWebPageHeaders(w) hasAdmin, err := a.repo.HasAdminAccount(r.Context()) @@ -48,7 +64,7 @@ func (a *API) adminWebPage(w http.ResponseWriter, r *http.Request) { return } - a.renderAdminWebDashboard(w, r, principal, http.StatusOK, adminWebNotice(r), "") + a.renderAdminWebPage(w, r, principal, page, http.StatusOK, adminWebNotice(r), "") } func (a *API) adminWebLogin(w http.ResponseWriter, r *http.Request) { @@ -430,14 +446,14 @@ func (a *API) adminWebChangeOwnPassword(w http.ResponseWriter, r *http.Request) if !ok { return } - if ok := a.parseAdminWebDashboardForm(w, r, principal, "The password form could not be read."); !ok { + if ok := a.parseAdminWebPageForm(w, r, principal, adminWebPageSettings, "The password form could not be read."); !ok { return } if !a.validateAdminWebCSRF(w, r, principal) { return } if !auth.VerifyPassword(principal.Account.PasswordHash, r.FormValue("current_password")) { - a.renderAdminWebDashboard(w, r, principal, http.StatusUnauthorized, "", "Current password is invalid.") + a.renderAdminWebSettings(w, r, principal, http.StatusUnauthorized, "", "Current password is invalid.") return } account, status, message, err, ok := a.adminWebUpdatePassword(r, principal.Account.ID, r.FormValue("new_password")) @@ -446,14 +462,14 @@ func (a *API) adminWebChangeOwnPassword(w http.ResponseWriter, r *http.Request) return } if !ok { - a.renderAdminWebDashboard(w, r, principal, status, "", message) + a.renderAdminWebSettings(w, r, principal, status, "", message) return } if _, err := a.repo.RevokeAccountSessions(r.Context(), account.ID, principal.Session.ID); err != nil { a.adminWebInternalError(w, "revoke admin web own sessions", err) return } - http.Redirect(w, r, "/admin?notice=password_changed", http.StatusSeeOther) + http.Redirect(w, r, "/admin/settings?notice=password_changed", http.StatusSeeOther) } func (a *API) adminWebCreateAccount(w http.ResponseWriter, r *http.Request) { @@ -462,7 +478,7 @@ func (a *API) adminWebCreateAccount(w http.ResponseWriter, r *http.Request) { if !ok { return } - if ok := a.parseAdminWebDashboardForm(w, r, principal, "The account form could not be read."); !ok { + if ok := a.parseAdminWebPageForm(w, r, principal, adminWebPageAccounts, "The account form could not be read."); !ok { return } if !a.validateAdminWebCSRF(w, r, principal) { @@ -475,10 +491,10 @@ func (a *API) adminWebCreateAccount(w http.ResponseWriter, r *http.Request) { return } if !ok { - a.renderAdminWebDashboard(w, r, principal, status, "", message) + a.renderAdminWebAccounts(w, r, principal, status, "", message) return } - http.Redirect(w, r, "/admin?notice=account_created", http.StatusSeeOther) + http.Redirect(w, r, "/admin/accounts?notice=account_created", http.StatusSeeOther) } func (a *API) adminWebResetAccountPassword(w http.ResponseWriter, r *http.Request) { @@ -487,7 +503,7 @@ func (a *API) adminWebResetAccountPassword(w http.ResponseWriter, r *http.Reques if !ok { return } - if ok := a.parseAdminWebDashboardForm(w, r, principal, "The password reset form could not be read."); !ok { + if ok := a.parseAdminWebPageForm(w, r, principal, adminWebPageAccounts, "The password reset form could not be read."); !ok { return } if !a.validateAdminWebCSRF(w, r, principal) { @@ -496,7 +512,7 @@ func (a *API) adminWebResetAccountPassword(w http.ResponseWriter, r *http.Reques accountID := r.PathValue("account_id") if accountID == principal.Account.ID { - a.renderAdminWebDashboard(w, r, principal, http.StatusBadRequest, "", "Use the admin password form to change your own password.") + a.renderAdminWebAccounts(w, r, principal, http.StatusBadRequest, "", "Use the admin password form to change your own password.") return } account, status, message, err, ok := a.adminWebUpdatePassword(r, accountID, r.FormValue("new_password")) @@ -505,14 +521,14 @@ func (a *API) adminWebResetAccountPassword(w http.ResponseWriter, r *http.Reques return } if !ok { - a.renderAdminWebDashboard(w, r, principal, status, "", message) + a.renderAdminWebAccounts(w, r, principal, status, "", message) return } if _, err := a.repo.RevokeAccountSessions(r.Context(), account.ID, ""); err != nil { a.adminWebInternalError(w, "revoke admin web account sessions", err) return } - http.Redirect(w, r, "/admin?notice=account_password_reset", http.StatusSeeOther) + http.Redirect(w, r, "/admin/accounts?notice=account_password_reset", http.StatusSeeOther) } func (a *API) adminWebResetAccountSecondFactorRecovery(w http.ResponseWriter, r *http.Request) { @@ -521,7 +537,7 @@ func (a *API) adminWebResetAccountSecondFactorRecovery(w http.ResponseWriter, r if !ok { return } - if ok := a.parseAdminWebDashboardForm(w, r, principal, "The second-factor recovery form could not be read."); !ok { + if ok := a.parseAdminWebPageForm(w, r, principal, adminWebPageAccounts, "The second-factor recovery form could not be read."); !ok { return } if !a.validateAdminWebCSRF(w, r, principal) { @@ -530,12 +546,12 @@ func (a *API) adminWebResetAccountSecondFactorRecovery(w http.ResponseWriter, r accountID := r.PathValue("account_id") if accountID == principal.Account.ID { - a.renderAdminWebDashboard(w, r, principal, http.StatusBadRequest, "", "Second-factor recovery reset for the current admin account is not available from this form.") + a.renderAdminWebAccounts(w, r, principal, http.StatusBadRequest, "", "Second-factor recovery reset for the current admin account is not available from this form.") return } reason := strings.TrimSpace(r.FormValue("reason")) if !auth.ValidAccountRecoveryReason(reason) { - a.renderAdminWebDashboard(w, r, principal, http.StatusBadRequest, "", "Recovery reason is not supported.") + a.renderAdminWebAccounts(w, r, principal, http.StatusBadRequest, "", "Recovery reason is not supported.") return } if _, _, err := a.repo.ResetAccountSecondFactorRecovery(r.Context(), auth.ResetAccountSecondFactorRecoveryParams{ @@ -543,13 +559,13 @@ func (a *API) adminWebResetAccountSecondFactorRecovery(w http.ResponseWriter, r AdminAccountID: principal.Account.ID, Reason: reason, }); errors.Is(err, auth.ErrNotFound) { - a.renderAdminWebDashboard(w, r, principal, http.StatusNotFound, "", "Account was not found.") + a.renderAdminWebAccounts(w, r, principal, http.StatusNotFound, "", "Account was not found.") return } else if err != nil { a.adminWebInternalError(w, "reset admin web account second-factor recovery", err) return } - http.Redirect(w, r, "/admin?notice=account_second_factor_reset", http.StatusSeeOther) + http.Redirect(w, r, "/admin/accounts?notice=account_second_factor_reset", http.StatusSeeOther) } func (a *API) adminWebRevokeAccountSessions(w http.ResponseWriter, r *http.Request) { @@ -558,7 +574,7 @@ func (a *API) adminWebRevokeAccountSessions(w http.ResponseWriter, r *http.Reque if !ok { return } - if ok := a.parseAdminWebDashboardForm(w, r, principal, "The session revocation form could not be read."); !ok { + if ok := a.parseAdminWebPageForm(w, r, principal, adminWebPageAccounts, "The session revocation form could not be read."); !ok { return } if !a.validateAdminWebCSRF(w, r, principal) { @@ -567,11 +583,11 @@ func (a *API) adminWebRevokeAccountSessions(w http.ResponseWriter, r *http.Reque accountID := r.PathValue("account_id") if accountID == principal.Account.ID { - a.renderAdminWebDashboard(w, r, principal, http.StatusBadRequest, "", "Use sign out to end the current admin session.") + a.renderAdminWebAccounts(w, r, principal, http.StatusBadRequest, "", "Use sign out to end the current admin session.") return } if _, err := a.repo.GetAccountByID(r.Context(), accountID); errors.Is(err, auth.ErrNotFound) { - a.renderAdminWebDashboard(w, r, principal, http.StatusNotFound, "", "Account was not found.") + a.renderAdminWebAccounts(w, r, principal, http.StatusNotFound, "", "Account was not found.") return } else if err != nil { a.adminWebInternalError(w, "get admin web account for session revocation", err) @@ -581,7 +597,7 @@ func (a *API) adminWebRevokeAccountSessions(w http.ResponseWriter, r *http.Reque a.adminWebInternalError(w, "revoke admin web account sessions", err) return } - http.Redirect(w, r, "/admin?notice=account_sessions_revoked", http.StatusSeeOther) + http.Redirect(w, r, "/admin/accounts?notice=account_sessions_revoked", http.StatusSeeOther) } func (a *API) adminWebRequestIncidentDeletion(w http.ResponseWriter, r *http.Request) { @@ -590,7 +606,7 @@ func (a *API) adminWebRequestIncidentDeletion(w http.ResponseWriter, r *http.Req if !ok { return } - if ok := a.parseAdminWebDashboardForm(w, r, principal, "The incident deletion form could not be read."); !ok { + if ok := a.parseAdminWebPageForm(w, r, principal, adminWebPageIncidents, "The incident deletion form could not be read."); !ok { return } if !a.validateAdminWebCSRF(w, r, principal) { @@ -599,7 +615,7 @@ func (a *API) adminWebRequestIncidentDeletion(w http.ResponseWriter, r *http.Req reasonCode, statusCode, message, ok := adminWebNormalizeDeletionReasonCode(r.FormValue("reason_code")) if !ok { - a.renderAdminWebDashboard(w, r, principal, statusCode, "", message) + a.renderAdminWebIncidents(w, r, principal, statusCode, "", message) return } status, err := a.repo.RequestIncidentDeletion(r.Context(), incidents.IncidentDeletionRequest{ @@ -610,18 +626,18 @@ func (a *API) adminWebRequestIncidentDeletion(w http.ResponseWriter, r *http.Req AllowOpen: adminWebFormBool(r.FormValue("allow_open")), }) if errors.Is(err, incidents.ErrNotFound) { - a.renderAdminWebDashboard(w, r, principal, http.StatusNotFound, "", "Incident was not found.") + a.renderAdminWebIncidents(w, r, principal, http.StatusNotFound, "", "Incident was not found.") return } if errors.Is(err, incidents.ErrInvalidState) { - a.renderAdminWebDashboard(w, r, principal, http.StatusConflict, "", "Incident cannot be deleted in its current state.") + a.renderAdminWebIncidents(w, r, principal, http.StatusConflict, "", "Incident cannot be deleted in its current state.") return } if err != nil { a.adminWebInternalError(w, "request admin web incident deletion", err) return } - redirect := "/admin?notice=incident_deletion_requested&deletion_incident_id=" + url.QueryEscape(status.IncidentID) + redirect := "/admin/incidents?notice=incident_deletion_requested&deletion_incident_id=" + url.QueryEscape(status.IncidentID) http.Redirect(w, r, redirect, http.StatusSeeOther) } @@ -631,7 +647,7 @@ func (a *API) adminWebReassignLegacyUnownedIncident(w http.ResponseWriter, r *ht if !ok { return } - if ok := a.parseAdminWebDashboardForm(w, r, principal, "The incident reassignment form could not be read."); !ok { + if ok := a.parseAdminWebPageForm(w, r, principal, adminWebPageIncidents, "The incident reassignment form could not be read."); !ok { return } if !a.validateAdminWebCSRF(w, r, principal) { @@ -642,27 +658,27 @@ func (a *API) adminWebReassignLegacyUnownedIncident(w http.ResponseWriter, r *ht newOwnerAccountID := strings.TrimSpace(r.FormValue("new_owner_account_id")) reasonCode := strings.TrimSpace(r.FormValue("reason_code")) if !validLegacyReassignmentAction(action) { - a.renderAdminWebDashboard(w, r, principal, http.StatusBadRequest, "", "Reassignment action is not supported.") + a.renderAdminWebIncidents(w, r, principal, http.StatusBadRequest, "", "Reassignment action is not supported.") return } if !validLegacyReassignmentReasonCode(reasonCode) { - a.renderAdminWebDashboard(w, r, principal, http.StatusBadRequest, "", "Reassignment reason code is not supported.") + a.renderAdminWebIncidents(w, r, principal, http.StatusBadRequest, "", "Reassignment reason code is not supported.") return } if action == incidents.LegacyIncidentReassignmentActionAssignOwner { if newOwnerAccountID == "" { - a.renderAdminWebDashboard(w, r, principal, http.StatusBadRequest, "", "New owner account ID is required.") + a.renderAdminWebIncidents(w, r, principal, http.StatusBadRequest, "", "New owner account ID is required.") return } if _, err := a.repo.GetAccountByID(r.Context(), newOwnerAccountID); errors.Is(err, auth.ErrNotFound) { - a.renderAdminWebDashboard(w, r, principal, http.StatusNotFound, "", "Destination account was not found.") + a.renderAdminWebIncidents(w, r, principal, http.StatusNotFound, "", "Destination account was not found.") return } else if err != nil { a.adminWebInternalError(w, "get admin web reassignment account", err) return } } else if newOwnerAccountID != "" { - a.renderAdminWebDashboard(w, r, principal, http.StatusBadRequest, "", "New owner account ID is only allowed when assigning an owner.") + a.renderAdminWebIncidents(w, r, principal, http.StatusBadRequest, "", "New owner account ID is only allowed when assigning an owner.") return } @@ -675,18 +691,18 @@ func (a *API) adminWebReassignLegacyUnownedIncident(w http.ResponseWriter, r *ht Source: incidents.LegacyIncidentReassignmentSourceAdminAPI, }) if errors.Is(err, incidents.ErrNotFound) { - a.renderAdminWebDashboard(w, r, principal, http.StatusNotFound, "", "Legacy unowned incident was not found.") + a.renderAdminWebIncidents(w, r, principal, http.StatusNotFound, "", "Legacy unowned incident was not found.") return } if errors.Is(err, incidents.ErrInvalidState) { - a.renderAdminWebDashboard(w, r, principal, http.StatusConflict, "", "Incident is not an active unowned legacy incident.") + a.renderAdminWebIncidents(w, r, principal, http.StatusConflict, "", "Incident is not an active unowned legacy incident.") return } if err != nil { a.adminWebInternalError(w, "reassign admin web legacy unowned incident", err) return } - http.Redirect(w, r, "/admin?notice=incident_reassignment_recorded", http.StatusSeeOther) + http.Redirect(w, r, "/admin/incidents?notice=incident_reassignment_recorded", http.StatusSeeOther) } func (a *API) adminWebDeletionStatusFromQuery(r *http.Request) (adminWebDeletionStatus, string, error) { diff --git a/internal/httpapi/admin_web_session.go b/internal/httpapi/admin_web_session.go index 61515e8..92d2493 100644 --- a/internal/httpapi/admin_web_session.go +++ b/internal/httpapi/admin_web_session.go @@ -23,10 +23,14 @@ func (a *API) parseAdminWebForm(w http.ResponseWriter, r *http.Request, data adm } func (a *API) parseAdminWebDashboardForm(w http.ResponseWriter, r *http.Request, principal privatePrincipal, message string) bool { + return a.parseAdminWebPageForm(w, r, principal, adminWebPageOverview, message) +} + +func (a *API) parseAdminWebPageForm(w http.ResponseWriter, r *http.Request, principal privatePrincipal, page, message string) bool { r.Body = http.MaxBytesReader(w, r.Body, fieldLimit) defer r.Body.Close() if err := r.ParseForm(); err != nil { - a.renderAdminWebDashboard(w, r, principal, http.StatusBadRequest, "", message) + a.renderAdminWebPage(w, r, principal, page, http.StatusBadRequest, "", message) return false } return true diff --git a/internal/httpapi/admin_web_test.go b/internal/httpapi/admin_web_test.go index 49e1417..9c23623 100644 --- a/internal/httpapi/admin_web_test.go +++ b/internal/httpapi/admin_web_test.go @@ -68,16 +68,15 @@ func TestAdminWebLoginSetsHttpOnlyCookieAndOpensDashboard(t *testing.T) { for _, expected := range []string{ "Proofline Admin", "Operator Console", - `href="#accounts"`, - `href="#operations"`, - `href="#boundary"`, - "Admin session", - "Private /admin", - "Public viewer", - "Not mounted", - "Incident Operations", - "Private admin API", - "Evidence Boundary", + `href="/admin/accounts"`, + `href="/admin/incidents"`, + `href="/admin/settings"`, + "Dashboard Overview", + "Metadata", + "Blob store", + "Registered local accounts", + "Committed blobs", + "Private Only", } { if !bytes.Contains(body, []byte(expected)) { t.Fatalf("admin dashboard missing %q: %s", expected, body) @@ -94,23 +93,27 @@ func TestAdminWebDashboardListsAccounts(t *testing.T) { app := newTestApp(t) createAccountAndLogin(t, app, "managed-user", "managed-password", auth.RoleUser) userAccount := mustGetAccountByUsername(t, app, "managed-user") + if _, err := app.db.ExecContext(t.Context(), `UPDATE accounts SET email_normalized = ? WHERE id = ?`, "managed@example.invalid", userAccount.ID); err != nil { + t.Fatalf("set managed account email: %v", err) + } cookie := loginAdminWeb(t, app) - response, body := requestWithCookie(t, app.adminHandler, http.MethodGet, "/admin", "", nil, cookie) + response, body := requestWithCookie(t, app.adminHandler, http.MethodGet, "/admin/accounts", "", nil, cookie) defer response.Body.Close() if response.StatusCode != http.StatusOK { - t.Fatalf("expected admin dashboard status 200, got %d: %s", response.StatusCode, body) + t.Fatalf("expected admin accounts status 200, got %d: %s", response.StatusCode, body) } for _, expected := range []string{ - "Admin Password", "User Accounts", - `id="accounts"`, - `id="operations"`, + "Search accounts", + `action="/admin/accounts"`, + `method="get"`, + `name="q"`, + "Back", + "Next", "test-admin", "managed-user", - `action="/admin/accounts"`, - `action="/admin/password"`, `action="/admin/accounts/` + userAccount.ID + `/password"`, `action="/admin/accounts/` + userAccount.ID + `/sessions/revoke"`, `action="/admin/accounts/` + userAccount.ID + `/second-factor/recovery/reset"`, @@ -126,6 +129,97 @@ func TestAdminWebDashboardListsAccounts(t *testing.T) { t.Fatalf("admin dashboard exposed %q: %s", disallowed, body) } } + + response, body = requestWithCookie(t, app.adminHandler, http.MethodGet, "/admin/accounts?q=managed@example.invalid", "", nil, cookie) + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("expected admin account search status 200, got %d: %s", response.StatusCode, body) + } + for _, expected := range []string{"managed-user", "managed@example.invalid", `value="managed@example.invalid"`} { + if !bytes.Contains(body, []byte(expected)) { + t.Fatalf("admin account search missing %q: %s", expected, body) + } + } + if bytes.Contains(body, []byte(">test-admin<")) { + t.Fatalf("admin account email search included unrelated account: %s", body) + } +} + +func TestAdminWebSettingsShowsAdminControlsAndRedactedConfig(t *testing.T) { + app := newTestAppWithOptions(t, httpapi.Options{ + MaxUploadBytes: 4096, + AccountBlobQuotaBytes: 8192, + WebAuth: httpapi.WebAuthConfig{ + Enabled: true, + }, + WebAuthn: httpapi.WebAuthnConfig{ + Enabled: true, + RPID: "admin.example.invalid", + RPDisplayName: "Proofline Admin Test", + AllowedOrigins: []string{"https://admin.example.invalid"}, + }, + EmailSender: &recordingEmailSender{}, + MainRateLimit: httpapi.MainRateLimitConfig{ + Enabled: true, + Window: time.Minute, + }, + PublicRateLimit: httpapi.PublicRateLimitConfig{ + Enabled: true, + Window: 2 * time.Minute, + }, + RelayCapability: httpapi.RelayCapabilityConfig{ + Secret: "relay-capability-secret", + }, + RelayService: httpapi.RelayServiceConfig{ + AuthToken: "relay-service-token", + }, + }) + cookie := loginAdminWeb(t, app) + + response, body := requestWithCookie(t, app.adminHandler, http.MethodGet, "/admin/settings", "", nil, cookie) + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("expected admin settings status 200, got %d: %s", response.StatusCode, body) + } + for _, expected := range []string{ + "Settings", + "Admin Password", + `action="/admin/password"`, + "Second Factor", + "Email challenge", + "TOTP", + "Security keys", + "Configuration", + "Max upload", + "Account blob quota", + "Registration", + "Browser sessions", + "WebAuthn", + "Email sender", + "Relay capability", + "Relay service auth", + "Provider details redacted", + "RP details redacted", + "Secret redacted", + "Token redacted", + } { + if !bytes.Contains(body, []byte(expected)) { + t.Fatalf("admin settings missing %q: %s", expected, body) + } + } + for _, disallowed := range []string{ + app.authToken, + "test-password", + "relay-capability-secret", + "relay-service-token", + "admin.example.invalid", + "https://admin.example.invalid", + "Authorization", + } { + if bytes.Contains(body, []byte(disallowed)) { + t.Fatalf("admin settings exposed %q: %s", disallowed, body) + } + } } func TestAdminWebAdminCanCreateAccount(t *testing.T) { @@ -144,7 +238,7 @@ func TestAdminWebAdminCanCreateAccount(t *testing.T) { if response.StatusCode != http.StatusSeeOther { t.Fatalf("expected account create redirect 303, got %d: %s", response.StatusCode, body) } - if location := response.Header.Get("Location"); location != "/admin?notice=account_created" { + if location := response.Header.Get("Location"); location != "/admin/accounts?notice=account_created" { t.Fatalf("expected account create redirect notice, got %q", location) } @@ -157,7 +251,7 @@ func TestAdminWebAdminCanCreateAccount(t *testing.T) { t.Fatalf("created account should require setup: %+v", loginAccount) } - response, body = requestWithCookie(t, app.adminHandler, http.MethodGet, "/admin?notice=account_created", "", nil, cookie) + response, body = requestWithCookie(t, app.adminHandler, http.MethodGet, "/admin/accounts?notice=account_created", "", nil, cookie) defer response.Body.Close() if response.StatusCode != http.StatusOK { t.Fatalf("expected dashboard after account create status 200, got %d: %s", response.StatusCode, body) @@ -189,7 +283,7 @@ func TestAdminWebAdminCanChangeOwnPassword(t *testing.T) { if response.StatusCode != http.StatusSeeOther { t.Fatalf("expected own password change redirect 303, got %d: %s", response.StatusCode, body) } - if location := response.Header.Get("Location"); location != "/admin?notice=password_changed" { + if location := response.Header.Get("Location"); location != "/admin/settings?notice=password_changed" { t.Fatalf("expected own password change redirect notice, got %q", location) } @@ -223,7 +317,7 @@ func TestAdminWebAdminCanResetUserPassword(t *testing.T) { if response.StatusCode != http.StatusSeeOther { t.Fatalf("expected account password reset redirect 303, got %d: %s", response.StatusCode, body) } - if location := response.Header.Get("Location"); location != "/admin?notice=account_password_reset" { + if location := response.Header.Get("Location"); location != "/admin/accounts?notice=account_password_reset" { t.Fatalf("expected account password reset redirect notice, got %q", location) } @@ -248,7 +342,7 @@ func TestAdminWebAdminCanRevokeUserSessions(t *testing.T) { if response.StatusCode != http.StatusSeeOther { t.Fatalf("expected session revoke redirect 303, got %d: %s", response.StatusCode, body) } - if location := response.Header.Get("Location"); location != "/admin?notice=account_sessions_revoked" { + if location := response.Header.Get("Location"); location != "/admin/accounts?notice=account_sessions_revoked" { t.Fatalf("expected session revoke redirect notice, got %q", location) } @@ -278,7 +372,7 @@ func TestAdminWebAdminCanResetUserSecondFactorRecovery(t *testing.T) { if response.StatusCode != http.StatusSeeOther { t.Fatalf("expected second-factor recovery redirect 303, got %d: %s", response.StatusCode, body) } - if location := response.Header.Get("Location"); location != "/admin?notice=account_second_factor_reset" { + if location := response.Header.Get("Location"); location != "/admin/accounts?notice=account_second_factor_reset" { t.Fatalf("expected second-factor recovery redirect notice, got %q", location) } @@ -292,7 +386,7 @@ func TestAdminWebAdminCanResetUserSecondFactorRecovery(t *testing.T) { t.Fatalf("expected recovered user session status 401, got %d: %s", response.StatusCode, body) } - response, body = requestWithCookie(t, app.adminHandler, http.MethodGet, "/admin?notice=account_second_factor_reset", "", nil, cookie) + response, body = requestWithCookie(t, app.adminHandler, http.MethodGet, "/admin/accounts?notice=account_second_factor_reset", "", nil, cookie) defer response.Body.Close() if response.StatusCode != http.StatusOK { t.Fatalf("expected dashboard after recovery reset status 200, got %d: %s", response.StatusCode, body) @@ -321,10 +415,10 @@ func TestAdminWebDashboardShowsIncidentOperationsSafely(t *testing.T) { viewerToken := createIncidentToken(t, app, legacyIncident.ID, "viewer", nil) cookie := loginAdminWeb(t, app) - response, body = requestWithCookie(t, app.adminHandler, http.MethodGet, "/admin", "", nil, cookie) + response, body = requestWithCookie(t, app.adminHandler, http.MethodGet, "/admin/incidents", "", nil, cookie) defer response.Body.Close() if response.StatusCode != http.StatusOK { - t.Fatalf("expected admin dashboard status 200, got %d: %s", response.StatusCode, body) + t.Fatalf("expected admin incidents status 200, got %d: %s", response.StatusCode, body) } for _, expected := range []string{ "Incident Operations", @@ -378,7 +472,7 @@ func TestAdminWebAdminCanReassignLegacyIncident(t *testing.T) { if response.StatusCode != http.StatusSeeOther { t.Fatalf("expected reassignment redirect 303, got %d: %s", response.StatusCode, body) } - if location := response.Header.Get("Location"); location != "/admin?notice=incident_reassignment_recorded" { + if location := response.Header.Get("Location"); location != "/admin/incidents?notice=incident_reassignment_recorded" { t.Fatalf("expected reassignment notice redirect, got %q", location) } response, body = requestWithAuth(t, app.privateHandler, http.MethodGet, "/v1/incidents/"+legacyIncident.ID, "", nil, ownerToken) @@ -399,7 +493,7 @@ func TestAdminWebAdminCanReassignLegacyIncident(t *testing.T) { if response.StatusCode != http.StatusSeeOther { t.Fatalf("expected keep-unowned redirect 303, got %d: %s", response.StatusCode, body) } - if location := response.Header.Get("Location"); location != "/admin?notice=incident_reassignment_recorded" { + if location := response.Header.Get("Location"); location != "/admin/incidents?notice=incident_reassignment_recorded" { t.Fatalf("expected keep-unowned notice redirect, got %q", location) } response, body = requestWithAuth(t, app.privateHandler, http.MethodGet, "/v1/incidents/"+quarantinedIncident.ID, "", nil, ownerToken) @@ -429,7 +523,7 @@ func TestAdminWebAdminCanRequestIncidentDeletionAndViewStatus(t *testing.T) { t.Fatalf("expected deletion request redirect 303, got %d: %s", response.StatusCode, body) } location := response.Header.Get("Location") - if location != "/admin?notice=incident_deletion_requested&deletion_incident_id="+incidentID { + if location != "/admin/incidents?notice=incident_deletion_requested&deletion_incident_id="+incidentID { t.Fatalf("expected deletion status notice redirect, got %q", location) } @@ -714,7 +808,7 @@ func TestAdminWebBlocksUnsafeOwnAccountActions(t *testing.T) { } } - response, body := requestWithCookie(t, app.adminHandler, http.MethodGet, "/admin", "", nil, cookie) + response, body := requestWithCookie(t, app.adminHandler, http.MethodGet, "/admin/accounts", "", nil, cookie) defer response.Body.Close() if response.StatusCode != http.StatusOK || !bytes.Contains(body, []byte("User Accounts")) { t.Fatalf("expected own-account block to preserve admin session, got %d: %s", response.StatusCode, body) @@ -736,7 +830,7 @@ func TestAdminWebLogoutRequiresCSRFToken(t *testing.T) { response, body = requestWithCookie(t, app.adminHandler, http.MethodGet, "/admin", "", nil, cookie) response.Body.Close() - if response.StatusCode != http.StatusOK || !bytes.Contains(body, []byte("User Accounts")) { + if response.StatusCode != http.StatusOK || !bytes.Contains(body, []byte("Dashboard Overview")) { t.Fatalf("expected admin session to remain valid after bad logout, got %d: %s", response.StatusCode, body) } @@ -908,7 +1002,7 @@ func TestAdminWebEmailSecondFactorSetupUnlocksDashboard(t *testing.T) { if response.StatusCode != http.StatusOK { t.Fatalf("expected admin dashboard after email setup status 200, got %d: %s", response.StatusCode, body) } - for _, expected := range []string{"User Accounts", "Admin session", "Private /admin"} { + for _, expected := range []string{"Dashboard Overview", "Metadata", "Private Only"} { if !bytes.Contains(body, []byte(expected)) { t.Fatalf("admin dashboard after email setup missing %q: %s", expected, body) } @@ -968,7 +1062,7 @@ func TestAdminWebEmailSecondFactorSetupUnlocksDashboard(t *testing.T) { if response.StatusCode != http.StatusOK { t.Fatalf("expected admin dashboard after email session verification status 200, got %d: %s", response.StatusCode, body) } - if !bytes.Contains(body, []byte("User Accounts")) || bytes.Contains(body, []byte(verifyCode)) { + if !bytes.Contains(body, []byte("Dashboard Overview")) || bytes.Contains(body, []byte(verifyCode)) { t.Fatalf("expected dashboard without email code exposure: %s", body) } } @@ -1018,7 +1112,7 @@ func TestAdminWebRequiresTOTPVerifiedSessionBeforeDashboard(t *testing.T) { if response.StatusCode != http.StatusOK { t.Fatalf("expected admin dashboard after TOTP status 200, got %d: %s", response.StatusCode, body) } - if !bytes.Contains(body, []byte("User Accounts")) || bytes.Contains(body, []byte(code)) { + if !bytes.Contains(body, []byte("Dashboard Overview")) || bytes.Contains(body, []byte(code)) { t.Fatalf("expected dashboard without TOTP code exposure: %s", body) } } diff --git a/internal/httpapi/admin_web_view.go b/internal/httpapi/admin_web_view.go index 3f3b49a..47461d8 100644 --- a/internal/httpapi/admin_web_view.go +++ b/internal/httpapi/admin_web_view.go @@ -2,25 +2,43 @@ package httpapi import ( "errors" + "fmt" "net/http" + "net/url" + "strconv" + "strings" "time" "github.com/open-proofline/server/internal/auth" "github.com/open-proofline/server/internal/incidents" ) +const ( + adminWebPageOverview = "dashboard" + adminWebPageAccounts = "accounts" + adminWebPageIncidents = "incidents" + adminWebPageSettings = "settings" + adminWebAccountsPageSize = 10 +) + type adminWebData struct { Title string Mode string + AdminShell bool + PageTitle string + PageLead string Error string Notice string CSRFToken string Account adminWebAccount Accounts []adminWebAccount + AccountPagination adminWebAccountPagination IncidentCandidates []adminWebIncidentCandidate DeletionStatus adminWebDeletionStatus NavItems []adminWebNavItem StatusItems []adminWebStatusItem + ConfigItems []adminWebStatusItem + SecondFactorItems []adminWebStatusItem SecondFactorEmailAvailable bool SecondFactorTOTPAvailable bool SecondFactorWebAuthnAvailable bool @@ -29,12 +47,27 @@ type adminWebData struct { type adminWebAccount struct { ID string Username string + Email string Role string + AccountState string + SecondFactorSetup string CreatedAt time.Time PasswordChangedAt time.Time IsCurrent bool } +type adminWebAccountPagination struct { + Search string + Page int + Total int + FilteredTotal int + RangeLabel string + PrevHref string + NextHref string + HasPrev bool + HasNext bool +} + type adminWebIncidentCandidate struct { IncidentID string Status string @@ -74,9 +107,10 @@ type adminWebNavItem struct { } type adminWebStatusItem struct { - Label string - Value string - Tone string + Label string + Value string + Description string + Tone string } func (a *API) renderAdminWeb(w http.ResponseWriter, status int, data adminWebData) { @@ -91,11 +125,54 @@ func (a *API) renderAdminWeb(w http.ResponseWriter, status int, data adminWebDat } func (a *API) renderAdminWebDashboard(w http.ResponseWriter, r *http.Request, principal privatePrincipal, status int, notice, message string) { + a.renderAdminWebOverview(w, r, principal, status, notice, message) +} + +func (a *API) renderAdminWebPage(w http.ResponseWriter, r *http.Request, principal privatePrincipal, page string, status int, notice, message string) { + switch page { + case adminWebPageAccounts: + a.renderAdminWebAccounts(w, r, principal, status, notice, message) + case adminWebPageIncidents: + a.renderAdminWebIncidents(w, r, principal, status, notice, message) + case adminWebPageSettings: + a.renderAdminWebSettings(w, r, principal, status, notice, message) + default: + a.renderAdminWebOverview(w, r, principal, status, notice, message) + } +} + +func (a *API) renderAdminWebOverview(w http.ResponseWriter, r *http.Request, principal privatePrincipal, status int, notice, message string) { + accounts, err := a.repo.ListAccounts(r.Context()) + if err != nil { + a.adminWebInternalError(w, "list admin web accounts", err) + return + } + candidates, err := a.repo.ListLegacyUnownedIncidentCandidates(r.Context(), defaultLegacyUnownedCandidateLimit) + if err != nil { + a.adminWebInternalError(w, "list admin web legacy unowned incidents", err) + return + } + committedBytes, err := a.adminWebCommittedBlobBytes(r, accounts) + if err != nil { + a.adminWebInternalError(w, "read admin web committed blob usage", err) + return + } + repoOK := a.repo.Check(r.Context()) == nil + storeOK := a.store.Check(r.Context()) == nil + a.renderAdminWeb(w, status, makeAdminWebOverviewData(principal, accounts, len(candidates), committedBytes, repoOK, storeOK, adminWebCSRFTokenFromRequest(r), notice, message)) +} + +func (a *API) renderAdminWebAccounts(w http.ResponseWriter, r *http.Request, principal privatePrincipal, status int, notice, message string) { accounts, err := a.repo.ListAccounts(r.Context()) if err != nil { a.adminWebInternalError(w, "list admin web accounts", err) return } + filtered, pagination := adminWebPaginateAccounts(r, accounts, principal.Account.ID) + a.renderAdminWeb(w, status, makeAdminWebAccountsData(principal, filtered, pagination, adminWebCSRFTokenFromRequest(r), notice, message)) +} + +func (a *API) renderAdminWebIncidents(w http.ResponseWriter, r *http.Request, principal privatePrincipal, status int, notice, message string) { candidates, err := a.repo.ListLegacyUnownedIncidentCandidates(r.Context(), defaultLegacyUnownedCandidateLimit) if err != nil { a.adminWebInternalError(w, "list admin web legacy unowned incidents", err) @@ -110,7 +187,23 @@ func (a *API) renderAdminWebDashboard(w http.ResponseWriter, r *http.Request, pr status = http.StatusNotFound message = deletionMessage } - a.renderAdminWeb(w, status, makeAdminWebDashboardData(principal, accounts, candidates, deletionStatus, adminWebCSRFTokenFromRequest(r), notice, message)) + a.renderAdminWeb(w, status, makeAdminWebIncidentsData(principal, candidates, deletionStatus, adminWebCSRFTokenFromRequest(r), notice, message)) +} + +func (a *API) renderAdminWebSettings(w http.ResponseWriter, r *http.Request, principal privatePrincipal, status int, notice, message string) { + a.renderAdminWeb(w, status, makeAdminWebSettingsData(principal, adminWebCSRFTokenFromRequest(r), notice, message, a.adminWebConfigItems(), a.adminWebSecondFactorItems())) +} + +func (a *API) adminWebCommittedBlobBytes(r *http.Request, accounts []auth.Account) (int64, error) { + var total int64 + for _, account := range accounts { + usage, err := a.repo.AccountCommittedBlobBytes(r.Context(), account.ID) + if err != nil { + return 0, err + } + total += usage + } + return total, nil } func (a *API) renderAdminWebSecondFactorSetup(w http.ResponseWriter, r *http.Request, principal privatePrincipal, status int, notice, message string) { @@ -213,27 +306,61 @@ func makeAdminWebForbiddenData() adminWebData { } } -func makeAdminWebDashboardData(principal privatePrincipal, accounts []auth.Account, candidates []incidents.LegacyUnownedIncidentCandidate, deletionStatus adminWebDeletionStatus, csrfToken, notice, message string) adminWebData { +func makeAdminWebOverviewData(principal privatePrincipal, accounts []auth.Account, candidateCount int, committedBytes int64, repoOK, storeOK bool, csrfToken, notice, message string) adminWebData { + data := makeAdminWebShellData(principal, adminWebPageOverview, "Dashboard Overview", "Private operational summary for this Proofline server.", csrfToken, notice, message) + data.StatusItems = []adminWebStatusItem{ + {Label: "Metadata", Value: adminWebHealthValue(repoOK), Description: "Repository check", Tone: adminWebHealthTone(repoOK)}, + {Label: "Blob store", Value: adminWebHealthValue(storeOK), Description: "Storage boundary check", Tone: adminWebHealthTone(storeOK)}, + {Label: "Accounts", Value: strconv.Itoa(len(accounts)), Description: "Registered local accounts", Tone: "neutral"}, + {Label: "Committed blobs", Value: formatAdminWebBytes(committedBytes), Description: "Tracked encrypted chunk bytes", Tone: "neutral"}, + {Label: "Incident queue", Value: strconv.Itoa(candidateCount), Description: "Visible legacy unowned candidates", Tone: "warn"}, + {Label: "Storage capacity", Value: "Not exposed", Description: "Placeholder until backend usage totals are exposed", Tone: "warn"}, + } + return data +} + +func makeAdminWebAccountsData(principal privatePrincipal, accounts []adminWebAccount, pagination adminWebAccountPagination, csrfToken, notice, message string) adminWebData { + data := makeAdminWebShellData(principal, adminWebPageAccounts, "Accounts", "Search and manage local Proofline accounts.", csrfToken, notice, message) + data.Accounts = accounts + data.AccountPagination = pagination + return data +} + +func makeAdminWebIncidentsData(principal privatePrincipal, candidates []incidents.LegacyUnownedIncidentCandidate, deletionStatus adminWebDeletionStatus, csrfToken, notice, message string) adminWebData { + data := makeAdminWebShellData(principal, adminWebPageIncidents, "Incidents", "Private incident operation controls.", csrfToken, notice, message) + data.IncidentCandidates = makeAdminWebIncidentCandidates(candidates) + data.DeletionStatus = deletionStatus + return data +} + +func makeAdminWebSettingsData(principal privatePrincipal, csrfToken, notice, message string, configItems, secondFactorItems []adminWebStatusItem) adminWebData { + data := makeAdminWebShellData(principal, adminWebPageSettings, "Settings", "Current admin account settings and safe server configuration.", csrfToken, notice, message) + data.ConfigItems = configItems + data.SecondFactorItems = secondFactorItems + return data +} + +func makeAdminWebShellData(principal privatePrincipal, page, title, lead, csrfToken, notice, message string) adminWebData { return adminWebData{ - Title: "Proofline Admin", - Mode: "dashboard", - Error: message, - Notice: notice, - CSRFToken: csrfToken, - Account: makeAdminWebAccount(principal.Account, principal.Account.ID), - Accounts: makeAdminWebAccounts(accounts, principal.Account.ID), - IncidentCandidates: makeAdminWebIncidentCandidates(candidates), - DeletionStatus: deletionStatus, - NavItems: []adminWebNavItem{ - {Label: "Accounts", Href: "#accounts", Description: "Local users", Current: true}, - {Label: "Operations", Href: "#operations", Description: "Incident controls"}, - {Label: "Boundary", Href: "#boundary", Description: "Private only"}, - }, - StatusItems: []adminWebStatusItem{ - {Label: "Admin session", Value: "Verified", Tone: "ok"}, - {Label: "Route group", Value: "Private /admin", Tone: "neutral"}, - {Label: "Public viewer", Value: "Not mounted", Tone: "warn"}, - }, + Title: "Proofline Admin", + Mode: page, + AdminShell: true, + PageTitle: title, + PageLead: lead, + Error: message, + Notice: notice, + CSRFToken: csrfToken, + Account: makeAdminWebAccount(principal.Account, principal.Account.ID), + NavItems: adminWebNavItems(page), + } +} + +func adminWebNavItems(page string) []adminWebNavItem { + return []adminWebNavItem{ + {Label: "Overview", Href: "/admin", Description: "Server status", Current: page == adminWebPageOverview}, + {Label: "Accounts", Href: "/admin/accounts", Description: "Local users", Current: page == adminWebPageAccounts}, + {Label: "Incidents", Href: "/admin/incidents", Description: "Incident controls", Current: page == adminWebPageIncidents}, + {Label: "Settings", Href: "/admin/settings", Description: "Admin controls", Current: page == adminWebPageSettings}, } } @@ -315,13 +442,126 @@ func makeAdminWebAccount(account auth.Account, currentAccountID string) adminWeb return adminWebAccount{ ID: account.ID, Username: account.Username, + Email: account.EmailNormalized, Role: account.Role, + AccountState: account.AccountState, + SecondFactorSetup: account.SecondFactorSetup, CreatedAt: account.CreatedAt, PasswordChangedAt: account.PasswordChangedAt, IsCurrent: account.ID == currentAccountID, } } +func adminWebPaginateAccounts(r *http.Request, accounts []auth.Account, currentAccountID string) ([]adminWebAccount, adminWebAccountPagination) { + search := strings.TrimSpace(r.URL.Query().Get("q")) + searchFolded := strings.ToLower(search) + filtered := make([]auth.Account, 0, len(accounts)) + for _, account := range accounts { + if searchFolded == "" || + strings.Contains(strings.ToLower(account.Username), searchFolded) || + strings.Contains(strings.ToLower(account.EmailNormalized), searchFolded) { + filtered = append(filtered, account) + } + } + + page := 1 + if rawPage := strings.TrimSpace(r.URL.Query().Get("page")); rawPage != "" { + if parsed, err := strconv.Atoi(rawPage); err == nil && parsed > 0 { + page = parsed + } + } + totalPages := 1 + if len(filtered) > 0 { + totalPages = (len(filtered) + adminWebAccountsPageSize - 1) / adminWebAccountsPageSize + } + if page > totalPages { + page = totalPages + } + start := (page - 1) * adminWebAccountsPageSize + if start > len(filtered) { + start = len(filtered) + } + end := start + adminWebAccountsPageSize + if end > len(filtered) { + end = len(filtered) + } + + pagination := adminWebAccountPagination{ + Search: search, + Page: page, + Total: len(accounts), + FilteredTotal: len(filtered), + RangeLabel: adminWebAccountRangeLabel(start, end, len(filtered)), + HasPrev: page > 1, + HasNext: page < totalPages, + } + if pagination.HasPrev { + pagination.PrevHref = adminWebAccountsHref(search, page-1) + } + if pagination.HasNext { + pagination.NextHref = adminWebAccountsHref(search, page+1) + } + return makeAdminWebAccounts(filtered[start:end], currentAccountID), pagination +} + +func adminWebAccountsHref(search string, page int) string { + values := url.Values{} + if search != "" { + values.Set("q", search) + } + if page > 1 { + values.Set("page", strconv.Itoa(page)) + } + if encoded := values.Encode(); encoded != "" { + return "/admin/accounts?" + encoded + } + return "/admin/accounts" +} + +func adminWebAccountRangeLabel(start, end, total int) string { + if total == 0 { + return "0 of 0" + } + return fmt.Sprintf("%d-%d of %d", start+1, end, total) +} + +func adminWebHealthValue(ok bool) string { + if ok { + return "OK" + } + return "Issue" +} + +func adminWebHealthTone(ok bool) string { + if ok { + return "ok" + } + return "danger" +} + +func formatAdminWebBytes(value int64) string { + if value < 0 { + value = 0 + } + const unit = int64(1024) + if value < unit { + return fmt.Sprintf("%d B", value) + } + div, exp := unit, 0 + for n := value / unit; n >= unit && exp < 4; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(value)/float64(div), "KMGTPE"[exp]) +} + +func formatAdminWebDuration(value time.Duration) string { + if value <= 0 { + return "disabled" + } + return value.String() +} + func (a *API) adminWebAuthnAvailable() bool { return a.webAuthn.Enabled && a.webAuthn.RPID != "" && len(a.webAuthn.AllowedOrigins) > 0 } @@ -351,6 +591,64 @@ func (a *API) adminWebEmailAvailable(r *http.Request, principal privatePrincipal return false, err } +func (a *API) adminWebSecondFactorItems() []adminWebStatusItem { + emailValue := "Not configured" + emailTone := "warn" + if a.emailSender != nil { + emailValue = "Available" + emailTone = "neutral" + } + webAuthnValue := "Not configured" + webAuthnTone := "warn" + if a.adminWebAuthnAvailable() { + webAuthnValue = "Available" + webAuthnTone = "neutral" + } + return []adminWebStatusItem{ + {Label: "Email challenge", Value: emailValue, Description: "Mail delivery status", Tone: emailTone}, + {Label: "TOTP", Value: "API available", Description: "Authenticator-app setup", Tone: "neutral"}, + {Label: "Security keys", Value: webAuthnValue, Description: "WebAuthn/FIDO2 setup", Tone: webAuthnTone}, + } +} + +func (a *API) adminWebConfigItems() []adminWebStatusItem { + return []adminWebStatusItem{ + {Label: "Max upload", Value: formatAdminWebBytes(a.maxUploadBytes), Description: "Per request body limit", Tone: "neutral"}, + {Label: "Account blob quota", Value: formatAdminWebBytes(a.accountBlobQuotaBytes), Description: "Default committed chunk quota", Tone: "neutral"}, + {Label: "Session TTL", Value: formatAdminWebDuration(a.sessionTTL), Description: "Server-side auth session expiry", Tone: "neutral"}, + {Label: "Incident token TTL", Value: formatAdminWebDuration(a.defaultIncidentTokenTTL), Description: "Default viewer token expiry", Tone: "neutral"}, + {Label: "Registration", Value: a.accountRegistration.Mode, Description: "Account registration mode", Tone: "neutral"}, + {Label: "Browser sessions", Value: adminWebEnabledValue(a.webAuth.Enabled), Description: "Cookie auth for main API", Tone: adminWebEnabledTone(a.webAuth.Enabled)}, + {Label: "WebAuthn", Value: adminWebEnabledValue(a.webAuthn.Enabled), Description: "RP details redacted", Tone: adminWebEnabledTone(a.webAuthn.Enabled)}, + {Label: "Email sender", Value: adminWebConfiguredValue(a.emailSender != nil), Description: "Provider details redacted", Tone: adminWebEnabledTone(a.emailSender != nil)}, + {Label: "Main rate limits", Value: adminWebEnabledValue(a.mainRateLimit.Enabled), Description: formatAdminWebDuration(a.mainRateLimit.Window), Tone: adminWebEnabledTone(a.mainRateLimit.Enabled)}, + {Label: "Viewer rate limits", Value: adminWebEnabledValue(a.publicRateLimit.Enabled), Description: formatAdminWebDuration(a.publicRateLimit.Window), Tone: adminWebEnabledTone(a.publicRateLimit.Enabled)}, + {Label: "Relay capability", Value: adminWebConfiguredValue(a.relayCapability.Secret != ""), Description: "Secret redacted", Tone: adminWebEnabledTone(a.relayCapability.Secret != "")}, + {Label: "Relay service auth", Value: adminWebConfiguredValue(a.relayService.AuthToken != ""), Description: "Token redacted", Tone: adminWebEnabledTone(a.relayService.AuthToken != "")}, + } +} + +func adminWebEnabledValue(enabled bool) string { + if enabled { + return "Enabled" + } + return "Disabled" +} + +func adminWebConfiguredValue(configured bool) string { + if configured { + return "Configured" + } + return "Not configured" +} + +func adminWebEnabledTone(enabled bool) string { + if enabled { + return "neutral" + } + return "warn" +} + func setAdminWebPageHeaders(w http.ResponseWriter) { setPublicBrowserSecurityHeaders(w) setNoStore(w) diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index 81fe5b2..cafda50 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -100,6 +100,9 @@ func (a *API) registerAdminAPIRoutes(mux *http.ServeMux) { func (a *API) registerPrivateAdminWebRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /admin", a.adminWebPage) + mux.HandleFunc("GET /admin/accounts", a.adminWebAccountsPage) + mux.HandleFunc("GET /admin/incidents", a.adminWebIncidentsPage) + mux.HandleFunc("GET /admin/settings", a.adminWebSettingsPage) mux.HandleFunc("POST /admin/bootstrap", a.adminWebBootstrap) mux.HandleFunc("POST /admin/login", a.adminWebLogin) mux.HandleFunc("POST /admin/logout", a.adminWebLogout) diff --git a/internal/httpapi/web/admin/static/styles.css b/internal/httpapi/web/admin/static/styles.css index 71c83c3..a7fba07 100644 --- a/internal/httpapi/web/admin/static/styles.css +++ b/internal/httpapi/web/admin/static/styles.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}html{min-width:320px;background:#1a0c2e}body{margin:0;min-height:100vh;--tw-bg-opacity:1;background-color:rgb(26 12 46/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}dd,dl,h1,h2,h3,p{margin:0}h1{font-size:1.5rem;line-height:2rem;font-weight:600;line-height:1.25;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));letter-spacing:0}h2{font-size:1.125rem}h2,h3{font-weight:600;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));letter-spacing:0}h3{font-size:1rem}button{display:inline-flex;min-height:2.75rem;width:-moz-fit-content;width:fit-content;cursor:pointer;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(167 139 250/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(167 139 250/var(--tw-bg-opacity,1));padding:.625rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgb(18 7 31/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;letter-spacing:0}button:hover{--tw-border-opacity:1;border-color:rgb(196 181 253/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(196 181 253/var(--tw-bg-opacity,1))}a:focus-visible,button:focus-visible,input:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:#c084fc}label{display:grid;gap:.5rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}input{min-height:2.75rem;width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:.625rem .75rem;font-size:1rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;font:inherit}input:hover{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1))}input::-moz-placeholder{--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}input::placeholder{--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.skip-link{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.skip-link:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto;white-space:normal;position:fixed;left:1rem;top:1rem;z-index:50;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:500}.admin-shell,.skip-link:focus{--tw-bg-opacity:1;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.admin-shell{min-height:100vh;background-color:rgb(26 12 46/var(--tw-bg-opacity,1))}@media (min-width:1024px){.admin-shell[data-admin-mode=dashboard]{display:grid;grid-template-columns:248px minmax(0,1fr)}}.auth-shell{display:grid;place-items:center;padding:2.5rem 1rem}@media (min-width:640px){.auth-shell{padding-left:1.5rem;padding-right:1.5rem}}.auth-card{width:100%;max-width:28rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));padding:1.25rem;--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.auth-card{padding:1.5rem}}.auth-brand{display:flex;align-items:flex-start;gap:.75rem}.auth-logo{height:4rem;width:4rem;flex-shrink:0;--tw-scale-x:1.25;--tw-scale-y:1.25;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));-o-object-fit:contain;object-fit:contain}.auth-kicker{line-height:1.25rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1))}.auth-kicker,.auth-lead{font-size:.875rem;--tw-text-opacity:1}.auth-lead{line-height:1.5rem;color:rgb(229 216 255/var(--tw-text-opacity,1))}.auth-card .boundary-list,.auth-lead{margin-top:1rem}.mobile-header{position:sticky;top:0;z-index:20;display:flex;align-items:center;justify-content:space-between;gap:.75rem;border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.95);padding:.75rem 1rem;--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}@media (min-width:1024px){.mobile-header{display:none}}.brand-lockup,.sidebar-brand{display:inline-flex;min-width:0;align-items:center;gap:.75rem;border-radius:.375rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));text-decoration-line:none}.brand-logo{height:2.5rem;width:2.5rem;flex-shrink:0;-o-object-fit:contain;object-fit:contain}.brand-copy,.sidebar-brand span{display:grid;min-width:0;gap:.125rem}.brand-title,.sidebar-title{font-size:1.125rem;font-weight:600;line-height:1.5rem;color:rgb(248 244 255/var(--tw-text-opacity,1))}.brand-subtitle,.brand-title,.sidebar-kicker,.sidebar-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;--tw-text-opacity:1}.brand-subtitle,.sidebar-kicker{font-size:.75rem;line-height:1rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1))}.logout-form{flex-shrink:0}.admin-sidebar{display:none;min-height:100vh;flex-direction:column;gap:1.5rem;border-right-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:1.25rem 1rem}@media (min-width:1024px){.admin-sidebar{display:flex}}.sidebar-brand{padding-left:.25rem;padding-right:.25rem}.sidebar-logo{height:2.75rem;width:2.75rem;flex-shrink:0;-o-object-fit:contain;object-fit:contain}.sidebar-nav{display:grid;gap:.5rem}.nav-link{border-radius:.5rem;border-width:1px;border-color:transparent;padding:.75rem;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.nav-link-main{display:flex;align-items:center;gap:.5rem;font-weight:500}.nav-link small{margin-top:.25rem;display:block;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1));letter-spacing:0}.nav-link.is-active{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));--tw-shadow:0 10px 30px rgba(16,5,31,.18);--tw-shadow-colored:0 10px 30px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.nav-link.is-active small{--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.nav-link.is-disabled{background-color:transparent}.sidebar-note{margin-top:auto;display:flex;align-items:center;gap:.5rem;border-radius:.5rem;border-width:1px;border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));padding:.75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dashboard-main{margin-left:auto;margin-right:auto;width:100%;max-width:80rem;padding:1.25rem 1rem}@media (min-width:640px){.dashboard-main{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:1024px){.dashboard-main{padding:1.5rem 2rem}}.content-section,.metric-card,.page-header{border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.page-header{display:flex;flex-direction:column;gap:1rem;padding:1.25rem}@media (min-width:640px){.page-header{flex-direction:row;align-items:flex-start;justify-content:space-between;padding:1.5rem}}.page-lead{margin-top:.75rem;max-width:48rem;line-height:1.5rem;color:rgb(229 216 255/var(--tw-text-opacity,1))}.eyebrow,.page-lead{font-size:.875rem;--tw-text-opacity:1}.eyebrow{margin-bottom:.5rem;line-height:1.25rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.desktop-logout{display:none;flex-shrink:0}@media (min-width:1024px){.desktop-logout{display:block}}.ghost-button{border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgb(50 28 85/var(--tw-bg-opacity,1));color:rgb(229 216 255/var(--tw-text-opacity,1))}.ghost-button,.ghost-button:hover{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.ghost-button:hover{border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));color:rgb(248 244 255/var(--tw-text-opacity,1))}.inline-status{margin-top:1rem;border-radius:.375rem;border-width:1px;padding:.75rem;font-size:.875rem;font-weight:500;line-height:1.5rem}.inline-status.success{border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.inline-status.danger{border-color:rgba(251,113,133,.4);--tw-bg-opacity:1;background-color:rgb(63 11 22/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.metric-grid{margin-top:1.25rem;display:grid;gap:1rem}@media (min-width:640px){.metric-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.metric-card{display:grid;min-height:5rem;grid-template-columns:auto minmax(0,1fr);align-items:flex-start;gap:.75rem;padding:.75rem 1rem}.metric-card dt{font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.metric-card dd{margin-top:.25rem;overflow-wrap:break-word;font-size:1.25rem;line-height:1.75rem;font-weight:600;line-height:1.25;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.metric-icon{margin-top:.25rem;height:.75rem;width:.75rem;border-radius:9999px;--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.metric-card.ok .metric-icon{--tw-bg-opacity:1;background-color:rgb(52 211 153/var(--tw-bg-opacity,1))}.metric-card.neutral .metric-icon{--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.metric-card.warn .metric-icon{--tw-bg-opacity:1;background-color:rgb(251 191 36/var(--tw-bg-opacity,1))}.workspace-grid{margin-top:1.25rem;display:grid;gap:1.25rem}@media (min-width:1280px){.workspace-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.content-section{padding:1.25rem}@media (min-width:640px){.content-section{padding:1.5rem}}.accounts-section{min-width:0}@media (min-width:1280px){.accounts-section{grid-column:span 2/span 2}}.section-header{margin-bottom:1rem;display:flex;align-items:flex-start;justify-content:space-between;gap:1rem}.section-icon{height:2.25rem;width:2.25rem;flex-shrink:0;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));padding:.5rem;--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}.section-icon.warning{border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.status-badge{display:inline-flex;width:-moz-fit-content;width:fit-content;flex-shrink:0;align-items:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding:.25rem .5rem;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.status-badge.current{margin-left:.5rem;border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.account-list>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse));--tw-divide-opacity:1;border-color:rgb(91 59 137/var(--tw-divide-opacity,1))}.account-row{display:grid;gap:.75rem;padding-top:1rem;padding-bottom:1rem}.account-row:first-child{padding-top:0}.account-row:last-child{padding-bottom:0}.account-summary{display:grid;gap:.75rem}@media (min-width:640px){.account-summary{grid-template-columns:auto minmax(0,1fr);align-items:flex-start}}.account-avatar{display:grid;height:2.75rem;min-width:2.75rem;place-items:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding-left:.5rem;padding-right:.5rem;font-size:.75rem;line-height:1rem;font-weight:600;text-transform:uppercase;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.account-copy{min-width:0}.account-copy h3{overflow-wrap:break-word}.metadata-row{margin-top:.75rem;display:grid;gap:.75rem}@media (min-width:640px){.metadata-row{grid-template-columns:repeat(3,minmax(0,1fr))}}.metadata-row dt{font-size:.75rem;line-height:1rem;font-weight:500;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.metadata-row dd{margin-top:.25rem;overflow-wrap:break-word;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.side-stack{display:grid;align-content:flex-start;gap:1.25rem}.stack-form{margin-top:1.25rem;display:grid;gap:1rem}.inline-form{display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.inline-form{grid-template-columns:minmax(0,1fr) auto}}.compact-form{max-width:42rem}.muted{font-size:.875rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.boundary-section{border-color:rgba(251,191,36,.4)}.boundary-list{margin:0;list-style-type:disc;padding-left:1.25rem;font-size:.875rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.boundary-list li+li{margin-top:.5rem}.icon{height:1rem;width:1rem;flex-shrink:0}.visible{visibility:visible}.hidden{display:none} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}html{min-width:320px;background:#1a0c2e}body{margin:0;min-height:100vh;--tw-bg-opacity:1;background-color:rgb(26 12 46/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}dd,dl,h1,h2,h3,p{margin:0}h1{font-size:1.5rem;line-height:2rem;font-weight:600;line-height:1.25;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));letter-spacing:0}h2{font-size:1.125rem}h2,h3{font-weight:600;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));letter-spacing:0}h3{font-size:1rem}button{display:inline-flex;min-height:2.75rem;width:-moz-fit-content;width:fit-content;cursor:pointer;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(167 139 250/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(167 139 250/var(--tw-bg-opacity,1));padding:.625rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgb(18 7 31/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;letter-spacing:0}button:hover{--tw-border-opacity:1;border-color:rgb(196 181 253/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(196 181 253/var(--tw-bg-opacity,1))}a:focus-visible,button:focus-visible,input:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:#c084fc}label{display:grid;gap:.5rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}input{min-height:2.75rem;width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:.625rem .75rem;font-size:1rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;font:inherit}input:hover{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1))}input::-moz-placeholder{--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}input::placeholder{--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.skip-link{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.skip-link:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto;white-space:normal;position:fixed;left:1rem;top:1rem;z-index:50;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:500}.admin-shell,.skip-link:focus{--tw-bg-opacity:1;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.admin-shell{min-height:100vh;background-color:rgb(26 12 46/var(--tw-bg-opacity,1))}@media (min-width:1024px){.admin-shell[data-admin-mode=accounts],.admin-shell[data-admin-mode=dashboard],.admin-shell[data-admin-mode=incidents],.admin-shell[data-admin-mode=settings]{display:grid;grid-template-columns:248px minmax(0,1fr)}}.auth-shell{display:grid;place-items:center;padding:2.5rem 1rem}@media (min-width:640px){.auth-shell{padding-left:1.5rem;padding-right:1.5rem}}.auth-card{width:100%;max-width:28rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));padding:1.25rem;--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.auth-card{padding:1.5rem}}.auth-brand{display:flex;align-items:flex-start;gap:.75rem}.auth-logo{height:4rem;width:4rem;flex-shrink:0;--tw-scale-x:1.25;--tw-scale-y:1.25;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));-o-object-fit:contain;object-fit:contain}.auth-kicker{line-height:1.25rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1))}.auth-kicker,.auth-lead{font-size:.875rem;--tw-text-opacity:1}.auth-lead{line-height:1.5rem;color:rgb(229 216 255/var(--tw-text-opacity,1))}.auth-card .boundary-list,.auth-lead{margin-top:1rem}.mobile-header{position:sticky;top:0;z-index:20;display:flex;align-items:center;justify-content:space-between;gap:.75rem;border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.95);padding:.75rem 1rem;--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}@media (min-width:1024px){.mobile-header{display:none}}.brand-lockup,.sidebar-brand{display:inline-flex;min-width:0;align-items:center;gap:.75rem;border-radius:.375rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));text-decoration-line:none}.brand-logo{height:2.5rem;width:2.5rem;flex-shrink:0;-o-object-fit:contain;object-fit:contain}.brand-copy,.sidebar-brand span{display:grid;min-width:0;gap:.125rem}.brand-title,.sidebar-title{font-size:1.125rem;font-weight:600;line-height:1.5rem;color:rgb(248 244 255/var(--tw-text-opacity,1))}.brand-subtitle,.brand-title,.sidebar-kicker,.sidebar-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;--tw-text-opacity:1}.brand-subtitle,.sidebar-kicker{font-size:.75rem;line-height:1rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1))}.logout-form{flex-shrink:0}.admin-sidebar{display:none;min-height:100vh;flex-direction:column;gap:1.5rem;border-right-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:1.25rem 1rem}@media (min-width:1024px){.admin-sidebar{display:flex}}.sidebar-brand{padding-left:.25rem;padding-right:.25rem}.sidebar-logo{height:2.75rem;width:2.75rem;flex-shrink:0;-o-object-fit:contain;object-fit:contain}.sidebar-nav{display:grid;gap:.5rem}.nav-link{border-radius:.5rem;border-width:1px;border-color:transparent;padding:.75rem;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.nav-link-main{display:flex;align-items:center;gap:.5rem;font-weight:500}.nav-link small{margin-top:.25rem;display:block;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1));letter-spacing:0}.nav-link.is-active{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));--tw-shadow:0 10px 30px rgba(16,5,31,.18);--tw-shadow-colored:0 10px 30px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.nav-link.is-active small{--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.nav-link.is-disabled{background-color:transparent}.sidebar-note{margin-top:auto;display:flex;align-items:center;gap:.5rem;border-radius:.5rem;border-width:1px;border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));padding:.75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dashboard-main{margin-left:auto;margin-right:auto;width:100%;max-width:80rem;padding:1.25rem 1rem}@media (min-width:640px){.dashboard-main{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:1024px){.dashboard-main{padding:1.5rem 2rem}}.content-section,.metric-card,.page-header{border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.page-header{display:flex;flex-direction:column;gap:1rem;padding:1.25rem}@media (min-width:640px){.page-header{flex-direction:row;align-items:flex-start;justify-content:space-between;padding:1.5rem}}.page-lead{margin-top:.75rem;max-width:48rem;line-height:1.5rem;color:rgb(229 216 255/var(--tw-text-opacity,1))}.eyebrow,.page-lead{font-size:.875rem;--tw-text-opacity:1}.eyebrow{margin-bottom:.5rem;line-height:1.25rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.desktop-logout{display:none;flex-shrink:0}@media (min-width:1024px){.desktop-logout{display:block}}.ghost-button{border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgb(50 28 85/var(--tw-bg-opacity,1));color:rgb(229 216 255/var(--tw-text-opacity,1))}.ghost-button,.ghost-button:hover{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.ghost-button:hover{border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));color:rgb(248 244 255/var(--tw-text-opacity,1))}.inline-status{margin-top:1rem;border-radius:.375rem;border-width:1px;padding:.75rem;font-size:.875rem;font-weight:500;line-height:1.5rem}.inline-status.success{border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.inline-status.danger{border-color:rgba(251,113,133,.4);--tw-bg-opacity:1;background-color:rgb(63 11 22/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.metric-grid{margin-top:1.25rem;display:grid;gap:1rem}@media (min-width:640px){.metric-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.metric-card{display:grid;min-height:5rem;grid-template-columns:auto minmax(0,1fr);align-items:flex-start;gap:.75rem;padding:.75rem 1rem}.metric-card dt{font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.metric-card dd{margin-top:.25rem;overflow-wrap:break-word;font-size:1.25rem;line-height:1.75rem;font-weight:600;line-height:1.25;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.metric-description{margin-top:.25rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.metric-icon{margin-top:.25rem;height:.75rem;width:.75rem;border-radius:9999px;--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.metric-card.ok .metric-icon{--tw-bg-opacity:1;background-color:rgb(52 211 153/var(--tw-bg-opacity,1))}.metric-card.neutral .metric-icon{--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.metric-card.warn .metric-icon{--tw-bg-opacity:1;background-color:rgb(251 191 36/var(--tw-bg-opacity,1))}.metric-card.danger .metric-icon{--tw-bg-opacity:1;background-color:rgb(251 113 133/var(--tw-bg-opacity,1))}.workspace-grid{margin-top:1.25rem;display:grid;gap:1.25rem}@media (min-width:1280px){.workspace-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.workspace-grid.single-column{grid-template-columns:repeat(1,minmax(0,1fr))}}.overview-grid,.settings-grid{margin-top:1.25rem;display:grid;gap:1.25rem}@media (min-width:1024px){.overview-grid,.settings-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.settings-wide{grid-column:span 2/span 2}}.content-section{padding:1.25rem}@media (min-width:640px){.content-section{padding:1.5rem}}.accounts-section{min-width:0}@media (min-width:1280px){.accounts-section{grid-column:span 2/span 2}}.section-header{margin-bottom:1rem;display:flex;align-items:flex-start;justify-content:space-between;gap:1rem}.section-icon{height:2.25rem;width:2.25rem;flex-shrink:0;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));padding:.5rem;--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}.section-icon.warning{border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.status-badge{display:inline-flex;width:-moz-fit-content;width:fit-content;flex-shrink:0;align-items:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding:.25rem .5rem;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.status-badge.current{margin-left:.5rem;border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.toolbar-form{margin-bottom:1.25rem;display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.toolbar-form{grid-template-columns:minmax(0,1fr) auto auto}}.account-list>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse));--tw-divide-opacity:1;border-color:rgb(91 59 137/var(--tw-divide-opacity,1))}.fixed-list{min-height:42rem}.account-row{display:grid;gap:.75rem;padding-top:1rem;padding-bottom:1rem}.account-row:first-child{padding-top:0}.account-row:last-child{padding-bottom:0}.create-row{margin-bottom:1rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.4);padding-left:1rem;padding-right:1rem}.account-summary{display:grid;gap:.75rem}@media (min-width:640px){.account-summary{grid-template-columns:auto minmax(0,1fr);align-items:flex-start}}.account-avatar{display:grid;height:2.75rem;min-width:2.75rem;place-items:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding-left:.5rem;padding-right:.5rem;font-size:.75rem;line-height:1rem;font-weight:600;text-transform:uppercase;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.account-copy{min-width:0}.account-copy h3{overflow-wrap:break-word}.metadata-row{margin-top:.75rem;display:grid;gap:.75rem}@media (min-width:640px){.metadata-row{grid-template-columns:repeat(3,minmax(0,1fr))}}.metadata-row dt{font-size:.75rem;line-height:1rem;font-weight:500;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.metadata-row dd{margin-top:.25rem;overflow-wrap:break-word;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.stack-form{margin-top:1.25rem;display:grid;gap:1rem}.inline-form{display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.inline-form{grid-template-columns:minmax(0,1fr) auto}}.compact-form{max-width:42rem}.pager{margin-top:1.25rem;display:flex;align-items:center;justify-content:space-between;gap:.75rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1rem}.pager-current{font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.ghost-link{display:inline-flex;min-height:2.5rem;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:600;color:rgb(229 216 255/var(--tw-text-opacity,1))}.ghost-link,.ghost-link:hover{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.ghost-link:hover{border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));color:rgb(248 244 255/var(--tw-text-opacity,1))}.ghost-link.is-disabled{cursor:default;opacity:.5}.detail-list{display:grid;gap:1rem}.config-item dt,.detail-list dt{font-size:.75rem;line-height:1rem;font-weight:500;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.config-item dd,.detail-list dd{margin-top:.25rem;overflow-wrap:break-word;font-size:.875rem;font-weight:600;line-height:1.25rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.config-grid{display:grid;gap:.75rem}@media (min-width:640px){.config-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1024px){.config-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.config-item{min-height:6rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.4);padding:1rem}.config-item.ok{border-color:rgba(52,211,153,.4)}.config-item.warn{border-color:rgba(251,191,36,.4)}.config-item.danger{border-color:rgba(251,113,133,.4)}.config-item p{margin-top:.5rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.muted{font-size:.875rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.boundary-section{border-color:rgba(251,191,36,.4)}.boundary-list{margin:0;list-style-type:disc;padding-left:1.25rem;font-size:.875rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.boundary-list li+li{margin-top:.5rem}.icon{height:1rem;width:1rem;flex-shrink:0}.visible{visibility:visible}.hidden{display:none} \ No newline at end of file diff --git a/internal/httpapi/web/admin/tailwind.css b/internal/httpapi/web/admin/tailwind.css index e82f72b..7a62207 100644 --- a/internal/httpapi/web/admin/tailwind.css +++ b/internal/httpapi/web/admin/tailwind.css @@ -87,12 +87,18 @@ @apply min-h-screen bg-proofline-bg text-proofline-text; } - .admin-shell[data-admin-mode="dashboard"] { + .admin-shell[data-admin-mode="dashboard"], + .admin-shell[data-admin-mode="accounts"], + .admin-shell[data-admin-mode="incidents"], + .admin-shell[data-admin-mode="settings"] { @apply lg:grid; } @media (min-width: 1024px) { - .admin-shell[data-admin-mode="dashboard"] { + .admin-shell[data-admin-mode="dashboard"], + .admin-shell[data-admin-mode="accounts"], + .admin-shell[data-admin-mode="incidents"], + .admin-shell[data-admin-mode="settings"] { grid-template-columns: 248px minmax(0, 1fr); } } @@ -265,6 +271,10 @@ @apply mt-1 break-words text-xl font-semibold leading-tight text-proofline-text; } + .metric-description { + @apply mt-1 text-xs leading-5 text-proofline-text-disabled; + } + .metric-icon { @apply mt-1 h-3 w-3 rounded-full bg-proofline-info; } @@ -281,10 +291,27 @@ @apply bg-proofline-warning; } + .metric-card.danger .metric-icon { + @apply bg-proofline-danger; + } + .workspace-grid { @apply mt-5 grid gap-5 xl:grid-cols-3; } + .workspace-grid.single-column { + @apply xl:grid-cols-1; + } + + .overview-grid, + .settings-grid { + @apply mt-5 grid gap-5 lg:grid-cols-2; + } + + .settings-wide { + @apply lg:col-span-2; + } + .content-section { @apply p-5 sm:p-6; } @@ -313,14 +340,26 @@ @apply ml-2 border-proofline-success/40 bg-proofline-success-bg text-proofline-success; } + .toolbar-form { + @apply mb-5 grid items-end gap-3 sm:grid-cols-[minmax(0,1fr)_auto_auto]; + } + .account-list { @apply divide-y divide-proofline-border; } + .fixed-list { + min-height: 42rem; + } + .account-row { @apply grid gap-3 py-4 first:pt-0 last:pb-0; } + .create-row { + @apply mb-4 rounded-lg border border-proofline-border bg-proofline-bg-deep/40 px-4; + } + .account-summary { @apply grid gap-3 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-start; } @@ -366,6 +405,65 @@ @apply max-w-2xl; } + .pager { + @apply mt-5 flex items-center justify-between gap-3 border-t border-proofline-border pt-4; + } + + .pager-current { + @apply text-sm font-medium text-proofline-text-muted; + } + + .ghost-link { + @apply inline-flex min-h-10 items-center justify-center rounded-md border border-proofline-border bg-proofline-surface-elevated px-3 py-2 text-sm font-semibold text-proofline-text-secondary; + } + + .ghost-link:hover { + @apply border-proofline-border-strong bg-proofline-surface-strong text-proofline-text; + } + + .ghost-link.is-disabled { + @apply cursor-default opacity-50; + } + + .detail-list { + @apply grid gap-4; + } + + .detail-list dt, + .config-item dt { + @apply text-xs font-medium uppercase text-proofline-text-muted; + letter-spacing: 0; + } + + .detail-list dd, + .config-item dd { + @apply mt-1 break-words text-sm font-semibold leading-5 text-proofline-text-secondary; + } + + .config-grid { + @apply grid gap-3 sm:grid-cols-2 lg:grid-cols-3; + } + + .config-item { + @apply min-h-24 rounded-lg border border-proofline-border bg-proofline-bg-deep/40 p-4; + } + + .config-item.ok { + @apply border-proofline-success/40; + } + + .config-item.warn { + @apply border-proofline-warning/40; + } + + .config-item.danger { + @apply border-proofline-danger/40; + } + + .config-item p { + @apply mt-2 text-xs leading-5 text-proofline-text-disabled; + } + .muted { @apply text-sm leading-6 text-proofline-text-muted; } diff --git a/internal/httpapi/web/templates/admin.html b/internal/httpapi/web/templates/admin.html index 1d0ba8b..6d286fd 100644 --- a/internal/httpapi/web/templates/admin.html +++ b/internal/httpapi/web/templates/admin.html @@ -17,7 +17,7 @@ - {{if eq .Mode "dashboard"}} + {{if .AdminShell}}
@@ -68,9 +68,9 @@
+ {{end}} {{else}} From 4361ff2a23bc772bc2a5edb8e4d738881584762c Mon Sep 17 00:00:00 2001 From: Ellie Melton Date: Sun, 14 Jun 2026 02:31:22 +1000 Subject: [PATCH 02/10] Split admin email 2FA code entry page --- internal/httpapi/admin_web_handlers.go | 49 +++++- internal/httpapi/admin_web_test.go | 48 +++++- internal/httpapi/admin_web_view.go | 13 ++ internal/httpapi/routes.go | 1 + internal/httpapi/web/admin/static/styles.css | 2 +- internal/httpapi/web/admin/tailwind.css | 20 +++ internal/httpapi/web/templates/admin.html | 161 ++++++++++++------- 7 files changed, 224 insertions(+), 70 deletions(-) diff --git a/internal/httpapi/admin_web_handlers.go b/internal/httpapi/admin_web_handlers.go index 0001fc1..eb15a65 100644 --- a/internal/httpapi/admin_web_handlers.go +++ b/internal/httpapi/admin_web_handlers.go @@ -174,6 +174,48 @@ func (a *API) adminWebLogout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin", http.StatusSeeOther) } +func (a *API) adminWebEmailSecondFactorVerifyPage(w http.ResponseWriter, r *http.Request) { + setAdminWebPageHeaders(w) + principal, ok := a.requireAdminWebSession(w, r) + if !ok { + return + } + if adminRequiresSecondFactorSetup(principal.Account) { + if a.emailSender == nil { + a.renderAdminWebSecondFactorSetup(w, r, principal, http.StatusForbidden, adminWebNotice(r), "") + return + } + a.renderAdminWeb(w, http.StatusForbidden, makeAdminWebSecondFactorSetupEmailVerifyData( + principal, + adminWebCSRFTokenFromRequest(r), + adminWebNotice(r), + "", + a.adminWebAuthnAvailable(), + )) + return + } + required, err := a.sessionRequiresSecondFactorVerification(r.Context(), principal.Account, principal.Session) + if err != nil { + a.adminWebInternalError(w, "check admin web email second factor requirement", err) + return + } + if !required { + http.Redirect(w, r, "/admin", http.StatusSeeOther) + return + } + data, err := a.makeAdminWebSecondFactorVerificationDataForRequest(r, principal, adminWebNotice(r), "") + if err != nil { + a.adminWebInternalError(w, "build admin web email second factor verification data", err) + return + } + if !data.SecondFactorEmailAvailable { + a.renderAdminWeb(w, http.StatusForbidden, data) + return + } + data.Mode = "second_factor_verify_email" + a.renderAdminWeb(w, http.StatusForbidden, data) +} + func (a *API) adminWebRequestEmailSecondFactorChallenge(w http.ResponseWriter, r *http.Request) { setAdminWebPageHeaders(w) principal, ok := a.requireAdminWebSession(w, r) @@ -229,7 +271,7 @@ func (a *API) adminWebRequestEmailSecondFactorChallenge(w http.ResponseWriter, r a.renderAdminWeb(w, http.StatusServiceUnavailable, data) return } - http.Redirect(w, r, "/admin?notice=second_factor_challenge_sent", http.StatusSeeOther) + http.Redirect(w, r, "/admin/second-factor/email/verify?notice=second_factor_challenge_sent", http.StatusSeeOther) return } data := makeAdminWebSecondFactorSetupData(principal, adminWebCSRFTokenFromRequest(r), "", "The second-factor setup form could not be read.", a.emailSender != nil, a.adminWebAuthnAvailable()) @@ -286,7 +328,7 @@ func (a *API) adminWebRequestEmailSecondFactorChallenge(w http.ResponseWriter, r a.renderAdminWeb(w, http.StatusServiceUnavailable, data) return } - http.Redirect(w, r, "/admin?notice=second_factor_challenge_sent", http.StatusSeeOther) + http.Redirect(w, r, "/admin/second-factor/email/verify?notice=second_factor_challenge_sent", http.StatusSeeOther) } func (a *API) adminWebVerifyEmailSecondFactorChallenge(w http.ResponseWriter, r *http.Request) { @@ -310,6 +352,7 @@ func (a *API) adminWebVerifyEmailSecondFactorChallenge(w http.ResponseWriter, r a.adminWebInternalError(w, "build admin web email second factor verification data", err) return } + data.Mode = "second_factor_verify_email" if ok := a.parseAdminWebForm(w, r, data); !ok { return } @@ -340,7 +383,7 @@ func (a *API) adminWebVerifyEmailSecondFactorChallenge(w http.ResponseWriter, r http.Redirect(w, r, "/admin?notice=second_factor_verified", http.StatusSeeOther) return } - data := makeAdminWebSecondFactorSetupData(principal, adminWebCSRFTokenFromRequest(r), "", "The second-factor verification form could not be read.", a.emailSender != nil, a.adminWebAuthnAvailable()) + data := makeAdminWebSecondFactorSetupEmailVerifyData(principal, adminWebCSRFTokenFromRequest(r), "", "The second-factor verification form could not be read.", a.adminWebAuthnAvailable()) if ok := a.parseAdminWebForm(w, r, data); !ok { return } diff --git a/internal/httpapi/admin_web_test.go b/internal/httpapi/admin_web_test.go index 9c23623..3a96b75 100644 --- a/internal/httpapi/admin_web_test.go +++ b/internal/httpapi/admin_web_test.go @@ -962,11 +962,16 @@ func TestAdminWebEmailSecondFactorSetupUnlocksDashboard(t *testing.T) { if response.StatusCode != http.StatusForbidden { t.Fatalf("expected admin web setup status 403, got %d: %s", response.StatusCode, body) } - for _, expected := range []string{"Set Up Admin 2FA", "Send email code", "Complete setup"} { + for _, expected := range []string{"Set Up Admin 2FA", "Email code", "Send email code"} { if !bytes.Contains(body, []byte(expected)) { t.Fatalf("admin web setup page missing %q: %s", expected, body) } } + for _, disallowed := range []string{"Complete setup", `action="/admin/second-factor/email/verify"`} { + if bytes.Contains(body, []byte(disallowed)) { + t.Fatalf("admin web setup page exposed code verification form %q: %s", disallowed, body) + } + } csrfToken := adminWebCSRFTokenFromBody(t, body) response, body = postAdminWebFormWithCookie(t, app, "/admin/second-factor/email/challenge", url.Values{ @@ -977,7 +982,7 @@ func TestAdminWebEmailSecondFactorSetupUnlocksDashboard(t *testing.T) { if response.StatusCode != http.StatusSeeOther { t.Fatalf("expected email challenge redirect 303, got %d: %s", response.StatusCode, body) } - if location := response.Header.Get("Location"); location != "/admin?notice=second_factor_challenge_sent" { + if location := response.Header.Get("Location"); location != "/admin/second-factor/email/verify?notice=second_factor_challenge_sent" { t.Fatalf("expected email challenge notice redirect, got %q", location) } if len(sender.messages) != 1 { @@ -985,6 +990,22 @@ func TestAdminWebEmailSecondFactorSetupUnlocksDashboard(t *testing.T) { } code := secondFactorCodeFromEmail(t, sender.messages[0]) + response, body = requestWithCookie(t, app.adminHandler, http.MethodGet, "/admin/second-factor/email/verify?notice=second_factor_challenge_sent", "", nil, cookie) + response.Body.Close() + if response.StatusCode != http.StatusForbidden { + t.Fatalf("expected admin web setup code status 403, got %d: %s", response.StatusCode, body) + } + for _, expected := range []string{"Verify Email Code", "Second-factor challenge sent.", "Complete setup", `action="/admin/second-factor/email/verify"`} { + if !bytes.Contains(body, []byte(expected)) { + t.Fatalf("admin web setup code page missing %q: %s", expected, body) + } + } + for _, disallowed := range []string{"Send email code", "admin-2fa@example.invalid", app.authToken} { + if bytes.Contains(body, []byte(disallowed)) { + t.Fatalf("admin web setup code page exposed %q: %s", disallowed, body) + } + } + csrfToken = adminWebCSRFTokenFromBody(t, body) response, body = postAdminWebFormWithCookie(t, app, "/admin/second-factor/email/verify", url.Values{ "csrf_token": {csrfToken}, "code": {code}, @@ -1019,12 +1040,12 @@ func TestAdminWebEmailSecondFactorSetupUnlocksDashboard(t *testing.T) { if response.StatusCode != http.StatusForbidden { t.Fatalf("expected admin email 2FA gate after relogin status 403, got %d: %s", response.StatusCode, body) } - for _, expected := range []string{"Verify Admin 2FA", "Send email code", "Verify email code", `action="/admin/second-factor/email/challenge"`} { + for _, expected := range []string{"Verify Admin 2FA", "Email code", "Send email code", `action="/admin/second-factor/email/challenge"`} { if !bytes.Contains(body, []byte(expected)) { t.Fatalf("admin web email gate missing %q: %s", expected, body) } } - for _, disallowed := range []string{"User Accounts", `action="/admin/password"`, code, "admin-2fa@example.invalid", app.authToken} { + for _, disallowed := range []string{"User Accounts", `action="/admin/password"`, `action="/admin/second-factor/email/verify"`, code, "admin-2fa@example.invalid", app.authToken} { if bytes.Contains(body, []byte(disallowed)) { t.Fatalf("admin web email gate exposed %q: %s", disallowed, body) } @@ -1038,13 +1059,30 @@ func TestAdminWebEmailSecondFactorSetupUnlocksDashboard(t *testing.T) { if response.StatusCode != http.StatusSeeOther { t.Fatalf("expected email verification challenge redirect 303, got %d: %s", response.StatusCode, body) } - if location := response.Header.Get("Location"); location != "/admin?notice=second_factor_challenge_sent" { + if location := response.Header.Get("Location"); location != "/admin/second-factor/email/verify?notice=second_factor_challenge_sent" { t.Fatalf("expected email verification challenge notice redirect, got %q", location) } if len(sender.messages) != 2 { t.Fatalf("expected two second-factor emails, got %d", len(sender.messages)) } verifyCode := secondFactorCodeFromEmail(t, sender.messages[1]) + + response, body = requestWithCookie(t, app.adminHandler, http.MethodGet, "/admin/second-factor/email/verify?notice=second_factor_challenge_sent", "", nil, newCookie) + response.Body.Close() + if response.StatusCode != http.StatusForbidden { + t.Fatalf("expected admin web email code status 403, got %d: %s", response.StatusCode, body) + } + for _, expected := range []string{"Verify Email Code", "Second-factor challenge sent.", "Verify email code", `action="/admin/second-factor/email/verify"`} { + if !bytes.Contains(body, []byte(expected)) { + t.Fatalf("admin web email code page missing %q: %s", expected, body) + } + } + for _, disallowed := range []string{"Send email code", "admin-2fa@example.invalid", app.authToken} { + if bytes.Contains(body, []byte(disallowed)) { + t.Fatalf("admin web email code page exposed %q: %s", disallowed, body) + } + } + csrfToken = adminWebCSRFTokenFromBody(t, body) response, body = postAdminWebFormWithCookie(t, app, "/admin/second-factor/email/verify", url.Values{ "csrf_token": {csrfToken}, "code": {verifyCode}, diff --git a/internal/httpapi/admin_web_view.go b/internal/httpapi/admin_web_view.go index 47461d8..2775f5a 100644 --- a/internal/httpapi/admin_web_view.go +++ b/internal/httpapi/admin_web_view.go @@ -416,6 +416,19 @@ func makeAdminWebSecondFactorSetupData(principal privatePrincipal, csrfToken, no } } +func makeAdminWebSecondFactorSetupEmailVerifyData(principal privatePrincipal, csrfToken, notice, message string, webAuthnAvailable bool) adminWebData { + return adminWebData{ + Title: "Proofline Admin 2FA Setup", + Mode: "second_factor_setup_email_verify", + Error: message, + Notice: notice, + CSRFToken: csrfToken, + Account: makeAdminWebAccount(principal.Account, principal.Account.ID), + SecondFactorEmailAvailable: true, + SecondFactorWebAuthnAvailable: webAuthnAvailable, + } +} + func makeAdminWebSecondFactorVerificationData(principal privatePrincipal, csrfToken, notice, message string, emailAvailable, totpAvailable, webAuthnAvailable bool) adminWebData { return adminWebData{ Title: "Proofline Admin 2FA Verification", diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index cafda50..d838cd8 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -106,6 +106,7 @@ func (a *API) registerPrivateAdminWebRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /admin/bootstrap", a.adminWebBootstrap) mux.HandleFunc("POST /admin/login", a.adminWebLogin) mux.HandleFunc("POST /admin/logout", a.adminWebLogout) + mux.HandleFunc("GET /admin/second-factor/email/verify", a.adminWebEmailSecondFactorVerifyPage) mux.HandleFunc("POST /admin/second-factor/email/challenge", a.adminWebRequestEmailSecondFactorChallenge) mux.HandleFunc("POST /admin/second-factor/email/verify", a.adminWebVerifyEmailSecondFactorChallenge) mux.HandleFunc("POST /admin/second-factor/totp/verify", a.adminWebVerifyTOTPSecondFactorChallenge) diff --git a/internal/httpapi/web/admin/static/styles.css b/internal/httpapi/web/admin/static/styles.css index a7fba07..004dce4 100644 --- a/internal/httpapi/web/admin/static/styles.css +++ b/internal/httpapi/web/admin/static/styles.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}html{min-width:320px;background:#1a0c2e}body{margin:0;min-height:100vh;--tw-bg-opacity:1;background-color:rgb(26 12 46/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}dd,dl,h1,h2,h3,p{margin:0}h1{font-size:1.5rem;line-height:2rem;font-weight:600;line-height:1.25;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));letter-spacing:0}h2{font-size:1.125rem}h2,h3{font-weight:600;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));letter-spacing:0}h3{font-size:1rem}button{display:inline-flex;min-height:2.75rem;width:-moz-fit-content;width:fit-content;cursor:pointer;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(167 139 250/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(167 139 250/var(--tw-bg-opacity,1));padding:.625rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgb(18 7 31/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;letter-spacing:0}button:hover{--tw-border-opacity:1;border-color:rgb(196 181 253/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(196 181 253/var(--tw-bg-opacity,1))}a:focus-visible,button:focus-visible,input:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:#c084fc}label{display:grid;gap:.5rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}input{min-height:2.75rem;width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:.625rem .75rem;font-size:1rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;font:inherit}input:hover{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1))}input::-moz-placeholder{--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}input::placeholder{--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.skip-link{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.skip-link:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto;white-space:normal;position:fixed;left:1rem;top:1rem;z-index:50;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:500}.admin-shell,.skip-link:focus{--tw-bg-opacity:1;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.admin-shell{min-height:100vh;background-color:rgb(26 12 46/var(--tw-bg-opacity,1))}@media (min-width:1024px){.admin-shell[data-admin-mode=accounts],.admin-shell[data-admin-mode=dashboard],.admin-shell[data-admin-mode=incidents],.admin-shell[data-admin-mode=settings]{display:grid;grid-template-columns:248px minmax(0,1fr)}}.auth-shell{display:grid;place-items:center;padding:2.5rem 1rem}@media (min-width:640px){.auth-shell{padding-left:1.5rem;padding-right:1.5rem}}.auth-card{width:100%;max-width:28rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));padding:1.25rem;--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.auth-card{padding:1.5rem}}.auth-brand{display:flex;align-items:flex-start;gap:.75rem}.auth-logo{height:4rem;width:4rem;flex-shrink:0;--tw-scale-x:1.25;--tw-scale-y:1.25;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));-o-object-fit:contain;object-fit:contain}.auth-kicker{line-height:1.25rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1))}.auth-kicker,.auth-lead{font-size:.875rem;--tw-text-opacity:1}.auth-lead{line-height:1.5rem;color:rgb(229 216 255/var(--tw-text-opacity,1))}.auth-card .boundary-list,.auth-lead{margin-top:1rem}.mobile-header{position:sticky;top:0;z-index:20;display:flex;align-items:center;justify-content:space-between;gap:.75rem;border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.95);padding:.75rem 1rem;--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}@media (min-width:1024px){.mobile-header{display:none}}.brand-lockup,.sidebar-brand{display:inline-flex;min-width:0;align-items:center;gap:.75rem;border-radius:.375rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));text-decoration-line:none}.brand-logo{height:2.5rem;width:2.5rem;flex-shrink:0;-o-object-fit:contain;object-fit:contain}.brand-copy,.sidebar-brand span{display:grid;min-width:0;gap:.125rem}.brand-title,.sidebar-title{font-size:1.125rem;font-weight:600;line-height:1.5rem;color:rgb(248 244 255/var(--tw-text-opacity,1))}.brand-subtitle,.brand-title,.sidebar-kicker,.sidebar-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;--tw-text-opacity:1}.brand-subtitle,.sidebar-kicker{font-size:.75rem;line-height:1rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1))}.logout-form{flex-shrink:0}.admin-sidebar{display:none;min-height:100vh;flex-direction:column;gap:1.5rem;border-right-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:1.25rem 1rem}@media (min-width:1024px){.admin-sidebar{display:flex}}.sidebar-brand{padding-left:.25rem;padding-right:.25rem}.sidebar-logo{height:2.75rem;width:2.75rem;flex-shrink:0;-o-object-fit:contain;object-fit:contain}.sidebar-nav{display:grid;gap:.5rem}.nav-link{border-radius:.5rem;border-width:1px;border-color:transparent;padding:.75rem;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.nav-link-main{display:flex;align-items:center;gap:.5rem;font-weight:500}.nav-link small{margin-top:.25rem;display:block;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1));letter-spacing:0}.nav-link.is-active{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));--tw-shadow:0 10px 30px rgba(16,5,31,.18);--tw-shadow-colored:0 10px 30px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.nav-link.is-active small{--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.nav-link.is-disabled{background-color:transparent}.sidebar-note{margin-top:auto;display:flex;align-items:center;gap:.5rem;border-radius:.5rem;border-width:1px;border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));padding:.75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dashboard-main{margin-left:auto;margin-right:auto;width:100%;max-width:80rem;padding:1.25rem 1rem}@media (min-width:640px){.dashboard-main{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:1024px){.dashboard-main{padding:1.5rem 2rem}}.content-section,.metric-card,.page-header{border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.page-header{display:flex;flex-direction:column;gap:1rem;padding:1.25rem}@media (min-width:640px){.page-header{flex-direction:row;align-items:flex-start;justify-content:space-between;padding:1.5rem}}.page-lead{margin-top:.75rem;max-width:48rem;line-height:1.5rem;color:rgb(229 216 255/var(--tw-text-opacity,1))}.eyebrow,.page-lead{font-size:.875rem;--tw-text-opacity:1}.eyebrow{margin-bottom:.5rem;line-height:1.25rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.desktop-logout{display:none;flex-shrink:0}@media (min-width:1024px){.desktop-logout{display:block}}.ghost-button{border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgb(50 28 85/var(--tw-bg-opacity,1));color:rgb(229 216 255/var(--tw-text-opacity,1))}.ghost-button,.ghost-button:hover{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.ghost-button:hover{border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));color:rgb(248 244 255/var(--tw-text-opacity,1))}.inline-status{margin-top:1rem;border-radius:.375rem;border-width:1px;padding:.75rem;font-size:.875rem;font-weight:500;line-height:1.5rem}.inline-status.success{border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.inline-status.danger{border-color:rgba(251,113,133,.4);--tw-bg-opacity:1;background-color:rgb(63 11 22/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.metric-grid{margin-top:1.25rem;display:grid;gap:1rem}@media (min-width:640px){.metric-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.metric-card{display:grid;min-height:5rem;grid-template-columns:auto minmax(0,1fr);align-items:flex-start;gap:.75rem;padding:.75rem 1rem}.metric-card dt{font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.metric-card dd{margin-top:.25rem;overflow-wrap:break-word;font-size:1.25rem;line-height:1.75rem;font-weight:600;line-height:1.25;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.metric-description{margin-top:.25rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.metric-icon{margin-top:.25rem;height:.75rem;width:.75rem;border-radius:9999px;--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.metric-card.ok .metric-icon{--tw-bg-opacity:1;background-color:rgb(52 211 153/var(--tw-bg-opacity,1))}.metric-card.neutral .metric-icon{--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.metric-card.warn .metric-icon{--tw-bg-opacity:1;background-color:rgb(251 191 36/var(--tw-bg-opacity,1))}.metric-card.danger .metric-icon{--tw-bg-opacity:1;background-color:rgb(251 113 133/var(--tw-bg-opacity,1))}.workspace-grid{margin-top:1.25rem;display:grid;gap:1.25rem}@media (min-width:1280px){.workspace-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.workspace-grid.single-column{grid-template-columns:repeat(1,minmax(0,1fr))}}.overview-grid,.settings-grid{margin-top:1.25rem;display:grid;gap:1.25rem}@media (min-width:1024px){.overview-grid,.settings-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.settings-wide{grid-column:span 2/span 2}}.content-section{padding:1.25rem}@media (min-width:640px){.content-section{padding:1.5rem}}.accounts-section{min-width:0}@media (min-width:1280px){.accounts-section{grid-column:span 2/span 2}}.section-header{margin-bottom:1rem;display:flex;align-items:flex-start;justify-content:space-between;gap:1rem}.section-icon{height:2.25rem;width:2.25rem;flex-shrink:0;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));padding:.5rem;--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}.section-icon.warning{border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.status-badge{display:inline-flex;width:-moz-fit-content;width:fit-content;flex-shrink:0;align-items:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding:.25rem .5rem;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.status-badge.current{margin-left:.5rem;border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.toolbar-form{margin-bottom:1.25rem;display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.toolbar-form{grid-template-columns:minmax(0,1fr) auto auto}}.account-list>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse));--tw-divide-opacity:1;border-color:rgb(91 59 137/var(--tw-divide-opacity,1))}.fixed-list{min-height:42rem}.account-row{display:grid;gap:.75rem;padding-top:1rem;padding-bottom:1rem}.account-row:first-child{padding-top:0}.account-row:last-child{padding-bottom:0}.create-row{margin-bottom:1rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.4);padding-left:1rem;padding-right:1rem}.account-summary{display:grid;gap:.75rem}@media (min-width:640px){.account-summary{grid-template-columns:auto minmax(0,1fr);align-items:flex-start}}.account-avatar{display:grid;height:2.75rem;min-width:2.75rem;place-items:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding-left:.5rem;padding-right:.5rem;font-size:.75rem;line-height:1rem;font-weight:600;text-transform:uppercase;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.account-copy{min-width:0}.account-copy h3{overflow-wrap:break-word}.metadata-row{margin-top:.75rem;display:grid;gap:.75rem}@media (min-width:640px){.metadata-row{grid-template-columns:repeat(3,minmax(0,1fr))}}.metadata-row dt{font-size:.75rem;line-height:1rem;font-weight:500;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.metadata-row dd{margin-top:.25rem;overflow-wrap:break-word;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.stack-form{margin-top:1.25rem;display:grid;gap:1rem}.inline-form{display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.inline-form{grid-template-columns:minmax(0,1fr) auto}}.compact-form{max-width:42rem}.pager{margin-top:1.25rem;display:flex;align-items:center;justify-content:space-between;gap:.75rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1rem}.pager-current{font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.ghost-link{display:inline-flex;min-height:2.5rem;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:600;color:rgb(229 216 255/var(--tw-text-opacity,1))}.ghost-link,.ghost-link:hover{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.ghost-link:hover{border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));color:rgb(248 244 255/var(--tw-text-opacity,1))}.ghost-link.is-disabled{cursor:default;opacity:.5}.detail-list{display:grid;gap:1rem}.config-item dt,.detail-list dt{font-size:.75rem;line-height:1rem;font-weight:500;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.config-item dd,.detail-list dd{margin-top:.25rem;overflow-wrap:break-word;font-size:.875rem;font-weight:600;line-height:1.25rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.config-grid{display:grid;gap:.75rem}@media (min-width:640px){.config-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1024px){.config-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.config-item{min-height:6rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.4);padding:1rem}.config-item.ok{border-color:rgba(52,211,153,.4)}.config-item.warn{border-color:rgba(251,191,36,.4)}.config-item.danger{border-color:rgba(251,113,133,.4)}.config-item p{margin-top:.5rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.muted{font-size:.875rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.boundary-section{border-color:rgba(251,191,36,.4)}.boundary-list{margin:0;list-style-type:disc;padding-left:1.25rem;font-size:.875rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.boundary-list li+li{margin-top:.5rem}.icon{height:1rem;width:1rem;flex-shrink:0}.visible{visibility:visible}.hidden{display:none} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}html{min-width:320px;background:#1a0c2e}body{margin:0;min-height:100vh;--tw-bg-opacity:1;background-color:rgb(26 12 46/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}dd,dl,h1,h2,h3,p{margin:0}h1{font-size:1.5rem;line-height:2rem;font-weight:600;line-height:1.25;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));letter-spacing:0}h2{font-size:1.125rem}h2,h3{font-weight:600;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));letter-spacing:0}h3{font-size:1rem}button{display:inline-flex;min-height:2.75rem;width:-moz-fit-content;width:fit-content;cursor:pointer;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(167 139 250/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(167 139 250/var(--tw-bg-opacity,1));padding:.625rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgb(18 7 31/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;letter-spacing:0}button:hover{--tw-border-opacity:1;border-color:rgb(196 181 253/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(196 181 253/var(--tw-bg-opacity,1))}a:focus-visible,button:focus-visible,input:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:#c084fc}label{display:grid;gap:.5rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}input{min-height:2.75rem;width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:.625rem .75rem;font-size:1rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;font:inherit}input:hover{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1))}input::-moz-placeholder{--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}input::placeholder{--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.skip-link{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.skip-link:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto;white-space:normal;position:fixed;left:1rem;top:1rem;z-index:50;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:500}.admin-shell,.skip-link:focus{--tw-bg-opacity:1;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.admin-shell{min-height:100vh;background-color:rgb(26 12 46/var(--tw-bg-opacity,1))}@media (min-width:1024px){.admin-shell[data-admin-mode=accounts],.admin-shell[data-admin-mode=dashboard],.admin-shell[data-admin-mode=incidents],.admin-shell[data-admin-mode=settings]{display:grid;grid-template-columns:248px minmax(0,1fr)}}.auth-shell{display:grid;place-items:center;padding:2.5rem 1rem}@media (min-width:640px){.auth-shell{padding-left:1.5rem;padding-right:1.5rem}}.auth-card{width:100%;max-width:28rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));padding:1.25rem;--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.auth-card{padding:1.5rem}}.auth-brand{display:flex;align-items:flex-start;gap:.75rem}.auth-logo{height:4rem;width:4rem;flex-shrink:0;--tw-scale-x:1.25;--tw-scale-y:1.25;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));-o-object-fit:contain;object-fit:contain}.auth-kicker{line-height:1.25rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1))}.auth-kicker,.auth-lead{font-size:.875rem;--tw-text-opacity:1}.auth-lead{line-height:1.5rem;color:rgb(229 216 255/var(--tw-text-opacity,1))}.auth-card .boundary-list,.auth-lead{margin-top:1rem}.auth-methods{margin-top:1.25rem;display:grid;gap:1.25rem}.auth-method{border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1.25rem}.auth-method:first-child{border-top-width:0;padding-top:0}.auth-method h2{font-size:1rem;font-weight:600;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.auth-actions{margin-top:1.25rem;display:flex;flex-wrap:wrap;align-items:center;gap:.75rem}.mobile-header{position:sticky;top:0;z-index:20;display:flex;align-items:center;justify-content:space-between;gap:.75rem;border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.95);padding:.75rem 1rem;--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}@media (min-width:1024px){.mobile-header{display:none}}.brand-lockup,.sidebar-brand{display:inline-flex;min-width:0;align-items:center;gap:.75rem;border-radius:.375rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));text-decoration-line:none}.brand-logo{height:2.5rem;width:2.5rem;flex-shrink:0;-o-object-fit:contain;object-fit:contain}.brand-copy,.sidebar-brand span{display:grid;min-width:0;gap:.125rem}.brand-title,.sidebar-title{font-size:1.125rem;font-weight:600;line-height:1.5rem;color:rgb(248 244 255/var(--tw-text-opacity,1))}.brand-subtitle,.brand-title,.sidebar-kicker,.sidebar-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;--tw-text-opacity:1}.brand-subtitle,.sidebar-kicker{font-size:.75rem;line-height:1rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1))}.logout-form{flex-shrink:0}.admin-sidebar{display:none;min-height:100vh;flex-direction:column;gap:1.5rem;border-right-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:1.25rem 1rem}@media (min-width:1024px){.admin-sidebar{display:flex}}.sidebar-brand{padding-left:.25rem;padding-right:.25rem}.sidebar-logo{height:2.75rem;width:2.75rem;flex-shrink:0;-o-object-fit:contain;object-fit:contain}.sidebar-nav{display:grid;gap:.5rem}.nav-link{border-radius:.5rem;border-width:1px;border-color:transparent;padding:.75rem;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.nav-link-main{display:flex;align-items:center;gap:.5rem;font-weight:500}.nav-link small{margin-top:.25rem;display:block;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1));letter-spacing:0}.nav-link.is-active{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));--tw-shadow:0 10px 30px rgba(16,5,31,.18);--tw-shadow-colored:0 10px 30px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.nav-link.is-active small{--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.nav-link.is-disabled{background-color:transparent}.sidebar-note{margin-top:auto;display:flex;align-items:center;gap:.5rem;border-radius:.5rem;border-width:1px;border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));padding:.75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dashboard-main{margin-left:auto;margin-right:auto;width:100%;max-width:80rem;padding:1.25rem 1rem}@media (min-width:640px){.dashboard-main{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:1024px){.dashboard-main{padding:1.5rem 2rem}}.content-section,.metric-card,.page-header{border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.page-header{display:flex;flex-direction:column;gap:1rem;padding:1.25rem}@media (min-width:640px){.page-header{flex-direction:row;align-items:flex-start;justify-content:space-between;padding:1.5rem}}.page-lead{margin-top:.75rem;max-width:48rem;line-height:1.5rem;color:rgb(229 216 255/var(--tw-text-opacity,1))}.eyebrow,.page-lead{font-size:.875rem;--tw-text-opacity:1}.eyebrow{margin-bottom:.5rem;line-height:1.25rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.desktop-logout{display:none;flex-shrink:0}@media (min-width:1024px){.desktop-logout{display:block}}.ghost-button{border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgb(50 28 85/var(--tw-bg-opacity,1));color:rgb(229 216 255/var(--tw-text-opacity,1))}.ghost-button,.ghost-button:hover{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.ghost-button:hover{border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));color:rgb(248 244 255/var(--tw-text-opacity,1))}.inline-status{margin-top:1rem;border-radius:.375rem;border-width:1px;padding:.75rem;font-size:.875rem;font-weight:500;line-height:1.5rem}.inline-status.success{border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.inline-status.danger{border-color:rgba(251,113,133,.4);--tw-bg-opacity:1;background-color:rgb(63 11 22/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.metric-grid{margin-top:1.25rem;display:grid;gap:1rem}@media (min-width:640px){.metric-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.metric-card{display:grid;min-height:5rem;grid-template-columns:auto minmax(0,1fr);align-items:flex-start;gap:.75rem;padding:.75rem 1rem}.metric-card dt{font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.metric-card dd{margin-top:.25rem;overflow-wrap:break-word;font-size:1.25rem;line-height:1.75rem;font-weight:600;line-height:1.25;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.metric-description{margin-top:.25rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.metric-icon{margin-top:.25rem;height:.75rem;width:.75rem;border-radius:9999px;--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.metric-card.ok .metric-icon{--tw-bg-opacity:1;background-color:rgb(52 211 153/var(--tw-bg-opacity,1))}.metric-card.neutral .metric-icon{--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.metric-card.warn .metric-icon{--tw-bg-opacity:1;background-color:rgb(251 191 36/var(--tw-bg-opacity,1))}.metric-card.danger .metric-icon{--tw-bg-opacity:1;background-color:rgb(251 113 133/var(--tw-bg-opacity,1))}.workspace-grid{margin-top:1.25rem;display:grid;gap:1.25rem}@media (min-width:1280px){.workspace-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.workspace-grid.single-column{grid-template-columns:repeat(1,minmax(0,1fr))}}.overview-grid,.settings-grid{margin-top:1.25rem;display:grid;gap:1.25rem}@media (min-width:1024px){.overview-grid,.settings-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.settings-wide{grid-column:span 2/span 2}}.content-section{padding:1.25rem}@media (min-width:640px){.content-section{padding:1.5rem}}.accounts-section{min-width:0}@media (min-width:1280px){.accounts-section{grid-column:span 2/span 2}}.section-header{margin-bottom:1rem;display:flex;align-items:flex-start;justify-content:space-between;gap:1rem}.section-icon{height:2.25rem;width:2.25rem;flex-shrink:0;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));padding:.5rem;--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}.section-icon.warning{border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.status-badge{display:inline-flex;width:-moz-fit-content;width:fit-content;flex-shrink:0;align-items:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding:.25rem .5rem;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.status-badge.current{margin-left:.5rem;border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.toolbar-form{margin-bottom:1.25rem;display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.toolbar-form{grid-template-columns:minmax(0,1fr) auto auto}}.account-list>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse));--tw-divide-opacity:1;border-color:rgb(91 59 137/var(--tw-divide-opacity,1))}.fixed-list{min-height:42rem}.account-row{display:grid;gap:.75rem;padding-top:1rem;padding-bottom:1rem}.account-row:first-child{padding-top:0}.account-row:last-child{padding-bottom:0}.create-row{margin-bottom:1rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.4);padding-left:1rem;padding-right:1rem}.account-summary{display:grid;gap:.75rem}@media (min-width:640px){.account-summary{grid-template-columns:auto minmax(0,1fr);align-items:flex-start}}.account-avatar{display:grid;height:2.75rem;min-width:2.75rem;place-items:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding-left:.5rem;padding-right:.5rem;font-size:.75rem;line-height:1rem;font-weight:600;text-transform:uppercase;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.account-copy{min-width:0}.account-copy h3{overflow-wrap:break-word}.metadata-row{margin-top:.75rem;display:grid;gap:.75rem}@media (min-width:640px){.metadata-row{grid-template-columns:repeat(3,minmax(0,1fr))}}.metadata-row dt{font-size:.75rem;line-height:1rem;font-weight:500;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.metadata-row dd{margin-top:.25rem;overflow-wrap:break-word;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.stack-form{margin-top:1.25rem;display:grid;gap:1rem}.inline-form{display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.inline-form{grid-template-columns:minmax(0,1fr) auto}}.compact-form{max-width:42rem}.pager{margin-top:1.25rem;display:flex;align-items:center;justify-content:space-between;gap:.75rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1rem}.pager-current{font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.ghost-link{display:inline-flex;min-height:2.5rem;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:600;color:rgb(229 216 255/var(--tw-text-opacity,1))}.ghost-link,.ghost-link:hover{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.ghost-link:hover{border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));color:rgb(248 244 255/var(--tw-text-opacity,1))}.ghost-link.is-disabled{cursor:default;opacity:.5}.detail-list{display:grid;gap:1rem}.config-item dt,.detail-list dt{font-size:.75rem;line-height:1rem;font-weight:500;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.config-item dd,.detail-list dd{margin-top:.25rem;overflow-wrap:break-word;font-size:.875rem;font-weight:600;line-height:1.25rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.config-grid{display:grid;gap:.75rem}@media (min-width:640px){.config-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1024px){.config-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.config-item{min-height:6rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.4);padding:1rem}.config-item.ok{border-color:rgba(52,211,153,.4)}.config-item.warn{border-color:rgba(251,191,36,.4)}.config-item.danger{border-color:rgba(251,113,133,.4)}.config-item p{margin-top:.5rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.muted{font-size:.875rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.boundary-section{border-color:rgba(251,191,36,.4)}.boundary-list{margin:0;list-style-type:disc;padding-left:1.25rem;font-size:.875rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.boundary-list li+li{margin-top:.5rem}.icon{height:1rem;width:1rem;flex-shrink:0}.visible{visibility:visible}.hidden{display:none} \ No newline at end of file diff --git a/internal/httpapi/web/admin/tailwind.css b/internal/httpapi/web/admin/tailwind.css index 7a62207..c1c7c70 100644 --- a/internal/httpapi/web/admin/tailwind.css +++ b/internal/httpapi/web/admin/tailwind.css @@ -131,6 +131,26 @@ @apply mt-4; } + .auth-methods { + @apply mt-5 grid gap-5; + } + + .auth-method { + @apply border-t border-proofline-border pt-5; + } + + .auth-method:first-child { + @apply border-t-0 pt-0; + } + + .auth-method h2 { + @apply text-base font-semibold leading-6 text-proofline-text; + } + + .auth-actions { + @apply mt-5 flex flex-wrap items-center gap-3; + } + .mobile-header { @apply sticky top-0 z-20 flex items-center justify-between gap-3 border-b border-proofline-border bg-proofline-bg-deep/95 px-4 py-3 backdrop-blur lg:hidden; } diff --git a/internal/httpapi/web/templates/admin.html b/internal/httpapi/web/templates/admin.html index 6d286fd..ef38f9f 100644 --- a/internal/httpapi/web/templates/admin.html +++ b/internal/httpapi/web/templates/admin.html @@ -514,8 +514,12 @@

Create First Admin

Admin Login

{{else if eq .Mode "second_factor_setup"}}

Set Up Admin 2FA

+ {{else if eq .Mode "second_factor_setup_email_verify"}} +

Verify Email Code

{{else if eq .Mode "second_factor_verify"}}

Verify Admin 2FA

+ {{else if eq .Mode "second_factor_verify_email"}} +

Verify Email Code

{{else if eq .Mode "forbidden"}}

Access Denied

{{else}} @@ -530,8 +534,12 @@

Proofline Admin

Sign in to the private operator console for local account administration.

{{else if eq .Mode "second_factor_setup"}}

Complete admin 2FA for {{.Account.Username}} before using private operator actions.

+ {{else if eq .Mode "second_factor_setup_email_verify"}} +

Enter the email code for {{.Account.Username}} to finish admin 2FA setup.

{{else if eq .Mode "second_factor_verify"}}

Verify the active second factor for {{.Account.Username}} before opening the operator console.

+ {{else if eq .Mode "second_factor_verify_email"}} +

Enter the email code for {{.Account.Username}} to continue.

{{else if eq .Mode "forbidden"}}

This account cannot open the admin interface.

{{else}} @@ -574,77 +582,108 @@

Proofline Admin

{{else if eq .Mode "second_factor_setup"}} - - {{if .SecondFactorEmailAvailable}} -
- - - -
-
- - - -
- {{else}} -

Email fallback is not configured on this server.

- {{end}} +
+
+

Email code

+ {{if .SecondFactorEmailAvailable}} +
+ + + +
+ {{else}} +

Email fallback is not configured on this server.

+ {{end}} +
+
+

Security keys and authenticator apps

+

Security-key and TOTP setup remain available through the authenticated second-factor API.

+
+
+ {{else if eq .Mode "second_factor_setup_email_verify"}} +
+
+

Email code

+
+ + + +
+
+
+
+ Back +
+ + +
+
{{else if eq .Mode "second_factor_verify"}} - - {{if .SecondFactorEmailAvailable}} -
- - -
-
- - - -
- {{end}} - {{if .SecondFactorTOTPAvailable}} -
- - - -
- {{end}} + {{if .SecondFactorTOTPAvailable}} +
+

Authenticator app

+
+ + + +
+
+ {{end}} + {{if and (not .SecondFactorEmailAvailable) (not .SecondFactorTOTPAvailable)}} +
+

No web form available

+

This account does not have an email or TOTP factor available for the admin web form.

+
+ {{end}} +
+ {{else if eq .Mode "second_factor_verify_email"}} +
+
+

Email code

+
+ + + +
+
+
+
+ Back +
+ + +
+
{{else if eq .Mode "forbidden"}}

Admin role is required.

{{end}} From dedfe5e935b0038faf729a730dc37f69394ef4fd Mon Sep 17 00:00:00 2001 From: Ellie Melton Date: Sun, 14 Jun 2026 02:36:45 +1000 Subject: [PATCH 03/10] Separate admin account creation card --- internal/httpapi/web/admin/static/styles.css | 2 +- internal/httpapi/web/admin/tailwind.css | 4 -- internal/httpapi/web/templates/admin.html | 53 ++++++++++---------- 3 files changed, 28 insertions(+), 31 deletions(-) diff --git a/internal/httpapi/web/admin/static/styles.css b/internal/httpapi/web/admin/static/styles.css index 004dce4..ab9daad 100644 --- a/internal/httpapi/web/admin/static/styles.css +++ b/internal/httpapi/web/admin/static/styles.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}html{min-width:320px;background:#1a0c2e}body{margin:0;min-height:100vh;--tw-bg-opacity:1;background-color:rgb(26 12 46/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}dd,dl,h1,h2,h3,p{margin:0}h1{font-size:1.5rem;line-height:2rem;font-weight:600;line-height:1.25;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));letter-spacing:0}h2{font-size:1.125rem}h2,h3{font-weight:600;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));letter-spacing:0}h3{font-size:1rem}button{display:inline-flex;min-height:2.75rem;width:-moz-fit-content;width:fit-content;cursor:pointer;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(167 139 250/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(167 139 250/var(--tw-bg-opacity,1));padding:.625rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgb(18 7 31/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;letter-spacing:0}button:hover{--tw-border-opacity:1;border-color:rgb(196 181 253/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(196 181 253/var(--tw-bg-opacity,1))}a:focus-visible,button:focus-visible,input:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:#c084fc}label{display:grid;gap:.5rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}input{min-height:2.75rem;width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:.625rem .75rem;font-size:1rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;font:inherit}input:hover{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1))}input::-moz-placeholder{--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}input::placeholder{--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.skip-link{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.skip-link:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto;white-space:normal;position:fixed;left:1rem;top:1rem;z-index:50;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:500}.admin-shell,.skip-link:focus{--tw-bg-opacity:1;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.admin-shell{min-height:100vh;background-color:rgb(26 12 46/var(--tw-bg-opacity,1))}@media (min-width:1024px){.admin-shell[data-admin-mode=accounts],.admin-shell[data-admin-mode=dashboard],.admin-shell[data-admin-mode=incidents],.admin-shell[data-admin-mode=settings]{display:grid;grid-template-columns:248px minmax(0,1fr)}}.auth-shell{display:grid;place-items:center;padding:2.5rem 1rem}@media (min-width:640px){.auth-shell{padding-left:1.5rem;padding-right:1.5rem}}.auth-card{width:100%;max-width:28rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));padding:1.25rem;--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.auth-card{padding:1.5rem}}.auth-brand{display:flex;align-items:flex-start;gap:.75rem}.auth-logo{height:4rem;width:4rem;flex-shrink:0;--tw-scale-x:1.25;--tw-scale-y:1.25;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));-o-object-fit:contain;object-fit:contain}.auth-kicker{line-height:1.25rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1))}.auth-kicker,.auth-lead{font-size:.875rem;--tw-text-opacity:1}.auth-lead{line-height:1.5rem;color:rgb(229 216 255/var(--tw-text-opacity,1))}.auth-card .boundary-list,.auth-lead{margin-top:1rem}.auth-methods{margin-top:1.25rem;display:grid;gap:1.25rem}.auth-method{border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1.25rem}.auth-method:first-child{border-top-width:0;padding-top:0}.auth-method h2{font-size:1rem;font-weight:600;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.auth-actions{margin-top:1.25rem;display:flex;flex-wrap:wrap;align-items:center;gap:.75rem}.mobile-header{position:sticky;top:0;z-index:20;display:flex;align-items:center;justify-content:space-between;gap:.75rem;border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.95);padding:.75rem 1rem;--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}@media (min-width:1024px){.mobile-header{display:none}}.brand-lockup,.sidebar-brand{display:inline-flex;min-width:0;align-items:center;gap:.75rem;border-radius:.375rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));text-decoration-line:none}.brand-logo{height:2.5rem;width:2.5rem;flex-shrink:0;-o-object-fit:contain;object-fit:contain}.brand-copy,.sidebar-brand span{display:grid;min-width:0;gap:.125rem}.brand-title,.sidebar-title{font-size:1.125rem;font-weight:600;line-height:1.5rem;color:rgb(248 244 255/var(--tw-text-opacity,1))}.brand-subtitle,.brand-title,.sidebar-kicker,.sidebar-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;--tw-text-opacity:1}.brand-subtitle,.sidebar-kicker{font-size:.75rem;line-height:1rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1))}.logout-form{flex-shrink:0}.admin-sidebar{display:none;min-height:100vh;flex-direction:column;gap:1.5rem;border-right-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:1.25rem 1rem}@media (min-width:1024px){.admin-sidebar{display:flex}}.sidebar-brand{padding-left:.25rem;padding-right:.25rem}.sidebar-logo{height:2.75rem;width:2.75rem;flex-shrink:0;-o-object-fit:contain;object-fit:contain}.sidebar-nav{display:grid;gap:.5rem}.nav-link{border-radius:.5rem;border-width:1px;border-color:transparent;padding:.75rem;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.nav-link-main{display:flex;align-items:center;gap:.5rem;font-weight:500}.nav-link small{margin-top:.25rem;display:block;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1));letter-spacing:0}.nav-link.is-active{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));--tw-shadow:0 10px 30px rgba(16,5,31,.18);--tw-shadow-colored:0 10px 30px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.nav-link.is-active small{--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.nav-link.is-disabled{background-color:transparent}.sidebar-note{margin-top:auto;display:flex;align-items:center;gap:.5rem;border-radius:.5rem;border-width:1px;border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));padding:.75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dashboard-main{margin-left:auto;margin-right:auto;width:100%;max-width:80rem;padding:1.25rem 1rem}@media (min-width:640px){.dashboard-main{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:1024px){.dashboard-main{padding:1.5rem 2rem}}.content-section,.metric-card,.page-header{border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.page-header{display:flex;flex-direction:column;gap:1rem;padding:1.25rem}@media (min-width:640px){.page-header{flex-direction:row;align-items:flex-start;justify-content:space-between;padding:1.5rem}}.page-lead{margin-top:.75rem;max-width:48rem;line-height:1.5rem;color:rgb(229 216 255/var(--tw-text-opacity,1))}.eyebrow,.page-lead{font-size:.875rem;--tw-text-opacity:1}.eyebrow{margin-bottom:.5rem;line-height:1.25rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.desktop-logout{display:none;flex-shrink:0}@media (min-width:1024px){.desktop-logout{display:block}}.ghost-button{border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgb(50 28 85/var(--tw-bg-opacity,1));color:rgb(229 216 255/var(--tw-text-opacity,1))}.ghost-button,.ghost-button:hover{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.ghost-button:hover{border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));color:rgb(248 244 255/var(--tw-text-opacity,1))}.inline-status{margin-top:1rem;border-radius:.375rem;border-width:1px;padding:.75rem;font-size:.875rem;font-weight:500;line-height:1.5rem}.inline-status.success{border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.inline-status.danger{border-color:rgba(251,113,133,.4);--tw-bg-opacity:1;background-color:rgb(63 11 22/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.metric-grid{margin-top:1.25rem;display:grid;gap:1rem}@media (min-width:640px){.metric-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.metric-card{display:grid;min-height:5rem;grid-template-columns:auto minmax(0,1fr);align-items:flex-start;gap:.75rem;padding:.75rem 1rem}.metric-card dt{font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.metric-card dd{margin-top:.25rem;overflow-wrap:break-word;font-size:1.25rem;line-height:1.75rem;font-weight:600;line-height:1.25;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.metric-description{margin-top:.25rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.metric-icon{margin-top:.25rem;height:.75rem;width:.75rem;border-radius:9999px;--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.metric-card.ok .metric-icon{--tw-bg-opacity:1;background-color:rgb(52 211 153/var(--tw-bg-opacity,1))}.metric-card.neutral .metric-icon{--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.metric-card.warn .metric-icon{--tw-bg-opacity:1;background-color:rgb(251 191 36/var(--tw-bg-opacity,1))}.metric-card.danger .metric-icon{--tw-bg-opacity:1;background-color:rgb(251 113 133/var(--tw-bg-opacity,1))}.workspace-grid{margin-top:1.25rem;display:grid;gap:1.25rem}@media (min-width:1280px){.workspace-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.workspace-grid.single-column{grid-template-columns:repeat(1,minmax(0,1fr))}}.overview-grid,.settings-grid{margin-top:1.25rem;display:grid;gap:1.25rem}@media (min-width:1024px){.overview-grid,.settings-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.settings-wide{grid-column:span 2/span 2}}.content-section{padding:1.25rem}@media (min-width:640px){.content-section{padding:1.5rem}}.accounts-section{min-width:0}@media (min-width:1280px){.accounts-section{grid-column:span 2/span 2}}.section-header{margin-bottom:1rem;display:flex;align-items:flex-start;justify-content:space-between;gap:1rem}.section-icon{height:2.25rem;width:2.25rem;flex-shrink:0;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));padding:.5rem;--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}.section-icon.warning{border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.status-badge{display:inline-flex;width:-moz-fit-content;width:fit-content;flex-shrink:0;align-items:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding:.25rem .5rem;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.status-badge.current{margin-left:.5rem;border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.toolbar-form{margin-bottom:1.25rem;display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.toolbar-form{grid-template-columns:minmax(0,1fr) auto auto}}.account-list>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse));--tw-divide-opacity:1;border-color:rgb(91 59 137/var(--tw-divide-opacity,1))}.fixed-list{min-height:42rem}.account-row{display:grid;gap:.75rem;padding-top:1rem;padding-bottom:1rem}.account-row:first-child{padding-top:0}.account-row:last-child{padding-bottom:0}.create-row{margin-bottom:1rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.4);padding-left:1rem;padding-right:1rem}.account-summary{display:grid;gap:.75rem}@media (min-width:640px){.account-summary{grid-template-columns:auto minmax(0,1fr);align-items:flex-start}}.account-avatar{display:grid;height:2.75rem;min-width:2.75rem;place-items:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding-left:.5rem;padding-right:.5rem;font-size:.75rem;line-height:1rem;font-weight:600;text-transform:uppercase;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.account-copy{min-width:0}.account-copy h3{overflow-wrap:break-word}.metadata-row{margin-top:.75rem;display:grid;gap:.75rem}@media (min-width:640px){.metadata-row{grid-template-columns:repeat(3,minmax(0,1fr))}}.metadata-row dt{font-size:.75rem;line-height:1rem;font-weight:500;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.metadata-row dd{margin-top:.25rem;overflow-wrap:break-word;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.stack-form{margin-top:1.25rem;display:grid;gap:1rem}.inline-form{display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.inline-form{grid-template-columns:minmax(0,1fr) auto}}.compact-form{max-width:42rem}.pager{margin-top:1.25rem;display:flex;align-items:center;justify-content:space-between;gap:.75rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1rem}.pager-current{font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.ghost-link{display:inline-flex;min-height:2.5rem;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:600;color:rgb(229 216 255/var(--tw-text-opacity,1))}.ghost-link,.ghost-link:hover{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.ghost-link:hover{border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));color:rgb(248 244 255/var(--tw-text-opacity,1))}.ghost-link.is-disabled{cursor:default;opacity:.5}.detail-list{display:grid;gap:1rem}.config-item dt,.detail-list dt{font-size:.75rem;line-height:1rem;font-weight:500;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.config-item dd,.detail-list dd{margin-top:.25rem;overflow-wrap:break-word;font-size:.875rem;font-weight:600;line-height:1.25rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.config-grid{display:grid;gap:.75rem}@media (min-width:640px){.config-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1024px){.config-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.config-item{min-height:6rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.4);padding:1rem}.config-item.ok{border-color:rgba(52,211,153,.4)}.config-item.warn{border-color:rgba(251,191,36,.4)}.config-item.danger{border-color:rgba(251,113,133,.4)}.config-item p{margin-top:.5rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.muted{font-size:.875rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.boundary-section{border-color:rgba(251,191,36,.4)}.boundary-list{margin:0;list-style-type:disc;padding-left:1.25rem;font-size:.875rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.boundary-list li+li{margin-top:.5rem}.icon{height:1rem;width:1rem;flex-shrink:0}.visible{visibility:visible}.hidden{display:none} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}html{min-width:320px;background:#1a0c2e}body{margin:0;min-height:100vh;--tw-bg-opacity:1;background-color:rgb(26 12 46/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}dd,dl,h1,h2,h3,p{margin:0}h1{font-size:1.5rem;line-height:2rem;font-weight:600;line-height:1.25;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));letter-spacing:0}h2{font-size:1.125rem}h2,h3{font-weight:600;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));letter-spacing:0}h3{font-size:1rem}button{display:inline-flex;min-height:2.75rem;width:-moz-fit-content;width:fit-content;cursor:pointer;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(167 139 250/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(167 139 250/var(--tw-bg-opacity,1));padding:.625rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgb(18 7 31/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;letter-spacing:0}button:hover{--tw-border-opacity:1;border-color:rgb(196 181 253/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(196 181 253/var(--tw-bg-opacity,1))}a:focus-visible,button:focus-visible,input:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:#c084fc}label{display:grid;gap:.5rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}input{min-height:2.75rem;width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:.625rem .75rem;font-size:1rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;font:inherit}input:hover{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1))}input::-moz-placeholder{--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}input::placeholder{--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.skip-link{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.skip-link:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto;white-space:normal;position:fixed;left:1rem;top:1rem;z-index:50;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:500}.admin-shell,.skip-link:focus{--tw-bg-opacity:1;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.admin-shell{min-height:100vh;background-color:rgb(26 12 46/var(--tw-bg-opacity,1))}@media (min-width:1024px){.admin-shell[data-admin-mode=accounts],.admin-shell[data-admin-mode=dashboard],.admin-shell[data-admin-mode=incidents],.admin-shell[data-admin-mode=settings]{display:grid;grid-template-columns:248px minmax(0,1fr)}}.auth-shell{display:grid;place-items:center;padding:2.5rem 1rem}@media (min-width:640px){.auth-shell{padding-left:1.5rem;padding-right:1.5rem}}.auth-card{width:100%;max-width:28rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));padding:1.25rem;--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.auth-card{padding:1.5rem}}.auth-brand{display:flex;align-items:flex-start;gap:.75rem}.auth-logo{height:4rem;width:4rem;flex-shrink:0;--tw-scale-x:1.25;--tw-scale-y:1.25;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));-o-object-fit:contain;object-fit:contain}.auth-kicker{line-height:1.25rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1))}.auth-kicker,.auth-lead{font-size:.875rem;--tw-text-opacity:1}.auth-lead{line-height:1.5rem;color:rgb(229 216 255/var(--tw-text-opacity,1))}.auth-card .boundary-list,.auth-lead{margin-top:1rem}.auth-methods{margin-top:1.25rem;display:grid;gap:1.25rem}.auth-method{border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1.25rem}.auth-method:first-child{border-top-width:0;padding-top:0}.auth-method h2{font-size:1rem;font-weight:600;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.auth-actions{margin-top:1.25rem;display:flex;flex-wrap:wrap;align-items:center;gap:.75rem}.mobile-header{position:sticky;top:0;z-index:20;display:flex;align-items:center;justify-content:space-between;gap:.75rem;border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.95);padding:.75rem 1rem;--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}@media (min-width:1024px){.mobile-header{display:none}}.brand-lockup,.sidebar-brand{display:inline-flex;min-width:0;align-items:center;gap:.75rem;border-radius:.375rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));text-decoration-line:none}.brand-logo{height:2.5rem;width:2.5rem;flex-shrink:0;-o-object-fit:contain;object-fit:contain}.brand-copy,.sidebar-brand span{display:grid;min-width:0;gap:.125rem}.brand-title,.sidebar-title{font-size:1.125rem;font-weight:600;line-height:1.5rem;color:rgb(248 244 255/var(--tw-text-opacity,1))}.brand-subtitle,.brand-title,.sidebar-kicker,.sidebar-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;--tw-text-opacity:1}.brand-subtitle,.sidebar-kicker{font-size:.75rem;line-height:1rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1))}.logout-form{flex-shrink:0}.admin-sidebar{display:none;min-height:100vh;flex-direction:column;gap:1.5rem;border-right-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:1.25rem 1rem}@media (min-width:1024px){.admin-sidebar{display:flex}}.sidebar-brand{padding-left:.25rem;padding-right:.25rem}.sidebar-logo{height:2.75rem;width:2.75rem;flex-shrink:0;-o-object-fit:contain;object-fit:contain}.sidebar-nav{display:grid;gap:.5rem}.nav-link{border-radius:.5rem;border-width:1px;border-color:transparent;padding:.75rem;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.nav-link-main{display:flex;align-items:center;gap:.5rem;font-weight:500}.nav-link small{margin-top:.25rem;display:block;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1));letter-spacing:0}.nav-link.is-active{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));--tw-shadow:0 10px 30px rgba(16,5,31,.18);--tw-shadow-colored:0 10px 30px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.nav-link.is-active small{--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.nav-link.is-disabled{background-color:transparent}.sidebar-note{margin-top:auto;display:flex;align-items:center;gap:.5rem;border-radius:.5rem;border-width:1px;border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));padding:.75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dashboard-main{margin-left:auto;margin-right:auto;width:100%;max-width:80rem;padding:1.25rem 1rem}@media (min-width:640px){.dashboard-main{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:1024px){.dashboard-main{padding:1.5rem 2rem}}.content-section,.metric-card,.page-header{border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.page-header{display:flex;flex-direction:column;gap:1rem;padding:1.25rem}@media (min-width:640px){.page-header{flex-direction:row;align-items:flex-start;justify-content:space-between;padding:1.5rem}}.page-lead{margin-top:.75rem;max-width:48rem;line-height:1.5rem;color:rgb(229 216 255/var(--tw-text-opacity,1))}.eyebrow,.page-lead{font-size:.875rem;--tw-text-opacity:1}.eyebrow{margin-bottom:.5rem;line-height:1.25rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.desktop-logout{display:none;flex-shrink:0}@media (min-width:1024px){.desktop-logout{display:block}}.ghost-button{border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgb(50 28 85/var(--tw-bg-opacity,1));color:rgb(229 216 255/var(--tw-text-opacity,1))}.ghost-button,.ghost-button:hover{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.ghost-button:hover{border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));color:rgb(248 244 255/var(--tw-text-opacity,1))}.inline-status{margin-top:1rem;border-radius:.375rem;border-width:1px;padding:.75rem;font-size:.875rem;font-weight:500;line-height:1.5rem}.inline-status.success{border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.inline-status.danger{border-color:rgba(251,113,133,.4);--tw-bg-opacity:1;background-color:rgb(63 11 22/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.metric-grid{margin-top:1.25rem;display:grid;gap:1rem}@media (min-width:640px){.metric-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.metric-card{display:grid;min-height:5rem;grid-template-columns:auto minmax(0,1fr);align-items:flex-start;gap:.75rem;padding:.75rem 1rem}.metric-card dt{font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.metric-card dd{margin-top:.25rem;overflow-wrap:break-word;font-size:1.25rem;line-height:1.75rem;font-weight:600;line-height:1.25;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.metric-description{margin-top:.25rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.metric-icon{margin-top:.25rem;height:.75rem;width:.75rem;border-radius:9999px;--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.metric-card.ok .metric-icon{--tw-bg-opacity:1;background-color:rgb(52 211 153/var(--tw-bg-opacity,1))}.metric-card.neutral .metric-icon{--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.metric-card.warn .metric-icon{--tw-bg-opacity:1;background-color:rgb(251 191 36/var(--tw-bg-opacity,1))}.metric-card.danger .metric-icon{--tw-bg-opacity:1;background-color:rgb(251 113 133/var(--tw-bg-opacity,1))}.workspace-grid{margin-top:1.25rem;display:grid;gap:1.25rem}@media (min-width:1280px){.workspace-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.workspace-grid.single-column{grid-template-columns:repeat(1,minmax(0,1fr))}}.overview-grid,.settings-grid{margin-top:1.25rem;display:grid;gap:1.25rem}@media (min-width:1024px){.overview-grid,.settings-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.settings-wide{grid-column:span 2/span 2}}.content-section{padding:1.25rem}@media (min-width:640px){.content-section{padding:1.5rem}}.accounts-section{min-width:0}@media (min-width:1280px){.accounts-section{grid-column:span 2/span 2}}.section-header{margin-bottom:1rem;display:flex;align-items:flex-start;justify-content:space-between;gap:1rem}.section-icon{height:2.25rem;width:2.25rem;flex-shrink:0;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));padding:.5rem;--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}.section-icon.warning{border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.status-badge{display:inline-flex;width:-moz-fit-content;width:fit-content;flex-shrink:0;align-items:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding:.25rem .5rem;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.status-badge.current{margin-left:.5rem;border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.toolbar-form{margin-bottom:1.25rem;display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.toolbar-form{grid-template-columns:minmax(0,1fr) auto auto}}.account-list>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse));--tw-divide-opacity:1;border-color:rgb(91 59 137/var(--tw-divide-opacity,1))}.fixed-list{min-height:42rem}.account-row{display:grid;gap:.75rem;padding-top:1rem;padding-bottom:1rem}.account-row:first-child{padding-top:0}.account-row:last-child{padding-bottom:0}.account-summary{display:grid;gap:.75rem}@media (min-width:640px){.account-summary{grid-template-columns:auto minmax(0,1fr);align-items:flex-start}}.account-avatar{display:grid;height:2.75rem;min-width:2.75rem;place-items:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding-left:.5rem;padding-right:.5rem;font-size:.75rem;line-height:1rem;font-weight:600;text-transform:uppercase;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.account-copy{min-width:0}.account-copy h3{overflow-wrap:break-word}.metadata-row{margin-top:.75rem;display:grid;gap:.75rem}@media (min-width:640px){.metadata-row{grid-template-columns:repeat(3,minmax(0,1fr))}}.metadata-row dt{font-size:.75rem;line-height:1rem;font-weight:500;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.metadata-row dd{margin-top:.25rem;overflow-wrap:break-word;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.stack-form{margin-top:1.25rem;display:grid;gap:1rem}.inline-form{display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.inline-form{grid-template-columns:minmax(0,1fr) auto}}.compact-form{max-width:42rem}.pager{margin-top:1.25rem;display:flex;align-items:center;justify-content:space-between;gap:.75rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1rem}.pager-current{font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.ghost-link{display:inline-flex;min-height:2.5rem;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:600;color:rgb(229 216 255/var(--tw-text-opacity,1))}.ghost-link,.ghost-link:hover{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.ghost-link:hover{border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));color:rgb(248 244 255/var(--tw-text-opacity,1))}.ghost-link.is-disabled{cursor:default;opacity:.5}.detail-list{display:grid;gap:1rem}.config-item dt,.detail-list dt{font-size:.75rem;line-height:1rem;font-weight:500;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.config-item dd,.detail-list dd{margin-top:.25rem;overflow-wrap:break-word;font-size:.875rem;font-weight:600;line-height:1.25rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.config-grid{display:grid;gap:.75rem}@media (min-width:640px){.config-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1024px){.config-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.config-item{min-height:6rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.4);padding:1rem}.config-item.ok{border-color:rgba(52,211,153,.4)}.config-item.warn{border-color:rgba(251,191,36,.4)}.config-item.danger{border-color:rgba(251,113,133,.4)}.config-item p{margin-top:.5rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.muted{font-size:.875rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.boundary-section{border-color:rgba(251,191,36,.4)}.boundary-list{margin:0;list-style-type:disc;padding-left:1.25rem;font-size:.875rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.boundary-list li+li{margin-top:.5rem}.icon{height:1rem;width:1rem;flex-shrink:0}.visible{visibility:visible}.hidden{display:none} \ No newline at end of file diff --git a/internal/httpapi/web/admin/tailwind.css b/internal/httpapi/web/admin/tailwind.css index c1c7c70..0f28928 100644 --- a/internal/httpapi/web/admin/tailwind.css +++ b/internal/httpapi/web/admin/tailwind.css @@ -376,10 +376,6 @@ @apply grid gap-3 py-4 first:pt-0 last:pb-0; } - .create-row { - @apply mb-4 rounded-lg border border-proofline-border bg-proofline-bg-deep/40 px-4; - } - .account-summary { @apply grid gap-3 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-start; } diff --git a/internal/httpapi/web/templates/admin.html b/internal/httpapi/web/templates/admin.html index ef38f9f..2859783 100644 --- a/internal/httpapi/web/templates/admin.html +++ b/internal/httpapi/web/templates/admin.html @@ -137,6 +137,33 @@

Private Only

{{else if eq .Mode "accounts"}}
+
+
+
+

Local directory

+

Create Account

+

New local accounts require second-factor setup before private operator actions.

+
+ +
+
+ + + + + +
+
+
@@ -155,32 +182,6 @@

User Accounts

{{if .AccountPagination.Search}}Clear{{end}} - - {{else if eq .Mode "second_factor_verify"}}
- {{if .SecondFactorEmailAvailable}} + {{if .SecondFactorWebAuthnAvailable}}
-

Email code

- - - - +
+

Security keys

+ Recommended +
+

WebAuthn/FIDO2 verification is available for configured security-key factors.

{{end}} {{if .SecondFactorTOTPAvailable}}
-

Authenticator app

+
+

Authenticator app

+ {{if .SecondFactorWebAuthnAvailable}}Recommended fallback{{else}}Recommended{{end}} +
{{end}} + {{if .SecondFactorEmailAvailable}} +
+
+

Email challenge

+ Backup +
+
+ + +
+
+ {{end}} {{if and (not .SecondFactorEmailAvailable) (not .SecondFactorTOTPAvailable)}}

No web form available

From df526c082a588da61a6773d48b549d3186787155 Mon Sep 17 00:00:00 2001 From: Ellie Melton Date: Sun, 14 Jun 2026 03:41:46 +1000 Subject: [PATCH 09/10] Add admin settings TOTP setup --- go.mod | 2 +- internal/httpapi/admin_web_handlers.go | 140 +++++++++++++- internal/httpapi/admin_web_test.go | 192 +++++++++++++++++++ internal/httpapi/admin_web_view.go | 142 +++++++++++--- internal/httpapi/routes.go | 2 + internal/httpapi/web/admin/static/styles.css | 2 +- internal/httpapi/web/admin/tailwind.css | 35 +++- internal/httpapi/web/templates/admin.html | 83 +++++++- 8 files changed, 557 insertions(+), 41 deletions(-) diff --git a/go.mod b/go.mod index 85fa272..b86ca21 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/aws/aws-sdk-go-v2 v1.42.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3 github.com/aws/smithy-go v1.27.2 + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc github.com/go-webauthn/webauthn v0.17.4 github.com/jackc/pgx/v5 v5.10.0 github.com/mattn/go-sqlite3 v1.14.45 @@ -26,7 +27,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect - github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect diff --git a/internal/httpapi/admin_web_handlers.go b/internal/httpapi/admin_web_handlers.go index a4f1163..21ef705 100644 --- a/internal/httpapi/admin_web_handlers.go +++ b/internal/httpapi/admin_web_handlers.go @@ -460,8 +460,13 @@ func (a *API) adminWebStartTOTPSecondFactorEnrollment(w http.ResponseWriter, r * a.adminWebInternalError(w, "create admin web TOTP enrollment", err) return } + enrollmentData, err := makeAdminWebTOTPEnrollment(principal.Account, factor) + if err != nil { + a.adminWebInternalError(w, "build admin web TOTP enrollment view", err) + return + } data.Notice = "TOTP setup started. Enter the current authenticator app code to finish setup." - data.SecondFactorTOTPEnrollment = makeAdminWebTOTPEnrollment(principal.Account, factor) + data.SecondFactorTOTPEnrollment = enrollmentData a.renderAdminWeb(w, http.StatusForbidden, data) } @@ -494,7 +499,12 @@ func (a *API) adminWebConfirmTOTPSecondFactorEnrollment(w http.ResponseWriter, r a.adminWebInternalError(w, "get admin web pending TOTP factor", err) return } - data.SecondFactorTOTPEnrollment = makeAdminWebTOTPEnrollment(principal.Account, factor) + enrollmentData, err := makeAdminWebTOTPEnrollment(principal.Account, factor) + if err != nil { + a.adminWebInternalError(w, "build admin web pending TOTP enrollment view", err) + return + } + data.SecondFactorTOTPEnrollment = enrollmentData code := strings.TrimSpace(r.FormValue("code")) if code == "" { @@ -630,6 +640,132 @@ func (a *API) adminWebChangeOwnPassword(w http.ResponseWriter, r *http.Request) http.Redirect(w, r, "/admin/settings?notice=password_changed", http.StatusSeeOther) } +func (a *API) adminWebStartSettingsTOTPSecondFactorEnrollment(w http.ResponseWriter, r *http.Request) { + setAdminWebPageHeaders(w) + principal, ok := a.requireAdminWeb(w, r) + if !ok { + return + } + data, err := a.makeAdminWebSettingsDataForRequest(r, principal, "", "The TOTP setup form could not be read.") + if err != nil { + a.adminWebInternalError(w, "build admin web settings TOTP setup data", err) + return + } + if ok := a.parseAdminWebForm(w, r, data); !ok { + return + } + data.Error = "" + if !a.validateAdminWebCSRFForData(w, r, data) { + return + } + + enrollment, err := auth.GenerateTOTPEnrollment(principal.Account.Username) + if err != nil { + a.adminWebInternalError(w, "generate admin web settings TOTP enrollment", err) + return + } + factor, err := a.repo.CreateTOTPSecondFactorEnrollment(r.Context(), auth.CreateTOTPSecondFactorEnrollmentParams{ + AccountID: principal.Account.ID, + Secret: enrollment.Secret, + PeriodSeconds: enrollment.PeriodSeconds, + Digits: enrollment.Digits, + Algorithm: enrollment.Algorithm, + }) + if errors.Is(err, auth.ErrDuplicate) { + data.Error = "TOTP second factor is already configured." + data.SecondFactorTOTPActive = true + a.renderAdminWeb(w, http.StatusConflict, data) + return + } + if errors.Is(err, auth.ErrNotFound) { + data.Error = "Account was not found." + a.renderAdminWeb(w, http.StatusNotFound, data) + return + } + if err != nil { + a.adminWebInternalError(w, "create admin web settings TOTP enrollment", err) + return + } + enrollmentData, err := makeAdminWebTOTPEnrollment(principal.Account, factor) + if err != nil { + a.adminWebInternalError(w, "build admin web settings TOTP enrollment view", err) + return + } + data.Notice = "TOTP setup started. Enter the current authenticator app code to finish setup." + data.SecondFactorTOTPEnrollment = enrollmentData + a.renderAdminWeb(w, http.StatusOK, data) +} + +func (a *API) adminWebConfirmSettingsTOTPSecondFactorEnrollment(w http.ResponseWriter, r *http.Request) { + setAdminWebPageHeaders(w) + principal, ok := a.requireAdminWeb(w, r) + if !ok { + return + } + data, err := a.makeAdminWebSettingsDataForRequest(r, principal, "", "The TOTP confirmation form could not be read.") + if err != nil { + a.adminWebInternalError(w, "build admin web settings TOTP confirmation data", err) + return + } + if ok := a.parseAdminWebForm(w, r, data); !ok { + return + } + data.Error = "" + if !a.validateAdminWebCSRFForData(w, r, data) { + return + } + + factor, err := a.repo.GetPendingTOTPSecondFactor(r.Context(), principal.Account.ID) + if errors.Is(err, auth.ErrNotFound) { + data.Error = "TOTP setup is not active. Start authenticator-app setup again." + a.renderAdminWeb(w, http.StatusBadRequest, data) + return + } + if err != nil { + a.adminWebInternalError(w, "get admin web settings pending TOTP factor", err) + return + } + enrollmentData, err := makeAdminWebTOTPEnrollment(principal.Account, factor) + if err != nil { + a.adminWebInternalError(w, "build admin web settings pending TOTP enrollment view", err) + return + } + data.SecondFactorTOTPEnrollment = enrollmentData + + code := strings.TrimSpace(r.FormValue("code")) + if code == "" { + data.Error = "TOTP challenge is invalid or expired." + a.renderAdminWeb(w, http.StatusBadRequest, data) + return + } + now := time.Now().UTC() + timeStep, valid, err := auth.MatchTOTPCode(factor.TOTPSecret, code, now, factor.TOTPPeriodSeconds, factor.TOTPDigits, factor.TOTPAlgorithm) + if err != nil { + a.adminWebInternalError(w, "validate admin web settings pending TOTP code", err) + return + } + if !valid { + data.Error = "TOTP challenge is invalid or expired." + a.renderAdminWeb(w, http.StatusBadRequest, data) + return + } + factor, _, err = a.repo.ActivateTOTPSecondFactor(r.Context(), principal.Account.ID, factor.ID, now, timeStep) + if errors.Is(err, auth.ErrNotFound) { + data.Error = "TOTP challenge is invalid or expired." + a.renderAdminWeb(w, http.StatusBadRequest, data) + return + } + if err != nil { + a.adminWebInternalError(w, "activate admin web settings TOTP factor", err) + return + } + if _, err := a.repo.MarkSessionSecondFactorVerified(r.Context(), principal.Session.ID, factor.ID, auth.SecondFactorTypeTOTP, now); err != nil { + a.adminWebInternalError(w, "mark admin web settings TOTP session verified", err) + return + } + http.Redirect(w, r, "/admin/settings?notice=second_factor_setup_complete", http.StatusSeeOther) +} + func (a *API) adminWebCreateAccount(w http.ResponseWriter, r *http.Request) { setAdminWebPageHeaders(w) principal, ok := a.requireAdminWeb(w, r) diff --git a/internal/httpapi/admin_web_test.go b/internal/httpapi/admin_web_test.go index 6404dc9..c9b0126 100644 --- a/internal/httpapi/admin_web_test.go +++ b/internal/httpapi/admin_web_test.go @@ -198,14 +198,20 @@ func TestAdminWebSettingsShowsAdminControlsAndRedactedConfig(t *testing.T) { "Security keys", "Recommended fallback without WebAuthn", "Configuration", + "Auth", + "Storage", + "Rate limits", + "Blob store", "Max upload", "Account blob quota", "Registration", + "Relay", "Browser sessions", "WebAuthn", "Email sender", "Relay capability", "Relay service auth", + `action="/admin/settings/second-factor/totp/enroll"`, "Provider details redacted", "RP details redacted", "Secret redacted", @@ -222,6 +228,8 @@ func TestAdminWebSettingsShowsAdminControlsAndRedactedConfig(t *testing.T) { "relay-service-token", "admin.example.invalid", "https://admin.example.invalid", + "Manual setup key", + "otpauth://", "Authorization", } { if bytes.Contains(body, []byte(disallowed)) { @@ -1196,6 +1204,190 @@ func TestAdminWebTOTPSecondFactorSetupUnlocksDashboard(t *testing.T) { } } +func TestAdminWebSettingsTOTPSetupAndVerification(t *testing.T) { + app := newTestApp(t) + cookie := loginAdminWeb(t, app) + + response, body := requestWithCookie(t, app.adminHandler, http.MethodGet, "/admin/settings", "", nil, cookie) + response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("expected admin settings status 200, got %d: %s", response.StatusCode, body) + } + for _, expected := range []string{"Second Factor", "Authenticator app", `action="/admin/settings/second-factor/totp/enroll"`} { + if !bytes.Contains(body, []byte(expected)) { + t.Fatalf("admin settings TOTP setup missing %q: %s", expected, body) + } + } + for _, disallowed := range []string{"Manual setup key", "OTPAuth URI", "otpauth://", app.authToken} { + if bytes.Contains(body, []byte(disallowed)) { + t.Fatalf("admin settings TOTP setup exposed %q before enrollment: %s", disallowed, body) + } + } + csrfToken := adminWebCSRFTokenFromBody(t, body) + + response, body = postAdminWebFormWithCookie(t, app, "/admin/settings/second-factor/totp/enroll", url.Values{ + "csrf_token": {csrfToken}, + }, cookie) + response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("expected settings TOTP enrollment status 200, got %d: %s", response.StatusCode, body) + } + adminAccount := mustGetAccountByUsername(t, app, "test-admin") + factor, err := incidents.NewRepository(app.db).GetPendingTOTPSecondFactor(t.Context(), adminAccount.ID) + if err != nil { + t.Fatalf("get pending settings TOTP factor: %v", err) + } + for _, expected := range []string{ + "TOTP setup started.", + "TOTP QR code", + "Manual setup key", + factor.TOTPSecret, + "OTPAuth URI", + "otpauth://totp/Proofline:test-admin", + `action="/admin/settings/second-factor/totp/confirm"`, + "Confirm authenticator app", + } { + if !bytes.Contains(body, []byte(expected)) { + t.Fatalf("admin settings TOTP enrollment missing %q: %s", expected, body) + } + } + for _, disallowed := range []string{app.authToken} { + if bytes.Contains(body, []byte(disallowed)) { + t.Fatalf("admin settings TOTP enrollment exposed %q: %s", disallowed, body) + } + } + csrfToken = adminWebCSRFTokenFromBody(t, body) + + code, err := auth.GenerateTOTPCodeForTest(factor.TOTPSecret, time.Now().UTC()) + if err != nil { + t.Fatalf("generate settings TOTP setup code: %v", err) + } + response, body = postAdminWebFormWithCookie(t, app, "/admin/settings/second-factor/totp/confirm", url.Values{ + "csrf_token": {csrfToken}, + "code": {code}, + }, cookie) + response.Body.Close() + if response.StatusCode != http.StatusSeeOther { + t.Fatalf("expected settings TOTP confirmation redirect 303, got %d: %s", response.StatusCode, body) + } + if location := response.Header.Get("Location"); location != "/admin/settings?notice=second_factor_setup_complete" { + t.Fatalf("expected settings TOTP setup complete redirect, got %q", location) + } + + response, body = requestWithCookie(t, app.adminHandler, http.MethodGet, "/admin/settings?notice=second_factor_setup_complete", "", nil, cookie) + response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("expected settings after TOTP setup status 200, got %d: %s", response.StatusCode, body) + } + for _, expected := range []string{"Admin second-factor setup completed.", "Authenticator-app TOTP is configured", "Configured"} { + if !bytes.Contains(body, []byte(expected)) { + t.Fatalf("admin settings after TOTP setup missing %q: %s", expected, body) + } + } + for _, disallowed := range []string{factor.TOTPSecret, code, "Manual setup key", "OTPAuth URI", "otpauth://", app.authToken} { + if bytes.Contains(body, []byte(disallowed)) { + t.Fatalf("admin settings after TOTP setup exposed %q: %s", disallowed, body) + } + } + + newCookie := loginAdminWeb(t, app) + response, body = requestWithCookie(t, app.adminHandler, http.MethodGet, "/admin", "", nil, newCookie) + response.Body.Close() + if response.StatusCode != http.StatusForbidden { + t.Fatalf("expected admin TOTP gate after relogin status 403, got %d: %s", response.StatusCode, body) + } + for _, expected := range []string{"Verify Admin 2FA", "Authenticator app", `action="/admin/second-factor/totp/verify"`} { + if !bytes.Contains(body, []byte(expected)) { + t.Fatalf("admin TOTP gate after settings setup missing %q: %s", expected, body) + } + } + for _, disallowed := range []string{factor.TOTPSecret, code, app.authToken} { + if bytes.Contains(body, []byte(disallowed)) { + t.Fatalf("admin TOTP gate after settings setup exposed %q: %s", disallowed, body) + } + } + csrfToken = adminWebCSRFTokenFromBody(t, body) + verifyCode, err := auth.GenerateTOTPCodeForTest(factor.TOTPSecret, time.Now().UTC().Add(time.Duration(auth.TOTPDefaultPeriodSeconds)*time.Second)) + if err != nil { + t.Fatalf("generate settings TOTP verification code: %v", err) + } + response, body = postAdminWebFormWithCookie(t, app, "/admin/second-factor/totp/verify", url.Values{ + "csrf_token": {csrfToken}, + "code": {verifyCode}, + }, newCookie) + response.Body.Close() + if response.StatusCode != http.StatusSeeOther { + t.Fatalf("expected admin TOTP verify redirect 303, got %d: %s", response.StatusCode, body) + } + if location := response.Header.Get("Location"); location != "/admin?notice=second_factor_verified" { + t.Fatalf("expected admin TOTP verified redirect, got %q", location) + } + + response, body = requestWithCookie(t, app.adminHandler, http.MethodGet, "/admin", "", nil, newCookie) + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("expected admin dashboard after TOTP verification status 200, got %d: %s", response.StatusCode, body) + } + for _, disallowed := range []string{factor.TOTPSecret, code, verifyCode, app.authToken} { + if bytes.Contains(body, []byte(disallowed)) { + t.Fatalf("admin dashboard after TOTP verification exposed %q: %s", disallowed, body) + } + } +} + +func TestAdminWebSettingsTOTPSetupRequiresCSRFToken(t *testing.T) { + app := newTestApp(t) + cookie := loginAdminWeb(t, app) + + response, body := postAdminWebFormWithCookie(t, app, "/admin/settings/second-factor/totp/enroll", url.Values{}, cookie) + response.Body.Close() + if response.StatusCode != http.StatusForbidden { + t.Fatalf("expected settings TOTP enroll without CSRF status 403, got %d: %s", response.StatusCode, body) + } + if !bytes.Contains(body, []byte("The form expired.")) { + t.Fatalf("expected settings TOTP enroll CSRF error: %s", body) + } + for _, disallowed := range []string{"Manual setup key", "OTPAuth URI", "otpauth://", app.authToken} { + if bytes.Contains(body, []byte(disallowed)) { + t.Fatalf("settings TOTP enroll CSRF error exposed %q: %s", disallowed, body) + } + } + + csrfToken := adminWebDashboardCSRFToken(t, app, cookie) + response, body = postAdminWebFormWithCookie(t, app, "/admin/settings/second-factor/totp/enroll", url.Values{ + "csrf_token": {csrfToken}, + }, cookie) + response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("expected settings TOTP enrollment status 200, got %d: %s", response.StatusCode, body) + } + adminAccount := mustGetAccountByUsername(t, app, "test-admin") + factor, err := incidents.NewRepository(app.db).GetPendingTOTPSecondFactor(t.Context(), adminAccount.ID) + if err != nil { + t.Fatalf("get pending settings TOTP factor: %v", err) + } + + code, err := auth.GenerateTOTPCodeForTest(factor.TOTPSecret, time.Now().UTC()) + if err != nil { + t.Fatalf("generate settings TOTP CSRF code: %v", err) + } + response, body = postAdminWebFormWithCookie(t, app, "/admin/settings/second-factor/totp/confirm", url.Values{ + "code": {code}, + }, cookie) + response.Body.Close() + if response.StatusCode != http.StatusForbidden { + t.Fatalf("expected settings TOTP confirm without CSRF status 403, got %d: %s", response.StatusCode, body) + } + if !bytes.Contains(body, []byte("The form expired.")) { + t.Fatalf("expected settings TOTP confirm CSRF error: %s", body) + } + for _, disallowed := range []string{factor.TOTPSecret, code, "Manual setup key", "OTPAuth URI", "otpauth://", app.authToken} { + if bytes.Contains(body, []byte(disallowed)) { + t.Fatalf("settings TOTP confirm CSRF error exposed %q: %s", disallowed, body) + } + } +} + func TestAdminWebRequiresTOTPVerifiedSessionBeforeDashboard(t *testing.T) { app := newTestApp(t) enrollment := startTOTPEnrollmentForTest(t, app, app.authToken) diff --git a/internal/httpapi/admin_web_view.go b/internal/httpapi/admin_web_view.go index 47d5c5f..487d2b4 100644 --- a/internal/httpapi/admin_web_view.go +++ b/internal/httpapi/admin_web_view.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/boombuler/barcode/qr" "github.com/open-proofline/server/internal/auth" "github.com/open-proofline/server/internal/incidents" ) @@ -38,10 +39,11 @@ type adminWebData struct { DeletionStatus adminWebDeletionStatus NavItems []adminWebNavItem StatusItems []adminWebStatusItem - ConfigItems []adminWebStatusItem + ConfigGroups []adminWebStatusGroup SecondFactorItems []adminWebStatusItem SecondFactorEmailAvailable bool SecondFactorTOTPAvailable bool + SecondFactorTOTPActive bool SecondFactorWebAuthnAvailable bool SecondFactorTOTPEnrollment adminWebTOTPEnrollment } @@ -115,9 +117,15 @@ type adminWebStatusItem struct { Tone string } +type adminWebStatusGroup struct { + Label string + Items []adminWebStatusItem +} + type adminWebTOTPEnrollment struct { Secret string OTPAuthURL string + QRCodeRows [][]bool Issuer string AccountName string PeriodSeconds int @@ -209,7 +217,22 @@ func (a *API) renderAdminWebIncidents(w http.ResponseWriter, r *http.Request, pr } func (a *API) renderAdminWebSettings(w http.ResponseWriter, r *http.Request, principal privatePrincipal, status int, notice, message string) { - a.renderAdminWeb(w, status, makeAdminWebSettingsData(principal, adminWebCSRFTokenFromRequest(r), notice, message, a.adminWebConfigItems(), a.adminWebSecondFactorItems())) + data, err := a.makeAdminWebSettingsDataForRequest(r, principal, notice, message) + if err != nil { + a.adminWebInternalError(w, "build admin web settings data", err) + return + } + a.renderAdminWeb(w, status, data) +} + +func (a *API) makeAdminWebSettingsDataForRequest(r *http.Request, principal privatePrincipal, notice, message string) (adminWebData, error) { + secondFactorItems, totpActive, err := a.adminWebSecondFactorItems(r, principal) + if err != nil { + return adminWebData{}, err + } + data := makeAdminWebSettingsData(principal, adminWebCSRFTokenFromRequest(r), notice, message, a.adminWebConfigGroups(), secondFactorItems, totpActive) + data.SecondFactorWebAuthnAvailable = a.adminWebAuthnAvailable() + return data, nil } func (a *API) adminWebCommittedBlobBytes(r *http.Request, accounts []auth.Account) (int64, error) { @@ -351,10 +374,12 @@ func makeAdminWebIncidentsData(principal privatePrincipal, candidates []incident return data } -func makeAdminWebSettingsData(principal privatePrincipal, csrfToken, notice, message string, configItems, secondFactorItems []adminWebStatusItem) adminWebData { +func makeAdminWebSettingsData(principal privatePrincipal, csrfToken, notice, message string, configGroups []adminWebStatusGroup, secondFactorItems []adminWebStatusItem, totpActive bool) adminWebData { data := makeAdminWebShellData(principal, adminWebPageSettings, "Settings", "Current admin account settings and safe server configuration.", csrfToken, notice, message) - data.ConfigItems = configItems + data.ConfigGroups = configGroups data.SecondFactorItems = secondFactorItems + data.SecondFactorTOTPAvailable = true + data.SecondFactorTOTPActive = totpActive return data } @@ -470,16 +495,22 @@ func makeAdminWebSecondFactorVerificationData(principal privatePrincipal, csrfTo } } -func makeAdminWebTOTPEnrollment(account auth.Account, factor auth.SecondFactor) adminWebTOTPEnrollment { +func makeAdminWebTOTPEnrollment(account auth.Account, factor auth.SecondFactor) (adminWebTOTPEnrollment, error) { + otpAuthURL := adminWebTOTPAuthURL(account.Username, factor.TOTPSecret, factor.TOTPPeriodSeconds, factor.TOTPDigits, factor.TOTPAlgorithm) + qrRows, err := adminWebTOTPQRCodeRows(otpAuthURL) + if err != nil { + return adminWebTOTPEnrollment{}, err + } return adminWebTOTPEnrollment{ Secret: factor.TOTPSecret, - OTPAuthURL: adminWebTOTPAuthURL(account.Username, factor.TOTPSecret, factor.TOTPPeriodSeconds, factor.TOTPDigits, factor.TOTPAlgorithm), + OTPAuthURL: otpAuthURL, + QRCodeRows: qrRows, Issuer: auth.TOTPIssuer, AccountName: account.Username, PeriodSeconds: factor.TOTPPeriodSeconds, Digits: factor.TOTPDigits, Algorithm: factor.TOTPAlgorithm, - } + }, nil } func adminWebTOTPAuthURL(accountName, secret string, periodSeconds, digits int, algorithm string) string { @@ -497,6 +528,24 @@ func adminWebTOTPAuthURL(accountName, secret string, periodSeconds, digits int, }).String() } +func adminWebTOTPQRCodeRows(value string) ([][]bool, error) { + code, err := qr.Encode(value, qr.M, qr.Auto) + if err != nil { + return nil, err + } + bounds := code.Bounds() + rows := make([][]bool, bounds.Dy()) + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + row := make([]bool, bounds.Dx()) + for x := bounds.Min.X; x < bounds.Max.X; x++ { + red, green, blue, alpha := code.At(x, y).RGBA() + row[x-bounds.Min.X] = alpha > 0 && red+green+blue < 0x8000*3 + } + rows[y-bounds.Min.Y] = row + } + return rows, nil +} + func makeAdminWebAccounts(accounts []auth.Account, currentAccountID string) []adminWebAccount { response := make([]adminWebAccount, 0, len(accounts)) for _, account := range accounts { @@ -658,12 +707,27 @@ func (a *API) adminWebEmailAvailable(r *http.Request, principal privatePrincipal return false, err } -func (a *API) adminWebSecondFactorItems() []adminWebStatusItem { +func (a *API) adminWebSecondFactorItems(r *http.Request, principal privatePrincipal) ([]adminWebStatusItem, bool, error) { emailValue := "Not configured" emailTone := "warn" if a.emailSender != nil { emailValue = "Available" emailTone = "neutral" + emailActive, err := a.adminWebEmailAvailable(r, principal) + if err != nil { + return nil, false, err + } + if emailActive { + emailValue = "Configured" + } + } + totpActive, err := a.adminWebTOTPAvailable(r, principal) + if err != nil { + return nil, false, err + } + totpValue := "Available" + if totpActive { + totpValue = "Configured" } webAuthnValue := "Not configured" webAuthnTone := "warn" @@ -673,25 +737,51 @@ func (a *API) adminWebSecondFactorItems() []adminWebStatusItem { } return []adminWebStatusItem{ {Label: "Security keys", Value: webAuthnValue, Description: "Recommended when WebAuthn/FIDO2 is configured", Tone: webAuthnTone}, - {Label: "TOTP", Value: "Available", Description: "Recommended fallback without WebAuthn", Tone: "neutral"}, + {Label: "TOTP", Value: totpValue, Description: "Recommended fallback without WebAuthn", Tone: "neutral"}, {Label: "Email challenge", Value: emailValue, Description: "Backup mail delivery fallback", Tone: emailTone}, - } -} - -func (a *API) adminWebConfigItems() []adminWebStatusItem { - return []adminWebStatusItem{ - {Label: "Max upload", Value: formatAdminWebBytes(a.maxUploadBytes), Description: "Per request body limit", Tone: "neutral"}, - {Label: "Account blob quota", Value: formatAdminWebBytes(a.accountBlobQuotaBytes), Description: "Default committed chunk quota", Tone: "neutral"}, - {Label: "Session TTL", Value: formatAdminWebDuration(a.sessionTTL), Description: "Server-side auth session expiry", Tone: "neutral"}, - {Label: "Incident token TTL", Value: formatAdminWebDuration(a.defaultIncidentTokenTTL), Description: "Default viewer token expiry", Tone: "neutral"}, - {Label: "Registration", Value: a.accountRegistration.Mode, Description: "Account registration mode", Tone: "neutral"}, - {Label: "Browser sessions", Value: adminWebEnabledValue(a.webAuth.Enabled), Description: "Cookie auth for main API", Tone: adminWebEnabledTone(a.webAuth.Enabled)}, - {Label: "WebAuthn", Value: adminWebEnabledValue(a.webAuthn.Enabled), Description: "RP details redacted", Tone: adminWebEnabledTone(a.webAuthn.Enabled)}, - {Label: "Email sender", Value: adminWebConfiguredValue(a.emailSender != nil), Description: "Provider details redacted", Tone: adminWebEnabledTone(a.emailSender != nil)}, - {Label: "Main rate limits", Value: adminWebEnabledValue(a.mainRateLimit.Enabled), Description: formatAdminWebDuration(a.mainRateLimit.Window), Tone: adminWebEnabledTone(a.mainRateLimit.Enabled)}, - {Label: "Viewer rate limits", Value: adminWebEnabledValue(a.publicRateLimit.Enabled), Description: formatAdminWebDuration(a.publicRateLimit.Window), Tone: adminWebEnabledTone(a.publicRateLimit.Enabled)}, - {Label: "Relay capability", Value: adminWebConfiguredValue(a.relayCapability.Secret != ""), Description: "Secret redacted", Tone: adminWebEnabledTone(a.relayCapability.Secret != "")}, - {Label: "Relay service auth", Value: adminWebConfiguredValue(a.relayService.AuthToken != ""), Description: "Token redacted", Tone: adminWebEnabledTone(a.relayService.AuthToken != "")}, + }, totpActive, nil +} + +func (a *API) adminWebConfigGroups() []adminWebStatusGroup { + return []adminWebStatusGroup{ + { + Label: "Auth", + Items: []adminWebStatusItem{ + {Label: "Session TTL", Value: formatAdminWebDuration(a.sessionTTL), Description: "Server-side auth session expiry", Tone: "neutral"}, + {Label: "Incident token TTL", Value: formatAdminWebDuration(a.defaultIncidentTokenTTL), Description: "Default viewer token expiry", Tone: "neutral"}, + {Label: "Browser sessions", Value: adminWebEnabledValue(a.webAuth.Enabled), Description: "Cookie auth for main API", Tone: adminWebEnabledTone(a.webAuth.Enabled)}, + {Label: "WebAuthn", Value: adminWebEnabledValue(a.webAuthn.Enabled), Description: "RP details redacted", Tone: adminWebEnabledTone(a.webAuthn.Enabled)}, + {Label: "Email sender", Value: adminWebConfiguredValue(a.emailSender != nil), Description: "Provider details redacted", Tone: adminWebEnabledTone(a.emailSender != nil)}, + }, + }, + { + Label: "Storage", + Items: []adminWebStatusItem{ + {Label: "Blob store", Value: adminWebConfiguredValue(a.store != nil), Description: "Backend details redacted", Tone: adminWebEnabledTone(a.store != nil)}, + {Label: "Max upload", Value: formatAdminWebBytes(a.maxUploadBytes), Description: "Per request body limit", Tone: "neutral"}, + {Label: "Account blob quota", Value: formatAdminWebBytes(a.accountBlobQuotaBytes), Description: "Default committed chunk quota", Tone: "neutral"}, + }, + }, + { + Label: "Registration", + Items: []adminWebStatusItem{ + {Label: "Registration", Value: a.accountRegistration.Mode, Description: "Account registration mode", Tone: "neutral"}, + }, + }, + { + Label: "Relay", + Items: []adminWebStatusItem{ + {Label: "Relay capability", Value: adminWebConfiguredValue(a.relayCapability.Secret != ""), Description: "Secret redacted", Tone: adminWebEnabledTone(a.relayCapability.Secret != "")}, + {Label: "Relay service auth", Value: adminWebConfiguredValue(a.relayService.AuthToken != ""), Description: "Token redacted", Tone: adminWebEnabledTone(a.relayService.AuthToken != "")}, + }, + }, + { + Label: "Rate limits", + Items: []adminWebStatusItem{ + {Label: "Main rate limits", Value: adminWebEnabledValue(a.mainRateLimit.Enabled), Description: formatAdminWebDuration(a.mainRateLimit.Window), Tone: adminWebEnabledTone(a.mainRateLimit.Enabled)}, + {Label: "Viewer rate limits", Value: adminWebEnabledValue(a.publicRateLimit.Enabled), Description: formatAdminWebDuration(a.publicRateLimit.Window), Tone: adminWebEnabledTone(a.publicRateLimit.Enabled)}, + }, + }, } } diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index c0e2b10..a34d4c8 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -113,6 +113,8 @@ func (a *API) registerPrivateAdminWebRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /admin/second-factor/totp/confirm", a.adminWebConfirmTOTPSecondFactorEnrollment) mux.HandleFunc("POST /admin/second-factor/totp/verify", a.adminWebVerifyTOTPSecondFactorChallenge) mux.HandleFunc("POST /admin/password", a.adminWebChangeOwnPassword) + mux.HandleFunc("POST /admin/settings/second-factor/totp/enroll", a.adminWebStartSettingsTOTPSecondFactorEnrollment) + mux.HandleFunc("POST /admin/settings/second-factor/totp/confirm", a.adminWebConfirmSettingsTOTPSecondFactorEnrollment) mux.HandleFunc("POST /admin/accounts", a.adminWebCreateAccount) mux.HandleFunc("POST /admin/accounts/{account_id}/password", a.adminWebResetAccountPassword) mux.HandleFunc("POST /admin/accounts/{account_id}/second-factor/recovery/reset", a.adminWebResetAccountSecondFactorRecovery) diff --git a/internal/httpapi/web/admin/static/styles.css b/internal/httpapi/web/admin/static/styles.css index 5284116..36657c8 100644 --- a/internal/httpapi/web/admin/static/styles.css +++ b/internal/httpapi/web/admin/static/styles.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}html{min-width:320px;background:#1a0c2e}body{margin:0;min-height:100vh;--tw-bg-opacity:1;background-color:rgb(26 12 46/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}dd,dl,h1,h2,h3,p{margin:0}h1{font-size:1.5rem;line-height:2rem;font-weight:600;line-height:1.25;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));letter-spacing:0}h2{font-size:1.125rem}h2,h3{font-weight:600;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));letter-spacing:0}h3{font-size:1rem}button{display:inline-flex;min-height:2.75rem;width:-moz-fit-content;width:fit-content;cursor:pointer;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(167 139 250/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(167 139 250/var(--tw-bg-opacity,1));padding:.625rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgb(18 7 31/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;letter-spacing:0}button:hover{--tw-border-opacity:1;border-color:rgb(196 181 253/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(196 181 253/var(--tw-bg-opacity,1))}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:#c084fc}label{display:grid;gap:.5rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}input,select{min-height:2.75rem;width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:.625rem .75rem;font-size:1rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;font:inherit}input:hover,select:hover{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1))}input::-moz-placeholder{--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}input::placeholder{--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.skip-link{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.skip-link:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto;white-space:normal;position:fixed;left:1rem;top:1rem;z-index:50;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:500}.admin-shell,.skip-link:focus{--tw-bg-opacity:1;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.admin-shell{min-height:100vh;background-color:rgb(26 12 46/var(--tw-bg-opacity,1))}@media (min-width:1024px){.admin-shell[data-admin-mode=accounts],.admin-shell[data-admin-mode=dashboard],.admin-shell[data-admin-mode=incidents],.admin-shell[data-admin-mode=settings]{display:grid;grid-template-columns:248px minmax(0,1fr)}}.auth-shell{display:grid;place-items:center;padding:2.5rem 1rem}@media (min-width:640px){.auth-shell{padding-left:1.5rem;padding-right:1.5rem}}.auth-card{width:100%;max-width:28rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));padding:1.25rem;--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.auth-card{padding:1.5rem}}.auth-brand{display:flex;align-items:flex-start;gap:.75rem}.auth-logo{height:4rem;width:4rem;flex-shrink:0;--tw-scale-x:1.25;--tw-scale-y:1.25;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));-o-object-fit:contain;object-fit:contain}.auth-kicker{line-height:1.25rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1))}.auth-kicker,.auth-lead{font-size:.875rem;--tw-text-opacity:1}.auth-lead{line-height:1.5rem;color:rgb(229 216 255/var(--tw-text-opacity,1))}.auth-card .boundary-list,.auth-lead{margin-top:1rem}.auth-methods{margin-top:1.25rem;display:grid;gap:1.25rem}.auth-method{border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1.25rem}.auth-method:first-child{border-top-width:0;padding-top:0}.auth-method h2{font-size:1rem;font-weight:600;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.method-heading{display:flex;flex-direction:column;gap:.5rem}@media (min-width:640px){.method-heading{flex-direction:row;align-items:center;justify-content:space-between}}.method-badge{display:inline-flex;width:-moz-fit-content;width:fit-content;border-radius:.375rem;border-width:1px;padding:.25rem .5rem;font-size:.75rem;line-height:1rem;font-weight:600}.method-badge.recommended{border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.method-badge.backup{border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.auth-actions{margin-top:1.25rem;display:flex;flex-wrap:wrap;align-items:center;gap:.75rem}.auth-actions .ghost-link,.auth-actions button,.auth-actions form,.stack-form button{width:100%}@media (min-width:640px){.auth-actions .ghost-link,.auth-actions button,.auth-actions form,.stack-form button{width:-moz-fit-content;width:fit-content}}.mobile-header{position:sticky;top:0;z-index:30;display:flex;align-items:center;justify-content:space-between;gap:.75rem;border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.95);padding:.75rem 1rem;--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}@media (min-width:1024px){.mobile-header{display:none}}.mobile-menu{position:relative;flex-shrink:0}@media (min-width:1024px){.mobile-menu{display:none}}.mobile-menu-button{display:flex;width:2.5rem;height:2.5rem;cursor:pointer;list-style-type:none;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.mobile-menu-button:hover{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1))}.mobile-menu-button::-webkit-details-marker{display:none}.mobile-menu-icon{height:1.25rem;width:1.25rem}.mobile-menu-icon-close,.mobile-menu[open] .mobile-menu-icon-open{display:none}.mobile-menu[open] .mobile-menu-icon-close{display:block}.mobile-menu-panel{position:absolute;right:0;top:3rem;z-index:30;display:grid;max-height:calc(100vh - 6rem);width:min(22rem,calc(100vw - 2rem));gap:.75rem;overflow-y:auto;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:.75rem;--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.mobile-nav{display:grid;gap:.25rem}.mobile-nav-link{display:grid;min-height:2.75rem;border-radius:.375rem;border-width:1px;border-color:transparent;padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1));text-decoration-line:none;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.mobile-nav-link:hover{--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.mobile-nav-link small{margin-top:.125rem;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1));letter-spacing:0}.mobile-nav-link.is-active{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.mobile-nav-link.is-active small{--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.mobile-menu-note{display:flex;align-items:center;gap:.5rem;border-radius:.375rem;border-width:1px;border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));padding:.75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.mobile-menu-logout button{width:100%}.brand-lockup,.sidebar-brand{display:inline-flex;min-width:0;align-items:center;gap:.75rem;border-radius:.375rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));text-decoration-line:none}.brand-logo{height:2.5rem;width:2.5rem;flex-shrink:0;-o-object-fit:contain;object-fit:contain}.brand-copy,.sidebar-brand span{display:grid;min-width:0;gap:.125rem}.brand-title,.sidebar-title{font-size:1.125rem;font-weight:600;line-height:1.5rem;color:rgb(248 244 255/var(--tw-text-opacity,1))}.brand-subtitle,.brand-title,.sidebar-kicker,.sidebar-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;--tw-text-opacity:1}.brand-subtitle,.sidebar-kicker{font-size:.75rem;line-height:1rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1))}.admin-sidebar{display:none;min-height:100vh;flex-direction:column;gap:1.5rem;border-right-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:1.25rem 1rem}@media (min-width:1024px){.admin-sidebar{position:sticky;top:0;display:flex;height:100vh}}.sidebar-brand{padding-left:.25rem;padding-right:.25rem}.sidebar-logo{height:2.75rem;width:2.75rem;flex-shrink:0;-o-object-fit:contain;object-fit:contain}.sidebar-nav{display:grid;flex:1 1 0%;align-content:flex-start;gap:.5rem}.nav-link{border-radius:.5rem;border-width:1px;border-color:transparent;padding:.75rem;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.nav-link-main{display:flex;align-items:center;gap:.5rem;font-weight:500}.nav-link small{margin-top:.25rem;display:block;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1));letter-spacing:0}.nav-link.is-active{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));--tw-shadow:0 10px 30px rgba(16,5,31,.18);--tw-shadow-colored:0 10px 30px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.nav-link.is-active small{--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.nav-link.is-disabled{background-color:transparent}.sidebar-note{margin-top:auto;display:flex;align-items:center;gap:.5rem;border-radius:.5rem;border-width:1px;border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));padding:.75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dashboard-main{margin-left:auto;margin-right:auto;width:100%;max-width:80rem;padding:1.25rem 1rem}@media (min-width:640px){.dashboard-main{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:1024px){.dashboard-main{padding:1.5rem 2rem}}.content-section,.metric-card,.page-header{border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.page-header{display:flex;flex-direction:column;gap:1rem;padding:1.25rem}@media (min-width:640px){.page-header{flex-direction:row;align-items:flex-start;justify-content:space-between;padding:1.5rem}}.page-lead{margin-top:.75rem;max-width:48rem;line-height:1.5rem;color:rgb(229 216 255/var(--tw-text-opacity,1))}.eyebrow,.page-lead{font-size:.875rem;--tw-text-opacity:1}.eyebrow{margin-bottom:.5rem;line-height:1.25rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.desktop-logout{display:none;flex-shrink:0}@media (min-width:1024px){.desktop-logout{display:block}}.ghost-button{border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgb(50 28 85/var(--tw-bg-opacity,1));color:rgb(229 216 255/var(--tw-text-opacity,1))}.ghost-button,.ghost-button:hover{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.ghost-button:hover{border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));color:rgb(248 244 255/var(--tw-text-opacity,1))}.inline-status{margin-top:1rem;border-radius:.375rem;border-width:1px;padding:.75rem;font-size:.875rem;font-weight:500;line-height:1.5rem}.inline-status.success{border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.inline-status.danger{border-color:rgba(251,113,133,.4);--tw-bg-opacity:1;background-color:rgb(63 11 22/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.metric-grid{margin-top:1.25rem;display:grid;gap:1rem}@media (min-width:640px){.metric-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.metric-card{display:grid;min-height:5rem;grid-template-columns:auto minmax(0,1fr);align-items:flex-start;gap:.75rem;padding:.75rem 1rem}.metric-card dt{font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.metric-card dd{margin-top:.25rem;overflow-wrap:break-word;font-size:1.25rem;line-height:1.75rem;font-weight:600;line-height:1.25;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.metric-description{margin-top:.25rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.metric-icon{margin-top:.25rem;height:.75rem;width:.75rem;border-radius:9999px;--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.metric-card.ok .metric-icon{--tw-bg-opacity:1;background-color:rgb(52 211 153/var(--tw-bg-opacity,1))}.metric-card.neutral .metric-icon{--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.metric-card.warn .metric-icon{--tw-bg-opacity:1;background-color:rgb(251 191 36/var(--tw-bg-opacity,1))}.metric-card.danger .metric-icon{--tw-bg-opacity:1;background-color:rgb(251 113 133/var(--tw-bg-opacity,1))}.workspace-grid{margin-top:1.25rem;display:grid;gap:1.25rem}@media (min-width:1280px){.workspace-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.workspace-grid.single-column{grid-template-columns:repeat(1,minmax(0,1fr))}}.overview-grid,.settings-grid{margin-top:1.25rem;display:grid;gap:1.25rem}@media (min-width:1024px){.overview-grid,.settings-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.settings-wide{grid-column:span 2/span 2}}.content-section{padding:1.25rem}@media (min-width:640px){.content-section{padding:1.5rem}}.accounts-section{min-width:0}@media (min-width:1280px){.accounts-section{grid-column:span 2/span 2}}.section-header{margin-bottom:1rem;display:flex;flex-direction:column;gap:.75rem}@media (min-width:640px){.section-header{flex-direction:row;align-items:flex-start;justify-content:space-between;gap:1rem}}.section-icon{height:2.25rem;width:2.25rem;flex-shrink:0;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));padding:.5rem;--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}.section-icon.warning{border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.status-badge{display:inline-flex;width:-moz-fit-content;width:fit-content;flex-shrink:0;align-items:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding:.25rem .5rem;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.status-badge.current{margin-left:.5rem;border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.toolbar-form{margin-bottom:1.25rem;display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.toolbar-form{grid-template-columns:minmax(0,1fr) auto auto}}.account-list>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse));--tw-divide-opacity:1;border-color:rgb(91 59 137/var(--tw-divide-opacity,1))}.fixed-list{min-height:0}@media (min-width:640px){.fixed-list{min-height:42rem}}.account-row{padding-top:1.25rem;padding-bottom:1.25rem}.account-row:first-child{padding-top:0}.account-row:last-child{padding-bottom:0}.account-record{display:grid;gap:1rem}@media (min-width:1024px){.account-record{grid-template-columns:minmax(0,1fr) minmax(19rem,28rem);align-items:flex-start}}.account-summary{display:grid;gap:.75rem}@media (min-width:640px){.account-summary{grid-template-columns:auto minmax(0,1fr);align-items:flex-start}}.account-avatar{display:grid;height:2.75rem;min-width:2.75rem;place-items:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding-left:.5rem;padding-right:.5rem;font-size:.75rem;line-height:1rem;font-weight:600;text-transform:uppercase;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.account-copy{min-width:0}.account-copy h3{overflow-wrap:break-word}.metadata-row{margin-top:.75rem;display:grid;-moz-column-gap:1.5rem;column-gap:1.5rem;row-gap:.75rem}@media (min-width:640px){.metadata-row{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1536px){.metadata-row{grid-template-columns:repeat(3,minmax(0,1fr))}}.metadata-row dt{font-size:.75rem;line-height:1rem;font-weight:500;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.metadata-row dd{margin-top:.25rem;overflow-wrap:break-word;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.account-actions{display:grid;gap:.75rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1rem}@media (min-width:1024px){.account-actions{border-left-width:1px;border-top-width:0;padding-left:1.25rem;padding-top:0}}.account-password-form{display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.account-password-form{grid-template-columns:minmax(0,1fr) auto}}.account-password-form button,.toolbar-form .ghost-link,.toolbar-form button{width:100%}@media (min-width:640px){.account-password-form button,.toolbar-form .ghost-link,.toolbar-form button{width:-moz-fit-content;width:fit-content}}.account-secondary-actions{display:flex;flex-wrap:wrap;gap:.75rem}.account-secondary-actions button,.account-secondary-actions form{width:100%}@media (min-width:640px){.account-secondary-actions button,.account-secondary-actions form{width:-moz-fit-content;width:fit-content}}.account-danger-zone{display:grid;gap:.75rem;border-radius:.375rem;border-width:1px;border-color:rgba(251,113,133,.4);background-color:rgba(63,11,22,.4);padding:.75rem}.account-danger-title{font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.account-danger-copy{margin-top:.25rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.danger-button{border-color:rgba(251,113,133,.5);--tw-bg-opacity:1;background-color:rgb(63 11 22/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.danger-button:hover{--tw-border-opacity:1;border-color:rgb(251 113 133/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(251 113 133/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(18 7 31/var(--tw-text-opacity,1))}.stack-form{margin-top:1.25rem;display:grid;gap:1rem}.inline-form{display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.inline-form{grid-template-columns:minmax(0,1fr) auto}}.compact-form{max-width:42rem}.pager{margin-top:1.25rem;display:grid;gap:.75rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1rem}@media (min-width:640px){.pager{display:flex;align-items:center;justify-content:space-between}}.pager-current{text-align:center;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}@media (min-width:640px){.pager-current{text-align:left}}.pager .ghost-link{width:100%}@media (min-width:640px){.pager .ghost-link{width:-moz-fit-content;width:fit-content}}.ghost-link{display:inline-flex;min-height:2.5rem;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:600;color:rgb(229 216 255/var(--tw-text-opacity,1))}.ghost-link,.ghost-link:hover{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.ghost-link:hover{border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));color:rgb(248 244 255/var(--tw-text-opacity,1))}.ghost-link.is-disabled{cursor:default;opacity:.5}.detail-list{display:grid;gap:1rem}.totp-enrollment{margin-top:1rem;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.5);padding:1rem}.secret-value,.uri-value{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.uri-value{word-break:break-all}.config-item dt,.detail-list dt{font-size:.75rem;line-height:1rem;font-weight:500;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.config-item dd,.detail-list dd{margin-top:.25rem;overflow-wrap:break-word;font-size:.875rem;font-weight:600;line-height:1.25rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.config-grid{display:grid;gap:.75rem}@media (min-width:640px){.config-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1024px){.config-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.config-item{min-height:6rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.4);padding:1rem}.config-item.ok{border-color:rgba(52,211,153,.4)}.config-item.warn{border-color:rgba(251,191,36,.4)}.config-item.danger{border-color:rgba(251,113,133,.4)}.config-item p{margin-top:.5rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.muted{font-size:.875rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.boundary-section{border-color:rgba(251,191,36,.4)}.boundary-list{margin:0;list-style-type:disc;padding-left:1.25rem;font-size:.875rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.boundary-list li+li{margin-top:.5rem}.icon{height:1rem;width:1rem;flex-shrink:0}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.hidden{display:none} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}html{min-width:320px;background:#1a0c2e}body{margin:0;min-height:100vh;--tw-bg-opacity:1;background-color:rgb(26 12 46/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}dd,dl,h1,h2,h3,p{margin:0}h1{font-size:1.5rem;line-height:2rem;font-weight:600;line-height:1.25;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));letter-spacing:0}h2{font-size:1.125rem}h2,h3{font-weight:600;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));letter-spacing:0}h3{font-size:1rem}button{display:inline-flex;min-height:2.75rem;width:-moz-fit-content;width:fit-content;cursor:pointer;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(167 139 250/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(167 139 250/var(--tw-bg-opacity,1));padding:.625rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgb(18 7 31/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;letter-spacing:0}button:hover{--tw-border-opacity:1;border-color:rgb(196 181 253/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(196 181 253/var(--tw-bg-opacity,1))}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:#c084fc}label{display:grid;gap:.5rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}input,select{min-height:2.75rem;width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:.625rem .75rem;font-size:1rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;font:inherit}input:hover,select:hover{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1))}input::-moz-placeholder{--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}input::placeholder{--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.skip-link{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.skip-link:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto;white-space:normal;position:fixed;left:1rem;top:1rem;z-index:50;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:500}.admin-shell,.skip-link:focus{--tw-bg-opacity:1;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.admin-shell{min-height:100vh;background-color:rgb(26 12 46/var(--tw-bg-opacity,1))}@media (min-width:1024px){.admin-shell[data-admin-mode=accounts],.admin-shell[data-admin-mode=dashboard],.admin-shell[data-admin-mode=incidents],.admin-shell[data-admin-mode=settings]{display:grid;grid-template-columns:248px minmax(0,1fr)}}.auth-shell{display:grid;place-items:center;padding:2.5rem 1rem}@media (min-width:640px){.auth-shell{padding-left:1.5rem;padding-right:1.5rem}}.auth-card{width:100%;max-width:28rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));padding:1.25rem;--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.auth-card{padding:1.5rem}}.auth-brand{display:flex;align-items:flex-start;gap:.75rem}.auth-logo{height:4rem;width:4rem;flex-shrink:0;--tw-scale-x:1.25;--tw-scale-y:1.25;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));-o-object-fit:contain;object-fit:contain}.auth-kicker{line-height:1.25rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1))}.auth-kicker,.auth-lead{font-size:.875rem;--tw-text-opacity:1}.auth-lead{line-height:1.5rem;color:rgb(229 216 255/var(--tw-text-opacity,1))}.auth-card .boundary-list,.auth-lead{margin-top:1rem}.auth-methods{margin-top:1.25rem;display:grid;gap:1.25rem}.auth-method{border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1.25rem}.auth-method:first-child{border-top-width:0;padding-top:0}.auth-method h2{font-size:1rem;font-weight:600;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.method-heading{display:flex;flex-direction:column;gap:.5rem}@media (min-width:640px){.method-heading{flex-direction:row;align-items:center;justify-content:space-between}}.method-badge{display:inline-flex;width:-moz-fit-content;width:fit-content;border-radius:.375rem;border-width:1px;padding:.25rem .5rem;font-size:.75rem;line-height:1rem;font-weight:600}.method-badge.recommended{border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.method-badge.backup{border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.auth-actions{margin-top:1.25rem;display:flex;flex-wrap:wrap;align-items:center;gap:.75rem}.auth-actions .ghost-link,.auth-actions button,.auth-actions form,.stack-form button{width:100%}@media (min-width:640px){.auth-actions .ghost-link,.auth-actions button,.auth-actions form,.stack-form button{width:-moz-fit-content;width:fit-content}}.mobile-header{position:sticky;top:0;z-index:30;display:flex;align-items:center;justify-content:space-between;gap:.75rem;border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.95);padding:.75rem 1rem;--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}@media (min-width:1024px){.mobile-header{display:none}}.mobile-menu{position:relative;flex-shrink:0}@media (min-width:1024px){.mobile-menu{display:none}}.mobile-menu-button{display:flex;width:2.5rem;height:2.5rem;cursor:pointer;list-style-type:none;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.mobile-menu-button:hover{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1))}.mobile-menu-button::-webkit-details-marker{display:none}.mobile-menu-icon{height:1.25rem;width:1.25rem}.mobile-menu-icon-close,.mobile-menu[open] .mobile-menu-icon-open{display:none}.mobile-menu[open] .mobile-menu-icon-close{display:block}.mobile-menu-panel{position:absolute;right:0;top:3rem;z-index:30;display:grid;max-height:calc(100vh - 6rem);width:min(22rem,calc(100vw - 2rem));gap:.75rem;overflow-y:auto;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:.75rem;--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.mobile-nav{display:grid;gap:.25rem}.mobile-nav-link{display:grid;min-height:2.75rem;border-radius:.375rem;border-width:1px;border-color:transparent;padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1));text-decoration-line:none;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.mobile-nav-link:hover{--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.mobile-nav-link small{margin-top:.125rem;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1));letter-spacing:0}.mobile-nav-link.is-active{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.mobile-nav-link.is-active small{--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.mobile-menu-note{display:flex;align-items:center;gap:.5rem;border-radius:.375rem;border-width:1px;border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));padding:.75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.mobile-menu-logout button{width:100%}.brand-lockup,.sidebar-brand{display:inline-flex;min-width:0;align-items:center;gap:.75rem;border-radius:.375rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));text-decoration-line:none}.brand-logo{height:2.5rem;width:2.5rem;flex-shrink:0;-o-object-fit:contain;object-fit:contain}.brand-copy,.sidebar-brand span{display:grid;min-width:0;gap:.125rem}.brand-title,.sidebar-title{font-size:1.125rem;font-weight:600;line-height:1.5rem;color:rgb(248 244 255/var(--tw-text-opacity,1))}.brand-subtitle,.brand-title,.sidebar-kicker,.sidebar-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;--tw-text-opacity:1}.brand-subtitle,.sidebar-kicker{font-size:.75rem;line-height:1rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1))}.admin-sidebar{display:none;min-height:100vh;flex-direction:column;gap:1.5rem;border-right-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:1.25rem 1rem}@media (min-width:1024px){.admin-sidebar{position:sticky;top:0;display:flex;height:100vh}}.sidebar-brand{padding-left:.25rem;padding-right:.25rem}.sidebar-logo{height:2.75rem;width:2.75rem;flex-shrink:0;-o-object-fit:contain;object-fit:contain}.sidebar-nav{display:grid;flex:1 1 0%;align-content:flex-start;gap:.5rem}.nav-link{border-radius:.5rem;border-width:1px;border-color:transparent;padding:.75rem;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.nav-link-main{display:flex;align-items:center;gap:.5rem;font-weight:500}.nav-link small{margin-top:.25rem;display:block;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1));letter-spacing:0}.nav-link.is-active{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));--tw-shadow:0 10px 30px rgba(16,5,31,.18);--tw-shadow-colored:0 10px 30px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.nav-link.is-active small{--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.nav-link.is-disabled{background-color:transparent}.sidebar-note{margin-top:auto;display:flex;align-items:center;gap:.5rem;border-radius:.5rem;border-width:1px;border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));padding:.75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dashboard-main{margin-left:auto;margin-right:auto;width:100%;max-width:80rem;padding:1.25rem 1rem}@media (min-width:640px){.dashboard-main{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:1024px){.dashboard-main{padding:1.5rem 2rem}}.content-section,.metric-card,.page-header{border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.page-header{display:flex;flex-direction:column;gap:1rem;padding:1.25rem}@media (min-width:640px){.page-header{flex-direction:row;align-items:flex-start;justify-content:space-between;padding:1.5rem}}.page-lead{margin-top:.75rem;max-width:48rem;line-height:1.5rem;color:rgb(229 216 255/var(--tw-text-opacity,1))}.eyebrow,.page-lead{font-size:.875rem;--tw-text-opacity:1}.eyebrow{margin-bottom:.5rem;line-height:1.25rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.desktop-logout{display:none;flex-shrink:0}@media (min-width:1024px){.desktop-logout{display:block}}.ghost-button{border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgb(50 28 85/var(--tw-bg-opacity,1));color:rgb(229 216 255/var(--tw-text-opacity,1))}.ghost-button,.ghost-button:hover{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.ghost-button:hover{border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));color:rgb(248 244 255/var(--tw-text-opacity,1))}.inline-status{margin-top:1rem;border-radius:.375rem;border-width:1px;padding:.75rem;font-size:.875rem;font-weight:500;line-height:1.5rem}.inline-status.success{border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.inline-status.danger{border-color:rgba(251,113,133,.4);--tw-bg-opacity:1;background-color:rgb(63 11 22/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.metric-grid{margin-top:1.25rem;display:grid;gap:1rem}@media (min-width:640px){.metric-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.metric-card{display:grid;min-height:5rem;grid-template-columns:auto minmax(0,1fr);align-items:flex-start;gap:.75rem;padding:.75rem 1rem}.metric-card dt{font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.metric-card dd{margin-top:.25rem;overflow-wrap:break-word;font-size:1.25rem;line-height:1.75rem;font-weight:600;line-height:1.25;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.metric-description{margin-top:.25rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.metric-icon{margin-top:.25rem;height:.75rem;width:.75rem;border-radius:9999px;--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.metric-card.ok .metric-icon{--tw-bg-opacity:1;background-color:rgb(52 211 153/var(--tw-bg-opacity,1))}.metric-card.neutral .metric-icon{--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.metric-card.warn .metric-icon{--tw-bg-opacity:1;background-color:rgb(251 191 36/var(--tw-bg-opacity,1))}.metric-card.danger .metric-icon{--tw-bg-opacity:1;background-color:rgb(251 113 133/var(--tw-bg-opacity,1))}.workspace-grid{margin-top:1.25rem;display:grid;gap:1.25rem}@media (min-width:1280px){.workspace-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.workspace-grid.single-column{grid-template-columns:repeat(1,minmax(0,1fr))}}.overview-grid,.settings-grid{margin-top:1.25rem;display:grid;gap:1.25rem}@media (min-width:1024px){.overview-grid,.settings-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.settings-wide{grid-column:span 2/span 2}}.config-groups{display:grid;gap:1.25rem}.config-group{display:grid;gap:.75rem}.config-group h3{font-size:.875rem;line-height:1.25rem;font-weight:600;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.content-section{padding:1.25rem}@media (min-width:640px){.content-section{padding:1.5rem}}.accounts-section{min-width:0}@media (min-width:1280px){.accounts-section{grid-column:span 2/span 2}}.section-header{margin-bottom:1rem;display:flex;flex-direction:column;gap:.75rem}@media (min-width:640px){.section-header{flex-direction:row;align-items:flex-start;justify-content:space-between;gap:1rem}}.section-icon{height:2.25rem;width:2.25rem;flex-shrink:0;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));padding:.5rem;--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}.section-icon.warning{border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.status-badge{display:inline-flex;width:-moz-fit-content;width:fit-content;flex-shrink:0;align-items:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding:.25rem .5rem;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.status-badge.current{margin-left:.5rem;border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.toolbar-form{margin-bottom:1.25rem;display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.toolbar-form{grid-template-columns:minmax(0,1fr) auto auto}}.account-list>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse));--tw-divide-opacity:1;border-color:rgb(91 59 137/var(--tw-divide-opacity,1))}.fixed-list{min-height:0}@media (min-width:640px){.fixed-list{min-height:42rem}}.account-row{padding-top:1.25rem;padding-bottom:1.25rem}.account-row:first-child{padding-top:0}.account-row:last-child{padding-bottom:0}.account-record{display:grid;gap:1rem}@media (min-width:1024px){.account-record{grid-template-columns:minmax(0,1fr) minmax(19rem,28rem);align-items:flex-start}}.account-summary{display:grid;gap:.75rem}@media (min-width:640px){.account-summary{grid-template-columns:auto minmax(0,1fr);align-items:flex-start}}.account-avatar{display:grid;height:2.75rem;min-width:2.75rem;place-items:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding-left:.5rem;padding-right:.5rem;font-size:.75rem;line-height:1rem;font-weight:600;text-transform:uppercase;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.account-copy{min-width:0}.account-copy h3{overflow-wrap:break-word}.metadata-row{margin-top:.75rem;display:grid;-moz-column-gap:1.5rem;column-gap:1.5rem;row-gap:.75rem}@media (min-width:640px){.metadata-row{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1536px){.metadata-row{grid-template-columns:repeat(3,minmax(0,1fr))}}.metadata-row dt{font-size:.75rem;line-height:1rem;font-weight:500;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.metadata-row dd{margin-top:.25rem;overflow-wrap:break-word;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.account-actions{display:grid;gap:.75rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1rem}@media (min-width:1024px){.account-actions{border-left-width:1px;border-top-width:0;padding-left:1.25rem;padding-top:0}}.account-password-form{display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.account-password-form{grid-template-columns:minmax(0,1fr) auto}}.account-password-form button,.toolbar-form .ghost-link,.toolbar-form button{width:100%}@media (min-width:640px){.account-password-form button,.toolbar-form .ghost-link,.toolbar-form button{width:-moz-fit-content;width:fit-content}}.account-secondary-actions{display:flex;flex-wrap:wrap;gap:.75rem}.account-secondary-actions button,.account-secondary-actions form{width:100%}@media (min-width:640px){.account-secondary-actions button,.account-secondary-actions form{width:-moz-fit-content;width:fit-content}}.account-danger-zone{display:grid;gap:.75rem;border-radius:.375rem;border-width:1px;border-color:rgba(251,113,133,.4);background-color:rgba(63,11,22,.4);padding:.75rem}.account-danger-title{font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.account-danger-copy{margin-top:.25rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.danger-button{border-color:rgba(251,113,133,.5);--tw-bg-opacity:1;background-color:rgb(63 11 22/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.danger-button:hover{--tw-border-opacity:1;border-color:rgb(251 113 133/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(251 113 133/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(18 7 31/var(--tw-text-opacity,1))}.stack-form{margin-top:1.25rem;display:grid;gap:1rem}.inline-form{display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.inline-form{grid-template-columns:minmax(0,1fr) auto}}.compact-form{max-width:42rem}.pager{margin-top:1.25rem;display:grid;gap:.75rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1rem}@media (min-width:640px){.pager{display:flex;align-items:center;justify-content:space-between}}.pager-current{text-align:center;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}@media (min-width:640px){.pager-current{text-align:left}}.pager .ghost-link{width:100%}@media (min-width:640px){.pager .ghost-link{width:-moz-fit-content;width:fit-content}}.ghost-link{display:inline-flex;min-height:2.5rem;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:600;color:rgb(229 216 255/var(--tw-text-opacity,1))}.ghost-link,.ghost-link:hover{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.ghost-link:hover{border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));color:rgb(248 244 255/var(--tw-text-opacity,1))}.ghost-link.is-disabled{cursor:default;opacity:.5}.detail-list{display:grid;gap:1rem}.totp-enrollment{margin-top:1rem;display:grid;gap:1rem;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.5);padding:1rem}@media (min-width:640px){.totp-enrollment{grid-template-columns:auto minmax(0,1fr);align-items:flex-start}}.qr-code{display:grid;width:-moz-fit-content;width:fit-content;gap:.125rem;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(248 244 255/var(--tw-bg-opacity,1));padding:.5rem}.qr-row{display:flex;gap:.125rem}.qr-cell{display:block;height:.375rem;width:.375rem;--tw-bg-opacity:1;background-color:rgb(248 244 255/var(--tw-bg-opacity,1))}.qr-cell.is-dark{--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1))}.settings-factor-panel{margin-top:1.25rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1.25rem}.secret-value,.uri-value{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.uri-value{word-break:break-all}.config-item dt,.detail-list dt{font-size:.75rem;line-height:1rem;font-weight:500;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.config-item dd,.detail-list dd{margin-top:.25rem;overflow-wrap:break-word;font-size:.875rem;font-weight:600;line-height:1.25rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.config-grid{display:grid;gap:.75rem}@media (min-width:640px){.config-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1024px){.config-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.config-item{min-height:6rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.4);padding:1rem}.config-item.ok{border-color:rgba(52,211,153,.4)}.config-item.warn{border-color:rgba(251,191,36,.4)}.config-item.danger{border-color:rgba(251,113,133,.4)}.config-item p{margin-top:.5rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.muted{font-size:.875rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.boundary-section{border-color:rgba(251,191,36,.4)}.boundary-list{margin:0;list-style-type:disc;padding-left:1.25rem;font-size:.875rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.boundary-list li+li{margin-top:.5rem}.icon{height:1rem;width:1rem;flex-shrink:0}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.hidden{display:none} \ No newline at end of file diff --git a/internal/httpapi/web/admin/tailwind.css b/internal/httpapi/web/admin/tailwind.css index 2a8b1ba..fb6609d 100644 --- a/internal/httpapi/web/admin/tailwind.css +++ b/internal/httpapi/web/admin/tailwind.css @@ -428,6 +428,19 @@ @apply lg:col-span-2; } + .config-groups { + @apply grid gap-5; + } + + .config-group { + @apply grid gap-3; + } + + .config-group h3 { + @apply text-sm font-semibold uppercase text-proofline-text-muted; + letter-spacing: 0; + } + .content-section { @apply p-5 sm:p-6; } @@ -593,7 +606,27 @@ } .totp-enrollment { - @apply mt-4 rounded-md border border-proofline-border bg-proofline-bg-deep/50 p-4; + @apply mt-4 grid gap-4 rounded-md border border-proofline-border bg-proofline-bg-deep/50 p-4 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-start; + } + + .qr-code { + @apply grid w-fit gap-0.5 rounded-md border border-proofline-border bg-proofline-text p-2; + } + + .qr-row { + @apply flex gap-0.5; + } + + .qr-cell { + @apply block h-1.5 w-1.5 bg-proofline-text; + } + + .qr-cell.is-dark { + @apply bg-proofline-bg-deep; + } + + .settings-factor-panel { + @apply mt-5 border-t border-proofline-border pt-5; } .secret-value, diff --git a/internal/httpapi/web/templates/admin.html b/internal/httpapi/web/templates/admin.html index 36f1f2b..ebffdbb 100644 --- a/internal/httpapi/web/templates/admin.html +++ b/internal/httpapi/web/templates/admin.html @@ -522,6 +522,55 @@

Second Factor

{{end}}
+
+
+
+

Authenticator app

+

TOTP is the recommended fallback provider when WebAuthn/FIDO2 is not configured.

+
+ {{if .SecondFactorWebAuthnAvailable}}Recommended fallback{{else}}Recommended{{end}} +
+ {{if .SecondFactorTOTPActive}} +

Authenticator-app TOTP is configured for this admin account.

+ {{else if .SecondFactorTOTPEnrollment.Secret}} +
+ +
+
+
Manual setup key
+
{{.SecondFactorTOTPEnrollment.Secret}}
+
+
+
OTPAuth URI
+
{{.SecondFactorTOTPEnrollment.OTPAuthURL}}
+
+
+
Policy
+
{{.SecondFactorTOTPEnrollment.Digits}} digits, {{.SecondFactorTOTPEnrollment.PeriodSeconds}} seconds, {{.SecondFactorTOTPEnrollment.Algorithm}}
+
+
+
+
+ + + +
+ {{else}} +
+ + +
+ {{end}} +
@@ -531,17 +580,24 @@

Second Factor

Configuration

-
- {{range .ConfigItems}} -
-
-
-
{{.Label}}
-
{{.Value}}
+
+ {{range .ConfigGroups}} +
+

{{.Label}}

+
+ {{range .Items}} +
+
+
+
{{.Label}}
+
{{.Value}}
+
+
+ {{if .Description}}

{{.Description}}

{{end}}
-
- {{if .Description}}

{{.Description}}

{{end}} -
+ {{end}} +
+
{{end}} @@ -648,6 +704,13 @@

Authenticator app

Use a TOTP authenticator app for admin access. This works without WebAuthn/FIDO2 configuration.

{{if .SecondFactorTOTPEnrollment.Secret}}
+
Manual setup key
From ee576ba785b65f4e7dbf8a36cf5306e925a4dd49 Mon Sep 17 00:00:00 2001 From: Ellie Melton Date: Sun, 14 Jun 2026 03:59:36 +1000 Subject: [PATCH 10/10] Fix admin TOTP enrollment wrapping --- internal/httpapi/web/admin/static/styles.css | 2 +- internal/httpapi/web/admin/tailwind.css | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/httpapi/web/admin/static/styles.css b/internal/httpapi/web/admin/static/styles.css index 36657c8..0725919 100644 --- a/internal/httpapi/web/admin/static/styles.css +++ b/internal/httpapi/web/admin/static/styles.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}html{min-width:320px;background:#1a0c2e}body{margin:0;min-height:100vh;--tw-bg-opacity:1;background-color:rgb(26 12 46/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}dd,dl,h1,h2,h3,p{margin:0}h1{font-size:1.5rem;line-height:2rem;font-weight:600;line-height:1.25;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));letter-spacing:0}h2{font-size:1.125rem}h2,h3{font-weight:600;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));letter-spacing:0}h3{font-size:1rem}button{display:inline-flex;min-height:2.75rem;width:-moz-fit-content;width:fit-content;cursor:pointer;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(167 139 250/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(167 139 250/var(--tw-bg-opacity,1));padding:.625rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgb(18 7 31/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;letter-spacing:0}button:hover{--tw-border-opacity:1;border-color:rgb(196 181 253/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(196 181 253/var(--tw-bg-opacity,1))}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:#c084fc}label{display:grid;gap:.5rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}input,select{min-height:2.75rem;width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:.625rem .75rem;font-size:1rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;font:inherit}input:hover,select:hover{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1))}input::-moz-placeholder{--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}input::placeholder{--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.skip-link{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.skip-link:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto;white-space:normal;position:fixed;left:1rem;top:1rem;z-index:50;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:500}.admin-shell,.skip-link:focus{--tw-bg-opacity:1;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.admin-shell{min-height:100vh;background-color:rgb(26 12 46/var(--tw-bg-opacity,1))}@media (min-width:1024px){.admin-shell[data-admin-mode=accounts],.admin-shell[data-admin-mode=dashboard],.admin-shell[data-admin-mode=incidents],.admin-shell[data-admin-mode=settings]{display:grid;grid-template-columns:248px minmax(0,1fr)}}.auth-shell{display:grid;place-items:center;padding:2.5rem 1rem}@media (min-width:640px){.auth-shell{padding-left:1.5rem;padding-right:1.5rem}}.auth-card{width:100%;max-width:28rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));padding:1.25rem;--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.auth-card{padding:1.5rem}}.auth-brand{display:flex;align-items:flex-start;gap:.75rem}.auth-logo{height:4rem;width:4rem;flex-shrink:0;--tw-scale-x:1.25;--tw-scale-y:1.25;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));-o-object-fit:contain;object-fit:contain}.auth-kicker{line-height:1.25rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1))}.auth-kicker,.auth-lead{font-size:.875rem;--tw-text-opacity:1}.auth-lead{line-height:1.5rem;color:rgb(229 216 255/var(--tw-text-opacity,1))}.auth-card .boundary-list,.auth-lead{margin-top:1rem}.auth-methods{margin-top:1.25rem;display:grid;gap:1.25rem}.auth-method{border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1.25rem}.auth-method:first-child{border-top-width:0;padding-top:0}.auth-method h2{font-size:1rem;font-weight:600;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.method-heading{display:flex;flex-direction:column;gap:.5rem}@media (min-width:640px){.method-heading{flex-direction:row;align-items:center;justify-content:space-between}}.method-badge{display:inline-flex;width:-moz-fit-content;width:fit-content;border-radius:.375rem;border-width:1px;padding:.25rem .5rem;font-size:.75rem;line-height:1rem;font-weight:600}.method-badge.recommended{border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.method-badge.backup{border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.auth-actions{margin-top:1.25rem;display:flex;flex-wrap:wrap;align-items:center;gap:.75rem}.auth-actions .ghost-link,.auth-actions button,.auth-actions form,.stack-form button{width:100%}@media (min-width:640px){.auth-actions .ghost-link,.auth-actions button,.auth-actions form,.stack-form button{width:-moz-fit-content;width:fit-content}}.mobile-header{position:sticky;top:0;z-index:30;display:flex;align-items:center;justify-content:space-between;gap:.75rem;border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.95);padding:.75rem 1rem;--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}@media (min-width:1024px){.mobile-header{display:none}}.mobile-menu{position:relative;flex-shrink:0}@media (min-width:1024px){.mobile-menu{display:none}}.mobile-menu-button{display:flex;width:2.5rem;height:2.5rem;cursor:pointer;list-style-type:none;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.mobile-menu-button:hover{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1))}.mobile-menu-button::-webkit-details-marker{display:none}.mobile-menu-icon{height:1.25rem;width:1.25rem}.mobile-menu-icon-close,.mobile-menu[open] .mobile-menu-icon-open{display:none}.mobile-menu[open] .mobile-menu-icon-close{display:block}.mobile-menu-panel{position:absolute;right:0;top:3rem;z-index:30;display:grid;max-height:calc(100vh - 6rem);width:min(22rem,calc(100vw - 2rem));gap:.75rem;overflow-y:auto;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:.75rem;--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.mobile-nav{display:grid;gap:.25rem}.mobile-nav-link{display:grid;min-height:2.75rem;border-radius:.375rem;border-width:1px;border-color:transparent;padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1));text-decoration-line:none;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.mobile-nav-link:hover{--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.mobile-nav-link small{margin-top:.125rem;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1));letter-spacing:0}.mobile-nav-link.is-active{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.mobile-nav-link.is-active small{--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.mobile-menu-note{display:flex;align-items:center;gap:.5rem;border-radius:.375rem;border-width:1px;border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));padding:.75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.mobile-menu-logout button{width:100%}.brand-lockup,.sidebar-brand{display:inline-flex;min-width:0;align-items:center;gap:.75rem;border-radius:.375rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));text-decoration-line:none}.brand-logo{height:2.5rem;width:2.5rem;flex-shrink:0;-o-object-fit:contain;object-fit:contain}.brand-copy,.sidebar-brand span{display:grid;min-width:0;gap:.125rem}.brand-title,.sidebar-title{font-size:1.125rem;font-weight:600;line-height:1.5rem;color:rgb(248 244 255/var(--tw-text-opacity,1))}.brand-subtitle,.brand-title,.sidebar-kicker,.sidebar-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;--tw-text-opacity:1}.brand-subtitle,.sidebar-kicker{font-size:.75rem;line-height:1rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1))}.admin-sidebar{display:none;min-height:100vh;flex-direction:column;gap:1.5rem;border-right-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:1.25rem 1rem}@media (min-width:1024px){.admin-sidebar{position:sticky;top:0;display:flex;height:100vh}}.sidebar-brand{padding-left:.25rem;padding-right:.25rem}.sidebar-logo{height:2.75rem;width:2.75rem;flex-shrink:0;-o-object-fit:contain;object-fit:contain}.sidebar-nav{display:grid;flex:1 1 0%;align-content:flex-start;gap:.5rem}.nav-link{border-radius:.5rem;border-width:1px;border-color:transparent;padding:.75rem;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.nav-link-main{display:flex;align-items:center;gap:.5rem;font-weight:500}.nav-link small{margin-top:.25rem;display:block;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1));letter-spacing:0}.nav-link.is-active{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));--tw-shadow:0 10px 30px rgba(16,5,31,.18);--tw-shadow-colored:0 10px 30px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.nav-link.is-active small{--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.nav-link.is-disabled{background-color:transparent}.sidebar-note{margin-top:auto;display:flex;align-items:center;gap:.5rem;border-radius:.5rem;border-width:1px;border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));padding:.75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dashboard-main{margin-left:auto;margin-right:auto;width:100%;max-width:80rem;padding:1.25rem 1rem}@media (min-width:640px){.dashboard-main{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:1024px){.dashboard-main{padding:1.5rem 2rem}}.content-section,.metric-card,.page-header{border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.page-header{display:flex;flex-direction:column;gap:1rem;padding:1.25rem}@media (min-width:640px){.page-header{flex-direction:row;align-items:flex-start;justify-content:space-between;padding:1.5rem}}.page-lead{margin-top:.75rem;max-width:48rem;line-height:1.5rem;color:rgb(229 216 255/var(--tw-text-opacity,1))}.eyebrow,.page-lead{font-size:.875rem;--tw-text-opacity:1}.eyebrow{margin-bottom:.5rem;line-height:1.25rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.desktop-logout{display:none;flex-shrink:0}@media (min-width:1024px){.desktop-logout{display:block}}.ghost-button{border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgb(50 28 85/var(--tw-bg-opacity,1));color:rgb(229 216 255/var(--tw-text-opacity,1))}.ghost-button,.ghost-button:hover{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.ghost-button:hover{border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));color:rgb(248 244 255/var(--tw-text-opacity,1))}.inline-status{margin-top:1rem;border-radius:.375rem;border-width:1px;padding:.75rem;font-size:.875rem;font-weight:500;line-height:1.5rem}.inline-status.success{border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.inline-status.danger{border-color:rgba(251,113,133,.4);--tw-bg-opacity:1;background-color:rgb(63 11 22/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.metric-grid{margin-top:1.25rem;display:grid;gap:1rem}@media (min-width:640px){.metric-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.metric-card{display:grid;min-height:5rem;grid-template-columns:auto minmax(0,1fr);align-items:flex-start;gap:.75rem;padding:.75rem 1rem}.metric-card dt{font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.metric-card dd{margin-top:.25rem;overflow-wrap:break-word;font-size:1.25rem;line-height:1.75rem;font-weight:600;line-height:1.25;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.metric-description{margin-top:.25rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.metric-icon{margin-top:.25rem;height:.75rem;width:.75rem;border-radius:9999px;--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.metric-card.ok .metric-icon{--tw-bg-opacity:1;background-color:rgb(52 211 153/var(--tw-bg-opacity,1))}.metric-card.neutral .metric-icon{--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.metric-card.warn .metric-icon{--tw-bg-opacity:1;background-color:rgb(251 191 36/var(--tw-bg-opacity,1))}.metric-card.danger .metric-icon{--tw-bg-opacity:1;background-color:rgb(251 113 133/var(--tw-bg-opacity,1))}.workspace-grid{margin-top:1.25rem;display:grid;gap:1.25rem}@media (min-width:1280px){.workspace-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.workspace-grid.single-column{grid-template-columns:repeat(1,minmax(0,1fr))}}.overview-grid,.settings-grid{margin-top:1.25rem;display:grid;gap:1.25rem}@media (min-width:1024px){.overview-grid,.settings-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.settings-wide{grid-column:span 2/span 2}}.config-groups{display:grid;gap:1.25rem}.config-group{display:grid;gap:.75rem}.config-group h3{font-size:.875rem;line-height:1.25rem;font-weight:600;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.content-section{padding:1.25rem}@media (min-width:640px){.content-section{padding:1.5rem}}.accounts-section{min-width:0}@media (min-width:1280px){.accounts-section{grid-column:span 2/span 2}}.section-header{margin-bottom:1rem;display:flex;flex-direction:column;gap:.75rem}@media (min-width:640px){.section-header{flex-direction:row;align-items:flex-start;justify-content:space-between;gap:1rem}}.section-icon{height:2.25rem;width:2.25rem;flex-shrink:0;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));padding:.5rem;--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}.section-icon.warning{border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.status-badge{display:inline-flex;width:-moz-fit-content;width:fit-content;flex-shrink:0;align-items:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding:.25rem .5rem;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.status-badge.current{margin-left:.5rem;border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.toolbar-form{margin-bottom:1.25rem;display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.toolbar-form{grid-template-columns:minmax(0,1fr) auto auto}}.account-list>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse));--tw-divide-opacity:1;border-color:rgb(91 59 137/var(--tw-divide-opacity,1))}.fixed-list{min-height:0}@media (min-width:640px){.fixed-list{min-height:42rem}}.account-row{padding-top:1.25rem;padding-bottom:1.25rem}.account-row:first-child{padding-top:0}.account-row:last-child{padding-bottom:0}.account-record{display:grid;gap:1rem}@media (min-width:1024px){.account-record{grid-template-columns:minmax(0,1fr) minmax(19rem,28rem);align-items:flex-start}}.account-summary{display:grid;gap:.75rem}@media (min-width:640px){.account-summary{grid-template-columns:auto minmax(0,1fr);align-items:flex-start}}.account-avatar{display:grid;height:2.75rem;min-width:2.75rem;place-items:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding-left:.5rem;padding-right:.5rem;font-size:.75rem;line-height:1rem;font-weight:600;text-transform:uppercase;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.account-copy{min-width:0}.account-copy h3{overflow-wrap:break-word}.metadata-row{margin-top:.75rem;display:grid;-moz-column-gap:1.5rem;column-gap:1.5rem;row-gap:.75rem}@media (min-width:640px){.metadata-row{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1536px){.metadata-row{grid-template-columns:repeat(3,minmax(0,1fr))}}.metadata-row dt{font-size:.75rem;line-height:1rem;font-weight:500;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.metadata-row dd{margin-top:.25rem;overflow-wrap:break-word;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.account-actions{display:grid;gap:.75rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1rem}@media (min-width:1024px){.account-actions{border-left-width:1px;border-top-width:0;padding-left:1.25rem;padding-top:0}}.account-password-form{display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.account-password-form{grid-template-columns:minmax(0,1fr) auto}}.account-password-form button,.toolbar-form .ghost-link,.toolbar-form button{width:100%}@media (min-width:640px){.account-password-form button,.toolbar-form .ghost-link,.toolbar-form button{width:-moz-fit-content;width:fit-content}}.account-secondary-actions{display:flex;flex-wrap:wrap;gap:.75rem}.account-secondary-actions button,.account-secondary-actions form{width:100%}@media (min-width:640px){.account-secondary-actions button,.account-secondary-actions form{width:-moz-fit-content;width:fit-content}}.account-danger-zone{display:grid;gap:.75rem;border-radius:.375rem;border-width:1px;border-color:rgba(251,113,133,.4);background-color:rgba(63,11,22,.4);padding:.75rem}.account-danger-title{font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.account-danger-copy{margin-top:.25rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.danger-button{border-color:rgba(251,113,133,.5);--tw-bg-opacity:1;background-color:rgb(63 11 22/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.danger-button:hover{--tw-border-opacity:1;border-color:rgb(251 113 133/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(251 113 133/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(18 7 31/var(--tw-text-opacity,1))}.stack-form{margin-top:1.25rem;display:grid;gap:1rem}.inline-form{display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.inline-form{grid-template-columns:minmax(0,1fr) auto}}.compact-form{max-width:42rem}.pager{margin-top:1.25rem;display:grid;gap:.75rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1rem}@media (min-width:640px){.pager{display:flex;align-items:center;justify-content:space-between}}.pager-current{text-align:center;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}@media (min-width:640px){.pager-current{text-align:left}}.pager .ghost-link{width:100%}@media (min-width:640px){.pager .ghost-link{width:-moz-fit-content;width:fit-content}}.ghost-link{display:inline-flex;min-height:2.5rem;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:600;color:rgb(229 216 255/var(--tw-text-opacity,1))}.ghost-link,.ghost-link:hover{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.ghost-link:hover{border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));color:rgb(248 244 255/var(--tw-text-opacity,1))}.ghost-link.is-disabled{cursor:default;opacity:.5}.detail-list{display:grid;gap:1rem}.totp-enrollment{margin-top:1rem;display:grid;gap:1rem;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.5);padding:1rem}@media (min-width:640px){.totp-enrollment{grid-template-columns:auto minmax(0,1fr);align-items:flex-start}}.qr-code{display:grid;width:-moz-fit-content;width:fit-content;gap:.125rem;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(248 244 255/var(--tw-bg-opacity,1));padding:.5rem}.qr-row{display:flex;gap:.125rem}.qr-cell{display:block;height:.375rem;width:.375rem;--tw-bg-opacity:1;background-color:rgb(248 244 255/var(--tw-bg-opacity,1))}.qr-cell.is-dark{--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1))}.settings-factor-panel{margin-top:1.25rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1.25rem}.secret-value,.uri-value{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.uri-value{word-break:break-all}.config-item dt,.detail-list dt{font-size:.75rem;line-height:1rem;font-weight:500;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.config-item dd,.detail-list dd{margin-top:.25rem;overflow-wrap:break-word;font-size:.875rem;font-weight:600;line-height:1.25rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.config-grid{display:grid;gap:.75rem}@media (min-width:640px){.config-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1024px){.config-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.config-item{min-height:6rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.4);padding:1rem}.config-item.ok{border-color:rgba(52,211,153,.4)}.config-item.warn{border-color:rgba(251,191,36,.4)}.config-item.danger{border-color:rgba(251,113,133,.4)}.config-item p{margin-top:.5rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.muted{font-size:.875rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.boundary-section{border-color:rgba(251,191,36,.4)}.boundary-list{margin:0;list-style-type:disc;padding-left:1.25rem;font-size:.875rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.boundary-list li+li{margin-top:.5rem}.icon{height:1rem;width:1rem;flex-shrink:0}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.hidden{display:none} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}*{box-sizing:border-box}html{min-width:320px;background:#1a0c2e}body{margin:0;min-height:100vh;--tw-bg-opacity:1;background-color:rgb(26 12 46/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}dd,dl,h1,h2,h3,p{margin:0}h1{font-size:1.5rem;line-height:2rem;font-weight:600;line-height:1.25;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));letter-spacing:0}h2{font-size:1.125rem}h2,h3{font-weight:600;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));letter-spacing:0}h3{font-size:1rem}button{display:inline-flex;min-height:2.75rem;width:-moz-fit-content;width:fit-content;cursor:pointer;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(167 139 250/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(167 139 250/var(--tw-bg-opacity,1));padding:.625rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgb(18 7 31/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;letter-spacing:0}button:hover{--tw-border-opacity:1;border-color:rgb(196 181 253/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(196 181 253/var(--tw-bg-opacity,1))}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:#c084fc}label{display:grid;gap:.5rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}input,select{min-height:2.75rem;width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:.625rem .75rem;font-size:1rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;font:inherit}input:hover,select:hover{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1))}input::-moz-placeholder{--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}input::placeholder{--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.skip-link{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.skip-link:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto;white-space:normal;position:fixed;left:1rem;top:1rem;z-index:50;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;font-weight:500}.admin-shell,.skip-link:focus{--tw-bg-opacity:1;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.admin-shell{min-height:100vh;background-color:rgb(26 12 46/var(--tw-bg-opacity,1))}@media (min-width:1024px){.admin-shell[data-admin-mode=accounts],.admin-shell[data-admin-mode=dashboard],.admin-shell[data-admin-mode=incidents],.admin-shell[data-admin-mode=settings]{display:grid;grid-template-columns:248px minmax(0,1fr)}}.auth-shell{display:grid;place-items:center;padding:2.5rem 1rem}@media (min-width:640px){.auth-shell{padding-left:1.5rem;padding-right:1.5rem}}.auth-card{width:100%;max-width:28rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));padding:1.25rem;--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.auth-card{padding:1.5rem}}.auth-brand{display:flex;align-items:flex-start;gap:.75rem}.auth-logo{height:4rem;width:4rem;flex-shrink:0;--tw-scale-x:1.25;--tw-scale-y:1.25;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));-o-object-fit:contain;object-fit:contain}.auth-kicker{line-height:1.25rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1))}.auth-kicker,.auth-lead{font-size:.875rem;--tw-text-opacity:1}.auth-lead{line-height:1.5rem;color:rgb(229 216 255/var(--tw-text-opacity,1))}.auth-card .boundary-list,.auth-lead{margin-top:1rem}.auth-methods{margin-top:1.25rem;display:grid;gap:1.25rem}.auth-method{border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1.25rem}.auth-method:first-child{border-top-width:0;padding-top:0}.auth-method h2{font-size:1rem;font-weight:600;line-height:1.5rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.method-heading{display:flex;flex-direction:column;gap:.5rem}@media (min-width:640px){.method-heading{flex-direction:row;align-items:center;justify-content:space-between}}.method-badge{display:inline-flex;width:-moz-fit-content;width:fit-content;border-radius:.375rem;border-width:1px;padding:.25rem .5rem;font-size:.75rem;line-height:1rem;font-weight:600}.method-badge.recommended{border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.method-badge.backup{border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.auth-actions{margin-top:1.25rem;display:flex;flex-wrap:wrap;align-items:center;gap:.75rem}.auth-actions .ghost-link,.auth-actions button,.auth-actions form,.stack-form button{width:100%}@media (min-width:640px){.auth-actions .ghost-link,.auth-actions button,.auth-actions form,.stack-form button{width:-moz-fit-content;width:fit-content}}.mobile-header{position:sticky;top:0;z-index:30;display:flex;align-items:center;justify-content:space-between;gap:.75rem;border-bottom-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.95);padding:.75rem 1rem;--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}@media (min-width:1024px){.mobile-header{display:none}}.mobile-menu{position:relative;flex-shrink:0}@media (min-width:1024px){.mobile-menu{display:none}}.mobile-menu-button{display:flex;width:2.5rem;height:2.5rem;cursor:pointer;list-style-type:none;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.mobile-menu-button:hover{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1))}.mobile-menu-button::-webkit-details-marker{display:none}.mobile-menu-icon{height:1.25rem;width:1.25rem}.mobile-menu-icon-close,.mobile-menu[open] .mobile-menu-icon-open{display:none}.mobile-menu[open] .mobile-menu-icon-close{display:block}.mobile-menu-panel{position:absolute;right:0;top:3rem;z-index:30;display:grid;max-height:calc(100vh - 6rem);width:min(22rem,calc(100vw - 2rem));gap:.75rem;overflow-y:auto;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:.75rem;--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.mobile-nav{display:grid;gap:.25rem}.mobile-nav-link{display:grid;min-height:2.75rem;border-radius:.375rem;border-width:1px;border-color:transparent;padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1));text-decoration-line:none;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.mobile-nav-link:hover{--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.mobile-nav-link small{margin-top:.125rem;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1));letter-spacing:0}.mobile-nav-link.is-active{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.mobile-nav-link.is-active small{--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.mobile-menu-note{display:flex;align-items:center;gap:.5rem;border-radius:.375rem;border-width:1px;border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));padding:.75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.mobile-menu-logout button{width:100%}.brand-lockup,.sidebar-brand{display:inline-flex;min-width:0;align-items:center;gap:.75rem;border-radius:.375rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));text-decoration-line:none}.brand-logo{height:2.5rem;width:2.5rem;flex-shrink:0;-o-object-fit:contain;object-fit:contain}.brand-copy,.sidebar-brand span{display:grid;min-width:0;gap:.125rem}.brand-title,.sidebar-title{font-size:1.125rem;font-weight:600;line-height:1.5rem;color:rgb(248 244 255/var(--tw-text-opacity,1))}.brand-subtitle,.brand-title,.sidebar-kicker,.sidebar-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;--tw-text-opacity:1}.brand-subtitle,.sidebar-kicker{font-size:.75rem;line-height:1rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1))}.admin-sidebar{display:none;min-height:100vh;flex-direction:column;gap:1.5rem;border-right-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1));padding:1.25rem 1rem}@media (min-width:1024px){.admin-sidebar{position:sticky;top:0;display:flex;height:100vh}}.sidebar-brand{padding-left:.25rem;padding-right:.25rem}.sidebar-logo{height:2.75rem;width:2.75rem;flex-shrink:0;-o-object-fit:contain;object-fit:contain}.sidebar-nav{display:grid;flex:1 1 0%;align-content:flex-start;gap:.5rem}.nav-link{border-radius:.5rem;border-width:1px;border-color:transparent;padding:.75rem;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.nav-link-main{display:flex;align-items:center;gap:.5rem;font-weight:500}.nav-link small{margin-top:.25rem;display:block;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1));letter-spacing:0}.nav-link.is-active{--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));--tw-shadow:0 10px 30px rgba(16,5,31,.18);--tw-shadow-colored:0 10px 30px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.nav-link.is-active small{--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.nav-link.is-disabled{background-color:transparent}.sidebar-note{margin-top:auto;display:flex;align-items:center;gap:.5rem;border-radius:.5rem;border-width:1px;border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));padding:.75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.dashboard-main{margin-left:auto;margin-right:auto;width:100%;max-width:80rem;padding:1.25rem 1rem}@media (min-width:640px){.dashboard-main{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:1024px){.dashboard-main{padding:1.5rem 2rem}}.content-section,.metric-card,.page-header{border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(37 20 63/var(--tw-bg-opacity,1));--tw-shadow:0 18px 45px rgba(16,5,31,.24);--tw-shadow-colored:0 18px 45px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.page-header{display:flex;flex-direction:column;gap:1rem;padding:1.25rem}@media (min-width:640px){.page-header{flex-direction:row;align-items:flex-start;justify-content:space-between;padding:1.5rem}}.page-lead{margin-top:.75rem;max-width:48rem;line-height:1.5rem;color:rgb(229 216 255/var(--tw-text-opacity,1))}.eyebrow,.page-lead{font-size:.875rem;--tw-text-opacity:1}.eyebrow{margin-bottom:.5rem;line-height:1.25rem;font-weight:500;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.desktop-logout{display:none;flex-shrink:0}@media (min-width:1024px){.desktop-logout{display:block}}.ghost-button{border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgb(50 28 85/var(--tw-bg-opacity,1));color:rgb(229 216 255/var(--tw-text-opacity,1))}.ghost-button,.ghost-button:hover{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.ghost-button:hover{border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));color:rgb(248 244 255/var(--tw-text-opacity,1))}.inline-status{margin-top:1rem;border-radius:.375rem;border-width:1px;padding:.75rem;font-size:.875rem;font-weight:500;line-height:1.5rem}.inline-status.success{border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.inline-status.danger{border-color:rgba(251,113,133,.4);--tw-bg-opacity:1;background-color:rgb(63 11 22/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.metric-grid{margin-top:1.25rem;display:grid;gap:1rem}@media (min-width:640px){.metric-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.metric-card{display:grid;min-height:5rem;grid-template-columns:auto minmax(0,1fr);align-items:flex-start;gap:.75rem;padding:.75rem 1rem}.metric-card dt{font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.metric-card dd{margin-top:.25rem;overflow-wrap:break-word;font-size:1.25rem;line-height:1.75rem;font-weight:600;line-height:1.25;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1))}.metric-description{margin-top:.25rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.metric-icon{margin-top:.25rem;height:.75rem;width:.75rem;border-radius:9999px;--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.metric-card.ok .metric-icon{--tw-bg-opacity:1;background-color:rgb(52 211 153/var(--tw-bg-opacity,1))}.metric-card.neutral .metric-icon{--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.metric-card.warn .metric-icon{--tw-bg-opacity:1;background-color:rgb(251 191 36/var(--tw-bg-opacity,1))}.metric-card.danger .metric-icon{--tw-bg-opacity:1;background-color:rgb(251 113 133/var(--tw-bg-opacity,1))}.workspace-grid{margin-top:1.25rem;display:grid;gap:1.25rem}@media (min-width:1280px){.workspace-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.workspace-grid.single-column{grid-template-columns:repeat(1,minmax(0,1fr))}}.overview-grid,.settings-grid{margin-top:1.25rem;display:grid;gap:1.25rem}@media (min-width:1024px){.overview-grid,.settings-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.settings-wide{grid-column:span 2/span 2}}.config-groups{display:grid;gap:1.25rem}.config-group{display:grid;gap:.75rem}.config-group h3{font-size:.875rem;line-height:1.25rem;font-weight:600;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.content-section{padding:1.25rem}@media (min-width:640px){.content-section{padding:1.5rem}}.accounts-section{min-width:0}@media (min-width:1280px){.accounts-section{grid-column:span 2/span 2}}.section-header{margin-bottom:1rem;display:flex;flex-direction:column;gap:.75rem}@media (min-width:640px){.section-header{flex-direction:row;align-items:flex-start;justify-content:space-between;gap:1rem}}.section-icon{height:2.25rem;width:2.25rem;flex-shrink:0;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(124 91 196/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(68 40 111/var(--tw-bg-opacity,1));padding:.5rem;--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}.section-icon.warning{border-color:rgba(251,191,36,.4);--tw-bg-opacity:1;background-color:rgb(58 38 3/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.status-badge{display:inline-flex;width:-moz-fit-content;width:fit-content;flex-shrink:0;align-items:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding:.25rem .5rem;font-size:.75rem;line-height:1rem;font-weight:500;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.status-badge.current{margin-left:.5rem;border-color:rgba(52,211,153,.4);--tw-bg-opacity:1;background-color:rgb(5 46 36/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.toolbar-form{margin-bottom:1.25rem;display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.toolbar-form{grid-template-columns:minmax(0,1fr) auto auto}}.account-list>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse));--tw-divide-opacity:1;border-color:rgb(91 59 137/var(--tw-divide-opacity,1))}.fixed-list{min-height:0}@media (min-width:640px){.fixed-list{min-height:42rem}}.account-row{padding-top:1.25rem;padding-bottom:1.25rem}.account-row:first-child{padding-top:0}.account-row:last-child{padding-bottom:0}.account-record{display:grid;gap:1rem}@media (min-width:1024px){.account-record{grid-template-columns:minmax(0,1fr) minmax(19rem,28rem);align-items:flex-start}}.account-summary{display:grid;gap:.75rem}@media (min-width:640px){.account-summary{grid-template-columns:auto minmax(0,1fr);align-items:flex-start}}.account-avatar{display:grid;height:2.75rem;min-width:2.75rem;place-items:center;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding-left:.5rem;padding-right:.5rem;font-size:.75rem;line-height:1rem;font-weight:600;text-transform:uppercase;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.account-copy{min-width:0}.account-copy h3{overflow-wrap:break-word}.metadata-row{margin-top:.75rem;display:grid;-moz-column-gap:1.5rem;column-gap:1.5rem;row-gap:.75rem}@media (min-width:640px){.metadata-row{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1536px){.metadata-row{grid-template-columns:repeat(3,minmax(0,1fr))}}.metadata-row dt{font-size:.75rem;line-height:1rem;font-weight:500;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.metadata-row dd{margin-top:.25rem;overflow-wrap:break-word;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.account-actions{display:grid;gap:.75rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1rem}@media (min-width:1024px){.account-actions{border-left-width:1px;border-top-width:0;padding-left:1.25rem;padding-top:0}}.account-password-form{display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.account-password-form{grid-template-columns:minmax(0,1fr) auto}}.account-password-form button,.toolbar-form .ghost-link,.toolbar-form button{width:100%}@media (min-width:640px){.account-password-form button,.toolbar-form .ghost-link,.toolbar-form button{width:-moz-fit-content;width:fit-content}}.account-secondary-actions{display:flex;flex-wrap:wrap;gap:.75rem}.account-secondary-actions button,.account-secondary-actions form{width:100%}@media (min-width:640px){.account-secondary-actions button,.account-secondary-actions form{width:-moz-fit-content;width:fit-content}}.account-danger-zone{display:grid;gap:.75rem;border-radius:.375rem;border-width:1px;border-color:rgba(251,113,133,.4);background-color:rgba(63,11,22,.4);padding:.75rem}.account-danger-title{font-size:.875rem;line-height:1.25rem;font-weight:600;--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.account-danger-copy{margin-top:.25rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.danger-button{border-color:rgba(251,113,133,.5);--tw-bg-opacity:1;background-color:rgb(63 11 22/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.danger-button:hover{--tw-border-opacity:1;border-color:rgb(251 113 133/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(251 113 133/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(18 7 31/var(--tw-text-opacity,1))}.stack-form{margin-top:1.25rem;display:grid;gap:1rem}.inline-form{display:grid;align-items:flex-end;gap:.75rem}@media (min-width:640px){.inline-form{grid-template-columns:minmax(0,1fr) auto}}.compact-form{max-width:42rem}.pager{margin-top:1.25rem;display:grid;gap:.75rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1rem}@media (min-width:640px){.pager{display:flex;align-items:center;justify-content:space-between}}.pager-current{text-align:center;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}@media (min-width:640px){.pager-current{text-align:left}}.pager .ghost-link{width:100%}@media (min-width:640px){.pager .ghost-link{width:-moz-fit-content;width:fit-content}}.ghost-link{display:inline-flex;min-height:2.5rem;align-items:center;justify-content:center;border-radius:.375rem;border-width:1px;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgb(50 28 85/var(--tw-bg-opacity,1));padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:600;color:rgb(229 216 255/var(--tw-text-opacity,1))}.ghost-link,.ghost-link:hover{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.ghost-link:hover{border-color:rgb(124 91 196/var(--tw-border-opacity,1));background-color:rgb(68 40 111/var(--tw-bg-opacity,1));color:rgb(248 244 255/var(--tw-text-opacity,1))}.ghost-link.is-disabled{cursor:default;opacity:.5}.detail-list{display:grid;gap:1rem}.totp-enrollment{margin-top:1rem;max-width:100%;gap:1rem;background-color:rgba(16,5,31,.5);padding:1rem}.qr-code,.totp-enrollment{display:grid;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1))}.qr-code{width:-moz-fit-content;width:fit-content;gap:.125rem;--tw-bg-opacity:1;background-color:rgb(248 244 255/var(--tw-bg-opacity,1));padding:.5rem}.qr-row{display:flex;gap:.125rem}.qr-cell{display:block;height:.375rem;width:.375rem;--tw-bg-opacity:1;background-color:rgb(248 244 255/var(--tw-bg-opacity,1))}.qr-cell.is-dark{--tw-bg-opacity:1;background-color:rgb(16 5 31/var(--tw-bg-opacity,1))}.settings-factor-panel{margin-top:1.25rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));padding-top:1.25rem}.secret-value,.uri-value{min-width:0;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(248 244 255/var(--tw-text-opacity,1));overflow-wrap:anywhere}.secret-value,.uri-value{word-break:break-all}.config-item dt,.detail-list dt{font-size:.75rem;line-height:1rem;font-weight:500;text-transform:uppercase;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1));letter-spacing:0}.config-item dd,.detail-list dd{margin-top:.25rem;overflow-wrap:break-word;font-size:.875rem;font-weight:600;line-height:1.25rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.config-grid{display:grid;gap:.75rem}@media (min-width:640px){.config-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1024px){.config-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}.config-item{min-height:6rem;border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(91 59 137/var(--tw-border-opacity,1));background-color:rgba(16,5,31,.4);padding:1rem}.config-item.ok{border-color:rgba(52,211,153,.4)}.config-item.warn{border-color:rgba(251,191,36,.4)}.config-item.danger{border-color:rgba(251,113,133,.4)}.config-item p{margin-top:.5rem;font-size:.75rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(142 120 179/var(--tw-text-opacity,1))}.muted{font-size:.875rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(201 184 234/var(--tw-text-opacity,1))}.boundary-section{border-color:rgba(251,191,36,.4)}.boundary-list{margin:0;list-style-type:disc;padding-left:1.25rem;font-size:.875rem;line-height:1.5rem;--tw-text-opacity:1;color:rgb(229 216 255/var(--tw-text-opacity,1))}.boundary-list li+li{margin-top:.5rem}.icon{height:1rem;width:1rem;flex-shrink:0}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.hidden{display:none} \ No newline at end of file diff --git a/internal/httpapi/web/admin/tailwind.css b/internal/httpapi/web/admin/tailwind.css index fb6609d..2b76d26 100644 --- a/internal/httpapi/web/admin/tailwind.css +++ b/internal/httpapi/web/admin/tailwind.css @@ -606,7 +606,7 @@ } .totp-enrollment { - @apply mt-4 grid gap-4 rounded-md border border-proofline-border bg-proofline-bg-deep/50 p-4 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-start; + @apply mt-4 grid max-w-full gap-4 rounded-md border border-proofline-border bg-proofline-bg-deep/50 p-4; } .qr-code { @@ -631,7 +631,8 @@ .secret-value, .uri-value { - @apply font-mono text-xs leading-5 text-proofline-text; + @apply min-w-0 break-all font-mono text-xs leading-5 text-proofline-text; + overflow-wrap: anywhere; } .uri-value {