feat(auth): 登录白名单(注册后管理员审批放行 + 全局开关)
用户仍可自助注册,普通账号默认 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) <noreply@anthropic.com>
This commit is contained in:
1 parent
f8b40353ef
commit
1d1ab384ef
16 files changed
+508
-5
No files matched your search
@@ -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-<appId>`). 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=<JWT>&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`.
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
MousePointerClick,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
User,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
@@ -222,6 +223,14 @@ function SecondaryNavSidebar() {
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/login-whitelist")}
|
||||
>
|
||||
<ShieldCheck className="size-4" />
|
||||
白名单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canManageSensitiveWords() && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/sensitive-words")}
|
||||
@@ -508,6 +517,12 @@ function LegacyAccordionSidebar({
|
||||
按钮管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
|
||||
<ShieldCheck className="size-4" />
|
||||
白名单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canManageSensitiveWords() && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/sensitive-words")}>
|
||||
<ShieldAlert className="size-4" />
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface UserListItem {
|
||||
agent_count: number;
|
||||
/** 问答次数:用户提问的次数(一次提问算一次)。 */
|
||||
qa_count: number;
|
||||
/** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */
|
||||
approved: boolean;
|
||||
}
|
||||
|
||||
export async function listAllUsers(): Promise<UserListItem[]> {
|
||||
@@ -19,6 +21,20 @@ export async function listAllUsers(): Promise<UserListItem[]> {
|
||||
return res.json() as Promise<UserListItem[]>;
|
||||
}
|
||||
|
||||
/** 放行 / 取消放行某个注册用户(登录白名单)。Admin only。 */
|
||||
export async function setUserApproval(userId: string, approved: boolean): Promise<UserListItem> {
|
||||
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<UserListItem>;
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<string | null>(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 <Navigate to="/page/workspace/chats/new" replace />;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-slate-50/60 px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
{/* 顶部信息卡 + 总开关 */}
|
||||
<div className="relative mb-6 overflow-hidden rounded-3xl border border-slate-200/60 bg-white p-6 shadow-sm md:p-8">
|
||||
<div className="pointer-events-none absolute right-0 top-0 select-none p-8 opacity-[0.03]">
|
||||
<ShieldCheck className="size-44 text-slate-900" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="mb-4 flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<span className="mb-3 inline-flex select-none items-center gap-1.5 rounded-full border border-red-100 bg-red-50 px-3 py-1 text-xs font-semibold text-red-600">
|
||||
<ShieldCheck className="size-3" />
|
||||
登录访问控制
|
||||
</span>
|
||||
<h1 className="flex items-center gap-3 text-2xl font-bold tracking-tight text-slate-800 md:text-3xl">
|
||||
白名单管理
|
||||
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-0.5 font-mono text-xs font-medium text-slate-500">
|
||||
{users.length} 用户 / {approvedCount} 可登录
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-slate-200/60 bg-slate-50/70 px-4 py-3">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-semibold text-slate-700">登录白名单</div>
|
||||
<div className="text-xs text-slate-500">{enabled ? "已启用:仅已放行用户可登录" : "已停用:所有人可登录"}</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(enabled)}
|
||||
disabled={loadingSwitch || setEnabled.isPending}
|
||||
onCheckedChange={handleToggleSwitch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="flex max-w-2xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
|
||||
<span>
|
||||
用户仍可<strong>自助注册</strong>,注册后默认<strong> 待审批</strong>。开关<strong>关闭</strong>时所有人可正常登录使用;
|
||||
<strong> 开启</strong>后仅<strong>已放行</strong>的用户可登录,其余被拦在登录外,请在下方逐个放行。
|
||||
<strong> 管理员(admin)始终可登录</strong>,不受开关与放行状态影响。
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className="mb-6 rounded-2xl border border-slate-200/60 bg-white p-4 shadow-sm md:p-5">
|
||||
<div className="relative w-full md:w-80">
|
||||
<Search className="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索邮箱 / 用户ID…"
|
||||
value={query}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 用户列表 */}
|
||||
<div className="overflow-hidden rounded-2xl border border-slate-200/60 bg-white shadow-sm">
|
||||
<div className="grid grid-cols-[1fr_auto_auto] items-center gap-3 border-b border-slate-200/60 bg-slate-50/70 px-4 py-2.5 text-xs font-semibold text-slate-500">
|
||||
<span>用户</span>
|
||||
<span className="px-1 text-center">问答</span>
|
||||
<span className="px-1 text-center">放行</span>
|
||||
</div>
|
||||
|
||||
{loadingUsers ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-slate-400">加载中…</div>
|
||||
) : visibleUsers.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-slate-400">
|
||||
{users.length === 0 ? "暂无注册用户" : "没有匹配的用户"}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{visibleUsers.map((user) => {
|
||||
const isUserAdmin = user.system_role === "admin";
|
||||
const allowed = user.approved || isUserAdmin;
|
||||
return (
|
||||
<li
|
||||
key={user.id}
|
||||
className="grid grid-cols-[1fr_auto_auto] items-center gap-3 px-4 py-3 transition-colors hover:bg-slate-50/60"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-slate-800">{user.email}</span>
|
||||
{isUserAdmin ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-md border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[11px] font-semibold text-amber-600">
|
||||
<UserCheck className="size-3" />
|
||||
管理员
|
||||
</span>
|
||||
) : allowed ? (
|
||||
<span className="rounded-md border border-emerald-200 bg-emerald-50 px-1.5 py-0.5 text-[11px] font-semibold text-emerald-600">
|
||||
已放行
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-md border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[11px] font-semibold text-slate-500">
|
||||
待审批
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate font-mono text-xs text-slate-400">{user.id}</div>
|
||||
</div>
|
||||
<div className="px-1 text-center text-sm tabular-nums text-slate-500">{user.qa_count}</div>
|
||||
<div className="px-1">
|
||||
<Switch
|
||||
checked={allowed}
|
||||
disabled={isUserAdmin || (setApproval.isPending && pendingId === user.id)}
|
||||
onCheckedChange={(v) => handleToggleApproval(user.id, v)}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/login-whitelist"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<LoginWhitelistPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/roundtable-diagnostics"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 登录白名单「总开关」API 客户端 + React Query hooks。
|
||||
*
|
||||
* 后端:`GET|PUT /api/system-settings/login-whitelist`(admin only)。
|
||||
* 开关本身存系统设置 JSON,即时生效、免重启;逐用户的放行标记走
|
||||
* `core/auth`(`UserListItem.approved` + `setUserApproval`)。
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/system-settings/login-whitelist`;
|
||||
|
||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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] }),
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) ─────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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)
|
||||
|
||||
+52
@@ -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")
|
||||
@@ -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``).
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user