feat: add Admin Panel with JWT auth, DB settings, and integration management
Complete admin backend with login, where all integrations (weather, news, Home Assistant, Vikunja, Unraid, MQTT) can be configured via web UI instead of ENV variables. Two-layer config: ENV seeds DB on first start, then DB is source of truth. Auto-migration system on startup. Backend: db.py shared pool, auth.py JWT, settings_service CRUD, seed_service, admin router (protected), test_connections per integration, config.py rewrite. Frontend: react-router v6, login page, admin layout with sidebar, 8 settings pages (General, Weather, News, HA, Vikunja, Unraid, MQTT, ChangePassword), shared IntegrationForm + TestButton components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
89ed0c6d0a
commit
f6a42c2dd2
40 changed files with 3487 additions and 311 deletions
53
server/migrations/001_admin_schema.sql
Normal file
53
server/migrations/001_admin_schema.sql
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
-- Migration 001: Admin Backend Schema
|
||||
-- Creates tables for admin user, settings, integrations, and MQTT subscriptions.
|
||||
|
||||
-- Single admin user
|
||||
CREATE TABLE IF NOT EXISTS admin_user (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL DEFAULT 'admin',
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- General key/value settings (cache TTLs, preferences, etc.)
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
value_type VARCHAR(20) NOT NULL DEFAULT 'string',
|
||||
category VARCHAR(50) NOT NULL DEFAULT 'general',
|
||||
label VARCHAR(200) NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Integration configurations (one row per integration type)
|
||||
CREATE TABLE IF NOT EXISTS integrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
type VARCHAR(50) NOT NULL UNIQUE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- MQTT subscription management
|
||||
CREATE TABLE IF NOT EXISTS mqtt_subscriptions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
topic_pattern VARCHAR(500) NOT NULL,
|
||||
display_name VARCHAR(200) NOT NULL DEFAULT '',
|
||||
category VARCHAR(100) NOT NULL DEFAULT 'other',
|
||||
unit VARCHAR(50) NOT NULL DEFAULT '',
|
||||
widget_type VARCHAR(50) NOT NULL DEFAULT 'value',
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Record this migration
|
||||
INSERT INTO schema_version (version, description)
|
||||
VALUES (1, 'Admin backend: admin_user, app_settings, integrations, mqtt_subscriptions')
|
||||
ON CONFLICT (version) DO NOTHING;
|
||||
0
server/migrations/__init__.py
Normal file
0
server/migrations/__init__.py
Normal file
58
server/migrations/runner.py
Normal file
58
server/migrations/runner.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""Auto-migration runner. Applies pending SQL migrations on startup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import asyncpg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
async def run_migrations(pool: asyncpg.Pool) -> None:
|
||||
"""Check schema_version and apply any pending .sql migration files."""
|
||||
async with pool.acquire() as conn:
|
||||
# Ensure the version-tracking table exists
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
description TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
""")
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"SELECT COALESCE(MAX(version), 0) AS v FROM schema_version"
|
||||
)
|
||||
current_version: int = row["v"]
|
||||
logger.info("Current schema version: %d", current_version)
|
||||
|
||||
# Discover and sort SQL files by their numeric prefix
|
||||
sql_files = sorted(
|
||||
MIGRATIONS_DIR.glob("[0-9]*.sql"),
|
||||
key=lambda p: int(p.stem.split("_")[0]),
|
||||
)
|
||||
|
||||
applied = 0
|
||||
for sql_file in sql_files:
|
||||
version = int(sql_file.stem.split("_")[0])
|
||||
if version <= current_version:
|
||||
continue
|
||||
|
||||
logger.info("Applying migration %03d: %s", version, sql_file.name)
|
||||
sql = sql_file.read_text(encoding="utf-8")
|
||||
|
||||
# Execute the entire migration in a transaction
|
||||
async with conn.transaction():
|
||||
await conn.execute(sql)
|
||||
|
||||
logger.info("Migration %03d applied successfully", version)
|
||||
applied += 1
|
||||
|
||||
if applied == 0:
|
||||
logger.info("No pending migrations")
|
||||
else:
|
||||
logger.info("Applied %d migration(s)", applied)
|
||||
Loading…
Add table
Add a link
Reference in a new issue