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:
SpecialX
2026-06-23 17:37:06 +08:00
parent 242a770cc9
commit 1fcef5c3aa
22 changed files with 3091 additions and 52 deletions

View 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>
)
}