diff --git a/API.md b/API.md index 415e46a..8872341 100644 --- a/API.md +++ b/API.md @@ -45,7 +45,8 @@ - 缺失或过期 → HTTP `401`。 - 有效但用户不在 workspace → HTTP `403`(由 Nginx `auth_request` 透传给客户端)。 -> `/api/v1/auth/me` 与 `/api/v1/auth/login` 响应中的 `data.user` 对象额外携带以下两个字段: +> `/api/v1/auth/me` 与 `/api/v1/auth/login` 响应中的 `data.user` 对象额外携带以下三个字段: +> - `role_code: string | null` —— 用户的**平台角色**(`users.platform_role_id` 指向的 Roles 行的 `role_code`),取值为 `admin` / `developer` / `null`(未分配)。**注意:本字段同时也是该用户在所有 workspace 中的角色**——workspace 角色始终继承自平台角色,本端点不再返回 workspace 级独立角色码。 > - `is_system_admin: bool` —— 派生自 `users.platform_role_id` 指向的角色 `role_code == 'admin'` 且用户状态为 `active`。前端据此决定是否渲染"系统管理"入口。 > - `permissions: string[]` —— 当前用户通过其平台角色(`platform_role_id`)间接持有的菜单权限码列表(`permission_code`),按字典序排列;未分配平台角色时为空数组。前端据此过滤菜单与 `` 路由守卫。**仅控制前端展示,不参与后端 endpoint 鉴权**——后端鉴权继续由 `system_admin_context`(`role_code == 'admin'`)与 workspace membership 负责。详见 §7.12-7.14。 @@ -512,7 +513,7 @@ Base 前缀 `/api/v1/admin`。 | `DELETE` | `/api/v1/platform/workspaces/{workspace_id}` | 软删 workspace;级联软删其成员 | | `GET` | `/api/v1/platform/workspaces/{workspace_id}/members` | 列成员 | | `POST` | `/api/v1/platform/workspaces/{workspace_id}/members` | 添加成员(返回 201) | -| `PATCH` | `/api/v1/platform/workspaces/{workspace_id}/members/{user_id}` | 改成员角色/状态 | +| `PATCH` | `/api/v1/platform/workspaces/{workspace_id}/members/{user_id}` | 改成员 `member_status`;**不能改 role_code**(workspace 角色继承自平台角色) | | `DELETE` | `/api/v1/platform/workspaces/{workspace_id}/members/{user_id}` | 软删成员 | | `GET` | `/api/v1/platform/roles` | 列全部 `role_scope='platform'` 的角色及其 `permission_codes`(仅系统管理员) | | `GET` | `/api/v1/platform/roles/{role_code}/permissions` | 单个平台角色的 `permission_codes`(仅系统管理员) | @@ -597,28 +598,31 @@ Base 前缀 `/api/v1/admin`。 添加成员。 +> **Workspace 角色继承平台角色**:本端点不接受 `role_code`,新成员的 `workspace_members.role_id` 始终等于其 `users.platform_role_id` 指向的角色行。**不可填** `system_admin`(那是用户级身份,不是 workspace 角色)。要改某成员的 workspace 角色,请改 `users.platform_role_id`,即 `PATCH /api/v1/platform/employees/{user_id}`。 + - **请求体字段**: | 字段 | 类型 | 必填 | 限制 | 说明 | |---|---|---|---|---| | `user_id` | string | 是 | 26 字符 ULID | | -| `role_code` | string | 是 | `admin` \| `developer` | **不可填 `system_admin`**(那是用户级身份,不是 workspace 角色) | - 服务端默认 `member_status='active'`。 -- 用户不存在或已软删除 → 404;用户状态不是 `active` → 409;用户已是该 workspace 成员 → 409。 -- 新员工必须先通过 `POST /api/v1/platform/employees` 或现有 workspace 员工创建接口建立用户账号。 +- 用户不存在或已软删除 → 404;用户状态不是 `active` → 409;用户**尚未分配平台角色**(`users.platform_role_id IS NULL`)→ 409 "目标用户尚未分配平台角色,无法加入 workspace"。 +- 用户已是该 workspace 成员 → 409。 +- 新员工必须先通过 `POST /api/v1/platform/employees` 建立账号(可以同时传 `role_code=admin|developer`)。 ### 7.6 `PATCH /api/v1/platform/workspaces/{workspace_id}/members/{user_id}` -修改成员的角色或状态。 +修改成员的状态。**不能通过本端点修改 role_code**——workspace 角色始终继承自 `users.platform_role_id`;要改角色请 `PATCH /api/v1/platform/employees/{user_id}`。 - **请求体字段**(全部可选): | 字段 | 类型 | 限制 | 说明 | |---|---|---|---| -| `role_code` | string | `admin` \| `developer` | 降级最后 admin → 409 | | `member_status` | string | `active` \| `disabled` \| `locked` | 停用 / 锁定最后 admin → 409 | +- 提交 `role_code` 字段 → 422(Pydantic `extra='forbid'`),不是静默忽略。 + ### 7.7 `DELETE /api/v1/platform/workspaces/{workspace_id}/members/{user_id}` 软删除成员。 @@ -669,13 +673,15 @@ Base 前缀 `/api/v1/admin`。 | `display_name` | string | 是 | 1~100 字符 | 显示名称 | | `email` | string | 否 | ≤255 字符 | 邮箱,全平台唯一 | | `password` | string | 是 | 8~72 字符 | 登录密码 | +| `role_code` | string | 否 | `admin` \| `developer` | 平台角色;不传或 `null` 表示**不分配角色**(用户无法加入任何 workspace,见 §7.5) | -- 新用户状态固定为 `active`,且不分配平台角色。 +- 新用户状态固定为 `active`;`role_code` 决定 `users.platform_role_id` 指向 `role_code='admin'` / `'developer'` 的 Roles 行,未传则 `platform_role_id=NULL`。 +- 平台**仅**有 `admin` / `developer` 两个平台角色;不存在第三个角色枚举值。 - 密码使用 bcrypt 哈希保存;响应不包含 `password` 或 `password_hash`。 -- 用户名或邮箱重复 → 409。 -- 创建成功后,可调用 `POST /api/v1/platform/workspaces/{workspace_id}/members` 将用户加入指定 workspace。 +- 用户名或邮箱重复 → 409;`role_code` 取值非法 → 422。 +- 创建成功后,可调用 `POST /api/v1/platform/workspaces/{workspace_id}/members` 将用户加入指定 workspace(会要求用户已有 `platform_role_id`,否则 409)。 -- **响应 201**:字段与 §7.8 的员工元素一致,其中 `role_code`、`role_name` 均为 `null`,`meta` 为空对象。 +- **响应 201**:字段与 §7.8 的员工元素一致;当 `role_code` 传入时,`role_code` / `role_name` 反映 `platform_role_id`;未传入时均为 `null`。`meta` 为空对象。 ### 7.10 `PATCH /api/v1/platform/employees/{user_id}` @@ -691,11 +697,12 @@ Base 前缀 `/api/v1/admin`。 | `role_code` | string | `admin` \| `developer` | 同步设置 `users.platform_role_id`;`admin` 指向 `role_code='admin'` 的 Roles 行,`developer` 指向 `role_code='developer'` 的 Roles 行 | - 禁止通过该端点修改:`username`、`password`、`password_hash`、`platform_role_id`;请求体中包含这些字段 → 422。 -- `role_code` 只能取 `admin` / `developer`;不能通过本端点把 `platform_role_id` 置为 `null`。 +- `role_code` 只能取 `admin` / `developer`;**不能通过本端点把 `platform_role_id` 置为 `null`**(降级为"无平台角色"须走单独的内部流程,前端不要尝试)。 +- `role_code` 改动会**立即影响**该用户在**所有** workspace 中的角色——因为 `workspace_members.role_id` 在 §7.5/§7.6 不再被端点改写,workspace 角色始终等于 `users.platform_role_id` 指向的 Roles 行。本端点是调整任何成员 workspace 角色的**唯一**入口。 - 自保护: - 修改自身 `status` 为非 `active` → 409 "不能停用当前登录账号"。 - 修改自身 `role_code` 为 `developer`(即降级系统管理员身份)→ 409 "不能降级自身管理员角色"。 -- 最后系统管理员保护:当目标用户当前为 active 系统管理员,本次变更会让其离开"active 系统管理员"集合(降级角色 / 停用账号)时,平台必须仍保留至少一名 active 系统管理员,否则 → 409 "platform 必须保留至少一个 active 系统管理员"。 +- 最后系统管理员保护:当目标用户当前为 active 系统管理员,本次变更会让其离开"active 系统管理员"集合(降级角色 / 停用账号)时,平台必须仍保留至少一名 active 系统管理员,否则 → 409 "platform 必须保留至少一个 active 系统管理员"。**自保护在前、last-admin 计数在后**(参考 CLAUDE.md 工程笔记)。 - 目标用户不存在或已软删除 → 404 "用户不存在";`role_code` 对应的角色行不存在 → 422。 - **响应 200**:返回更新后的 `PlatformEmployeePayload`,`role_code` / `role_name` 反映最新的 `platform_role_id`。 diff --git a/CLAUDE.md b/CLAUDE.md index e4fbdfd..5eb5052 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,8 +79,10 @@ Hard-won lessons. Read the relevant bullet before touching the named area. Lessons from splitting `frontend/app/features/platform/ModelPlatformApp.tsx` (1057 → 121 lines) into zustand stores + nested routes. - **Barrel-file CSS imports vanish on refactor.** `features/admin/AdminPages.tsx` was a barrel that side-effect-imported `admin.css` + `dashboard.css`. When route files started importing `DashboardPage` / `SystemAdminPage` directly, the CSS disappeared silently. Fix: each page component imports its own CSS at the top — `DashboardPage` needs **both** `admin.css` (`.dashboard-page`, `.dashboard-hero`, `.dashboard-metrics`, `.dashboard-actions`) **and** `dashboard.css` (`.dashboard-grid`). `UserManagementPage` / `ProjectManagementPage` / `SystemAdminPage` only need `admin.css`. Don't put CSS imports in route files; let the components own their styles. `SchedulePage` already follows this pattern with `schedule.css`. -- **Module-level zustand store + `bindApi(api)`** for auth-dependent APIs. Keep a module-level `_api` ref; expose `bindScriptWorkspaceApi(api)`; layout calls it in `useEffect([api])`. Actions read `_api` internally — no api param on every call. `bindScriptWorkspaceApi(null)` in cleanup avoids stale refs on logout. +- **Module-level zustand store + `bindApi(api)`** for auth-dependent APIs. Keep a module-level `_api` ref; expose `bindScriptWorkspaceApi(api)`; layout calls it in **render body** (not `useEffect([api])`). Actions read `_api` internally — no api param on every call. Pair the render-body bind with a separate empty-deps `useEffect(() => () => bindScriptWorkspaceApi(null), [])` for unmount cleanup only. + - **Why render body, not `useEffect([api])`:** React effect order on deps change is *parent cleanup → child effect → parent effect*. With `useEffect([api])`, the parent's cleanup wipes `_api` to `null` *before* child effects (e.g. `ScriptsPage`'s `useEffect([workspaceId])` calling `load()`) run, producing the `script workspace API 未绑定` race whenever `currentWorkspace.workspace_id` changes. Render-body binding runs synchronously during the parent's render, which happens before the child's render and effects, so `_api` is always current by the time child code touches the store. - **Lifecycle hooks belong in the layout, not in route components.** Heartbeats (active edit session + cached sessions), the 10-min cleanup timer, and `beforeunload` lock-release must mount at the layout level — navigating to `/schedules` otherwise unmounts them and cached locks expire. Pattern: store exposes `tickHeartbeats()` / `tickCleanup()` / `releaseActiveOnUnload()`; the layout hook just owns the `setInterval` and `addEventListener`. +- **Route components with their own internal state must remount on workspace/user change.** `SchedulesPage` / `SystemAdminPage` / `UserManagementPage` / `ProjectManagementPage` keep `useState` for fetched data (`schedules`, `artifacts`, `selectedSchedule`, employees, projects). When `currentWorkspace` or `user` changes, the `api` reference updates but the cached state does not — the UI shows the previous workspace's data. Pattern: route wrappers set `key={\`${user?.user_id ?? "anon"}-${currentWorkspace?.workspace_id ?? "none"}\`}` on the page component to force React to unmount and remount, resetting all internal state and re-running `useEffect` data fetches. `ScriptsPage` doesn't need this — its store-backed state is reset via `scriptWorkspaceStore.reset()` on workspace change. - **`{ current: T | null }` module-level handle for non-subscribing consumers.** Sidebar reads "is there an active edit session?" without subscribing to the store — expose a plain `{ current: ... }` object at module scope and update it synchronously inside the store's `setEditSession` action. - **zustand `StateCreator` enforces declared action signatures.** Declaring `loadLatestVersion: () => Promise` while accidentally returning a cleanup function from the implementation makes `tsc` reject the whole store with TS2345. Match the declared type exactly. - **Nested routes in React Router v8.** Use `route("", "layout.tsx", [route("x", "x.tsx"), ...])` from `@react-router/dev/routes`. URLs stay flat; the layout renders ``. Don't use `route("*", ...)` as a wildcard — it skips the nested children config. diff --git a/backend/src/backend/auth.py b/backend/src/backend/auth.py index f0e1518..8976422 100644 --- a/backend/src/backend/auth.py +++ b/backend/src/backend/auth.py @@ -163,17 +163,17 @@ async def login( if default_workspace_id is None: default_workspace_id = workspace.workspace_id - # Pick a default role_code for the user payload: prefer admin if - # the user has it in any workspace, otherwise use the first one - # returned. This is only for UI greeting; access control checks - # run on a per-request basis via the chosen workspace_id. + # Workspace role is always inherited from the user's platform role + # (``Users.platform_role_id``); the iteration over rows above was + # a legacy way to find the "highest" workspace role and is no + # longer correct now that admin/developer are platform-only. user_role_code: str | None = None - for workspace, role, _ in rows: - if role.role_code == "admin": - user_role_code = "admin" - break - if user_role_code is None: - user_role_code = rows[0][1].role_code + if user.platform_role_id is not None: + platform_role_row = await session.scalar( + select(Roles).where(Roles.role_id == user.platform_role_id) + ) + if platform_role_row is not None: + user_role_code = platform_role_row.role_code token = issue_jwt(user.user_id, ttl_seconds=COOKIE_TTL_SECONDS) _set_session_cookie(request, response, token) @@ -256,12 +256,12 @@ async def me( workspaces = [_workspace_payload(ws, role) for ws, role, _ in rows] default_workspace_id = workspaces[0]["workspace_id"] if workspaces else None user_role_code: str | None = None - for _ws, role, _ in rows: - if role.role_code == "admin": - user_role_code = "admin" - break - if user_role_code is None and rows: - user_role_code = rows[0][1].role_code + if user.platform_role_id is not None: + platform_role_row = await session.scalar( + select(Roles).where(Roles.role_id == user.platform_role_id) + ) + if platform_role_row is not None: + user_role_code = platform_role_row.role_code is_system_admin = await resolve_is_system_admin(session, user) permissions = await load_user_permissions(session, user) diff --git a/backend/src/backend/platform.py b/backend/src/backend/platform.py index 9597c4c..b4a773b 100644 --- a/backend/src/backend/platform.py +++ b/backend/src/backend/platform.py @@ -109,16 +109,22 @@ class WorkspaceUpdate(BaseModel): class MemberCreate(BaseModel): + """Add a user to a workspace. Role is inherited from the user's + platform role (Users.platform_role_id) — not set here.""" + model_config = ConfigDict(extra="forbid") user_id: str = Field(min_length=26, max_length=26) - role_code: Literal["admin", "developer"] class MemberUpdate(BaseModel): + """Update a workspace membership's status. Role cannot be changed + via this endpoint — workspace role is always inherited from the + user's platform role. To change a member's role, PATCH + /platform/employees/{user_id} instead.""" + model_config = ConfigDict(extra="forbid") - role_code: Literal["admin", "developer"] | None = None member_status: Literal["active", "disabled", "locked"] | None = None @@ -129,6 +135,7 @@ class PlatformEmployeeCreate(BaseModel): display_name: str = Field(min_length=1, max_length=100) email: str | None = Field(default=None, max_length=255) password: str = Field(min_length=8, max_length=72) + role_code: Literal["admin", "developer"] | None = None class PlatformEmployeeUpdate(BaseModel): @@ -387,6 +394,10 @@ async def create_platform_employee( if duplicate is not None: raise HTTPException(status.HTTP_409_CONFLICT, "用户名或邮箱已存在") + new_role: Roles | None = None + if payload.role_code is not None: + new_role = await _load_role_by_code(session, payload.role_code) + user = Users( user_id=new_ulid(), username=username, @@ -394,12 +405,15 @@ async def create_platform_employee( email=payload.email.strip() if payload.email else None, password_hash=hash_password(payload.password), status="active", - platform_role_id=None, + platform_role_id=new_role.role_id if new_role is not None else None, ) session.add(user) await session.flush() await session.refresh(user) - return _envelope(context.request_id, platform_employee_payload(user, None)) + return _envelope( + context.request_id, + platform_employee_payload(user, new_role), + ) @router.patch("/employees/{user_id}") @@ -724,7 +738,12 @@ async def add_member( context: SystemAdminContext = Depends(system_admin_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - """Add a user to a workspace. The new row starts with member_status='active'.""" + """Add a user to a workspace. The new row starts with member_status='active'. + + The role is inherited from the target user's ``platform_role_id``; + the request body does NOT take a ``role_code``. To change a member's + role, PATCH ``/api/v1/platform/employees/{user_id}`` instead. + """ await _load_workspace(session, workspace_id) user = await session.get(Users, payload.user_id) if user is None or user.is_deleted != 0: @@ -734,7 +753,20 @@ async def add_member( status.HTTP_409_CONFLICT, f"用户状态为 {user.status},无法加入 workspace", ) - role = await _load_role_by_code(session, payload.role_code) + if user.platform_role_id is None: + raise HTTPException( + status.HTTP_409_CONFLICT, + "目标用户尚未分配平台角色,无法加入 workspace;" + "请先 PATCH /api/v1/platform/employees/{user_id} 设置 role_code", + ) + role = await session.scalar( + select(Roles).where(Roles.role_id == user.platform_role_id) + ) + if role is None: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "用户的平台角色行不存在", + ) duplicate = await session.scalar( select(WorkspaceMembers.user_id).where( WorkspaceMembers.workspace_id == workspace_id, @@ -747,12 +779,6 @@ async def add_member( status.HTTP_409_CONFLICT, "用户已是该 workspace 成员", ) - # 不变量: 给用户授予 workspace admin 时同步设置 platform_role_id, - # 否则前端 role_code === "admin" 与后端 is_system_admin 会给出 - # 不同的结论。降级路径不在此处处理(用户可能在其他 workspace - # 仍是 admin), 升级路径必须在此处理。 - if role.role_code == "admin" and user.platform_role_id is None: - user.platform_role_id = role.role_id membership = WorkspaceMembers( workspace_id=workspace_id, user_id=payload.user_id, @@ -773,7 +799,16 @@ async def update_member( context: SystemAdminContext = Depends(system_admin_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - """Update a member's role and/or status. Last-admin guard applies.""" + """Update a workspace membership's status. Role is not editable here. + + Workspace role is always inherited from the user's platform role + (``Users.platform_role_id``). To change role, PATCH + ``/api/v1/platform/employees/{user_id}`` instead. + + Last-admin guard still applies to ``member_status`` changes: setting + the only active admin to ``disabled``/``locked`` would leave the + workspace without admin coverage. + """ await _load_workspace(session, workspace_id) row = ( await session.execute( @@ -794,30 +829,6 @@ async def update_member( raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在") user, role, membership = row - next_role = role - if payload.role_code is not None and payload.role_code != role.role_code: - if ( - role.role_code == "admin" - and payload.role_code != "admin" - and membership.member_status == "active" - ): - remaining = await _count_active_admins( - session, workspace_id, exclude_user_id=user_id, - ) - if remaining == 0: - raise HTTPException( - status.HTTP_409_CONFLICT, - "workspace 必须保留至少一个 admin", - ) - next_role = await _load_role_by_code(session, payload.role_code) - membership.role_id = next_role.role_id - # 不变量: 升级到 workspace admin 时强制覆盖 platform_role_id。 - # (用户可能之前是 developer, platform_role_id 指向 developer role, - # 现在变成 admin 必须提升到 admin role。) 降级路径不动 (用户 - # 可能在其他 workspace 仍是 admin, 端点看不到全局)。 - if next_role.role_code == "admin": - user.platform_role_id = next_role.role_id - if payload.member_status is not None and payload.member_status != membership.member_status: if ( role.role_code == "admin" @@ -835,7 +846,7 @@ async def update_member( await session.flush() await session.refresh(membership) - return _envelope(context.request_id, member_payload(user, next_role, membership)) + return _envelope(context.request_id, member_payload(user, role, membership)) @router.delete("/workspaces/{workspace_id}/members/{user_id}")