"use client" import * as React from "react" import { useLocale, useTranslations } from "next-intl" import { toast } from "sonner" import { ShieldCheck, Smartphone, Loader2, LogIn, LogOut, UserPlus, AlertCircle, LogOutIcon, KeyRound, Copy, Check, RefreshCw, } from "lucide-react" import { disableTwoFactorAction, getSecurityCenterAction, regenerateBackupCodesAction, revokeAllOtherSessionsAction, setupTwoFactorAction, verifyTwoFactorAction, type LoginHistoryItem, type TwoFactorSetupData, type TwoFactorStatus, } from "@/modules/settings/actions-security" import { formatRelativeTime, parseUserAgent, } from "@/modules/settings/lib/security-utils" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" import { Input } from "@/shared/components/ui/input" import { Label } from "@/shared/components/ui/label" import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/shared/components/ui/card" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/shared/components/ui/dialog" interface SecurityCenterCardProps { /** 当前会话的 user agent,用于标记当前会话 */ currentDeviceLabel?: string } const ACTION_ICON_MAP: Record = { signin: , signout: , signup: , } type SetupStep = "idle" | "qr" | "backup" /** * 安全中心卡片 * * 提供: * - 2FA TOTP 完整流程(启用 / 关闭 / 重新生成备份码) * - 最近登录历史(最近 10 条,来自 login_logs 表) * - 远程登出其他会话 */ export function SecurityCenterCard({ currentDeviceLabel, }: SecurityCenterCardProps): React.ReactElement { const t = useTranslations("settings.security.center") const locale = useLocale() const [twoFactor, setTwoFactor] = React.useState(null) const [recentLogins, setRecentLogins] = React.useState([]) const [loading, setLoading] = React.useState(true) const [revoking, setRevoking] = React.useState(false) // 启用 2FA Dialog 状态 const [enableDialogOpen, setEnableDialogOpen] = React.useState(false) const [setupStep, setSetupStep] = React.useState("idle") const [setupData, setSetupData] = React.useState(null) const [verifyCode, setVerifyCode] = React.useState("") const [backupCodes, setBackupCodes] = React.useState([]) const [setupLoading, setSetupLoading] = React.useState(false) const [copied, setCopied] = React.useState(false) // 关闭 2FA Dialog 状态 const [disableDialogOpen, setDisableDialogOpen] = React.useState(false) const [disableCode, setDisableCode] = React.useState("") const [disableLoading, setDisableLoading] = React.useState(false) // 重新生成备份码 Dialog 状态 const [regenDialogOpen, setRegenDialogOpen] = React.useState(false) const [regenCode, setRegenCode] = React.useState("") const [regenLoading, setRegenLoading] = React.useState(false) const [regenBackupCodes, setRegenBackupCodes] = React.useState([]) React.useEffect(() => { let cancelled = false async function load(): Promise { try { const result = await getSecurityCenterAction() if (!cancelled && result.success && result.data) { setTwoFactor(result.data.twoFactor) setRecentLogins(result.data.recentLogins) } } catch { // 加载失败时静默处理 } finally { if (!cancelled) setLoading(false) } } void load() return () => { cancelled = true } }, []) // --- 启用 2FA 流程 --- const handleEnable2FA = async (): Promise => { setEnableDialogOpen(true) setSetupStep("idle") setSetupData(null) setVerifyCode("") setBackupCodes([]) setSetupLoading(true) try { const result = await setupTwoFactorAction() if (result.success && result.data) { setSetupData(result.data) setSetupStep("qr") } else { toast.error(result.message || t("twoFactor.setupFailure")) setEnableDialogOpen(false) } } catch { toast.error(t("twoFactor.setupFailure")) setEnableDialogOpen(false) } finally { setSetupLoading(false) } } const handleVerifySetup = async (): Promise => { if (!verifyCode.trim()) return setSetupLoading(true) try { const result = await verifyTwoFactorAction(verifyCode.trim()) if (result.success && result.data) { setBackupCodes(result.data.backupCodes) setTwoFactor(result.data.status) setSetupStep("backup") toast.success(t("twoFactor.enableSuccess")) } else { toast.error(result.message || t("twoFactor.invalidCode")) } } catch { toast.error(t("twoFactor.verifyFailure")) } finally { setSetupLoading(false) } } const handleCopyBackupCodes = async (): Promise => { try { await navigator.clipboard.writeText(backupCodes.join("\n")) setCopied(true) setTimeout(() => setCopied(false), 2000) } catch { // 剪贴板不可用时静默 } } const handleCloseEnableDialog = (): void => { setEnableDialogOpen(false) setSetupStep("idle") setSetupData(null) setVerifyCode("") setBackupCodes([]) } // --- 关闭 2FA 流程 --- const handleDisable2FA = async (): Promise => { if (!disableCode.trim()) return setDisableLoading(true) try { const result = await disableTwoFactorAction(disableCode.trim()) if (result.success && result.data) { setTwoFactor(result.data) setDisableDialogOpen(false) setDisableCode("") toast.success(t("twoFactor.disableSuccess")) } else { toast.error(result.message || t("twoFactor.invalidCode")) } } catch { toast.error(t("twoFactor.disableFailure")) } finally { setDisableLoading(false) } } // --- 重新生成备份码 --- const handleRegenerateBackupCodes = async (): Promise => { if (!regenCode.trim()) return setRegenLoading(true) try { const result = await regenerateBackupCodesAction(regenCode.trim()) if (result.success && result.data) { setRegenBackupCodes(result.data.backupCodes) setTwoFactor(result.data.status) setRegenCode("") toast.success(t("twoFactor.regenerateSuccess")) } else { toast.error(result.message || t("twoFactor.invalidCode")) } } catch { toast.error(t("twoFactor.regenerateFailure")) } finally { setRegenLoading(false) } } const handleRegenDialogOpen = (): void => { setRegenDialogOpen(true) setRegenCode("") setRegenBackupCodes([]) } // --- 远程登出 --- const handleRevokeAllSessions = async (): Promise => { setRevoking(true) try { const result = await revokeAllOtherSessionsAction() if (result.success && result.data) { if (result.data.revokedCount > 0) { toast.success(t("recentLogins.revokeSuccess", { count: result.data.revokedCount })) } else { toast.info(t("recentLogins.revokeSuccessEmpty")) } const refreshed = await getSecurityCenterAction() if (refreshed.success && refreshed.data) { setRecentLogins(refreshed.data.recentLogins) } } else { toast.error(result.message || t("recentLogins.revokeFailure")) } } catch { toast.error(t("recentLogins.revokeFailure")) } finally { setRevoking(false) } } return (
{t("title")} {t("description")}
{/* 2FA 区域 */}
{t("twoFactor.title")}

