From 1fcef5c3aae87793b32200116481961828544c80 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:37:06 +0800 Subject: [PATCH] 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 --- src/modules/settings/actions-avatar.ts | 111 +++ src/modules/settings/actions-notifications.ts | 61 ++ src/modules/settings/actions-security.ts | 486 +++++++++++++ src/modules/settings/actions-service.ts | 63 ++ .../settings/actions-system-settings.ts | 189 +++++ .../components/admin-settings-view.tsx | 296 +++++++- .../components/ai-provider-settings-card.tsx | 5 +- .../settings/components/avatar-upload.tsx | 186 +++++ .../notification-preferences-form.tsx | 84 ++- .../components/security-center-card.tsx | 645 ++++++++++++++++++ .../settings/components/settings-view.tsx | 17 +- .../components/theme-preferences-card.tsx | 26 +- .../settings/config/role-settings-config.tsx | 8 +- .../settings/data-access-system-settings.ts | 122 ++++ .../settings/data-access-two-factor.ts | 129 ++++ .../settings/lib/security-utils.test.ts | 108 +++ src/modules/settings/lib/security-utils.ts | 45 ++ .../lib/student-overview-data.test.ts | 180 +++++ .../settings/lib/student-overview-data.ts | 2 - src/modules/settings/lib/totp.test.ts | 214 ++++++ src/modules/settings/lib/totp.ts | 158 +++++ src/modules/settings/types.ts | 8 +- 22 files changed, 3091 insertions(+), 52 deletions(-) create mode 100644 src/modules/settings/actions-avatar.ts create mode 100644 src/modules/settings/actions-notifications.ts create mode 100644 src/modules/settings/actions-security.ts create mode 100644 src/modules/settings/actions-service.ts create mode 100644 src/modules/settings/actions-system-settings.ts create mode 100644 src/modules/settings/components/avatar-upload.tsx create mode 100644 src/modules/settings/components/security-center-card.tsx create mode 100644 src/modules/settings/data-access-system-settings.ts create mode 100644 src/modules/settings/data-access-two-factor.ts create mode 100644 src/modules/settings/lib/security-utils.test.ts create mode 100644 src/modules/settings/lib/security-utils.ts create mode 100644 src/modules/settings/lib/student-overview-data.test.ts create mode 100644 src/modules/settings/lib/totp.test.ts create mode 100644 src/modules/settings/lib/totp.ts diff --git a/src/modules/settings/actions-avatar.ts b/src/modules/settings/actions-avatar.ts new file mode 100644 index 0000000..1e0d654 --- /dev/null +++ b/src/modules/settings/actions-avatar.ts @@ -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 { + 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> { + 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> { + 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 } + } +} diff --git a/src/modules/settings/actions-notifications.ts b/src/modules/settings/actions-notifications.ts new file mode 100644 index 0000000..3171d99 --- /dev/null +++ b/src/modules/settings/actions-notifications.ts @@ -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 + +/** 将表单渠道名映射到 dispatcher 渠道名 */ +const CHANNEL_MAP: Record = { + push: "in_app", + email: "email", + sms: "sms", +} + +/** + * 发送测试通知 + * + * 向当前用户发送一条测试通知,用于验证通知渠道是否配置正确。 + * 调用 notifications/dispatcher.sendNotification 发送真实通知。 + */ +export async function sendTestNotificationAction( + input: TestNotificationInput +): Promise> { + 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 } + } +} diff --git a/src/modules/settings/actions-security.ts b/src/modules/settings/actions-security.ts new file mode 100644 index 0000000..9aa1d9d --- /dev/null +++ b/src/modules/settings/actions-security.ts @@ -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 { + // 一次查询 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 +> { + 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 +> { + 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> { + 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> { + 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> { + 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 } +} diff --git a/src/modules/settings/actions-service.ts b/src/modules/settings/actions-service.ts new file mode 100644 index 0000000..092f861 --- /dev/null +++ b/src/modules/settings/actions-service.ts @@ -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> { + return updateUserProfile(input) +} + +/** + * 更新通知偏好(Server Action wrapper) + * + * 将 UpdateNotificationPreferencesInput 直接传递给 data-access 层, + * 绕过 messaging/actions.ts 中基于 FormData 的签名,以适配 SettingsService 接口。 + */ +export async function updateNotificationPreferencesAction( + input: UpdateNotificationPreferencesInput +): Promise> { + 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" } + } +} diff --git a/src/modules/settings/actions-system-settings.ts b/src/modules/settings/actions-system-settings.ts new file mode 100644 index 0000000..b48abde --- /dev/null +++ b/src/modules/settings/actions-system-settings.ts @@ -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 + +// --- 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 +> { + try { + const ctx = await requirePermission(Permissions.SETTINGS_ADMIN) + void ctx + + const records = await getAllSystemSettings() + + const map = new Map() + 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> { + 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 } + } +} diff --git a/src/modules/settings/components/admin-settings-view.tsx b/src/modules/settings/components/admin-settings-view.tsx index 7e7cbb8..9c51c85 100644 --- a/src/modules/settings/components/admin-settings-view.tsx +++ b/src/modules/settings/components/admin-settings-view.tsx @@ -3,8 +3,12 @@ import * as React from "react" import { useTranslations } from "next-intl" 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 { Input } from "@/shared/components/ui/input" import { Label } from "@/shared/components/ui/label" @@ -13,24 +17,155 @@ import { Switch } from "@/shared/components/ui/switch" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" 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 模拟保存),未接入真实数据层。 - * 后续需新增 system_settings 表 + data-access + actions,替换 mock 逻辑。 - * 当前已适配 i18n,文本均通过 settings.admin.* 翻译键获取。 + * 通过 Server Actions 加载和保存系统设置,数据持久化到 system_settings 表。 + * 4 个 Card:学校信息 / 安全策略 / 文件上传 / 通知配置。 + * 所有文本通过 settings.admin.* i18n 键获取。 */ -export function AdminSettingsView() { +export function AdminSettingsView(): React.ReactElement { const t = useTranslations("settings.admin") + const [values, setValues] = React.useState(DEFAULT_VALUES) + const [loadedValues, setLoadedValues] = React.useState(DEFAULT_VALUES) + const [loading, setLoading] = React.useState(true) const [saving, setSaving] = React.useState(false) - const handleSave = async (e: React.FormEvent) => { + React.useEffect(() => { + let cancelled = false + async function load(): Promise { + 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 => { e.preventDefault() + if (!isDirty) return setSaving(true) - // TODO: 替换为真实 Server Action 调用 - await new Promise((resolve) => setTimeout(resolve, 800)) - toast.success(t("saveSuccess")) - setSaving(false) + try { + const result = await saveAdminSystemSettingsAction(values) + if (result.success) { + toast.success(t("saveSuccess")) + setLoadedValues(values) + } else { + toast.error(result.message || t("saveFailure")) + } + } catch { + toast.error(t("saveFailure")) + } finally { + 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 ( +
+ +
+ ) } return ( @@ -56,30 +191,68 @@ export function AdminSettingsView() {
- + updateSchoolInfo("schoolName", e.target.value)} + />
- + updateSchoolInfo("schoolCode", e.target.value)} + />
- + updateSchoolInfo("schoolPhone", e.target.value)} + />
- + updateSchoolInfo("schoolEmail", e.target.value)} + />
- + updateSchoolInfo("schoolAddress", e.target.value)} + />
-