Files
UHD_Media_Tool/apps/web/app/components/settings/account.tsx
T
rootandClaude Opus 5.5 c57411f9a5 docs(copy): 设置页账户与智能体分区文案按 human-copy 重写
- 改密码后的会话说法统一为「其他会话已退出」
- 模型服务向导里的 provider 统一写「提供方」
- 能力类别「提案」改「方案」;自动调查说明去掉「有界调查」

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 01:48:45 +08:00

207 lines
7.3 KiB
TypeScript

import { PasswordChangeRequest } 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 } from "~/api/auth.ts";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { SecretInput } from "~/components/console/secret-input.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 { PasskeyBlock } from "./account-passkeys.tsx";
import { TotpBlock } from "./account-totp.tsx";
import { ApiKeysSection } from "./api-keys.tsx";
import { SettingsBlock } from "./settings-block.tsx";
// 账户与安全:改密、二次验证(TOTP,account-totp.tsx)、Passkey(account-passkeys.tsx)、当前会话与本机 Token、
// 签发给外部程序的 API Key(api-keys.tsx)。快捷键在「界面」。
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 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="会话"
description="当前登录状态和这个浏览器保存的本机 Token。"
>
<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">
<SecretInput
label="本机 Token"
isSet={hasToken}
placeholder="粘贴 API Key"
value={token}
onChange={setTokenValue}
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 grid-cols-1 gap-8">
<PasswordBlock disabled={!cookie} />
<TotpBlock disabled={!cookie} />
<PasskeyBlock disabled={!cookie} />
<SessionBlock />
<ApiKeysSection />
</div>
);
}