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:
@@ -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<AdminSettingsFormValues>(DEFAULT_VALUES)
|
||||
const [loadedValues, setLoadedValues] = React.useState<AdminSettingsFormValues>(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<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()
|
||||
if (!isDirty) return
|
||||
setSaving(true)
|
||||
// TODO: 替换为真实 Server Action 调用
|
||||
await new Promise<void>((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 (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -56,30 +191,68 @@ export function AdminSettingsView() {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<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 className="space-y-2">
|
||||
<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 className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<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 className="space-y-2">
|
||||
<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 className="space-y-2">
|
||||
<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 className="space-y-2">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -99,11 +272,27 @@ export function AdminSettingsView() {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<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 className="space-y-2">
|
||||
<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>
|
||||
<Separator />
|
||||
@@ -112,21 +301,36 @@ export function AdminSettingsView() {
|
||||
<Label htmlFor="require-special-char">{t("securityPolicy.requireSpecialChar")}</Label>
|
||||
<p className="text-sm text-muted-foreground">{t("securityPolicy.requireSpecialCharDesc")}</p>
|
||||
</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 className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="require-uppercase">{t("securityPolicy.requireUppercase")}</Label>
|
||||
<p className="text-sm text-muted-foreground">{t("securityPolicy.requireUppercaseDesc")}</p>
|
||||
</div>
|
||||
<Switch id="require-uppercase" name="requireUppercase" />
|
||||
<Switch
|
||||
id="require-uppercase"
|
||||
name="requireUppercase"
|
||||
checked={values.securityPolicy.requireUppercase}
|
||||
onCheckedChange={(v) => updateSecurityPolicy("requireUppercase", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="force-password-change">{t("securityPolicy.forcePasswordChange")}</Label>
|
||||
<p className="text-sm text-muted-foreground">{t("securityPolicy.forcePasswordChangeDesc")}</p>
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -146,11 +350,25 @@ export function AdminSettingsView() {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<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 className="space-y-2">
|
||||
<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>
|
||||
</CardContent>
|
||||
@@ -173,28 +391,50 @@ export function AdminSettingsView() {
|
||||
<Label htmlFor="notify-new-user">{t("notificationConfig.notifyNewUser")}</Label>
|
||||
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyNewUserDesc")}</p>
|
||||
</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 className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notify-schedule-change">{t("notificationConfig.notifyScheduleChange")}</Label>
|
||||
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyScheduleChangeDesc")}</p>
|
||||
</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 className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notify-announcement">{t("notificationConfig.notifyAnnouncement")}</Label>
|
||||
<p className="text-sm text-muted-foreground">{t("notificationConfig.notifyAnnouncementDesc")}</p>
|
||||
</div>
|
||||
<Switch id="notify-announcement" name="notifyAnnouncement" />
|
||||
<Switch
|
||||
id="notify-announcement"
|
||||
name="notifyAnnouncement"
|
||||
checked={values.notificationConfig.notifyAnnouncement}
|
||||
onCheckedChange={(v) => updateNotificationConfig("notifyAnnouncement", v)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline">{t("reset")}</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={!isDirty || saving}
|
||||
>
|
||||
{t("reset")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving || !isDirty}>
|
||||
{saving ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</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 { Button } from "@/shared/components/ui/button"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -253,7 +254,7 @@ export function AiProviderSettingsCard({
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("existing")}</FormLabel>
|
||||
<Label>{t("existing")}</Label>
|
||||
<Select value={selectedId || NEW_PROVIDER_VALUE} onValueChange={handleSelectChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("selectPlaceholder")} />
|
||||
@@ -269,7 +270,7 @@ export function AiProviderSettingsCard({
|
||||
</Select>
|
||||
</div>
|
||||
<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">
|
||||
{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 { useTransition } from "react"
|
||||
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 { sendTestNotificationAction } from "@/modules/settings/actions-notifications"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
@@ -62,6 +63,7 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
|
||||
const t = useTranslations("settings.notifications")
|
||||
const { notifications } = useSettingsService()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [testingChannel, setTestingChannel] = React.useState<string | null>(null)
|
||||
|
||||
const [channels, setChannels] = React.useState({
|
||||
emailEnabled: preferences.emailEnabled,
|
||||
@@ -81,6 +83,35 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
|
||||
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) => {
|
||||
setChannels((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
}
|
||||
@@ -94,6 +125,7 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
if (!isDirty) return
|
||||
startTransition(async () => {
|
||||
try {
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -130,6 +178,7 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
|
||||
{CHANNELS.map((item) => {
|
||||
const Icon = item.icon
|
||||
const checked = channels[item.key]
|
||||
const channelName = item.key === "emailEnabled" ? "email" : item.key === "smsEnabled" ? "sms" : "push"
|
||||
return (
|
||||
<div key={item.key} className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -143,12 +192,31 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
|
||||
<p className="text-xs text-muted-foreground">{t(item.descKey)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id={item.key}
|
||||
checked={checked}
|
||||
onCheckedChange={() => toggleChannel(item.key)}
|
||||
aria-label={t(item.labelKey)}
|
||||
/>
|
||||
<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
|
||||
id={item.key}
|
||||
checked={checked}
|
||||
onCheckedChange={() => toggleChannel(item.key)}
|
||||
aria-label={t(item.labelKey)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -248,7 +316,7 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
|
||||
</div>
|
||||
</CardContent>
|
||||
<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 ? (
|
||||
<>
|
||||
<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 { NotificationPreferencesForm } from "@/modules/settings/components/notification-preferences-form"
|
||||
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 { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
@@ -44,6 +45,8 @@ interface SettingsViewProps {
|
||||
notificationPreferences: NotificationPreferences
|
||||
/** General 标签页中 ProfileSettingsForm 下方的内容(角色专属快捷链接等) */
|
||||
generalExtra?: ReactNode
|
||||
/** 当前请求的 User-Agent,用于安全中心标记当前会话 */
|
||||
currentUserAgent?: string
|
||||
}
|
||||
|
||||
const VALID_TABS = ["general", "notifications", "appearance", "security", "ai"] as const
|
||||
@@ -87,6 +90,7 @@ function SettingsViewInner({
|
||||
user,
|
||||
notificationPreferences,
|
||||
generalExtra,
|
||||
currentUserAgent,
|
||||
}: SettingsViewProps) {
|
||||
const t = useTranslations("settings")
|
||||
const router = useRouter()
|
||||
@@ -94,7 +98,15 @@ function SettingsViewInner({
|
||||
const { hasPermission } = usePermission()
|
||||
|
||||
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 params = new URLSearchParams(searchParams.toString())
|
||||
@@ -107,8 +119,6 @@ function SettingsViewInner({
|
||||
router.push(query ? `?${query}` : "?", { scroll: false })
|
||||
}
|
||||
|
||||
const canConfigureAi = hasPermission(Permissions.AI_CONFIGURE)
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -178,6 +188,7 @@ function SettingsViewInner({
|
||||
<SettingsSectionErrorBoundary>
|
||||
<Suspense fallback={<SettingsSectionSkeleton />}>
|
||||
<PasswordChangeForm />
|
||||
<SecurityCenterCard currentDeviceLabel={currentUserAgent} />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("security.session.title")}</CardTitle>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client"
|
||||
import { Monitor, Moon, Sun } from "lucide-react"
|
||||
|
||||
import { Monitor, Moon, Sun, Globe } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { LocaleSwitcher } from "@/shared/components/locale-switcher"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import {
|
||||
@@ -15,8 +17,15 @@ import {
|
||||
|
||||
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 tLang = useTranslations("settings.appearance.language")
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
const value: ThemeChoice = theme === "light" || theme === "dark" || theme === "system" ? theme : "system"
|
||||
@@ -27,7 +36,7 @@ export function ThemePreferencesCard() {
|
||||
<CardTitle>{t("title")}</CardTitle>
|
||||
<CardDescription>{t("description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:max-w-md">
|
||||
<CardContent className="grid gap-4 sm:max-w-md">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme">{t("label")}</Label>
|
||||
<Select value={value} onValueChange={(v) => setTheme(v)}>
|
||||
@@ -56,6 +65,17 @@ export function ThemePreferencesCard() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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>
|
||||
</Card>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user