import { PasswordChangeRequest, type SessionInfo, TotpCode } from "@umt/contracts"; import { KeyRound, LoaderCircle, LogOut, ShieldCheck, UserRound } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { z } from "zod"; import { useChangePassword, useLogout, useTotpAction, useTotpStatus } from "~/api/auth.ts"; import { Button } from "~/components/ui/button.tsx"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "~/components/ui/dialog.tsx"; import { Input } from "~/components/ui/input.tsx"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx"; import { errorMessage, LOGIN_PATH } from "~/lib/api.ts"; import { formatDateTime } from "~/lib/utils.ts"; import { FormField, zodFieldErrors } from "./form-field.tsx"; const PasswordForm = PasswordChangeRequest.extend({ confirm: z.string() }).refine((v) => v.newPassword === v.confirm, { message: "两次输入的新密码不一致", path: ["confirm"], }); function ChangePasswordDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) { const change = useChangePassword(); const [form, setForm] = useState({ currentPassword: "", newPassword: "", confirm: "" }); const [errors, setErrors] = useState>({}); 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({ currentPassword: "", newPassword: "", confirm: "" }); onOpenChange(false); } 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) => setForm((f) => ({ ...f, [k]: e.target.value })); return ( !change.isPending && onOpenChange(o)}> 修改密码 修改后其它设备上的会话会被注销,当前会话保留。
); } /** 二次验证(TOTP):未开启 → 生成密钥 → 在验证器里添加 → 输入 6 位码确认;已开启 → 当前密码 + 验证码关闭 */ function TotpDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) { const status = useTotpStatus(open); 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(null); const enabled = status.data?.enabled === true; const reset = () => { setSetup(null); setCode(""); setPassword(""); setError(null); }; const close = (o: boolean) => { if (action.isPending) return; if (!o) reset(); onOpenChange(o); }; 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; } setError(null); try { if (enabled) { if (!password) { setError("请输入当前密码"); return; } await action.mutateAsync({ action: "disable", password, code }); toast.success("已关闭二次验证"); } else { await action.mutateAsync({ action: "enable", code }); toast.success("已开启二次验证,下次登录需要输入验证码"); } reset(); onOpenChange(false); } catch (err) { setError(errorMessage(err)); } }; const grouped = setup?.secret.replace(/(.{4})/g, "$1 ").trim(); return ( 二次验证(TOTP) {status.isLoading ? "读取状态…" : enabled ? "已开启:登录时需要验证器里的 6 位验证码。关闭需要当前密码和验证码。" : "未开启:开启后登录除密码外还需要验证器(如 1Password、Google Authenticator)里的 6 位验证码。"} {!enabled && !setup ? (
{status.data?.pending ? (

有一个未确认的密钥;重新生成会作废它。

) : null} {error ?

{error}

: null}
) : (
{setup ? (

在验证器里选“手动输入密钥”,填入下面的密钥(基于时间,6 位);或把 otpauth 链接粘贴到支持的验证器。密钥只显示这一次。

{grouped} {setup.uri}
) : null} {enabled ? ( setPassword(e.target.value)} /> ) : null} setCode(e.target.value.replace(/\D/g, ""))} aria-invalid={!!error} />
)} {!enabled && !setup ? ( ) : ( )}
); } /** 右上角账户弹层:当前身份、会话到期、改密、二次验证、登出 */ export function AccountMenu({ session }: { session: SessionInfo }) { const [open, setOpen] = useState(false); const [pwdOpen, setPwdOpen] = useState(false); const [totpOpen, setTotpOpen] = useState(false); const logout = useLogout(); const actor = session.actor; const isCookie = !!session.expiresAt; const doLogout = async () => { try { await logout.mutateAsync(); } catch { // 会话已失效也照样回登录页 } window.location.assign(LOGIN_PATH); }; return ( <>

当前账户

{actor?.name ?? "—"}

{actor?.type === "api_key" ? "API Key 调用" : "管理员"} {isCookie ? ` · 会话到 ${formatDateTime(session.expiresAt)}` : ""}

{isCookie ? ( ) : null} {isCookie ? ( ) : null}
); }