mirror of
https://github.com/hansjone/netx.git
synced 2026-10-15 02:33:23 +08:00
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:
parent
313706595d
commit
2307f19779
16 changed files with 906 additions and 23 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue