feat: add auth subsystem with WebAuthn passkeys support

New modules: lib/auth, lib/auth_users, lib/db, lib/db_sqlite, lib/password,
lib/webauthn, daemon/handlers/auth, scripts/bootstrap_auth, tests/test_auth

Frontend: webui/api/auth, hoover/components/auth, pages/login, passkeys, users

Updates: daemon/iface and server, lib/common and nginx, pyproject.toml deps,
install script, server.py, app.js, and websocket/api clients
This commit is contained in:
2026-07-24 01:21:39 +00:00
parent 04417cf05c
commit 56b200d233
28 changed files with 4900 additions and 82 deletions
+352
View File
@@ -0,0 +1,352 @@
"""Authentication API blueprint.
Exposed at /api/auth/* and delegates all operations to vacuum-walld.
"""
from __future__ import annotations
import logging
from flask import Blueprint, request
from daemon.client import delete, get, post
from daemon.iface import (
DELETE_AUTH_USER,
DELETE_AUTH_WEBAUTHN_CREDENTIAL,
GET_AUTH_SESSION,
GET_AUTH_USERS,
GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS,
GET_AUTH_WEBAUTHN_CREDENTIALS,
POST_AUTH_LOGIN,
POST_AUTH_LOGOUT,
POST_AUTH_PASSWORD,
POST_AUTH_REFRESH,
POST_AUTH_USER_CREATE,
POST_AUTH_USER_UPDATE,
POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN,
POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH,
POST_AUTH_WEBAUTHN_REGISTER_BEGIN,
POST_AUTH_WEBAUTHN_REGISTER_FINISH,
)
from webui.api.common import _error, _ok
logger = logging.getLogger(__name__)
bp = Blueprint("auth", __name__)
@bp.route("/login", methods=["POST"])
def login():
"""Authenticate user with username and password.
Endpoint:
POST /api/auth/login
Body:
{ "username": "admin", "password": "secretpass" }
Returns:
{ "tokens": { "access_token": "...", "refresh_token": "..." },
"user": { "id": 1, "username": "admin" },
"permissions": { ... } }
"""
try:
body = request.get_json(silent=True) or {}
return _ok(post(POST_AUTH_LOGIN, body))
except Exception as exc:
logger.error("Login failed: %s", exc)
return _error(str(exc), 401)
@bp.route("/logout", methods=["POST"])
def logout():
"""Invalidate current session by blacklisting access and refresh tokens.
Endpoint:
POST /api/auth/logout
Body:
{ "refresh_token": "..." } -- client-provided refresh token
Returns:
{ "ok": true }
"""
try:
client_body = request.get_json(silent=True) or {}
body = {
**(request._user_ctx or {}),
"refresh_token": client_body.get("refresh_token"),
}
return _ok(post(POST_AUTH_LOGOUT, body))
except RuntimeError as exc:
logger.error("Logout failed: %s", exc)
return _error(str(exc), 500)
@bp.route("/refresh", methods=["POST"])
def refresh():
"""Rotate tokens using a refresh token.
Endpoint:
POST /api/auth/refresh
Body:
{ "refresh_token": "..." }
Returns:
{ "tokens": { ... }, "user": { ... }, "permissions": { ... } }
"""
try:
body = request.get_json(silent=True) or {}
return _ok(post(POST_AUTH_REFRESH, body))
except Exception as exc:
logger.error("Token refresh failed: %s", exc)
return _error(str(exc), 401)
@bp.route("/session", methods=["GET"])
def session():
"""Return current user session info.
Endpoint:
GET /api/auth/session
Returns:
{ "user": { ... }, "permissions": { ... } }
"""
try:
return _ok(get(GET_AUTH_SESSION, {**(request._user_ctx or {})}))
except Exception as exc:
logger.error("Session check failed: %s", exc)
return _error(str(exc), 401)
@bp.route("/password", methods=["POST"])
def change_password():
"""Change own password.
Endpoint:
POST /api/auth/password
Body:
{ "oldPassword": "...", "newPassword": "..." }
Returns:
{ "ok": true }
"""
try:
body = request.get_json(silent=True) or {}
user_ctx = getattr(request, "_user_ctx", None)
if user_ctx is not None:
body["username"] = user_ctx["username"]
return _ok(post(POST_AUTH_PASSWORD, body))
except Exception as exc:
logger.error("Password change failed: %s", exc)
return _error(str(exc), 400)
@bp.route("/users", methods=["GET"])
def list_users():
"""List all users.
Endpoint:
GET /api/auth/users
Returns:
{ "users": [{ "id": 1, "username": "...", "permissions": { ... }, ... }] }
"""
try:
return _ok(get(GET_AUTH_USERS))
except RuntimeError as exc:
logger.error("List users failed: %s", exc)
return _error(str(exc), 500)
@bp.route("/users", methods=["POST"])
def create_user():
"""Create a new user.
Endpoint:
POST /api/auth/users
Body:
{ "username": "...", "password": "...", "permissions": { ... } }
Returns:
{ "ok": true, "id": ..., "username": "...", "permissions": { ... } }
"""
try:
body = request.get_json(silent=True) or {}
return _ok(post(POST_AUTH_USER_CREATE, body))
except Exception as exc:
logger.error("Create user failed: %s", exc)
return _error(str(exc), 400)
@bp.route("/users/<username>", methods=["POST"])
def update_user(username: str):
"""Update user permissions.
Endpoint:
POST /api/auth/users/<username>
Body:
{ "permissions": { ... } }
Returns:
{ "ok": true, "id": ..., "username": "..." }
"""
try:
body = {**(request.get_json(silent=True) or {}), "username": username}
return _ok(post(POST_AUTH_USER_UPDATE, body))
except Exception as exc:
err = str(exc)
status = 404 if "not found" in err.lower() else 400
logger.error("Update user failed: %s", exc)
return _error(err, status)
@bp.route("/users/<username>", methods=["DELETE"])
def delete_user(username: str):
"""Delete a user.
Endpoint:
DELETE /api/auth/users/<username>
Returns:
{ "ok": true }
"""
# Prevent self-deletion
user_ctx = getattr(request, "_user_ctx", None)
if user_ctx is not None and user_ctx.get("username") == username:
return _error("Cannot delete your own account", 403)
try:
return _ok(delete(DELETE_AUTH_USER, {"username": username}))
except Exception as exc:
err = str(exc)
status = 404 if "not found" in err.lower() else 400
logger.error("Delete user failed: %s", exc)
return _error(err, status)
# ---------------------------------------------------------------------------
# WebAuthn routes
# ---------------------------------------------------------------------------
@bp.route("/webauthn/register-begin", methods=["POST"])
def webauthn_register_begin():
"""Begin WebAuthn registration.
Endpoint:
POST /api/auth/webauthn/register-begin
Body:
{ "username": "..." }
Returns:
Registration options for navigator.credentials.create()
"""
try:
body = request.get_json(silent=True) or {}
return _ok(post(POST_AUTH_WEBAUTHN_REGISTER_BEGIN, body))
except Exception as exc:
logger.error("WebAuthn register begin failed: %s", exc)
return _error(str(exc), 400)
@bp.route("/webauthn/register-finish", methods=["POST"])
def webauthn_register_finish():
"""Finish WebAuthn registration.
Endpoint:
POST /api/auth/webauthn/register-finish
Body:
{ "username": "...", "credential_response": {...}, "registration_options": {...}, "name": "..." }
Returns:
{ "ok": true, "credential": {...} }
"""
try:
body = request.get_json(silent=True) or {}
return _ok(post(POST_AUTH_WEBAUTHN_REGISTER_FINISH, body))
except Exception as exc:
logger.error("WebAuthn register finish failed: %s", exc)
return _error(str(exc), 400)
@bp.route("/webauthn/authenticate-begin", methods=["POST"])
def webauthn_authenticate_begin():
"""Begin WebAuthn authentication (public endpoint).
Endpoint:
POST /api/auth/webauthn/authenticate-begin
Body:
{ "username": "..." }
Returns:
Authentication options for navigator.credentials.get()
or { "no_webauthn": true } if user has no credentials.
"""
try:
body = request.get_json(silent=True) or {}
return _ok(post(POST_AUTH_WEBAUTHN_AUTHENTICATE_BEGIN, body))
except Exception as exc:
logger.error("WebAuthn authenticate begin failed: %s", exc)
return _error(str(exc), 400)
@bp.route("/webauthn/authenticate-finish", methods=["POST"])
def webauthn_authenticate_finish():
"""Finish WebAuthn authentication (public endpoint).
Endpoint:
POST /api/auth/webauthn/authenticate-finish
Body:
{ "username": "...", "assertion_response": {...}, "auth_options": {...} }
Returns:
{ "tokens": {...}, "user": {...}, "permissions": {...} }
"""
try:
body = request.get_json(silent=True) or {}
return _ok(post(POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH, body))
except Exception as exc:
logger.error("WebAuthn authenticate finish failed: %s", exc)
return _error(str(exc), 401)
@bp.route("/webauthn/credentials", methods=["GET"])
def webauthn_credentials_list():
"""List registered WebAuthn credentials.
Endpoint:
GET /api/auth/webauthn/credentials
Returns:
{ "credentials": [...] }
"""
try:
return _ok(get(GET_AUTH_WEBAUTHN_CREDENTIALS, {**(request._user_ctx or {})}))
except Exception as exc:
logger.error("List WebAuthn credentials failed: %s", exc)
return _error(str(exc), 500)
@bp.route("/webauthn/credential-counts", methods=["GET"])
def webauthn_credential_counts():
"""Return credential counts for all users.
Admin endpoint — returns a dict mapping usernames to credential counts.
Endpoint:
GET /api/auth/webauthn/credential-counts
Returns:
{ "counts": { "username": 2, ... } }
"""
try:
return _ok(get(GET_AUTH_WEBAUTHN_CREDENTIAL_COUNTS))
except RuntimeError as exc:
logger.error("List credential counts failed: %s", exc)
return _error(str(exc), 500)
@bp.route("/webauthn/creds/<credential_id>", methods=["DELETE"])
def webauthn_remove_credential(credential_id: str):
"""Remove a WebAuthn credential.
Endpoint:
DELETE /api/auth/webauthn/creds/<credential_id>
Returns:
{ "ok": true }
"""
try:
return _ok(
delete(
DELETE_AUTH_WEBAUTHN_CREDENTIAL,
{**(request._user_ctx or {}), "credential_id": credential_id},
)
)
except Exception as exc:
err = str(exc)
status = 404 if "not found" in err.lower() else 400
logger.error("Remove WebAuthn credential failed: %s", exc)
return _error(err, status)
+92 -1
View File
@@ -14,10 +14,13 @@ import sys
import time
from pathlib import Path
from flask import Flask, abort, request
from flask import Flask, abort, jsonify, request
from werkzeug.middleware.proxy_fix import ProxyFix
from lib.auth import validate_token
from lib.db import get_db
from lib.logging import setup_logging
from webui.api.auth import bp as auth_bp
from webui.api.certs import bp as certs_bp
from webui.api.dhcp import bp as dhcp_bp
from webui.api.firewall import bp as firewall_bp
@@ -86,6 +89,9 @@ app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 5 if _DEV_MODE else 31536000
# Flask is behind nginx — trust X-Forwarded-* headers for scheme/host detection
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
get_db()
app.register_blueprint(auth_bp, url_prefix="/api/auth")
app.register_blueprint(firewall_bp, url_prefix="/api/firewall")
app.register_blueprint(network_bp, url_prefix="/api/network")
app.register_blueprint(dhcp_bp, url_prefix="/api/dhcp")
@@ -96,6 +102,7 @@ app.register_blueprint(logs_bp, url_prefix="/api/logs")
app.register_blueprint(status_bp, url_prefix="/api/status")
BLUEPRINTS = [
("auth", auth_bp),
("firewall", firewall_bp),
("network", network_bp),
("dhcp", dhcp_bp),
@@ -109,6 +116,90 @@ BLUEPRINTS = [
for name, _ in BLUEPRINTS:
logger.info("Registered blueprint '%s' at /api/%s", name, name)
# ── Public endpoints (no auth required) ──
_AUTH_EXEMPT = {
("GET", "/"),
("POST", "/api/auth/login"),
("POST", "/api/auth/refresh"),
("POST", "/api/auth/webauthn/authenticate-begin"),
("POST", "/api/auth/webauthn/authenticate-finish"),
}
def _subsystem_from_path(path: str) -> str | None:
"""Extract subsystem name from API path."""
if not path.startswith("/api/"):
return None
parts = path.split("/")
if len(parts) >= 3:
return parts[2]
return None
def _has_permission(perms: dict, subsystem: str, method: str) -> bool:
"""Check if user has permission for subsystem + method."""
level = perms.get(subsystem)
if method == "GET":
return level in ("read", "rw")
return level == "rw"
# ── JWT authentication middleware ──
@app.before_request
def _auth_middleware():
"""Validate JWT from Authorization header for API routes.
Exempts: static routes, vendor files, and public auth endpoints.
Attaches request._user_ctx with user info for downstream handlers.
"""
method = request.method
path = request.path
# Exempt specific paths
if (method, path) in _AUTH_EXEMPT:
return
if method == "GET" and path.startswith("/vendor/"):
return
if method in ("GET", "HEAD") and path.startswith("/static/"):
return
# For non-API routes, skip auth
if not path.startswith("/api/"):
return
# Extract token from Authorization header
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return jsonify({"ok": False, "error": "unauthorized"}), 401
token_string = auth_header[7:] # strip "Bearer "
payload = validate_token(token_string, token_type="access")
if payload is None:
return jsonify({"ok": False, "error": "unauthorized"}), 401
username = payload.get("sub")
if not username:
return jsonify({"ok": False, "error": "unauthorized"}), 401
# Check subsystem permissions
subsystem = _subsystem_from_path(path)
if subsystem:
perms = payload.get("permissions", {})
if subsystem not in perms:
return jsonify({"ok": False, "error": "forbidden"}), 403
if not _has_permission(perms, subsystem, method):
return jsonify({"ok": False, "error": "forbidden"}), 403
request._user_ctx = {
"username": username,
"permissions": perms if subsystem else payload.get("permissions", {}),
"jti": payload.get("jti"),
}
return
# ---------------------------------------------------------------------------
# Request logging
# ---------------------------------------------------------------------------
+40 -12
View File
@@ -1,4 +1,4 @@
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=10';
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive, initAuth, getAuthToken, checkSession } from '/static/hoover/index.js?v=10';
import DashboardPage from '/static/pages/dashboard.js?v=11';
import InterfacesPage from '/static/pages/interfaces.js?v=9';
@@ -12,9 +12,12 @@ import CertsPage from '/static/pages/certs.js?v=9';
import WireguardPage from '/static/pages/wireguard.js?v=9';
import LogsPage from '/static/pages/logs.js?v=9';
import NotFoundPage from '/static/pages/notfound.js?v=9';
import LoginPage from '/static/pages/login.js';
import PasskeysPage from '/static/pages/passkeys.js';
import UsersPage from '/static/pages/users.js';
/* ── Navigation items ──────────────────────────────────────── */
const Nav = [
const _NavBase = [
{ path: '/dashboard', label: 'Dashboard' },
{ path: '/interfaces', label: 'Interfaces' },
{ path: '/zones', label: 'Zones' },
@@ -28,6 +31,15 @@ const Nav = [
{ path: '/logs', label: 'Logs' },
];
function getNav() {
const nav = [..._NavBase];
const perms = JSON.parse(localStorage.getItem('vw:permissions') || 'null');
if (perms && perms.auth === 'rw') {
nav.push({ path: '/users', label: 'Users' });
}
return nav;
}
modelRegister('firewall', {
subsystem: 'firewall',
fetch: async () => {
@@ -174,14 +186,17 @@ modelRegister('status', {
},
});
/* ── Initial fetch ─────────────────────────────────────────── */
for (const name of ['firewall', 'network', 'dnsmasq', 'nginx', 'backends', 'wireguard', 'acme', 'status']) {
modelFetch(name);
/* ── Initial fetch (after auth check) ───────────────────────── */
function fetchInitialData() {
for (const name of ['firewall', 'network', 'dnsmasq', 'nginx', 'backends', 'wireguard', 'acme', 'status']) {
modelFetch(name);
}
modelFetch('logs', 'journal');
}
modelFetch('logs', 'journal');
/* ── Page map ──────────────────────────────────────────────── */
const Pages = {
login: LoginPage,
dashboard: DashboardPage,
interfaces: InterfacesPage,
zones: ZonesPage,
@@ -193,11 +208,14 @@ const Pages = {
certs: CertsPage,
wireguard: WireguardPage,
logs: LogsPage,
passkeys: PasskeysPage,
users: UsersPage,
};
/* ── Router ────────────────────────────────────────────────── */
const router = {
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
isAuthenticated: false,
component() {
const name = this.state.path.replace(/^\//, '');
const page = Pages[name] || NotFoundPage;
@@ -213,10 +231,11 @@ window.addEventListener('hashchange', () => {
/* ── Sidebar render root ───────────────────────────────────── */
function Sidebar() {
const current = router.state.path;
const nav = getNav();
return h('div', { class: 'sidebar' },
h('div', { class: 'logo' }, 'Vacuum Wall'),
h('nav', null,
Nav.map(item =>
nav.map(item =>
Link({
path: item.path,
class: current === item.path ? 'active' : '',
@@ -236,17 +255,26 @@ function MainContent() {
}
/* ── Init ──────────────────────────────────────────────────── */
export function initApp() {
export async function initApp() {
const sidebarEl = document.getElementById('sidebar');
const mainEl = document.getElementById('main');
if (sidebarEl && mainEl) {
render(sidebarEl, Sidebar);
render(mainEl, MainContent);
}
// Defer connect() after the first render microtask settles to prevent
// the initial requestUpdate() from triggering a second commit while
// the vnode tree is still being finalized.
setTimeout(connect, 0);
// Check auth state before connecting WS
const ok = await initAuth();
if (ok) {
router.isAuthenticated = true;
fetchInitialData();
setTimeout(connect, 0);
} else {
// No valid session — redirect to login
if (router.state.path !== '/login') {
window.location.hash = '/login';
}
}
}
if (document.readyState === 'loading') {
+127 -7
View File
@@ -1,20 +1,123 @@
/**
* Hoover — api.js
*
* JSON-friendly fetch wrapper with automatic header management.
* JSON-friendly fetch wrapper with automatic header management and JWT auth.
* Toast notification system with auto-dismiss.
* Modal processing guard for async form submissions.
*/
import { modelFetch } from './model.js?v=9';
import { modelFetch } from './model.js?v=10';
import { requestUpdate } from './reactivity.js?v=9';
import { isModalProcessing, setModalProcessing, refreshModals } from './components/modal.js?v=9';
/**
* Global state — shared with auth.js component.
*
* ``window.__auth_token__`` — current access token (in memory, cleared on reload).
* ``localStorage['vw:refresh']`` — refresh token (survives reload).
* ``localStorage['vw:access_ttl']`` — access token TTL in ms for refresh scheduling.
*/
/**
* Inject ``Authorization: Bearer <token>`` header from ``window.__auth_token__``.
* Returns undefined when no token is available.
*
* @returns {string|undefined}
*/
function getAuthToken() {
return window.__auth_token__;
}
/**
* Store access token in memory and schedule refresh.
*
* @param {string} token
*/
function setAuthToken(token) {
window.__auth_token__ = token;
}
/**
* Clear all auth tokens from memory and storage.
*/
function clearAuthTokens() {
window.__auth_token__ = undefined;
localStorage.removeItem('vw:refresh');
localStorage.removeItem('vw:access_ttl');
localStorage.removeItem('vw:user');
if (typeof window.__authRefreshTimer__ !== 'undefined') {
clearTimeout(window.__authRefreshTimer__);
window.__authRefreshTimer__ = undefined;
}
}
/**
* Read refresh token and access TTL from localStorage.
* @returns {{refresh?: string, ttl?: number}}
*/
function getStoredAuth() {
return {
refresh: localStorage.getItem('vw:refresh'),
ttl: parseInt(localStorage.getItem('vw:access_ttl'), 10) || 900000,
};
}
/**
* Attempt to refresh the access token using the stored refresh token.
*
* Sends: POST /api/auth/refresh { refresh_token: ... }
* On success: updates ``window.__auth_token__`` and ``localStorage['vw:refresh']``.
* On failure: clears all tokens.
*
* @returns {Promise<boolean>} ``true`` if refresh succeeded
*/
async function tryRefreshToken() {
const stored = getStoredAuth();
if (!stored.refresh) return false;
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ refresh_token: stored.refresh }),
credentials: 'same-origin',
});
if (res.status !== 200) {
clearAuthTokens();
return false;
}
const json = await res.json();
if (!json.ok || !json.data?.tokens) {
clearAuthTokens();
return false;
}
const tokens = json.data.tokens;
window.__auth_token__ = tokens.access_token;
localStorage.setItem('vw:refresh', tokens.refresh_token);
localStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 900) * 1000));
localStorage.setItem('vw:user', JSON.stringify(json.data.user));
return true;
} catch {
clearAuthTokens();
return false;
}
}
/**
* Redirect to login page, clearing tokens.
*/
function redirectLogin() {
clearAuthTokens();
window.location.href = '/#/login';
}
/**
* JSON-friendly fetch wrapper.
*
* Automatically sets Content-Type for object bodies, parses JSON
* responses, and normalises the result to { ok, data, error, status }.
* Injects ``Authorization: Bearer`` header when a token is present.
* On 401, tries token refresh once; on persistent failure, redirects to login.
*
* @param {string} url Target URL
* @param {object} [options] Fetch options (method, body, headers, …)
@@ -23,6 +126,10 @@ import { isModalProcessing, setModalProcessing, refreshModals } from './componen
export async function apiFetch(url, options = {}) {
const { method = 'GET', body, ...opts } = options;
const headers = { 'Accept': 'application/json', ...opts.headers };
const token = getAuthToken();
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
if (body && typeof body === 'object' && !(body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
@@ -30,12 +137,21 @@ export async function apiFetch(url, options = {}) {
}
try {
const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts });
const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts });
if (opts.signal?.aborted) {
return { ok: false, data: null, error: 'Aborted', status: 0 };
}
if (res.status === 401) {
window.location.reload();
if (res.status === 401 && getAuthToken()) {
const refreshed = await tryRefreshToken();
if (refreshed) {
headers['Authorization'] = 'Bearer ' + getAuthToken();
const retryRes = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts });
const json = await retryRes.json();
if (retryRes.ok) {
return { ok: json.ok, data: json.ok ? json.data : json, error: null, status: retryRes.status };
}
}
redirectLogin();
return { ok: false, data: null, error: 'Session expired', status: 401 };
}
const json = await res.json();
@@ -49,6 +165,11 @@ export async function apiFetch(url, options = {}) {
}
}
/**
* Export auth helpers for use by other modules.
*/
export { setAuthToken, clearAuthTokens, getAuthToken, tryRefreshToken, redirectLogin };
/** ─── Toast notifications ────────────────────────────────── */
/** Toast notification queue. Exported for ToastContainer component. */
@@ -195,7 +316,6 @@ export function formAction(fn) {
return async () => {
if (isModalProcessing()) return;
setModalProcessing(true);
refreshModals();
try {
await fn();
} catch (e) {
@@ -244,13 +364,13 @@ export function apiSubmit(opts) {
handler: async () => {
if (isModalProcessing()) return;
setModalProcessing(true);
refreshModals();
try {
const b = body ? body() : {};
if (validate) {
const err = validate(b);
if (err) { toast(err, 'error'); return; }
}
refreshModals();
const res = await apiFetch(url, { method, body: b });
if (res.ok) {
const synced = res.data?.synced;
+268
View File
@@ -0,0 +1,268 @@
/**
* Hoover — auth.js
*
* Token refresh scheduler, session check, logout.
*/
import { apiFetch, setAuthToken, clearAuthTokens, redirectLogin, getAuthToken, tryRefreshToken, toast } from '../api.js?v=12';
/**
* Schedule a token refresh based on the access token TTL stored in localStorage.
* The refresh fires at TTL - 60 seconds to allow the browser to refresh smoothly.
*/
export function scheduleTokenRefresh() {
if (typeof window.__authRefreshTimer__ !== 'undefined') {
clearTimeout(window.__authRefreshTimer__);
}
const ttl = parseInt(localStorage.getItem('vw:access_ttl'), 10) || 900000;
const delay = Math.max(ttl - 60000, 30000);
window.__authRefreshTimer__ = setTimeout(async () => {
const ok = await tryRefreshToken();
if (ok) {
scheduleTokenRefresh();
}
}, delay);
}
/**
* Stop the refresh timer (e.g. user logs out or page unloads).
*/
export function cancelTokenRefresh() {
if (typeof window.__authRefreshTimer__ !== 'undefined') {
clearTimeout(window.__authRefreshTimer__);
window.__authRefreshTimer__ = undefined;
}
}
/**
* Check the current session by calling GET /api/auth/session.
* Returns true if the session is valid.
*
* @returns {Promise<boolean>}
*/
export async function checkSession() {
const result = await apiFetch('/api/auth/session');
if (result.ok) {
const { user, permissions } = result.data || {};
if (user) {
localStorage.setItem('vw:user', JSON.stringify(user));
if (permissions) {
localStorage.setItem('vw:permissions', JSON.stringify(permissions));
}
return true;
}
}
return false;
}
/**
* Logout: blacklist current token and clear auth state, then redirect to login.
*/
export async function logout() {
const token = getAuthToken();
if (token) {
try {
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Bearer ' + token,
};
const refresh = localStorage.getItem('vw:refresh');
await fetch('/api/auth/logout', {
method: 'POST',
headers,
credentials: 'same-origin',
body: JSON.stringify({ refresh_token: refresh || '' }),
});
} catch {
// ignore errors, we're clearing everything anyway
}
}
cancelTokenRefresh();
clearAuthTokens();
redirectLogin();
}
/**
* Initialize auth state on page load.
* Checks stored tokens, validates session, and schedules refresh.
*
* @returns {Promise<boolean>} true if authenticated
*/
export async function initAuth() {
const token = getAuthToken();
if (token) {
const saved = JSON.parse(localStorage.getItem('vw:user') || 'null');
if (saved) {
const ok = await checkSession();
if (ok) {
scheduleTokenRefresh();
return true;
}
}
}
clearAuthTokens();
return false;
}
/**
* Handle login response: store tokens, schedule refresh, redirect.
*
* @param {object} data — login/migrate response data
* @param {string} [redirectPath] — where to navigate after login
*/
export function handleLoginSuccess(data, redirectPath = '/dashboard') {
const { tokens, user, permissions } = data || {};
if (tokens) {
setAuthToken(tokens.access_token);
localStorage.setItem('vw:refresh', tokens.refresh_token);
localStorage.setItem('vw:access_ttl', String((data.access_ttl || 900) * 1000));
if (user) {
localStorage.setItem('vw:user', JSON.stringify(user));
if (permissions) {
localStorage.setItem('vw:permissions', JSON.stringify(permissions));
}
}
scheduleTokenRefresh();
}
window.location.hash = redirectPath;
}
/**
* Check if WebAuthn (passkeys) is supported in this browser.
*
* @returns {boolean}
*/
export function webauthnSupported() {
return typeof window !== 'undefined' && !!window.PublicKeyCredential;
}
/* ─── Base64url helpers ──────────────────────────────────────────────── */
/**
* Convert base64url string to ArrayBuffer.
* @param {string} b64url
* @returns {ArrayBuffer}
*/
function b64urlToArrayBuffer(b64url) {
const bin = atob(b64url.replace(/-/g, '+').replace(/_/g, '/'));
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) {
arr[i] = bin.charCodeAt(i);
}
return arr.buffer;
}
/**
* Convert ArrayBuffer to base64url string.
* @param {ArrayBuffer} buffer
* @returns {string}
*/
function arrayBufferToB64url(buffer) {
const bytes = new Uint8Array(buffer);
const bin = String.fromCharCode.apply(null, Array.from(bytes));
return btoa(bin)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
/* ─── WebAuthn navigator wrappers ────────────────────────────────────── */
/**
* Start a WebAuthn registration ceremony.
*
* Calls ``navigator.credentials.create()`` with the provided options,
* then returns the credential response as a JSON-serializable dict
* suitable for sending to the server.
*
* @param {object} registrationOptions — options from /webauthn/register-begin
* @returns {Promise<object>} credential response (id, rawId, type, response)
*/
export async function startRegistration(registrationOptions) {
if (!webauthnSupported()) {
throw new Error('WebAuthn is not supported in this browser');
}
const publicKey = {
challenge: b64urlToArrayBuffer(registrationOptions.challenge),
rp: registrationOptions.rp,
user: {
id: b64urlToArrayBuffer(registrationOptions.user.id),
name: registrationOptions.user.name,
displayName: registrationOptions.user.displayName,
},
pubKeyCredParams: registrationOptions.pubKeyCredParams,
timeout: registrationOptions.timeout,
};
if (registrationOptions.excludeCredentials) {
publicKey.excludeCredentials = registrationOptions.excludeCredentials.map(c => ({
...c,
id: b64urlToArrayBuffer(c.id),
}));
}
if (registrationOptions.authenticatorSelection) {
publicKey.authenticatorSelection = registrationOptions.authenticatorSelection;
}
const credential = await navigator.credentials.create({ publicKey });
const { id, rawId, type, response } = credential;
return {
id: arrayBufferToB64url(rawId),
rawId: arrayBufferToB64url(rawId),
type,
response: {
clientDataJSON: arrayBufferToB64url(response.clientDataJSON),
attestationObject: arrayBufferToB64url(response.attestationObject),
transports: response.getTransports ? response.getTransports() : [],
},
};
}
/**
* Start a WebAuthn authentication ceremony.
*
* Calls ``navigator.credentials.get()`` with the provided options,
* then returns the assertion response as a JSON-serializable dict.
*
* @param {object} authenticationOptions — options from /webauthn/authenticate-begin
* @returns {Promise<object>} assertion response (id, rawId, type, response)
*/
export async function startAuthentication(authenticationOptions) {
if (!webauthnSupported()) {
throw new Error('WebAuthn is not supported in this browser');
}
const publicKey = {
challenge: b64urlToArrayBuffer(authenticationOptions.challenge),
timeout: authenticationOptions.timeout,
userVerification: authenticationOptions.userVerification || 'preferred',
};
if (authenticationOptions.allowCredentials) {
publicKey.allowCredentials = authenticationOptions.allowCredentials.map(c => ({
...c,
id: b64urlToArrayBuffer(c.id),
}));
}
const credential = await navigator.credentials.get({ publicKey });
const { id, rawId, type, response } = credential;
return {
id: arrayBufferToB64url(rawId),
rawId: arrayBufferToB64url(rawId),
type,
response: {
clientDataJSON: arrayBufferToB64url(response.clientDataJSON),
authenticatorData: arrayBufferToB64url(response.authenticatorData),
signature: arrayBufferToB64url(response.signature),
userHandle: response.userHandle ? arrayBufferToB64url(response.userHandle) : null,
},
};
}
+2 -1
View File
@@ -191,7 +191,8 @@ export function formModal(inner, title, fields, actions) {
if (a.handler) {
const origHandler = a.handler;
btn.addEventListener('click', () => {
refreshModals();
btn.disabled = true;
btn.innerHTML = '<span class="btn-spinner"></span>';
origHandler();
});
}
+6 -3
View File
@@ -23,10 +23,13 @@ export { definePage, hComp } from './component.js?v=9';
export { createRouter, Link } from './router.js?v=9';
/* ── WebSocket ───────────────────────────────────────────────── */
export { connect, onMessage } from './websocket.js?v=9';
export { connect, onMessage } from './websocket.js?v=10';
/* ── API & Toast ─────────────────────────────────────────────── */
export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad, formAction } from './api.js?v=9';
export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad, formAction, setAuthToken, clearAuthTokens, getAuthToken } from './api.js?v=12';
/* ── UI Components: Auth ──────────────────────────────────────── */
export { scheduleTokenRefresh, cancelTokenRefresh, checkSession, logout, initAuth, handleLoginSuccess, webauthnSupported, startRegistration, startAuthentication } from './components/auth.js?v=2';
/* ── Model ───────────────────────────────────────────────────── */
export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js?v=9';
@@ -41,7 +44,7 @@ export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGr
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=9';
/* ── UI Components: Modal ────────────────────────────────────── */
export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal, isModalProcessing, setModalProcessing } from './components/modal.js?v=9';
export { openModal, closeModal, closeAllModals, modalVNodes, refreshModals, formModal, MultiSelectModal, QuickModal, isModalProcessing, setModalProcessing } from './components/modal.js?v=10';
/* ── UI Components: Apply ────────────────────────────────────── */
export { ApplyConfirm } from './components/applyconfirm.js?v=9';
+60 -3
View File
@@ -10,10 +10,41 @@ import { refreshByTopic } from './model.js?v=9';
let _wsConn = null;
let _wsReconnectMs = 0;
let _wsFailCount = 0;
/** Direct onMessage handlers: { topics, handler, unsubscribed }[] */
/** Direct onMessage handlers { topics, handler, unsubscribed }[] */
const _directHandlers = [];
/**
* Refresh the access token. Does NOT redirect on failure — the caller
* decides what to do when refresh fails.
*
* @returns {Promise<boolean>} true if token was refreshed
*/
async function _tryRefreshToken() {
const refresh = localStorage.getItem('vw:refresh');
if (!refresh) return false;
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ refresh_token: refresh }),
credentials: 'same-origin',
});
if (res.status !== 200) return false;
const json = await res.json();
if (!json.ok || !json.data?.tokens) return false;
const tokens = json.data.tokens;
window.__auth_token__ = tokens.access_token;
localStorage.setItem('vw:refresh', tokens.refresh_token);
localStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 900) * 1000));
localStorage.setItem('vw:user', JSON.stringify(json.data.user));
return true;
} catch {
return false;
}
}
/**
* Build the WebSocket URL. Supports an override via `window.__WS_URL__`
* (useful for proxy setups). Falls back to port 9091 when the current
@@ -25,17 +56,43 @@ function _wsUrl() {
return proto + '//' + location.host + '/ws';
}
/** Attempt a WebSocket connection. */
/** Attempt a WebSocket connection.
* Passes the JWT in the WebSocket subprotocol header (Sec-WebSocket-Protocol)
* instead of a query parameter, keeping it out of logs and browser history.
*/
function _wsConnect() {
if (_wsConn && _wsConn.readyState <= 1) return;
_wsConn = new WebSocket(_wsUrl());
const token = window.__auth_token__;
if (token) {
_wsConn = new WebSocket(_wsUrl(), ['Bearer ' + token]);
} else {
_wsConn = new WebSocket(_wsUrl());
}
_wsConn.onopen = () => {
_wsReconnectMs = 0;
_wsFailCount = 0;
};
_wsConn.onclose = () => {
if (!window.__auth_token__) return;
_wsFailCount++;
if (_wsFailCount >= 3) {
// Attempt token refresh after repeated failures. No redirect
// on failure — the reconnect loop continues.
(async () => {
const ok = await _tryRefreshToken();
if (ok) {
_wsFailCount = 0;
_wsReconnectMs = 0;
_wsConn = null;
setTimeout(_wsConnect, 100);
}
})();
}
_wsReconnectMs = Math.min(_wsReconnectMs * 2 + 1000, 15000);
setTimeout(_wsConnect, _wsReconnectMs);
};
+207
View File
@@ -0,0 +1,207 @@
/**
* Login page.
*
* Username + password form, plus "Sign in with passkey" button.
* On success: stores tokens and navigates to dashboard.
*/
import { h, definePage } from '/static/hoover/index.js?v=11';
import { apiFetch, toast, setAuthToken, getAuthToken } from '/static/hoover/api.js?v=12';
import {
handleLoginSuccess,
webauthnSupported,
startAuthentication,
} from '/static/hoover/components/auth.js';
import { html } from '/static/hoover/html.js?v=9';
function LoginPage() {
const hasWebAuthn = webauthnSupported();
return html`
<div class="login-page">
<div class="login-card">
<h2 class="login-title">Vacuum Wall</h2>
<p class="login-subtitle">Sign in to continue</p>
<form id="loginForm" class="login-form">
<div class="form-group">
<input
type="text"
id="loginUsername"
autocomplete="username"
placeholder="Username"
required
/>
</div>
<div id="loginPasswordGroup" class="form-group">
<input
type="password"
id="loginPassword"
autocomplete="current-password"
placeholder="Password"
required
/>
</div>
<div id="loginError" class="login-error"></div>
<button type="submit" class="btn btn-primary btn-login" id="loginBtn">Sign in</button>
</form>
${hasWebAuthn ? html`
<div class="login-divider">or</div>
<button type="button" class="btn btn-outline btn-passkey" id="passkeyBtn">
Sign in with passkey
</button>
` : ''}
</div>
</div>
`;
}
function handleLogin() {
const form = document.getElementById('loginForm');
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
await doPasswordLogin();
});
}
async function doPasswordLogin() {
const username = document.getElementById('loginUsername').value.trim();
const password = document.getElementById('loginPassword').value;
const errEl = document.getElementById('loginError');
if (!username || !password) {
errEl.textContent = 'Username and password are required';
return;
}
errEl.textContent = '';
const res = await apiFetch('/api/auth/login', {
method: 'POST',
body: { username, password },
});
if (res.ok) {
handleLoginSuccess(res.data);
toast('Welcome, ' + username, 'success');
} else {
errEl.textContent = res.error || 'Login failed';
}
}
function setupPasskeyButton() {
const passkeyBtn = document.getElementById('passkeyBtn');
if (!passkeyBtn) return;
const usernameInput = document.getElementById('loginUsername');
const passwordGroup = document.getElementById('loginPasswordGroup');
const loginBtn = document.getElementById('loginBtn');
const errEl = document.getElementById('loginError');
passkeyBtn.addEventListener('click', async () => {
errEl.textContent = '';
const username = usernameInput.value.trim();
if (!username) {
errEl.textContent = 'Enter your username first';
usernameInput.focus();
return;
}
passkeyBtn.disabled = true;
passkeyBtn.textContent = 'Checking...';
try {
const beginRes = await apiFetch('/api/auth/webauthn/authenticate-begin', {
method: 'POST',
body: { username },
});
if (!beginRes.ok) {
errEl.textContent = beginRes.error || 'Failed to start authentication';
passkeyBtn.disabled = false;
passkeyBtn.textContent = 'Sign in with passkey';
return;
}
if (beginRes.data && beginRes.data.no_webauthn) {
errEl.textContent = 'No passkey registered for this account';
passkeyBtn.disabled = false;
passkeyBtn.textContent = 'Sign in with passkey';
return;
}
const authOptions = beginRes.data;
passkeyBtn.textContent = 'Waiting for authenticator...';
const assertionResponse = await startAuthentication(authOptions);
passkeyBtn.textContent = 'Verifying...';
const finishRes = await apiFetch('/api/auth/webauthn/authenticate-finish', {
method: 'POST',
body: {
username,
assertion_response: assertionResponse,
auth_options: authOptions,
},
});
if (finishRes.ok) {
handleLoginSuccess(finishRes.data);
toast('Welcome, ' + username, 'success');
} else {
errEl.textContent = finishRes.error || 'Passkey authentication failed';
}
} catch (err) {
if (err.message && err.message.toLowerCase().includes('user cancelled')) {
errEl.textContent = 'Authentication cancelled';
} else {
errEl.textContent = err.message || 'Passkey authentication failed';
}
} finally {
passkeyBtn.disabled = false;
passkeyBtn.textContent = 'Sign in with passkey';
}
});
passkeyBtn.addEventListener('mouseenter', () => {
if (passwordGroup) {
passwordGroup.style.display = 'none';
}
});
passkeyBtn.addEventListener('mouseleave', () => {
if (passwordGroup) {
passwordGroup.style.display = '';
}
});
}
const Page = definePage({
init() {
document.title = 'Login — Vacuum Wall';
},
async load(state, abortController) {
if (getAuthToken()) {
try {
const res = await apiFetch('/api/auth/session');
if (res.ok) {
window.location.hash = '/dashboard';
return;
}
} catch {
// auth check failed, show login
}
}
},
render() {
return h('div', null, LoginPage());
},
});
handleLogin();
setupPasskeyButton();
export default Page;
+288
View File
@@ -0,0 +1,288 @@
/**
* WebAuthn credentials management page.
*
* Lists registered passkeys with name, transports, and sign count.
* Provides "Add passkey" and "Remove" actions.
*/
import {
html,
definePage,
reactive,
apiFetch,
toast,
openModal,
closeModal,
formModal,
refreshModals,
PageHeader,
Empty,
Table,
esc,
ActionCell,
Badge,
startRegistration,
webauthnSupported,
} from '/static/hoover/index.js?v=12';
import { isModalProcessing, setModalProcessing } from '/static/hoover/components/modal.js?v=9';
const state = reactive({ credentials: [], loading: true, refreshing: false, error: null });
async function loadCredentials() {
if (state.credentials.length) state.refreshing = true;
else state.loading = true;
state.error = null;
try {
const res = await apiFetch('/api/auth/webauthn/credentials');
if (res.ok) {
state.credentials = res.data || [];
} else {
state.error = res.error || 'Failed to load credentials';
}
} catch (e) {
state.error = e.message || 'Failed to load credentials';
}
state.loading = false;
state.refreshing = false;
}
function addCredentialModal() {
if (!webauthnSupported()) {
toast('WebAuthn is not supported in this browser', 'error');
return;
}
openModal((inner) => {
formModal(
inner,
'Add passkey',
[
{
label: 'Passkey name',
id: 'cred-name',
type: 'text',
placeholder: 'My laptop key',
},
],
[
{
label: 'Cancel',
cls: 'btn-outline',
action: 'c',
handler: () => closeModal(),
},
{
label: 'Register',
cls: 'btn-primary',
action: 'r',
processing: true,
handler: async () => {
if (isModalProcessing()) return;
setModalProcessing(true);
refreshModals();
const user = JSON.parse(localStorage.getItem('vw:user') || 'null');
const username = user?.username || '';
if (!username) {
toast('Username not available', 'error');
setModalProcessing(false);
refreshModals();
return;
}
try {
// Step 1: Get registration options
const beginRes = await apiFetch('/api/auth/webauthn/register-begin', {
method: 'POST',
body: { username },
});
if (!beginRes.ok) {
throw beginRes.error || 'Registration failed';
}
const options = beginRes.data;
// Step 2: Call browser authenticator
const credentialName = document.getElementById('cred-name')?.value?.trim() || '';
const credentialResponse = await startRegistration(options);
// Step 3: Verify with server
const finishRes = await apiFetch('/api/auth/webauthn/register-finish', {
method: 'POST',
body: {
username,
credential_response: credentialResponse,
registration_options: options,
name: credentialName,
},
});
if (!finishRes.ok) {
throw finishRes.error || 'Registration verification failed';
}
toast('Passkey registered', 'success');
closeModal();
loadCredentials();
} catch (e) {
if (!e.message.toLowerCase().includes('cancelled')) {
toast(e.message || 'Registration failed', 'error');
}
} finally {
setModalProcessing(false);
refreshModals();
}
},
},
],
);
});
}
function confirmRemove(credentialId, credentialName) {
openModal((inner) => {
formModal(
inner,
'Remove passkey',
[],
[
html`<p class="text-sm">Remove "<strong>${esc(credentialName || credentialId.slice(0, 12))}</strong>"?</p>`,
{
label: 'Cancel',
cls: 'btn-outline',
action: 'c',
handler: () => closeModal(),
},
{
label: 'Remove',
cls: 'btn-primary btn-danger',
action: 'r',
processing: true,
handler: async () => {
if (isModalProcessing()) return;
setModalProcessing(true);
refreshModals();
try {
const res = await apiFetch('/api/auth/webauthn/creds/' + encodeURIComponent(credentialId), {
method: 'DELETE',
});
if (!res.ok) {
throw res.error || 'Removal failed';
}
toast('PassKey removed', 'success');
closeModal();
loadCredentials();
} catch (e) {
toast(e.message || 'Removal failed', 'error');
} finally {
setModalProcessing(false);
refreshModals();
}
},
},
],
);
});
}
function CredentialsPage() {
if (state.loading && !state.credentials.length) {
return [
PageHeader({ title: 'Passkeys', subtitle: 'Manage your passkey credentials for passwordless authentication' }),
html`<div class="card" key="loading">
<div class="card-body loading">Loading...</div>
</div>`,
];
}
if (state.error) {
return [
PageHeader({ title: 'Passkeys', subtitle: 'Manage your passkey credentials for passwordless authentication' }),
html`<div class="card" key="error">
<div class="card-body error-msg">${esc(state.error)}</div>
</div>`,
];
}
if (!state.credentials.length) {
return [
PageHeader({
title: 'Passkeys',
subtitle: 'Manage your passkey credentials for passwordless authentication',
actions: html`<button class="btn btn-sm btn-primary" onClick=${() => webauthnSupported() && addCredentialModal()}>
Add passkey
</button>`,
}),
html`<Empty text="No passkeys registered">
<button class="btn btn-sm btn-primary"
onClick=${() => webauthnSupported() && addCredentialModal()}>
Add passkey
</button>
</Empty>`,
];
}
const cols = [
{ key: 'name', label: 'Name' },
{ key: 'transports', label: 'Transports' },
{ key: 'signCount', label: 'Uses' },
{ key: 'id', label: 'ID' },
{ key: '_action', label: '' },
];
const rows = state.credentials.map(c => ({
name: esc(c.name || 'Unnamed'),
transports: (c.transports || ['internal']).map(t =>
html`<Badge>${esc(t)}</Badge>`
),
signCount: c.sign_count ?? 0,
id: esc(c.id.slice(0, 12) + '...'),
_action: ActionCell({
actions: [
{
label: 'Remove',
cls: 'btn-danger',
icon: 'Delete',
onClick: () => confirmRemove(c.id, c.name),
},
],
}),
}));
const actions = webauthnSupported()
? html`<button class="btn btn-sm btn-primary" onClick=${() => addCredentialModal()}>
Add passkey
</button>`
: html`<span class="text-sm text-muted">WebAuthn not supported in this browser</span>`;
return [
PageHeader({
title: 'Passkeys',
subtitle: 'Manage your passkey credentials for passwordless authentication',
actions: actions,
}),
Table({ columns: cols, rows }),
];
}
const Page = definePage({
init() {
document.title = 'Passkeys — Vacuum Wall';
return state;
},
async load(s, abortController) {
await loadCredentials();
},
render() {
return CredentialsPage();
},
});
export default Page;
+259
View File
@@ -0,0 +1,259 @@
/**
* Users management page.
*
* Multi-user admin: list, create, edit permissions, delete users.
* Requires auth: rw permission.
*/
import { h, definePage, reactive, requestUpdate } from '/static/hoover/index.js?v=11';
import { html, PageHeader, Table, Badge, ConfirmDelete, Empty, Card, openModal, closeModal, formModal, apiFetch, toast, esc } from '/static/hoover/index.js?v=11';
const SUBSYSTEMS = [
{ key: 'firewall', label: 'Firewall' },
{ key: 'network', label: 'Network' },
{ key: 'dhcp', label: 'DHCP' },
{ key: 'proxy', label: 'Proxy' },
{ key: 'certs', label: 'Certs' },
{ key: 'wireguard', label: 'WireGuard' },
{ key: 'logs', label: 'Logs' },
{ key: 'status', label: 'Status' },
{ key: 'auth', label: 'Auth' },
];
function currentUser() {
const u = JSON.parse(localStorage.getItem('vw:user') || 'null');
return u ? u.username : '';
}
function hasAuthAdmin() {
const perms = JSON.parse(localStorage.getItem('vw:permissions') || 'null');
return perms && perms.auth === 'rw';
}
const state = reactive({ users: [], loading: true, refreshing: false, error: null });
async function loadUsers(abortController) {
if (abortController?.signal?.aborted) return;
if (state.users.length) state.refreshing = true;
else state.loading = true;
state.error = null;
try {
const [usersRes, countsRes] = await Promise.all([
apiFetch('/api/auth/users'),
apiFetch('/api/auth/webauthn/credential-counts'),
]);
if (abortController?.signal?.aborted) return;
if (usersRes.ok) {
const credCounts = countsRes.ok ? (countsRes.data || {}) : {};
state.users = (usersRes.data || []).map(u => ({
...u,
credCount: credCounts[u.username] || 0,
}));
} else {
state.error = usersRes.error || 'Failed to load users';
}
} catch (e) {
state.error = 'Failed to load users';
}
state.loading = false;
state.refreshing = false;
}
function permissionLevel(perms, subsystem) {
return perms[subsystem] || '—';
}
function openCreateUserModal() {
openModal((inner) => {
const fields = [
{ label: 'Username', id: 'new-username', placeholder: '3-32 chars: letters, digits, dash, underscore' },
{ label: 'Password', id: 'new-password', type: 'password', placeholder: 'At least 8 characters' },
];
// Add subsystem permission selects
for (const sub of SUBSYSTEMS) {
fields.push({
label: sub.label,
id: 'perm-' + sub.key,
tag: 'select',
options: ['—', 'read', 'rw'],
});
}
const actions = [
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
{
label: 'Create',
cls: 'btn-primary',
action: 's',
processing: true,
handler: async () => {
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value;
if (!username) { toast('Username is required', 'error'); return; }
if (!password || password.length < 8) { toast('Password must be at least 8 characters', 'error'); return; }
const perms = {};
for (const sub of SUBSYSTEMS) {
const level = document.getElementById('perm-' + sub.key).value;
if (level && level !== '—') {
perms[sub.key] = level;
}
}
const res = await apiFetch('/api/auth/users', {
method: 'POST',
body: { username, password, permissions: perms },
});
if (res.ok) {
toast('User ' + username + ' created', 'success');
closeModal();
loadUsers();
} else {
toast(res.error || 'Failed to create user', 'error');
}
},
},
];
formModal(inner, 'Create User', fields, actions);
});
}
function openEditPermissionsModal(user) {
openModal((inner) => {
const perms = user.permissions || {};
const fields = [
{ label: 'Username', id: 'edit-username', value: user.username, type: 'text' },
];
for (const sub of SUBSYSTEMS) {
fields.push({
label: sub.label,
id: 'edit-perm-' + sub.key,
tag: 'select',
options: [['', '—'], ['read', 'read'], ['rw', 'rw']],
});
}
const actions = [
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
{
label: 'Save',
cls: 'btn-primary',
action: 's',
processing: true,
handler: async () => {
const perms = {};
for (const sub of SUBSYSTEMS) {
const level = document.getElementById('edit-perm-' + sub.key).value;
if (level && level !== '—') {
perms[sub.key] = level;
}
}
const res = await apiFetch('/api/auth/users/' + encodeURIComponent(user.username), {
method: 'POST',
body: { permissions: perms },
});
if (res.ok) {
toast('Permissions updated', 'success');
closeModal();
loadUsers();
} else {
toast(res.error || 'Failed to update permissions', 'error');
}
},
},
];
formModal(inner, 'Edit Permissions — ' + esc(user.username), fields, actions);
// Pre-select permission values
for (const sub of SUBSYSTEMS) {
const el = document.getElementById('edit-perm-' + sub.key);
if (el) {
el.value = perms[sub.key] || '';
}
}
});
}
function UsersPage() {
if (state.loading) {
return html`<div class="page-header"><h1>Users</h1><p>Manage users and permissions</p></div>
<div class="card"><div class="card-body"><p class="text-muted">Loading...</p></div></div>`;
}
if (state.error && !state.users.length) {
return html`<div class="page-header"><h1>Users</h1><p>Manage users and permissions</p></div>
<div class="card"><div class="card-body"><p class="text-danger">${esc(state.error)}</p></div></div>`;
}
const myUser = currentUser();
const rows = state.users.map(u => {
const isMe = u.username === myUser;
const permBadges = SUBSYSTEMS.map(sub => {
const level = permissionLevel(u.permissions, sub.key);
const variant = level === 'rw' ? 'info' : level === 'read' ? 'secondary' : 'light';
if (level === '—') return null;
return html`<span key=${sub.key}><${Badge} text=${level} variant=${variant} /> ${sub.label} </span>`;
}).filter(Boolean);
return html`<tr key=${u.username}>
<td><strong>${esc(u.username)}</strong></td>
<td class="text-sm">${u.credCount || 0}</td>
<td class="text-sm text-muted">${permBadges.length ? permBadges.join(' ') : '—'}</td>
<td>
<button class="btn btn-sm btn-outline" onClick=${() => openEditPermissionsModal(u)}>Edit</button>
${isMe ? html`<span class="text-muted text-sm">(you)</span>` :
html`<${ConfirmDelete}
url=${'/api/auth/users/' + encodeURIComponent(u.username)}
deleteKey=${u.username}
message=${'Delete user ' + esc(u.username) + '? This cannot be undone.'}
success=${'User ' + esc(u.username) + ' deleted'}
onRefresh=${() => loadUsers()} />`}
</td>
</tr>`;
});
return [
PageHeader({
title: 'Users',
subtitle: 'Manage users and permissions',
actions: html`<button class="btn btn-primary" onClick=${() => openCreateUserModal()}>Add User</button>`,
}),
html`<div class="card">
<div class="card-body">
<${Table}
columns=${['Username', 'Passkeys', 'Permissions', 'Actions']}
rows=${rows}
emptyText="No users found" />
</div>
</div>`,
];
}
export default definePage({
init() {
return state;
},
async load(s, abortController) {
if (!hasAuthAdmin()) {
s.error = 'Admin access required';
s.loading = false;
return;
}
await loadUsers(abortController);
},
render(s) {
return h('div', null, UsersPage());
},
});