Merge branch 'ji' into master

账号级登录口令(白名单管理页设明文口令,凭 /login/<用户名>?password= 直登)。
This commit is contained in:
吉鑫升 committed 2026-06-27 11:19:58 +08:00
commit 199269712f
11 files changed
+402 -44

No files matched your search

+25
View File
@@ -13,6 +13,12 @@ export interface UserListItem {
qa_count: number;
/** 登录白名单放行标记:开关开启后仅 approved 用户(或 admin)可登录。 */
approved: boolean;
/**
* 账号级「登录口令」(明文,仅管理员可见)。非空 → 该账号用户名直登必须带匹配的
* `?password=`;为空/null → 走原有共享口令门/免口令逻辑。管理员据此拼出登录链接
* `…/login/<用户名>?password=xxxx` 发给用户。
*/
login_password: string | null;
}
export async function listAllUsers(): Promise<UserListItem[]> {
@@ -35,6 +41,25 @@ export async function setUserApproval(userId: string, approved: boolean): Promis
return res.json() as Promise<UserListItem>;
}
/**
* 设置 / 清除某个账号的「登录口令」(明文)。Admin only。
*
* 传非空字符串 → 设置;传 `null` 或空字符串 → 清除。设置后该账号用户名直登需带匹配的
* `?password=`。返回更新后的用户项(含明文 `login_password`)。
*/
export async function setUserLoginPassword(userId: string, password: string | null): Promise<UserListItem> {
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users/${encodeURIComponent(userId)}/login-password`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
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
+19 -1
View File
@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { OptType, writeYLog } from "@/core/log/y-log";
import { listAllUsers, setUserApproval } from "./api";
import { listAllUsers, setUserApproval, setUserLoginPassword } from "./api";
export function useAllUsers(enabled: boolean) {
return useQuery({
@@ -28,3 +28,21 @@ export function useSetUserApproval() {
},
});
}
/** 设置 / 清除某账号的「登录口令」(明文),成功后刷新用户列表缓存。 */
export function useSetUserLoginPassword() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ userId, password }: { userId: string; password: string | null }) => setUserLoginPassword(userId, password),
onSuccess: (_data, vars) => {
const cleared = vars.password == null || vars.password === "";
writeYLog({
optType: cleared ? OptType.delete : OptType.edit,
optDetail: `CM助手-全量-白名单管理-${cleared ? "清除" : "设置"}账号登录口令`,
url: `/api/v1/auth/users/${vars.userId}/login-password`,
reqParam: { userId: vars.userId },
});
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
},
});
}
+189 -36
View File
@@ -1,13 +1,14 @@
"use client";
import { useMemo, useState } from "react";
import { Fragment, useMemo, useState } from "react";
import { Navigate } from "react-router-dom";
import { Info, Search, ShieldCheck, UserCheck } from "lucide-react";
import { Copy, Info, KeyRound, Link2, Search, ShieldCheck, UserCheck, X } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import type { UserListItem } from "@/core/auth/api";
import { getStoredAuth } from "@/core/auth";
import { useAllUsers, useSetUserApproval } from "@/core/auth/hooks";
import { useAllUsers, useSetUserApproval, useSetUserLoginPassword } from "@/core/auth/hooks";
import {
useLoginWhitelistEnabled,
useSetLoginWhitelistEnabled,
@@ -28,9 +29,13 @@ export default function LoginWhitelistPage() {
const { data: users = [], isLoading: loadingUsers } = useAllUsers(isAdmin);
const setApproval = useSetUserApproval();
const setLoginPassword = useSetUserLoginPassword();
const [query, setQuery] = useState("");
const [pendingId, setPendingId] = useState<string | null>(null);
// 口令编辑:当前展开编辑的用户 id + 输入草稿(明文,便于管理员直接查看/复制)。
const [editingId, setEditingId] = useState<string | null>(null);
const [pwdDraft, setPwdDraft] = useState("");
const q = query.trim().toLowerCase();
const visibleUsers = useMemo(
@@ -61,6 +66,56 @@ export default function LoginWhitelistPage() {
}
};
// 用户名直登链接:`<当前源>/#/login/<邮箱本地部分>?password=<口令>`。用户名取邮箱
// `@` 前缀(用户名直登据此解析回同一账号);base 取去掉 hash 的当前地址,保留可能的
// 部署子路径。
const buildLoginLink = (user: UserListItem, password: string) => {
const localPart = (user.email.split("@")[0] || "").trim();
const base = window.location.href.split("#")[0];
return `${base}#/login/${encodeURIComponent(localPart)}?password=${encodeURIComponent(password)}`;
};
const copyText = async (text: string, okMsg: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success(okMsg);
} catch {
toast.error("复制失败,请手动复制");
}
};
const openEditor = (user: UserListItem) => {
setEditingId(user.id);
setPwdDraft(user.login_password ?? "");
};
const closeEditor = () => {
setEditingId(null);
setPwdDraft("");
};
const handleSavePassword = async (user: UserListItem) => {
const pwd = pwdDraft.trim();
if (!pwd) return;
try {
await setLoginPassword.mutateAsync({ userId: user.id, password: pwd });
toast.success("已设置登录口令");
closeEditor();
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存失败");
}
};
const handleClearPassword = async (user: UserListItem) => {
try {
await setLoginPassword.mutateAsync({ userId: user.id, password: null });
toast.success("已清除登录口令");
closeEditor();
} catch (err) {
toast.error(err instanceof Error ? err.message : "清除失败");
}
};
if (!isAdmin) return <Navigate to="/page/workspace/chats/new" replace />;
return (
@@ -126,9 +181,10 @@ export default function LoginWhitelistPage() {
{/* 用户列表 */}
<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">
<div className="grid grid-cols-[1fr_auto_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>
<span className="px-1 text-center">放行</span>
</div>
@@ -143,40 +199,137 @@ export default function LoginWhitelistPage() {
{visibleUsers.map((user) => {
const isUserAdmin = user.system_role === "admin";
const allowed = user.approved || isUserAdmin;
const hasPwd = Boolean(user.login_password);
const isEditing = editingId === user.id;
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-rose-200 bg-rose-50 px-1.5 py-0.5 text-[11px] font-semibold text-rose-600">
已拦截
</span>
)}
<Fragment key={user.id}>
<li className="grid grid-cols-[1fr_auto_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-rose-200 bg-rose-50 px-1.5 py-0.5 text-[11px] font-semibold text-rose-600">
已拦截
</span>
)}
</div>
<div className="truncate font-mono text-xs text-slate-400">{user.id}</div>
</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>
<div className="px-1 text-center text-sm tabular-nums text-slate-500">{user.qa_count}</div>
{/* 登录口令:设置 / 修改 + 复制登录链接 */}
<div className="px-1">
<div className="flex items-center justify-center gap-1.5">
<button
type="button"
onClick={() => (isEditing ? closeEditor() : openEditor(user))}
title={hasPwd ? "已设登录口令,点击修改" : "设置登录口令"}
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-semibold transition-colors ${
hasPwd
? "border-emerald-200 bg-emerald-50 text-emerald-600 hover:bg-emerald-100"
: "border-slate-200 bg-slate-50 text-slate-500 hover:bg-slate-100"
}`}
>
<KeyRound className="size-3" />
{hasPwd ? "已设" : "设置"}
</button>
{hasPwd && (
<button
type="button"
onClick={() => copyText(buildLoginLink(user, user.login_password ?? ""), "已复制登录链接")}
title="复制登录链接"
className="inline-flex items-center rounded-md border border-slate-200 bg-white px-1.5 py-1 text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-700"
>
<Link2 className="size-3" />
</button>
)}
</div>
</div>
<div className="px-1">
<Switch
checked={allowed}
disabled={isUserAdmin || (setApproval.isPending && pendingId === user.id)}
onCheckedChange={(v) => handleToggleApproval(user.id, v)}
/>
</div>
</li>
{isEditing && (
<li className="bg-slate-50/70 px-4 py-4">
<div className="rounded-xl border border-slate-200/70 bg-white p-4 shadow-sm">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-600">
<KeyRound className="size-3.5 text-slate-400" />
为 <span className="font-mono text-slate-800">{user.email}</span> 设置登录口令
</div>
<p className="mb-3 text-[11px] leading-relaxed text-slate-400">
设置后该账号需用此口令登录:
<code className="rounded bg-slate-100 px-1 text-slate-500">/login/用户名?password=口令</code>
。口令以明文随链接传输,仅用于临时开通,请妥善保管。
</p>
<div className="flex flex-wrap items-center gap-2">
<input
type="text"
value={pwdDraft}
onChange={(e) => setPwdDraft(e.target.value)}
placeholder="输入登录口令"
onKeyDown={(e) => {
if (e.key === "Enter") void handleSavePassword(user);
}}
className="w-56 rounded-lg border border-slate-200 bg-slate-50/50 px-3 py-1.5 text-sm transition-all hover:border-slate-300 focus:border-red-500 focus:outline-none focus:ring-4 focus:ring-red-500/5"
/>
<button
type="button"
disabled={!pwdDraft.trim() || setLoginPassword.isPending}
onClick={() => void handleSavePassword(user)}
className="inline-flex items-center gap-1 rounded-lg bg-red-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
>
保存
</button>
{hasPwd && (
<button
type="button"
disabled={setLoginPassword.isPending}
onClick={() => void handleClearPassword(user)}
className="inline-flex items-center gap-1 rounded-lg border border-rose-200 bg-rose-50 px-3 py-1.5 text-sm font-semibold text-rose-600 transition-colors hover:bg-rose-100 disabled:opacity-50"
>
清除
</button>
)}
<button
type="button"
onClick={closeEditor}
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-500 transition-colors hover:bg-slate-50"
>
<X className="size-3.5" />
取消
</button>
</div>
{pwdDraft.trim() && (
<div className="mt-3 flex items-center gap-2 rounded-lg border border-slate-200/70 bg-slate-50/70 p-2">
<Link2 className="size-3.5 shrink-0 text-slate-400" />
<span className="truncate font-mono text-[11px] text-slate-500">{buildLoginLink(user, pwdDraft.trim())}</span>
<button
type="button"
onClick={() => copyText(buildLoginLink(user, pwdDraft.trim()), "已复制登录链接")}
className="ml-auto inline-flex shrink-0 items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-[11px] font-medium text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-700"
>
<Copy className="size-3" />
复制链接
</button>
</div>
)}
</div>
</li>
)}
</Fragment>
);
})}
</ul>