From 1d1ab384ef291a75a3fc1fb64508724d26103ad8 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 25 Jun 2026 16:09:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E7=99=BB=E5=BD=95=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95(=E6=B3=A8=E5=86=8C=E5=90=8E=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E5=AE=A1=E6=89=B9=E6=94=BE=E8=A1=8C=20+=20?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E5=BC=80=E5=85=B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户仍可自助注册,普通账号默认 approved=False(待审批),admin 建号即 True; 存量用户迁移回填 True 避免锁死。系统设置 login_whitelist.enabled 总开关:关= 所有人可登录,开=仅已放行用户(或 admin)可登录。三登录端点建号后、发 token 前 经 enforce_user_approved 拦截,未放行返回 403 PENDING_APPROVAL。管理员在「白名单 管理」页(设置下拉入口)切总开关 + 逐用户放行。取消放行只拦下次登录。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 1 + frontend-web/src/components/page-sidebar.tsx | 15 ++ frontend-web/src/core/auth/api.ts | 16 ++ frontend-web/src/core/auth/hooks.ts | 23 ++- frontend-web/src/pages/LoginWhitelistPage.tsx | 188 ++++++++++++++++++ frontend-web/src/pages/StrategyRoutes.tsx | 11 + .../api/login-whitelist.ts | 68 +++++++ .../app/gateway/auth/local_provider.py | 5 +- .../backend/app/gateway/auth/models.py | 1 + .../app/gateway/auth/repositories/sqlite.py | 3 + .../backend/app/gateway/routers/auth.py | 73 ++++++- .../app/gateway/routers/system_settings.py | 30 +++ .../deerflow/config/system_settings.py | 13 ++ .../versions/20260625_02_users_approved.py | 52 +++++ .../deerflow/persistence/user/model.py | 7 + .../scripts/sql/column_additions_mysql.sql | 7 + 16 files changed, 508 insertions(+), 5 deletions(-) create mode 100644 frontend-web/src/pages/LoginWhitelistPage.tsx create mode 100644 frontend-web/src/strategy-components/api/login-whitelist.ts create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260625_02_users_approved.py diff --git a/CLAUDE.md b/CLAUDE.md index 9c3d4419..d80dabce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source JSON is **any** shape — the agent's `SOUL.md` maps it to the template's schema skeleton. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, and dispatch. - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). +- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users still **self-register** as usual but a regular new account defaults to `approved=False` (待审批); admins are created `approved=True`; existing rows are backfilled `True` so flipping the switch on never locks out existing users. Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. - **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. - **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 - **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, appendAuth, authTokenParam, authNameParam, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`appendAuth` (追加登录态)**: a `url` button can additionally append `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」页,在 **both** 单智能体 (`TaskFxWorkspace`) 与 多智能体会商 (`RoundtablePlanningPage` 深链 rwfx-A) 都显示(共用 `TaskJumpBar`)。**`runTaskButton` 的 `url` 类型支持应用内路由**:target 以单个 `/` 开头时按内部路由处理——`self` 走 react-router 同窗导航(不刷新),`blank` 拼成当前源的 hash 路由新标签打开;外链行为不变。 The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index d01d6d6e..dcd6d382 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -11,6 +11,7 @@ import { MousePointerClick, Settings, ShieldAlert, + ShieldCheck, User, Wrench, } from "lucide-react"; @@ -222,6 +223,14 @@ function SecondaryNavSidebar() { 按钮管理 )} + {isAdmin && ( + navigate("/page/strategy/admin/login-whitelist")} + > + + 白名单管理 + + )} {canManageSensitiveWords() && ( navigate("/page/strategy/admin/sensitive-words")} @@ -508,6 +517,12 @@ function LegacyAccordionSidebar({ 按钮管理 )} + {isAdmin && ( + navigate("/page/strategy/admin/login-whitelist")}> + + 白名单管理 + + )} {canManageSensitiveWords() && ( navigate("/page/strategy/admin/sensitive-words")}> diff --git a/frontend-web/src/core/auth/api.ts b/frontend-web/src/core/auth/api.ts index 1a2eb5f3..b2295ea8 100644 --- a/frontend-web/src/core/auth/api.ts +++ b/frontend-web/src/core/auth/api.ts @@ -11,6 +11,8 @@ export interface UserListItem { agent_count: number; /** 问答次数:用户提问的次数(一次提问算一次)。 */ qa_count: number; + /** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */ + approved: boolean; } export async function listAllUsers(): Promise { @@ -19,6 +21,20 @@ export async function listAllUsers(): Promise { return res.json() as Promise; } +/** 放行 / 取消放行某个注册用户(登录白名单)。Admin only。 */ +export async function setUserApproval(userId: string, approved: boolean): Promise { + const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users/${encodeURIComponent(userId)}/approval`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ approved }), + }); + if (!res.ok) { + const data = await res.json().catch(() => null); + throw new Error(extractErrorMessage(data, "保存失败")); + } + return res.json() as Promise; +} + // Normalize a FastAPI error body into a human-readable string. `detail` may be // a plain string, an object like AuthErrorResponse ({ code, message }), or an // array of validation errors — anything but a string would otherwise render as diff --git a/frontend-web/src/core/auth/hooks.ts b/frontend-web/src/core/auth/hooks.ts index 3bc5b40f..0adf00e7 100644 --- a/frontend-web/src/core/auth/hooks.ts +++ b/frontend-web/src/core/auth/hooks.ts @@ -1,6 +1,8 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { listAllUsers } from "./api"; +import { OptType, writeYLog } from "@/core/log/y-log"; + +import { listAllUsers, setUserApproval } from "./api"; export function useAllUsers(enabled: boolean) { return useQuery({ @@ -9,3 +11,20 @@ export function useAllUsers(enabled: boolean) { enabled, }); } + +/** 放行 / 取消放行某个用户(登录白名单),成功后刷新用户列表缓存。 */ +export function useSetUserApproval() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ userId, approved }: { userId: string; approved: boolean }) => setUserApproval(userId, approved), + onSuccess: (_data, vars) => { + writeYLog({ + optType: vars.approved ? OptType.enabled : OptType.disabled, + optDetail: `CM助手-全量-白名单管理-${vars.approved ? "放行" : "取消放行"}用户`, + url: `/api/v1/auth/users/${vars.userId}/approval`, + reqParam: vars, + }); + void queryClient.invalidateQueries({ queryKey: ["admin", "users"] }); + }, + }); +} diff --git a/frontend-web/src/pages/LoginWhitelistPage.tsx b/frontend-web/src/pages/LoginWhitelistPage.tsx new file mode 100644 index 00000000..b5851d2a --- /dev/null +++ b/frontend-web/src/pages/LoginWhitelistPage.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Navigate } from "react-router-dom"; +import { Info, Search, ShieldCheck, UserCheck } from "lucide-react"; +import { toast } from "sonner"; + +import { Switch } from "@/components/ui/switch"; +import { getStoredAuth } from "@/core/auth"; +import { useAllUsers, useSetUserApproval } from "@/core/auth/hooks"; +import { + useLoginWhitelistEnabled, + useSetLoginWhitelistEnabled, +} from "@/strategy-components/api/login-whitelist"; + +/** + * 登录白名单管理(管理员)。 + * + * 顶部一个「总开关」:关 = 所有人可登录;开 = 仅已放行用户(或 admin)可登录。 + * 下方列出全部注册用户,逐个放行 / 取消放行。用户仍可自助注册,注册后默认 + * 「待审批」,开关打开时被拦在登录外,直到管理员在此放行。 + */ +export default function LoginWhitelistPage() { + const isAdmin = getStoredAuth()?.system_role === "admin"; + + const { data: enabled, isLoading: loadingSwitch } = useLoginWhitelistEnabled(); + const setEnabled = useSetLoginWhitelistEnabled(); + + const { data: users = [], isLoading: loadingUsers } = useAllUsers(isAdmin); + const setApproval = useSetUserApproval(); + + const [query, setQuery] = useState(""); + const [pendingId, setPendingId] = useState(null); + + const q = query.trim().toLowerCase(); + const visibleUsers = useMemo( + () => (!q ? users : users.filter((u) => u.email.toLowerCase().includes(q) || u.id.toLowerCase().includes(q))), + [users, q], + ); + + const approvedCount = users.filter((u) => u.approved || u.system_role === "admin").length; + + const handleToggleSwitch = async (next: boolean) => { + try { + await setEnabled.mutateAsync(next); + toast.success(next ? "已启用登录白名单" : "已停用登录白名单(所有人可登录)"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "保存失败"); + } + }; + + const handleToggleApproval = async (userId: string, next: boolean) => { + setPendingId(userId); + try { + await setApproval.mutateAsync({ userId, approved: next }); + toast.success(next ? "已放行该用户" : "已取消放行(下次登录被拦)"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "保存失败"); + } finally { + setPendingId(null); + } + }; + + if (!isAdmin) return ; + + return ( +
+
+ {/* 顶部信息卡 + 总开关 */} +
+
+ +
+
+
+
+ + + 登录访问控制 + +

