feat(settings): add security center, 2FA/TOTP, avatar upload, system settings
- 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
This commit is contained in:
645
src/modules/settings/components/security-center-card.tsx
Normal file
645
src/modules/settings/components/security-center-card.tsx
Normal file
@@ -0,0 +1,645 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user