Let users pick and order their workbench module tiles.

Per-user layout lives in the new app_user.prefs JSON column, read via
/v1/auth/me and written via PUT /v1/auth/me/prefs/workbench. The
workbench gets an in-place edit mode (gear -> drag to reorder, x to
remove, tray to add back, reset/done). Reset stores empty prefs so
registry changes still apply; ids of modules the user can't currently
see are kept on save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-13 08:31:51 +08:00
parent 313706595d
commit 2307f19779
16 changed files with 906 additions and 23 deletions

View file

@ -2,10 +2,12 @@
from __future__ import annotations
import re
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from .auth_cookies import clear_auth_cookies, read_refresh_cookie, set_auth_cookies
from .auth_deps import AuthContext, require_admin, require_user
@ -36,6 +38,7 @@ from .auth_service import (
revoke_auth_sessions,
update_api_token,
update_user,
user_prefs,
user_public,
write_audit,
)
@ -46,9 +49,14 @@ from .auth_rate_limit import (
)
from .client_ip import resolve_client_ip
from .db import get_db
from .models import AppUser
router = APIRouter(tags=["auth"])
_PREF_KEYS = frozenset({"workbench"})
_PREF_ID_RE = re.compile(r"^[a-z0-9-]{1,64}$")
_PREF_LIST_MAX = 64
def _client_meta(request: Request) -> tuple[str, str]:
ip = resolve_client_ip(request)
@ -56,6 +64,34 @@ def _client_meta(request: Request) -> tuple[str, str]:
return ip, ua
def _validate_id_list(value: Any, *, field: str) -> list[str]:
if not isinstance(value, list):
raise HTTPException(status_code=422, detail=f"invalid_{field}")
if len(value) > _PREF_LIST_MAX:
raise HTTPException(status_code=422, detail=f"{field}_too_long")
out: list[str] = []
for item in value:
if not isinstance(item, str) or not _PREF_ID_RE.fullmatch(item):
raise HTTPException(status_code=422, detail=f"invalid_{field}_item")
out.append(item)
return out
def _validate_workbench_prefs(value: Any) -> dict[str, list[str]]:
if not isinstance(value, dict):
raise HTTPException(status_code=422, detail="invalid_workbench_prefs")
return {
"order": _validate_id_list(value.get("order", []), field="order"),
"hidden": _validate_id_list(value.get("hidden", []), field="hidden"),
}
def _validate_pref_value(key: str, value: Any) -> Any:
if key == "workbench":
return _validate_workbench_prefs(value)
raise HTTPException(status_code=404, detail="unknown_pref_key")
def _token_response(response: Response, request: Request, out: dict[str, Any]) -> dict[str, Any]:
access = str(out.get("access_token") or "")
refresh = str(out.get("refresh_token") or "")
@ -269,9 +305,39 @@ def api_me(ctx: Annotated[AuthContext, Depends(require_user)]) -> dict[str, Any]
"auth_via": ctx.auth_via,
"scopes": sorted(ctx.scopes),
"all_scopes": sorted(ALL_SCOPES),
"prefs": user_prefs(ctx.user),
}
@router.get("/v1/auth/me/prefs")
def api_get_prefs(ctx: Annotated[AuthContext, Depends(require_user)]) -> dict[str, Any]:
return {"prefs": user_prefs(ctx.user)}
@router.put("/v1/auth/me/prefs/{key}")
def api_put_pref(
key: str,
body: dict[str, Any],
ctx: Annotated[AuthContext, Depends(require_user)],
db: Session = Depends(get_db),
) -> dict[str, Any]:
if key not in _PREF_KEYS:
raise HTTPException(status_code=404, detail="unknown_pref_key")
validated = _validate_pref_value(key, body)
# Reload in this session: ctx.user may be the middleware's detached copy, or the
# synthetic "system" user when auth is disabled (no row — never insert it).
user = db.get(AppUser, ctx.user.id)
if user is None:
raise HTTPException(status_code=409, detail="prefs_unavailable")
prefs = user_prefs(user)
prefs[key] = validated
user.prefs = prefs
flag_modified(user, "prefs")
db.commit()
db.refresh(user)
return {"prefs": user_prefs(user)}
@router.post("/v1/auth/change-password")
def api_change_password(
body: ChangePasswordRequest,

View file

@ -76,6 +76,11 @@ def user_public(user: AppUser) -> dict[str, Any]:
}
def user_prefs(user: AppUser) -> dict[str, Any]:
raw = getattr(user, "prefs", None)
return dict(raw) if isinstance(raw, dict) else {}
def sanitize_detail(detail: Any) -> Any:
"""Recursively drop secret-looking keys from audit detail payloads."""
if isinstance(detail, dict):
@ -431,6 +436,7 @@ def login_issue_token(
"expires_in": ttl,
"refresh_expires_in": refresh_ttl,
"user": user_public(user),
"prefs": user_prefs(user),
}

View file

@ -21,6 +21,8 @@ class AppUser(Base):
role: Mapped[str] = mapped_column(String(32), default="user", index=True) # admin | user
# Optional capability override; empty => role defaults (see auth_scopes).
scopes: Mapped[list] = mapped_column(_JsonType, default=list)
# Per-user UI preferences (e.g. workbench card layout). Pure client prefs; not audited.
prefs: Mapped[dict] = mapped_column(_JsonType, default=dict)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
created_by: Mapped[str] = mapped_column(String(64), default="")

View file

@ -108,6 +108,7 @@ def apply_auth_schema_patches(conn: Connection) -> None:
"ALTER TABLE app_user ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN DEFAULT FALSE",
)
_run_sql(conn, "ALTER TABLE app_user ADD COLUMN IF NOT EXISTS scopes JSON DEFAULT '[]'")
_run_sql(conn, "ALTER TABLE app_user ADD COLUMN IF NOT EXISTS prefs JSON DEFAULT '{}'")
_run_sql(conn, "ALTER TABLE api_token ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP")
_run_sql(conn, "ALTER TABLE api_token ADD COLUMN IF NOT EXISTS scopes JSON DEFAULT '[]'")
_run_sql(
@ -354,6 +355,7 @@ def apply_domain_schema_patches(conn: Connection) -> None:
"CREATE INDEX IF NOT EXISTS ix_ume_alarms_history_host_name ON ume_alarms_history (host_name)",
"ALTER TABLE api_token ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP",
"ALTER TABLE app_user ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN DEFAULT FALSE",
"ALTER TABLE app_user ADD COLUMN IF NOT EXISTS prefs JSON DEFAULT '{}'",
"ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_enabled BOOLEAN DEFAULT FALSE",
"ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_vendor VARCHAR(32) DEFAULT 'zte'",
"ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_host VARCHAR(128) DEFAULT ''",