+ 白名单管理 + + {users.length} 用户 / {approvedCount} 可登录 + +

+
+ +
+
+
登录白名单
+
{enabled ? "已启用:仅已放行用户可登录" : "已停用:所有人可登录"}
+
+ +
+
+ +

+ + + 用户仍可自助注册,注册后默认 待审批。开关关闭时所有人可正常登录使用; + 开启后仅已放行的用户可登录,其余被拦在登录外,请在下方逐个放行。 + 管理员(admin)始终可登录,不受开关与放行状态影响。 + +

+
+
+ + {/* 搜索栏 */} +
+
+ + setQuery(e.target.value)} + className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-2 pl-10 pr-4 text-sm transition-all hover:border-slate-300 focus:border-red-500 focus:outline-none focus:ring-4 focus:ring-red-500/5" + /> +
+
+ + {/* 用户列表 */} +
+
+ 用户 + 问答 + 放行 +
+ + {loadingUsers ? ( +
加载中…
+ ) : visibleUsers.length === 0 ? ( +
+ {users.length === 0 ? "暂无注册用户" : "没有匹配的用户"} +
+ ) : ( +
    + {visibleUsers.map((user) => { + const isUserAdmin = user.system_role === "admin"; + const allowed = user.approved || isUserAdmin; + return ( +
  • +
    +
    + {user.email} + {isUserAdmin ? ( + + + 管理员 + + ) : allowed ? ( + + 已放行 + + ) : ( + + 待审批 + + )} +
    +
    {user.id}
    +
    +
    {user.qa_count}
    +
    + handleToggleApproval(user.id, v)} + /> +
    +
  • + ); + })} +
+ )} +
+
+
+ ); +} diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 84714ed1..1aa6498a 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -39,6 +39,7 @@ import LightAppIframeView from "./LightAppIframeView"; import LightAppListPage from "./LightAppListPage"; import LightAppManagementPage from "./LightAppManagementPage"; import MenuManagementPage from "./MenuManagementPage"; +import LoginWhitelistPage from "./LoginWhitelistPage"; import SensitiveWordsPage from "./SensitiveWordsPage"; import SkillAddressesPage from "./SkillAddressesPage"; import TaskButtonsPage from "./TaskButtonsPage"; @@ -395,6 +396,16 @@ export default function StrategyRoutes() { ) } /> + + ) : ( + + ) + } + /> `${getBackendBaseURL()}/api/system-settings/login-whitelist`; + +async function readJson(res: Response, ctx: string): Promise { + if (!res.ok) { + const data = await res.json().catch(() => null as unknown); + const detail = + data && typeof data === "object" && "detail" in data + ? String((data as { detail: unknown }).detail) + : `${ctx} failed (${res.status})`; + throw new Error(detail); + } + return (await res.json()) as T; +} + +export async function getLoginWhitelistEnabled(): Promise { + const res = await apiFetch(BASE(), { method: "GET" }); + const data = await readJson<{ enabled: boolean }>(res, "load login whitelist"); + return Boolean(data.enabled); +} + +export async function setLoginWhitelistEnabled(enabled: boolean): Promise { + const res = await apiFetch(BASE(), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + const data = await readJson<{ enabled: boolean }>(res, "save login whitelist"); + writeYLog({ + optType: enabled ? OptType.enabled : OptType.disabled, + optDetail: `CM助手-全量-白名单管理-${enabled ? "启用" : "停用"}登录白名单`, + url: "/api/system-settings/login-whitelist", + reqParam: { enabled }, + }); + return Boolean(data.enabled); +} + +export const LOGIN_WHITELIST_KEY = "login-whitelist-enabled"; + +export function useLoginWhitelistEnabled() { + return useQuery({ + queryKey: [LOGIN_WHITELIST_KEY], + queryFn: getLoginWhitelistEnabled, + refetchOnWindowFocus: false, + staleTime: 60_000, + }); +} + +export function useSetLoginWhitelistEnabled() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: setLoginWhitelistEnabled, + onSuccess: () => queryClient.invalidateQueries({ queryKey: [LOGIN_WHITELIST_KEY] }), + }); +} diff --git a/offline-backend-20260512/backend/app/gateway/auth/local_provider.py b/offline-backend-20260512/backend/app/gateway/auth/local_provider.py index 74527c2e..30cb55d1 100644 --- a/offline-backend-20260512/backend/app/gateway/auth/local_provider.py +++ b/offline-backend-20260512/backend/app/gateway/auth/local_provider.py @@ -79,7 +79,7 @@ class LocalAuthProvider(AuthProvider): """ return await self._repo.find_users_by_email_local_part_like(substring, limit=limit) - async def create_user(self, email: str, password: str | None = None, system_role: str = "user", needs_setup: bool = False) -> User: + async def create_user(self, email: str, password: str | None = None, system_role: str = "user", needs_setup: bool = False, approved: bool = False) -> User: """Create a new local user. Args: @@ -87,6 +87,8 @@ class LocalAuthProvider(AuthProvider): password: Plain text password (will be hashed) system_role: Role to assign ("admin" or "user") needs_setup: If True, user must complete setup on first login + approved: Login-whitelist 放行标记. Regular self-registered users + default to ``False`` (待审批); admins are created ``True``. Returns: Created User instance @@ -97,6 +99,7 @@ class LocalAuthProvider(AuthProvider): password_hash=password_hash, system_role=system_role, needs_setup=needs_setup, + approved=approved, ) return await self._repo.create_user(user) diff --git a/offline-backend-20260512/backend/app/gateway/auth/models.py b/offline-backend-20260512/backend/app/gateway/auth/models.py index 5298a856..8484df23 100644 --- a/offline-backend-20260512/backend/app/gateway/auth/models.py +++ b/offline-backend-20260512/backend/app/gateway/auth/models.py @@ -30,6 +30,7 @@ class User(BaseModel): # Auth lifecycle needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes") token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs") + approved: bool = Field(default=False, description="登录白名单放行标记;仅当系统设置 login_whitelist.enabled 打开时拦截未放行用户。新普通用户默认待审批(False)。") class UserResponse(BaseModel): diff --git a/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py b/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py index dde62309..0a37ddbe 100644 --- a/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py +++ b/offline-backend-20260512/backend/app/gateway/auth/repositories/sqlite.py @@ -46,6 +46,7 @@ class SQLiteUserRepository(UserRepository): oauth_id=row.oauth_id, needs_setup=row.needs_setup, token_version=row.token_version, + approved=row.approved, ) @staticmethod @@ -60,6 +61,7 @@ class SQLiteUserRepository(UserRepository): oauth_id=user.oauth_id, needs_setup=user.needs_setup, token_version=user.token_version, + approved=user.approved, ) # ── CRUD ────────────────────────────────────────────────────────── @@ -154,6 +156,7 @@ class SQLiteUserRepository(UserRepository): row.oauth_id = user.oauth_id row.needs_setup = user.needs_setup row.token_version = user.token_version + row.approved = user.approved await session.commit() return user diff --git a/offline-backend-20260512/backend/app/gateway/routers/auth.py b/offline-backend-20260512/backend/app/gateway/routers/auth.py index 33bda94d..d0c63079 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/auth.py +++ b/offline-backend-20260512/backend/app/gateway/routers/auth.py @@ -12,6 +12,7 @@ from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel, EmailStr, Field, field_validator from deerflow.config import get_app_config +from deerflow.config.system_settings import load_system_settings from deerflow.persistence.agents.base import AgentStore from deerflow.persistence.skills.base import SkillStore @@ -255,7 +256,9 @@ async def _get_or_create_user_by_username(username: str): if user is None: try: role = "admin" if username == "admin" else "user" - user = await provider.create_user(email=email, password=None, system_role=role, needs_setup=False) + # admin 创建即放行;普通用户自助注册后默认「待审批」(approved=False), + # 需管理员在白名单管理页放行——但仅当登录白名单开关打开时才会被拦。 + user = await provider.create_user(email=email, password=None, system_role=role, needs_setup=False, approved=(role == "admin")) created = True logger.info("Auto-registered passwordless user: %s", email) except ValueError: @@ -276,6 +279,35 @@ async def _get_or_create_user_by_username(username: str): return user, created +def enforce_user_approved(user) -> None: + """登录白名单拦截:开关打开且用户未放行时拒绝登录(注册照常,只是不发 token)。 + + 规则(三条硬约束防锁死): + - 开关关(``login_whitelist.enabled`` 默认 False)→ 直接放行,人人可登录。 + - admin → 永远放行(避免管理员把自己关在门外)。 + - 其余:``approved`` 为 False(注册后待审批)→ 403 ``PENDING_APPROVAL``; + 前端据此提示「等待管理员开通」,且不写任何登录态。 + + 放在各登录端点拿到 user 之后、签发 JWT 之前调用——账号此时已自助注册完成, + 被拦只是拿不到 token,管理员可在白名单管理页看到该待审批账号并放行。 + """ + try: + enabled = load_system_settings().login_whitelist.enabled + except Exception: + # 读配置失败时绝不误伤(白名单是附加闸门,故障时回到「不拦截」)。 + logger.warning("读取登录白名单配置失败,本次登录不拦截", exc_info=True) + return + if not enabled: + return + if getattr(user, "system_role", None) == "admin": + return + if not getattr(user, "approved", False): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"code": "PENDING_APPROVAL", "message": "您的账号正在等待管理员开通,请联系管理员"}, + ) + + async def _fetch_username_from_token(token: str) -> str: """Resolve an upstream access token to a username via the configured endpoint. @@ -507,6 +539,7 @@ async def login_local( ) _record_login_success(client_ip) + enforce_user_approved(user) token = create_access_token(str(user.id), token_version=user.token_version) _set_session_cookie(response, token, request) await _sync_user_position(request, user.id) @@ -552,6 +585,7 @@ async def login_by_username( user, created = await _get_or_create_user_by_username(body.username) + enforce_user_approved(user) token = create_access_token(str(user.id), token_version=user.token_version) _set_session_cookie(response, token, request) await _sync_user_position(request, user.id) @@ -604,6 +638,7 @@ async def login_by_token( user, created = await _get_or_create_user_by_username(username) + enforce_user_approved(user) access_token = create_access_token(str(user.id), token_version=user.token_version) _set_session_cookie(response, access_token, request) await _sync_user_position(request, user.id) @@ -767,7 +802,7 @@ async def initialize_admin(request: Request, response: Response, body: Initializ ) try: - user = await get_local_provider().create_user(email=body.email, password=body.password, system_role="admin", needs_setup=False) + user = await get_local_provider().create_user(email=body.email, password=body.password, system_role="admin", needs_setup=False, approved=True) except ValueError: # DB unique-constraint race: another concurrent request beat us. raise HTTPException( @@ -791,6 +826,7 @@ class UserListItem(BaseModel): skill_count: int agent_count: int qa_count: int = 0 # 问答次数: number of runs (one run = one question) + approved: bool = True # 登录白名单放行标记(白名单管理页据此放行/取消) @router.get("/users", response_model=list[UserListItem]) @@ -823,11 +859,44 @@ async def list_all_users( skill_count=await skill_store.count_owned(uid), agent_count=await agent_store.count_owned(uid), qa_count=qa_counts.get(uid, 0), + approved=getattr(u, "approved", True), ) ) return result +class UserApprovalRequest(BaseModel): + approved: bool = Field(..., description="True=放行该用户登录;False=取消放行(下次登录被拦)") + + +@router.put("/users/{user_id}/approval", response_model=UserListItem) +async def set_user_approval(user_id: str, body: UserApprovalRequest, request: Request) -> UserListItem: + """放行 / 取消放行某个注册用户(登录白名单)。Admin only。 + + 取消放行只拦该用户的下次登录(不动 ``token_version``,现有会话不强制下线)。 + """ + admin = await get_current_user_from_request(request) + if admin.system_role != "admin": + raise HTTPException(status_code=403, detail="Admin only") + + provider = get_local_provider() + target = await provider.get_user(user_id) + if target is None: + raise HTTPException(status_code=404, detail="用户不存在") + + target.approved = bool(body.approved) + saved = await provider.update_user(target) + return UserListItem( + id=str(saved.id), + email=saved.email, + system_role=saved.system_role, + skill_count=0, + agent_count=0, + qa_count=0, + approved=saved.approved, + ) + + # ── OAuth Endpoints (Future/Placeholder) ───────────────────────────────── diff --git a/offline-backend-20260512/backend/app/gateway/routers/system_settings.py b/offline-backend-20260512/backend/app/gateway/routers/system_settings.py index fc014c8d..1241b5cd 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/system_settings.py +++ b/offline-backend-20260512/backend/app/gateway/routers/system_settings.py @@ -451,6 +451,36 @@ class AgentModuleConfigUpdateRequest(BaseModel): tag_ids: list[str] = Field(default_factory=list, description="Full replacement list of selected agent tag ids") +class LoginWhitelistResponse(BaseModel): + enabled: bool = Field(..., description="是否启用登录白名单(关=所有人可登录,开=仅已放行用户/admin 可登录)") + + +class LoginWhitelistUpdateRequest(BaseModel): + enabled: bool | None = None + + +@router.get("/login-whitelist", response_model=LoginWhitelistResponse) +async def get_login_whitelist(request: Request) -> LoginWhitelistResponse: + """Return the login-whitelist master switch. Admin only (它驱动谁能登录,不公开).""" + await _require_admin(request) + return LoginWhitelistResponse(enabled=load_system_settings().login_whitelist.enabled) + + +@router.put("/login-whitelist", response_model=LoginWhitelistResponse) +async def update_login_whitelist(body: LoginWhitelistUpdateRequest, request: Request) -> LoginWhitelistResponse: + """Toggle the login-whitelist master switch. Admin only.""" + await _require_admin(request) + + current = load_system_settings() + update: dict[str, object] = {} + fields = body.model_dump(exclude_unset=True) + if "enabled" in fields and fields["enabled"] is not None: + update["enabled"] = bool(fields["enabled"]) + updated = current.login_whitelist.model_copy(update=update) + saved = save_system_settings(current.model_copy(update={"login_whitelist": updated})) + return LoginWhitelistResponse(enabled=saved.login_whitelist.enabled) + + @router.get("/agent-modules/{key}", response_model=AgentModuleConfigResponse) async def get_agent_module_config(key: str) -> AgentModuleConfigResponse: """Return the agent tag ids configured for ``key`` (readable by any user — diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py index e95183c1..052c4be4 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py @@ -221,10 +221,23 @@ def set_module_tag_ids( return settings.model_copy(update={"modules": modules}) +class LoginWhitelistSettings(BaseModel): + """登录白名单总开关(管理员可在「白名单管理」页切换,即时生效、免重启)。 + + ``enabled`` 关(默认) → 忽略每个用户的 ``approved`` 放行标记,人人可登录; + 开 → 仅 ``approved=True`` 或 admin 可登录,其余用户注册后处于「待审批」, + 被管理员放行前一律 403。空/默认即关,故部署后行为零变化。 + """ + + enabled: bool = Field(default=False, description="是否启用登录白名单;关=所有人可登录,开=仅已放行用户(或 admin)可登录") + model_config = ConfigDict(extra="ignore") + + class SystemSettings(BaseModel): """Top-level system settings document.""" prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings) + login_whitelist: LoginWhitelistSettings = Field(default_factory=LoginWhitelistSettings) private_square: PrivateSquareSettings = Field(default_factory=PrivateSquareSettings) publish_squares: PublishSquaresSettings = Field(default_factory=PublishSquaresSettings) appearance: AppearanceSettings = Field(default_factory=AppearanceSettings) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260625_02_users_approved.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260625_02_users_approved.py new file mode 100644 index 00000000..989fc130 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260625_02_users_approved.py @@ -0,0 +1,52 @@ +"""Add users.approved (登录白名单放行标记). + +Backs the login-whitelist feature: when system setting ``login_whitelist.enabled`` +is on, only ``approved=True`` users (or admins) may log in; self-registered +regular users default to ``approved=False`` (待审批) until an admin approves them. + +Existing rows are backfilled to TRUE (``server_default="1"``) so flipping the +switch on for the first time never locks out users who already had accounts. + +Revision ID: 20260625_02 +Revises: 20260625_01 +Create Date: 2026-06-25 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + + +revision = "20260625_02" +down_revision = "20260625_01" +branch_labels = None +depends_on = None + + +def _has_table(table_name: str) -> bool: + inspector = inspect(op.get_bind()) + return table_name in inspector.get_table_names() + + +def _has_column(table_name: str, column_name: str) -> bool: + if not _has_table(table_name): + return False + inspector = inspect(op.get_bind()) + return any(col["name"] == column_name for col in inspector.get_columns(table_name)) + + +def upgrade() -> None: + # A brand-new DB gets the column from create_all; only patch existing tables. + if _has_table("users") and not _has_column("users", "approved"): + op.add_column( + "users", + # server_default="1" backfills existing rows to 已放行 (avoid lockout). + sa.Column("approved", sa.Boolean(), nullable=False, server_default="1"), + ) + + +def downgrade() -> None: + if _has_column("users", "approved"): + op.drop_column("users", "approved") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/user/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/user/model.py index 2c065b65..ff90ee6e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/user/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/user/model.py @@ -49,6 +49,13 @@ class UserRow(Base): needs_setup: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) token_version: Mapped[int] = mapped_column(nullable=False, default=0) + # 登录白名单(放行)标记。仅当系统设置 ``login_whitelist.enabled`` 打开时生效: + # 关 → 忽略此列,人人可登录;开 → 仅 approved=True(或 admin)可登录,其余 + # 注册后处于「待审批」直到管理员放行。新注册的普通用户默认 False(待审批), + # admin 创建即 True;存量用户由迁移回填 True(避免首次打开开关时把老用户锁死)。 + # server_default=1 让迁移 ADD COLUMN 时把已存在行置为已放行。 + approved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("1")) + # 岗位 (position) the user is assigned to (1:1). NULL = no explicit # position → the user inherits the ``is_default`` position's grants. Drives # the subscription sync engine (see ``deerflow.services.position_sync``). diff --git a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql index 758dff98..75439e15 100644 --- a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql +++ b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql @@ -117,6 +117,13 @@ CALL _add_column_if_missing('knowledge_notes', 'folder', 'VARCHAR(512) NULL DEFA -- ORM: RoundtableJobRow.task_id = String(128), nullable, indexed。 CALL _add_column_if_missing('roundtable_jobs', 'task_id', 'VARCHAR(128) NULL DEFAULT NULL'); +-- ---- users.approved: 登录白名单放行标记,对应 migration 20260625_02 ----------- +-- 系统设置 login_whitelist.enabled 打开后,仅 approved=1(或 admin)可登录;普通用户 +-- 自助注册后默认 0(待审批),管理员在白名单管理页放行。DEFAULT 1 让存量用户(执行本 +-- 脚本补列时)一并置为已放行,避免首次打开开关把老用户锁死。 +-- ORM: UserRow.approved = Boolean, NOT NULL, server_default '1'。 +CALL _add_column_if_missing('users', 'approved', 'TINYINT(1) NOT NULL DEFAULT 1'); + -- ---- 清理临时存储过程 -------------------------------------------------------- DROP PROCEDURE IF EXISTS _add_column_if_missing;