diff --git a/daemon/handlers/network.py b/daemon/handlers/network.py index 68f464b..e7b2eb9 100644 --- a/daemon/handlers/network.py +++ b/daemon/handlers/network.py @@ -127,7 +127,7 @@ def get_interfaces(_request: Any, _body: Any) -> dict[str, Any]: runtime = parse_networkctl_status(raw) merged: dict[str, Any] = {} - all_names = set(ifaces_cfg.keys()) | set(runtime.keys()) - {"lo"} + all_names = set(ifaces_cfg.keys()) | set(runtime.keys()) for name in sorted(all_names): merged[name] = { "config": ifaces_cfg.get(name, {}), diff --git a/daemon/handlers/nginx.py b/daemon/handlers/nginx.py index 8938694..36e181a 100644 --- a/daemon/handlers/nginx.py +++ b/daemon/handlers/nginx.py @@ -150,6 +150,9 @@ def _get_config() -> dict[str, Any]: cfg, changed = _migrate_config(raw) if changed: _save_config(cfg) + # The on-disk config format changed under the state store's feet; + # re-collect so cached state (e.g. the domains list) matches the file. + refresh_state(["nginx"]) return cfg @@ -172,6 +175,9 @@ def _get_backends() -> dict[str, Any]: backends["webui"]["_migrated"] = True cfg["backends"] = backends _save_config(cfg) + # Config file was rewritten (builtin backend materialized); re-collect + # so cached state matches the file. + refresh_state(["nginx"]) return backends diff --git a/docs/api.md b/docs/api.md index 707dff1..758bb54 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1780,7 +1780,7 @@ Endpoints prefixed with `/api/network/...`. Manage systemd-networkd interface co GET /api/network/interfaces ``` -Return all configured interfaces with their network config and runtime state from `networkctl`. +Return all network interfaces (configured and live, including loopback) with their network config and runtime state from `networkctl`. `runtime.state` is the networkctl operational state (`routable`, `degraded`, `carrier`, `off`, …). **Response:** diff --git a/docs/architecture.md b/docs/architecture.md index 498e1f3..5d5eaf1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -304,6 +304,8 @@ Both `config/` and `data/` reside within the project directory. The systemd serv The daemon uses a **runtime directory** at `/run/vacuum-wall` (created by systemd `RuntimeDirectory=`) for secure temporary files during config apply. `tempfile.NamedTemporaryFile` writes to this directory before `sudo cp` moves files to their final destination, eliminating TOCTOU symlink races that would exist with `/tmp`. The directory is automatically removed on service stop. +`/run` is a fresh tmpfs at every boot, so volatile runtime paths must be recreated at startup. This is a hard requirement, not a best practice: with `ProtectSystem=strict`, namespace setup fails (`226/NAMESPACE`) and the unit crash-loops if any `ReadWritePaths=` entry does not exist when the unit spawns. Each `/run` path the daemon references therefore needs a boot-time creator: the unit's `RuntimeDirectory=vacuum-wall nginx` covers the daemon-owned directories, and the `system/tmpfiles.d/vacuum-wall.conf` spec (installed to `/etc/tmpfiles.d/`) pre-creates `/run/firewalld` at early boot via `systemd-tmpfiles-setup.service` (in practice firewalld creates it itself, and it starts before the daemon). `/run/sudo` is deliberately *not* in the unit's `ReadWritePaths=`: the daemon's sudo children use the NOPASSWD whitelist and never read or write sudo's session directory, so listing it only added a boot-time and restart-time failure mode (sudo removes `/run/sudo` when the last session ends). + ## File System Layout The following file system locations are used for integration with system services: @@ -317,6 +319,9 @@ The following file system locations are used for integration with system service | `/etc/systemd/network/50-.network` | Generated systemd-networkd drop-in files. Written from `config/network/config.json`, one per interface. | Vacuum Wall (lib/network.py) | | `/etc/sudoers.d/vacuum-walld` | Sudo whitelist for the daemon user. Defines all permitted privilege escalations. | Install script (rendered from Jinja2 template) | | `/run/vacuum-wall` | Runtime directory for secure temp files during config apply (nginx, dnsmasq). Created by systemd `RuntimeDirectory=`, removed on stop. | Daemon (systemd unit) | +| `/run/nginx` | Runtime directory referenced by the daemon's `ReadWritePaths=`; must exist at spawn. Created by systemd `RuntimeDirectory=` before namespace setup. | Daemon (systemd unit) | +| `/run/firewalld` | Root-owned runtime dir of firewalld. Must exist at spawn because of `ProtectSystem=strict` + `ReadWritePaths=` (see volatile-/run note above). Present while firewalld runs; also pre-created at early boot by `system/tmpfiles.d/vacuum-wall.conf`. | firewalld / systemd-tmpfiles (early boot) | +| `/run/sudo` | sudo's session directory. Present only while sudo sessions exist. **Not** in the unit's `ReadWritePaths=` (NOPASSWD sudo children never need it) — see volatile-/run note above. | sudo (created/removed on demand) | | `data/auth.db` | SQLite database: users, permissions, token_blacklist, webauthn_creds. Created on first access via `get_db()`. | Auth layer (lib/db.py) | The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location. diff --git a/docs/overview.md b/docs/overview.md index 31d5150..76642ee 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -87,8 +87,10 @@ After installation, access the management interface at `https://.local │ ├── systemd/ # Service and timer unit files │ │ ├── vacuum-wall.service # Web UI service (rendered at install) │ │ ├── vacuum-wall-acme.service # Certificate renewal (rendered at install) -│ │ └── vacuum-wall-acme.timer # Renewal schedule +│ │ ├── vacuum-wall-acme.timer # Renewal schedule +│ │ └── vacuum-walld.service # Privileged daemon (rendered at install) │ ├── sudoers.d/ # Sudo whitelist (rendered at install) +│ ├── tmpfiles.d/ # tmpfiles.d spec (installed verbatim) │ ├── nginx/ # Nginx config templates (rendered at runtime) │ ├── dnsmasq.conf # Dnsmasq template (rendered at runtime) │ └── wireguard*.conf # WireGuard templates (rendered at runtime) diff --git a/docs/security.md b/docs/security.md index e13555b..6e1b4cd 100644 --- a/docs/security.md +++ b/docs/security.md @@ -145,8 +145,9 @@ Both `vacuum-wall.service` (WebUI) and `vacuum-walld.service` (daemon) apply com | Directive | Value | Effect | |---|---|---| | `ProtectSystem` | `strict` | Mounts the entire file system as read-only, except explicitly allowed paths | -| `ReadWritePaths` | project dir, `/tmp`, `/run/vacuum-wall`, and (WebUI only) `config/`, `data/` subdirs | The project directory and runtime paths are writable | -| `RuntimeDirectory` | `vacuum-wall` (daemon only) | Creates `/run/vacuum-wall` owned by the daemon user; removed on stop | +| `ReadWritePaths` | project dir, `/tmp`, the generated `/etc` config dirs, and the volatile `/run` entries (`/run/vacuum-wall`, `/run/firewalld`, `/run/nginx`); (WebUI only) `config/`, `data/` subdirs | The project directory and runtime paths are writable. Every entry must **exist** when the unit spawns or namespace setup fails (`226/NAMESPACE`), so volatile `/run` entries are pre-created by systemd (see below). Only paths the unit genuinely writes are listed — e.g. `/run/sudo` was historically listed but is now omitted because the NOPASSWD sudo children never need it | +| `RuntimeDirectory` | `vacuum-wall nginx` (daemon only) | Creates `/run/vacuum-wall` and `/run/nginx` owned by the daemon user before namespace setup; removed on stop | +| tmpfiles.d spec | `system/tmpfiles.d/vacuum-wall.conf` (installed to `/etc/tmpfiles.d/`, applied at early boot by `systemd-tmpfiles-setup.service`) | Pre-creates the root-owned `/run/firewalld` at early boot so the daemon's `ReadWritePaths=` entries resolve on a fresh boot (in practice firewalld, which starts first, creates the directory itself) | | `PrivateTmp` | `yes` | Provides a private `/tmp` and `/var/tmp` namespace | | `NoNewPrivileges` | `yes` | Prevents the process from gaining new privileges via `setuid`/`setgid` | | `PrivateDevices` | `yes` | Hides all device files under `/dev` | diff --git a/lib/state.py b/lib/state.py index c47d94e..6d8d9ce 100644 --- a/lib/state.py +++ b/lib/state.py @@ -37,6 +37,10 @@ _DEFAULT_POLL_INTERVALS: dict[str, int] = { "dnsmasq": 10, "networkd": 10, "system": 30, + # nginx/acme state derives from config files (and lazy in-place migration + # can rewrite them without a mutation); poll so drift self-heals. + "nginx": 60, + "acme": 300, } diff --git a/lib/system_import.py b/lib/system_import.py index 5a82144..008c768 100644 --- a/lib/system_import.py +++ b/lib/system_import.py @@ -713,7 +713,20 @@ def _parse_bool(val: str) -> bool | str: def import_nginx() -> bool: - """Parse data/nginx/sites-enabled/*.conf -> config/nginx/config.json.""" + """Parse data/nginx/sites-enabled/*.conf -> config/nginx/config.json. + + Bootstraps config.json on hosts that already have rendered sites + (repo reinstalled over an existing data/ dir). If the declarative + config already exists it wins: sites are vacuum-wall's own generated + output ("do not edit manually") and re-parsing them is lossy — backend + references get flattened to inline paths, which render empty nginx + sites and hide domains from the WebUI. + """ + cfg_path = PROJECT_DIR / "config" / "nginx" / "config.json" + if cfg_path.exists(): + logger.debug("Skipping nginx: %s already exists", cfg_path) + return False + if not NGINX_SITES_DIR.exists(): logger.debug("Skipping nginx: %s not found", NGINX_SITES_DIR) return False @@ -741,7 +754,6 @@ def import_nginx() -> bool: logger.debug("Skipping nginx: no valid site files") return False - cfg_path = PROJECT_DIR / "config" / "nginx" / "config.json" existing: dict[str, Any] = load_json(cfg_path, {"domains": {}, "ssl": {}}) domains_cfg = existing.setdefault("domains", {}) diff --git a/scripts/install.sh b/scripts/install.sh index e1e7561..da8c947 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -274,6 +274,11 @@ render_template "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.service" \ | install -m 0644 /dev/stdin /etc/systemd/system/vacuum-wall-acme.service install -m 0644 "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.timer" /etc/systemd/system/vacuum-wall-acme.timer + +# Volatile /run entries (sudo, firewalld) must exist before vacuum-walld +# spawns — systemd-tmpfiles-setup.service restores them at every boot. +install -m 0644 "${PROJECT_DIR}/system/tmpfiles.d/vacuum-wall.conf" /etc/tmpfiles.d/vacuum-wall.conf +systemd-tmpfiles --create systemctl daemon-reload # --- 7. Enable IP forwarding (persistent via sysctl.conf + runtime apply) --- diff --git a/system/systemd/vacuum-walld.service b/system/systemd/vacuum-walld.service index 5004639..33f9259 100644 --- a/system/systemd/vacuum-walld.service +++ b/system/systemd/vacuum-walld.service @@ -18,15 +18,39 @@ Environment=PYTHONUNBUFFERED=1 Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme Environment=HOME={{ PROJECT_DIR }} -# Runtime directory for temp files used during config apply -RuntimeDirectory=vacuum-wall +# Runtime directories created before namespace setup. ProtectSystem=strict +# makes the whole hierarchy read-only, and namespace setup fails +# (exit 226/NAMESPACE) if any ReadWritePaths= entry is missing at spawn. +# /run is a fresh tmpfs at every boot, so volatile /run paths must be +# created up front (RuntimeDirectory= here; /run/firewalld via +# system/tmpfiles.d/vacuum-wall.conf and by firewalld itself) rather than +# at first use. +# vacuum-wall : secure temp files used during config apply +# nginx : /run/nginx (listed in ReadWritePaths) +RuntimeDirectory=vacuum-wall nginx RuntimeDirectoryMode=0750 LogsDirectory=vacuum-wall # Security hardening ProtectSystem=strict -ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/vacuum-wall /run/sudo /run/firewalld /run/nginx /run/nginx.pid /var/log/nginx /var/log/vacuum-wall +# NOTE: every ReadWritePaths= entry must exist when the unit spawns or namespace +# setup fails (226/NAMESPACE). Volatile /run entries are pre-created: +# /run/vacuum-wall, /run/nginx → RuntimeDirectory= (above) +# /run/firewalld → system/tmpfiles.d/vacuum-wall.conf (and is +# present while firewalld runs, which starts +# before this unit) +# /run/sudo is intentionally NOT listed: the daemon's sudo children use the +# NOPASSWD whitelist and never need sudo's session directory (verified with +# the directory absent). Listing it made the unit crash-loop whenever it +# restarted after the last sudo session had ended and sudo removed /run/sudo. +# /run/nginx.pid IS listed: nginx -t opens the pid file for *writing* in +# addition to -s/acme reading it, so a read-only mount makes every daemon-side +# `nginx -t` (and therefore /nginx/apply) fail with EROFS. The file is +# pre-created by system/tmpfiles.d/vacuum-wall.conf so the ReadWritePaths= +# entry always exists at spawn (nginx rewrites it on start; nginx -t does +# not modify its contents). +ReadWritePaths={{ PROJECT_DIR }} /tmp /etc/systemd/network /etc/nginx /etc/dnsmasq.d /etc/wireguard /run/vacuum-wall /run/firewalld /run/nginx /run/nginx.pid /var/log/nginx /var/log/vacuum-wall PrivateTmp=yes ProtectKernelTunables=yes ProtectKernelModules=yes diff --git a/system/tmpfiles.d/vacuum-wall.conf b/system/tmpfiles.d/vacuum-wall.conf new file mode 100644 index 0000000..6e851ac --- /dev/null +++ b/system/tmpfiles.d/vacuum-wall.conf @@ -0,0 +1,25 @@ +# Volatile /run entries that must exist before vacuum-walld spawns. +# +# vacuum-walld runs with ProtectSystem=strict and lists these paths in +# ReadWritePaths=; if a ReadWritePaths= entry is missing at spawn time, +# systemd's mount-namespace setup fails (exit 226/NAMESPACE) and the unit +# crash-loops without ever creating data/daemon.sock. /run is a fresh tmpfs +# at every boot, so every /run path the unit references needs a boot-time +# creator. Status per path: +# +# /run/vacuum-wall, /run/nginx -> unit RuntimeDirectory= (daemon-owned) +# /run/firewalld -> this file (the firewalld unit only creates +# it while firewalld itself is running) +# /run/nginx.pid -> this file (nginx rewrites it on start; the +# daemon's `nginx -t` must be able to open +# it for writing inside its ProtectSystem=strict +# namespace, so it needs both a boot-time +# creator and a ReadWritePaths= entry) +# /run/sudo -> not referenced by the unit (see +# ReadWritePaths note in vacuum-walld.service); +# the sudo package ships its own tmpfiles spec +# +# Applied at early boot by systemd-tmpfiles-setup.service and by the install +# script (`systemd-tmpfiles --create`) for existing hosts. +d /run/firewalld 0750 root root - +f /run/nginx.pid 0644 root root - diff --git a/webui/static/app.js b/webui/static/app.js index 6c60c68..c1522ef 100644 --- a/webui/static/app.js +++ b/webui/static/app.js @@ -219,21 +219,45 @@ const Pages = { /* ── Router ────────────────────────────────────────────────── */ const router = { state: reactive({ path: location.hash.slice(1) || '/dashboard' }), - isAuthenticated: false, component() { - const name = this.state.path.replace(/^\//, ''); + const { path } = this.state; + // Auth guard: unauthenticated users see the login page for any + // protected route (manual hash entry, back/forward, runtime + // expiry). Reactive — the auth model's data mutation re-renders + // this function, so the real page appears the instant login + // completes. + if (path !== '/login' && !isAuthenticated()) { + return hComp(LoginPage, '/login'); + } + const name = path.replace(/^\//, ''); const page = Pages[name] || NotFoundPage; - return hComp(page, this.state.path); + return hComp(page, path); }, }; +// Set once the bootstrap session check settles (and implicitly on every +// later login/logout transition — isAuthenticated flips reactively). Until +// then the hashchange clamp below must NOT force unauthenticated hashes to +// #/login: a valid-session reload arrives with its 'check' still in flight, +// and clamping early would strand the user on login. +let authChecked = false; + window.location.hash || (window.location.hash = router.state.path); window.addEventListener('hashchange', () => { - router.state.path = location.hash.slice(1) || '/dashboard'; + const raw = location.hash.slice(1) || '/dashboard'; + const path = raw !== '/login' && authChecked && !isAuthenticated() ? '/login' : raw; + router.state.path = path; + // Keep the URL in sync with the clamped path (loop-safe: the follow-up + // hashchange lands on the already-clamped '/login'). + if (location.hash.slice(1) !== path) location.hash = path; }); /* ── Sidebar render root ───────────────────────────────────── */ function Sidebar() { + // No nav when logged out — unauthenticated users get the full-bleed + // login page. Reactive: the auth model's terminal transition (logout / + // session expiry) re-renders this root back to null. + if (!isAuthenticated()) return null; const current = router.state.path; const nav = getNav(); return h('div', { class: 'sidebar' }, @@ -260,16 +284,8 @@ function MainContent() { /* ── Init ──────────────────────────────────────────────────── */ export async function initApp() { - const sidebarEl = document.getElementById('sidebar'); - const mainEl = document.getElementById('main'); - if (sidebarEl && mainEl) { - render(sidebarEl, Sidebar); - render(mainEl, MainContent); - } - // Listen for login events to update router state after auth window.addEventListener('auth:login', () => { - router.isAuthenticated = true; // Defer to a macrotask: at dispatch time (microtask) the login form's // hash change has not run yet — router.state.path is still '/login'. // The deferred check runs after the hashchange task, so a fresh login @@ -290,17 +306,31 @@ export async function initApp() { disconnect(); }); - // Check auth state before connecting WS + // Check auth state BEFORE mounting the shell: an unauthenticated visitor + // must never flash the sidebar or a protected page before the redirect + // to #/login lands. await modelFetch('auth', { action: 'check' }); + authChecked = true; if (isAuthenticated()) { - router.isAuthenticated = true; + // A session restored at bootstrap (or a reload) may leave the URL on + // #/login — the auth guard renders the login form for that hash even + // when authenticated. Bounce to the default page so a valid session + // never strands the user on a stale login screen. + if (router.state.path === '/login') { + window.location.hash = '/dashboard'; + } fetchInitialData(); setTimeout(connect, 0); - } else { - // No valid session — redirect to login - if (router.state.path !== '/login') { - window.location.hash = '/login'; - } + } else if (router.state.path !== '/login') { + // No valid session — redirect to login before the first paint. + window.location.hash = '/login'; + } + + const sidebarEl = document.getElementById('sidebar'); + const mainEl = document.getElementById('main'); + if (sidebarEl && mainEl) { + render(sidebarEl, Sidebar); + render(mainEl, MainContent); } } diff --git a/webui/static/hoover/websocket.js b/webui/static/hoover/websocket.js index edc530a..e349d5f 100644 --- a/webui/static/hoover/websocket.js +++ b/webui/static/hoover/websocket.js @@ -11,6 +11,12 @@ * never on the refresh promise. Terminal (no-token) transitions are * handled by the auth model's onSuccess (clears storage, redirects, * dispatches auth:logout). + * + * The refresh path is capped at 2 consecutive failing episodes (3 closed + * connections each): if refresh + reconnect still cannot establish a + * socket, the WS path itself is dead, and retrying would loop token + * rotation forever. Reconnection is then abandoned until the page is + * reloaded; the UI keeps working via the REST API. */ import { refreshByTopic } from './model.js'; @@ -19,6 +25,11 @@ import { refreshAuth, getAuthToken } from './auth_model.js'; let _wsConn = null; let _wsReconnectMs = 0; let _wsFailCount = 0; +// Consecutive refresh→reconnect episodes that still failed. Capped so a +// dead WS path cannot loop `refreshAuth()` forever (each 200 refresh rotates +// the user's token pair, so an unbounded loop storms the refresh endpoint). +let _wsRefreshStreak = 0; +let _wsGivingUp = false; let _wsClosingHandled = false; /** Direct onMessage handlers — { topics, handler, unsubscribed }[] */ @@ -55,6 +66,8 @@ function _wsConnect() { _wsConn.onopen = () => { _wsReconnectMs = 0; _wsFailCount = 0; + _wsRefreshStreak = 0; + _wsGivingUp = false; _wsClosingHandled = false; }; @@ -62,11 +75,25 @@ function _wsConnect() { if (_wsClosingHandled) return; _wsClosingHandled = true; if (!getAuthToken()) return; + if (_wsGivingUp) return; _wsFailCount++; if (_wsFailCount >= 3) { const oldConn = _wsConn; _wsFailCount = 0; + _wsRefreshStreak++; + if (_wsRefreshStreak >= 2) { + // Refresh + reconnect has failed twice in a row — the WS path + // is dead (not just the token). Stop retrying: the page keeps + // working API-only, and a fresh page load (or the next + // successful socket) restarts the cycle. + _wsGivingUp = true; + console.error( + '[WS] giving up after repeated refresh+reconnect failures; ' + + 'live updates paused until the page is reloaded', + ); + return; + } await refreshAuth(); // never rejects; failure path handled by model onSuccess if (getAuthToken()) { _wsReconnectMs = 0; diff --git a/webui/static/pages/dashboard.js b/webui/static/pages/dashboard.js index 2019ff4..c22e908 100644 --- a/webui/static/pages/dashboard.js +++ b/webui/static/pages/dashboard.js @@ -53,10 +53,13 @@ export default definePage({ const ifaces = allNames.map(name => { const fw = fwIfaces.find(f => f.name === name); const netEntry = netIfaces[name] || {}; + // /api/network/interfaces returns {config, runtime} per interface — + // state fields (state, addresses, mac) live under runtime. + const runtime = netEntry.runtime || {}; const traffic = sysTraffic[name] || {}; const ips = fw ? [...(fw.ips || []), ...(fw.ipv6 || [])] : []; - const addrs = netEntry?.addresses || []; - const isUp = ['routable', 'degraded'].some(s => (netEntry.state || '').startsWith(s)); + const addrs = runtime.addresses || []; + const isUp = ['routable', 'degraded', 'carrier'].some(s => (runtime.state || '').startsWith(s)); return { name, mac: fw?.mac || null, diff --git a/webui/static/pages/interfaces.js b/webui/static/pages/interfaces.js index 69eefb5..255ef6e 100644 --- a/webui/static/pages/interfaces.js +++ b/webui/static/pages/interfaces.js @@ -52,7 +52,8 @@ export default definePage({ const zones = fwZones.available || []; const activeZones = fwZones.active || {}; - const ifaces = Object.entries(netData).map(([name, entry]) => { + // Loopback has no networkd config to manage — show real NICs only. + const ifaces = Object.entries(netData).filter(([name]) => name !== 'lo').map(([name, entry]) => { let zone = null; for (const [zoneName, zIfaces] of Object.entries(activeZones)) { if ((zIfaces || []).includes(name)) { diff --git a/webui/static/style.css b/webui/static/style.css index d774642..dddf596 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -95,6 +95,11 @@ body { min-width: 0; } +/* Full-bleed main when the sidebar renders nothing (logged-out / login view) */ +#sidebar:empty ~ .main { + margin-left: 0; +} + /* Cards */ .card { background: var(--bg-secondary); @@ -255,6 +260,68 @@ body { min-height: 80px; } +/* Login */ +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + +.login-card { + width: 100%; + max-width: 380px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + padding: 2.5rem 2rem; +} + +.login-title { + font-size: 1.5rem; + font-weight: 700; + color: var(--accent); +} + +.login-subtitle { + color: var(--text-muted); + margin-bottom: 1.75rem; +} + +.login-form .form-group { + margin-bottom: 1rem; +} + +.login-error { + min-height: 1.25rem; + margin-bottom: 0.75rem; + color: var(--danger); + font-size: 0.85rem; +} + +.login-divider { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 1.5rem 0; + color: var(--text-muted); + font-size: 0.8rem; +} + +.login-divider::before, +.login-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); +} + +.btn-login, +.btn-passkey { + width: 100%; +} + /* Badges */ .badge { display: inline-block;