feat(gui): implement first-run setup wizard (1b-8) (#24)

* feat(gui): implement first-run setup wizard (1b-8)

Add a 5-step setup wizard that replaces the single-step /setup:
1. Create Operator - create initial operator account
2. System Settings - configure map tile URL and attribution
3. API Keys - optionally add API keys for adapters
4. Configure Adapters - enable/disable adapters with region picker
5. Finish Setup - review and complete setup

Key changes:
- Update middleware to handle wizard URL structure and step routing
- Add wizard routes for each step with proper auth checks
- Create new templates using base_wizard.html for consistent styling
- Add audit events for system.update and setup.complete
- Update tests for new middleware behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(gui): handle CSRF errors on wizard paths

Update csrf_exception_handler to re-render wizard forms with error
message instead of redirecting to /login when CSRF validation fails.

- /setup/operator: re-render with error
- /setup/system: re-render with current system values + error
- /setup/keys: re-render with current keys list + error
- /setup/adapters: re-render with current adapter config + error
- /setup/finish: re-render with summary data + error
- /setup: redirect to /setup (middleware routes to appropriate step)

Add error display to setup_keys.html and setup_finish.html templates.
Add 7 new CSRF handler tests for wizard paths.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(gui): region picker render + click-to-draw

Bug A: Maps render blank on /setup/adapters for FIRMS and USGS
because Leaflet computed zero dimensions before container layout
settled. Fix: add setTimeout invalidateSize() after map creation.

Bug B: No click-to-draw functionality - only drag corners. Fix:
add L.Control.Draw for rectangle drawing with CREATED event handler
to replace existing rectangle.

Both fixes applied to:
- setup_adapters.html (wizard inline JS)
- _region_picker.html (standalone edit page)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(gui): handle revisiting /setup/operator after operator created

When an operator already exists, /setup/operator now shows a
confirmation page instead of the create form. This prevents:
- Unique constraint violations on duplicate username
- Silent creation of duplicate operators

GET /setup/operator: queries config.operators; if any exist,
renders confirmation state with existing_operator context.

POST /setup/operator: checks operator count before INSERT; if
non-zero, renders confirmation state without inserting.

Template updated with conditional to show "Operator Already
Configured" message when existing_operator is set.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(csrf): replace fastapi-csrf-protect with session-bound CSRF

Fixes CSRF race condition where every GET rotated the CSRF token,
causing POST failures when users had multiple tabs or slow connections.

Changes:
- Remove fastapi-csrf-protect dependency
- Add session-bound CSRF tokens stored in config.sessions table
- Add pre-auth CSRF for unauthenticated routes (/login, /setup/operator)
- Add csrf.py module for pre-auth token generation/validation
- Update routes to use new CSRF token handling
- Add migration 013 to add csrf_token column to sessions

The session-bound approach ensures CSRF tokens remain stable for the
duration of a session, eliminating the race condition.

Note: Route tests (test_wizard.py, test_adapters.py, etc.) need
refactoring to mock get_settings() instead of CsrfProtect dependency.
Core auth/CSRF handler tests pass (74 tests).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(csrf): update test suite for session-bound CSRF tokens

- Add CSRF fixtures to conftest.py for pre-auth and session CSRF
- Update test_wizard.py: use bypass_pre_auth_csrf and patch_route_settings
- Update test_adapters.py: set request.state.csrf_token and form mock data
- Update test_api_keys.py: add CSRF token to form data for POST routes
- Update test_streams.py: change return_value to side_effect for CSRF support
- Update test_region_picker.py: add CSRF token handling
- Update test_config_store.py: set CENTRAL_CSRF_SECRET env var in fixture

All 285 tests now pass with session-bound CSRF validation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Matt Johnson <mj@k7zvx.com>
This commit is contained in:
malice 2026-05-17 22:06:22 -06:00 committed by GitHub
commit 494ad1c799
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2897 additions and 377 deletions

View file

@ -29,23 +29,6 @@ _cleanup_task: asyncio.Task | None = None
_app: FastAPI | None = None
def _configure_csrf() -> None:
"""Configure CSRF protection. Must be called before app starts."""
from fastapi_csrf_protect import CsrfProtect
from pydantic import BaseModel
from central.bootstrap_config import get_settings
class CsrfSettings(BaseModel):
secret_key: str
token_location: str = "body"
token_key: str = "csrf_token"
@CsrfProtect.load_config
def get_csrf_config():
settings = get_settings()
return CsrfSettings(secret_key=settings.csrf_secret)
async def _session_cleanup_loop() -> None:
"""Periodically clean up expired sessions."""
global _shutdown_event
@ -117,9 +100,6 @@ def _create_app() -> FastAPI:
from central.gui.middleware import SessionMiddleware, SetupGateMiddleware
from central.gui.routes import router
# Configure CSRF before creating app
_configure_csrf()
app = FastAPI(
title="Central GUI",
lifespan=lifespan,
@ -137,45 +117,214 @@ def _create_app() -> FastAPI:
app.include_router(router)
# CSRF exception handler - return friendly error instead of 500
from fastapi_csrf_protect.exceptions import CsrfProtectError
from central.gui.auth import CsrfValidationError
from central.gui.csrf import generate_pre_auth_csrf, set_pre_auth_csrf_cookie
from central.bootstrap_config import get_settings
from fastapi.responses import RedirectResponse
@app.exception_handler(CsrfProtectError)
async def csrf_exception_handler(request, exc: CsrfProtectError):
from fastapi_csrf_protect import CsrfProtect
csrf_protect = CsrfProtect()
csrf_token, signed_token = csrf_protect.generate_csrf_tokens()
@app.exception_handler(CsrfValidationError)
async def csrf_exception_handler(request, exc: CsrfValidationError):
from central.gui.db import get_pool
settings = get_settings()
# For pre-auth paths, generate a new pre-auth token
# For session paths, we'll just show the error (session token stays valid)
csrf_token, signed_token = generate_pre_auth_csrf(settings.csrf_secret)
error_msg = "Your session expired. Please try again."
if request.url.path == "/login":
response = templates.TemplateResponse(
request=request,
name="login.html",
context={"csrf_token": csrf_token, "error": "Your session expired. Please try again."},
context={"csrf_token": csrf_token, "error": error_msg},
)
csrf_protect.set_csrf_cookie(signed_token, response)
set_pre_auth_csrf_cookie(response, signed_token)
return response
elif request.url.path == "/setup":
# /setup is a redirect path now, not a form
return RedirectResponse("/setup", status_code=302)
elif request.url.path == "/setup/operator":
response = templates.TemplateResponse(
request=request,
name="setup.html",
context={"csrf_token": csrf_token, "error": "Your session expired. Please try again."},
name="setup_operator.html",
context={"csrf_token": csrf_token, "error": error_msg, "form_data": None},
)
csrf_protect.set_csrf_cookie(signed_token, response)
set_pre_auth_csrf_cookie(response, signed_token)
return response
elif request.url.path == "/setup/system":
pool = get_pool()
system = {
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"map_attribution": "&copy; OpenStreetMap contributors",
}
if pool:
try:
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT map_tile_url, map_attribution FROM config.system WHERE id = true"
)
if row:
system = {
"map_tile_url": row["map_tile_url"],
"map_attribution": row["map_attribution"],
}
except Exception:
pass
response = templates.TemplateResponse(
request=request,
name="setup_system.html",
context={
"csrf_token": csrf_token,
"error": error_msg,
"errors": None,
"form_data": None,
"system": system,
},
)
set_pre_auth_csrf_cookie(response, signed_token)
return response
elif request.url.path == "/setup/keys":
pool = get_pool()
keys = []
if pool:
try:
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT alias, created_at FROM config.api_keys ORDER BY alias"
)
keys = [{"alias": row["alias"], "created_at": row["created_at"]} for row in rows]
except Exception:
pass
response = templates.TemplateResponse(
request=request,
name="setup_keys.html",
context={
"csrf_token": csrf_token,
"keys": keys,
"errors": None,
"form_data": None,
"success": None,
"error": error_msg,
},
)
set_pre_auth_csrf_cookie(response, signed_token)
return response
elif request.url.path == "/setup/adapters":
pool = get_pool()
adapters = []
api_keys = []
tile_url = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
tile_attribution = "&copy; OpenStreetMap contributors"
if pool:
try:
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT name, enabled, cadence_s, settings FROM config.adapters ORDER BY name"
)
for row in rows:
settings = row["settings"] or {}
adapters.append({
"name": row["name"],
"enabled": row["enabled"],
"cadence_s": row["cadence_s"],
"settings": settings,
})
key_rows = await conn.fetch(
"SELECT alias FROM config.api_keys ORDER BY alias"
)
api_keys = [{"alias": k["alias"]} for k in key_rows]
sys_row = await conn.fetchrow(
"SELECT map_tile_url, map_attribution FROM config.system WHERE id = true"
)
if sys_row:
tile_url = sys_row["map_tile_url"]
tile_attribution = sys_row["map_attribution"]
except Exception:
pass
# Import helper functions for valid values
from central.gui.routes import _get_valid_satellites, _get_valid_feeds
response = templates.TemplateResponse(
request=request,
name="setup_adapters.html",
context={
"csrf_token": csrf_token,
"adapters": adapters,
"api_keys": api_keys,
"valid_satellites": _get_valid_satellites(),
"valid_feeds": sorted(_get_valid_feeds()),
"tile_url": tile_url,
"tile_attribution": tile_attribution,
"error": error_msg,
"errors": None,
"form_data": None,
},
)
set_pre_auth_csrf_cookie(response, signed_token)
return response
elif request.url.path == "/setup/finish":
pool = get_pool()
operator_count = 0
key_count = 0
system = {"map_tile_url": ""}
adapters = []
if pool:
try:
async with pool.acquire() as conn:
operator_count = await conn.fetchval("SELECT COUNT(*) FROM config.operators")
key_count = await conn.fetchval("SELECT COUNT(*) FROM config.api_keys")
sys_row = await conn.fetchrow(
"SELECT map_tile_url FROM config.system WHERE id = true"
)
if sys_row:
system = {"map_tile_url": sys_row["map_tile_url"]}
rows = await conn.fetch(
"SELECT name, enabled, cadence_s FROM config.adapters ORDER BY name"
)
adapters = [
{"name": row["name"], "enabled": row["enabled"], "cadence_s": row["cadence_s"]}
for row in rows
]
except Exception:
pass
response = templates.TemplateResponse(
request=request,
name="setup_finish.html",
context={
"csrf_token": csrf_token,
"operator_count": operator_count,
"key_count": key_count,
"system": system,
"adapters": adapters,
"error": error_msg,
},
)
set_pre_auth_csrf_cookie(response, signed_token)
return response
elif request.url.path == "/logout":
return RedirectResponse("/login", status_code=302)
elif request.url.path == "/change-password":
response = templates.TemplateResponse(
request=request,
name="change_password.html",
context={"csrf_token": csrf_token, "error": "Your session expired. Please try again."},
context={"csrf_token": csrf_token, "error": error_msg},
)
csrf_protect.set_csrf_cookie(signed_token, response)
set_pre_auth_csrf_cookie(response, signed_token)
return response
elif request.url.path.startswith("/adapters/"):
# Redirect back to adapters list
return RedirectResponse("/adapters", status_code=302)
else:
# Fallback: redirect to login
return RedirectResponse("/login", status_code=302)

