- 改密码后的会话说法统一为「其他会话已退出」 - 模型服务向导里的 provider 统一写「提供方」 - 能力类别「提案」改「方案」;自动调查说明去掉「有界调查」 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
207 lines
7.3 KiB
TypeScript
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>
|
|
);
|
|
}
|