Auto-fix of 602 string literals across 74 files (half-width punctuation next to Chinese, parenthesised Chinese, quotes); biome reflow; e2/e6 assertions follow the new full-width summaries. check:copy now runs with --strict in ci. Co-Authored-By: Claude Opus 5.5 <[email protected]>
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
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<HTMLDivElement>(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: 二维码必须白底(深色主题下也一样)
|
|
<div className="w-fit rounded-xl border border-border bg-white p-3">
|
|
<div ref={box} role="img" aria-label="TOTP 二维码" className="size-44" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 恢复码展示:只此一次;可复制、下载 */
|
|
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 (
|
|
<div className="grid max-w-md gap-3">
|
|
<p className="text-caption">
|
|
验证器丢了就用恢复码登录,每个只能用一次。
|
|
<span className="text-muted-foreground">它们只显示这一次,存到密码管理器里。</span>
|
|
</p>
|
|
<ol className="grid grid-cols-2 gap-x-6 gap-y-1 rounded-lg bg-muted px-4 py-3 font-mono text-sm select-all">
|
|
{codes.map((c) => (
|
|
<li key={c}>{c}</li>
|
|
))}
|
|
</ol>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button size="sm" variant="outline" onClick={copy}>
|
|
<Copy />
|
|
复制
|
|
</Button>
|
|
<Button size="sm" variant="outline" onClick={download}>
|
|
<Download />
|
|
下载
|
|
</Button>
|
|
<Button size="sm" onClick={onDone}>
|
|
已保存
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type Mode = "idle" | "setup" | "codes" | "disable" | "regenerate";
|
|
|
|
export function TotpBlock({ disabled }: { disabled: boolean }) {
|
|
const status = useTotpStatus(!disabled);
|
|
const action = useTotpAction();
|
|
const [mode, setMode] = useState<Mode>("idle");
|
|
const setup = useTotpSetup(!disabled && mode === "setup");
|
|
const [codes, setCodes] = useState<string[]>([]);
|
|
const [code, setCode] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [error, setError] = useState<string | null>(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 = (
|
|
<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>
|
|
);
|
|
const cancel = (
|
|
<Button type="button" variant="ghost" size="sm" onClick={() => reset()} disabled={action.isPending}>
|
|
取消
|
|
</Button>
|
|
);
|
|
const spinner = action.isPending ? <LoaderCircle className="animate-spin" /> : null;
|
|
const grouped = setup.data?.secret.replace(/(.{4})/g, "$1 ").trim();
|
|
|
|
let body: React.ReactNode;
|
|
if (disabled) body = <p className="text-caption text-muted-foreground">当前用 API Key 访问,登录会话才能修改。</p>;
|
|
else if (mode === "codes") body = <RecoveryCodes codes={codes} onDone={() => reset()} />;
|
|
else if (mode === "setup")
|
|
body = setup.isPending ? (
|
|
<p className="flex items-center gap-2 text-caption text-muted-foreground">
|
|
<LoaderCircle className="size-4 animate-spin" />
|
|
生成密钥…
|
|
</p>
|
|
) : setup.isError ? (
|
|
<div className="grid justify-items-start gap-2">
|
|
<p className="text-meta text-destructive">{errorMessage(setup.error)}</p>
|
|
{cancel}
|
|
</div>
|
|
) : (
|
|
<form onSubmit={enable} noValidate className="grid gap-4 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-start">
|
|
<QrCode value={setup.data.otpauthUri} />
|
|
<div className="grid max-w-md gap-2">
|
|
<p className="text-caption">
|
|
用验证器(1Password、Google Authenticator 等)扫码,再输入它显示的 6 位验证码。
|
|
</p>
|
|
<details className="text-meta text-muted-foreground">
|
|
<summary className="cursor-pointer select-none">扫不了码?手动输入密钥</summary>
|
|
<code className="mt-1.5 block rounded-lg bg-muted px-2 py-1.5 font-mono text-sm break-all text-foreground select-all">
|
|
{grouped}
|
|
</code>
|
|
</details>
|
|
<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}
|
|
className="max-w-40 font-mono tracking-[0.3em]"
|
|
/>
|
|
</FormField>
|
|
<div className="flex gap-2">
|
|
<Button type="submit" size="sm" disabled={action.isPending}>
|
|
{spinner}
|
|
确认开启
|
|
</Button>
|
|
{cancel}
|
|
</div>
|
|
</div>
|
|
</form>
|
|
);
|
|
else if (mode === "disable")
|
|
body = (
|
|
<form onSubmit={disable} noValidate className="grid max-w-md gap-1">
|
|
{passwordField}
|
|
<FormField label="验证码或恢复码" htmlFor="acc-totp-code" error={error ?? undefined}>
|
|
<Input
|
|
id="acc-totp-code"
|
|
autoComplete="one-time-code"
|
|
maxLength={16}
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value)}
|
|
aria-invalid={!!error}
|
|
/>
|
|
</FormField>
|
|
<div className="flex gap-2">
|
|
<Button type="submit" size="sm" variant="destructive" disabled={action.isPending}>
|
|
{spinner}
|
|
关闭二次验证
|
|
</Button>
|
|
{cancel}
|
|
</div>
|
|
</form>
|
|
);
|
|
else if (mode === "regenerate")
|
|
body = (
|
|
<form onSubmit={regenerate} noValidate className="grid max-w-md gap-1">
|
|
<p className="mb-1 text-caption text-muted-foreground">旧的恢复码会全部作废。</p>
|
|
{passwordField}
|
|
{error ? <p className="text-meta text-destructive">{error}</p> : null}
|
|
<div className="flex gap-2">
|
|
<Button type="submit" size="sm" disabled={action.isPending}>
|
|
{spinner}
|
|
重新生成
|
|
</Button>
|
|
{cancel}
|
|
</div>
|
|
</form>
|
|
);
|
|
else if (enabled)
|
|
body = (
|
|
<div className="grid justify-items-start gap-3">
|
|
<p className={left <= 2 ? "text-caption text-warning" : "text-caption text-muted-foreground"}>
|
|
还剩 {left} 个恢复码(共 {RECOVERY_CODE_COUNT} 个)。{left <= 2 ? "快用完了,重新生成一组。" : ""}
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button size="sm" variant="outline" onClick={() => reset("regenerate")}>
|
|
重新生成恢复码
|
|
</Button>
|
|
<Button size="sm" variant="ghost" onClick={() => reset("disable")}>
|
|
关闭二次验证
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
else
|
|
body = (
|
|
<div className="grid justify-items-start gap-2">
|
|
<Button size="sm" onClick={() => reset("setup")} disabled={status.isPending}>
|
|
开启二次验证
|
|
</Button>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<SettingsBlock
|
|
section="account"
|
|
anchor="totp"
|
|
title="二次验证(TOTP)"
|
|
description={
|
|
enabled
|
|
? "登录时除了密码还要输入验证器里的 6 位验证码。关闭需要当前密码。"
|
|
: "开启后登录还要输入验证器里的 6 位验证码,同时生成 8 个一次性恢复码。"
|
|
}
|
|
actions={
|
|
disabled || status.isPending ? null : (
|
|
<StatusBadge tone={enabled ? "success" : "neutral"}>{enabled ? "已开启" : "未开启"}</StatusBadge>
|
|
)
|
|
}
|
|
>
|
|
{body}
|
|
</SettingsBlock>
|
|
);
|
|
}
|