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

@ -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

View file

@ -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.<userId>` → 默认。
- 保存: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`),用户未定制时用角色默认。

View file

@ -2,10 +2,12 @@
from __future__ import annotations from __future__ import annotations
import re
from typing import Annotated, Any 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 import Session
from sqlalchemy.orm.attributes import flag_modified
from .auth_cookies import clear_auth_cookies, read_refresh_cookie, set_auth_cookies from .auth_cookies import clear_auth_cookies, read_refresh_cookie, set_auth_cookies
from .auth_deps import AuthContext, require_admin, require_user from .auth_deps import AuthContext, require_admin, require_user
@ -36,6 +38,7 @@ from .auth_service import (
revoke_auth_sessions, revoke_auth_sessions,
update_api_token, update_api_token,
update_user, update_user,
user_prefs,
user_public, user_public,
write_audit, write_audit,
) )
@ -46,9 +49,14 @@ from .auth_rate_limit import (
) )
from .client_ip import resolve_client_ip from .client_ip import resolve_client_ip
from .db import get_db from .db import get_db
from .models import AppUser
router = APIRouter(tags=["auth"]) 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]: def _client_meta(request: Request) -> tuple[str, str]:
ip = resolve_client_ip(request) ip = resolve_client_ip(request)
@ -56,6 +64,34 @@ def _client_meta(request: Request) -> tuple[str, str]:
return ip, ua 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]: def _token_response(response: Response, request: Request, out: dict[str, Any]) -> dict[str, Any]:
access = str(out.get("access_token") or "") access = str(out.get("access_token") or "")
refresh = str(out.get("refresh_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, "auth_via": ctx.auth_via,
"scopes": sorted(ctx.scopes), "scopes": sorted(ctx.scopes),
"all_scopes": sorted(ALL_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") @router.post("/v1/auth/change-password")
def api_change_password( def api_change_password(
body: ChangePasswordRequest, 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: def sanitize_detail(detail: Any) -> Any:
"""Recursively drop secret-looking keys from audit detail payloads.""" """Recursively drop secret-looking keys from audit detail payloads."""
if isinstance(detail, dict): if isinstance(detail, dict):
@ -431,6 +436,7 @@ def login_issue_token(
"expires_in": ttl, "expires_in": ttl,
"refresh_expires_in": refresh_ttl, "refresh_expires_in": refresh_ttl,
"user": user_public(user), "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 role: Mapped[str] = mapped_column(String(32), default="user", index=True) # admin | user
# Optional capability override; empty => role defaults (see auth_scopes). # Optional capability override; empty => role defaults (see auth_scopes).
scopes: Mapped[list] = mapped_column(_JsonType, default=list) 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) is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False) must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
created_by: Mapped[str] = mapped_column(String(64), default="") 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", "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 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 expires_at TIMESTAMP")
_run_sql(conn, "ALTER TABLE api_token ADD COLUMN IF NOT EXISTS scopes JSON DEFAULT '[]'") _run_sql(conn, "ALTER TABLE api_token ADD COLUMN IF NOT EXISTS scopes JSON DEFAULT '[]'")
_run_sql( _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)", "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 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 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_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_vendor VARCHAR(32) DEFAULT 'zte'",
"ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_host VARCHAR(128) DEFAULT ''", "ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_host VARCHAR(128) DEFAULT ''",

193
tests/test_user_prefs.py Normal file
View file

@ -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()

View file

@ -28,6 +28,8 @@ export type ModuleDefinition = {
adminOnly?: boolean; adminOnly?: boolean;
/** Hide from workbench launcher; still used for module window registration. */ /** Hide from workbench launcher; still used for module window registration. */
workbenchHidden?: boolean; workbenchHidden?: boolean;
/** When true and not listed in the user's order, start in the "available" tray. */
defaultHidden?: boolean;
}; };
export const MODULES: readonly ModuleDefinition[] = [ export const MODULES: readonly ModuleDefinition[] = [
@ -159,6 +161,13 @@ export function modulesInSection(section: WorkbenchSection): ModuleDefinition[]
return MODULES.filter((m) => m.section === section); 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 { export function isWorkbenchPath(pathname: string): boolean {
return pathname === "/" || pathname === "/workbench"; return pathname === "/" || pathname === "/workbench";
} }

View file

@ -21,11 +21,15 @@ export type AuthUser = {
updated_at?: string | null; updated_at?: string | null;
}; };
export type UserPrefs = Record<string, unknown>;
type AuthState = { type AuthState = {
ready: boolean; ready: boolean;
token: string | null; token: string | null;
user: AuthUser | null; user: AuthUser | null;
scopes: string[]; scopes: string[];
prefs: UserPrefs;
setPrefs: (prefs: UserPrefs) => void;
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
refreshMe: () => Promise<void>; refreshMe: () => Promise<void>;
@ -35,24 +39,33 @@ type AuthState = {
const AuthContext = createContext<AuthState | null>(null); const AuthContext = createContext<AuthState | null>(null);
function asPrefs(value: unknown): UserPrefs {
return value && typeof value === "object" && !Array.isArray(value) ? (value as UserPrefs) : {};
}
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
// token is opaque for UI; cookie session means we only care about user presence. // token is opaque for UI; cookie session means we only care about user presence.
const [token, setToken] = useState<string | null>(null); const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<AuthUser | null>(null); const [user, setUser] = useState<AuthUser | null>(null);
const [scopes, setScopes] = useState<string[]>([]); const [scopes, setScopes] = useState<string[]>([]);
const [prefs, setPrefs] = useState<UserPrefs>({});
const refreshMe = useCallback(async () => { const refreshMe = useCallback(async () => {
clearAuthToken(); // drop any legacy localStorage tokens clearAuthToken(); // drop any legacy localStorage tokens
try { 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"); setToken("cookie");
setUser(data.user); setUser(data.user);
setScopes(data.scopes || data.user.scopes || []); setScopes(data.scopes || data.user.scopes || []);
setPrefs(asPrefs(data.prefs));
} catch { } catch {
setToken(null); setToken(null);
setUser(null); setUser(null);
setScopes([]); setScopes([]);
setPrefs({});
} }
}, []); }, []);
@ -64,7 +77,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, [refreshMe]); }, [refreshMe]);
const login = useCallback(async (username: string, password: string) => { 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, username,
password, password,
}); });
@ -72,6 +85,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setToken("cookie"); setToken("cookie");
setUser(data.user); setUser(data.user);
setScopes(data.user.scopes || []); setScopes(data.user.scopes || []);
setPrefs(asPrefs(data.prefs));
}, []); }, []);
const logout = useCallback(async () => { const logout = useCallback(async () => {
@ -84,6 +98,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setToken(null); setToken(null);
setUser(null); setUser(null);
setScopes([]); setScopes([]);
setPrefs({});
}, []); }, []);
const value = useMemo<AuthState>( const value = useMemo<AuthState>(
@ -92,13 +107,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
token, token,
user, user,
scopes, scopes,
prefs,
setPrefs,
login, login,
logout, logout,
refreshMe, refreshMe,
isAdmin: user?.role === "admin", isAdmin: user?.role === "admin",
hasScope: (scope: string) => scopes.includes(scope) || 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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View file

@ -2,7 +2,7 @@ import { useMemo, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Button } from "@heroui/react"; import { Button } from "@heroui/react";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import { WorkbenchModules, type DisplaySection } from "./components/WorkbenchModules"; import { WorkbenchModules } from "./components/WorkbenchModules";
import { ConsoleIcon } from "./components/ConsoleIcon"; import { ConsoleIcon } from "./components/ConsoleIcon";
import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../../config/modules"; import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../../config/modules";
import { openOrFocusModule } from "../../utils/moduleWindows"; import { openOrFocusModule } from "../../utils/moduleWindows";
@ -331,15 +331,11 @@ export function WorkbenchPage() {
) as Record<WorkbenchSection, ModuleDefinition[]>; ) as Record<WorkbenchSection, ModuleDefinition[]>;
}, [isAdmin, hasScope]); }, [isAdmin, hasScope]);
const displaySections = useMemo((): DisplaySection[] => { const opsCandidates = useMemo(
const ops = [...visibleBySection.monitoring, ...visibleBySection.operations]; () => [...visibleBySection.monitoring, ...visibleBySection.operations],
const system = visibleBySection.system; [visibleBySection],
const sections: DisplaySection[] = [ );
{ key: "operations", mods: ops }, const systemMods = visibleBySection.system;
{ key: "system", mods: system },
];
return sections.filter((s) => s.mods.length > 0);
}, [visibleBySection]);
const railTasks = useMemo(() => { const railTasks = useMemo(() => {
const items = opsQuery.data?.items ?? []; const items = opsQuery.data?.items ?? [];
@ -501,7 +497,7 @@ export function WorkbenchPage() {
<div className="wb-runtime__foot"><span>{t("workbench.kpi.uptime")}</span><strong>{metrics ? formatAgeSec(metrics.uptime_sec) : metricsPlaceholder}</strong></div> <div className="wb-runtime__foot"><span>{t("workbench.kpi.uptime")}</span><strong>{metrics ? formatAgeSec(metrics.uptime_sec) : metricsPlaceholder}</strong></div>
</section> </section>
<div className="wb-body"> <div className="wb-body">
<WorkbenchModules sections={displaySections} /> <WorkbenchModules opsCandidates={opsCandidates} systemMods={systemMods} />
<aside className="wb-side" aria-label={t("workbench.sidePanel")}> <aside className="wb-side" aria-label={t("workbench.sidePanel")}>
<section className="wb-rail wb-panel wb-rail--tasks"> <section className="wb-rail wb-panel wb-rail--tasks">
<div className="wb-rail__head"> <div className="wb-rail__head">

View file

@ -1,4 +1,4 @@
type Props = { kind: "arrow" | "search" | "refresh" | "activity" | "check" | "close" }; type Props = { kind: "arrow" | "search" | "refresh" | "activity" | "check" | "close" | "settings" };
export function ConsoleIcon({ kind }: Props) { export function ConsoleIcon({ kind }: Props) {
return ( return (
@ -9,6 +9,12 @@ export function ConsoleIcon({ kind }: Props) {
{kind === "activity" && <path d="M3 12h4l3-7 4 14 3-7h4" />} {kind === "activity" && <path d="M3 12h4l3-7 4 14 3-7h4" />}
{kind === "check" && <><circle cx="12" cy="12" r="9" /><path d="m8 12 3 3 5-6" /></>} {kind === "check" && <><circle cx="12" cy="12" r="9" /><path d="m8 12 3 3 5-6" /></>}
{kind === "close" && <path d="m6 6 12 12M6 18 18 6" />} {kind === "close" && <path d="m6 6 12 12M6 18 18 6" />}
{kind === "settings" && (
<>
<circle cx="12" cy="12" r="3" />
<path d="M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.05 5.05l1.56 1.56M17.39 17.39l1.56 1.56M5.05 18.95l1.56-1.56M17.39 6.61l1.56-1.56" />
</>
)}
</svg> </svg>
); );
} }

View file

@ -1,20 +1,258 @@
import { useCallback, useEffect, useMemo, useState, type DragEvent } from "react";
import { Button } from "@heroui/react"; import { Button } from "@heroui/react";
import type { ModuleDefinition } from "../../../config/modules"; import type { ModuleDefinition } from "../../../config/modules";
import { useI18n } from "../../../i18n"; import { useI18n } from "../../../i18n";
import { useToast } from "../../../hooks/useToast";
import { openOrFocusModule } from "../../../utils/moduleWindows"; import { openOrFocusModule } from "../../../utils/moduleWindows";
import { useWorkbenchLayout } from "../useWorkbenchLayout";
import { ConsoleIcon } from "./ConsoleIcon";
import { WorkbenchCardIcon } from "./WorkbenchCardIcon"; import { WorkbenchCardIcon } from "./WorkbenchCardIcon";
export type DisplaySection = { key: "operations" | "system"; mods: ModuleDefinition[] }; type Props = {
/** Permission-filtered customizable modules (monitoring + operations). */
opsCandidates: ModuleDefinition[];
systemMods: ModuleDefinition[];
};
function draftFromLayout(shown: ModuleDefinition[], available: ModuleDefinition[]) {
return {
order: shown.map((m) => m.moduleId),
hidden: available.map((m) => m.moduleId),
};
}
/** Primary modules as a grid of tiles, system admin as a quiet link row. */ /** Primary modules as a grid of tiles, system admin as a quiet link row. */
export function WorkbenchModules({ sections }: { sections: DisplaySection[] }) { export function WorkbenchModules({ opsCandidates, systemMods }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const { showError } = useToast();
const { shown, available, save, reset, layoutPrefs } = useWorkbenchLayout(opsCandidates);
const [editing, setEditing] = useState(false);
const [draftOrder, setDraftOrder] = useState<string[]>([]);
const [draftHidden, setDraftHidden] = useState<string[]>([]);
const [dragId, setDragId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const byId = useMemo(() => new Map(opsCandidates.map((m) => [m.moduleId, m])), [opsCandidates]);
const enterEdit = useCallback(() => {
const d = draftFromLayout(shown, available);
setDraftOrder(d.order);
setDraftHidden(d.hidden);
setEditing(true);
}, [shown, available]);
const exitEdit = useCallback(() => {
setEditing(false);
setDragId(null);
setDraftOrder([]);
setDraftHidden([]);
}, []);
useEffect(() => {
if (!editing) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
exitEdit();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [editing, exitEdit]);
const draftShown = useMemo(
() => draftOrder.map((id) => byId.get(id)).filter((m): m is ModuleDefinition => Boolean(m)),
[draftOrder, byId],
);
const draftAvailable = useMemo(
() => draftHidden.map((id) => byId.get(id)).filter((m): m is ModuleDefinition => Boolean(m)),
[draftHidden, byId],
);
const displayOps = editing ? draftShown : shown;
const canCustomize = opsCandidates.length > 0;
const removeTile = (moduleId: string) => {
setDraftOrder((prev) => prev.filter((id) => id !== moduleId));
setDraftHidden((prev) => (prev.includes(moduleId) ? prev : [...prev, moduleId]));
};
const addTile = (moduleId: string) => {
setDraftHidden((prev) => prev.filter((id) => id !== moduleId));
setDraftOrder((prev) => (prev.includes(moduleId) ? prev : [...prev, moduleId]));
};
const onDragStart = (moduleId: string) => (e: DragEvent) => {
setDragId(moduleId);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", moduleId);
};
const onDragOver = (overId: string) => (e: DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!dragId || dragId === overId) return;
setDraftOrder((prev) => {
const from = prev.indexOf(dragId);
const to = prev.indexOf(overId);
if (from < 0 || to < 0 || from === to) return prev;
const next = [...prev];
next.splice(from, 1);
next.splice(to, 0, dragId);
return next;
});
};
const onDrop = (e: DragEvent) => {
e.preventDefault();
setDragId(null);
};
const defaults = useMemo(
() => ({
order: opsCandidates.filter((m) => !m.defaultHidden).map((m) => m.moduleId),
hidden: opsCandidates.filter((m) => m.defaultHidden).map((m) => m.moduleId),
}),
[opsCandidates],
);
const onDone = async () => {
setSaving(true);
try {
const sameAsDefault =
draftOrder.join(",") === defaults.order.join(",") &&
[...draftHidden].sort().join(",") === [...defaults.hidden].sort().join(",");
if (sameAsDefault) {
// Store nothing so later registry changes (new modules, reordering) still apply.
await reset();
} else {
// Keep entries for modules this user can't see right now (e.g. scope temporarily
// revoked) so their choice survives until the permission comes back.
const keepOrder = (layoutPrefs?.order ?? []).filter((id) => !byId.has(id));
const keepHidden = (layoutPrefs?.hidden ?? []).filter((id) => !byId.has(id));
await save([...draftOrder, ...keepOrder], [...draftHidden, ...keepHidden]);
}
exitEdit();
} catch {
showError(t("workbench.customizeSaveFailed"));
} finally {
setSaving(false);
}
};
const onReset = () => {
setDraftOrder(defaults.order);
setDraftHidden(defaults.hidden);
};
return ( return (
<section className="wb-modules wb-panel" aria-label={t("workbench.modules")}> <section
{sections.map((section) => ( className={`wb-modules wb-panel${editing ? " wb-modules--editing" : ""}`}
<div key={section.key} className={`wb-section wb-section--${section.key}`} role="group" aria-label={t(`workbench.${section.key}`)}> aria-label={t("workbench.modules")}
{section.mods.map((mod) => ( >
{canCustomize ? (
<div className="wb-modules__toolbar">
{!editing ? (
<Button
type="button"
variant="ghost"
size="sm"
className="wb-modules__gear"
aria-label={t("workbench.customize")}
onPress={enterEdit}
>
<ConsoleIcon kind="settings" />
</Button>
) : null}
</div>
) : null}
{canCustomize || displayOps.length > 0 ? (
<div className="wb-section wb-section--operations" role="group" aria-label={t("workbench.operations")}>
{displayOps.length === 0 && !editing ? (
<div className="wb-modules__empty">
<Button type="button" variant="ghost" className="wb-modules__empty-btn" onPress={enterEdit}>
{t("workbench.customizeEmpty")}
</Button>
</div>
) : (
displayOps.map((mod) => (
<div
key={mod.moduleId}
className={`wb-tile-wrap${editing ? " wb-tile-wrap--edit" : ""}${dragId === mod.moduleId ? " is-dragging" : ""}`}
draggable={editing}
onDragStart={editing ? onDragStart(mod.moduleId) : undefined}
onDragOver={editing ? onDragOver(mod.moduleId) : undefined}
onDrop={editing ? onDrop : undefined}
onDragEnd={() => setDragId(null)}
>
<Button
variant="ghost"
className={`wb-tile wb-tile--${mod.iconTone}`}
aria-label={
editing
? t(mod.labelKey)
: `${t(mod.labelKey)} · ${t("workbench.openModule")}`
}
onPress={() => {
if (editing) return;
openOrFocusModule({ moduleId: mod.moduleId, path: mod.path });
}}
>
<WorkbenchCardIcon tone={mod.iconTone} kind={mod.iconKind} />
<span className="wb-tile__label">{t(mod.labelKey)}</span>
</Button>
{editing ? (
<button
type="button"
className="wb-tile__remove"
aria-label={`${t(mod.labelKey)} ×`}
onClick={() => removeTile(mod.moduleId)}
>
<ConsoleIcon kind="close" />
</button>
) : null}
</div>
))
)}
</div>
) : null}
{editing && draftAvailable.length > 0 ? (
<div className="wb-modules__tray" role="group" aria-label={t("workbench.customizeAvailable")}>
<div className="wb-modules__tray-label">{t("workbench.customizeAvailable")}</div>
<div className="wb-modules__tray-grid">
{draftAvailable.map((mod) => (
<Button
key={mod.moduleId}
variant="ghost"
className={`wb-tile wb-tile--${mod.iconTone} wb-tile--available`}
aria-label={t(mod.labelKey)}
onPress={() => addTile(mod.moduleId)}
>
<WorkbenchCardIcon tone={mod.iconTone} kind={mod.iconKind} />
<span className="wb-tile__label">{t(mod.labelKey)}</span>
</Button>
))}
</div>
</div>
) : null}
{editing ? (
<div className="wb-modules__actions">
<Button type="button" variant="ghost" size="sm" onPress={onReset} isDisabled={saving}>
{t("workbench.customizeReset")}
</Button>
<Button type="button" variant="primary" size="sm" onPress={() => void onDone()} isDisabled={saving}>
{t("workbench.customizeDone")}
</Button>
</div>
) : null}
{systemMods.length > 0 ? (
<div className="wb-section wb-section--system" role="group" aria-label={t("workbench.system")}>
{systemMods.map((mod) => (
<Button <Button
key={mod.moduleId} key={mod.moduleId}
variant="ghost" variant="ghost"
@ -27,7 +265,7 @@ export function WorkbenchModules({ sections }: { sections: DisplaySection[] }) {
</Button> </Button>
))} ))}
</div> </div>
))} ) : null}
</section> </section>
); );
} }

View file

@ -0,0 +1,125 @@
import { useCallback, useMemo } from "react";
import type { ModuleDefinition } from "../../config/modules";
import { apiPut } from "../../services/api";
import { useAuth, type UserPrefs } from "../auth/AuthContext";
export type WorkbenchLayoutPrefs = {
order: string[];
hidden: string[];
};
const LS_PREFIX = "netx.wb.layout.";
function readLocalLayout(userId: string): WorkbenchLayoutPrefs | null {
try {
const raw = localStorage.getItem(`${LS_PREFIX}${userId}`);
if (!raw) return null;
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object") return null;
const order = (parsed as { order?: unknown }).order;
const hidden = (parsed as { hidden?: unknown }).hidden;
if (!Array.isArray(order) || !Array.isArray(hidden)) return null;
return {
order: order.filter((x): x is string => typeof x === "string"),
hidden: hidden.filter((x): x is string => typeof x === "string"),
};
} catch {
return null;
}
}
function writeLocalLayout(userId: string, layout: WorkbenchLayoutPrefs): void {
try {
localStorage.setItem(`${LS_PREFIX}${userId}`, JSON.stringify(layout));
} catch {
// ignore quota / private mode
}
}
function asWorkbenchPrefs(value: unknown): WorkbenchLayoutPrefs | null {
if (!value || typeof value !== "object") return null;
const order = (value as { order?: unknown }).order;
const hidden = (value as { hidden?: unknown }).hidden;
if (!Array.isArray(order) && !Array.isArray(hidden)) return null;
return {
order: Array.isArray(order) ? order.filter((x): x is string => typeof x === "string") : [],
hidden: Array.isArray(hidden) ? hidden.filter((x): x is string => typeof x === "string") : [],
};
}
/** Pure layout resolver — permission-filtered `all` in registry order. */
export function resolveLayout(
all: ModuleDefinition[],
prefs: WorkbenchLayoutPrefs | null | undefined,
): { shown: ModuleDefinition[]; available: ModuleDefinition[] } {
const byId = new Map(all.map((m) => [m.moduleId, m]));
const prefOrder = prefs?.order ?? [];
const hiddenSet = new Set((prefs?.hidden ?? []).filter((id) => byId.has(id)));
const orderIds = prefOrder.filter((id) => byId.has(id));
const inOrder = new Set(orderIds);
const ranked: ModuleDefinition[] = [];
const seen = new Set<string>();
for (const id of orderIds) {
const mod = byId.get(id);
if (!mod || seen.has(id)) continue;
ranked.push(mod);
seen.add(id);
}
for (const mod of all) {
if (seen.has(mod.moduleId)) continue;
ranked.push(mod);
seen.add(mod.moduleId);
}
const shown: ModuleDefinition[] = [];
const available: ModuleDefinition[] = [];
for (const mod of ranked) {
const hide =
hiddenSet.has(mod.moduleId) || (Boolean(mod.defaultHidden) && !inOrder.has(mod.moduleId));
if (hide) available.push(mod);
else shown.push(mod);
}
return { shown, available };
}
export function useWorkbenchLayout(all: ModuleDefinition[]) {
const { user, prefs, setPrefs, ready } = useAuth();
const userId = user?.id ?? "";
const layoutPrefs = useMemo(() => {
// After auth is ready, server prefs win (missing workbench => default).
// Before ready, fall back to localStorage to avoid a layout flash.
if (ready) return asWorkbenchPrefs(prefs?.workbench);
return userId ? readLocalLayout(userId) : null;
}, [ready, prefs, userId]);
const { shown, available } = useMemo(
() => resolveLayout(all, layoutPrefs),
[all, layoutPrefs],
);
const persist = useCallback(
async (next: WorkbenchLayoutPrefs) => {
const res = await apiPut<{ prefs: UserPrefs }>("/v1/auth/me/prefs/workbench", next);
const merged =
res.prefs && typeof res.prefs === "object" ? res.prefs : { ...prefs, workbench: next };
setPrefs(merged);
if (userId) writeLocalLayout(userId, next);
},
[prefs, setPrefs, userId],
);
const save = useCallback(
async (order: string[], hidden: string[]) => {
await persist({ order, hidden });
},
[persist],
);
const reset = useCallback(async () => {
await persist({ order: [], hidden: [] });
}, [persist]);
return { shown, available, save, reset, layoutPrefs };
}

View file

@ -149,6 +149,12 @@ const workbench = {
apiKeys: "API Keys", apiKeys: "API Keys",
sessions: "Login sessions", sessions: "Login sessions",
}, },
customize: "Customize modules",
customizeDone: "Done",
customizeReset: "Reset",
customizeAvailable: "Available",
customizeEmpty: "Add modules",
customizeSaveFailed: "Save failed",
}; };
export default workbench; export default workbench;

View file

@ -149,6 +149,12 @@ const workbench = {
apiKeys: "API Key", apiKeys: "API Key",
sessions: "登录会话", sessions: "登录会话",
}, },
customize: "自定义功能区",
customizeDone: "完成",
customizeReset: "恢复默认",
customizeAvailable: "可添加",
customizeEmpty: "添加功能",
customizeSaveFailed: "保存失败",
}; };
export default workbench; export default workbench;

View file

@ -29,6 +29,53 @@
/* Modules: one row of primary tiles, then a quiet row of admin links. */ /* Modules: one row of primary tiles, then a quiet row of admin links. */
.wb-modules { padding: var(--wb-pad); display: flex; flex-direction: column; gap: 14px; } .wb-modules { padding: var(--wb-pad); display: flex; flex-direction: column; gap: 14px; }
.wb-modules__toolbar { display: flex; justify-content: flex-end; margin: -4px 0 -6px; min-height: 28px; }
.app--shell .app-main .wb-modules__gear {
width: 28px; height: 28px; min-height: 0; padding: 0; margin: 0;
display: inline-flex; align-items: center; justify-content: center;
border: 0; border-radius: var(--nx-radius-sm); background: transparent;
color: var(--nx-text-faint); box-shadow: none;
}
.app--shell .app-main .wb-modules__gear:hover { background: rgb(255 255 255 / .06); color: var(--nx-text); }
.wb-modules__gear svg { width: 16px; height: 16px; }
.wb-modules__empty {
grid-column: 1 / -1; min-height: var(--wb-tile-h);
display: flex; align-items: center; justify-content: center;
}
.app--shell .app-main .wb-modules__empty-btn {
height: auto; min-height: 40px; padding: 8px 16px; border: 1px dashed var(--nx-line);
border-radius: 12px; background: transparent; color: var(--nx-text-muted); box-shadow: none;
}
.app--shell .app-main .wb-modules__empty-btn:hover { background: rgb(255 255 255 / .04); color: var(--nx-text); border-color: var(--nx-line-strong); }
.wb-tile-wrap { position: relative; min-width: 0; }
.wb-modules--editing .wb-tile-wrap--edit .wb-tile { border-color: var(--nx-line); cursor: grab; }
.wb-modules--editing .wb-tile-wrap--edit.is-dragging .wb-tile { opacity: .55; cursor: grabbing; }
.wb-modules--editing .wb-tile-wrap--edit .wb-tile:hover { transform: none; }
.app--shell .app-main .wb-tile__remove {
position: absolute; top: 4px; right: 4px; z-index: 2;
width: 22px; height: 22px; padding: 0; margin: 0;
display: inline-flex; align-items: center; justify-content: center;
border: 1px solid var(--nx-line); border-radius: 999px;
background: rgb(15 23 42 / .72); color: var(--nx-text-muted); cursor: pointer;
}
.app--shell .app-main .wb-tile__remove:hover { color: var(--nx-text); border-color: var(--nx-line-strong); }
.wb-tile__remove svg { width: 12px; height: 12px; }
.wb-modules__tray { display: flex; flex-direction: column; gap: 8px; padding-top: 4px; }
.wb-modules__tray-label { font-size: 12px; font-weight: 500; color: var(--nx-text-muted); }
.wb-modules__tray-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(max(88px, 16%), 1fr));
grid-auto-rows: calc(var(--wb-tile-h) * .85); gap: 10px;
}
.app--shell .app-main .wb-tile--available {
opacity: .55; border-style: dashed; border-color: var(--nx-line); min-height: 0;
background: rgb(255 255 255 / .02);
}
.app--shell .app-main .wb-tile--available:hover {
opacity: .85; transform: none;
border-color: var(--nx-line-strong);
background: rgb(255 255 255 / .05);
}
.wb-modules__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 4px; }
/* Fixed-size tiles fill rows in order; the panel's spare height is room for new modules. */ /* Fixed-size tiles fill rows in order; the panel's spare height is room for new modules. */
.wb-section--operations { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(88px, 16%), 1fr)); grid-auto-rows: var(--wb-tile-h); gap: 12px; } .wb-section--operations { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(88px, 16%), 1fr)); grid-auto-rows: var(--wb-tile-h); gap: 12px; }
.app--shell .app-main .wb-tile { .app--shell .app-main .wb-tile {