feat(web): settings with 9 sidebar sections and Agent upstream management
- /console/settings#<section>[/<anchor>]; legacy ?tab= redirected in clientLoader - SETTINGS_SECTIONS in contracts with field-ownership test - Agent section: upstream list, create wizard (provider -> credentials/model -> login/test/default), edit drawer with OAuth browser/device login, test, set default, delete - sites section with site registration, snapshot/rate-limit drawer and account credentials - path mappings moved into downloader / media-server drawers; metadata and account sections Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
459991421e
commit
c67f6a9682
25 files changed
+3842
-942
No files matched your search
@@ -0,0 +1,331 @@
|
||||
import { PasswordChangeRequest, TotpCode } from "@umt/contracts";
|
||||
import { LoaderCircle, LogOut } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useChangePassword, useLogout, useSession, useTotpAction, useTotpStatus } from "~/api/auth.ts";
|
||||
import { AgentShortcutsHelp } from "~/components/console/agent-launcher.tsx";
|
||||
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { clearAuth, errorMessage, getToken, LOGIN_PATH, setToken } from "~/lib/api.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
import { anchorId, SettingsBlock } from "./settings-block.tsx";
|
||||
|
||||
// 账户与安全:改密、二次验证(TOTP)、当前会话与本机 Token、快捷键
|
||||
|
||||
const PasswordForm = PasswordChangeRequest.extend({ confirm: z.string() }).refine((v) => v.newPassword === v.confirm, {
|
||||
message: "两次输入的新密码不一致",
|
||||
path: ["confirm"],
|
||||
});
|
||||
|
||||
const EMPTY_PWD = { currentPassword: "", newPassword: "", confirm: "" };
|
||||
|
||||
function PasswordBlock({ disabled }: { disabled: boolean }) {
|
||||
const change = useChangePassword();
|
||||
const [form, setForm] = useState(EMPTY_PWD);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const dirty = Object.values(form).some(Boolean);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const parsed = PasswordForm.safeParse(form);
|
||||
if (!parsed.success) {
|
||||
setErrors(zodFieldErrors(parsed.error.issues));
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
try {
|
||||
const r = await change.mutateAsync({ currentPassword: form.currentPassword, newPassword: form.newPassword });
|
||||
toast.success(r.revokedSessions ? `密码已修改,注销了其它 ${r.revokedSessions} 个会话` : "密码已修改");
|
||||
setForm(EMPTY_PWD);
|
||||
} catch (err) {
|
||||
const msg = errorMessage(err);
|
||||
if (/旧密码|当前密码|password/i.test(msg)) setErrors({ currentPassword: "当前密码不正确" });
|
||||
else toast.error(msg);
|
||||
}
|
||||
};
|
||||
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm((f) => ({ ...f, [k]: e.target.value }));
|
||||
const off = disabled || change.isPending;
|
||||
|
||||
return (
|
||||
<SettingsBlock
|
||||
section="account"
|
||||
anchor="password"
|
||||
title="修改密码"
|
||||
description="修改后其它设备上的会话全部注销,当前会话保留。"
|
||||
dirty={dirty}
|
||||
>
|
||||
<form onSubmit={submit} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-3">
|
||||
<FormField label="当前密码" htmlFor="acc-pwd-cur" error={errors.currentPassword}>
|
||||
<Input
|
||||
id="acc-pwd-cur"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={form.currentPassword}
|
||||
onChange={set("currentPassword")}
|
||||
aria-invalid={!!errors.currentPassword}
|
||||
disabled={off}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="新密码" htmlFor="acc-pwd-new" error={errors.newPassword} hint="至少 10 个字符">
|
||||
<Input
|
||||
id="acc-pwd-new"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={form.newPassword}
|
||||
onChange={set("newPassword")}
|
||||
aria-invalid={!!errors.newPassword}
|
||||
disabled={off}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="确认新密码" htmlFor="acc-pwd-confirm" error={errors.confirm}>
|
||||
<Input
|
||||
id="acc-pwd-confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={form.confirm}
|
||||
onChange={set("confirm")}
|
||||
aria-invalid={!!errors.confirm}
|
||||
disabled={off}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="sm:col-span-3">
|
||||
<Button type="submit" size="sm" disabled={off || !dirty}>
|
||||
{change.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
修改密码
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SettingsBlock>
|
||||
);
|
||||
}
|
||||
|
||||
function TotpBlock({ disabled }: { disabled: boolean }) {
|
||||
const status = useTotpStatus(!disabled);
|
||||
const action = useTotpAction();
|
||||
const [setup, setSetup] = useState<{ secret: string; uri: string } | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const enabled = status.data?.enabled === true;
|
||||
|
||||
const reset = () => {
|
||||
setSetup(null);
|
||||
setCode("");
|
||||
setPassword("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const begin = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const r = await action.mutateAsync({ action: "begin" });
|
||||
if (r.secret && r.otpauthUri) setSetup({ secret: r.secret, uri: r.otpauthUri });
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!TotpCode.safeParse(code).success) {
|
||||
setError("验证码为 6 位数字");
|
||||
return;
|
||||
}
|
||||
if (enabled && !password) {
|
||||
setError("输入当前密码");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
if (enabled) {
|
||||
await action.mutateAsync({ action: "disable", password, code });
|
||||
toast.success("二次验证已关闭");
|
||||
} else {
|
||||
await action.mutateAsync({ action: "enable", code });
|
||||
toast.success("二次验证已开启");
|
||||
}
|
||||
reset();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const grouped = setup?.secret.replace(/(.{4})/g, "$1 ").trim();
|
||||
|
||||
return (
|
||||
<SettingsBlock
|
||||
section="account"
|
||||
anchor="totp"
|
||||
title="二次验证(TOTP)"
|
||||
description={enabled ? "关闭需要当前密码和验证码。" : "开启后登录还要输入验证器里的 6 位验证码。"}
|
||||
actions={
|
||||
disabled ? null : status.isPending ? null : (
|
||||
<StatusBadge tone={enabled ? "success" : "neutral"}>{enabled ? "已开启" : "未开启"}</StatusBadge>
|
||||
)
|
||||
}
|
||||
>
|
||||
{disabled ? (
|
||||
<p className="text-caption text-muted-foreground">当前用 API Key 访问,登录会话才能修改。</p>
|
||||
) : !enabled && !setup ? (
|
||||
<div className="grid justify-items-start gap-2">
|
||||
{status.data?.pending ? (
|
||||
<p className="text-meta text-muted-foreground">有一个未确认的密钥,重新生成会作废它。</p>
|
||||
) : null}
|
||||
{error ? <p className="text-meta text-destructive">{error}</p> : null}
|
||||
<Button size="sm" onClick={begin} disabled={action.isPending || status.isPending}>
|
||||
{action.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
生成密钥
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} noValidate className="grid max-w-md gap-1">
|
||||
{setup ? (
|
||||
<div className="mb-2 grid gap-1.5">
|
||||
<p className="text-meta text-muted-foreground">
|
||||
在验证器里手动输入密钥,或粘贴 otpauth 链接。密钥只显示这一次。
|
||||
</p>
|
||||
<code className="rounded-md bg-muted px-2 py-1.5 font-mono text-sm break-all select-all">{grouped}</code>
|
||||
<code className="rounded-md bg-muted px-2 py-1.5 font-mono text-meta break-all text-muted-foreground select-all">
|
||||
{setup.uri}
|
||||
</code>
|
||||
</div>
|
||||
) : null}
|
||||
{enabled ? (
|
||||
<FormField label="当前密码" htmlFor="acc-totp-pwd">
|
||||
<Input
|
||||
id="acc-totp-pwd"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
) : null}
|
||||
<FormField label="验证码" htmlFor="acc-totp-code" error={error ?? undefined}>
|
||||
<Input
|
||||
id="acc-totp-code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" variant={enabled ? "destructive" : "default"} disabled={action.isPending}>
|
||||
{action.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
{enabled ? "关闭二次验证" : "确认开启"}
|
||||
</Button>
|
||||
{setup ? (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={reset} disabled={action.isPending}>
|
||||
取消
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</SettingsBlock>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionBlock() {
|
||||
const session = useSession();
|
||||
const logout = useLogout();
|
||||
const [hasToken, setHasToken] = useState(() => !!getToken());
|
||||
const [token, setTokenValue] = useState("");
|
||||
const s = session.data;
|
||||
|
||||
const doLogout = async () => {
|
||||
try {
|
||||
await logout.mutateAsync();
|
||||
} catch {
|
||||
// 会话已失效也照样回登录页
|
||||
}
|
||||
window.location.assign(LOGIN_PATH);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsBlock section="account" anchor="session" title="会话">
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1.5 text-caption">
|
||||
<dt className="text-muted-foreground">身份</dt>
|
||||
<dd>
|
||||
{s?.actor?.name ?? "—"} · {s?.actor?.type === "api_key" ? "API Key" : "管理员"}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">当前会话</dt>
|
||||
<dd>{s?.expiresAt ? `到 ${formatDateTime(s.expiresAt)} 失效` : "未使用登录会话"}</dd>
|
||||
<dt className="text-muted-foreground">其它会话</dt>
|
||||
<dd className="text-muted-foreground">修改密码时全部注销</dd>
|
||||
</dl>
|
||||
<div className="mt-4 border-t border-border/60 pt-4">
|
||||
<p className="text-caption font-medium">本机 Token</p>
|
||||
<p className="mb-2 text-meta text-muted-foreground">
|
||||
只存在这个浏览器,有值时请求会带上它。{hasToken ? "已设置。" : "未设置。"}
|
||||
</p>
|
||||
<div className="flex max-w-md flex-wrap items-center gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
aria-label="本机 Token"
|
||||
placeholder={hasToken ? "输入新值替换" : "粘贴 API Key"}
|
||||
value={token}
|
||||
onChange={(e) => setTokenValue(e.target.value)}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!token.trim()}
|
||||
onClick={() => {
|
||||
setToken(token.trim());
|
||||
setTokenValue("");
|
||||
setHasToken(true);
|
||||
toast.success("本机 Token 已保存");
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
{hasToken ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
clearAuth();
|
||||
setHasToken(false);
|
||||
toast.success("本机 Token 已清除");
|
||||
}}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 border-t border-border/60 pt-4">
|
||||
<Button variant="outline" size="sm" onClick={doLogout} disabled={logout.isPending}>
|
||||
<LogOut />
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsBlock>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountSection() {
|
||||
const session = useSession();
|
||||
const cookie = !!session.data?.expiresAt;
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
<PasswordBlock disabled={!cookie} />
|
||||
<TotpBlock disabled={!cookie} />
|
||||
<SessionBlock />
|
||||
{/* 快捷键说明自带标题,只包卡片与锚点 */}
|
||||
<section id={anchorId("account", "shortcuts")} className="pg-slab min-w-0 scroll-mt-24 rounded-xl p-5">
|
||||
<AgentShortcutsHelp />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user