View file

@ -14,6 +14,8 @@ STREAM_UPDATE = "stream.update"
API_KEY_CREATE = "api_key.create"
API_KEY_ROTATE = "api_key.rotate"
API_KEY_DELETE = "api_key.delete"
SYSTEM_UPDATE = "system.update"
SETUP_COMPLETE = "setup.complete"
async def write_audit(

View file

@ -12,6 +12,11 @@ from argon2.exceptions import VerifyMismatchError
_hasher = PasswordHasher()
class CsrfValidationError(Exception):
"""Raised when CSRF token validation fails."""
pass
@dataclass
class Operator:
"""Operator account."""
@ -46,39 +51,46 @@ def generate_token() -> str:
return secrets.token_urlsafe(32)
def generate_csrf_token() -> str:
"""Generate a cryptographically secure CSRF token."""
return secrets.token_hex(32)
async def create_session(
conn: Any, # asyncpg.Connection
operator_id: int,
lifetime_days: int,
) -> tuple[str, datetime]:
) -> tuple[str, datetime, str]:
"""Create a new session for an operator.
Returns (token, expires_at).
Returns (token, expires_at, csrf_token).
"""
token = generate_token()
csrf_token = generate_csrf_token()
expires_at = datetime.now(timezone.utc) + timedelta(days=lifetime_days)
await conn.execute(
"""
INSERT INTO config.sessions (token, operator_id, expires_at)
VALUES ($1, $2, $3)
INSERT INTO config.sessions (token, operator_id, expires_at, csrf_token)
VALUES ($1, $2, $3, $4)
""",
token,
operator_id,
expires_at,
csrf_token,
)
return token, expires_at
return token, expires_at, csrf_token
async def get_session(conn: Any, token: str) -> Operator | None:
"""Look up a session and return the associated operator.
async def get_session(conn: Any, token: str) -> tuple[Operator, str] | None:
"""Look up a session and return the associated operator and csrf_token.
Returns None if token is invalid or expired.
Returns (Operator, csrf_token) or None if token is invalid or expired.
"""
row = await conn.fetchrow(
"""
SELECT o.id, o.username, o.created_at, o.password_changed_at
SELECT o.id, o.username, o.created_at, o.password_changed_at, s.csrf_token
FROM config.sessions s
JOIN config.operators o ON s.operator_id = o.id
WHERE s.token = $1 AND s.expires_at > now()
@ -89,12 +101,14 @@ async def get_session(conn: Any, token: str) -> Operator | None:
if row is None:
return None
return Operator(
operator = Operator(
id=row["id"],
username=row["username"],
created_at=row["created_at"],
password_changed_at=row.get("password_changed_at"),
)
return operator, row["csrf_token"]
async def delete_session(conn: Any, token: str) -> None:

72
src/central/gui/csrf.py Normal file
View file

@ -0,0 +1,72 @@
"""Pre-auth CSRF protection for login and setup/operator pages.
These routes cannot use session-bound CSRF because no session exists yet.
Uses a simple cookie-based pattern with short-lived tokens.
"""
import secrets
from typing import Optional
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from starlette.requests import Request
from starlette.responses import Response
# 10 minute max age for pre-auth CSRF tokens
PRE_AUTH_CSRF_MAX_AGE = 600
PRE_AUTH_CSRF_COOKIE = "central_preauth_csrf"
def _get_serializer(secret_key: str) -> URLSafeTimedSerializer:
"""Get a timed serializer for CSRF tokens."""
return URLSafeTimedSerializer(secret_key, salt="preauth-csrf")
def generate_pre_auth_csrf(secret_key: str) -> tuple[str, str]:
"""Generate a pre-auth CSRF token pair.
Returns (plain_token, signed_token).
The plain_token goes in the form, signed_token goes in the cookie.
"""
plain_token = secrets.token_hex(32)
serializer = _get_serializer(secret_key)
signed_token = serializer.dumps(plain_token)
return plain_token, signed_token
def set_pre_auth_csrf_cookie(response: Response, signed_token: str) -> None:
"""Set the pre-auth CSRF cookie on a response."""
response.set_cookie(
PRE_AUTH_CSRF_COOKIE,
signed_token,
max_age=PRE_AUTH_CSRF_MAX_AGE,
path="/",
httponly=True,
samesite="lax",
)
def validate_pre_auth_csrf(
request: Request,
form_token: str,
secret_key: str,
) -> bool:
"""Validate a pre-auth CSRF token.
Returns True if valid, False otherwise.
"""
cookie_value = request.cookies.get(PRE_AUTH_CSRF_COOKIE)
if not cookie_value or not form_token:
return False
serializer = _get_serializer(secret_key)
try:
expected_token = serializer.loads(cookie_value, max_age=PRE_AUTH_CSRF_MAX_AGE)
return secrets.compare_digest(form_token, expected_token)
except (BadSignature, SignatureExpired):
return False
def unset_pre_auth_csrf_cookie(response: Response) -> None:
"""Remove the pre-auth CSRF cookie."""
response.delete_cookie(PRE_AUTH_CSRF_COOKIE, path="/")

View file

@ -12,11 +12,10 @@ from central.gui.db import get_pool
logger = logging.getLogger(__name__)
# Paths that don't require setup to be complete
SETUP_EXEMPT_PATHS = {"/setup", "/health"}
SETUP_EXEMPT_PREFIXES = ("/static/",)
SETUP_EXEMPT_PREFIXES = ("/static/", "/setup")
# Paths that don't require authentication
AUTH_EXEMPT_PATHS = {"/setup", "/login", "/health"}
AUTH_EXEMPT_PATHS = {"/setup/operator", "/login", "/health"}
AUTH_EXEMPT_PREFIXES = ("/static/",)
@ -30,6 +29,35 @@ def _is_exempt(path: str, exempt_paths: set, exempt_prefixes: tuple) -> bool:
return False
async def _get_wizard_redirect_step(conn) -> str:
"""Determine which wizard step to redirect to based on DB state."""
# Check if any operators exist
op_count = await conn.fetchval("SELECT COUNT(*) FROM config.operators")
if op_count == 0:
return "/setup/operator"
# Check if system settings have been configured (map_tile_url not default)
sys_row = await conn.fetchrow(
"SELECT map_tile_url FROM config.system WHERE id = true"
)
default_tile = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
if sys_row is None or sys_row["map_tile_url"] == default_tile:
return "/setup/system"
# Keys step is optional, so check adapters have been reviewed
# We consider adapters reviewed if any adapter has a non-null updated_at
# (meaning it was explicitly saved during setup)
adapters_touched = await conn.fetchval(
"SELECT COUNT(*) FROM config.adapters WHERE updated_at IS NOT NULL"
)
if adapters_touched == 0:
# Go to keys first, then adapters
return "/setup/keys"
# All steps done, go to finish
return "/setup/finish"
class SetupGateMiddleware(BaseHTTPMiddleware):
"""Redirect to /setup if setup is not complete."""
@ -55,25 +83,44 @@ class SetupGateMiddleware(BaseHTTPMiddleware):
return await call_next(request)
if not setup_complete:
# Setup not complete - only allow exempt paths
if not _is_exempt(path, SETUP_EXEMPT_PATHS, SETUP_EXEMPT_PREFIXES):
# Setup not complete - only allow setup paths and static/health
if path.startswith("/setup"):
# Allow all /setup/* paths (handler will enforce auth)
# But /setup with no subpath should redirect to appropriate step
if path == "/setup" or path == "/setup/":
try:
async with pool.acquire() as conn:
redirect_step = await _get_wizard_redirect_step(conn)
return RedirectResponse(url=redirect_step, status_code=302)
except Exception:
logger.warning("Failed to determine wizard step", exc_info=True)
return RedirectResponse(url="/setup/operator", status_code=302)
return await call_next(request)
elif path == "/health" or path.startswith("/static/"):
return await call_next(request)
elif path == "/login":
# During setup, login redirects to /setup
return RedirectResponse(url="/setup", status_code=302)
else:
# All other paths redirect to /setup
return RedirectResponse(url="/setup", status_code=302)
else:
# Setup complete - redirect /setup to /
if path == "/setup":
# Setup complete - redirect /setup* to /
if path.startswith("/setup"):
return RedirectResponse(url="/", status_code=302)
return await call_next(request)
class SessionMiddleware(BaseHTTPMiddleware):
"""Load session from cookie and attach operator to request.state."""
"""Load session from cookie and attach operator + csrf_token to request.state."""
async def dispatch(self, request: Request, call_next) -> Response:
path = request.url.path
# Initialize operator to None
# Initialize state
request.state.operator = None
request.state.csrf_token = None
# Try to load session from cookie
session_token = request.cookies.get("central_session")
@ -82,11 +129,15 @@ class SessionMiddleware(BaseHTTPMiddleware):
if pool is not None:
try:
async with pool.acquire() as conn:
operator = await get_session(conn, session_token)
request.state.operator = operator
result = await get_session(conn, session_token)
if result is not None:
operator, csrf_token = result
request.state.operator = operator
request.state.csrf_token = csrf_token
except Exception:
logger.warning("Failed to load session", exc_info=True)
request.state.operator = None
request.state.csrf_token = None
# Check if auth is required
if not _is_exempt(path, AUTH_EXEMPT_PATHS, AUTH_EXEMPT_PREFIXES):

File diff suppressed because it is too large Load diff

View file

@ -59,6 +59,10 @@
maxZoom: 18
}).addTo(map);
// Ensure map renders correctly even if container has not
// finished laying out at init time
setTimeout(function() { map.invalidateSize(); }, 100);
// Create initial rectangle
const bounds = L.latLngBounds(
L.latLng(savedSouth, savedWest),
@ -69,11 +73,34 @@
map.fitBounds(bounds.pad(0.1));
// Create editable rectangle
const rectangle = L.rectangle(bounds, {
let rectangle = L.rectangle(bounds, {
color: '#3388ff',
weight: 2,
fillOpacity: 0.2
}).addTo(map);
});
// Set up Leaflet.draw for click-to-draw
const drawnItems = new L.FeatureGroup();
drawnItems.addLayer(rectangle);
map.addLayer(drawnItems);
const drawControl = new L.Control.Draw({
draw: {
rectangle: { shapeOptions: { color: '#3388ff', weight: 2,
fillOpacity: 0.2 } },
polyline: false,
polygon: false,
circle: false,
marker: false,
circlemarker: false
},
edit: {
featureGroup: drawnItems,
edit: false,
remove: false
}
});
map.addControl(drawControl);
// Make rectangle editable
rectangle.editing.enable();
@ -96,13 +123,33 @@
// Listen for rectangle edit events
rectangle.on('edit', updateInputs);
// When user draws a new rectangle, replace the existing one
map.on(L.Draw.Event.CREATED, function(e) {
drawnItems.clearLayers();
rectangle = e.layer;
rectangle.setStyle({ color: '#3388ff', weight: 2,
fillOpacity: 0.2 });
drawnItems.addLayer(rectangle);
rectangle.editing.enable();
rectangle.on('edit', updateInputs);
updateInputs();
});
// Reset button
document.getElementById('region-reset-btn').addEventListener('click', function() {
const originalBounds = L.latLngBounds(
L.latLng(savedSouth, savedWest),
L.latLng(savedNorth, savedEast)
);
rectangle.setBounds(originalBounds);
drawnItems.clearLayers();
rectangle = L.rectangle(originalBounds, {
color: '#3388ff',
weight: 2,
fillOpacity: 0.2
});
drawnItems.addLayer(rectangle);
rectangle.editing.enable();
rectangle.on('edit', updateInputs);
updateInputs();
});

View file

@ -0,0 +1,6 @@
<article style="margin-bottom: 2rem;">
<header>
<strong>Step {{ step }} of 5</strong> — {{ step_name }}
</header>
<progress value="{{ step }}" max="5" style="margin-bottom: 0;"></progress>
</article>

View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Central - Setup{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
{% block head %}{% endblock %}
</head>
<body>
<nav class="container">
<ul>
<li><strong>Central</strong></li>
</ul>
<ul>
<li>Setup Wizard</li>
</ul>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
</body>
</html>

View file

@ -0,0 +1,256 @@
{% extends "base_wizard.html" %}
{% block title %}Central - Configure Adapters{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" integrity="sha512-gc3xjCmIy673V6MyOAZhIW93xhM9ei1I+gLbmFjUHIjocENRsLX/QUE1htk5q1XV2D/iie/VQ8DXI6Uj8GB1Og==" crossorigin="anonymous">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js" integrity="sha512-ozq8xQKq6urvuU6jNgkfqAmT7jKN2XumbrX1JiB3TnF7tI48DPI4Ber9dLJ0ikXiRg9G9Vl2jXwqjZ5LDGQ3g==" crossorigin="anonymous"></script>
{% endblock %}
{% block content %}
{% with step=4, step_name="Configure Adapters" %}
{% include "_wizard_header.html" %}
{% endwith %}
<article>
<header>
<h1>Configure Adapters</h1>
<p>Enable and configure data source adapters. Each adapter polls an external API and normalizes events.</p>
</header>
{% if error %}
<p style="color: var(--pico-color-red-500);">{{ error }}</p>
{% endif %}
<form action="/setup/adapters" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% for adapter in adapters %}
<details open style="margin-bottom: 2rem;">
<summary><strong>{{ adapter.name }}</strong></summary>
<div style="padding: 1rem; border-left: 3px solid var(--pico-primary);">
<label>
<input type="checkbox" name="{{ adapter.name }}_enabled"
{% if form_data and form_data.get(adapter.name + '_enabled') %}checked
{% elif not form_data and adapter.enabled %}checked{% endif %}>
Enabled
</label>
{% if errors and errors.get(adapter.name + '_enabled') %}
<small style="color: var(--pico-color-red-500); display: block;">{{ errors[adapter.name + '_enabled'] }}</small>
{% endif %}
<label for="{{ adapter.name }}_cadence_s">Cadence (seconds)</label>
<input type="number" id="{{ adapter.name }}_cadence_s" name="{{ adapter.name }}_cadence_s"
value="{{ form_data.get(adapter.name + '_cadence_s') if form_data else adapter.cadence_s }}"
min="60" max="3600">
{% if errors and errors.get(adapter.name + '_cadence_s') %}
<small style="color: var(--pico-color-red-500);">{{ errors[adapter.name + '_cadence_s'] }}</small>
{% endif %}
{% if adapter.name == 'nws' %}
<label for="{{ adapter.name }}_contact_email">Contact Email</label>
<input type="email" id="{{ adapter.name }}_contact_email" name="{{ adapter.name }}_contact_email"
value="{{ form_data.get(adapter.name + '_contact_email') if form_data else adapter.settings.contact_email }}">
{% if errors and errors.get(adapter.name + '_contact_email') %}
<small style="color: var(--pico-color-red-500);">{{ errors[adapter.name + '_contact_email'] }}</small>
{% endif %}
{% endif %}
{% if adapter.name == 'firms' %}
<label for="{{ adapter.name }}_api_key_alias">API Key Alias</label>
<select id="{{ adapter.name }}_api_key_alias" name="{{ adapter.name }}_api_key_alias">
<option value="">(none)</option>
{% for key in api_keys %}
<option value="{{ key.alias }}"
{% if (form_data.get(adapter.name + '_api_key_alias') if form_data else adapter.settings.api_key_alias) == key.alias %}selected{% endif %}>
{{ key.alias }}
</option>
{% endfor %}
</select>
{% if errors and errors.get(adapter.name + '_api_key_alias') %}
<small style="color: var(--pico-color-red-500);">{{ errors[adapter.name + '_api_key_alias'] }}</small>
{% endif %}
<label>Satellites</label>
{% for sat in valid_satellites %}
<label style="display: inline-block; margin-right: 1rem;">
<input type="checkbox" name="{{ adapter.name }}_satellites" value="{{ sat }}"
{% if sat in (form_data.getlist(adapter.name + '_satellites') if form_data else adapter.settings.satellites or []) %}checked{% endif %}>
{{ sat }}
</label>
{% endfor %}
{% endif %}
{% if adapter.name == 'usgs_quake' %}
<label for="{{ adapter.name }}_feed">Feed</label>
<select id="{{ adapter.name }}_feed" name="{{ adapter.name }}_feed">
{% for f in valid_feeds %}
<option value="{{ f }}"
{% if (form_data.get(adapter.name + '_feed') if form_data else adapter.settings.feed) == f %}selected{% endif %}>
{{ f }}
</option>
{% endfor %}
</select>
{% if errors and errors.get(adapter.name + '_feed') %}
<small style="color: var(--pico-color-red-500);">{{ errors[adapter.name + '_feed'] }}</small>
{% endif %}
{% endif %}
<h4>Region</h4>
{% set region = form_data if form_data else adapter.settings.region %}
<div id="region-picker-{{ adapter.name }}"
data-adapter="{{ adapter.name }}"
data-north="{{ form_data.get(adapter.name + '_region_north') if form_data else (adapter.settings.region.north if adapter.settings.region else 49.5) }}"
data-south="{{ form_data.get(adapter.name + '_region_south') if form_data else (adapter.settings.region.south if adapter.settings.region else 31.0) }}"
data-east="{{ form_data.get(adapter.name + '_region_east') if form_data else (adapter.settings.region.east if adapter.settings.region else -102.0) }}"
data-west="{{ form_data.get(adapter.name + '_region_west') if form_data else (adapter.settings.region.west if adapter.settings.region else -124.5) }}"
data-tile-url="{{ tile_url }}"
data-tile-attr="{{ tile_attribution }}">
<div id="region-map-{{ adapter.name }}" style="height: 300px; margin-bottom: 1rem;"></div>
<div class="grid">
<div>
<label>North</label>
<input type="number" name="{{ adapter.name }}_region_north" step="0.0001" min="-90" max="90" readonly
value="{{ form_data.get(adapter.name + '_region_north') if form_data else (adapter.settings.region.north if adapter.settings.region else 49.5) }}">
</div>
<div>
<label>South</label>
<input type="number" name="{{ adapter.name }}_region_south" step="0.0001" min="-90" max="90" readonly
value="{{ form_data.get(adapter.name + '_region_south') if form_data else (adapter.settings.region.south if adapter.settings.region else 31.0) }}">
</div>
<div>
<label>East</label>
<input type="number" name="{{ adapter.name }}_region_east" step="0.0001" min="-180" max="180" readonly
value="{{ form_data.get(adapter.name + '_region_east') if form_data else (adapter.settings.region.east if adapter.settings.region else -102.0) }}">
</div>
<div>
<label>West</label>
<input type="number" name="{{ adapter.name }}_region_west" step="0.0001" min="-180" max="180" readonly
value="{{ form_data.get(adapter.name + '_region_west') if form_data else (adapter.settings.region.west if adapter.settings.region else -124.5) }}">
</div>
</div>
{% if errors and errors.get(adapter.name + '_region') %}
<small style="color: var(--pico-color-red-500);">{{ errors[adapter.name + '_region'] }}</small>
{% endif %}
</div>
</div>
</details>
{% endfor %}
<div style="display: flex; gap: 1rem; margin-top: 1rem;">
<a href="/setup/keys" role="button" class="outline">&larr; Back</a>
<button type="submit">Next &rarr;</button>
</div>
</form>
</article>
<script>
document.addEventListener('DOMContentLoaded', function() {
const adapters = ['nws', 'firms', 'usgs_quake'];
adapters.forEach(function(adapterName) {
const container = document.getElementById('region-picker-' + adapterName);
if (!container) return;
const savedNorth = parseFloat(container.dataset.north);
const savedSouth = parseFloat(container.dataset.south);
const savedEast = parseFloat(container.dataset.east);
const savedWest = parseFloat(container.dataset.west);
const tileUrl = container.dataset.tileUrl || 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
const tileAttr = container.dataset.tileAttr || '&copy; OpenStreetMap contributors';
const centerLat = (savedNorth + savedSouth) / 2;
const centerLng = (savedEast + savedWest) / 2;
const mapEl = document.getElementById('region-map-' + adapterName);
const map = L.map(mapEl).setView([centerLat, centerLng], 4);
L.tileLayer(tileUrl, {
attribution: tileAttr,
maxZoom: 18
}).addTo(map);
// Ensure map renders correctly even if container has not
// finished laying out at init time
setTimeout(function() { map.invalidateSize(); }, 100);
const bounds = L.latLngBounds(
L.latLng(savedSouth, savedWest),
L.latLng(savedNorth, savedEast)
);
map.fitBounds(bounds.pad(0.1));
let rectangle = L.rectangle(bounds, {
color: '#3388ff',
weight: 2,
fillOpacity: 0.2
});
// Set up Leaflet.draw for click-to-draw
const drawnItems = new L.FeatureGroup();
drawnItems.addLayer(rectangle);
map.addLayer(drawnItems);
const drawControl = new L.Control.Draw({
draw: {
rectangle: { shapeOptions: { color: '#3388ff', weight: 2,
fillOpacity: 0.2 } },
polyline: false,
polygon: false,
circle: false,
marker: false,
circlemarker: false
},
edit: {
featureGroup: drawnItems,
edit: false,
remove: false
}
});
map.addControl(drawControl);
rectangle.editing.enable();
const northInput = container.querySelector('input[name="' + adapterName + '_region_north"]');
const southInput = container.querySelector('input[name="' + adapterName + '_region_south"]');
const eastInput = container.querySelector('input[name="' + adapterName + '_region_east"]');
const westInput = container.querySelector('input[name="' + adapterName + '_region_west"]');
function updateInputs() {
const b = rectangle.getBounds();
northInput.value = b.getNorth().toFixed(4);
southInput.value = b.getSouth().toFixed(4);
eastInput.value = b.getEast().toFixed(4);
westInput.value = b.getWest().toFixed(4);
}
rectangle.on('edit', updateInputs);
updateInputs();
// When user draws a new rectangle, replace the existing one
map.on(L.Draw.Event.CREATED, function(e) {
drawnItems.clearLayers();
rectangle = e.layer;
rectangle.setStyle({ color: '#3388ff', weight: 2,
fillOpacity: 0.2 });
drawnItems.addLayer(rectangle);
rectangle.editing.enable();
rectangle.on('edit', updateInputs);
updateInputs();
});
// Fix map size when details is opened
const details = container.closest('details');
if (details) {
details.addEventListener('toggle', function() {
setTimeout(function() { map.invalidateSize(); }, 100);
});
}
});
});
</script>
{% endblock %}

View file

@ -0,0 +1,73 @@
{% extends "base_wizard.html" %}
{% block title %}Central - Finish Setup{% endblock %}
{% block content %}
{% with step=5, step_name="Finish Setup" %}
{% include "_wizard_header.html" %}
{% endwith %}
<article>
<header>
<h1>Setup Complete</h1>
<p>Review your configuration and finish the setup wizard.</p>
</header>
{% if error %}
<p style="color: var(--pico-color-red-500);">{{ error }}</p>
{% endif %}
<h2>Summary</h2>
<table>
<tbody>
<tr>
<th>Operators</th>
<td>{{ operator_count }} configured</td>
</tr>
<tr>
<th>API Keys</th>
<td>{{ key_count }} configured</td>
</tr>
<tr>
<th>Map Tile URL</th>
<td style="word-break: break-all;">{{ system.map_tile_url }}</td>
</tr>
</tbody>
</table>
<h3>Adapters</h3>
<table>
<thead>
<tr>
<th>Adapter</th>
<th>Status</th>
<th>Cadence</th>
</tr>
</thead>
<tbody>
{% for adapter in adapters %}
<tr>
<td><strong>{{ adapter.name }}</strong></td>
<td>
{% if adapter.enabled %}
<span style="color: var(--pico-color-green-500);">Enabled</span>
{% else %}
<span style="color: var(--pico-color-grey-500);">Disabled</span>
{% endif %}
</td>
<td>{{ adapter.cadence_s }}s</td>
</tr>
{% endfor %}
</tbody>
</table>
<form action="/setup/finish" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div style="display: flex; gap: 1rem; margin-top: 2rem;">
<a href="/setup/adapters" role="button" class="outline">&larr; Back</a>
<button type="submit">Finish Setup</button>
</div>
</form>
</article>
{% endblock %}

View file

@ -0,0 +1,88 @@
{% extends "base_wizard.html" %}
{% block title %}Central - API Keys{% endblock %}
{% block content %}
{% with step=3, step_name="API Keys" %}
{% include "_wizard_header.html" %}
{% endwith %}
<article>
<header>
<h1>API Keys</h1>
<p>Add API keys for adapters that require external service credentials (e.g., FIRMS).</p>
</header>
{% if error %}
<p style="color: var(--pico-color-red-500);">{{ error }}</p>
{% endif %}
{% if success %}
<p style="color: var(--pico-color-green-500);">{{ success }}</p>
{% endif %}
{% if keys %}
<h2>Existing Keys</h2>
<table>
<thead>
<tr>
<th>Alias</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{% for key in keys %}
<tr>
<td><strong>{{ key.alias }}</strong></td>
<td>{{ key.created_at.strftime('%Y-%m-%d %H:%M') if key.created_at else '(never)' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p><em>No API keys configured yet.</em></p>
{% endif %}
<h2>Add New Key</h2>
<form action="/setup/keys" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="add">
<div class="grid">
<div>
<label for="alias">Alias</label>
<input type="text" id="alias" name="alias" placeholder="e.g., firms"
value="{{ form_data.alias if form_data else '' }}" maxlength="64">
{% if errors and errors.alias %}
<small style="color: var(--pico-color-red-500);">{{ errors.alias }}</small>
{% else %}
<small>Letters, numbers, and underscores only.</small>
{% endif %}
</div>
<div>
<label for="plaintext_key">API Key</label>
<input type="password" id="plaintext_key" name="plaintext_key"
placeholder="Paste your API key">
{% if errors and errors.plaintext_key %}
<small style="color: var(--pico-color-red-500);">{{ errors.plaintext_key }}</small>
{% else %}
<small>Will be encrypted before storage.</small>
{% endif %}
</div>
</div>
<button type="submit" class="outline">Add Key</button>
</form>
<hr>
<form action="/setup/keys" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="next">
<div style="display: flex; gap: 1rem;">
<a href="/setup/system" role="button" class="outline">&larr; Back</a>
<button type="submit">Next &rarr;</button>
</div>
</form>
</article>
{% endblock %}

View file

@ -0,0 +1,57 @@
{% extends "base_wizard.html" %}
{% block title %}Central - Create Operator{% endblock %}
{% block content %}
{% with step=1, step_name="Create Operator" %}
{% include "_wizard_header.html" %}
{% endwith %}
{% if existing_operator %}
<article>
<header>
<h1>Operator Already Configured</h1>
</header>
<p>The operator account <strong>{{ existing_operator.username }}</strong> has been created.</p>
<div style="display: flex; gap: 1rem; margin-top: 1rem;">
<a href="/setup/system" role="button">Next &rarr;</a>
</div>
</article>
{% else %}
<article>
<header>
<h1>Create Operator Account</h1>
<p>Create the initial operator account to manage Central.</p>
</header>
{% if error %}
<p style="color: var(--pico-color-red-500);">{{ error }}</p>
{% endif %}
<form action="/setup/operator" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="username">
Username
<input type="text" id="username" name="username" required
autocomplete="username" autofocus value="{{ form_data.username if form_data else '' }}">
</label>
<label for="password">
Password
<input type="password" id="password" name="password" required
autocomplete="new-password" minlength="8">
<small>Minimum 8 characters</small>
</label>
<label for="confirm_password">
Confirm Password
<input type="password" id="confirm_password" name="confirm_password" required
autocomplete="new-password">
</label>
<button type="submit">Create Operator &rarr;</button>
</form>
</article>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,49 @@
{% extends "base_wizard.html" %}
{% block title %}Central - System Settings{% endblock %}
{% block content %}
{% with step=2, step_name="System Settings" %}
{% include "_wizard_header.html" %}
{% endwith %}
<article>
<header>
<h1>System Settings</h1>
<p>Configure map tile provider for the region picker.</p>
</header>
{% if error %}
<p style="color: var(--pico-color-red-500);">{{ error }}</p>
{% endif %}
<form action="/setup/system" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="map_tile_url">
Map Tile URL
<input type="text" id="map_tile_url" name="map_tile_url"
value="{{ form_data.map_tile_url if form_data else system.map_tile_url }}" required>
<small>Use {z}, {x}, {y} placeholders. Example: https://tile.openstreetmap.org/{z}/{x}/{y}.png</small>
</label>
{% if errors and errors.map_tile_url %}
<small style="color: var(--pico-color-red-500);">{{ errors.map_tile_url }}</small>
{% endif %}
<label for="map_attribution">
Map Attribution
<input type="text" id="map_attribution" name="map_attribution"
value="{{ form_data.map_attribution if form_data else system.map_attribution }}" required>
<small>Credit the map provider (required by most tile services).</small>
</label>
{% if errors and errors.map_attribution %}
<small style="color: var(--pico-color-red-500);">{{ errors.map_attribution }}</small>
{% endif %}
<div style="display: flex; gap: 1rem; margin-top: 1rem;">
<a href="/setup/operator" role="button" class="outline">&larr; Back</a>
<button type="submit">Next &rarr;</button>
</div>
</form>
</article>
{% endblock %}