Add an admin page for managing collector API keys
Admins (Microsoft 365 sign-in names in BACKUP_DASH_ADMINS) get an Admin link to /admin.html, where they can create, list and revoke the API keys collectors use, instead of running manage.py on the server. Backend - /api/v1/admin/keys (list, create, revoke), admins only (403 otherwise); the user comes from the headers nginx sets after sign-in - Changes need an X-Requested-By header, which another site's page can't add, so a signed-in admin's browser can't be used to forge them - A new key is returned once; only its hash is stored. Names are unique for good, so revoked keys' history stays unambiguous - Keys record who created/revoked them and the company they last reported for (schema v2; databases migrate automatically on start) - /api/v1/me reports is_admin; manage.py shares the key code and records itself as creator/revoker Frontend - admin.html / admin.js: name validation shown after interaction (:user-invalid, aria-invalid kept in sync), the one-time key with a copy button (falls back to selecting it if clipboard access is refused), a key table flagging keys quiet for over 24 hours, and revoking confirmed in a <dialog>; results announced in a status region - Shared el() and signed-in helpers moved to dom.js / signed-in.js - Mock mode (?mock=1) includes sample keys Tested: 15 new backend tests (access, forged requests, names, duplicates, revoking, last-used company, v1 -> v2 upgrade); in the browser, every admin flow in mock mode and at phone width; end to end through the nginx config with the real API (a key created on the API works for ingest; revoking it makes ingest 401; non-admins see an explanation and no Admin link). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -79,9 +79,23 @@ The OpenAPI schema is at `/api/v1/openapi.json`.
|
|||||||
signed-in user can see every tenant. Sessions last 12 hours; signing in again is usually silent.
|
signed-in user can see every tenant. Sessions last 12 hours; signing in again is usually silent.
|
||||||
The dashboard shows who's signed in; **Sign out** also signs out of Microsoft in that browser,
|
The dashboard shows who's signed in; **Sign out** also signs out of Microsoft in that browser,
|
||||||
otherwise the next visit would sign straight back in.
|
otherwise the next visit would sign straight back in.
|
||||||
|
- **Admins:** the sign-in names in `BACKUP_DASH_ADMINS` (in `/etc/backup-dashboard/backup-dashboard.env`,
|
||||||
|
comma-separated) get an **Admin** link to the API keys page. Restart the API after changing it.
|
||||||
- **Collectors:** per-collector bearer keys, checked by the app. Only a SHA-256 hash is stored.
|
- **Collectors:** per-collector bearer keys, checked by the app. Only a SHA-256 hash is stored.
|
||||||
To revoke one, run `python manage.py revoke-key NAME`. `/api/v1/ingest` is the only path that
|
`/api/v1/ingest` is the only path that doesn't need Microsoft sign-in.
|
||||||
doesn't need Microsoft sign-in.
|
|
||||||
|
### Managing API keys
|
||||||
|
|
||||||
|
On the **Admin** page (`/admin.html`), admins can:
|
||||||
|
- **Create** a key. It's shown once, with a copy button. Paste it when a collector's installer
|
||||||
|
asks for the API key.
|
||||||
|
- **See** each key's status, when it was last used and for which company, and who created it.
|
||||||
|
Active keys quiet for over 24 hours are flagged, since collectors report hourly.
|
||||||
|
- **Revoke** a key. The collectors using it are refused from their next run. This can't be undone,
|
||||||
|
and names stay taken, so the history stays unambiguous.
|
||||||
|
|
||||||
|
The same is possible from the server with `manage.py` (`create-key`, `list-keys`, `revoke-key`),
|
||||||
|
recorded as created/revoked by `manage.py (<user>)`.
|
||||||
|
|
||||||
### Microsoft 365 sign-in setup
|
### Microsoft 365 sign-in setup
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Admin API: managing collector API keys. Only for users in BACKUP_DASH_ADMINS."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, StringConstraints
|
||||||
|
|
||||||
|
from . import db, keys
|
||||||
|
from .auth import require_admin, require_page_request
|
||||||
|
|
||||||
|
log = logging.getLogger("backup_dashboard")
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/admin", dependencies=[Depends(require_admin)])
|
||||||
|
|
||||||
|
|
||||||
|
class NewKey(BaseModel):
|
||||||
|
name: Annotated[str, StringConstraints(strip_whitespace=True, pattern=f"^{keys.NAME_PATTERN}$")]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/keys")
|
||||||
|
def list_keys(conn: sqlite3.Connection = Depends(db.get_db)):
|
||||||
|
return {"keys": keys.list_keys(conn)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/keys", status_code=201, dependencies=[Depends(require_page_request)])
|
||||||
|
def create_key(body: NewKey, admin: str = Depends(require_admin), conn: sqlite3.Connection = Depends(db.get_db)):
|
||||||
|
try:
|
||||||
|
record, secret = keys.create_key(conn, body.name, created_by=admin)
|
||||||
|
except keys.KeyNameTaken:
|
||||||
|
raise HTTPException(409, f"A key named {body.name!r} already exists (revoked keys keep their names)") from None
|
||||||
|
log.info("admin %s created API key %r", admin, body.name)
|
||||||
|
# The key itself is returned this once; only its hash is stored
|
||||||
|
return {"key": record, "secret": secret}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/keys/{key_id}/revoke", dependencies=[Depends(require_page_request)])
|
||||||
|
def revoke_key(key_id: int, admin: str = Depends(require_admin), conn: sqlite3.Connection = Depends(db.get_db)):
|
||||||
|
record = keys.revoke_key(conn, key_id, revoked_by=admin)
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(404, "Key not found")
|
||||||
|
log.info("admin %s revoked API key %r", admin, record["name"])
|
||||||
|
return {"key": record}
|
||||||
+35
-4
@@ -1,14 +1,16 @@
|
|||||||
"""Collector (machine) authentication.
|
"""Authentication and authorisation.
|
||||||
|
|
||||||
Dashboard *users* are authenticated by nginx in front of the app (Microsoft 365 sign-in via
|
Collectors (machines) authenticate with API keys, checked here. Dashboard *users* sign in with
|
||||||
oauth2-proxy), so nothing here deals with people.
|
Microsoft 365 in nginx (via oauth2-proxy), which then tells the app who they are in
|
||||||
|
X-Forwarded-* headers it sets itself (a browser can't supply them). The app only decides whether
|
||||||
|
that user is an admin.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException
|
from fastapi import Depends, Header, HTTPException, Request
|
||||||
|
|
||||||
from .db import get_db
|
from .db import get_db
|
||||||
from .timeutil import to_db, utcnow
|
from .timeutil import to_db, utcnow
|
||||||
@@ -25,6 +27,35 @@ def hash_key(key: str) -> str:
|
|||||||
return hashlib.sha256(key.encode()).hexdigest()
|
return hashlib.sha256(key.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def current_user(request: Request) -> str | None:
|
||||||
|
"""The signed-in user's sign-in name, lowercase; None without Microsoft sign-in (e.g. dev)."""
|
||||||
|
user = request.headers.get("x-forwarded-email") or request.headers.get("x-forwarded-preferred-username")
|
||||||
|
return user.strip().lower() if user and user.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin(request: Request) -> bool:
|
||||||
|
user = current_user(request)
|
||||||
|
return user is not None and user in request.app.state.settings.admins
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(request: Request) -> str:
|
||||||
|
if not is_admin(request):
|
||||||
|
raise HTTPException(403, "Admins only. Admins are listed in BACKUP_DASH_ADMINS on the server.")
|
||||||
|
return current_user(request)
|
||||||
|
|
||||||
|
|
||||||
|
# Changes made from the admin page must carry this header. A page on another site can't add a
|
||||||
|
# custom header to a request to the dashboard (the browser would first ask the API, which allows
|
||||||
|
# no other origins), so a signed-in admin's browser can't be used to forge them.
|
||||||
|
PAGE_HEADER = "X-Requested-By"
|
||||||
|
PAGE_HEADER_VALUE = "backup-dashboard"
|
||||||
|
|
||||||
|
|
||||||
|
def require_page_request(x_requested_by: str | None = Header(None)) -> None:
|
||||||
|
if x_requested_by != PAGE_HEADER_VALUE:
|
||||||
|
raise HTTPException(403, f"Missing {PAGE_HEADER} header")
|
||||||
|
|
||||||
|
|
||||||
def require_ingest_key(
|
def require_ingest_key(
|
||||||
authorization: str | None = Header(None),
|
authorization: str | None = Header(None),
|
||||||
conn: sqlite3.Connection = Depends(get_db),
|
conn: sqlite3.Connection = Depends(get_db),
|
||||||
|
|||||||
+18
-3
@@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
SCHEMA_VERSION = 1
|
SCHEMA_VERSION = 2
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS tenants (
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
@@ -67,6 +67,17 @@ def connect(path: Path | str) -> sqlite3.Connection:
|
|||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
# Changes since SCHEMA (which is v1), applied in order to bring any database up to SCHEMA_VERSION
|
||||||
|
MIGRATIONS = {
|
||||||
|
2: [
|
||||||
|
# Who created/revoked a key from the admin page, and which company it last reported for
|
||||||
|
"ALTER TABLE api_keys ADD COLUMN created_by TEXT",
|
||||||
|
"ALTER TABLE api_keys ADD COLUMN revoked_by TEXT",
|
||||||
|
"ALTER TABLE api_keys ADD COLUMN last_used_tenant TEXT",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def init_db(path: Path | str) -> None:
|
def init_db(path: Path | str) -> None:
|
||||||
conn = connect(path)
|
conn = connect(path)
|
||||||
try:
|
try:
|
||||||
@@ -74,8 +85,12 @@ def init_db(path: Path | str) -> None:
|
|||||||
version = conn.execute("PRAGMA user_version").fetchone()[0]
|
version = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version > SCHEMA_VERSION:
|
if version > SCHEMA_VERSION:
|
||||||
raise RuntimeError(f"Database schema v{version} is newer than this app (v{SCHEMA_VERSION})")
|
raise RuntimeError(f"Database schema v{version} is newer than this app (v{SCHEMA_VERSION})")
|
||||||
conn.executescript(SCHEMA)
|
conn.executescript(SCHEMA) # v1 tables, for a new database
|
||||||
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
with transaction(conn):
|
||||||
|
for target in range(max(version, 1) + 1, SCHEMA_VERSION + 1):
|
||||||
|
for statement in MIGRATIONS[target]:
|
||||||
|
conn.execute(statement)
|
||||||
|
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Collector API keys: create, list, revoke. Used by the admin API and manage.py."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .auth import generate_key, hash_key
|
||||||
|
from .timeutil import to_db, utcnow
|
||||||
|
|
||||||
|
# Letters, digits, '.', '_', '-'; starts with a letter or digit. E.g. "veeam-acme", "mars.fs01"
|
||||||
|
NAME_PATTERN = r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}"
|
||||||
|
|
||||||
|
_FIELDS = "id, name, created_at, created_by, last_used_at, last_used_tenant, revoked_at, revoked_by"
|
||||||
|
|
||||||
|
|
||||||
|
class KeyNameTaken(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def valid_name(name: str) -> bool:
|
||||||
|
return re.fullmatch(NAME_PATTERN, name) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def list_keys(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||||
|
# Active keys first, then by name
|
||||||
|
rows = conn.execute(f"SELECT {_FIELDS} FROM api_keys ORDER BY revoked_at IS NOT NULL, name").fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_key(conn: sqlite3.Connection, key_id: int) -> dict[str, Any] | None:
|
||||||
|
row = conn.execute(f"SELECT {_FIELDS} FROM api_keys WHERE id = ?", (key_id,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_key(conn: sqlite3.Connection, name: str, created_by: str) -> tuple[dict[str, Any], str]:
|
||||||
|
"""Returns the stored key's record and the key itself, which isn't stored and can't be shown again."""
|
||||||
|
secret = generate_key()
|
||||||
|
try:
|
||||||
|
key_id = conn.execute(
|
||||||
|
"INSERT INTO api_keys (name, key_hash, created_at, created_by) VALUES (?, ?, ?, ?) RETURNING id",
|
||||||
|
(name, hash_key(secret), to_db(utcnow()), created_by),
|
||||||
|
).fetchone()[0]
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
# Names stay taken after revocation, so the audit trail stays unambiguous
|
||||||
|
raise KeyNameTaken(name) from None
|
||||||
|
return get_key(conn, key_id), secret
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_key(conn: sqlite3.Connection, key_id: int, revoked_by: str) -> dict[str, Any] | None:
|
||||||
|
"""Revokes the key (no-op if already revoked). Returns its record, or None if there's no such key."""
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE api_keys SET revoked_at = ?, revoked_by = ? WHERE id = ? AND revoked_at IS NULL",
|
||||||
|
(to_db(utcnow()), revoked_by, key_id),
|
||||||
|
)
|
||||||
|
return get_key(conn, key_id)
|
||||||
+6
-2
@@ -7,8 +7,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request
|
||||||
|
|
||||||
from . import db, repo
|
from . import admin, db, repo
|
||||||
from .auth import require_ingest_key
|
from .auth import is_admin, require_ingest_key
|
||||||
from .models import IngestIn
|
from .models import IngestIn
|
||||||
from .settings import HISTORY_DAYS, Settings, load_settings
|
from .settings import HISTORY_DAYS, Settings, load_settings
|
||||||
from .timeutil import to_db, utcnow
|
from .timeutil import to_db, utcnow
|
||||||
@@ -32,6 +32,7 @@ def me(request: Request):
|
|||||||
return {
|
return {
|
||||||
"email": request.headers.get("x-forwarded-email") or None,
|
"email": request.headers.get("x-forwarded-email") or None,
|
||||||
"name": request.headers.get("x-forwarded-preferred-username") or request.headers.get("x-forwarded-user") or None,
|
"name": request.headers.get("x-forwarded-preferred-username") or request.headers.get("x-forwarded-user") or None,
|
||||||
|
"is_admin": is_admin(request),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -75,6 +76,8 @@ def ingest(
|
|||||||
conn: sqlite3.Connection = Depends(db.get_db),
|
conn: sqlite3.Connection = Depends(db.get_db),
|
||||||
):
|
):
|
||||||
job_id, run_id = repo.ingest(conn, item)
|
job_id, run_id = repo.ingest(conn, item)
|
||||||
|
# Shown on the admin page, so you can tell what each key is used for
|
||||||
|
conn.execute("UPDATE api_keys SET last_used_tenant = ? WHERE name = ?", (item.tenant, key_name))
|
||||||
log.info("ingest key=%s tenant=%r job=%r run=%s", key_name, item.tenant, item.job.name, run_id)
|
log.info("ingest key=%s tenant=%r job=%r run=%s", key_name, item.tenant, item.job.name, run_id)
|
||||||
return {"job_id": job_id, "run_id": run_id}
|
return {"job_id": job_id, "run_id": run_id}
|
||||||
|
|
||||||
@@ -118,6 +121,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
)
|
)
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.include_router(router)
|
app.include_router(router)
|
||||||
|
app.include_router(admin.router)
|
||||||
|
|
||||||
if settings.serve_static:
|
if settings.serve_static:
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ class Settings:
|
|||||||
retention_days: int = 90
|
retention_days: int = 90
|
||||||
# Dev only: serve ../public from the API so you can run without nginx.
|
# Dev only: serve ../public from the API so you can run without nginx.
|
||||||
serve_static: bool = False
|
serve_static: bool = False
|
||||||
|
# Microsoft 365 sign-in names allowed into the admin pages (lowercase)
|
||||||
|
admins: frozenset[str] = frozenset()
|
||||||
|
|
||||||
|
|
||||||
def read_env_file(path: Path) -> dict[str, str]:
|
def read_env_file(path: Path) -> dict[str, str]:
|
||||||
@@ -40,4 +42,10 @@ def load_settings() -> Settings:
|
|||||||
db_path=Path(env.get("BACKUP_DASH_DB", "backup-dashboard.db")),
|
db_path=Path(env.get("BACKUP_DASH_DB", "backup-dashboard.db")),
|
||||||
retention_days=max(HISTORY_DAYS, int(env.get("BACKUP_DASH_RETENTION_DAYS", "90"))),
|
retention_days=max(HISTORY_DAYS, int(env.get("BACKUP_DASH_RETENTION_DAYS", "90"))),
|
||||||
serve_static=env.get("BACKUP_DASH_SERVE_STATIC") == "1",
|
serve_static=env.get("BACKUP_DASH_SERVE_STATIC") == "1",
|
||||||
|
admins=parse_admins(env.get("BACKUP_DASH_ADMINS", "")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_admins(value: str) -> frozenset[str]:
|
||||||
|
"""'[email protected], [email protected]' -> {'[email protected]', '[email protected]'}"""
|
||||||
|
return frozenset(part.strip().lower() for part in value.replace(";", ",").split(",") if part.strip())
|
||||||
|
|||||||
+24
-17
@@ -15,41 +15,48 @@ Run it as the service user so files it creates stay writable by the API:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import getpass
|
||||||
import random
|
import random
|
||||||
import sys
|
import sys
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app import db, repo
|
from app import db, keys, repo
|
||||||
from app.auth import generate_key, hash_key
|
|
||||||
from app.models import IngestIn
|
from app.models import IngestIn
|
||||||
from app.settings import DEFAULT_ENV_FILE, load_settings
|
from app.settings import DEFAULT_ENV_FILE, load_settings
|
||||||
from app.timeutil import to_db, utcnow
|
from app.timeutil import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
def cli_user() -> str:
|
||||||
|
# Recorded as who created/revoked a key; the admin page records the signed-in user instead
|
||||||
|
try:
|
||||||
|
return f"manage.py ({getpass.getuser()})"
|
||||||
|
except Exception:
|
||||||
|
return "manage.py"
|
||||||
|
|
||||||
|
|
||||||
def create_key(conn, args):
|
def create_key(conn, args):
|
||||||
key = generate_key()
|
if not keys.valid_name(args.name):
|
||||||
|
sys.exit("Key names use letters, digits, '.', '_' and '-', starting with a letter or digit")
|
||||||
try:
|
try:
|
||||||
conn.execute(
|
_, secret = keys.create_key(conn, args.name, created_by=cli_user())
|
||||||
"INSERT INTO api_keys (name, key_hash, created_at) VALUES (?, ?, ?)",
|
except keys.KeyNameTaken:
|
||||||
(args.name, hash_key(key), to_db(utcnow())),
|
|
||||||
)
|
|
||||||
except db.sqlite3.IntegrityError:
|
|
||||||
sys.exit(f"A key named {args.name!r} already exists")
|
sys.exit(f"A key named {args.name!r} already exists")
|
||||||
print(f"API key for {args.name!r} (shown once, store it in the collector's config):\n{key}")
|
print(f"API key for {args.name!r} (shown once, store it in the collector's config):\n{secret}")
|
||||||
|
|
||||||
|
|
||||||
def list_keys(conn, args):
|
def list_keys(conn, args):
|
||||||
for r in conn.execute("SELECT name, created_at, last_used_at, revoked_at FROM api_keys ORDER BY name"):
|
for k in keys.list_keys(conn):
|
||||||
state = f"revoked {r['revoked_at']}" if r["revoked_at"] else "active"
|
state = f"revoked {k['revoked_at']}" if k["revoked_at"] else "active"
|
||||||
print(f"{r['name']:<30} {state:<30} created {r['created_at']} last used {r['last_used_at'] or 'never'}")
|
tenant = f" ({k['last_used_tenant']})" if k["last_used_tenant"] else ""
|
||||||
|
print(f"{k['name']:<30} {state:<30} created {k['created_at']} last used {k['last_used_at'] or 'never'}{tenant}")
|
||||||
|
|
||||||
|
|
||||||
def revoke_key(conn, args):
|
def revoke_key(conn, args):
|
||||||
cur = conn.execute(
|
row = conn.execute("SELECT id FROM api_keys WHERE name = ? AND revoked_at IS NULL", (args.name,)).fetchone()
|
||||||
"UPDATE api_keys SET revoked_at = ? WHERE name = ? AND revoked_at IS NULL", (to_db(utcnow()), args.name)
|
if row is None:
|
||||||
)
|
sys.exit(f"No active key named {args.name!r}")
|
||||||
sys.exit(0 if cur.rowcount else f"No active key named {args.name!r}")
|
keys.revoke_key(conn, row["id"], revoked_by=cli_user())
|
||||||
|
|
||||||
|
|
||||||
def list_jobs(conn, args):
|
def list_jobs(conn, args):
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import sqlite3
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.main import create_app
|
||||||
|
from app.settings import Settings, parse_admins
|
||||||
|
|
||||||
|
ADMIN = {"X-Forwarded-Email": "[email protected]"}
|
||||||
|
PAGE = {"X-Requested-By": "backup-dashboard"}
|
||||||
|
ADMIN_PAGE = ADMIN | PAGE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path):
|
||||||
|
settings = Settings(db_path=tmp_path / "test.db", admins=frozenset({"[email protected]"}))
|
||||||
|
with TestClient(create_app(settings)) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def create(client, name="veeam-acme", headers=ADMIN_PAGE):
|
||||||
|
return client.post("/api/v1/admin/keys", json={"name": name}, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
def ingest(client, secret, tenant="Acme"):
|
||||||
|
return client.post(
|
||||||
|
"/api/v1/ingest",
|
||||||
|
json={"tenant": tenant, "source_system": "Veeam B&R", "job": {"external_id": "j1", "name": "Job"}},
|
||||||
|
headers={"Authorization": f"Bearer {secret}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_admins():
|
||||||
|
assert parse_admins(" [email protected], [email protected];[email protected] ,") == {
|
||||||
|
"[email protected]", "[email protected]", "[email protected]"}
|
||||||
|
assert parse_admins("") == frozenset()
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_says_whether_the_user_is_an_admin(client):
|
||||||
|
assert client.get("/api/v1/me", headers=ADMIN).json()["is_admin"] is True
|
||||||
|
assert client.get("/api/v1/me", headers={"X-Forwarded-Email": "[email protected]"}).json()["is_admin"] is False
|
||||||
|
assert client.get("/api/v1/me").json()["is_admin"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("headers", [{}, {"X-Forwarded-Email": "[email protected]"}])
|
||||||
|
def test_admin_pages_are_admins_only(client, headers):
|
||||||
|
assert client.get("/api/v1/admin/keys", headers=headers).status_code == 403
|
||||||
|
assert create(client, headers=headers | PAGE).status_code == 403
|
||||||
|
assert client.post("/api/v1/admin/keys/1/revoke", headers=headers | PAGE).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_match_ignores_case_and_accepts_the_sign_in_name(client):
|
||||||
|
assert client.get("/api/v1/admin/keys", headers={"X-Forwarded-Email": "[email protected]"}).status_code == 200
|
||||||
|
assert client.get("/api/v1/admin/keys", headers={"X-Forwarded-Preferred-Username": "[email protected]"}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_changes_need_the_page_header(client):
|
||||||
|
# Without it, a page on another site could make a signed-in admin's browser send the request
|
||||||
|
assert create(client, headers=ADMIN).status_code == 403
|
||||||
|
secret = create(client).json()["secret"]
|
||||||
|
key_id = client.get("/api/v1/admin/keys", headers=ADMIN).json()["keys"][0]["id"]
|
||||||
|
assert client.post(f"/api/v1/admin/keys/{key_id}/revoke", headers=ADMIN).status_code == 403
|
||||||
|
assert ingest(client, secret).status_code == 200 # still active
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", ["", " ", "-leading-dash", "has space", "a" * 65, "semi;colon"])
|
||||||
|
def test_rejects_bad_key_names(client, name):
|
||||||
|
assert create(client, name=name).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_created_key_is_shown_once_and_works_for_ingest(client):
|
||||||
|
r = create(client, name=" veeam-acme ")
|
||||||
|
assert r.status_code == 201
|
||||||
|
body = r.json()
|
||||||
|
assert body["secret"].startswith("bdk_")
|
||||||
|
assert body["key"]["name"] == "veeam-acme"
|
||||||
|
assert body["key"]["created_by"] == "[email protected]"
|
||||||
|
listed = client.get("/api/v1/admin/keys", headers=ADMIN).json()["keys"]
|
||||||
|
assert "secret" not in listed[0] and "key_hash" not in listed[0]
|
||||||
|
assert ingest(client, body["secret"]).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_records_when_and_for_which_company_it_was_last_used(client):
|
||||||
|
secret = create(client).json()["secret"]
|
||||||
|
ingest(client, secret, tenant="Acme Corp")
|
||||||
|
key = client.get("/api/v1/admin/keys", headers=ADMIN).json()["keys"][0]
|
||||||
|
assert key["last_used_at"] is not None
|
||||||
|
assert key["last_used_tenant"] == "Acme Corp"
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_names_are_refused_even_after_revoking(client):
|
||||||
|
key_id = create(client).json()["key"]["id"]
|
||||||
|
assert create(client).status_code == 409
|
||||||
|
client.post(f"/api/v1/admin/keys/{key_id}/revoke", headers=ADMIN_PAGE)
|
||||||
|
assert create(client).status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoking_stops_the_key_and_records_who(client):
|
||||||
|
created = create(client).json()
|
||||||
|
r = client.post(f"/api/v1/admin/keys/{created['key']['id']}/revoke", headers=ADMIN_PAGE)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["key"]["revoked_by"] == "[email protected]"
|
||||||
|
assert r.json()["key"]["revoked_at"] is not None
|
||||||
|
assert ingest(client, created["secret"]).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoking_twice_keeps_the_first_revocation(client):
|
||||||
|
key_id = create(client).json()["key"]["id"]
|
||||||
|
first = client.post(f"/api/v1/admin/keys/{key_id}/revoke", headers=ADMIN_PAGE).json()["key"]
|
||||||
|
again = client.post(f"/api/v1/admin/keys/{key_id}/revoke",
|
||||||
|
headers={"X-Forwarded-Email": "[email protected]"} | PAGE).json()["key"]
|
||||||
|
assert again["revoked_at"] == first["revoked_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoking_an_unknown_key_is_404(client):
|
||||||
|
assert client.post("/api/v1/admin/keys/999/revoke", headers=ADMIN_PAGE).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_lists_active_keys_first(client):
|
||||||
|
for name in ("b-key", "a-key", "c-key"):
|
||||||
|
create(client, name=name)
|
||||||
|
a_id = next(k["id"] for k in client.get("/api/v1/admin/keys", headers=ADMIN).json()["keys"] if k["name"] == "a-key")
|
||||||
|
client.post(f"/api/v1/admin/keys/{a_id}/revoke", headers=ADMIN_PAGE)
|
||||||
|
names = [k["name"] for k in client.get("/api/v1/admin/keys", headers=ADMIN).json()["keys"]]
|
||||||
|
assert names == ["b-key", "c-key", "a-key"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrades_a_version_1_database_keeping_its_keys(tmp_path):
|
||||||
|
path = tmp_path / "old.db"
|
||||||
|
conn = sqlite3.connect(path)
|
||||||
|
conn.executescript(db.SCHEMA) # the v1 schema, as deployed before admin pages
|
||||||
|
conn.execute("INSERT INTO api_keys (name, key_hash, created_at) VALUES ('old-key', 'abc', '2026-10-01T00:00:00Z')")
|
||||||
|
conn.execute("PRAGMA user_version = 1")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
db.init_db(path)
|
||||||
|
db.init_db(path) # and running it again is harmless
|
||||||
|
|
||||||
|
conn = db.connect(path)
|
||||||
|
assert conn.execute("PRAGMA user_version").fetchone()[0] == db.SCHEMA_VERSION
|
||||||
|
row = dict(conn.execute("SELECT * FROM api_keys").fetchone())
|
||||||
|
assert row["name"] == "old-key"
|
||||||
|
assert {"created_by", "revoked_by", "last_used_tenant"} <= row.keys()
|
||||||
|
conn.close()
|
||||||
@@ -125,6 +125,6 @@ def test_tenant_name_is_case_insensitive(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_me_reports_signed_in_user_from_proxy_headers(client):
|
def test_me_reports_signed_in_user_from_proxy_headers(client):
|
||||||
assert client.get("/api/v1/me").json() == {"email": None, "name": None}
|
assert client.get("/api/v1/me").json() == {"email": None, "name": None, "is_admin": False}
|
||||||
r = client.get("/api/v1/me", headers={"X-Forwarded-Email": "[email protected]", "X-Forwarded-User": "abc-123"})
|
r = client.get("/api/v1/me", headers={"X-Forwarded-Email": "[email protected]", "X-Forwarded-User": "abc-123"})
|
||||||
assert r.json() == {"email": "[email protected]", "name": "abc-123"}
|
assert r.json() == {"email": "[email protected]", "name": "abc-123", "is_admin": False}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ named `<SERVER> - Azure Backup` by default.
|
|||||||
Runs under Windows PowerShell 5.1 (`powershell.exe`), which the agent's `MSOnlineBackup` module
|
Runs under Windows PowerShell 5.1 (`powershell.exe`), which the agent's `MSOnlineBackup` module
|
||||||
targets and which every supported Windows Server has.
|
targets and which every supported Windows Server has.
|
||||||
|
|
||||||
1. On the dashboard host, create a key for this server:
|
1. Create a key for this server on the dashboard's **Admin** page, or on the dashboard host:
|
||||||
```bash
|
```bash
|
||||||
python manage.py create-key mars-acme-fs01
|
python manage.py create-key mars-acme-fs01
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ one with `python manage.py delete-job <id>`.
|
|||||||
|
|
||||||
## Install (on the VB365 server, as an administrator)
|
## Install (on the VB365 server, as an administrator)
|
||||||
|
|
||||||
1. On the dashboard host, create a key for this server:
|
1. Create a key for this server on the dashboard's **Admin** page, or on the dashboard host:
|
||||||
```bash
|
```bash
|
||||||
python manage.py create-key vb365-01
|
python manage.py create-key vb365-01
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Requires PowerShell 7 installed machine-wide (`winget install Microsoft.PowerShe
|
|||||||
which VBR 13's Veeam module expects. The Microsoft Store version won't work, because the SYSTEM
|
which VBR 13's Veeam module expects. The Microsoft Store version won't work, because the SYSTEM
|
||||||
account can't run it. The installer checks for this before changing anything.
|
account can't run it. The installer checks for this before changing anything.
|
||||||
|
|
||||||
1. On the dashboard host, create a key for this server:
|
1. Create a key for this server on the dashboard's **Admin** page, or on the dashboard host:
|
||||||
```bash
|
```bash
|
||||||
python manage.py create-key veeam-acme
|
python manage.py create-key veeam-acme
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Requires the Windows Server Backup feature with its PowerShell module
|
|||||||
(`Install-WindowsFeature Windows-Server-Backup`). Runs under Windows PowerShell 5.1
|
(`Install-WindowsFeature Windows-Server-Backup`). Runs under Windows PowerShell 5.1
|
||||||
(`powershell.exe`), which that module targets.
|
(`powershell.exe`), which that module targets.
|
||||||
|
|
||||||
1. On the dashboard host, create a key for this server:
|
1. Create a key for this server on the dashboard's **Admin** page, or on the dashboard host:
|
||||||
```bash
|
```bash
|
||||||
python manage.py create-key wsb-acme-dc01
|
python manage.py create-key wsb-acme-dc01
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,3 +2,5 @@
|
|||||||
# Read by both the API service and manage.py, so they always use the same database.
|
# Read by both the API service and manage.py, so they always use the same database.
|
||||||
BACKUP_DASH_DB=/var/lib/backup-dashboard/backup-dashboard.db
|
BACKUP_DASH_DB=/var/lib/backup-dashboard/backup-dashboard.db
|
||||||
BACKUP_DASH_RETENTION_DAYS=90
|
BACKUP_DASH_RETENTION_DAYS=90
|
||||||
|
# Microsoft 365 sign-in names (comma-separated) allowed into the admin pages (API key management)
|
||||||
|
BACKUP_DASH_ADMINS=[email protected]
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>API keys · Backup Monitor</title>
|
||||||
|
<link rel="stylesheet" href="/css/styles.css">
|
||||||
|
<script type="module" src="/js/admin.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar__nav">
|
||||||
|
<a id="back-link" class="btn" href="/">← Dashboard</a>
|
||||||
|
<h1 class="topbar__title">API keys</h1>
|
||||||
|
</div>
|
||||||
|
<div class="topbar__meta">
|
||||||
|
<span id="signed-in" class="topbar__user" hidden>
|
||||||
|
<span id="signed-in-email" class="muted"></span>
|
||||||
|
<a id="sign-out" class="btn" href="#">Sign out</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container container--narrow">
|
||||||
|
<div id="error-banner" class="banner banner--error" role="alert" hidden></div>
|
||||||
|
<p id="not-admin" class="banner banner--info" hidden>
|
||||||
|
Your account isn't an admin. Admins are the sign-in names listed in <code>BACKUP_DASH_ADMINS</code>
|
||||||
|
in the server's settings file.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="admin-content" hidden>
|
||||||
|
<section class="panel" aria-labelledby="new-key-title">
|
||||||
|
<h2 id="new-key-title" class="panel__title">New key</h2>
|
||||||
|
<p class="muted">Create one key per collector install, so each can be revoked on its own.</p>
|
||||||
|
<form id="new-key-form" class="new-key">
|
||||||
|
<div class="field">
|
||||||
|
<label for="key-name">Name</label>
|
||||||
|
<span id="key-name-hint" class="hint">For example <code>veeam-acme</code> or <code>mars-acme-fs01</code>:
|
||||||
|
letters, digits, <code>.</code>, <code>_</code> and <code>-</code>.</span>
|
||||||
|
<input id="key-name" name="name" class="input" required maxlength="64"
|
||||||
|
pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,63}" autocomplete="off" spellcheck="false"
|
||||||
|
aria-describedby="key-name-hint" aria-errormessage="key-name-error">
|
||||||
|
<div id="key-name-error" class="error-msg">
|
||||||
|
Use up to 64 letters, digits, <code>.</code>, <code>_</code> or <code>-</code>, starting with a letter or digit.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="create-btn" class="btn btn--primary" type="submit">Create key</button>
|
||||||
|
<p id="create-error" class="form-error" role="alert" hidden></p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="new-key-result" class="secret" hidden>
|
||||||
|
<h3 id="new-key-result-title" class="secret__title" tabindex="-1">Copy the key for <span id="new-key-name"></span> now</h3>
|
||||||
|
<p>It won't be shown again: the dashboard only keeps a hash of it. Paste it when the collector's
|
||||||
|
installer asks for the API key.</p>
|
||||||
|
<div class="secret__row">
|
||||||
|
<code id="new-key-secret" class="secret__value"></code>
|
||||||
|
<button id="copy-btn" class="btn" type="button">Copy</button>
|
||||||
|
</div>
|
||||||
|
<button id="done-btn" class="btn" type="button">Done, hide it</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="keys-title">
|
||||||
|
<h2 id="keys-title" class="panel__title" tabindex="-1">Keys</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="jobs keys">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Name</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Last used</th>
|
||||||
|
<th scope="col">Created</th>
|
||||||
|
<th scope="col"><span class="visually-hidden">Actions</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="keys-body"></tbody>
|
||||||
|
</table>
|
||||||
|
<p id="keys-empty" class="empty" hidden>No keys yet. Create one above for each collector.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Announces results (created, copied, revoked) to screen readers -->
|
||||||
|
<p id="status" class="visually-hidden" role="status"></p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<dialog id="revoke-dialog" class="modal modal--small" aria-labelledby="revoke-title">
|
||||||
|
<form method="dialog">
|
||||||
|
<div class="modal__header">
|
||||||
|
<h2 id="revoke-title" class="modal__title">Revoke <span id="revoke-name"></span>?</h2>
|
||||||
|
</div>
|
||||||
|
<div class="modal__body">
|
||||||
|
<p>Collectors using this key will be refused from their next run. This can't be undone: to
|
||||||
|
reconnect them, create a new key and re-run their installer.</p>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="btn" value="cancel" autofocus>Cancel</button>
|
||||||
|
<button class="btn btn--danger" value="revoke">Revoke key</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -345,3 +345,80 @@ button.badge:hover { filter: brightness(0.95); }
|
|||||||
.runs tbody tr:last-child td { border-bottom: none; }
|
.runs tbody tr:last-child td { border-bottom: none; }
|
||||||
.runs tr.is-selected td { background: color-mix(in srgb, var(--accent) 16%, transparent); }
|
.runs tr.is-selected td { background: color-mix(in srgb, var(--accent) 16%, transparent); }
|
||||||
.runs .badge { font-size: 0.75rem; padding: 0.1rem 0.5rem; }
|
.runs .badge { font-size: 0.75rem; padding: 0.1rem 0.5rem; }
|
||||||
|
/* ---------- Admin page ---------- */
|
||||||
|
.container--narrow { max-width: 1100px; }
|
||||||
|
.topbar__nav { display: flex; align-items: center; gap: 1rem; }
|
||||||
|
|
||||||
|
.banner--info { background: var(--surface-alt); color: var(--text); border-color: var(--border); }
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem 1.25rem 1.25rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.panel__title { font-size: 1.05rem; margin: 0 0 0.25rem; }
|
||||||
|
section > .panel__title { margin: 0 0 0.75rem; }
|
||||||
|
.panel__title:focus { outline: none; } /* focused from script after a change, not by the user */
|
||||||
|
.panel p { margin: 0 0 0.75rem; }
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* New key form: label, hint above the input (so autocomplete can't cover it), error below */
|
||||||
|
.new-key { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.75rem; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 0.25rem; flex: 1 1 22rem; max-width: 30rem; }
|
||||||
|
.field label { font-weight: 600; }
|
||||||
|
.hint { font-size: 0.85rem; color: var(--muted); }
|
||||||
|
.field .input { width: 100%; }
|
||||||
|
.error-msg { display: none; font-size: 0.85rem; color: var(--failed-fg); }
|
||||||
|
/* Only after the user has interacted with the field (or tried to submit) */
|
||||||
|
.field .input:user-invalid { border-color: var(--failed-bd); background: var(--failed-bg); }
|
||||||
|
.field .input:user-invalid + .error-msg { display: block; }
|
||||||
|
.form-error { flex-basis: 100%; margin: 0; color: var(--failed-fg); }
|
||||||
|
|
||||||
|
.btn--primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
|
||||||
|
.btn--primary:hover { filter: brightness(1.08); border-color: var(--accent); }
|
||||||
|
.btn--danger { background: var(--failed-bd); border-color: var(--failed-bd); color: #fff; font-weight: 600; }
|
||||||
|
.btn--danger-outline { color: var(--failed-fg); border-color: var(--failed-bd); background: transparent; }
|
||||||
|
.btn--danger-outline:hover { background: var(--failed-bg); border-color: var(--failed-bd); }
|
||||||
|
|
||||||
|
/* The one-time key */
|
||||||
|
.secret {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--warning-bd);
|
||||||
|
border-left-width: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--warning-bg);
|
||||||
|
}
|
||||||
|
.secret__title { margin: 0 0 0.5rem; font-size: 1rem; }
|
||||||
|
.secret__title:focus { outline: none; }
|
||||||
|
.secret__row { display: flex; gap: 0.5rem; align-items: stretch; margin-bottom: 0.75rem; flex-wrap: wrap; }
|
||||||
|
.secret__value {
|
||||||
|
flex: 1 1 20rem;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
user-select: all; /* one click selects the whole key */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Key table */
|
||||||
|
.keys td { vertical-align: top; }
|
||||||
|
.keys td .badge { margin-bottom: 0.25rem; }
|
||||||
|
.keys td > .time__rel { display: block; }
|
||||||
|
.keys .actions { text-align: right; width: 1%; }
|
||||||
|
.keys tr.is-revoked td { color: var(--muted); }
|
||||||
|
.keys tr.is-revoked code { text-decoration: line-through; }
|
||||||
|
.stale-note { display: block; font-size: 0.8rem; font-weight: 600; color: var(--warning-fg); }
|
||||||
|
|
||||||
|
/* Confirm dialog */
|
||||||
|
.modal--small { width: min(460px, calc(100vw - 2rem)); }
|
||||||
|
.modal--small .modal__body p { margin-top: 0; }
|
||||||
|
.dialog-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<div class="topbar__meta">
|
<div class="topbar__meta">
|
||||||
<span id="last-updated" class="muted" aria-live="polite">Loading…</span>
|
<span id="last-updated" class="muted" aria-live="polite">Loading…</span>
|
||||||
<button id="refresh-btn" class="btn" type="button">Refresh</button>
|
<button id="refresh-btn" class="btn" type="button">Refresh</button>
|
||||||
|
<a id="admin-link" class="btn" href="/admin.html" hidden>Admin</a>
|
||||||
<!-- Filled in from /api/v1/me when signed in with Microsoft 365 -->
|
<!-- Filled in from /api/v1/me when signed in with Microsoft 365 -->
|
||||||
<span id="signed-in" class="topbar__user" hidden>
|
<span id="signed-in" class="topbar__user" hidden>
|
||||||
<span id="signed-in-email" class="muted"></span>
|
<span id="signed-in-email" class="muted"></span>
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// Admin page: create, list and revoke the API keys collectors use.
|
||||||
|
import { getApi } from './api.js';
|
||||||
|
import { el } from './dom.js';
|
||||||
|
import { parseDate, formatDateTime, formatRelative } from './format.js';
|
||||||
|
import { showSignedInUser } from './signed-in.js';
|
||||||
|
|
||||||
|
const api = await getApi();
|
||||||
|
|
||||||
|
// A collector runs hourly; an active key quiet for longer than this probably has a problem
|
||||||
|
const STALE_HOURS = 24;
|
||||||
|
|
||||||
|
const $ = (sel) => document.querySelector(sel);
|
||||||
|
const dom = {
|
||||||
|
backLink: $('#back-link'),
|
||||||
|
signedIn: $('#signed-in'),
|
||||||
|
signedInEmail: $('#signed-in-email'),
|
||||||
|
signOut: $('#sign-out'),
|
||||||
|
errorBanner: $('#error-banner'),
|
||||||
|
notAdmin: $('#not-admin'),
|
||||||
|
content: $('#admin-content'),
|
||||||
|
form: $('#new-key-form'),
|
||||||
|
name: $('#key-name'),
|
||||||
|
createBtn: $('#create-btn'),
|
||||||
|
createError: $('#create-error'),
|
||||||
|
result: $('#new-key-result'),
|
||||||
|
resultTitle: $('#new-key-result-title'),
|
||||||
|
resultName: $('#new-key-name'),
|
||||||
|
secret: $('#new-key-secret'),
|
||||||
|
copyBtn: $('#copy-btn'),
|
||||||
|
doneBtn: $('#done-btn'),
|
||||||
|
keysTitle: $('#keys-title'),
|
||||||
|
tbody: $('#keys-body'),
|
||||||
|
empty: $('#keys-empty'),
|
||||||
|
status: $('#status'),
|
||||||
|
dialog: $('#revoke-dialog'),
|
||||||
|
revokeName: $('#revoke-name'),
|
||||||
|
};
|
||||||
|
|
||||||
|
let keys = [];
|
||||||
|
let pendingRevoke = null;
|
||||||
|
|
||||||
|
// ---------- Feedback ----------
|
||||||
|
|
||||||
|
// Screen-reader announcement. Cleared first so repeating the same message is announced again.
|
||||||
|
// (A timer rather than requestAnimationFrame, which doesn't run while the tab is in the background.)
|
||||||
|
function announce(message) {
|
||||||
|
dom.status.textContent = '';
|
||||||
|
setTimeout(() => { dom.status.textContent = message; }, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
dom.errorBanner.textContent = message;
|
||||||
|
dom.errorBanner.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Key table ----------
|
||||||
|
|
||||||
|
function timeBlock(date, ...extra) {
|
||||||
|
return el('time', { class: 'time', datetime: date.toISOString(), title: date.toString() },
|
||||||
|
el('span', {}, formatDateTime(date)),
|
||||||
|
el('span', { class: 'time__rel' }, formatRelative(date), ...extra));
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusCell(key) {
|
||||||
|
if (key.revoked_at) {
|
||||||
|
return el('td', {},
|
||||||
|
el('span', { class: 'badge badge--unknown' }, 'Revoked'),
|
||||||
|
el('span', { class: 'time__rel' }, formatDateTime(parseDate(key.revoked_at)),
|
||||||
|
key.revoked_by ? ` by ${key.revoked_by}` : null));
|
||||||
|
}
|
||||||
|
return el('td', {}, el('span', { class: 'badge badge--success' }, 'Active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastUsedCell(key) {
|
||||||
|
const lastUsed = parseDate(key.last_used_at);
|
||||||
|
if (!lastUsed) {
|
||||||
|
return el('td', {}, el('span', { class: 'muted' }, key.revoked_at ? 'Never' : 'Never: not installed yet?'));
|
||||||
|
}
|
||||||
|
const stale = !key.revoked_at && Date.now() - lastUsed.getTime() > STALE_HOURS * 3_600_000;
|
||||||
|
return el('td', { class: stale ? 'is-stale' : null },
|
||||||
|
timeBlock(lastUsed, key.last_used_tenant ? ` · ${key.last_used_tenant}` : null),
|
||||||
|
stale ? el('span', { class: 'stale-note' }, `Quiet for over ${STALE_HOURS} hours`) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createdCell(key) {
|
||||||
|
const created = parseDate(key.created_at);
|
||||||
|
return el('td', {},
|
||||||
|
created ? timeBlock(created) : el('span', { class: 'muted' }, 'Unknown'),
|
||||||
|
key.created_by ? el('span', { class: 'time__rel' }, `by ${key.created_by}`) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKeys() {
|
||||||
|
dom.tbody.replaceChildren(...keys.map((key) => el('tr', { class: key.revoked_at ? 'is-revoked' : null },
|
||||||
|
el('td', {}, el('code', {}, key.name)),
|
||||||
|
statusCell(key),
|
||||||
|
lastUsedCell(key),
|
||||||
|
createdCell(key),
|
||||||
|
el('td', { class: 'actions' }, key.revoked_at ? null : el('button', {
|
||||||
|
type: 'button',
|
||||||
|
class: 'btn btn--danger-outline',
|
||||||
|
'aria-haspopup': 'dialog',
|
||||||
|
'aria-label': `Revoke ${key.name}`,
|
||||||
|
dataset: { keyId: String(key.id) },
|
||||||
|
}, 'Revoke')))));
|
||||||
|
dom.empty.hidden = keys.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadKeys() {
|
||||||
|
try {
|
||||||
|
({ keys } = await api.listKeys());
|
||||||
|
dom.errorBanner.hidden = true;
|
||||||
|
renderKeys();
|
||||||
|
} catch (err) {
|
||||||
|
showError(`Couldn't load the keys: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Creating a key ----------
|
||||||
|
|
||||||
|
// Keep aria-invalid in step with :user-invalid (the browser doesn't sync them)
|
||||||
|
function syncInvalid() {
|
||||||
|
dom.name.setAttribute('aria-invalid', dom.name.matches(':user-invalid') ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
dom.name.addEventListener('blur', syncInvalid);
|
||||||
|
dom.name.addEventListener('invalid', syncInvalid); // a submit attempt with a bad name
|
||||||
|
dom.name.addEventListener('input', () => { if (dom.name.hasAttribute('aria-invalid')) syncInvalid(); });
|
||||||
|
|
||||||
|
function showSecret(name, secret) {
|
||||||
|
dom.resultName.textContent = name;
|
||||||
|
dom.secret.textContent = secret;
|
||||||
|
dom.copyBtn.textContent = 'Copy';
|
||||||
|
dom.result.hidden = false;
|
||||||
|
dom.resultTitle.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSecret() {
|
||||||
|
dom.result.hidden = true;
|
||||||
|
dom.secret.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only fires once the browser's own validation (required, pattern) has passed
|
||||||
|
dom.form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dom.createBtn.disabled = true;
|
||||||
|
dom.createError.hidden = true;
|
||||||
|
hideSecret();
|
||||||
|
try {
|
||||||
|
const { key, secret } = await api.createKey(dom.name.value.trim());
|
||||||
|
dom.form.reset();
|
||||||
|
dom.name.removeAttribute('aria-invalid');
|
||||||
|
showSecret(key.name, secret);
|
||||||
|
announce(`Key ${key.name} created. Copy it now: it won't be shown again.`);
|
||||||
|
await loadKeys();
|
||||||
|
} catch (err) {
|
||||||
|
dom.createError.textContent = err.message;
|
||||||
|
dom.createError.hidden = false;
|
||||||
|
} finally {
|
||||||
|
dom.createBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.copyBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(dom.secret.textContent);
|
||||||
|
dom.copyBtn.textContent = 'Copied';
|
||||||
|
announce('Key copied to the clipboard.');
|
||||||
|
} catch {
|
||||||
|
// Clipboard access can be refused (permissions, non-secure context): select it for Ctrl+C instead
|
||||||
|
getSelection().selectAllChildren(dom.secret);
|
||||||
|
dom.copyBtn.textContent = 'Selected: press Ctrl+C';
|
||||||
|
announce('Copy blocked by the browser. The key is selected: press Ctrl+C to copy it.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.doneBtn.addEventListener('click', () => {
|
||||||
|
hideSecret();
|
||||||
|
dom.name.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Revoking a key ----------
|
||||||
|
|
||||||
|
dom.tbody.addEventListener('click', (e) => {
|
||||||
|
const button = e.target.closest('button[data-key-id]');
|
||||||
|
if (!button) return;
|
||||||
|
pendingRevoke = keys.find((k) => String(k.id) === button.dataset.keyId) ?? null;
|
||||||
|
if (!pendingRevoke) return;
|
||||||
|
dom.revokeName.textContent = pendingRevoke.name;
|
||||||
|
dom.dialog.returnValue = '';
|
||||||
|
dom.dialog.showModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// returnValue is the clicked button's value; Esc leaves it empty, which cancels
|
||||||
|
dom.dialog.addEventListener('close', async () => {
|
||||||
|
const key = pendingRevoke;
|
||||||
|
pendingRevoke = null;
|
||||||
|
if (dom.dialog.returnValue !== 'revoke' || !key) return;
|
||||||
|
try {
|
||||||
|
await api.revokeKey(key.id);
|
||||||
|
announce(`Key ${key.name} revoked.`);
|
||||||
|
await loadKeys();
|
||||||
|
} catch (err) {
|
||||||
|
showError(`Couldn't revoke ${key.name}: ${err.message}`);
|
||||||
|
}
|
||||||
|
// Its Revoke button is gone, so focus would otherwise fall back to the page
|
||||||
|
dom.keysTitle.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Start ----------
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
dom.backLink.href = `/${location.search}`; // keeps ?mock=1
|
||||||
|
let me;
|
||||||
|
try {
|
||||||
|
me = await api.getMe();
|
||||||
|
} catch (err) {
|
||||||
|
showError(`Couldn't check who's signed in: ${err.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showSignedInUser(me, dom);
|
||||||
|
if (!me?.is_admin) {
|
||||||
|
dom.notAdmin.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dom.content.hidden = false;
|
||||||
|
await loadKeys();
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
+42
-5
@@ -8,18 +8,41 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request(path) {
|
// The API's explanation from an error response: FastAPI's {"detail": "..."}, or for validation
|
||||||
|
// errors {"detail": [{"msg": "..."}, ...]}
|
||||||
|
async function errorDetail(res) {
|
||||||
|
try {
|
||||||
|
const { detail } = await res.json();
|
||||||
|
if (typeof detail === 'string') return detail;
|
||||||
|
if (Array.isArray(detail)) return detail.map((d) => d.msg).filter(Boolean).join('; ');
|
||||||
|
} catch {
|
||||||
|
// not JSON: fall back to the status code
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, { method = 'GET', body } = {}) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), CONFIG.requestTimeoutMs);
|
const timer = setTimeout(() => controller.abort(), CONFIG.requestTimeoutMs);
|
||||||
|
const headers = { Accept: 'application/json' };
|
||||||
|
if (method !== 'GET') {
|
||||||
|
// Required by the API for changes: another site's page can't add it to a request here
|
||||||
|
headers['X-Requested-By'] = 'backup-dashboard';
|
||||||
|
}
|
||||||
|
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${CONFIG.apiBaseUrl}${path}`, {
|
const res = await fetch(`${CONFIG.apiBaseUrl}${path}`, {
|
||||||
headers: { Accept: 'application/json' },
|
method,
|
||||||
credentials: 'same-origin', // ready for cookie/session auth behind nginx
|
headers,
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
credentials: 'same-origin', // the sign-in session cookie
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
// 401 means the nginx/SSO session lapsed; a reload sends the user back through sign-in
|
// 401 means the nginx/SSO session lapsed; a reload sends the user back through sign-in
|
||||||
if (res.status === 401) throw new ApiError('Your session has expired. Reload the page to sign in again', 401);
|
if (res.status === 401) throw new ApiError('Your session has expired. Reload the page to sign in again', 401);
|
||||||
if (!res.ok) throw new ApiError(`Server returned HTTP ${res.status}`, res.status);
|
if (!res.ok) {
|
||||||
|
throw new ApiError((await errorDetail(res)) ?? `Server returned HTTP ${res.status}`, res.status);
|
||||||
|
}
|
||||||
return await res.json();
|
return await res.json();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name === 'AbortError') throw new ApiError('Request timed out');
|
if (err.name === 'AbortError') throw new ApiError('Request timed out');
|
||||||
@@ -49,10 +72,24 @@ const httpApi = {
|
|||||||
getRun(id) {
|
getRun(id) {
|
||||||
return request(`/runs/${encodeURIComponent(id)}`);
|
return request(`/runs/${encodeURIComponent(id)}`);
|
||||||
},
|
},
|
||||||
/** GET /me → { email, name } of the Microsoft 365 user, both null without sign-in */
|
/** GET /me → { email, name, is_admin } of the Microsoft 365 user; email/name null without sign-in */
|
||||||
getMe() {
|
getMe() {
|
||||||
return request('/me');
|
return request('/me');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ----- Admin (403 for non-admins) -----
|
||||||
|
/** GET /admin/keys → { keys: ApiKey[] } (active first) */
|
||||||
|
listKeys() {
|
||||||
|
return request('/admin/keys');
|
||||||
|
},
|
||||||
|
/** POST /admin/keys → { key: ApiKey, secret } — the secret is only ever returned here */
|
||||||
|
createKey(name) {
|
||||||
|
return request('/admin/keys', { method: 'POST', body: { name } });
|
||||||
|
},
|
||||||
|
/** POST /admin/keys/:id/revoke → { key: ApiKey } */
|
||||||
|
revokeKey(id) {
|
||||||
|
return request(`/admin/keys/${encodeURIComponent(id)}/revoke`, { method: 'POST' });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock is loaded lazily so it never ships in the production request path.
|
// Mock is loaded lazily so it never ships in the production request path.
|
||||||
|
|||||||
+7
-28
@@ -1,5 +1,7 @@
|
|||||||
import { CONFIG } from './config.js';
|
import { CONFIG } from './config.js';
|
||||||
import { getApi } from './api.js';
|
import { getApi } from './api.js';
|
||||||
|
import { el } from './dom.js';
|
||||||
|
import { showSignedInUser } from './signed-in.js';
|
||||||
import {
|
import {
|
||||||
parseDate, formatDateTime, formatTime, formatRelative, formatDuration, formatBytes,
|
parseDate, formatDateTime, formatTime, formatRelative, formatDuration, formatBytes,
|
||||||
} from './format.js';
|
} from './format.js';
|
||||||
@@ -59,6 +61,7 @@ const dom = {
|
|||||||
signedIn: $('#signed-in'),
|
signedIn: $('#signed-in'),
|
||||||
signedInEmail: $('#signed-in-email'),
|
signedInEmail: $('#signed-in-email'),
|
||||||
signOut: $('#sign-out'),
|
signOut: $('#sign-out'),
|
||||||
|
adminLink: $('#admin-link'),
|
||||||
lastUpdated: $('#last-updated'),
|
lastUpdated: $('#last-updated'),
|
||||||
errorBanner: $('#error-banner'),
|
errorBanner: $('#error-banner'),
|
||||||
dialog: $('#detail-dialog'),
|
dialog: $('#detail-dialog'),
|
||||||
@@ -67,19 +70,6 @@ const dom = {
|
|||||||
detailBody: $('#detail-body'),
|
detailBody: $('#detail-body'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tiny element builder. All text goes through append() → text nodes, so API data can never inject HTML.
|
|
||||||
function el(tag, props = {}, ...children) {
|
|
||||||
const node = document.createElement(tag);
|
|
||||||
for (const [k, v] of Object.entries(props)) {
|
|
||||||
if (v == null || v === false) continue;
|
|
||||||
if (k === 'class') node.className = v;
|
|
||||||
else if (k === 'dataset') Object.assign(node.dataset, v);
|
|
||||||
else node.setAttribute(k, v === true ? '' : v);
|
|
||||||
}
|
|
||||||
node.append(...children.flat().filter((c) => c != null && c !== false));
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Rendering ----------
|
// ---------- Rendering ----------
|
||||||
function statusBadge(status, { asButton = false, opensDialog = false, dataset = null, title = null } = {}) {
|
function statusBadge(status, { asButton = false, opensDialog = false, dataset = null, title = null } = {}) {
|
||||||
const { label, icon } = STATUS[status];
|
const { label, icon } = STATUS[status];
|
||||||
@@ -527,20 +517,9 @@ dom.search.addEventListener('input', () => {
|
|||||||
|
|
||||||
dom.refreshBtn.addEventListener('click', () => refresh().then(schedulePoll));
|
dom.refreshBtn.addEventListener('click', () => refresh().then(schedulePoll));
|
||||||
|
|
||||||
// ---------- Signed-in user ----------
|
|
||||||
async function showSignedInUser() {
|
|
||||||
try {
|
|
||||||
const me = await api.getMe();
|
|
||||||
const who = me?.email || me?.name;
|
|
||||||
if (!who) return; // no Microsoft 365 sign-in in front of the dashboard (dev, mock)
|
|
||||||
dom.signedInEmail.textContent = who;
|
|
||||||
dom.signOut.href = CONFIG.signOutUrl;
|
|
||||||
dom.signedIn.hidden = false;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err); // not worth a banner: the jobs request will surface real problems
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Boot ----------
|
// ---------- Boot ----------
|
||||||
showSignedInUser();
|
api.getMe().then(
|
||||||
|
(me) => showSignedInUser(me, dom),
|
||||||
|
(err) => console.error(err), // not worth a banner: the jobs request will surface real problems
|
||||||
|
);
|
||||||
refresh().then(schedulePoll);
|
refresh().then(schedulePoll);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Tiny element builder. All text goes through append() → text nodes, so API data can never inject HTML.
|
||||||
|
export function el(tag, props = {}, ...children) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
for (const [k, v] of Object.entries(props)) {
|
||||||
|
if (v == null || v === false) continue;
|
||||||
|
if (k === 'class') node.className = v;
|
||||||
|
else if (k === 'dataset') Object.assign(node.dataset, v);
|
||||||
|
else node.setAttribute(k, v === true ? '' : v);
|
||||||
|
}
|
||||||
|
node.append(...children.flat().filter((c) => c != null && c !== false));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
+38
-1
@@ -96,6 +96,18 @@ for (const job of JOBS) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// Sample API keys for the admin page: one healthy, one gone quiet, one never used, one revoked
|
||||||
|
const KEYS = [
|
||||||
|
{ id: 1, name: 'veeam-acme', created_at: iso(-40 * DAY), created_by: '[email protected]',
|
||||||
|
last_used_at: iso(-12 * MIN), last_used_tenant: 'Acme Corp', revoked_at: null, revoked_by: null },
|
||||||
|
{ id: 2, name: 'mars-bluewater-fs01', created_at: iso(-20 * DAY), created_by: 'manage.py (backupdash)',
|
||||||
|
last_used_at: iso(-3 * DAY), last_used_tenant: 'Bluewater Dental', revoked_at: null, revoked_by: null },
|
||||||
|
{ id: 3, name: 'wsb-northfield-dc01', created_at: iso(-2 * HR), created_by: '[email protected]',
|
||||||
|
last_used_at: null, last_used_tenant: null, revoked_at: null, revoked_by: null },
|
||||||
|
{ id: 4, name: 'veeam-old-server', created_at: iso(-90 * DAY), created_by: 'manage.py (backupdash)',
|
||||||
|
last_used_at: iso(-31 * DAY), last_used_tenant: 'Acme Corp', revoked_at: iso(-30 * DAY), revoked_by: '[email protected]' },
|
||||||
|
];
|
||||||
const summary = ({ details, every_h, last_status, duration_seconds, size_bytes, ...rest }) => rest;
|
const summary = ({ details, every_h, last_status, duration_seconds, size_bytes, ...rest }) => rest;
|
||||||
|
|
||||||
export const mockApi = {
|
export const mockApi = {
|
||||||
@@ -127,6 +139,31 @@ export const mockApi = {
|
|||||||
throw new Error('Run not found');
|
throw new Error('Run not found');
|
||||||
},
|
},
|
||||||
async getMe() {
|
async getMe() {
|
||||||
return { email: null, name: null }; // no sign-in in mock mode
|
// No sign-in in mock mode, but show the admin pages so they can be tried out
|
||||||
|
return { email: null, name: null, is_admin: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
async listKeys() {
|
||||||
|
await delay(200);
|
||||||
|
return { keys: [...KEYS].sort((a, b) => (!!a.revoked_at - !!b.revoked_at) || a.name.localeCompare(b.name)) };
|
||||||
|
},
|
||||||
|
async createKey(name) {
|
||||||
|
await delay(300);
|
||||||
|
if (KEYS.some((k) => k.name === name)) {
|
||||||
|
throw new Error(`A key named '${name}' already exists (revoked keys keep their names)`);
|
||||||
|
}
|
||||||
|
const key = { id: KEYS.length + 1, name, created_at: new Date().toISOString(), created_by: '[email protected]',
|
||||||
|
last_used_at: null, last_used_tenant: null, revoked_at: null, revoked_by: null };
|
||||||
|
KEYS.push(key);
|
||||||
|
const random = crypto.getRandomValues(new Uint8Array(32));
|
||||||
|
return { key, secret: `bdk_mock_${btoa(String.fromCharCode(...random)).replace(/[+/=]/g, '')}` };
|
||||||
|
},
|
||||||
|
async revokeKey(id) {
|
||||||
|
await delay(200);
|
||||||
|
const key = KEYS.find((k) => k.id === id);
|
||||||
|
if (!key) throw new Error('Key not found');
|
||||||
|
key.revoked_at ??= new Date().toISOString();
|
||||||
|
key.revoked_by ??= '[email protected]';
|
||||||
|
return { key };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { CONFIG } from './config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills in the top bar's "signed in as … / Sign out" from /api/v1/me, and shows the Admin link
|
||||||
|
* to admins. Pass the elements that exist on the page; missing ones are skipped.
|
||||||
|
*/
|
||||||
|
export function showSignedInUser(me, { signedIn, signedInEmail, signOut, adminLink } = {}) {
|
||||||
|
const who = me?.email || me?.name;
|
||||||
|
if (who && signedIn) { // nothing to show without Microsoft 365 sign-in (dev, mock)
|
||||||
|
signedInEmail.textContent = who;
|
||||||
|
signOut.href = CONFIG.signOutUrl;
|
||||||
|
signedIn.hidden = false;
|
||||||
|
}
|
||||||
|
if (me?.is_admin && adminLink) {
|
||||||
|
adminLink.href = `/admin.html${location.search}`; // keeps ?mock=1
|
||||||
|
adminLink.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user