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:
AdministratorandClaude Opus 4.8 committed 2026-06-25 16:09:38 +08:00
1 parent f8b40353ef
commit 1d1ab384ef
16 files changed
+508 -5

No files matched your search

@@ -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" />
+16
View File
@@ -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
+21 -2
View File
@@ -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>
);
}
+11
View File
@@ -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] }),
});
}