Files
UHD_Media_Tool/apps/web/app/components/settings/account.tsx
T
rootandClaude Opus 5.5 c67f6a9682 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]>
2026-09-28 03:05:51 +08:00

332 lines
12 KiB
TypeScript

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>
);
}