feat(settings): add security center, 2FA/TOTP, avatar upload, system settings

- Add TOTP implementation and two-factor data-access for 2FA enrollment

- Add security center card with password policy and session management

- Add avatar upload action and component

- Add system settings actions and data-access (actions-system-settings, data-access-system-settings)

- Add notification preferences and service actions

- Add security-utils and student-overview-data with tests

- Update existing settings views, data-access, and types for new features
This commit is contained in:
SpecialX
2026-06-23 17:37:06 +08:00
parent 242a770cc9
commit 1fcef5c3aa
22 changed files with 3091 additions and 52 deletions

View File

@@ -0,0 +1,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>
)
}