Persist runtime config in app db and seed from env
This commit is contained in:
+15
-8
@@ -18,6 +18,7 @@ from app.services.auth import (
|
||||
revoke_session,
|
||||
validate_csrf_token,
|
||||
)
|
||||
from app.services.config_page import build_config_sections
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
|
||||
@@ -33,7 +34,7 @@ def login_page(
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> Response:
|
||||
if current_auth is not None:
|
||||
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
|
||||
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
csrf_token = issue_login_csrf_token()
|
||||
response = templates.TemplateResponse(
|
||||
@@ -79,7 +80,7 @@ def login_submit(
|
||||
)
|
||||
|
||||
auth_session, raw_token = create_session(session, user=user, settings=settings)
|
||||
response = RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
|
||||
response = RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
||||
response.delete_cookie(LOGIN_CSRF_COOKIE_NAME, path="/login")
|
||||
response.set_cookie(
|
||||
key=settings.auth_session_cookie_name,
|
||||
@@ -94,7 +95,7 @@ def login_submit(
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/admin/change-password", response_class=HTMLResponse)
|
||||
@router.post("/config/change-password", response_class=HTMLResponse)
|
||||
def change_password_submit(
|
||||
request: Request,
|
||||
current_password: str = Form(),
|
||||
@@ -110,9 +111,10 @@ def change_password_submit(
|
||||
|
||||
if not validate_csrf_token(expected=current_auth.session.csrf_token, actual=csrf_token):
|
||||
logger.warning("Rejected password change attempt due to CSRF validation failure")
|
||||
return _render_admin_page(
|
||||
return _render_config_page(
|
||||
request,
|
||||
settings=settings,
|
||||
auth_db_session=session,
|
||||
current_auth=current_auth,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
password_change_error="invalid password change request",
|
||||
@@ -132,16 +134,17 @@ def change_password_submit(
|
||||
current_auth.user.username,
|
||||
exc,
|
||||
)
|
||||
return _render_admin_page(
|
||||
return _render_config_page(
|
||||
request,
|
||||
settings=settings,
|
||||
auth_db_session=session,
|
||||
current_auth=current_auth,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
password_change_error="password change failed",
|
||||
)
|
||||
|
||||
logger.info("Password updated for user '%s'", current_auth.user.username)
|
||||
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
|
||||
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
@@ -200,17 +203,18 @@ def _set_login_csrf_cookie(response: HTMLResponse, *, settings: Settings, token:
|
||||
)
|
||||
|
||||
|
||||
def _render_admin_page(
|
||||
def _render_config_page(
|
||||
request: Request,
|
||||
*,
|
||||
settings: Settings,
|
||||
auth_db_session: Session,
|
||||
current_auth: AuthenticatedSession,
|
||||
status_code: int,
|
||||
password_change_error: str | None,
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin.html",
|
||||
"config.html",
|
||||
{
|
||||
"app_name": settings.app_name,
|
||||
"app_env": settings.app_env,
|
||||
@@ -218,6 +222,9 @@ def _render_admin_page(
|
||||
"csrf_token": current_auth.session.csrf_token,
|
||||
"force_password_change": current_auth.user.force_password_change,
|
||||
"password_change_error": password_change_error,
|
||||
"config_error": None,
|
||||
"config_saved": False,
|
||||
"config_sections": build_config_sections(auth_db_session, settings),
|
||||
},
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
+87
-11
@@ -1,30 +1,45 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.config import Settings
|
||||
from app.dependencies import get_app_settings, get_current_auth_session
|
||||
from app.config import Settings, get_settings
|
||||
from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session
|
||||
from app.services.auth import AuthenticatedSession
|
||||
from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
|
||||
router = APIRouter(tags=["pages"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def home(request: Request, settings: Settings = Depends(get_app_settings)) -> HTMLResponse:
|
||||
context = {
|
||||
"app_name": settings.app_name,
|
||||
"app_env": settings.app_env,
|
||||
"notion_status": "Legacy scope, removed from the Python rewrite target.",
|
||||
}
|
||||
return templates.TemplateResponse(request, "home.html", context)
|
||||
def home(
|
||||
request: Request,
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> RedirectResponse:
|
||||
if current_auth is None:
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.get("/admin", response_class=HTMLResponse)
|
||||
def admin_page(
|
||||
def admin_redirect(
|
||||
request: Request,
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> RedirectResponse:
|
||||
if current_auth is None:
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.get("/config", response_class=HTMLResponse)
|
||||
def config_page(
|
||||
request: Request,
|
||||
auth_db_session: Session = Depends(get_auth_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> Response:
|
||||
@@ -38,5 +53,66 @@ def admin_page(
|
||||
"csrf_token": current_auth.session.csrf_token,
|
||||
"force_password_change": current_auth.user.force_password_change,
|
||||
"password_change_error": None,
|
||||
"config_error": None,
|
||||
"config_saved": request.query_params.get("saved") == "1",
|
||||
"config_sections": build_config_sections(auth_db_session, settings),
|
||||
}
|
||||
return templates.TemplateResponse(request, "admin.html", context)
|
||||
return templates.TemplateResponse(request, "config.html", context)
|
||||
|
||||
|
||||
@router.post("/config", response_class=HTMLResponse)
|
||||
async def config_submit(
|
||||
request: Request,
|
||||
auth_db_session: Session = Depends(get_auth_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> Response:
|
||||
if current_auth is None:
|
||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
form = await request.form()
|
||||
csrf_token = form.get("csrf_token")
|
||||
if csrf_token != current_auth.session.csrf_token:
|
||||
logger.warning("Rejected config update due to CSRF validation failure")
|
||||
context = {
|
||||
"app_name": settings.app_name,
|
||||
"app_env": settings.app_env,
|
||||
"current_username": current_auth.user.username,
|
||||
"csrf_token": current_auth.session.csrf_token,
|
||||
"force_password_change": current_auth.user.force_password_change,
|
||||
"password_change_error": None,
|
||||
"config_error": "invalid config update request",
|
||||
"config_saved": False,
|
||||
"config_sections": build_config_sections(auth_db_session, settings),
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"config.html",
|
||||
context,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
save_config_updates(auth_db_session, dict(form), settings)
|
||||
except ConfigSaveError:
|
||||
logger.warning("Rejected config update due to invalid submitted values")
|
||||
refreshed_settings = build_runtime_settings(auth_db_session, get_settings())
|
||||
context = {
|
||||
"app_name": refreshed_settings.app_name,
|
||||
"app_env": refreshed_settings.app_env,
|
||||
"current_username": current_auth.user.username,
|
||||
"csrf_token": current_auth.session.csrf_token,
|
||||
"force_password_change": current_auth.user.force_password_change,
|
||||
"password_change_error": None,
|
||||
"config_error": "invalid config submission",
|
||||
"config_saved": False,
|
||||
"config_sections": build_config_sections(auth_db_session, refreshed_settings),
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"config.html",
|
||||
context,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return RedirectResponse(url="/config?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
+7
-6
@@ -9,16 +9,17 @@ from app.db import get_db_session
|
||||
from app.integrations.homeassistant import HomeAssistantClient
|
||||
from app.poo_db import get_poo_db_session
|
||||
from app.services.auth import AuthenticatedSession, get_authenticated_session
|
||||
|
||||
|
||||
def get_app_settings() -> Settings:
|
||||
return get_settings()
|
||||
from app.services.config_page import build_runtime_settings
|
||||
|
||||
|
||||
def get_auth_db() -> Generator[Session, None, None]:
|
||||
yield from get_auth_db_session()
|
||||
|
||||
|
||||
def get_app_settings(session: Session = Depends(get_auth_db)) -> Settings:
|
||||
return build_runtime_settings(session, get_settings())
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
yield from get_db_session()
|
||||
|
||||
@@ -27,8 +28,8 @@ def get_poo_db() -> Generator[Session, None, None]:
|
||||
yield from get_poo_db_session()
|
||||
|
||||
|
||||
def get_homeassistant_client() -> HomeAssistantClient:
|
||||
return HomeAssistantClient(get_settings())
|
||||
def get_homeassistant_client(settings: Settings = Depends(get_app_settings)) -> HomeAssistantClient:
|
||||
return HomeAssistantClient(settings)
|
||||
|
||||
|
||||
def get_current_auth_session(
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.api.routes.location import router as location_router
|
||||
from app.api.routes.poo import router as poo_router
|
||||
from app.config import get_settings
|
||||
from app.services.auth import AuthBootstrapError, initialize_auth_schema
|
||||
from app.services.config_page import seed_missing_config_from_bootstrap
|
||||
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
||||
from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db
|
||||
from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db
|
||||
@@ -25,6 +26,7 @@ def ensure_auth_db_ready() -> None:
|
||||
try:
|
||||
validate_app_runtime_db(get_settings().app_database_url)
|
||||
initialize_auth_schema(session, get_settings())
|
||||
seed_missing_config_from_bootstrap(session, get_settings())
|
||||
except AppDatabaseAdoptionError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
except AuthBootstrapError as exc:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""SQLAlchemy models package."""
|
||||
|
||||
from app.models.auth import AuthSession, AuthUser
|
||||
from app.models.config import AppConfigEntry
|
||||
from app.models.location import Location
|
||||
|
||||
__all__ = ["AuthSession", "AuthUser", "Location"]
|
||||
__all__ = ["AppConfigEntry", "AuthSession", "AuthUser", "Location"]
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.auth_db import AuthBase
|
||||
|
||||
|
||||
class AppConfigEntry(AuthBase):
|
||||
__tablename__ = "app_config"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
value: Mapped[str] = mapped_column(String, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
@@ -0,0 +1,245 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth_db import reset_auth_db_caches
|
||||
from app.config import Settings, get_settings
|
||||
from app.models.config import AppConfigEntry
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigField:
|
||||
section: str
|
||||
env_name: str
|
||||
setting_attr: str
|
||||
label: str
|
||||
secret: bool = False
|
||||
input_type: str = "text"
|
||||
|
||||
|
||||
CONFIG_FIELDS: tuple[ConfigField, ...] = (
|
||||
ConfigField("System", "APP_NAME", "app_name", "App Name"),
|
||||
ConfigField("System", "APP_ENV", "app_env", "App Env"),
|
||||
ConfigField("System", "APP_DEBUG", "app_debug", "App Debug"),
|
||||
ConfigField("System", "APP_HOST", "app_host", "App Host"),
|
||||
ConfigField("System", "APP_PORT", "app_port", "App Port"),
|
||||
ConfigField(
|
||||
"Authentication",
|
||||
"AUTH_SESSION_COOKIE_NAME",
|
||||
"auth_session_cookie_name",
|
||||
"Session Cookie Name",
|
||||
),
|
||||
ConfigField("Authentication", "AUTH_SESSION_TTL_HOURS", "auth_session_ttl_hours", "Session TTL Hours"),
|
||||
ConfigField(
|
||||
"Authentication",
|
||||
"AUTH_COOKIE_SECURE_OVERRIDE",
|
||||
"auth_cookie_secure_override",
|
||||
"Cookie Secure Override",
|
||||
),
|
||||
ConfigField("Poo", "POO_WEBHOOK_ID", "poo_webhook_id", "Poo Webhook ID", secret=True),
|
||||
ConfigField(
|
||||
"Poo",
|
||||
"POO_SENSOR_ENTITY_NAME",
|
||||
"poo_sensor_entity_name",
|
||||
"Poo Sensor Entity Name",
|
||||
),
|
||||
ConfigField(
|
||||
"Poo",
|
||||
"POO_SENSOR_FRIENDLY_NAME",
|
||||
"poo_sensor_friendly_name",
|
||||
"Poo Sensor Friendly Name",
|
||||
),
|
||||
ConfigField("TickTick", "TICKTICK_CLIENT_ID", "ticktick_client_id", "TickTick Client ID"),
|
||||
ConfigField(
|
||||
"TickTick",
|
||||
"TICKTICK_CLIENT_SECRET",
|
||||
"ticktick_client_secret",
|
||||
"TickTick Client Secret",
|
||||
secret=True,
|
||||
),
|
||||
ConfigField(
|
||||
"TickTick",
|
||||
"TICKTICK_REDIRECT_URI",
|
||||
"ticktick_redirect_uri",
|
||||
"TickTick Redirect URI",
|
||||
),
|
||||
ConfigField("TickTick", "TICKTICK_TOKEN", "ticktick_token", "TickTick Token", secret=True),
|
||||
ConfigField(
|
||||
"Home Assistant",
|
||||
"HOME_ASSISTANT_BASE_URL",
|
||||
"home_assistant_base_url",
|
||||
"Home Assistant Base URL",
|
||||
),
|
||||
ConfigField(
|
||||
"Home Assistant",
|
||||
"HOME_ASSISTANT_AUTH_TOKEN",
|
||||
"home_assistant_auth_token",
|
||||
"Home Assistant Auth Token",
|
||||
secret=True,
|
||||
),
|
||||
ConfigField(
|
||||
"Home Assistant",
|
||||
"HOME_ASSISTANT_TIMEOUT_SECONDS",
|
||||
"home_assistant_timeout_seconds",
|
||||
"Home Assistant Timeout Seconds",
|
||||
),
|
||||
ConfigField(
|
||||
"Home Assistant",
|
||||
"HOME_ASSISTANT_ACTION_TASK_PROJECT_ID",
|
||||
"home_assistant_action_task_project_id",
|
||||
"Home Assistant Action Task Project ID",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ConfigSaveError(ValueError):
|
||||
"""Raised when the submitted config payload is invalid."""
|
||||
|
||||
|
||||
def seed_missing_config_from_bootstrap(session: Session, bootstrap_settings: Settings) -> None:
|
||||
current_values = _read_config_values(session)
|
||||
missing_values: dict[str, str] = {}
|
||||
|
||||
for field in CONFIG_FIELDS:
|
||||
if field.env_name in current_values:
|
||||
continue
|
||||
missing_values[field.env_name] = _stringify(getattr(bootstrap_settings, field.setting_attr))
|
||||
|
||||
if not missing_values:
|
||||
return
|
||||
|
||||
_persist_config_values(session, {**current_values, **missing_values})
|
||||
|
||||
|
||||
def build_runtime_settings(session: Session, bootstrap_settings: Settings) -> Settings:
|
||||
overrides = _read_config_values(session)
|
||||
if not overrides:
|
||||
return bootstrap_settings
|
||||
|
||||
payload = _settings_payload(bootstrap_settings)
|
||||
for field in CONFIG_FIELDS:
|
||||
if field.env_name in overrides:
|
||||
payload[field.setting_attr] = overrides[field.env_name]
|
||||
|
||||
return Settings(_env_file=None, **payload)
|
||||
|
||||
|
||||
def build_config_sections(session: Session, bootstrap_settings: Settings) -> list[dict[str, Any]]:
|
||||
runtime_settings = build_runtime_settings(session, bootstrap_settings)
|
||||
persisted_values = _read_config_values(session)
|
||||
sections: list[dict[str, Any]] = []
|
||||
current_section: dict[str, Any] | None = None
|
||||
|
||||
for field in CONFIG_FIELDS:
|
||||
if current_section is None or current_section["name"] != field.section:
|
||||
current_section = {"name": field.section, "fields": []}
|
||||
sections.append(current_section)
|
||||
|
||||
current_section["fields"].append(
|
||||
{
|
||||
"env_name": field.env_name,
|
||||
"label": field.label,
|
||||
"value": "" if field.secret else _stringify(getattr(runtime_settings, field.setting_attr)),
|
||||
"secret": field.secret,
|
||||
"input_type": "password" if field.secret else field.input_type,
|
||||
"configured": field.env_name in persisted_values
|
||||
or bool(_stringify(getattr(bootstrap_settings, field.setting_attr))),
|
||||
}
|
||||
)
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def save_config_updates(session: Session, form_data: dict[str, str], bootstrap_settings: Settings) -> None:
|
||||
current_values = _read_config_values(session)
|
||||
merged_values = dict(current_values)
|
||||
|
||||
for field in CONFIG_FIELDS:
|
||||
submitted_value = form_data.get(field.env_name, "")
|
||||
if field.secret:
|
||||
if submitted_value:
|
||||
merged_values[field.env_name] = submitted_value
|
||||
else:
|
||||
merged_values[field.env_name] = submitted_value
|
||||
|
||||
_validate_config_values(merged_values, bootstrap_settings)
|
||||
_persist_config_values(session, merged_values)
|
||||
get_settings.cache_clear()
|
||||
reset_auth_db_caches()
|
||||
|
||||
|
||||
def _read_config_values(session: Session) -> dict[str, str]:
|
||||
rows = session.execute(select(AppConfigEntry).order_by(AppConfigEntry.key)).scalars().all()
|
||||
return {row.key: row.value for row in rows}
|
||||
|
||||
|
||||
def _validate_config_values(config_values: dict[str, str], bootstrap_settings: Settings) -> None:
|
||||
payload = _settings_payload(bootstrap_settings)
|
||||
for field in CONFIG_FIELDS:
|
||||
if field.env_name in config_values:
|
||||
payload[field.setting_attr] = config_values[field.env_name]
|
||||
|
||||
try:
|
||||
Settings(_env_file=None, **payload)
|
||||
except Exception as exc:
|
||||
raise ConfigSaveError("invalid config submission") from exc
|
||||
|
||||
|
||||
def _persist_config_values(session: Session, config_values: dict[str, str]) -> None:
|
||||
existing_entries = {
|
||||
row.key: row
|
||||
for row in session.execute(select(AppConfigEntry)).scalars().all()
|
||||
}
|
||||
now = datetime.now(UTC)
|
||||
|
||||
for env_name, value in config_values.items():
|
||||
entry = existing_entries.get(env_name)
|
||||
if entry is None:
|
||||
session.add(AppConfigEntry(key=env_name, value=value, updated_at=now))
|
||||
else:
|
||||
entry.value = value
|
||||
entry.updated_at = now
|
||||
|
||||
session.commit()
|
||||
|
||||
|
||||
def _stringify(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool):
|
||||
return str(value).lower()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _settings_payload(settings: Settings) -> dict[str, Any]:
|
||||
return {
|
||||
"app_name": settings.app_name,
|
||||
"app_env": settings.app_env,
|
||||
"app_debug": settings.app_debug,
|
||||
"app_host": settings.app_host,
|
||||
"app_port": settings.app_port,
|
||||
"app_database_url": settings.app_database_url,
|
||||
"location_database_url": settings.location_database_url,
|
||||
"poo_database_url": settings.poo_database_url,
|
||||
"ticktick_client_id": settings.ticktick_client_id,
|
||||
"ticktick_client_secret": settings.ticktick_client_secret,
|
||||
"ticktick_redirect_uri": settings.ticktick_redirect_uri,
|
||||
"ticktick_token": settings.ticktick_token,
|
||||
"home_assistant_base_url": settings.home_assistant_base_url,
|
||||
"home_assistant_auth_token": settings.home_assistant_auth_token,
|
||||
"home_assistant_timeout_seconds": settings.home_assistant_timeout_seconds,
|
||||
"home_assistant_action_task_project_id": settings.home_assistant_action_task_project_id,
|
||||
"poo_webhook_id": settings.poo_webhook_id,
|
||||
"poo_sensor_entity_name": settings.poo_sensor_entity_name,
|
||||
"poo_sensor_friendly_name": settings.poo_sensor_friendly_name,
|
||||
"auth_bootstrap_username": settings.auth_bootstrap_username,
|
||||
"auth_bootstrap_password": settings.auth_bootstrap_password,
|
||||
"auth_session_cookie_name": settings.auth_session_cookie_name,
|
||||
"auth_session_ttl_hours": settings.auth_session_ttl_hours,
|
||||
"auth_cookie_secure_override": settings.auth_cookie_secure_override,
|
||||
}
|
||||
@@ -61,6 +61,11 @@ h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.single-column {
|
||||
grid-template-columns: minmax(180px, 320px);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.meta div {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
@@ -136,6 +141,47 @@ button:hover {
|
||||
color: #8b2a2a;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(45, 106, 79, 0.08);
|
||||
border: 1px solid rgba(45, 106, 79, 0.14);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.config-block + .config-block {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.config-block h2 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.config-form {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(31, 41, 51, 0.08);
|
||||
border-radius: 16px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.config-section legend {
|
||||
padding: 0 8px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.config-form label small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shell {
|
||||
margin: 24px auto;
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin · {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<p class="eyebrow">Protected Area</p>
|
||||
<h1>Admin</h1>
|
||||
{% if force_password_change %}
|
||||
<p class="lead">
|
||||
首次登录后需要先修改密码。完成后,这里会继续作为未来配置页面的入口。
|
||||
</p>
|
||||
|
||||
{% if password_change_error %}
|
||||
<div class="alert">{{ password_change_error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="auth-form" method="post" action="/admin/change-password">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<label>
|
||||
<span>Current Password</span>
|
||||
<input type="password" name="current_password" autocomplete="current-password" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>New Password</span>
|
||||
<input type="password" name="new_password" autocomplete="new-password" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Confirm New Password</span>
|
||||
<input type="password" name="confirm_password" autocomplete="new-password" required>
|
||||
</label>
|
||||
|
||||
<button type="submit">修改密码</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="lead">
|
||||
你已经登录。这个页面目前是一个受保护的空白配置占位页,后续会在这里接入配置的增删查改。
|
||||
</p>
|
||||
|
||||
<dl class="meta">
|
||||
<div>
|
||||
<dt>当前用户</dt>
|
||||
<dd>{{ current_username }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>运行环境</dt>
|
||||
<dd>{{ app_env }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>下一步</dt>
|
||||
<dd>在这里接入配置页面与更细的受保护操作。</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{% endif %}
|
||||
|
||||
<form class="logout-form" method="post" action="/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">登出</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,90 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Config · {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<p class="eyebrow">Configuration</p>
|
||||
<h1>Config</h1>
|
||||
|
||||
{% if force_password_change %}
|
||||
<div class="alert">
|
||||
首次登录后需要先修改密码。完成后再继续长期使用当前配置页面。
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if password_change_error %}
|
||||
<div class="alert">{{ password_change_error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if config_error %}
|
||||
<div class="alert">{{ config_error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if config_saved %}
|
||||
<div class="notice">config saved to .env. Some changes may require an app restart.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="meta single-column">
|
||||
<div>
|
||||
<dt>当前用户</dt>
|
||||
<dd>admin</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="config-block">
|
||||
<h2>Change Password</h2>
|
||||
<form class="auth-form" method="post" action="/config/change-password">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<label>
|
||||
<span>Current Password</span>
|
||||
<input type="password" name="current_password" autocomplete="current-password" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>New Password</span>
|
||||
<input type="password" name="new_password" autocomplete="new-password" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Confirm New Password</span>
|
||||
<input type="password" name="confirm_password" autocomplete="new-password" required>
|
||||
</label>
|
||||
|
||||
<button type="submit">修改密码</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="config-block">
|
||||
<h2>Config</h2>
|
||||
<form class="config-form" method="post" action="/config">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
{% for section in config_sections %}
|
||||
<fieldset class="config-section">
|
||||
<legend>{{ section.name }}</legend>
|
||||
{% for field in section.fields %}
|
||||
<label>
|
||||
<span>{{ field.label }}</span>
|
||||
{% if field.secret %}
|
||||
<input type="{{ field.input_type }}" name="{{ field.env_name }}" value="" placeholder="leave blank to keep current value">
|
||||
<small>{% if field.configured %}configured{% else %}not configured{% endif %}</small>
|
||||
{% else %}
|
||||
<input type="{{ field.input_type }}" name="{{ field.env_name }}" value="{{ field.value }}">
|
||||
{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
|
||||
<button type="submit">Save Config</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<form class="logout-form" method="post" action="/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">登出</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -7,7 +7,7 @@
|
||||
<p class="eyebrow">Authentication</p>
|
||||
<h1>登录</h1>
|
||||
<p class="lead">
|
||||
这个页面只负责当前 Python 重构项目的基础登录能力。配置管理等页面会在后续迭代中接入。
|
||||
登录成功后会进入受保护的 config 页面。
|
||||
</p>
|
||||
|
||||
{% if error_message %}
|
||||
|
||||
Reference in New Issue
Block a user