diff --git a/alembic/versions/20261013_user_prefs.py b/alembic/versions/20261013_user_prefs.py new file mode 100644 index 0000000..abd5d96 --- /dev/null +++ b/alembic/versions/20261013_user_prefs.py @@ -0,0 +1,52 @@ +"""Add app_user.prefs JSON for per-user UI preferences. + +Revision ID: 20261013_user_prefs +Revises: 20260922_biz_state_queued +Create Date: 2026-10-13 +""" + +from __future__ import annotations + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261013_user_prefs" +down_revision: Union[str, Sequence[str], None] = "20260922_biz_state_queued" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + dialect = str(getattr(bind.dialect, "name", "") or "").lower() + if dialect.startswith("postgres"): + op.execute( + "ALTER TABLE app_user ADD COLUMN IF NOT EXISTS prefs JSON DEFAULT '{}'::json NOT NULL" + ) + # Ensure NOT NULL + default for brownfield rows that already had a nullable column. + op.execute("UPDATE app_user SET prefs = '{}'::json WHERE prefs IS NULL") + op.execute("ALTER TABLE app_user ALTER COLUMN prefs SET DEFAULT '{}'::json") + op.execute("ALTER TABLE app_user ALTER COLUMN prefs SET NOT NULL") + else: + insp = sa.inspect(bind) + if "app_user" in insp.get_table_names(): + cols = {c["name"] for c in insp.get_columns("app_user")} + if "prefs" not in cols: + op.add_column( + "app_user", + sa.Column("prefs", sa.JSON(), nullable=False, server_default="{}"), + ) + + +def downgrade() -> None: + bind = op.get_bind() + dialect = str(getattr(bind.dialect, "name", "") or "").lower() + if dialect.startswith("postgres"): + op.execute("ALTER TABLE app_user DROP COLUMN IF EXISTS prefs") + else: + try: + op.drop_column("app_user", "prefs") + except Exception: + pass diff --git a/docs/reviews/workbench-custom-modules-plan-2026-10-13.md b/docs/reviews/workbench-custom-modules-plan-2026-10-13.md new file mode 100644 index 0000000..82f52f7 --- /dev/null +++ b/docs/reviews/workbench-custom-modules-plan-2026-10-13.md @@ -0,0 +1,112 @@ +# 工作台功能卡片自定义 — 实现方案(2026-10-13) + +## 目标 + +工作台功能区(UME 同步、网元管理、网络管理、拓扑、WebCRT…)以后会越来越多,每个人关心的不一样。 +让每个用户自己选择:**显示哪些卡片、按什么顺序**;设置跟着账号走(换电脑/换浏览器仍生效)。 + +## 现状 + +- 卡片注册表:`web/src/config/modules.ts:33` `MODULES`,按 `section` 分 `monitoring / operations / system`。 +- 过滤逻辑:`web/src/features/workbench/WorkbenchPage.tsx:323` `visibleBySection` —— 只按权限(`adminOnly` / `requiredScope`)和 `workbenchHidden` 过滤。 +- 渲染:`web/src/features/workbench/components/WorkbenchModules.tsx:23`,operations 是大磁贴网格,system 是底部一排小链接。 +- 后端无任何“用户偏好”存储;`AppUser`(`netx_api/models/auth.py:13`)只有账号字段。 + +## 设计决定 + +| 项 | 决定 | 理由 | +|---|---|---| +| 可自定义范围 | 只做 **operations 区的磁贴**(含 monitoring 合并进来的 UME 同步)。system 区链接行保持固定 | 系统管理入口少且是管理类,隐藏后反而找不到;需求说的是“功能卡片” | +| 存储位置 | **服务端,按用户**:`app_user.prefs` JSON 列 | “每个人不一样”= 按账号;localStorage 只按浏览器,换机就丢 | +| 本地缓存 | 同时写一份 localStorage(key 带 user id),首屏先用缓存渲染,避免卡片闪烁 | 首屏体验 | +| 数据结构 | `{ order: string[], hidden: string[] }`,存的是“隐藏了哪些”而不是“显示哪些” | 以后新增模块默认对所有人出现,不需要每人手动开启 | +| 新模块默认隐藏 | `ModuleDefinition` 增加可选 `defaultHidden?: boolean`;不在 `order` 里的这类模块视为隐藏 | 给以后“小众功能”留开关 | +| 权限 | 没权限的模块永远不出现在卡片区,也不出现在“可添加”列表;偏好里残留的 id 静默忽略 | 偏好不能绕过权限 | +| 交互 | 卡片区右上角一个小齿轮 → 进入**原地编辑模式**:已显示卡片可拖拽排序、右上角 × 移除;下方出现“可添加”托盘(灰显卡片,点击加入);按钮只有「恢复默认」「完成」 | 和 iOS 主屏编辑一致,最少文案;不弹窗 | +| 拖拽实现 | HTML5 原生 drag & drop,不引入新依赖 | 卡片数量小(<20),原生够用;项目里没有 dnd 库 | +| 保存时机 | 点「完成」时一次 PUT;失败则 toast 并保留编辑状态 | 避免拖动过程中频繁请求 | +| 全部隐藏 | 允许,但功能区显示一个空态(只有齿轮 + 一句“添加功能”) | 不阻止用户,但要能找回 | + +## 任务 + +### 后端 + +**B1 — `app_user.prefs` 列** +- `netx_api/models/auth.py:13` `AppUser` 加 `prefs: Mapped[dict] = mapped_column(_JsonType, default=dict)`。 +- 新迁移 `alembic/versions/20261013_user_prefs.py`,`down_revision = "20260922_biz_state_queued"`,`add_column("app_user", sa.Column("prefs", JSON, nullable=False, server_default="{}"))`。 + +**B2 — 读写接口**(`netx_api/auth_router.py`,紧跟 `api_me` 之后,约 :273) +- `GET /v1/auth/me/prefs` → `{"prefs": {...}}` +- `PUT /v1/auth/me/prefs/{key}`,body 为该 key 的值;`key` 白名单,目前只允许 `workbench`。 +- 校验 `workbench`:`{order: list[str], hidden: list[str]}`,每个列表 ≤ 64 项、每个 id ≤ 64 字符、只允许 `[a-z0-9-]`;其他字段丢弃。超限 422。 +- 只改自己的偏好(`require_user`),不需要额外 scope;不写审计(纯 UI 偏好,避免刷屏)。 +- `/v1/auth/me` 响应里顺带返回 `prefs`,前端登录后一次拿到,不多一次请求。 + +### 前端 + +**F1 — 注册表扩展** `web/src/config/modules.ts` +- `ModuleDefinition` 加 `defaultHidden?: boolean`。 +- 新增 `export function customizableModules()`:operations + monitoring 中非 `workbenchHidden` 的模块(即当前 `displaySections` 里 operations 那组的来源)。 + +**F2 — 偏好 hook** 新建 `web/src/features/workbench/useWorkbenchLayout.ts` +- 输入:权限过滤后的可用模块列表;输出:`{ shown: ModuleDefinition[], available: ModuleDefinition[], save(order, hidden), reset() }`。 +- 解析规则(纯函数 `resolveLayout(all, prefs)`,单独导出便于测试): + 1. 先按 `prefs.order` 排已知 id,丢弃不存在/无权限的 id; + 2. 剩余模块按注册表顺序追加到末尾; + 3. 在 `hidden` 中的、或 `defaultHidden` 且不在 `order` 中的,归入 `available`。 +- 数据来源:`AuthContext` 的 `me.prefs.workbench` → 没有则 localStorage 缓存 `netx.wb.layout.` → 默认。 +- 保存:PUT 成功后更新 AuthContext 中的 prefs 和 localStorage。localStorage 读写全部 try/catch。 + +**F3 — AuthContext** `web/src/features/auth/AuthContext.tsx:48` +- `refreshMe` / `login` 把响应里的 `prefs` 存进 state,并暴露 `prefs` 与 `setPrefs`。 + +**F4 — 编辑模式 UI** `web/src/features/workbench/components/WorkbenchModules.tsx` +- `WorkbenchPage.tsx:334` `displaySections` 的 operations 组改为用 `useWorkbenchLayout().shown`。 +- 卡片区右上角齿轮按钮(`ConsoleIcon` 加一个 `settings` 图标),`aria-label` = “自定义功能区”。 +- 编辑态: + - 磁贴轻微抖动或描边(遵循 glass 风格:**不发光**,只用 `--nx-line` 边框)、右上角 × 按钮; + - `draggable`,`onDragStart/onDragOver/onDrop` 重排本地草稿数组; + - 下方“可添加”托盘:灰显磁贴,点击加入末尾;托盘为空时不显示; + - 底部两个按钮:「恢复默认」(清空草稿为默认)、「完成」(保存);Esc = 放弃草稿退出。 + - 编辑态下点击磁贴不打开模块。 +- 非编辑态 shown 为空:显示空态 + “添加功能”按钮(直接进入编辑态)。 +- 样式写在 `web/src/styles/pages/workbench.css`,颜色只用现有 token(蓝黑 200–215°,无紫色)。 + +**F5 — 文案** `web/src/i18n/locales/{zh,en}/workbench.ts` +- 新增:`customize`(自定义功能区)、`customizeDone`(完成)、`customizeReset`(恢复默认)、`customizeAvailable`(可添加)、`customizeEmpty`(添加功能)、`customizeSaveFailed`(保存失败)。只这些,不加说明文字。 + +## 验收 + +| ID | 场景 | 期望 | +|---|---|---| +| A1 | 新用户首次登录 | 卡片与改动前完全一致(顺序、数量) | +| A2 | 隐藏「拓扑」、把「网元管理」拖到第一位,点完成 | 立即生效;刷新、换浏览器登录同一账号仍是该布局 | +| A3 | 用户 A 定制后,用户 B 登录 | B 不受影响 | +| A4 | 在 `MODULES` 新增一个模块 | 已定制过的用户自动在末尾看到它;若设了 `defaultHidden` 则出现在“可添加”里 | +| A5 | 偏好里包含无权限模块 id(如去掉用户 `ne:read` 后) | 不显示、不出现在可添加;不报错 | +| A6 | 全部移除后完成 | 显示空态,点“添加功能”可恢复 | +| A7 | 编辑中按 Esc / 保存失败(断网) | Esc 恢复原布局;失败提示并停留在编辑态 | +| A8 | `PUT /v1/auth/me/prefs/workbench` 传非法 key、超长列表、非法字符 | 404 / 422 | +| A9 | 视觉 | 编辑态无发光、无紫色,与现有 glass 风格一致 | + +## 回归命令 + +```bash +alembic upgrade head +``` + +```bash +python -m pytest tests/test_auth.py tests/test_user_prefs.py -q +``` + +```bash +cd web && npm run lint && npm run build +``` + +- 新增 `tests/test_user_prefs.py`:覆盖 B2 的读写、白名单、校验、用户隔离(A3、A8)。 +- `resolveLayout` 纯函数若项目有前端单测则补上(A1、A4、A5);没有则在验收时手测。 + +## 以后可扩展(本次不做) + +- 同一机制用 `prefs.workbench.panels` 控制右侧任务栏、网元/拓扑/调度三张统计卡的显隐。 +- 管理员给角色设默认布局(`role_default_prefs`),用户未定制时用角色默认。 diff --git a/netx_api/auth_router.py b/netx_api/auth_router.py index f402c70..1ff052a 100644 --- a/netx_api/auth_router.py +++ b/netx_api/auth_router.py @@ -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, diff --git a/netx_api/auth_service.py b/netx_api/auth_service.py index 94b166c..464bd1d 100644 --- a/netx_api/auth_service.py +++ b/netx_api/auth_service.py @@ -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), } diff --git a/netx_api/models/auth.py b/netx_api/models/auth.py index 39947a0..ffdbc39 100644 --- a/netx_api/models/auth.py +++ b/netx_api/models/auth.py @@ -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="") diff --git a/netx_api/schema_patches.py b/netx_api/schema_patches.py index 1dc4528..888d732 100644 --- a/netx_api/schema_patches.py +++ b/netx_api/schema_patches.py @@ -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 ''", diff --git a/tests/test_user_prefs.py b/tests/test_user_prefs.py new file mode 100644 index 0000000..9cd1254 --- /dev/null +++ b/tests/test_user_prefs.py @@ -0,0 +1,193 @@ +"""Per-user prefs API: workbench layout read/write, whitelist, validation, isolation.""" + +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from netx_api.auth_middleware import AuthAuditMiddleware +from netx_api.auth_router import router as auth_router +from netx_api.auth_service import bootstrap_admin_if_needed, create_user +from netx_api.db import Base, get_db +from netx_api.models import AppUser + + +class UserPrefsApiTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=self.engine) + self.Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False) + + self.app = FastAPI() + self.app.add_middleware(AuthAuditMiddleware) + self.app.include_router(auth_router) + + def _override_db(): + db = self.Session() + try: + yield db + finally: + db.close() + + self.app.dependency_overrides[get_db] = _override_db + + self._sess_patch = patch("netx_api.auth_middleware.SessionLocal", self.Session) + self._sess_patch.start() + self._settings_patches = [ + patch("netx_api.auth_middleware.settings.auth_enabled", True), + patch("netx_api.audit_async.settings.audit_async", False), + patch("netx_api.auth_tokens.settings.auth_secret", "unit-test-auth-secret-32bytes!!"), + patch("netx_api.auth_tokens.settings.auth_token_ttl_sec", 3600), + patch("netx_api.auth_service.settings.bootstrap_admin_username", "admin"), + patch("netx_api.auth_service.settings.bootstrap_admin_password", "adminpass"), + patch("netx_api.auth_deps.settings.auth_enabled", True), + ] + for p in self._settings_patches: + p.start() + + db = self.Session() + try: + bootstrap_admin_if_needed(db) + admin = db.query(AppUser).filter(AppUser.username == "admin").one() + admin.must_change_password = False + create_user(db, username="alice", password="alice123", role="user", actor=admin) + create_user(db, username="bob", password="bob12345", role="user", actor=admin) + for name in ("alice", "bob"): + u = db.query(AppUser).filter(AppUser.username == name).one() + u.must_change_password = False + db.commit() + finally: + db.close() + + self.client = TestClient(self.app) + + def tearDown(self) -> None: + self._sess_patch.stop() + for p in self._settings_patches: + p.stop() + self.app.dependency_overrides.clear() + self.engine.dispose() + + def _login(self, username: str, password: str) -> str: + r = self.client.post("/v1/auth/login", json={"username": username, "password": password}) + self.assertEqual(r.status_code, 200, r.text) + return str(r.json()["access_token"]) + + def test_me_includes_empty_prefs(self) -> None: + token = self._login("alice", "alice123") + me = self.client.get("/v1/auth/me", headers={"Authorization": f"Bearer {token}"}) + self.assertEqual(me.status_code, 200, me.text) + self.assertEqual(me.json().get("prefs"), {}) + login = self.client.post("/v1/auth/login", json={"username": "alice", "password": "alice123"}) + self.assertEqual(login.json().get("prefs"), {}) + + def test_get_and_put_workbench(self) -> None: + token = self._login("alice", "alice123") + empty = self.client.get("/v1/auth/me/prefs", headers={"Authorization": f"Bearer {token}"}) + self.assertEqual(empty.status_code, 200, empty.text) + self.assertEqual(empty.json(), {"prefs": {}}) + + body = {"order": ["ne", "topology"], "hidden": ["webcrt"]} + put = self.client.put( + "/v1/auth/me/prefs/workbench", + headers={"Authorization": f"Bearer {token}"}, + json=body, + ) + self.assertEqual(put.status_code, 200, put.text) + self.assertEqual(put.json()["prefs"]["workbench"], body) + + got = self.client.get("/v1/auth/me/prefs", headers={"Authorization": f"Bearer {token}"}) + self.assertEqual(got.json()["prefs"]["workbench"], body) + me = self.client.get("/v1/auth/me", headers={"Authorization": f"Bearer {token}"}) + self.assertEqual(me.json()["prefs"]["workbench"], body) + + def test_put_strips_unknown_fields(self) -> None: + token = self._login("alice", "alice123") + put = self.client.put( + "/v1/auth/me/prefs/workbench", + headers={"Authorization": f"Bearer {token}"}, + json={"order": ["ne"], "hidden": [], "extra": True, "panels": {}}, + ) + self.assertEqual(put.status_code, 200, put.text) + self.assertEqual(put.json()["prefs"]["workbench"], {"order": ["ne"], "hidden": []}) + + def test_unknown_key_404(self) -> None: + token = self._login("alice", "alice123") + r = self.client.put( + "/v1/auth/me/prefs/panels", + headers={"Authorization": f"Bearer {token}"}, + json={"order": [], "hidden": []}, + ) + self.assertEqual(r.status_code, 404) + + def test_validation_422(self) -> None: + token = self._login("alice", "alice123") + headers = {"Authorization": f"Bearer {token}"} + + bad_char = self.client.put( + "/v1/auth/me/prefs/workbench", + headers=headers, + json={"order": ["NE_Admin"], "hidden": []}, + ) + self.assertEqual(bad_char.status_code, 422) + + too_long = self.client.put( + "/v1/auth/me/prefs/workbench", + headers=headers, + json={"order": [f"m{i}" for i in range(65)], "hidden": []}, + ) + self.assertEqual(too_long.status_code, 422) + + bad_type = self.client.put( + "/v1/auth/me/prefs/workbench", + headers=headers, + json={"order": "ne", "hidden": []}, + ) + self.assertEqual(bad_type.status_code, 422) + + def test_user_isolation(self) -> None: + alice = self._login("alice", "alice123") + bob = self._login("bob", "bob12345") + layout = {"order": ["topology", "ne"], "hidden": ["ume"]} + put = self.client.put( + "/v1/auth/me/prefs/workbench", + headers={"Authorization": f"Bearer {alice}"}, + json=layout, + ) + self.assertEqual(put.status_code, 200, put.text) + + bob_me = self.client.get("/v1/auth/me", headers={"Authorization": f"Bearer {bob}"}) + self.assertEqual(bob_me.status_code, 200, bob_me.text) + self.assertEqual(bob_me.json().get("prefs"), {}) + + alice_me = self.client.get("/v1/auth/me", headers={"Authorization": f"Bearer {alice}"}) + self.assertEqual(alice_me.json()["prefs"]["workbench"], layout) + + def test_auth_disabled_does_not_insert_system_user(self) -> None: + with patch("netx_api.auth_deps.settings.auth_enabled", False), patch( + "netx_api.auth_middleware.settings.auth_enabled", False + ): + r = self.client.put( + "/v1/auth/me/prefs/workbench", + json={"order": ["ne"], "hidden": []}, + ) + self.assertEqual(r.status_code, 409, r.text) + db = self.Session() + try: + self.assertIsNone(db.get(AppUser, "system")) + finally: + db.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/web/src/config/modules.ts b/web/src/config/modules.ts index 4c957e0..0cc907c 100644 --- a/web/src/config/modules.ts +++ b/web/src/config/modules.ts @@ -28,6 +28,8 @@ export type ModuleDefinition = { adminOnly?: boolean; /** Hide from workbench launcher; still used for module window registration. */ workbenchHidden?: boolean; + /** When true and not listed in the user's order, start in the "available" tray. */ + defaultHidden?: boolean; }; export const MODULES: readonly ModuleDefinition[] = [ @@ -159,6 +161,13 @@ export function modulesInSection(section: WorkbenchSection): ModuleDefinition[] return MODULES.filter((m) => m.section === section); } +/** Operations-area tiles users can show/hide/reorder (monitoring folded into ops). */ +export function customizableModules(): ModuleDefinition[] { + return MODULES.filter( + (m) => (m.section === "operations" || m.section === "monitoring") && !m.workbenchHidden, + ); +} + export function isWorkbenchPath(pathname: string): boolean { return pathname === "/" || pathname === "/workbench"; } diff --git a/web/src/features/auth/AuthContext.tsx b/web/src/features/auth/AuthContext.tsx index cf8b1f8..5b0e01f 100644 --- a/web/src/features/auth/AuthContext.tsx +++ b/web/src/features/auth/AuthContext.tsx @@ -21,11 +21,15 @@ export type AuthUser = { updated_at?: string | null; }; +export type UserPrefs = Record; + type AuthState = { ready: boolean; token: string | null; user: AuthUser | null; scopes: string[]; + prefs: UserPrefs; + setPrefs: (prefs: UserPrefs) => void; login: (username: string, password: string) => Promise; logout: () => Promise; refreshMe: () => Promise; @@ -35,24 +39,33 @@ type AuthState = { const AuthContext = createContext(null); +function asPrefs(value: unknown): UserPrefs { + return value && typeof value === "object" && !Array.isArray(value) ? (value as UserPrefs) : {}; +} + export function AuthProvider({ children }: { children: ReactNode }) { const [ready, setReady] = useState(false); // token is opaque for UI; cookie session means we only care about user presence. const [token, setToken] = useState(null); const [user, setUser] = useState(null); const [scopes, setScopes] = useState([]); + const [prefs, setPrefs] = useState({}); const refreshMe = useCallback(async () => { clearAuthToken(); // drop any legacy localStorage tokens try { - const data = await apiGet<{ user: AuthUser; scopes?: string[] }>("/v1/auth/me"); + const data = await apiGet<{ user: AuthUser; scopes?: string[]; prefs?: UserPrefs }>( + "/v1/auth/me", + ); setToken("cookie"); setUser(data.user); setScopes(data.scopes || data.user.scopes || []); + setPrefs(asPrefs(data.prefs)); } catch { setToken(null); setUser(null); setScopes([]); + setPrefs({}); } }, []); @@ -64,7 +77,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, [refreshMe]); const login = useCallback(async (username: string, password: string) => { - const data = await apiPost<{ user: AuthUser }>("/v1/auth/login", { + const data = await apiPost<{ user: AuthUser; prefs?: UserPrefs }>("/v1/auth/login", { username, password, }); @@ -72,6 +85,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setToken("cookie"); setUser(data.user); setScopes(data.user.scopes || []); + setPrefs(asPrefs(data.prefs)); }, []); const logout = useCallback(async () => { @@ -84,6 +98,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setToken(null); setUser(null); setScopes([]); + setPrefs({}); }, []); const value = useMemo( @@ -92,13 +107,15 @@ export function AuthProvider({ children }: { children: ReactNode }) { token, user, scopes, + prefs, + setPrefs, login, logout, refreshMe, isAdmin: user?.role === "admin", hasScope: (scope: string) => scopes.includes(scope) || user?.role === "admin", }), - [ready, token, user, scopes, login, logout, refreshMe], + [ready, token, user, scopes, prefs, login, logout, refreshMe], ); return {children}; diff --git a/web/src/features/workbench/WorkbenchPage.tsx b/web/src/features/workbench/WorkbenchPage.tsx index b7fb4fb..774db86 100644 --- a/web/src/features/workbench/WorkbenchPage.tsx +++ b/web/src/features/workbench/WorkbenchPage.tsx @@ -2,7 +2,7 @@ import { useMemo, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { Button } from "@heroui/react"; import { useI18n } from "../../i18n"; -import { WorkbenchModules, type DisplaySection } from "./components/WorkbenchModules"; +import { WorkbenchModules } from "./components/WorkbenchModules"; import { ConsoleIcon } from "./components/ConsoleIcon"; import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../../config/modules"; import { openOrFocusModule } from "../../utils/moduleWindows"; @@ -331,15 +331,11 @@ export function WorkbenchPage() { ) as Record; }, [isAdmin, hasScope]); - const displaySections = useMemo((): DisplaySection[] => { - const ops = [...visibleBySection.monitoring, ...visibleBySection.operations]; - const system = visibleBySection.system; - const sections: DisplaySection[] = [ - { key: "operations", mods: ops }, - { key: "system", mods: system }, - ]; - return sections.filter((s) => s.mods.length > 0); - }, [visibleBySection]); + const opsCandidates = useMemo( + () => [...visibleBySection.monitoring, ...visibleBySection.operations], + [visibleBySection], + ); + const systemMods = visibleBySection.system; const railTasks = useMemo(() => { const items = opsQuery.data?.items ?? []; @@ -501,7 +497,7 @@ export function WorkbenchPage() {
{t("workbench.kpi.uptime")}{metrics ? formatAgeSec(metrics.uptime_sec) : metricsPlaceholder}
- +