import { RECOVERY_CODE_COUNT, TotpCode } from "@umt/contracts"; import { Copy, Download, LoaderCircle } from "lucide-react"; import QRCodeStyling from "qr-code-styling"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useTotpAction, useTotpSetup, useTotpStatus } from "~/api/auth.ts"; import { FormField } 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 { errorMessage } from "~/lib/api.ts"; import { SettingsBlock } from "./settings-block.tsx"; // 二次验证(TOTP):扫码绑定 → 输入验证码开启 → 显示一次性恢复码;已开启时可重新生成恢复码或关闭。 function QrCode({ value }: { value: string }) { const box = useRef(null); useEffect(() => { const el = box.current; if (!el) return; el.replaceChildren(); const qr = new QRCodeStyling({ width: 176, height: 176, type: "svg", data: value, margin: 0, qrOptions: { errorCorrectionLevel: "M" }, // token-allow: 二维码必须深色码点 + 白底,扫码器才认 dotsOptions: { type: "rounded", color: "#18181b" }, // token-allow: 同上 cornersSquareOptions: { type: "extra-rounded", color: "#18181b" }, // token-allow: 同上 backgroundOptions: { color: "#ffffff" }, }); qr.append(el); }, [value]); return ( // token-allow: 二维码必须白底(深色主题下也一样)
); } /** 恢复码展示:只此一次;可复制、下载 */ function RecoveryCodes({ codes, onDone }: { codes: string[]; onDone: () => void }) { const text = `UHD Media Tool 恢复码(每个只能用一次)\n\n${codes.join("\n")}\n`; const copy = async () => { try { await navigator.clipboard.writeText(codes.join("\n")); toast.success("恢复码已复制"); } catch { toast.error("复制失败,手动选中复制"); } }; const download = () => { const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" })); const a = document.createElement("a"); a.href = url; a.download = "umt-recovery-codes.txt"; a.click(); URL.revokeObjectURL(url); }; return (

验证器丢了就用恢复码登录,每个只能用一次。 它们只显示这一次,存到密码管理器里。

    {codes.map((c) => (
  1. {c}
  2. ))}
); } type Mode = "idle" | "setup" | "codes" | "disable" | "regenerate"; export function TotpBlock({ disabled }: { disabled: boolean }) { const status = useTotpStatus(!disabled); const action = useTotpAction(); const [mode, setMode] = useState("idle"); const setup = useTotpSetup(!disabled && mode === "setup"); const [codes, setCodes] = useState([]); const [code, setCode] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); const enabled = status.data?.enabled === true; const left = status.data?.recoveryCodesRemaining ?? 0; const reset = (next: Mode = "idle") => { setMode(next); setCode(""); setPassword(""); setError(null); }; const enable = async (e: React.FormEvent) => { e.preventDefault(); if (!TotpCode.safeParse(code).success) return setError("验证码是 6 位数字"); setError(null); try { const r = await action.mutateAsync({ action: "enable", code }); toast.success("二次验证已开启"); setCodes(r.recoveryCodes ?? []); reset(r.recoveryCodes?.length ? "codes" : "idle"); } catch (err) { setError(errorMessage(err)); } }; const disable = async (e: React.FormEvent) => { e.preventDefault(); if (!password) return setError("输入当前密码"); const c = code.trim(); if (!/^\d{6}$/.test(c) && c.replace(/[\s-]/g, "").length < 10) return setError("输入 6 位验证码或一个恢复码"); setError(null); try { await action.mutateAsync({ action: "disable", password, code: c }); toast.success("二次验证已关闭"); reset(); } catch (err) { setError(errorMessage(err)); } }; const regenerate = async (e: React.FormEvent) => { e.preventDefault(); if (!password) return setError("输入当前密码"); setError(null); try { const r = await action.mutateAsync({ action: "recovery_codes", password }); setCodes(r.recoveryCodes ?? []); reset("codes"); } catch (err) { setError(errorMessage(err)); } }; const passwordField = ( setPassword(e.target.value)} /> ); const cancel = ( ); const spinner = action.isPending ? : null; const grouped = setup.data?.secret.replace(/(.{4})/g, "$1 ").trim(); let body: React.ReactNode; if (disabled) body =

当前用 API Key 访问,登录会话才能修改。

; else if (mode === "codes") body = reset()} />; else if (mode === "setup") body = setup.isPending ? (

生成密钥…

) : setup.isError ? (

{errorMessage(setup.error)}

{cancel}
) : (

用验证器(1Password、Google Authenticator 等)扫码,再输入它显示的 6 位验证码。

扫不了码?手动输入密钥 {grouped}
setCode(e.target.value.replace(/\D/g, ""))} aria-invalid={!!error} className="max-w-40 font-mono tracking-[0.3em]" />
{cancel}
); else if (mode === "disable") body = (
{passwordField} setCode(e.target.value)} aria-invalid={!!error} />
{cancel}
); else if (mode === "regenerate") body = (

旧的恢复码会全部作废。

{passwordField} {error ?

{error}

: null}
{cancel}
); else if (enabled) body = (

还剩 {left} 个恢复码(共 {RECOVERY_CODE_COUNT} 个)。{left <= 2 ? "快用完了,重新生成一组。" : ""}

); else body = (
); return ( {enabled ? "已开启" : "未开启"} ) } > {body} ); }