feat(web): TOTP QR code and recovery codes, passkey management and passkey login
- Account & security: TOTP setup renders the otpauth URL as a QR code (qr-code-styling) with the secret as a fallback, shows the 8 recovery codes once after enabling (copy / download), and offers regenerate and disable (verification code or recovery code) when enabled. - New Passkey block: add (named, @simplewebauthn/browser), rename, delete; hidden outside a secure context. - Login page: "用 Passkey 登录" when a passkey is registered and the browser supports it; the TOTP step can switch to a recovery code. - Account menu links to the settings block instead of its own TOTP dialog, so enabling always shows the recovery codes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b0e5d539ed
commit
dd0167fe85
6 files changed
+739
-308
No files matched your search
@@ -1,19 +1,20 @@
|
||||
import { PasswordChangeRequest, TotpCode } from "@umt/contracts";
|
||||
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, useTotpAction, useTotpStatus } from "~/api/auth.ts";
|
||||
import { useChangePassword, useLogout, useSession } from "~/api/auth.ts";
|
||||
import { AgentShortcutsHelp } from "~/components/console/agent-launcher.tsx";
|
||||
import { FormField, zodFieldErrors } 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 { 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 { anchorId, SettingsBlock } from "./settings-block.tsx";
|
||||
|
||||
// 账户与安全:改密、二次验证(TOTP)、当前会话与本机 Token、快捷键
|
||||
// 账户与安全:改密、二次验证(TOTP,account-totp.tsx)、Passkey(account-passkeys.tsx)、当前会话与本机 Token、快捷键
|
||||
|
||||
const PasswordForm = PasswordChangeRequest.extend({ confirm: z.string() }).refine((v) => v.newPassword === v.confirm, {
|
||||
message: "两次输入的新密码不一致",
|
||||
@@ -103,136 +104,6 @@ function PasswordBlock({ disabled }: { disabled: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TotpBlock({ disabled }: { disabled: boolean }) {
|
||||
const status = useTotpStatus(!disabled);
|
||||
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<string | null>(null);
|
||||
const enabled = status.data?.enabled === true;
|
||||
|
||||
const reset = () => {
|
||||
setSetup(null);
|
||||
setCode("");
|
||||
setPassword("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
if (enabled && !password) {
|
||||
setError("输入当前密码");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
if (enabled) {
|
||||
await action.mutateAsync({ action: "disable", password, code });
|
||||
toast.success("二次验证已关闭");
|
||||
} else {
|
||||
await action.mutateAsync({ action: "enable", code });
|
||||
toast.success("二次验证已开启");
|
||||
}
|
||||
reset();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const grouped = setup?.secret.replace(/(.{4})/g, "$1 ").trim();
|
||||
|
||||
return (
|
||||
<SettingsBlock
|
||||
section="account"
|
||||
anchor="totp"
|
||||
title="二次验证(TOTP)"
|
||||
description={enabled ? "关闭需要当前密码和验证码。" : "开启后登录还要输入验证器里的 6 位验证码。"}
|
||||
actions={
|
||||
disabled ? null : status.isPending ? null : (
|
||||
<StatusBadge tone={enabled ? "success" : "neutral"}>{enabled ? "已开启" : "未开启"}</StatusBadge>
|
||||
)
|
||||
}
|
||||
>
|
||||
{disabled ? (
|
||||
<p className="text-caption text-muted-foreground">当前用 API Key 访问,登录会话才能修改。</p>
|
||||
) : !enabled && !setup ? (
|
||||
<div className="grid justify-items-start gap-2">
|
||||
{status.data?.pending ? (
|
||||
<p className="text-meta text-muted-foreground">有一个未确认的密钥,重新生成会作废它。</p>
|
||||
) : null}
|
||||
{error ? <p className="text-meta text-destructive">{error}</p> : null}
|
||||
<Button size="sm" onClick={begin} disabled={action.isPending || status.isPending}>
|
||||
{action.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
生成密钥
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} noValidate className="grid max-w-md gap-1">
|
||||
{setup ? (
|
||||
<div className="mb-2 grid gap-1.5">
|
||||
<p className="text-meta text-muted-foreground">
|
||||
在验证器里手动输入密钥,或粘贴 otpauth 链接。密钥只显示这一次。
|
||||
</p>
|
||||
<code className="rounded-md bg-muted px-2 py-1.5 font-mono text-sm break-all select-all">{grouped}</code>
|
||||
<code className="rounded-md bg-muted px-2 py-1.5 font-mono text-meta break-all text-muted-foreground select-all">
|
||||
{setup.uri}
|
||||
</code>
|
||||
</div>
|
||||
) : null}
|
||||
{enabled ? (
|
||||
<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>
|
||||
) : null}
|
||||
<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}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" variant={enabled ? "destructive" : "default"} disabled={action.isPending}>
|
||||
{action.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
{enabled ? "关闭二次验证" : "确认开启"}
|
||||
</Button>
|
||||
{setup ? (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={reset} disabled={action.isPending}>
|
||||
取消
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</SettingsBlock>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionBlock() {
|
||||
const session = useSession();
|
||||
const logout = useLogout();
|
||||
@@ -321,6 +192,7 @@ export function AccountSection() {
|
||||
<div className="grid gap-8">
|
||||
<PasswordBlock disabled={!cookie} />
|
||||
<TotpBlock disabled={!cookie} />
|
||||
<PasskeyBlock disabled={!cookie} />
|
||||
<SessionBlock />
|
||||
{/* 快捷键说明自带标题,只包卡片与锚点 */}
|
||||
<section id={anchorId("account", "shortcuts")} className="pg-slab min-w-0 scroll-mt-24 rounded-xl p-5">
|
||||
|
||||
Reference in new issue
Block a user