From ba1361a9b6af2dce9eddf53db2741e69abe43fd8 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 00:46:55 +0800 Subject: [PATCH] feat(web): S-3 TOTP entry in the account popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Account popover (cookie sessions) gets a "二次验证" item showing the current state; the dialog wires GET/POST /auth/totp: begin shows the one-time base32 secret (grouped) and otpauth URI for manual entry, enable confirms with a 6-digit code, disable takes current password + code. No QR image (would need a new dependency); session query is invalidated so the state label updates. Co-Authored-By: Claude Opus 5.5 --- .../app/components/console/account-menu.tsx | 172 +++++++++++++++++- 1 file changed, 168 insertions(+), 4 deletions(-) diff --git a/apps/web/app/components/console/account-menu.tsx b/apps/web/app/components/console/account-menu.tsx index 2085e31b..df33927e 100644 --- a/apps/web/app/components/console/account-menu.tsx +++ b/apps/web/app/components/console/account-menu.tsx @@ -1,9 +1,9 @@ -import { PasswordChangeRequest, type SessionInfo } from "@umt/contracts"; -import { KeyRound, LoaderCircle, LogOut, UserRound } from "lucide-react"; +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 } from "~/api/auth.ts"; +import { useChangePassword, useLogout, useTotpAction, useTotpStatus } from "~/api/auth.ts"; import { Button } from "~/components/ui/button.tsx"; import { Dialog, @@ -105,10 +105,157 @@ function ChangePasswordDialog({ open, onOpenChange }: { open: boolean; onOpenCha ); } -/** 右上角账户弹层:当前身份、会话到期、改密、登出 */ +/** 二次验证(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; @@ -154,6 +301,22 @@ export function AccountMenu({ session }: { session: SessionInfo }) { 修改密码 ) : null} + {isCookie ? ( + + ) : null}