- Add TOTP implementation and two-factor data-access for 2FA enrollment - Add security center card with password policy and session management - Add avatar upload action and component - Add system settings actions and data-access (actions-system-settings, data-access-system-settings) - Add notification preferences and service actions - Add security-utils and student-overview-data with tests - Update existing settings views, data-access, and types for new features
646 lines
23 KiB
TypeScript
646 lines
23 KiB
TypeScript
"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<LoginHistoryItem["action"], React.ReactNode> = {
|
||
signin: <LogIn className="h-4 w-4" />,
|
||
signout: <LogOut className="h-4 w-4" />,
|
||
signup: <UserPlus className="h-4 w-4" />,
|
||
}
|
||
|
||
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<TwoFactorStatus | null>(null)
|
||
const [recentLogins, setRecentLogins] = React.useState<LoginHistoryItem[]>([])
|
||
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<SetupStep>("idle")
|
||
const [setupData, setSetupData] = React.useState<TwoFactorSetupData | null>(null)
|
||
const [verifyCode, setVerifyCode] = React.useState("")
|
||
const [backupCodes, setBackupCodes] = React.useState<string[]>([])
|
||
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<string[]>([])
|
||
|
||
React.useEffect(() => {
|
||
let cancelled = false
|
||
async function load(): Promise<void> {
|
||
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<void> => {
|
||
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<void> => {
|
||
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<void> => {
|
||
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<void> => {
|
||
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<void> => {
|
||
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<void> => {
|
||
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 (
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center gap-2">
|
||
<ShieldCheck className="h-5 w-5 text-primary" />
|
||
<div>
|
||
<CardTitle className="text-base">{t("title")}</CardTitle>
|
||
<CardDescription>{t("description")}</CardDescription>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
{/* 2FA 区域 */}
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||
<div className="flex items-start gap-3">
|
||
<Smartphone className="mt-0.5 h-5 w-5 text-muted-foreground" />
|
||
<div className="space-y-0.5">
|
||
<div className="text-sm font-medium">{t("twoFactor.title")}</div>
|
||
<p className="text-sm text-muted-foreground">
|
||
{t("twoFactor.description")}
|
||
</p>
|
||
{twoFactor?.enabled ? (
|
||
<div className="mt-1 flex items-center gap-2">
|
||
<Badge variant="secondary">{t("twoFactor.enabled")}</Badge>
|
||
<span className="text-xs text-muted-foreground">
|
||
{t("twoFactor.backupRemaining", { count: twoFactor.backupCodesRemaining })}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
{loading ? (
|
||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||
) : twoFactor?.enabled ? (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setDisableDialogOpen(true)}
|
||
>
|
||
{t("twoFactor.disable")}
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
onClick={handleEnable2FA}
|
||
>
|
||
{t("twoFactor.enable")}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{twoFactor?.enabled ? (
|
||
<div className="flex items-center justify-between rounded-lg border border-dashed p-3">
|
||
<div className="flex items-start gap-2">
|
||
<KeyRound className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||
<div>
|
||
<div className="text-xs font-medium">{t("twoFactor.backupCodes")}</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("twoFactor.backupHint")}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={handleRegenDialogOpen}
|
||
className="h-7 gap-1.5 text-xs"
|
||
>
|
||
<RefreshCw className="h-3.5 w-3.5" />
|
||
{t("twoFactor.regenerate")}
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<p className="flex items-start gap-1.5 text-xs text-muted-foreground">
|
||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||
{t("twoFactor.hint")}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 最近登录历史 */}
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<h4 className="text-sm font-medium">{t("recentLogins.title")}</h4>
|
||
<div className="flex items-center gap-2">
|
||
{recentLogins.length > 0 ? (
|
||
<span className="text-xs text-muted-foreground">
|
||
{t("recentLogins.showingLatest", { count: recentLogins.length })}
|
||
</span>
|
||
) : null}
|
||
{!loading && recentLogins.length > 0 ? (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={handleRevokeAllSessions}
|
||
disabled={revoking}
|
||
className="h-7 gap-1.5 text-xs"
|
||
>
|
||
{revoking ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<LogOutIcon className="h-3.5 w-3.5" />
|
||
)}
|
||
{revoking ? t("recentLogins.revoking") : t("recentLogins.revokeAll")}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-6">
|
||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : recentLogins.length === 0 ? (
|
||
<div className="rounded-md border border-dashed py-6 text-center text-sm text-muted-foreground">
|
||
{t("recentLogins.empty")}
|
||
</div>
|
||
) : (
|
||
<ul className="divide-y rounded-md border">
|
||
{recentLogins.map((item) => {
|
||
const { device, browser } = parseUserAgent(item.userAgent)
|
||
const isCurrent = currentDeviceLabel
|
||
? item.userAgent?.includes(currentDeviceLabel)
|
||
: false
|
||
return (
|
||
<li
|
||
key={item.id}
|
||
className="flex items-center gap-3 px-3 py-2.5 text-sm"
|
||
>
|
||
<span
|
||
className={
|
||
item.status === "success"
|
||
? "text-green-600"
|
||
: "text-red-600"
|
||
}
|
||
>
|
||
{ACTION_ICON_MAP[item.action]}
|
||
</span>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium">
|
||
{t(`recentLogins.actions.${item.action}`)}
|
||
</span>
|
||
{item.status === "failure" ? (
|
||
<Badge variant="destructive" className="text-xs">
|
||
{t("recentLogins.failed")}
|
||
</Badge>
|
||
) : null}
|
||
{isCurrent ? (
|
||
<Badge variant="outline" className="text-xs">
|
||
{t("recentLogins.current")}
|
||
</Badge>
|
||
) : null}
|
||
</div>
|
||
<div className="text-xs text-muted-foreground truncate">
|
||
{device} · {browser}
|
||
{item.ipAddress ? ` · ${item.ipAddress}` : ""}
|
||
</div>
|
||
</div>
|
||
<time className="text-xs text-muted-foreground whitespace-nowrap">
|
||
{formatRelativeTime(item.createdAt, locale)}
|
||
</time>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
|
||
{/* 启用 2FA Dialog */}
|
||
<Dialog open={enableDialogOpen} onOpenChange={(o) => { if (!o) handleCloseEnableDialog() }}>
|
||
<DialogContent className="max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>{t("twoFactor.title")}</DialogTitle>
|
||
<DialogDescription>{t("twoFactor.description")}</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{setupStep === "qr" && setupData ? (
|
||
<div className="space-y-4">
|
||
<div className="flex flex-col items-center gap-3">
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={setupData.qrCodeDataUrl}
|
||
alt="2FA QR Code"
|
||
className="rounded-md border"
|
||
width={240}
|
||
height={240}
|
||
/>
|
||
<p className="text-center text-sm text-muted-foreground">
|
||
{t("twoFactor.scanQr")}
|
||
</p>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">{t("twoFactor.manualEntry")}</Label>
|
||
<code className="block rounded-md bg-muted p-2 text-xs break-all">
|
||
{setupData.secret}
|
||
</code>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="verifyCode">{t("twoFactor.enterCode")}</Label>
|
||
<Input
|
||
id="verifyCode"
|
||
type="text"
|
||
inputMode="numeric"
|
||
autoComplete="one-time-code"
|
||
placeholder="123456"
|
||
maxLength={6}
|
||
value={verifyCode}
|
||
onChange={(e) => setVerifyCode(e.target.value)}
|
||
disabled={setupLoading}
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={handleCloseEnableDialog} disabled={setupLoading}>
|
||
{t("twoFactor.cancel")}
|
||
</Button>
|
||
<Button onClick={handleVerifySetup} disabled={setupLoading || !verifyCode.trim()}>
|
||
{setupLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||
{t("twoFactor.verify")}
|
||
</Button>
|
||
</DialogFooter>
|
||
</div>
|
||
) : null}
|
||
|
||
{setupStep === "backup" ? (
|
||
<div className="space-y-4">
|
||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
|
||
<p className="flex items-start gap-1.5 text-xs text-amber-800 dark:text-amber-200">
|
||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||
{t("twoFactor.backupWarning")}
|
||
</p>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<Label>{t("twoFactor.backupCodes")}</Label>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={handleCopyBackupCodes}
|
||
className="h-7 gap-1.5 text-xs"
|
||
>
|
||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||
{copied ? t("twoFactor.copied") : t("twoFactor.copy")}
|
||
</Button>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2 rounded-md border p-3">
|
||
{backupCodes.map((code, i) => (
|
||
<code key={i} className="text-sm font-mono">
|
||
{code}
|
||
</code>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button onClick={handleCloseEnableDialog}>
|
||
{t("twoFactor.done")}
|
||
</Button>
|
||
</DialogFooter>
|
||
</div>
|
||
) : null}
|
||
|
||
{setupStep === "idle" ? (
|
||
<div className="flex items-center justify-center py-8">
|
||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : null}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 关闭 2FA Dialog */}
|
||
<Dialog open={disableDialogOpen} onOpenChange={setDisableDialogOpen}>
|
||
<DialogContent className="max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>{t("twoFactor.disableTitle")}</DialogTitle>
|
||
<DialogDescription>{t("twoFactor.disableDescription")}</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="disableCode">{t("twoFactor.enterCodeDisable")}</Label>
|
||
<Input
|
||
id="disableCode"
|
||
type="text"
|
||
inputMode="numeric"
|
||
autoComplete="one-time-code"
|
||
placeholder="123456"
|
||
maxLength={8}
|
||
value={disableCode}
|
||
onChange={(e) => setDisableCode(e.target.value)}
|
||
disabled={disableLoading}
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setDisableDialogOpen(false)} disabled={disableLoading}>
|
||
{t("twoFactor.cancel")}
|
||
</Button>
|
||
<Button
|
||
variant="destructive"
|
||
onClick={handleDisable2FA}
|
||
disabled={disableLoading || !disableCode.trim()}
|
||
>
|
||
{disableLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||
{t("twoFactor.disable")}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 重新生成备份码 Dialog */}
|
||
<Dialog open={regenDialogOpen} onOpenChange={setRegenDialogOpen}>
|
||
<DialogContent className="max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>{t("twoFactor.regenerateTitle")}</DialogTitle>
|
||
<DialogDescription>{t("twoFactor.regenerateDescription")}</DialogDescription>
|
||
</DialogHeader>
|
||
{regenBackupCodes.length === 0 ? (
|
||
<>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="regenCode">{t("twoFactor.enterCodeRegen")}</Label>
|
||
<Input
|
||
id="regenCode"
|
||
type="text"
|
||
inputMode="numeric"
|
||
autoComplete="one-time-code"
|
||
placeholder="123456"
|
||
maxLength={6}
|
||
value={regenCode}
|
||
onChange={(e) => setRegenCode(e.target.value)}
|
||
disabled={regenLoading}
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setRegenDialogOpen(false)} disabled={regenLoading}>
|
||
{t("twoFactor.cancel")}
|
||
</Button>
|
||
<Button
|
||
onClick={handleRegenerateBackupCodes}
|
||
disabled={regenLoading || !regenCode.trim()}
|
||
>
|
||
{regenLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||
{t("twoFactor.regenerate")}
|
||
</Button>
|
||
</DialogFooter>
|
||
</>
|
||
) : (
|
||
<div className="space-y-4">
|
||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
|
||
<p className="flex items-start gap-1.5 text-xs text-amber-800 dark:text-amber-200">
|
||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||
{t("twoFactor.backupWarning")}
|
||
</p>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2 rounded-md border p-3">
|
||
{regenBackupCodes.map((code, i) => (
|
||
<code key={i} className="text-sm font-mono">
|
||
{code}
|
||
</code>
|
||
))}
|
||
</div>
|
||
<DialogFooter>
|
||
<Button onClick={() => setRegenDialogOpen(false)}>
|
||
{t("twoFactor.done")}
|
||
</Button>
|
||
</DialogFooter>
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
</Card>
|
||
)
|
||
}
|