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:
111
src/modules/settings/actions-avatar.ts
Normal file
111
src/modules/settings/actions-avatar.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { unlink } from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
|
||||||
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { getUserProfile, updateUserAvatar } from "@/modules/users/data-access"
|
||||||
|
import {
|
||||||
|
deleteFileAttachment,
|
||||||
|
getFileByUrl,
|
||||||
|
} from "@/modules/files/data-access"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理旧头像文件(磁盘 + DB 记录)
|
||||||
|
* 静默失败,不影响主流程
|
||||||
|
*/
|
||||||
|
async function cleanupOldAvatarFile(oldImageUrl: string | null): Promise<void> {
|
||||||
|
if (!oldImageUrl) return
|
||||||
|
try {
|
||||||
|
const fileRecord = await getFileByUrl(oldImageUrl)
|
||||||
|
if (!fileRecord) return
|
||||||
|
|
||||||
|
// 删除磁盘文件
|
||||||
|
const absolutePath = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
"public",
|
||||||
|
fileRecord.storagePath,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
await unlink(absolutePath)
|
||||||
|
} catch {
|
||||||
|
// 文件可能已不存在,忽略错误
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除 DB 记录
|
||||||
|
await deleteFileAttachment(fileRecord.id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("cleanupOldAvatarFile failed:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户头像 URL
|
||||||
|
*
|
||||||
|
* 实际文件上传通过 /api/upload 路由完成,此 action 仅更新 users.image 字段。
|
||||||
|
* 更新成功后会清理旧头像文件(磁盘 + DB 记录)。
|
||||||
|
*/
|
||||||
|
export async function updateUserAvatarAction(
|
||||||
|
imageUrl: string
|
||||||
|
): Promise<ActionState<{ image: string }>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.USER_PROFILE_UPDATE)
|
||||||
|
|
||||||
|
// 获取旧头像 URL,用于后续清理
|
||||||
|
const oldUser = await getUserProfile(ctx.userId)
|
||||||
|
const oldImageUrl = oldUser?.image ?? null
|
||||||
|
|
||||||
|
const updated = await updateUserAvatar(ctx.userId, imageUrl)
|
||||||
|
if (!updated) {
|
||||||
|
return { success: false, message: "User not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新成功后清理旧头像文件(仅当新旧 URL 不同时)
|
||||||
|
if (oldImageUrl && oldImageUrl !== imageUrl) {
|
||||||
|
await cleanupOldAvatarFile(oldImageUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/profile")
|
||||||
|
revalidatePath("/settings")
|
||||||
|
|
||||||
|
return { success: true, data: { image: updated.image ?? imageUrl } }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to update avatar"
|
||||||
|
return { success: false, message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除用户头像
|
||||||
|
* 同时清理旧头像文件(磁盘 + DB 记录)
|
||||||
|
*/
|
||||||
|
export async function removeUserAvatarAction(): Promise<ActionState<null>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.USER_PROFILE_UPDATE)
|
||||||
|
|
||||||
|
// 获取旧头像 URL,用于后续清理
|
||||||
|
const oldUser = await getUserProfile(ctx.userId)
|
||||||
|
const oldImageUrl = oldUser?.image ?? null
|
||||||
|
|
||||||
|
const updated = await updateUserAvatar(ctx.userId, null)
|
||||||
|
if (!updated) {
|
||||||
|
return { success: false, message: "User not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新成功后清理旧头像文件
|
||||||
|
if (oldImageUrl) {
|
||||||
|
await cleanupOldAvatarFile(oldImageUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/profile")
|
||||||
|
revalidatePath("/settings")
|
||||||
|
|
||||||
|
return { success: true, data: null }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to remove avatar"
|
||||||
|
return { success: false, message }
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/modules/settings/actions-notifications.ts
Normal file
61
src/modules/settings/actions-notifications.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { sendNotification } from "@/modules/notifications/dispatcher"
|
||||||
|
import type { NotificationChannel } from "@/modules/notifications/types"
|
||||||
|
|
||||||
|
const TestNotificationSchema = z.object({
|
||||||
|
channel: z.enum(["push", "email", "sms"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
type TestNotificationInput = z.infer<typeof TestNotificationSchema>
|
||||||
|
|
||||||
|
/** 将表单渠道名映射到 dispatcher 渠道名 */
|
||||||
|
const CHANNEL_MAP: Record<TestNotificationInput["channel"], NotificationChannel> = {
|
||||||
|
push: "in_app",
|
||||||
|
email: "email",
|
||||||
|
sms: "sms",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送测试通知
|
||||||
|
*
|
||||||
|
* 向当前用户发送一条测试通知,用于验证通知渠道是否配置正确。
|
||||||
|
* 调用 notifications/dispatcher.sendNotification 发送真实通知。
|
||||||
|
*/
|
||||||
|
export async function sendTestNotificationAction(
|
||||||
|
input: TestNotificationInput
|
||||||
|
): Promise<ActionState<null>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.USER_PROFILE_UPDATE)
|
||||||
|
|
||||||
|
const parsed = TestNotificationSchema.parse(input)
|
||||||
|
const targetChannel = CHANNEL_MAP[parsed.channel]
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
userId: ctx.userId,
|
||||||
|
title: "Test Notification",
|
||||||
|
content: `This is a test notification sent via the ${parsed.channel} channel at ${new Date().toISOString()}.`,
|
||||||
|
type: "info" as const,
|
||||||
|
metadata: { test: true, channel: parsed.channel },
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await sendNotification(payload)
|
||||||
|
|
||||||
|
// 检查目标渠道的发送结果
|
||||||
|
const targetResult = results.find((r) => r.channel === targetChannel)
|
||||||
|
if (!targetResult || !targetResult.success) {
|
||||||
|
const errorMsg = targetResult?.error ?? `Failed to send via ${parsed.channel} channel`
|
||||||
|
return { success: false, message: errorMsg, data: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: null }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to send test notification"
|
||||||
|
return { success: false, message }
|
||||||
|
}
|
||||||
|
}
|
||||||
486
src/modules/settings/actions-security.ts
Normal file
486
src/modules/settings/actions-security.ts
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
import { desc, eq } from "drizzle-orm"
|
||||||
|
|
||||||
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { loginLogs, sessions } from "@/shared/db/schema"
|
||||||
|
import { logLoginEvent } from "@/shared/lib/login-logger"
|
||||||
|
import { getUserProfile } from "@/modules/users/data-access"
|
||||||
|
|
||||||
|
import {
|
||||||
|
getSystemSettingsByCategory,
|
||||||
|
} from "./data-access-system-settings"
|
||||||
|
import {
|
||||||
|
deleteBackupCodes,
|
||||||
|
deleteTotpSecret,
|
||||||
|
getBackupCodesHashed,
|
||||||
|
getTotpSecret,
|
||||||
|
getTwoFactorEnabled,
|
||||||
|
setBackupCodesHashed,
|
||||||
|
setTotpSecret,
|
||||||
|
setTwoFactorEnabled,
|
||||||
|
setTwoFactorEnabledAt,
|
||||||
|
} from "./data-access-two-factor"
|
||||||
|
import {
|
||||||
|
buildOtpAuthUrl,
|
||||||
|
consumeBackupCode,
|
||||||
|
countRemainingBackupCodes,
|
||||||
|
generateBackupCodes,
|
||||||
|
generateQrCodeDataUrl,
|
||||||
|
generateTotpSecret,
|
||||||
|
hashBackupCodes,
|
||||||
|
verifyBackupCode,
|
||||||
|
verifyTotpCode,
|
||||||
|
} from "./lib/totp"
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
export interface TwoFactorStatus {
|
||||||
|
enabled: boolean
|
||||||
|
/** 2FA 方式:totp / sms / email,当前仅支持 totp */
|
||||||
|
method: "totp" | "sms" | "email"
|
||||||
|
/** 启用时间 ISO 字符串 */
|
||||||
|
enabledAt: string | null
|
||||||
|
/** 剩余备份码数量 */
|
||||||
|
backupCodesRemaining: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginHistoryItem {
|
||||||
|
id: string
|
||||||
|
action: "signin" | "signout" | "signup"
|
||||||
|
status: "success" | "failure"
|
||||||
|
ipAddress: string | null
|
||||||
|
userAgent: string | null
|
||||||
|
errorMessage: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecurityCenterData {
|
||||||
|
twoFactor: TwoFactorStatus
|
||||||
|
recentLogins: LoginHistoryItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** setup 阶段返回:QR 码 + 密钥(明文,供手动输入) */
|
||||||
|
export interface TwoFactorSetupData {
|
||||||
|
qrCodeDataUrl: string
|
||||||
|
secret: string
|
||||||
|
otpauthUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Constants ---
|
||||||
|
|
||||||
|
const TWO_FACTOR_CATEGORY = "security_policy" as const
|
||||||
|
const SERVICE_NAME = "Next_Edu"
|
||||||
|
|
||||||
|
// --- 2FA Helpers ---
|
||||||
|
|
||||||
|
async function getTwoFactorStatus(userId: string): Promise<TwoFactorStatus> {
|
||||||
|
// 一次查询 security_policy 分类下所有设置,在内存中过滤当前用户的键(避免 N+1)
|
||||||
|
const allSettings = await getSystemSettingsByCategory(TWO_FACTOR_CATEGORY)
|
||||||
|
const userPrefix = `:${userId}`
|
||||||
|
|
||||||
|
const enabledRecord = allSettings.find(
|
||||||
|
(r) => r.key === `twoFactorEnabled${userPrefix}`,
|
||||||
|
)
|
||||||
|
const enabledAtRecord = allSettings.find(
|
||||||
|
(r) => r.key === `twoFactorEnabledAt${userPrefix}`,
|
||||||
|
)
|
||||||
|
const backupRecord = allSettings.find(
|
||||||
|
(r) => r.key === `totpBackupCodes${userPrefix}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const enabled = enabledRecord?.value === "true"
|
||||||
|
const enabledAt = enabledAtRecord?.value || null
|
||||||
|
const backupCodesRemaining = backupRecord?.value
|
||||||
|
? countRemainingBackupCodes(backupRecord.value)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
method: "totp",
|
||||||
|
enabledAt,
|
||||||
|
backupCodesRemaining,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Actions ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取安全中心数据:2FA 状态 + 最近登录历史
|
||||||
|
*/
|
||||||
|
export async function getSecurityCenterAction(): Promise<
|
||||||
|
ActionState<SecurityCenterData>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.USER_PROFILE_UPDATE)
|
||||||
|
|
||||||
|
const [twoFactor, recentLoginRows] = await Promise.all([
|
||||||
|
getTwoFactorStatus(ctx.userId),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: loginLogs.id,
|
||||||
|
action: loginLogs.action,
|
||||||
|
status: loginLogs.status,
|
||||||
|
ipAddress: loginLogs.ipAddress,
|
||||||
|
userAgent: loginLogs.userAgent,
|
||||||
|
errorMessage: loginLogs.errorMessage,
|
||||||
|
createdAt: loginLogs.createdAt,
|
||||||
|
})
|
||||||
|
.from(loginLogs)
|
||||||
|
.where(eq(loginLogs.userId, ctx.userId))
|
||||||
|
.orderBy(desc(loginLogs.createdAt))
|
||||||
|
.limit(10),
|
||||||
|
])
|
||||||
|
|
||||||
|
const recentLogins: LoginHistoryItem[] = recentLoginRows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
action: r.action,
|
||||||
|
status: r.status,
|
||||||
|
ipAddress: r.ipAddress ?? null,
|
||||||
|
userAgent: r.userAgent ?? null,
|
||||||
|
errorMessage: r.errorMessage ?? null,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { twoFactor, recentLogins },
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to load security center"
|
||||||
|
return { success: false, message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2FA 启用流程 - 第一步:生成密钥与二维码
|
||||||
|
*
|
||||||
|
* 将密钥写入 DB(但 twoFactorEnabled 仍为 false),等待用户在第二步输入验证码确认。
|
||||||
|
* 如果用户已有密钥(之前 setup 但未 verify),会重新生成覆盖。
|
||||||
|
*/
|
||||||
|
export async function setupTwoFactorAction(): Promise<
|
||||||
|
ActionState<TwoFactorSetupData>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.USER_PROFILE_UPDATE)
|
||||||
|
const profile = await getUserProfile(ctx.userId)
|
||||||
|
if (!profile) {
|
||||||
|
return { success: false, message: "User not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已启用 2FA 的用户不允许重新 setup(需先 disable)
|
||||||
|
const enabled = await getTwoFactorEnabled(ctx.userId)
|
||||||
|
if (enabled) {
|
||||||
|
return { success: false, message: "2FA is already enabled" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = generateTotpSecret()
|
||||||
|
const otpauthUrl = buildOtpAuthUrl({
|
||||||
|
serviceName: SERVICE_NAME,
|
||||||
|
accountName: profile.email,
|
||||||
|
secret,
|
||||||
|
})
|
||||||
|
const qrCodeDataUrl = await generateQrCodeDataUrl(otpauthUrl)
|
||||||
|
|
||||||
|
// 暂存密钥(twoFactorEnabled 仍为 false,登录不会要求 2FA)
|
||||||
|
await setTotpSecret(ctx.userId, secret)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { qrCodeDataUrl, secret, otpauthUrl },
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to setup 2FA"
|
||||||
|
return { success: false, message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2FA 启用流程 - 第二步:校验用户输入的一次性码,确认启用
|
||||||
|
*
|
||||||
|
* 同时生成 10 个备份码(明文只返回一次),哈希后存入 DB。
|
||||||
|
*/
|
||||||
|
export async function verifyTwoFactorAction(
|
||||||
|
token: string,
|
||||||
|
): Promise<ActionState<{ backupCodes: string[]; status: TwoFactorStatus }>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.USER_PROFILE_UPDATE)
|
||||||
|
|
||||||
|
const secret = await getTotpSecret(ctx.userId)
|
||||||
|
if (!secret) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Please setup 2FA first (no secret found)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = verifyTotpCode(token, secret)
|
||||||
|
if (!ok) {
|
||||||
|
return { success: false, message: "Invalid verification code" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成备份码
|
||||||
|
const backupCodes = generateBackupCodes()
|
||||||
|
const hashedJson = await hashBackupCodes(backupCodes)
|
||||||
|
await setBackupCodesHashed(ctx.userId, hashedJson)
|
||||||
|
|
||||||
|
// 启用 2FA
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await setTwoFactorEnabled(ctx.userId, true)
|
||||||
|
await setTwoFactorEnabledAt(ctx.userId, now)
|
||||||
|
|
||||||
|
revalidatePath("/settings")
|
||||||
|
|
||||||
|
const status = await getTwoFactorStatus(ctx.userId)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { backupCodes, status },
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to verify 2FA"
|
||||||
|
return { success: false, message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭 2FA
|
||||||
|
*
|
||||||
|
* 需要用户提供一个有效的一次性码或备份码以确认身份。
|
||||||
|
* 关闭后清除密钥和备份码。
|
||||||
|
*/
|
||||||
|
export async function disableTwoFactorAction(
|
||||||
|
token: string,
|
||||||
|
): Promise<ActionState<TwoFactorStatus>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.USER_PROFILE_UPDATE)
|
||||||
|
|
||||||
|
const enabled = await getTwoFactorEnabled(ctx.userId)
|
||||||
|
if (!enabled) {
|
||||||
|
return { success: false, message: "2FA is not enabled" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = await getTotpSecret(ctx.userId)
|
||||||
|
const backupHashed = await getBackupCodesHashed(ctx.userId)
|
||||||
|
|
||||||
|
// 先尝试 TOTP 验证,失败再尝试备份码
|
||||||
|
let verified = false
|
||||||
|
if (secret) {
|
||||||
|
verified = verifyTotpCode(token, secret)
|
||||||
|
}
|
||||||
|
if (!verified && backupHashed) {
|
||||||
|
const idx = await verifyBackupCode(token, backupHashed)
|
||||||
|
if (idx >= 0) {
|
||||||
|
verified = true
|
||||||
|
// 消耗该备份码(即使即将删除,也保持数据一致性)
|
||||||
|
const nextHashed = await consumeBackupCode(backupHashed, idx)
|
||||||
|
await setBackupCodesHashed(ctx.userId, nextHashed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return { success: false, message: "Invalid verification code" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除所有 2FA 相关数据
|
||||||
|
await setTwoFactorEnabled(ctx.userId, false)
|
||||||
|
await setTwoFactorEnabledAt(ctx.userId, "")
|
||||||
|
await deleteTotpSecret(ctx.userId)
|
||||||
|
await deleteBackupCodes(ctx.userId)
|
||||||
|
|
||||||
|
revalidatePath("/settings")
|
||||||
|
|
||||||
|
const status = await getTwoFactorStatus(ctx.userId)
|
||||||
|
return { success: true, data: status }
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to disable 2FA"
|
||||||
|
return { success: false, message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成新的备份码(用户用尽后可重新生成)
|
||||||
|
*
|
||||||
|
* 需要用户提供当前的有效 TOTP 码确认身份。
|
||||||
|
*/
|
||||||
|
export async function regenerateBackupCodesAction(
|
||||||
|
token: string,
|
||||||
|
): Promise<ActionState<{ backupCodes: string[]; status: TwoFactorStatus }>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.USER_PROFILE_UPDATE)
|
||||||
|
|
||||||
|
const enabled = await getTwoFactorEnabled(ctx.userId)
|
||||||
|
if (!enabled) {
|
||||||
|
return { success: false, message: "2FA is not enabled" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = await getTotpSecret(ctx.userId)
|
||||||
|
if (!secret) {
|
||||||
|
return { success: false, message: "No TOTP secret found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = verifyTotpCode(token, secret)
|
||||||
|
if (!ok) {
|
||||||
|
return { success: false, message: "Invalid verification code" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupCodes = generateBackupCodes()
|
||||||
|
const hashedJson = await hashBackupCodes(backupCodes)
|
||||||
|
await setBackupCodesHashed(ctx.userId, hashedJson)
|
||||||
|
|
||||||
|
revalidatePath("/settings")
|
||||||
|
|
||||||
|
const status = await getTwoFactorStatus(ctx.userId)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { backupCodes, status },
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to regenerate backup codes"
|
||||||
|
return { success: false, message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 远程登出当前用户的所有其他会话
|
||||||
|
*
|
||||||
|
* 删除 sessions 表中当前用户的所有记录,并记录一条 signout 日志。
|
||||||
|
* 注意:当前系统使用 JWT 策略,sessions 表可能无活跃记录;
|
||||||
|
* 此操作主要作为安全处置手段,配合未来的 token 黑名单机制使用。
|
||||||
|
*
|
||||||
|
* 返回被删除的会话数量。
|
||||||
|
*/
|
||||||
|
export async function revokeAllOtherSessionsAction(): Promise<
|
||||||
|
ActionState<{ revokedCount: number }>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.USER_PROFILE_UPDATE)
|
||||||
|
const profile = await getUserProfile(ctx.userId)
|
||||||
|
if (!profile) {
|
||||||
|
return { success: false, message: "User not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除 sessions 表中该用户的所有记录
|
||||||
|
const result = await db
|
||||||
|
.delete(sessions)
|
||||||
|
.where(eq(sessions.userId, ctx.userId))
|
||||||
|
|
||||||
|
// MySqlRawQueryResult 是 [rows, fields] 元组,rows 可能含 affectedRows
|
||||||
|
const rows = Array.isArray(result) ? result[0] : result
|
||||||
|
const revokedCount =
|
||||||
|
typeof rows === "object" && rows !== null && "affectedRows" in rows
|
||||||
|
? Number((rows as { affectedRows: unknown }).affectedRows)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
// 记录一条安全处置日志
|
||||||
|
await logLoginEvent({
|
||||||
|
userId: ctx.userId,
|
||||||
|
userEmail: profile.email,
|
||||||
|
action: "signout",
|
||||||
|
status: "success",
|
||||||
|
errorMessage: revokedCount > 0
|
||||||
|
? `Remote logout: revoked ${revokedCount} session(s)`
|
||||||
|
: "Remote logout: no active DB sessions (JWT-based)",
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath("/settings")
|
||||||
|
|
||||||
|
return { success: true, data: { revokedCount } }
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to revoke sessions"
|
||||||
|
return { success: false, message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 登录时 2FA 校验(供 auth.ts 调用,非 Server Action) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录预检:根据邮箱判断该用户是否启用了 2FA。
|
||||||
|
*
|
||||||
|
* 此函数不验证密码,仅查询 2FA 状态。登录表单在首次提交前可调用此函数,
|
||||||
|
* 若返回 required=true 则先展示 2FA 验证码输入框。
|
||||||
|
*
|
||||||
|
* 为防止邮箱枚举攻击,无论用户是否存在都返回 required=false(不存在则视为未启用)。
|
||||||
|
*/
|
||||||
|
export async function preflightTwoFactorAction(
|
||||||
|
email: string,
|
||||||
|
): Promise<{ required: boolean }> {
|
||||||
|
try {
|
||||||
|
const normalized = email.trim().toLowerCase()
|
||||||
|
if (!normalized) return { required: false }
|
||||||
|
|
||||||
|
const { eq } = await import("drizzle-orm")
|
||||||
|
const { db } = await import("@/shared/db")
|
||||||
|
const { users } = await import("@/shared/db/schema")
|
||||||
|
|
||||||
|
const [user] = await db
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.email, normalized))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!user) return { required: false }
|
||||||
|
|
||||||
|
const enabled = await getTwoFactorEnabled(user.id)
|
||||||
|
return { required: enabled }
|
||||||
|
} catch {
|
||||||
|
return { required: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录时校验 2FA:检查用户是否启用 2FA,并校验提供的一次性码或备份码。
|
||||||
|
*
|
||||||
|
* 返回值:
|
||||||
|
* - { required: true } — 用户启用了 2FA 但未提供 token,登录流程应要求输入
|
||||||
|
* - { required: false, valid: true } — 未启用 2FA,或已提供有效 token
|
||||||
|
* - { required: false, valid: false } — 启用了 2FA 且提供的 token 无效
|
||||||
|
*
|
||||||
|
* 此函数不使用 requirePermission(登录时还未建立会话),
|
||||||
|
* 由 auth.ts 的 authorize 回调直接调用。
|
||||||
|
*/
|
||||||
|
export async function verifyTwoFactorForLogin(params: {
|
||||||
|
userId: string
|
||||||
|
token?: string
|
||||||
|
}): Promise<{ required: boolean; valid: boolean }> {
|
||||||
|
const { userId, token } = params
|
||||||
|
|
||||||
|
const enabled = await getTwoFactorEnabled(userId)
|
||||||
|
if (!enabled) {
|
||||||
|
return { required: false, valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return { required: true, valid: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = await getTotpSecret(userId)
|
||||||
|
const backupHashed = await getBackupCodesHashed(userId)
|
||||||
|
|
||||||
|
// 先尝试 TOTP
|
||||||
|
if (secret && verifyTotpCode(token, secret)) {
|
||||||
|
return { required: true, valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再尝试备份码
|
||||||
|
if (backupHashed) {
|
||||||
|
const idx = await verifyBackupCode(token, backupHashed)
|
||||||
|
if (idx >= 0) {
|
||||||
|
const nextHashed = await consumeBackupCode(backupHashed, idx)
|
||||||
|
await setBackupCodesHashed(userId, nextHashed)
|
||||||
|
return { required: true, valid: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { required: true, valid: false }
|
||||||
|
}
|
||||||
63
src/modules/settings/actions-service.ts
Normal file
63
src/modules/settings/actions-service.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置模块服务层 Server Actions
|
||||||
|
*
|
||||||
|
* 这些 wrapper 函数将 users/messaging 模块的 Server Action 适配为
|
||||||
|
* SettingsService 接口所需的签名,使其可作为 Server Action 引用
|
||||||
|
* 直接传递给 Client Components(Next.js 要求传递给 Client Component
|
||||||
|
* 的函数必须是 "use server" 标记的 Server Action)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
|
||||||
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { updateUserProfile } from "@/modules/users/actions"
|
||||||
|
import type { UpdateUserProfileInput } from "@/modules/users/data-access"
|
||||||
|
import {
|
||||||
|
upsertNotificationPreferences,
|
||||||
|
} from "@/modules/notifications/preferences"
|
||||||
|
import type {
|
||||||
|
NotificationPreferences,
|
||||||
|
UpdateNotificationPreferencesInput,
|
||||||
|
} from "@/modules/notifications/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户资料(Server Action wrapper)
|
||||||
|
*
|
||||||
|
* 直接委托给 users 模块的 updateUserProfile,保持 Server Action 引用语义。
|
||||||
|
*/
|
||||||
|
export async function updateProfileAction(
|
||||||
|
input: UpdateUserProfileInput
|
||||||
|
): Promise<ActionState<void>> {
|
||||||
|
return updateUserProfile(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新通知偏好(Server Action wrapper)
|
||||||
|
*
|
||||||
|
* 将 UpdateNotificationPreferencesInput 直接传递给 data-access 层,
|
||||||
|
* 绕过 messaging/actions.ts 中基于 FormData 的签名,以适配 SettingsService 接口。
|
||||||
|
*/
|
||||||
|
export async function updateNotificationPreferencesAction(
|
||||||
|
input: UpdateNotificationPreferencesInput
|
||||||
|
): Promise<ActionState<NotificationPreferences>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||||
|
|
||||||
|
const updated = await upsertNotificationPreferences(ctx.userId, input)
|
||||||
|
if (!updated) {
|
||||||
|
return { success: false, message: "Failed to update notification preferences" }
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/settings")
|
||||||
|
|
||||||
|
return { success: true, message: "Notification preferences updated", data: updated }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
189
src/modules/settings/actions-system-settings.ts
Normal file
189
src/modules/settings/actions-system-settings.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { z } from "zod"
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
|
||||||
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAllSystemSettings,
|
||||||
|
upsertSystemSettings,
|
||||||
|
type SystemSettingCategory,
|
||||||
|
type SystemSettingValueType,
|
||||||
|
} from "./data-access-system-settings"
|
||||||
|
|
||||||
|
// --- Schemas ---
|
||||||
|
|
||||||
|
const SchoolInfoSchema = z.object({
|
||||||
|
schoolName: z.string().min(1).max(255),
|
||||||
|
schoolCode: z.string().max(50).default(""),
|
||||||
|
schoolPhone: z.string().max(50).default(""),
|
||||||
|
schoolEmail: z.string().email().or(z.literal("")).default(""),
|
||||||
|
schoolAddress: z.string().max(500).default(""),
|
||||||
|
schoolDescription: z.string().max(2000).default(""),
|
||||||
|
})
|
||||||
|
|
||||||
|
const SecurityPolicySchema = z.object({
|
||||||
|
passwordMinLength: z.coerce.number().int().min(6).max(32),
|
||||||
|
sessionTimeout: z.coerce.number().int().min(5).max(1440),
|
||||||
|
requireSpecialChar: z.coerce.boolean(),
|
||||||
|
requireUppercase: z.coerce.boolean(),
|
||||||
|
forcePasswordChange: z.coerce.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const FileUploadSchema = z.object({
|
||||||
|
maxFileSize: z.coerce.number().int().min(1).max(100),
|
||||||
|
allowedTypes: z.string().min(1).max(500),
|
||||||
|
})
|
||||||
|
|
||||||
|
const NotificationConfigSchema = z.object({
|
||||||
|
notifyNewUser: z.coerce.boolean(),
|
||||||
|
notifyScheduleChange: z.coerce.boolean(),
|
||||||
|
notifyAnnouncement: z.coerce.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const AdminSettingsFormSchema = z.object({
|
||||||
|
schoolInfo: SchoolInfoSchema,
|
||||||
|
securityPolicy: SecurityPolicySchema,
|
||||||
|
fileUpload: FileUploadSchema,
|
||||||
|
notificationConfig: NotificationConfigSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
type AdminSettingsFormValues = z.infer<typeof AdminSettingsFormSchema>
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function toSettingItem(
|
||||||
|
category: SystemSettingCategory,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
valueType: SystemSettingValueType
|
||||||
|
): { category: SystemSettingCategory; key: string; value: string; valueType: SystemSettingValueType } {
|
||||||
|
let strValue: string
|
||||||
|
if (valueType === "json") {
|
||||||
|
strValue = JSON.stringify(value)
|
||||||
|
} else if (valueType === "boolean") {
|
||||||
|
strValue = value ? "true" : "false"
|
||||||
|
} else if (valueType === "number") {
|
||||||
|
strValue = String(value)
|
||||||
|
} else {
|
||||||
|
strValue = String(value ?? "")
|
||||||
|
}
|
||||||
|
return { category, key, value: strValue, valueType }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Actions ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取管理员系统设置
|
||||||
|
*/
|
||||||
|
export async function getAdminSystemSettingsAction(): Promise<
|
||||||
|
ActionState<AdminSettingsFormValues>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.SETTINGS_ADMIN)
|
||||||
|
void ctx
|
||||||
|
|
||||||
|
const records = await getAllSystemSettings()
|
||||||
|
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
for (const r of records) {
|
||||||
|
map.set(`${r.category}.${r.key}`, r.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const get = (category: string, key: string, fallback: string): string =>
|
||||||
|
map.get(`${category}.${key}`) ?? fallback
|
||||||
|
|
||||||
|
const getBool = (category: string, key: string, fallback: boolean): boolean => {
|
||||||
|
const v = map.get(`${category}.${key}`)
|
||||||
|
if (v === undefined) return fallback
|
||||||
|
return v === "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNum = (category: string, key: string, fallback: number): number => {
|
||||||
|
const v = map.get(`${category}.${key}`)
|
||||||
|
if (v === undefined) return fallback
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isNaN(n) ? fallback : n
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: AdminSettingsFormValues = {
|
||||||
|
schoolInfo: {
|
||||||
|
schoolName: get("school_info", "schoolName", ""),
|
||||||
|
schoolCode: get("school_info", "schoolCode", ""),
|
||||||
|
schoolPhone: get("school_info", "schoolPhone", ""),
|
||||||
|
schoolEmail: get("school_info", "schoolEmail", ""),
|
||||||
|
schoolAddress: get("school_info", "schoolAddress", ""),
|
||||||
|
schoolDescription: get("school_info", "schoolDescription", ""),
|
||||||
|
},
|
||||||
|
securityPolicy: {
|
||||||
|
passwordMinLength: getNum("security_policy", "passwordMinLength", 8),
|
||||||
|
sessionTimeout: getNum("security_policy", "sessionTimeout", 60),
|
||||||
|
requireSpecialChar: getBool("security_policy", "requireSpecialChar", true),
|
||||||
|
requireUppercase: getBool("security_policy", "requireUppercase", false),
|
||||||
|
forcePasswordChange: getBool("security_policy", "forcePasswordChange", true),
|
||||||
|
},
|
||||||
|
fileUpload: {
|
||||||
|
maxFileSize: getNum("file_upload", "maxFileSize", 10),
|
||||||
|
allowedTypes: get("file_upload", "allowedTypes", "jpg,png,pdf,docx,xlsx,pptx"),
|
||||||
|
},
|
||||||
|
notificationConfig: {
|
||||||
|
notifyNewUser: getBool("notification_config", "notifyNewUser", true),
|
||||||
|
notifyScheduleChange: getBool("notification_config", "notifyScheduleChange", true),
|
||||||
|
notifyAnnouncement: getBool("notification_config", "notifyAnnouncement", false),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to load settings"
|
||||||
|
return { success: false, message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存管理员系统设置
|
||||||
|
*/
|
||||||
|
export async function saveAdminSystemSettingsAction(
|
||||||
|
values: AdminSettingsFormValues
|
||||||
|
): Promise<ActionState<null>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.SETTINGS_ADMIN)
|
||||||
|
|
||||||
|
const parsed = AdminSettingsFormSchema.parse(values)
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
// school_info
|
||||||
|
toSettingItem("school_info", "schoolName", parsed.schoolInfo.schoolName, "string"),
|
||||||
|
toSettingItem("school_info", "schoolCode", parsed.schoolInfo.schoolCode, "string"),
|
||||||
|
toSettingItem("school_info", "schoolPhone", parsed.schoolInfo.schoolPhone, "string"),
|
||||||
|
toSettingItem("school_info", "schoolEmail", parsed.schoolInfo.schoolEmail, "string"),
|
||||||
|
toSettingItem("school_info", "schoolAddress", parsed.schoolInfo.schoolAddress, "string"),
|
||||||
|
toSettingItem("school_info", "schoolDescription", parsed.schoolInfo.schoolDescription, "string"),
|
||||||
|
// security_policy
|
||||||
|
toSettingItem("security_policy", "passwordMinLength", parsed.securityPolicy.passwordMinLength, "number"),
|
||||||
|
toSettingItem("security_policy", "sessionTimeout", parsed.securityPolicy.sessionTimeout, "number"),
|
||||||
|
toSettingItem("security_policy", "requireSpecialChar", parsed.securityPolicy.requireSpecialChar, "boolean"),
|
||||||
|
toSettingItem("security_policy", "requireUppercase", parsed.securityPolicy.requireUppercase, "boolean"),
|
||||||
|
toSettingItem("security_policy", "forcePasswordChange", parsed.securityPolicy.forcePasswordChange, "boolean"),
|
||||||
|
// file_upload
|
||||||
|
toSettingItem("file_upload", "maxFileSize", parsed.fileUpload.maxFileSize, "number"),
|
||||||
|
toSettingItem("file_upload", "allowedTypes", parsed.fileUpload.allowedTypes, "string"),
|
||||||
|
// notification_config
|
||||||
|
toSettingItem("notification_config", "notifyNewUser", parsed.notificationConfig.notifyNewUser, "boolean"),
|
||||||
|
toSettingItem("notification_config", "notifyScheduleChange", parsed.notificationConfig.notifyScheduleChange, "boolean"),
|
||||||
|
toSettingItem("notification_config", "notifyAnnouncement", parsed.notificationConfig.notifyAnnouncement, "boolean"),
|
||||||
|
]
|
||||||
|
|
||||||
|
await upsertSystemSettings(items, ctx.userId)
|
||||||
|
|
||||||
|
revalidatePath("/admin/settings")
|
||||||
|
|
||||||
|
return { success: true, data: null }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to save settings"
|
||||||
|
return { success: false, message }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,12 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { School, Shield, Database, Bell } from "lucide-react"
|
import { School, Shield, Database, Bell, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAdminSystemSettingsAction,
|
||||||
|
saveAdminSystemSettingsAction,
|
||||||
|
} from "@/modules/settings/actions-system-settings"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
@@ -13,25 +17,156 @@ import { Switch } from "@/shared/components/ui/switch"
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Separator } from "@/shared/components/ui/separator"
|
import { Separator } from "@/shared/components/ui/separator"
|
||||||
|
|
||||||
|
interface AdminSettingsFormValues {
|
||||||
|
schoolInfo: {
|
||||||
|
schoolName: string
|
||||||
|
schoolCode: string
|
||||||
|
schoolPhone: string
|
||||||
|
schoolEmail: string
|
||||||
|
schoolAddress: string
|
||||||
|
schoolDescription: string
|
||||||
|
}
|
||||||
|
securityPolicy: {
|
||||||
|
passwordMinLength: number
|
||||||
|
sessionTimeout: number
|
||||||
|
requireSpecialChar: boolean
|
||||||
|
requireUppercase: boolean
|
||||||
|
forcePasswordChange: boolean
|
||||||
|
}
|
||||||
|
fileUpload: {
|
||||||
|
maxFileSize: number
|
||||||
|
allowedTypes: string
|
||||||
|
}
|
||||||
|
notificationConfig: {
|
||||||
|
notifyNewUser: boolean
|
||||||
|
notifyScheduleChange: boolean
|
||||||
|
notifyAnnouncement: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_VALUES: AdminSettingsFormValues = {
|
||||||
|
schoolInfo: {
|
||||||
|
schoolName: "",
|
||||||
|
schoolCode: "",
|
||||||
|
schoolPhone: "",
|
||||||
|
schoolEmail: "",
|
||||||
|
schoolAddress: "",
|
||||||
|
schoolDescription: "",
|
||||||
|
},
|
||||||
|
securityPolicy: {
|
||||||
|
passwordMinLength: 8,
|
||||||
|
sessionTimeout: 60,
|
||||||
|
requireSpecialChar: true,
|
||||||
|
requireUppercase: false,
|
||||||
|
forcePasswordChange: true,
|
||||||
|
},
|
||||||
|
fileUpload: {
|
||||||
|
maxFileSize: 10,
|
||||||
|
allowedTypes: "jpg,png,pdf,docx,xlsx,pptx",
|
||||||
|
},
|
||||||
|
notificationConfig: {
|
||||||
|
notifyNewUser: true,
|
||||||
|
notifyScheduleChange: true,
|
||||||
|
notifyAnnouncement: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理员系统设置视图
|
* 管理员系统设置视图
|
||||||
*
|
*
|
||||||
* TODO: 当前为 mock 实现(setTimeout 模拟保存),未接入真实数据层。
|
* 通过 Server Actions 加载和保存系统设置,数据持久化到 system_settings 表。
|
||||||
* 后续需新增 system_settings 表 + data-access + actions,替换 mock 逻辑。
|
* 4 个 Card:学校信息 / 安全策略 / 文件上传 / 通知配置。
|
||||||
* 当前已适配 i18n,文本均通过 settings.admin.* 翻译键获取。
|
* 所有文本通过 settings.admin.* i18n 键获取。
|
||||||
*/
|
*/
|
||||||
export function AdminSettingsView() {
|
export function AdminSettingsView(): React.ReactElement {
|
||||||
const t = useTranslations("settings.admin")
|
const t = useTranslations("settings.admin")
|
||||||
|
const [values, setValues] = React.useState<AdminSettingsFormValues>(DEFAULT_VALUES)
|
||||||
|
const [loadedValues, setLoadedValues] = React.useState<AdminSettingsFormValues>(DEFAULT_VALUES)
|
||||||
|
const [loading, setLoading] = React.useState(true)
|
||||||
const [saving, setSaving] = React.useState(false)
|
const [saving, setSaving] = React.useState(false)
|
||||||
|
|
||||||
const handleSave = async (e: React.FormEvent) => {
|
React.useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await getAdminSystemSettingsAction()
|
||||||
|
if (!cancelled && result.success && result.data) {
|
||||||
|
setValues(result.data)
|
||||||
|
setLoadedValues(result.data)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 加载失败时使用默认值
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// dirty 检测:当前值与加载值不一致时为 dirty
|
||||||
|
const isDirty = React.useMemo(
|
||||||
|
() => JSON.stringify(values) !== JSON.stringify(loadedValues),
|
||||||
|
[values, loadedValues],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSave = async (e: React.FormEvent): Promise<void> => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
if (!isDirty) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
// TODO: 替换为真实 Server Action 调用
|
try {
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, 800))
|
const result = await saveAdminSystemSettingsAction(values)
|
||||||
|
if (result.success) {
|
||||||
toast.success(t("saveSuccess"))
|
toast.success(t("saveSuccess"))
|
||||||
|
setLoadedValues(values)
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || t("saveFailure"))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("saveFailure"))
|
||||||
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = (): void => {
|
||||||
|
setValues(loadedValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSchoolInfo = (key: keyof AdminSettingsFormValues["schoolInfo"], value: string): void => {
|
||||||
|
setValues((prev) => ({ ...prev, schoolInfo: { ...prev.schoolInfo, [key]: value } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSecurityPolicy = (
|
||||||
|
key: keyof AdminSettingsFormValues["securityPolicy"],
|
||||||
|
value: number | boolean
|
||||||
|
): void => {
|
||||||
|
setValues((prev) => ({ ...prev, securityPolicy: { ...prev.securityPolicy, [key]: value } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFileUpload = (
|
||||||
|
key: keyof AdminSettingsFormValues["fileUpload"],
|
||||||
|
value: number | string
|
||||||
|
): void => {
|
||||||
|
setValues((prev) => ({ ...prev, fileUpload: { ...prev.fileUpload, [key]: value } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateNotificationConfig = (
|
||||||
|
key: keyof AdminSettingsFormValues["notificationConfig"],
|
||||||
|
value: boolean
|
||||||
|
): void => {
|
||||||
|
setValues((prev) => ({ ...prev, notificationConfig: { ...prev.notificationConfig, [key]: value } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-6">
|
<div className="flex h-full flex-col space-y-6">
|
||||||
@@ -56,30 +191,68 @@ export function AdminSettingsView() {
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="school-name">{t("schoolInfo.name")}</Label>
|
<Label htmlFor="school-name">{t("schoolInfo.name")}</Label>
|
||||||
<Input id="school-name" name="schoolName" placeholder={t("schoolInfo.namePlaceholder")} />
|
<Input
|
||||||
|
id="school-name"
|
||||||
|
name="schoolName"
|
||||||
|
placeholder={t("schoolInfo.namePlaceholder")}
|
||||||
|
value={values.schoolInfo.schoolName}
|
||||||
|
onChange={(e) => updateSchoolInfo("schoolName", e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="school-code">{t("schoolInfo.code")}</Label>
|
<Label htmlFor="school-code">{t("schoolInfo.code")}</Label>
|
||||||
<Input id="school-code" name="schoolCode" placeholder={t("schoolInfo.codePlaceholder")} />
|
<Input
|
||||||
|
id="school-code"
|
||||||
|
name="schoolCode"
|
||||||
|
placeholder={t("schoolInfo.codePlaceholder")}
|
||||||
|
value={values.schoolInfo.schoolCode}
|
||||||
|
onChange={(e) => updateSchoolInfo("schoolCode", e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="school-phone">{t("schoolInfo.phone")}</Label>
|
<Label htmlFor="school-phone">{t("schoolInfo.phone")}</Label>
|
||||||
<Input id="school-phone" name="schoolPhone" placeholder={t("schoolInfo.phonePlaceholder")} />
|
<Input
|
||||||
|
id="school-phone"
|
||||||
|
name="schoolPhone"
|
||||||
|
placeholder={t("schoolInfo.phonePlaceholder")}
|
||||||
|
value={values.schoolInfo.schoolPhone}
|
||||||
|
onChange={(e) => updateSchoolInfo("schoolPhone", e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="school-email">{t("schoolInfo.email")}</Label>
|
<Label htmlFor="school-email">{t("schoolInfo.email")}</Label>
|
||||||
<Input id="school-email" name="schoolEmail" type="email" placeholder={t("schoolInfo.emailPlaceholder")} />
|
<Input
|
||||||
|
id="school-email"
|
||||||
|
name="schoolEmail"
|
||||||
|
type="email"
|
||||||
|
placeholder={t("schoolInfo.emailPlaceholder")}
|
||||||
|
value={values.schoolInfo.schoolEmail}
|
||||||
|
onChange={(e) => updateSchoolInfo("schoolEmail", e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="school-address">{t("schoolInfo.address")}</Label>
|
<Label htmlFor="school-address">{t("schoolInfo.address")}</Label>
|
||||||
<Input id="school-address" name="schoolAddress" placeholder={t("schoolInfo.addressPlaceholder")} />
|
<Input
|
||||||
|
id="school-address"
|
||||||
|
name="schoolAddress"
|
||||||
|
placeholder={t("schoolInfo.addressPlaceholder")}
|
||||||
|
value={values.schoolInfo.schoolAddress}
|
||||||
|
onChange={(e) => updateSchoolInfo("schoolAddress", e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="school-desc">{t("schoolInfo.description2")}</Label>
|
<Label htmlFor="school-desc">{t("schoolInfo.description2")}</Label>
|
||||||
<Textarea id="school-desc" name="schoolDescription" placeholder={t("schoolInfo.descriptionPlaceholder")} rows={3} />
|
<Textarea
|
||||||
|
id="school-desc"
|
||||||
|
name="schoolDescription"
|
||||||
|
placeholder={t("schoolInfo.descriptionPlaceholder")}
|
||||||
|
rows={3}
|
||||||
|
value={values.schoolInfo.schoolDescription}
|
||||||
|
onChange={(e) => updateSchoolInfo("schoolDescription", e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -99,11 +272,27 @@ export function AdminSettingsView() {
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password-min-length">{t("securityPolicy.passwordMinLength")}</Label>
|
<Label htmlFor="password-min-length">{t("securityPolicy.passwordMinLength")}</Label>
|
||||||
<Input id="password-min-length" name="passwordMinLength" type="number" min={6} max={32} defaultValue={8} />
|
<Input
|
||||||
|
id="password-min-length"
|
||||||
|
name="passwordMinLength"
|
||||||
|
type="number"
|
||||||
|
min={6}
|
||||||
|
max={32}
|
||||||
|
value={values.securityPolicy.passwordMinLength}
|
||||||
|
onChange={(e) => updateSecurityPolicy("passwordMinLength", Number(e.target.value))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="session-timeout">{t("securityPolicy.sessionTimeout")}</Label>
|
<Label htmlFor="session-timeout">{t("securityPolicy.sessionTimeout")}</Label>
|
||||||
<Input id="session-timeout" name="sessionTimeout" type="number" min={5} max={1440} defaultValue={60} />
|
<Input
|
||||||
|
id="session-timeout"
|
||||||
|
name="sessionTimeout"
|
||||||
|
type="number"
|
||||||
|
min={5}
|
||||||
|
max={1440}
|
||||||
|
value={values.securityPolicy.sessionTimeout}
|
||||||
|
onChange={(e) => updateSecurityPolicy("sessionTimeout", Number(e.target.value))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -112,21 +301,36 @@ export function AdminSettingsView() {
|
|||||||
<Label htmlFor="require-special-char">{t("securityPolicy.requireSpecialChar")}</Label>
|
<Label htmlFor="require-special-char">{t("securityPolicy.requireSpecialChar")}</Label>
|
||||||
<p className="text-sm text-muted-foreground">{t("securityPolicy.requireSpecialCharDesc")}</p>
|
<p className="text-sm text-muted-foreground">{t("securityPolicy.requireSpecialCharDesc")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch id="require-special-char" name="requireSpecialChar" defaultChecked />
|
<Switch
|
||||||
|
id="require-special-char"
|
||||||
|
name="requireSpecialChar"
|
||||||
|
checked={values.securityPolicy.requireSpecialChar}
|
||||||
|
onCheckedChange={(v) => updateSecurityPolicy("requireSpecialChar", v)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="require-uppercase">{t("securityPolicy.requireUppercase")}</Label>
|
<Label htmlFor="require-uppercase">{t("securityPolicy.requireUppercase")}</Label>
|
||||||
<p className="text-sm text-muted-foreground">{t("securityPolicy.requireUppercaseDesc")}</p>
|
<p className="text-sm text-muted-foreground">{t("securityPolicy.requireUppercaseDesc")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch id="require-uppercase" name="requireUppercase" />
|
<Switch
|
||||||
|
id="require-uppercase"
|
||||||
|
name="requireUppercase"
|
||||||
|
checked={values.securityPolicy.requireUppercase}
|
||||||
|
onCheckedChange={(v) => updateSecurityPolicy("requireUppercase", v)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="force-password-change">{t("securityPolicy.forcePasswordChange")}</Label>
|
<Label htmlFor="force-password-change">{t("securityPolicy.forcePasswordChange")}</Label>
|
||||||
<p className="text-sm text-muted-foreground">{t("securityPolicy.forcePasswordChangeDesc")}</p>
|
<p className="text-sm text-muted-foreground">{t("securityPolicy.forcePasswordChangeDesc")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch id="force-password-change" name="forcePasswordChange" defaultChecked />
|
<Switch
|
||||||
|
id="force-password-change"
|
||||||
|
name="forcePasswordChange"
|
||||||
|
checked={values.securityPolicy.forcePasswordChange}
|
||||||
|
onCheckedChange={(v) => updateSecurityPolicy("forcePasswordChange", v)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -146,11 +350,25 @@ export function AdminSettingsView() {
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="max-file-size">{t("fileUpload.maxFileSize")}</Label>
|
<Label htmlFor="max-file-size">{t("fileUpload.maxFileSize")}</Label>
|
||||||
<Input id="max-file-size" name="maxFileSize" type="number" min={1} max={100} defaultValue={10} />
|
<Input
|
||||||
|
id="max-file-size"
|
||||||
|
name="maxFileSize"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={values.fileUpload.maxFileSize}
|
||||||
|
onChange={(e) => updateFileUpload("maxFileSize", Number(e.target.value))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="allowed-types">{t("fileUpload.allowedTypes")}</Label>
|
<Label htmlFor="allowed-types">{t("fileUpload.allowedTypes")}</Label>
|
||||||
<Input id="allowed-types" name="allowedTypes" placeholder={t("fileUpload.allowedTypesPlaceholder")} defaultValue="jpg,png,pdf,docx,xlsx,pptx" />
|
<Input
|
||||||
|
id="allowed-types"
|
||||||
|
name="allowedTypes"
|
||||||
|
placeholder={t("fileUpload.allowedTypesPlaceholder")}
|
||||||
|
value={values.fileUpload.allowedTypes}
|
||||||
|
onChange={(e) => updateFileUpload("allowedTypes", e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -173,28 +391,50 @@ export function AdminSettingsView() {
|
|||||||
<Label htmlFor="notify-new-user">{t("notificationConfig.notifyNewUser")}</Label>
|
<Label htmlFor="notify-new-user">{t("notificationConfig.notifyNewUser")}</Label>
|
||||||
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyNewUserDesc")}</p>
|
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyNewUserDesc")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch id="notify-new-user" name="notifyNewUser" defaultChecked />
|
<Switch
|
||||||
|
id="notify-new-user"
|
||||||
|
name="notifyNewUser"
|
||||||
|
checked={values.notificationConfig.notifyNewUser}
|
||||||
|
onCheckedChange={(v) => updateNotificationConfig("notifyNewUser", v)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="notify-schedule-change">{t("notificationConfig.notifyScheduleChange")}</Label>
|
<Label htmlFor="notify-schedule-change">{t("notificationConfig.notifyScheduleChange")}</Label>
|
||||||
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyScheduleChangeDesc")}</p>
|
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyScheduleChangeDesc")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch id="notify-schedule-change" name="notifyScheduleChange" defaultChecked />
|
<Switch
|
||||||
|
id="notify-schedule-change"
|
||||||
|
name="notifyScheduleChange"
|
||||||
|
checked={values.notificationConfig.notifyScheduleChange}
|
||||||
|
onCheckedChange={(v) => updateNotificationConfig("notifyScheduleChange", v)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="notify-announcement">{t("notificationConfig.notifyAnnouncement")}</Label>
|
<Label htmlFor="notify-announcement">{t("notificationConfig.notifyAnnouncement")}</Label>
|
||||||
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyAnnouncementDesc")}</p>
|
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyAnnouncementDesc")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch id="notify-announcement" name="notifyAnnouncement" />
|
<Switch
|
||||||
|
id="notify-announcement"
|
||||||
|
name="notifyAnnouncement"
|
||||||
|
checked={values.notificationConfig.notifyAnnouncement}
|
||||||
|
onCheckedChange={(v) => updateNotificationConfig("notifyAnnouncement", v)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button type="button" variant="outline">{t("reset")}</Button>
|
<Button
|
||||||
<Button type="submit" disabled={saving}>
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={!isDirty || saving}
|
||||||
|
>
|
||||||
|
{t("reset")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={saving || !isDirty}>
|
||||||
{saving ? t("saving") : t("save")}
|
{saving ? t("saving") : t("save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Loader2, Save, Sparkles } from "lucide-react"
|
|||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -253,7 +254,7 @@ export function AiProviderSettingsCard({
|
|||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<FormLabel>{t("existing")}</FormLabel>
|
<Label>{t("existing")}</Label>
|
||||||
<Select value={selectedId || NEW_PROVIDER_VALUE} onValueChange={handleSelectChange}>
|
<Select value={selectedId || NEW_PROVIDER_VALUE} onValueChange={handleSelectChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t("selectPlaceholder")} />
|
<SelectValue placeholder={t("selectPlaceholder")} />
|
||||||
@@ -269,7 +270,7 @@ export function AiProviderSettingsCard({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<FormLabel>{t("keyStatus")}</FormLabel>
|
<Label>{t("keyStatus")}</Label>
|
||||||
<div className="rounded-md border px-3 py-2 text-sm text-muted-foreground">
|
<div className="rounded-md border px-3 py-2 text-sm text-muted-foreground">
|
||||||
{selectedProvider?.apiKeyLast4
|
{selectedProvider?.apiKeyLast4
|
||||||
? `${t("stored")} • ****${selectedProvider.apiKeyLast4}`
|
? `${t("stored")} • ****${selectedProvider.apiKeyLast4}`
|
||||||
|
|||||||
186
src/modules/settings/components/avatar-upload.tsx
Normal file
186
src/modules/settings/components/avatar-upload.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { Loader2, Trash2, Upload } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { removeUserAvatarAction, updateUserAvatarAction } from "@/modules/settings/actions-avatar"
|
||||||
|
import type { FileUploadResult } from "@/modules/files/types"
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
|
||||||
|
interface AvatarUploadProps {
|
||||||
|
/** 当前头像 URL */
|
||||||
|
currentImage: string | null
|
||||||
|
/** 用户显示名(用于 fallback) */
|
||||||
|
name: string | null
|
||||||
|
/** 用户邮箱(用于 fallback) */
|
||||||
|
email: string
|
||||||
|
/** 头像更新后的回调 */
|
||||||
|
onUpdated?: (imageUrl: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"] as const
|
||||||
|
const MAX_AVATAR_SIZE = 2 * 1024 * 1024 // 2MB
|
||||||
|
const MAX_FILENAME_LENGTH = 255
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头像上传组件
|
||||||
|
*
|
||||||
|
* 支持上传新头像、预览、删除。
|
||||||
|
* 文件通过 /api/upload 上传,成功后调用 Server Action 更新 users.image。
|
||||||
|
*/
|
||||||
|
export function AvatarUpload({
|
||||||
|
currentImage,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
onUpdated,
|
||||||
|
}: AvatarUploadProps): React.ReactElement {
|
||||||
|
const t = useTranslations("settings.profile.avatar")
|
||||||
|
const [uploading, setUploading] = React.useState(false)
|
||||||
|
const [removing, setRemoving] = React.useState(false)
|
||||||
|
const [previewUrl, setPreviewUrl] = React.useState<string | null>(currentImage)
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setPreviewUrl(currentImage)
|
||||||
|
}, [currentImage])
|
||||||
|
|
||||||
|
const validateFile = (file: File): string | null => {
|
||||||
|
if (file.name.length > MAX_FILENAME_LENGTH) {
|
||||||
|
return t("tooLongName")
|
||||||
|
}
|
||||||
|
if (!ACCEPTED_IMAGE_TYPES.includes(file.type as (typeof ACCEPTED_IMAGE_TYPES)[number])) {
|
||||||
|
return t("invalidType")
|
||||||
|
}
|
||||||
|
if (file.size > MAX_AVATAR_SIZE) {
|
||||||
|
return t("tooLarge")
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
const error = validateFile(file)
|
||||||
|
if (error) {
|
||||||
|
toast.error(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
// 上传文件到 /api/upload
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("file", file)
|
||||||
|
formData.append("targetType", "user_avatar")
|
||||||
|
|
||||||
|
const response = await fetch("/api/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = (await response.json().catch(() => ({}))) as { message?: string }
|
||||||
|
throw new Error(body.message || "Upload failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await response.json()) as FileUploadResult
|
||||||
|
|
||||||
|
// 调用 Server Action 更新用户头像
|
||||||
|
const updateResult = await updateUserAvatarAction(result.url)
|
||||||
|
if (!updateResult.success) {
|
||||||
|
throw new Error(updateResult.message || "Failed to update avatar")
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewUrl(result.url)
|
||||||
|
onUpdated?.(result.url)
|
||||||
|
toast.success(t("uploadSuccess"))
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : t("uploadFailure")
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemove = async (): Promise<void> => {
|
||||||
|
setRemoving(true)
|
||||||
|
try {
|
||||||
|
const result = await removeUserAvatarAction()
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || "Failed to remove avatar")
|
||||||
|
}
|
||||||
|
setPreviewUrl(null)
|
||||||
|
onUpdated?.(null)
|
||||||
|
toast.success(t("removeSuccess"))
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : t("removeFailure")
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setRemoving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackText = (name ?? email).slice(0, 2).toUpperCase()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative group">
|
||||||
|
<Avatar className="h-20 w-20">
|
||||||
|
{previewUrl ? <AvatarImage src={previewUrl} alt={name ?? email} /> : null}
|
||||||
|
<AvatarFallback className="text-xl font-semibold">{fallbackText}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
{uploading ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-white" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={uploading || removing}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
{t("upload")}
|
||||||
|
</Button>
|
||||||
|
{previewUrl ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={uploading || removing}
|
||||||
|
onClick={handleRemove}
|
||||||
|
className="gap-2 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
{removing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||||
|
{t("remove")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("hint")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPTED_IMAGE_TYPES.join(",")}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="sr-only"
|
||||||
|
aria-label={t("upload")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useTransition } from "react"
|
import { useTransition } from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { Loader2, Save, Bell, Mail, MessageSquare, Megaphone, GraduationCap, BookOpen, CalendarCheck, Moon } from "lucide-react"
|
import { Loader2, Save, Bell, Mail, MessageSquare, Megaphone, GraduationCap, BookOpen, CalendarCheck, Moon, Send } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { sendTestNotificationAction } from "@/modules/settings/actions-notifications"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
@@ -62,6 +63,7 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
|
|||||||
const t = useTranslations("settings.notifications")
|
const t = useTranslations("settings.notifications")
|
||||||
const { notifications } = useSettingsService()
|
const { notifications } = useSettingsService()
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
const [testingChannel, setTestingChannel] = React.useState<string | null>(null)
|
||||||
|
|
||||||
const [channels, setChannels] = React.useState({
|
const [channels, setChannels] = React.useState({
|
||||||
emailEnabled: preferences.emailEnabled,
|
emailEnabled: preferences.emailEnabled,
|
||||||
@@ -81,6 +83,35 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
|
|||||||
quietHoursEnd: preferences.quietHoursEnd ?? "",
|
quietHoursEnd: preferences.quietHoursEnd ?? "",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 记录初始状态,用于 dirty 检测
|
||||||
|
const initialSnapshot = React.useMemo(
|
||||||
|
() => JSON.stringify({
|
||||||
|
channels: {
|
||||||
|
emailEnabled: preferences.emailEnabled,
|
||||||
|
smsEnabled: preferences.smsEnabled,
|
||||||
|
pushEnabled: preferences.pushEnabled,
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
homeworkNotifications: preferences.homeworkNotifications,
|
||||||
|
gradeNotifications: preferences.gradeNotifications,
|
||||||
|
announcementNotifications: preferences.announcementNotifications,
|
||||||
|
messageNotifications: preferences.messageNotifications,
|
||||||
|
attendanceNotifications: preferences.attendanceNotifications,
|
||||||
|
},
|
||||||
|
quietHours: {
|
||||||
|
quietHoursEnabled: preferences.quietHoursEnabled,
|
||||||
|
quietHoursStart: preferences.quietHoursStart ?? "",
|
||||||
|
quietHoursEnd: preferences.quietHoursEnd ?? "",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[preferences],
|
||||||
|
)
|
||||||
|
|
||||||
|
const isDirty = React.useMemo(() => {
|
||||||
|
const currentSnapshot = JSON.stringify({ channels, categories, quietHours })
|
||||||
|
return currentSnapshot !== initialSnapshot
|
||||||
|
}, [channels, categories, quietHours, initialSnapshot])
|
||||||
|
|
||||||
const toggleChannel = (key: keyof typeof channels) => {
|
const toggleChannel = (key: keyof typeof channels) => {
|
||||||
setChannels((prev) => ({ ...prev, [key]: !prev[key] }))
|
setChannels((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||||
}
|
}
|
||||||
@@ -94,6 +125,7 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onSubmit() {
|
function onSubmit() {
|
||||||
|
if (!isDirty) return
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await notifications.updatePreferences({
|
const result = await notifications.updatePreferences({
|
||||||
@@ -114,6 +146,22 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleTestNotification(channel: "push" | "email" | "sms"): Promise<void> {
|
||||||
|
setTestingChannel(channel)
|
||||||
|
try {
|
||||||
|
const result = await sendTestNotificationAction({ channel })
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(t("testSuccess"))
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || t("testFailure"))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("testFailure"))
|
||||||
|
} finally {
|
||||||
|
setTestingChannel(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -130,6 +178,7 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
|
|||||||
{CHANNELS.map((item) => {
|
{CHANNELS.map((item) => {
|
||||||
const Icon = item.icon
|
const Icon = item.icon
|
||||||
const checked = channels[item.key]
|
const checked = channels[item.key]
|
||||||
|
const channelName = item.key === "emailEnabled" ? "email" : item.key === "smsEnabled" ? "sms" : "push"
|
||||||
return (
|
return (
|
||||||
<div key={item.key} className="flex items-center justify-between gap-4">
|
<div key={item.key} className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -143,6 +192,24 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
|
|||||||
<p className="text-xs text-muted-foreground">{t(item.descKey)}</p>
|
<p className="text-xs text-muted-foreground">{t(item.descKey)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{checked ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5 text-xs"
|
||||||
|
disabled={testingChannel === channelName}
|
||||||
|
onClick={() => handleTestNotification(channelName as "push" | "email" | "sms")}
|
||||||
|
>
|
||||||
|
{testingChannel === channelName ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{t("test")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Switch
|
<Switch
|
||||||
id={item.key}
|
id={item.key}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@@ -150,6 +217,7 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
|
|||||||
aria-label={t(item.labelKey)}
|
aria-label={t(item.labelKey)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -248,7 +316,7 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex justify-end border-t px-6 py-4">
|
<CardFooter className="flex justify-end border-t px-6 py-4">
|
||||||
<Button type="button" onClick={onSubmit} disabled={isPending}>
|
<Button type="button" onClick={onSubmit} disabled={isPending || !isDirty}>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { ProfileSettingsForm } from "@/modules/settings/components/profile-setti
|
|||||||
import { PasswordChangeForm } from "@/modules/settings/components/password-change-form"
|
import { PasswordChangeForm } from "@/modules/settings/components/password-change-form"
|
||||||
import { NotificationPreferencesForm } from "@/modules/settings/components/notification-preferences-form"
|
import { NotificationPreferencesForm } from "@/modules/settings/components/notification-preferences-form"
|
||||||
import { AiProviderSettingsCard } from "@/modules/settings/components/ai-provider-settings-card"
|
import { AiProviderSettingsCard } from "@/modules/settings/components/ai-provider-settings-card"
|
||||||
|
import { SecurityCenterCard } from "@/modules/settings/components/security-center-card"
|
||||||
import { SettingsSectionErrorBoundary } from "@/modules/settings/components/settings-section-error-boundary"
|
import { SettingsSectionErrorBoundary } from "@/modules/settings/components/settings-section-error-boundary"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
@@ -44,6 +45,8 @@ interface SettingsViewProps {
|
|||||||
notificationPreferences: NotificationPreferences
|
notificationPreferences: NotificationPreferences
|
||||||
/** General 标签页中 ProfileSettingsForm 下方的内容(角色专属快捷链接等) */
|
/** General 标签页中 ProfileSettingsForm 下方的内容(角色专属快捷链接等) */
|
||||||
generalExtra?: ReactNode
|
generalExtra?: ReactNode
|
||||||
|
/** 当前请求的 User-Agent,用于安全中心标记当前会话 */
|
||||||
|
currentUserAgent?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALID_TABS = ["general", "notifications", "appearance", "security", "ai"] as const
|
const VALID_TABS = ["general", "notifications", "appearance", "security", "ai"] as const
|
||||||
@@ -87,6 +90,7 @@ function SettingsViewInner({
|
|||||||
user,
|
user,
|
||||||
notificationPreferences,
|
notificationPreferences,
|
||||||
generalExtra,
|
generalExtra,
|
||||||
|
currentUserAgent,
|
||||||
}: SettingsViewProps) {
|
}: SettingsViewProps) {
|
||||||
const t = useTranslations("settings")
|
const t = useTranslations("settings")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -94,7 +98,15 @@ function SettingsViewInner({
|
|||||||
const { hasPermission } = usePermission()
|
const { hasPermission } = usePermission()
|
||||||
|
|
||||||
const tabParam = searchParams.get("tab")
|
const tabParam = searchParams.get("tab")
|
||||||
const activeTab: TabValue = isTabValue(tabParam) ? tabParam : "general"
|
const canConfigureAi = hasPermission(Permissions.AI_CONFIGURE)
|
||||||
|
|
||||||
|
// 解析 tab 参数,对无权限的 tab(如非管理员的 ai)回退到 general
|
||||||
|
function resolveTab(value: string | null): TabValue {
|
||||||
|
if (!isTabValue(value)) return "general"
|
||||||
|
if (value === "ai" && !canConfigureAi) return "general"
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const activeTab: TabValue = resolveTab(tabParam)
|
||||||
|
|
||||||
const handleTabChange = (value: string) => {
|
const handleTabChange = (value: string) => {
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
@@ -107,8 +119,6 @@ function SettingsViewInner({
|
|||||||
router.push(query ? `?${query}` : "?", { scroll: false })
|
router.push(query ? `?${query}` : "?", { scroll: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
const canConfigureAi = hasPermission(Permissions.AI_CONFIGURE)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-8 p-8">
|
<div className="flex h-full flex-col gap-8 p-8">
|
||||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||||
@@ -178,6 +188,7 @@ function SettingsViewInner({
|
|||||||
<SettingsSectionErrorBoundary>
|
<SettingsSectionErrorBoundary>
|
||||||
<Suspense fallback={<SettingsSectionSkeleton />}>
|
<Suspense fallback={<SettingsSectionSkeleton />}>
|
||||||
<PasswordChangeForm />
|
<PasswordChangeForm />
|
||||||
|
<SecurityCenterCard currentDeviceLabel={currentUserAgent} />
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t("security.session.title")}</CardTitle>
|
<CardTitle>{t("security.session.title")}</CardTitle>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { Monitor, Moon, Sun } from "lucide-react"
|
|
||||||
|
import { Monitor, Moon, Sun, Globe } from "lucide-react"
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import { LocaleSwitcher } from "@/shared/components/locale-switcher"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
import {
|
import {
|
||||||
@@ -15,8 +17,15 @@ import {
|
|||||||
|
|
||||||
type ThemeChoice = "system" | "light" | "dark"
|
type ThemeChoice = "system" | "light" | "dark"
|
||||||
|
|
||||||
export function ThemePreferencesCard() {
|
/**
|
||||||
|
* 外观偏好卡片
|
||||||
|
*
|
||||||
|
* 包含主题切换(system/light/dark)和语言切换。
|
||||||
|
* 语言切换复用 shared/components/locale-switcher 组件。
|
||||||
|
*/
|
||||||
|
export function ThemePreferencesCard(): React.ReactElement {
|
||||||
const t = useTranslations("settings.appearance.theme")
|
const t = useTranslations("settings.appearance.theme")
|
||||||
|
const tLang = useTranslations("settings.appearance.language")
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
|
||||||
const value: ThemeChoice = theme === "light" || theme === "dark" || theme === "system" ? theme : "system"
|
const value: ThemeChoice = theme === "light" || theme === "dark" || theme === "system" ? theme : "system"
|
||||||
@@ -27,7 +36,7 @@ export function ThemePreferencesCard() {
|
|||||||
<CardTitle>{t("title")}</CardTitle>
|
<CardTitle>{t("title")}</CardTitle>
|
||||||
<CardDescription>{t("description")}</CardDescription>
|
<CardDescription>{t("description")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3 sm:max-w-md">
|
<CardContent className="grid gap-4 sm:max-w-md">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="theme">{t("label")}</Label>
|
<Label htmlFor="theme">{t("label")}</Label>
|
||||||
<Select value={value} onValueChange={(v) => setTheme(v)}>
|
<Select value={value} onValueChange={(v) => setTheme(v)}>
|
||||||
@@ -56,6 +65,17 @@ export function ThemePreferencesCard() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="language" className="flex items-center gap-1.5">
|
||||||
|
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{tLang("label")}
|
||||||
|
</Label>
|
||||||
|
<div id="language">
|
||||||
|
<LocaleSwitcher />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{tLang("description")}</p>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -51,21 +51,21 @@ const TEACHER_LINKS: QuickLinkItem[] = [
|
|||||||
|
|
||||||
export const ROLE_SETTINGS_CONFIG: Partial<Record<Role, RoleSettingsConfig>> = {
|
export const ROLE_SETTINGS_CONFIG: Partial<Record<Role, RoleSettingsConfig>> = {
|
||||||
admin: {
|
admin: {
|
||||||
descriptionKey: "settings.roleDescriptions.admin",
|
descriptionKey: "roleDescriptions.admin",
|
||||||
backHref: "/admin/dashboard",
|
backHref: "/admin/dashboard",
|
||||||
},
|
},
|
||||||
teacher: {
|
teacher: {
|
||||||
descriptionKey: "settings.roleDescriptions.teacher",
|
descriptionKey: "roleDescriptions.teacher",
|
||||||
backHref: "/teacher/dashboard",
|
backHref: "/teacher/dashboard",
|
||||||
generalExtra: <QuickLinksCard links={TEACHER_LINKS} />,
|
generalExtra: <QuickLinksCard links={TEACHER_LINKS} />,
|
||||||
},
|
},
|
||||||
student: {
|
student: {
|
||||||
descriptionKey: "settings.roleDescriptions.student",
|
descriptionKey: "roleDescriptions.student",
|
||||||
backHref: "/student/dashboard",
|
backHref: "/student/dashboard",
|
||||||
generalExtra: <QuickLinksCard links={STUDENT_LINKS} />,
|
generalExtra: <QuickLinksCard links={STUDENT_LINKS} />,
|
||||||
},
|
},
|
||||||
parent: {
|
parent: {
|
||||||
descriptionKey: "settings.roleDescriptions.parent",
|
descriptionKey: "roleDescriptions.parent",
|
||||||
backHref: "/parent/dashboard",
|
backHref: "/parent/dashboard",
|
||||||
generalExtra: <QuickLinksCard links={PARENT_LINKS} />,
|
generalExtra: <QuickLinksCard links={PARENT_LINKS} />,
|
||||||
},
|
},
|
||||||
|
|||||||
122
src/modules/settings/data-access-system-settings.ts
Normal file
122
src/modules/settings/data-access-system-settings.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { systemSettings } from "@/shared/db/schema"
|
||||||
|
|
||||||
|
// --- System Settings operations ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统设置分类
|
||||||
|
*/
|
||||||
|
export type SystemSettingCategory =
|
||||||
|
| "school_info"
|
||||||
|
| "security_policy"
|
||||||
|
| "file_upload"
|
||||||
|
| "notification_config"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统设置值类型
|
||||||
|
*/
|
||||||
|
export type SystemSettingValueType = "string" | "number" | "boolean" | "json"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统设置记录(数据库行)
|
||||||
|
*
|
||||||
|
* 注意:`category` 和 `valueType` 在数据库中存储为 varchar,读取时为 `string`。
|
||||||
|
* 写入时通过 `SystemSettingCategory` / `SystemSettingValueType` 联合类型约束输入。
|
||||||
|
*/
|
||||||
|
export interface SystemSettingRecord {
|
||||||
|
id: string
|
||||||
|
category: string
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
valueType: string
|
||||||
|
updatedBy: string | null
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定分类下所有设置项
|
||||||
|
*/
|
||||||
|
export async function getSystemSettingsByCategory(
|
||||||
|
category: SystemSettingCategory
|
||||||
|
): Promise<SystemSettingRecord[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(systemSettings)
|
||||||
|
.where(eq(systemSettings.category, category))
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有系统设置项
|
||||||
|
*/
|
||||||
|
export async function getAllSystemSettings(): Promise<SystemSettingRecord[]> {
|
||||||
|
const rows = await db.select().from(systemSettings)
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个设置项
|
||||||
|
*/
|
||||||
|
export async function getSystemSetting(
|
||||||
|
category: SystemSettingCategory,
|
||||||
|
key: string
|
||||||
|
): Promise<SystemSettingRecord | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(systemSettings)
|
||||||
|
.where(and(eq(systemSettings.category, category), eq(systemSettings.key, key)))
|
||||||
|
.limit(1)
|
||||||
|
return row ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插入或更新设置项(upsert)
|
||||||
|
*/
|
||||||
|
export async function upsertSystemSetting(
|
||||||
|
params: {
|
||||||
|
category: SystemSettingCategory
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
valueType: SystemSettingValueType
|
||||||
|
updatedBy?: string
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const { category, key, value, valueType, updatedBy } = params
|
||||||
|
const existing = await getSystemSetting(category, key)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.update(systemSettings)
|
||||||
|
.set({ value, valueType, updatedBy: updatedBy ?? null, updatedAt: new Date() })
|
||||||
|
.where(eq(systemSettings.id, existing.id))
|
||||||
|
} else {
|
||||||
|
await db.insert(systemSettings).values({
|
||||||
|
category,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
valueType,
|
||||||
|
updatedBy: updatedBy ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量 upsert 设置项
|
||||||
|
*/
|
||||||
|
export async function upsertSystemSettings(
|
||||||
|
items: ReadonlyArray<{
|
||||||
|
category: SystemSettingCategory
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
valueType: SystemSettingValueType
|
||||||
|
}>,
|
||||||
|
updatedBy?: string
|
||||||
|
): Promise<void> {
|
||||||
|
for (const item of items) {
|
||||||
|
await upsertSystemSetting({ ...item, updatedBy })
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/modules/settings/data-access-two-factor.ts
Normal file
129
src/modules/settings/data-access-two-factor.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import {
|
||||||
|
getSystemSetting,
|
||||||
|
upsertSystemSetting,
|
||||||
|
} from "./data-access-system-settings"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2FA (TOTP) 数据访问层
|
||||||
|
*
|
||||||
|
* 复用 system_settings 表(category = "security_policy"),通过用户 ID 限定键名作用域。
|
||||||
|
* 避免新增数据库表,保持 schema 稳定。
|
||||||
|
*
|
||||||
|
* 键命名约定:
|
||||||
|
* - twoFactorEnabled:${userId} — "true" | "false"
|
||||||
|
* - twoFactorMethod:${userId} — "totp"
|
||||||
|
* - twoFactorEnabledAt:${userId} — ISO 时间戳
|
||||||
|
* - totpSecret:${userId} — base32 密钥(仅在 setup 阶段写入,verify 后保留)
|
||||||
|
* - totpBackupCodes:${userId} — JSON 数组(bcrypt 哈希后的备份码列表)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CATEGORY = "security_policy" as const
|
||||||
|
|
||||||
|
function k(name: string, userId: string): string {
|
||||||
|
return `${name}:${userId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2FA 开关状态 ---
|
||||||
|
|
||||||
|
export async function getTwoFactorEnabled(userId: string): Promise<boolean> {
|
||||||
|
const record = await getSystemSetting(CATEGORY, k("twoFactorEnabled", userId))
|
||||||
|
return record?.value === "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setTwoFactorEnabled(
|
||||||
|
userId: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
await upsertSystemSetting({
|
||||||
|
category: CATEGORY,
|
||||||
|
key: k("twoFactorEnabled", userId),
|
||||||
|
value: enabled ? "true" : "false",
|
||||||
|
valueType: "boolean",
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTwoFactorEnabledAt(
|
||||||
|
userId: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const record = await getSystemSetting(CATEGORY, k("twoFactorEnabledAt", userId))
|
||||||
|
return record?.value || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setTwoFactorEnabledAt(
|
||||||
|
userId: string,
|
||||||
|
iso: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await upsertSystemSetting({
|
||||||
|
category: CATEGORY,
|
||||||
|
key: k("twoFactorEnabledAt", userId),
|
||||||
|
value: iso,
|
||||||
|
valueType: "string",
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TOTP 密钥 ---
|
||||||
|
|
||||||
|
export async function getTotpSecret(userId: string): Promise<string | null> {
|
||||||
|
const record = await getSystemSetting(CATEGORY, k("totpSecret", userId))
|
||||||
|
return record?.value ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setTotpSecret(
|
||||||
|
userId: string,
|
||||||
|
secret: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await upsertSystemSetting({
|
||||||
|
category: CATEGORY,
|
||||||
|
key: k("totpSecret", userId),
|
||||||
|
value: secret,
|
||||||
|
valueType: "string",
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTotpSecret(userId: string): Promise<void> {
|
||||||
|
// 用空字符串表示已删除(system_settings 无删除 API,空值等同无密钥)
|
||||||
|
await upsertSystemSetting({
|
||||||
|
category: CATEGORY,
|
||||||
|
key: k("totpSecret", userId),
|
||||||
|
value: "",
|
||||||
|
valueType: "string",
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 备份码 ---
|
||||||
|
|
||||||
|
export async function getBackupCodesHashed(
|
||||||
|
userId: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const record = await getSystemSetting(CATEGORY, k("totpBackupCodes", userId))
|
||||||
|
return record?.value ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setBackupCodesHashed(
|
||||||
|
userId: string,
|
||||||
|
hashedJson: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await upsertSystemSetting({
|
||||||
|
category: CATEGORY,
|
||||||
|
key: k("totpBackupCodes", userId),
|
||||||
|
value: hashedJson,
|
||||||
|
valueType: "json",
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBackupCodes(userId: string): Promise<void> {
|
||||||
|
await upsertSystemSetting({
|
||||||
|
category: CATEGORY,
|
||||||
|
key: k("totpBackupCodes", userId),
|
||||||
|
value: "[]",
|
||||||
|
valueType: "json",
|
||||||
|
updatedBy: userId,
|
||||||
|
})
|
||||||
|
}
|
||||||
108
src/modules/settings/lib/security-utils.test.ts
Normal file
108
src/modules/settings/lib/security-utils.test.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import { parseUserAgent, formatRelativeTime } from "./security-utils"
|
||||||
|
|
||||||
|
describe("security-utils", () => {
|
||||||
|
describe("parseUserAgent", () => {
|
||||||
|
it("should return Unknown for null input", () => {
|
||||||
|
expect(parseUserAgent(null)).toEqual({ device: "Unknown", browser: "Unknown" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return Unknown for empty string", () => {
|
||||||
|
expect(parseUserAgent("")).toEqual({ device: "Unknown", browser: "Unknown" })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect Mobile device for iPhone", () => {
|
||||||
|
const ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15"
|
||||||
|
const result = parseUserAgent(ua)
|
||||||
|
expect(result.device).toBe("Mobile")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect Mobile device for Android", () => {
|
||||||
|
const ua = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
|
||||||
|
const result = parseUserAgent(ua)
|
||||||
|
expect(result.device).toBe("Mobile")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect Tablet device for iPad", () => {
|
||||||
|
const ua = "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15"
|
||||||
|
const result = parseUserAgent(ua)
|
||||||
|
expect(result.device).toBe("Tablet")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect Desktop device for Windows", () => {
|
||||||
|
const ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||||
|
const result = parseUserAgent(ua)
|
||||||
|
expect(result.device).toBe("Desktop")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect Edge browser", () => {
|
||||||
|
const ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"
|
||||||
|
const result = parseUserAgent(ua)
|
||||||
|
expect(result.browser).toBe("Edge")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect Chrome browser", () => {
|
||||||
|
const ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
const result = parseUserAgent(ua)
|
||||||
|
expect(result.browser).toBe("Chrome")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect Firefox browser", () => {
|
||||||
|
const ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
|
||||||
|
const result = parseUserAgent(ua)
|
||||||
|
expect(result.browser).toBe("Firefox")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect Safari browser (without Chrome)", () => {
|
||||||
|
const ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
|
||||||
|
const result = parseUserAgent(ua)
|
||||||
|
expect(result.browser).toBe("Safari")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return Unknown browser for unrecognized UA", () => {
|
||||||
|
const ua = "SomeCustomBrowser/1.0"
|
||||||
|
const result = parseUserAgent(ua)
|
||||||
|
expect(result.browser).toBe("Unknown")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("formatRelativeTime", () => {
|
||||||
|
it("should return '刚刚' for zh locale when less than 1 minute ago", () => {
|
||||||
|
const now = new Date()
|
||||||
|
expect(formatRelativeTime(now.toISOString(), "zh")).toBe("刚刚")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return 'just now' for en locale when less than 1 minute ago", () => {
|
||||||
|
const now = new Date()
|
||||||
|
expect(formatRelativeTime(now.toISOString(), "en")).toBe("just now")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return minutes ago for zh locale", () => {
|
||||||
|
const past = new Date(Date.now() - 5 * 60 * 1000) // 5 minutes ago
|
||||||
|
const result = formatRelativeTime(past.toISOString(), "zh")
|
||||||
|
expect(result).toContain("5")
|
||||||
|
expect(result).toContain("分钟")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return hours ago for en locale", () => {
|
||||||
|
const past = new Date(Date.now() - 3 * 60 * 60 * 1000) // 3 hours ago
|
||||||
|
const result = formatRelativeTime(past.toISOString(), "en")
|
||||||
|
expect(result).toContain("3")
|
||||||
|
expect(result).toContain("hour")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return days ago for en locale", () => {
|
||||||
|
const past = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000) // 5 days ago
|
||||||
|
const result = formatRelativeTime(past.toISOString(), "en")
|
||||||
|
expect(result).toContain("5")
|
||||||
|
expect(result).toContain("day")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return formatted date for dates older than 30 days", () => {
|
||||||
|
const past = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000) // 60 days ago
|
||||||
|
const result = formatRelativeTime(past.toISOString(), "en")
|
||||||
|
// Should be a date string, not a relative time
|
||||||
|
expect(result).toMatch(/\d/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
45
src/modules/settings/lib/security-utils.ts
Normal file
45
src/modules/settings/lib/security-utils.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* 安全中心相关纯函数
|
||||||
|
*
|
||||||
|
* 从 security-center-card.tsx 抽出,便于单元测试。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 User-Agent 字符串,返回设备类型和浏览器类型
|
||||||
|
*/
|
||||||
|
export function parseUserAgent(ua: string | null): {
|
||||||
|
device: string
|
||||||
|
browser: string
|
||||||
|
} {
|
||||||
|
if (!ua) return { device: "Unknown", browser: "Unknown" }
|
||||||
|
const device = /Mobile|Android|iPhone/i.test(ua)
|
||||||
|
? "Mobile"
|
||||||
|
: /iPad|Tablet/i.test(ua)
|
||||||
|
? "Tablet"
|
||||||
|
: "Desktop"
|
||||||
|
let browser = "Unknown"
|
||||||
|
if (/Edg\//i.test(ua)) browser = "Edge"
|
||||||
|
else if (/Chrome\//i.test(ua)) browser = "Chrome"
|
||||||
|
else if (/Firefox\//i.test(ua)) browser = "Firefox"
|
||||||
|
else if (/Safari\//i.test(ua)) browser = "Safari"
|
||||||
|
return { device, browser }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 ISO 时间字符串格式化为相对时间(如"刚刚"、"5 分钟前")
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(iso: string, locale: string): string {
|
||||||
|
const date = new Date(iso)
|
||||||
|
const now = Date.now()
|
||||||
|
const diffMs = now - date.getTime()
|
||||||
|
const diffMin = Math.floor(diffMs / 60000)
|
||||||
|
const diffHour = Math.floor(diffMin / 60)
|
||||||
|
const diffDay = Math.floor(diffHour / 24)
|
||||||
|
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" })
|
||||||
|
if (diffMin < 1) return locale === "zh" ? "刚刚" : "just now"
|
||||||
|
if (diffMin < 60) return rtf.format(-diffMin, "minute")
|
||||||
|
if (diffHour < 24) return rtf.format(-diffHour, "hour")
|
||||||
|
if (diffDay < 30) return rtf.format(-diffDay, "day")
|
||||||
|
return date.toLocaleDateString(locale)
|
||||||
|
}
|
||||||
180
src/modules/settings/lib/student-overview-data.test.ts
Normal file
180
src/modules/settings/lib/student-overview-data.test.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
|
||||||
|
import {
|
||||||
|
computeStudentStats,
|
||||||
|
sortUpcomingAssignments,
|
||||||
|
filterTodaySchedule,
|
||||||
|
toWeekday,
|
||||||
|
buildStudentOverviewData,
|
||||||
|
} from "./student-overview-data"
|
||||||
|
|
||||||
|
// 测试辅助:构造最小合法 StudentHomeworkAssignmentListItem
|
||||||
|
function makeAssignment(
|
||||||
|
over: Partial<StudentHomeworkAssignmentListItem> & Pick<StudentHomeworkAssignmentListItem, "id" | "dueAt" | "progressStatus">
|
||||||
|
): StudentHomeworkAssignmentListItem {
|
||||||
|
return {
|
||||||
|
title: "Test Assignment",
|
||||||
|
subjectName: null,
|
||||||
|
availableAt: null,
|
||||||
|
maxAttempts: 1,
|
||||||
|
attemptsUsed: 0,
|
||||||
|
latestSubmissionId: null,
|
||||||
|
latestSubmittedAt: null,
|
||||||
|
latestScore: null,
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("student-overview-data", () => {
|
||||||
|
describe("toWeekday", () => {
|
||||||
|
it("should return 1 for Monday", () => {
|
||||||
|
const monday = new Date("2024-01-01T10:00:00Z") // Monday
|
||||||
|
expect(toWeekday(monday)).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return 7 for Sunday", () => {
|
||||||
|
const sunday = new Date("2024-01-07T10:00:00Z") // Sunday
|
||||||
|
expect(toWeekday(sunday)).toBe(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return 4 for Thursday", () => {
|
||||||
|
const thursday = new Date("2024-01-04T10:00:00Z") // Thursday
|
||||||
|
expect(toWeekday(thursday)).toBe(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("computeStudentStats", () => {
|
||||||
|
const now = new Date("2024-06-15T10:00:00Z")
|
||||||
|
|
||||||
|
it("should return all zeros for empty assignments", () => {
|
||||||
|
const stats = computeStudentStats([], now)
|
||||||
|
expect(stats).toEqual({ dueSoonCount: 0, overdueCount: 0, gradedCount: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should count graded assignments", () => {
|
||||||
|
const assignments = [
|
||||||
|
{ id: "1", dueAt: null, progressStatus: "graded" },
|
||||||
|
{ id: "2", dueAt: null, progressStatus: "not_started" },
|
||||||
|
{ id: "3", dueAt: null, progressStatus: "graded" },
|
||||||
|
]
|
||||||
|
const stats = computeStudentStats(assignments, now)
|
||||||
|
expect(stats.gradedCount).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should count overdue assignments (not graded, due before now)", () => {
|
||||||
|
const pastDate = new Date("2024-06-10T10:00:00Z").toISOString()
|
||||||
|
const assignments = [
|
||||||
|
{ id: "1", dueAt: pastDate, progressStatus: "not_started" },
|
||||||
|
{ id: "2", dueAt: pastDate, progressStatus: "graded" }, // graded → not overdue
|
||||||
|
{ id: "3", dueAt: null, progressStatus: "not_started" }, // no due date → not overdue
|
||||||
|
]
|
||||||
|
const stats = computeStudentStats(assignments, now)
|
||||||
|
expect(stats.overdueCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should count due-soon assignments (due within 7 days, not graded)", () => {
|
||||||
|
const in3Days = new Date("2024-06-18T10:00:00Z").toISOString()
|
||||||
|
const in10Days = new Date("2024-06-25T10:00:00Z").toISOString()
|
||||||
|
const assignments = [
|
||||||
|
{ id: "1", dueAt: in3Days, progressStatus: "not_started" }, // due soon
|
||||||
|
{ id: "2", dueAt: in10Days, progressStatus: "not_started" }, // too far
|
||||||
|
{ id: "3", dueAt: in3Days, progressStatus: "graded" }, // graded → not due soon
|
||||||
|
]
|
||||||
|
const stats = computeStudentStats(assignments, now)
|
||||||
|
expect(stats.dueSoonCount).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("sortUpcomingAssignments", () => {
|
||||||
|
it("should sort by dueAt ascending (nulls last)", () => {
|
||||||
|
const assignments = [
|
||||||
|
{ id: "c", dueAt: null, progressStatus: "not_started" },
|
||||||
|
{ id: "a", dueAt: "2024-06-20T10:00:00Z", progressStatus: "not_started" },
|
||||||
|
{ id: "b", dueAt: "2024-06-18T10:00:00Z", progressStatus: "not_started" },
|
||||||
|
]
|
||||||
|
const sorted = sortUpcomingAssignments(assignments)
|
||||||
|
expect(sorted[0].id).toBe("b")
|
||||||
|
expect(sorted[1].id).toBe("a")
|
||||||
|
expect(sorted[2].id).toBe("c")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should respect limit parameter", () => {
|
||||||
|
const assignments = Array.from({ length: 10 }, (_, i) => ({
|
||||||
|
id: String(i),
|
||||||
|
dueAt: new Date(2024, 5, 20 + i).toISOString(),
|
||||||
|
progressStatus: "not_started",
|
||||||
|
}))
|
||||||
|
const sorted = sortUpcomingAssignments(assignments, 3)
|
||||||
|
expect(sorted).toHaveLength(3)
|
||||||
|
expect(sorted[0].id).toBe("0")
|
||||||
|
expect(sorted[2].id).toBe("2")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not mutate original array", () => {
|
||||||
|
const assignments = [
|
||||||
|
{ id: "b", dueAt: "2024-06-18T10:00:00Z", progressStatus: "not_started" },
|
||||||
|
{ id: "a", dueAt: "2024-06-20T10:00:00Z", progressStatus: "not_started" },
|
||||||
|
]
|
||||||
|
const original = [...assignments]
|
||||||
|
sortUpcomingAssignments(assignments)
|
||||||
|
expect(assignments.map((a) => a.id)).toEqual(original.map((a) => a.id))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("filterTodaySchedule", () => {
|
||||||
|
it("should filter items matching today's weekday", () => {
|
||||||
|
// 2024-06-17 is Monday (weekday 1)
|
||||||
|
const monday = new Date("2024-06-17T10:00:00Z")
|
||||||
|
const schedule = [
|
||||||
|
{ id: "1", classId: "c1", className: "Class 1", course: "Math", startTime: "08:00", endTime: "09:00", location: "Room 1", weekday: 1 },
|
||||||
|
{ id: "2", classId: "c2", className: "Class 2", course: "English", startTime: "10:00", endTime: "11:00", location: null, weekday: 2 },
|
||||||
|
{ id: "3", classId: "c3", className: "Class 3", course: "Science", startTime: "14:00", endTime: "15:00", location: "Lab", weekday: 1 },
|
||||||
|
]
|
||||||
|
const result = filterTodaySchedule(schedule, monday)
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result[0].id).toBe("1")
|
||||||
|
expect(result[1].id).toBe("3")
|
||||||
|
expect(result[0].location).toBe("Room 1")
|
||||||
|
expect(result[1].location).toBe("Lab")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return empty array when no items match today's weekday", () => {
|
||||||
|
const monday = new Date("2024-06-17T10:00:00Z")
|
||||||
|
const schedule = [
|
||||||
|
{ id: "1", classId: "c1", className: "Class 1", course: "Math", startTime: "08:00", endTime: "09:00", location: null, weekday: 2 },
|
||||||
|
]
|
||||||
|
const result = filterTodaySchedule(schedule, monday)
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("buildStudentOverviewData", () => {
|
||||||
|
it("should assemble complete overview data", () => {
|
||||||
|
const now = new Date("2024-06-17T10:00:00Z") // Monday
|
||||||
|
const classes = [{ id: "1" }, { id: "2" }]
|
||||||
|
const schedule = [
|
||||||
|
{ id: "s1", classId: "c1", className: "Class 1", course: "Math", startTime: "08:00", endTime: "09:00", location: "Room 1", weekday: 1 },
|
||||||
|
]
|
||||||
|
const assignments = [
|
||||||
|
makeAssignment({ id: "a1", dueAt: "2024-06-18T10:00:00Z", progressStatus: "not_started" }),
|
||||||
|
]
|
||||||
|
const grades = { math: 90 }
|
||||||
|
|
||||||
|
const result = buildStudentOverviewData({
|
||||||
|
classes,
|
||||||
|
schedule,
|
||||||
|
assignments,
|
||||||
|
grades,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.enrolledClassCount).toBe(2)
|
||||||
|
expect(result.dueSoonCount).toBe(1)
|
||||||
|
expect(result.overdueCount).toBe(0)
|
||||||
|
expect(result.gradedCount).toBe(0)
|
||||||
|
expect(result.todayScheduleItems).toHaveLength(1)
|
||||||
|
expect(result.upcomingAssignments).toHaveLength(1)
|
||||||
|
expect(result.grades).toEqual({ math: 90 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import "server-only"
|
|
||||||
|
|
||||||
import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
|
import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
214
src/modules/settings/lib/totp.test.ts
Normal file
214
src/modules/settings/lib/totp.test.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { describe, it, expect, expectTypeOf } from "vitest"
|
||||||
|
import {
|
||||||
|
generateTotpSecret,
|
||||||
|
buildOtpAuthUrl,
|
||||||
|
verifyTotpCode,
|
||||||
|
generateBackupCodes,
|
||||||
|
hashBackupCodes,
|
||||||
|
verifyBackupCode,
|
||||||
|
consumeBackupCode,
|
||||||
|
countRemainingBackupCodes,
|
||||||
|
} from "./totp"
|
||||||
|
|
||||||
|
describe("totp - generateTotpSecret", () => {
|
||||||
|
it("returns a non-empty base32 string", () => {
|
||||||
|
const secret = generateTotpSecret()
|
||||||
|
expect(typeof secret).toBe("string")
|
||||||
|
expect(secret.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("generates different secrets on each call", () => {
|
||||||
|
const s1 = generateTotpSecret()
|
||||||
|
const s2 = generateTotpSecret()
|
||||||
|
expect(s1).not.toBe(s2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("only contains base32 characters (A-Z, 2-7)", () => {
|
||||||
|
const secret = generateTotpSecret()
|
||||||
|
expect(secret).toMatch(/^[A-Z2-7]+$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("totp - buildOtpAuthUrl", () => {
|
||||||
|
it("builds a valid otpauth:// URL for TOTP", () => {
|
||||||
|
const secret = "JBSWY3DPEHPK3PXP"
|
||||||
|
const url = buildOtpAuthUrl({
|
||||||
|
serviceName: "Next_Edu",
|
||||||
|
accountName: "user@example.com",
|
||||||
|
secret,
|
||||||
|
})
|
||||||
|
expect(url).toContain("otpauth://totp/")
|
||||||
|
expect(url).toContain("issuer=Next_Edu")
|
||||||
|
expect(url).toContain(`secret=${secret}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("URL-encodes the issuer and label", () => {
|
||||||
|
const url = buildOtpAuthUrl({
|
||||||
|
serviceName: "My Service",
|
||||||
|
accountName: "user@example.com",
|
||||||
|
secret: "JBSWY3DPEHPK3PXP",
|
||||||
|
})
|
||||||
|
// Spaces in issuer should be encoded
|
||||||
|
expect(url).toContain("My%20Service")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("totp - verifyTotpCode", () => {
|
||||||
|
it("returns false for an invalid token", () => {
|
||||||
|
const secret = generateTotpSecret()
|
||||||
|
expect(verifyTotpCode("000000", secret)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false for empty token", () => {
|
||||||
|
const secret = generateTotpSecret()
|
||||||
|
expect(verifyTotpCode("", secret)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false for malformed token", () => {
|
||||||
|
const secret = generateTotpSecret()
|
||||||
|
expect(verifyTotpCode("not-a-code", secret)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles whitespace in token", () => {
|
||||||
|
const secret = generateTotpSecret()
|
||||||
|
// Should not throw even with whitespace
|
||||||
|
expect(() => verifyTotpCode(" 123 456 ", secret)).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("totp - generateBackupCodes", () => {
|
||||||
|
it("generates 10 backup codes by default", () => {
|
||||||
|
const codes = generateBackupCodes()
|
||||||
|
expect(codes).toHaveLength(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("each code is 8 characters long", () => {
|
||||||
|
const codes = generateBackupCodes()
|
||||||
|
for (const code of codes) {
|
||||||
|
expect(code).toHaveLength(8)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("codes only contain safe alphabet characters (no 0/O/1/I)", () => {
|
||||||
|
const codes = generateBackupCodes()
|
||||||
|
const safeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
for (const code of codes) {
|
||||||
|
for (const ch of code) {
|
||||||
|
expect(safeAlphabet).toContain(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("generates different codes on each call", () => {
|
||||||
|
const c1 = generateBackupCodes()
|
||||||
|
const c2 = generateBackupCodes()
|
||||||
|
// Extremely unlikely all 10 codes match
|
||||||
|
expect(c1).not.toEqual(c2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("totp - hashBackupCodes & verifyBackupCode", () => {
|
||||||
|
it("hashes codes and verifies a correct code", async () => {
|
||||||
|
const codes = ["ABCDEFGH", "JKLMNOPQ"]
|
||||||
|
const hashed = await hashBackupCodes(codes)
|
||||||
|
expect(typeof hashed).toBe("string")
|
||||||
|
const parsed = JSON.parse(hashed) as unknown
|
||||||
|
expect(Array.isArray(parsed)).toBe(true)
|
||||||
|
expect(parsed).toHaveLength(2)
|
||||||
|
|
||||||
|
const idx = await verifyBackupCode("ABCDEFGH", hashed)
|
||||||
|
expect(idx).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns -1 for incorrect code", async () => {
|
||||||
|
const codes = ["ABCDEFGH"]
|
||||||
|
const hashed = await hashBackupCodes(codes)
|
||||||
|
const idx = await verifyBackupCode("WRONGCODE", hashed)
|
||||||
|
expect(idx).toBe(-1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("is case-insensitive (normalizes to uppercase)", async () => {
|
||||||
|
const codes = ["ABCDEFGH"]
|
||||||
|
const hashed = await hashBackupCodes(codes)
|
||||||
|
const idx = await verifyBackupCode("abcdefgh", hashed)
|
||||||
|
expect(idx).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("trims whitespace before verifying", async () => {
|
||||||
|
const codes = ["ABCDEFGH"]
|
||||||
|
const hashed = await hashBackupCodes(codes)
|
||||||
|
const idx = await verifyBackupCode(" ABCDEFGH ", hashed)
|
||||||
|
expect(idx).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns -1 for invalid JSON storage", async () => {
|
||||||
|
const idx = await verifyBackupCode("ABCDEFGH", "not-json")
|
||||||
|
expect(idx).toBe(-1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns -1 for non-array JSON", async () => {
|
||||||
|
const idx = await verifyBackupCode("ABCDEFGH", '{"valid": false}')
|
||||||
|
expect(idx).toBe(-1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns correct index for second code in list", async () => {
|
||||||
|
const codes = ["FIRSTCD", "SECONDAB", "THIRDXYZ"]
|
||||||
|
const hashed = await hashBackupCodes(codes)
|
||||||
|
const idx = await verifyBackupCode("SECONDAB", hashed)
|
||||||
|
expect(idx).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("totp - consumeBackupCode", () => {
|
||||||
|
it("removes the used code from the list", async () => {
|
||||||
|
const codes = ["AAAA1111", "BBBB2222", "CCCC3333"]
|
||||||
|
const hashed = await hashBackupCodes(codes)
|
||||||
|
const next = await consumeBackupCode(hashed, 1)
|
||||||
|
const remaining = JSON.parse(next) as unknown[]
|
||||||
|
expect(remaining).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns original JSON for invalid index (-1)", async () => {
|
||||||
|
const hashed = await hashBackupCodes(["AAAA1111"])
|
||||||
|
const next = await consumeBackupCode(hashed, -1)
|
||||||
|
expect(next).toBe(hashed)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns original JSON for out-of-bounds index", async () => {
|
||||||
|
const hashed = await hashBackupCodes(["AAAA1111"])
|
||||||
|
const next = await consumeBackupCode(hashed, 99)
|
||||||
|
expect(next).toBe(hashed)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns original JSON for invalid JSON input", async () => {
|
||||||
|
const next = await consumeBackupCode("not-json", 0)
|
||||||
|
expect(next).toBe("not-json")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("totp - countRemainingBackupCodes", () => {
|
||||||
|
it("counts codes in a valid array", async () => {
|
||||||
|
const hashed = await hashBackupCodes(["A", "B", "C"])
|
||||||
|
expect(countRemainingBackupCodes(hashed)).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns 0 for empty array", () => {
|
||||||
|
expect(countRemainingBackupCodes("[]")).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns 0 for invalid JSON", () => {
|
||||||
|
expect(countRemainingBackupCodes("not-json")).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns 0 for non-array JSON", () => {
|
||||||
|
expect(countRemainingBackupCodes('{"a":1}')).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("totp - type safety", () => {
|
||||||
|
it("verifyTotpCode returns boolean", () => {
|
||||||
|
const secret = generateTotpSecret()
|
||||||
|
const result = verifyTotpCode("123456", secret)
|
||||||
|
expectTypeOf(result).toBeBoolean()
|
||||||
|
})
|
||||||
|
})
|
||||||
158
src/modules/settings/lib/totp.ts
Normal file
158
src/modules/settings/lib/totp.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { generateSecret, generateURI, verifySync } from "otplib"
|
||||||
|
import QRCode from "qrcode"
|
||||||
|
import { hash, compare } from "bcryptjs"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TOTP (Time-based One-Time Password) 服务
|
||||||
|
*
|
||||||
|
* 基于 otplib v13 函数式 API 实现,遵循 RFC 6238。
|
||||||
|
* - 生成密钥 + otpauth URL + QR 码 Data URL
|
||||||
|
* - 校验一次性码(允许 ±30s 窗口防止时钟漂移)
|
||||||
|
* - 生成 + 哈希 + 校验备份码(10 个,8 位字母数字)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BACKUP_CODE_COUNT = 10
|
||||||
|
const BACKUP_CODE_LENGTH = 8
|
||||||
|
const BACKUP_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
const BCRYPT_ROUNDS = 10
|
||||||
|
/** TOTP 时间步长(秒) */
|
||||||
|
const TOTP_PERIOD = 30
|
||||||
|
/** 时钟漂移容差(秒),±30s 即 ±1 个窗口 */
|
||||||
|
const TOTP_TOLERANCE = 30
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 TOTP 密钥(base32)
|
||||||
|
*/
|
||||||
|
export function generateTotpSecret(): string {
|
||||||
|
return generateSecret()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 otpauth:// URL,用于二维码扫描
|
||||||
|
*/
|
||||||
|
export function buildOtpAuthUrl(params: {
|
||||||
|
serviceName: string
|
||||||
|
accountName: string
|
||||||
|
secret: string
|
||||||
|
}): string {
|
||||||
|
return generateURI({
|
||||||
|
issuer: params.serviceName,
|
||||||
|
label: params.accountName,
|
||||||
|
secret: params.secret,
|
||||||
|
period: TOTP_PERIOD,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 otpauth URL 转换为 QR 码 Data URL(base64 PNG)
|
||||||
|
*/
|
||||||
|
export async function generateQrCodeDataUrl(otpAuthUrl: string): Promise<string> {
|
||||||
|
return QRCode.toDataURL(otpAuthUrl, {
|
||||||
|
width: 240,
|
||||||
|
margin: 1,
|
||||||
|
errorCorrectionLevel: "M",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 TOTP 一次性码
|
||||||
|
*
|
||||||
|
* 使用同步 API(NobleCryptoPlugin 支持 sync HMAC)。
|
||||||
|
* epochTolerance=30 允许 ±1 个 30s 窗口。
|
||||||
|
*/
|
||||||
|
export function verifyTotpCode(token: string, secret: string): boolean {
|
||||||
|
try {
|
||||||
|
const result = verifySync({
|
||||||
|
secret,
|
||||||
|
token: token.replace(/\s+/g, ""),
|
||||||
|
period: TOTP_PERIOD,
|
||||||
|
epochTolerance: TOTP_TOLERANCE,
|
||||||
|
})
|
||||||
|
return result.valid
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成一批备份码(明文,返回给用户展示一次)
|
||||||
|
*
|
||||||
|
* 格式:8 位字母数字,去除易混淆字符(0/O/1/I)
|
||||||
|
*/
|
||||||
|
export function generateBackupCodes(): string[] {
|
||||||
|
const codes: string[] = []
|
||||||
|
for (let i = 0; i < BACKUP_CODE_COUNT; i++) {
|
||||||
|
let code = ""
|
||||||
|
for (let j = 0; j < BACKUP_CODE_LENGTH; j++) {
|
||||||
|
const idx = Math.floor(Math.random() * BACKUP_CODE_ALPHABET.length)
|
||||||
|
code += BACKUP_CODE_ALPHABET[idx]
|
||||||
|
}
|
||||||
|
codes.push(code)
|
||||||
|
}
|
||||||
|
return codes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将备份码列表哈希为 JSON 数组字符串(存入 DB)
|
||||||
|
*/
|
||||||
|
export async function hashBackupCodes(codes: string[]): Promise<string> {
|
||||||
|
const hashed = await Promise.all(codes.map((c) => hash(c, BCRYPT_ROUNDS)))
|
||||||
|
return JSON.stringify(hashed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验用户输入的备份码是否匹配已存储的哈希列表。
|
||||||
|
* 返回匹配的索引(>=0),未匹配返回 -1。
|
||||||
|
*
|
||||||
|
* 注意:bcrypt 比较较慢,10 个码串行约需 300ms,可接受。
|
||||||
|
*/
|
||||||
|
export async function verifyBackupCode(
|
||||||
|
input: string,
|
||||||
|
storedHashedJson: string,
|
||||||
|
): Promise<number> {
|
||||||
|
let hashed: string[] = []
|
||||||
|
try {
|
||||||
|
hashed = JSON.parse(storedHashedJson) as string[]
|
||||||
|
if (!Array.isArray(hashed)) return -1
|
||||||
|
} catch {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = input.trim().toUpperCase()
|
||||||
|
for (let i = 0; i < hashed.length; i++) {
|
||||||
|
const ok = await compare(normalized, hashed[i])
|
||||||
|
if (ok) return i
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从已存储的哈希列表中移除已使用的备份码,返回新的 JSON 字符串。
|
||||||
|
*/
|
||||||
|
export async function consumeBackupCode(
|
||||||
|
storedHashedJson: string,
|
||||||
|
usedIndex: number,
|
||||||
|
): Promise<string> {
|
||||||
|
let hashed: string[] = []
|
||||||
|
try {
|
||||||
|
hashed = JSON.parse(storedHashedJson) as string[]
|
||||||
|
} catch {
|
||||||
|
return storedHashedJson
|
||||||
|
}
|
||||||
|
if (usedIndex < 0 || usedIndex >= hashed.length) return storedHashedJson
|
||||||
|
const next = hashed.filter((_, i) => i !== usedIndex)
|
||||||
|
return JSON.stringify(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计剩余备份码数量
|
||||||
|
*/
|
||||||
|
export function countRemainingBackupCodes(storedHashedJson: string): number {
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(storedHashedJson) as unknown
|
||||||
|
if (!Array.isArray(arr)) return 0
|
||||||
|
return arr.length
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,9 +28,11 @@ export interface AiProviderExisting {
|
|||||||
* 个人资料服务接口(解耦 settings 组件对 users/actions 的直接依赖)
|
* 个人资料服务接口(解耦 settings 组件对 users/actions 的直接依赖)
|
||||||
*
|
*
|
||||||
* 由页面层注入实现,组件层通过 useSettingsService().profile 消费。
|
* 由页面层注入实现,组件层通过 useSettingsService().profile 消费。
|
||||||
|
* 注意:getProfile 为可选,因为数据已通过 props 传递;
|
||||||
|
* updateProfile 必须是 "use server" 标记的 Server Action 引用。
|
||||||
*/
|
*/
|
||||||
export interface ProfileService {
|
export interface ProfileService {
|
||||||
getProfile: () => Promise<UserProfile | null>
|
getProfile?: () => Promise<UserProfile | null>
|
||||||
updateProfile: (input: UpdateUserProfileInput) => Promise<ActionState<void>>
|
updateProfile: (input: UpdateUserProfileInput) => Promise<ActionState<void>>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,9 +40,11 @@ export interface ProfileService {
|
|||||||
* 通知偏好服务接口(解耦 settings 组件对 messaging/actions 的直接依赖)
|
* 通知偏好服务接口(解耦 settings 组件对 messaging/actions 的直接依赖)
|
||||||
*
|
*
|
||||||
* 由页面层注入实现,组件层通过 useSettingsService().notifications 消费。
|
* 由页面层注入实现,组件层通过 useSettingsService().notifications 消费。
|
||||||
|
* 注意:getPreferences 为可选,因为数据已通过 props 传递;
|
||||||
|
* updatePreferences 必须是 "use server" 标记的 Server Action 引用。
|
||||||
*/
|
*/
|
||||||
export interface NotificationPreferenceService {
|
export interface NotificationPreferenceService {
|
||||||
getPreferences: () => Promise<NotificationPreferences>
|
getPreferences?: () => Promise<NotificationPreferences>
|
||||||
updatePreferences: (
|
updatePreferences: (
|
||||||
input: UpdateNotificationPreferencesInput
|
input: UpdateNotificationPreferencesInput
|
||||||
) => Promise<ActionState<NotificationPreferences>>
|
) => Promise<ActionState<NotificationPreferences>>
|
||||||
|
|||||||
Reference in New Issue
Block a user