{t("twoFactor.description")}

{twoFactor?.enabled ? (
{t("twoFactor.enabled")} {t("twoFactor.backupRemaining", { count: twoFactor.backupCodesRemaining })}
) : null}
{loading ? ( ) : twoFactor?.enabled ? ( ) : ( )}
{twoFactor?.enabled ? (
{t("twoFactor.backupCodes")}

{t("twoFactor.backupHint")}

) : (

{t("twoFactor.hint")}

)}
{/* 最近登录历史 */}

{t("recentLogins.title")}

{recentLogins.length > 0 ? ( {t("recentLogins.showingLatest", { count: recentLogins.length })} ) : null} {!loading && recentLogins.length > 0 ? ( ) : null}
{loading ? (
) : recentLogins.length === 0 ? (
{t("recentLogins.empty")}
) : (
    {recentLogins.map((item) => { const { device, browser } = parseUserAgent(item.userAgent) const isCurrent = currentDeviceLabel ? item.userAgent?.includes(currentDeviceLabel) : false return (
  • {ACTION_ICON_MAP[item.action]}
    {t(`recentLogins.actions.${item.action}`)} {item.status === "failure" ? ( {t("recentLogins.failed")} ) : null} {isCurrent ? ( {t("recentLogins.current")} ) : null}
    {device} · {browser} {item.ipAddress ? ` · ${item.ipAddress}` : ""}
  • ) })}
)}
{/* 启用 2FA Dialog */} { if (!o) handleCloseEnableDialog() }}> {t("twoFactor.title")} {t("twoFactor.description")} {setupStep === "qr" && setupData ? (
{/* eslint-disable-next-line @next/next/no-img-element */} 2FA QR Code

{t("twoFactor.scanQr")}

{setupData.secret}
setVerifyCode(e.target.value)} disabled={setupLoading} autoFocus />
) : null} {setupStep === "backup" ? (

{t("twoFactor.backupWarning")}

{backupCodes.map((code, i) => ( {code} ))}
) : null} {setupStep === "idle" ? (
) : null}
{/* 关闭 2FA Dialog */} {t("twoFactor.disableTitle")} {t("twoFactor.disableDescription")}
setDisableCode(e.target.value)} disabled={disableLoading} autoFocus />
{/* 重新生成备份码 Dialog */} {t("twoFactor.regenerateTitle")} {t("twoFactor.regenerateDescription")} {regenBackupCodes.length === 0 ? ( <>
setRegenCode(e.target.value)} disabled={regenLoading} autoFocus />
) : (

{t("twoFactor.backupWarning")}

{regenBackupCodes.map((code, i) => ( {code} ))}
)}
) }