fix: patch P0 security vulnerabilities and critical UX issues across 6 modules

Security: Add admin/layout.tsx auth guard; Add requirePermission() to 12 admin pages

Dashboard: Fix StudentStatsGrid rendering; Fix teacher greeting; Add loading/error boundaries; Fix col-span; Add metadata

Announcements: Fix audience filtering; Add user detail page; Trigger notifications on publish; Pass classes data; Add loading.tsx

Messages: Implement soft delete; Add unread badge with polling; Add notification dropdown polling; Add keyword search; Add quiet hours DND

Management: Add loading/error for 9 admin routes; Fix admin-classes-view to use Select for school/grade

Profile/Settings: Add loading/error; Fix parent role routing; Create ParentSettingsView; Integrate AiProviderSettingsCard; Add Tab URL persistence; Add logout confirm; Add avatar; Fix Progress arbitrary class

Schema: Add senderDeletedAt/receiverDeletedAt to messages; Add quietHours to notificationPreferences; Add uniqueIndex import

Docs: Update architecture docs 004/005
This commit is contained in:
SpecialX
2026-06-22 13:57:31 +08:00
parent 5ff7ab9e72
commit a4d096a6fc
81 changed files with 2145 additions and 124 deletions

View File

@@ -3,14 +3,16 @@
import * as React from "react"
import { useActionState } from "react"
import { useFormStatus } from "react-dom"
import { Loader2, Save, Bell, Mail, MessageSquare, Megaphone, GraduationCap, BookOpen, CalendarCheck } from "lucide-react"
import { Loader2, Save, Bell, Mail, MessageSquare, Megaphone, GraduationCap, BookOpen, CalendarCheck, Moon } from "lucide-react"
import { toast } from "sonner"
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"
import { Switch } from "@/shared/components/ui/switch"
import { Label } from "@/shared/components/ui/label"
import { Separator } from "@/shared/components/ui/separator"
import { cn } from "@/shared/lib/utils"
import { updateNotificationPreferencesAction } from "@/modules/messaging/actions"
import type { NotificationPreferences } from "@/modules/notifications/types"
@@ -131,6 +133,11 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
messageNotifications: preferences.messageNotifications,
attendanceNotifications: preferences.attendanceNotifications,
})
const [quietHours, setQuietHours] = React.useState({
quietHoursEnabled: preferences.quietHoursEnabled,
quietHoursStart: preferences.quietHoursStart ?? "",
quietHoursEnd: preferences.quietHoursEnd ?? "",
})
React.useEffect(() => {
if (state?.success) {
@@ -148,6 +155,10 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
setCategories((prev) => ({ ...prev, [key]: !prev[key] }))
}
const toggleQuietHours = () => {
setQuietHours((prev) => ({ ...prev, quietHoursEnabled: !prev.quietHoursEnabled }))
}
return (
<Card>
<CardHeader>
@@ -250,6 +261,80 @@ export function NotificationPreferencesForm({ preferences }: NotificationPrefere
)
})}
</div>
<Separator />
{/* 免打扰时段 */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium">Quiet Hours</h4>
<p className="text-xs text-muted-foreground">
Suppress non-urgent notifications during a specified time period each day.
</p>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
<Moon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="space-y-0.5">
<Label htmlFor="quietHoursEnabled" className="text-sm font-medium cursor-pointer">
Enable Quiet Hours
</Label>
<p className="text-xs text-muted-foreground">
When enabled, only urgent notifications will be delivered during the specified hours.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
name="quietHoursEnabled"
checked={quietHours.quietHoursEnabled}
onChange={toggleQuietHours}
className="sr-only"
tabIndex={-1}
/>
<Switch
id="quietHoursEnabled"
checked={quietHours.quietHoursEnabled}
onCheckedChange={toggleQuietHours}
aria-label="Enable Quiet Hours"
/>
</div>
</div>
<div className={cn(
"grid gap-4 sm:grid-cols-2 transition-opacity",
!quietHours.quietHoursEnabled && "pointer-events-none opacity-50"
)}>
<div className="space-y-2">
<Label htmlFor="quietHoursStart" className="text-xs font-medium">
Start Time
</Label>
<Input
id="quietHoursStart"
name="quietHoursStart"
type="time"
value={quietHours.quietHoursStart}
onChange={(e) => setQuietHours((prev) => ({ ...prev, quietHoursStart: e.target.value }))}
disabled={!quietHours.quietHoursEnabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="quietHoursEnd" className="text-xs font-medium">
End Time
</Label>
<Input
id="quietHoursEnd"
name="quietHoursEnd"
type="time"
value={quietHours.quietHoursEnd}
onChange={(e) => setQuietHours((prev) => ({ ...prev, quietHoursEnd: e.target.value }))}
disabled={!quietHours.quietHoursEnabled}
/>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-end border-t px-6 py-4">
<SubmitButton />

View File

@@ -0,0 +1,65 @@
"use client"
import Link from "next/link"
import { LayoutDashboard, GraduationCap, CalendarDays, ClipboardList } from "lucide-react"
import { SettingsView } from "@/modules/settings/components/settings-view"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { UserProfile } from "@/modules/users/data-access"
import type { NotificationPreferences } from "@/modules/notifications/types"
interface ParentSettingsViewProps {
user: UserProfile
notificationPreferences: NotificationPreferences
}
export function ParentSettingsView({ user, notificationPreferences }: ParentSettingsViewProps) {
const generalExtra = (
<Card>
<CardHeader>
<CardTitle>Quick links</CardTitle>
<CardDescription>Common places you may want to visit.</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button asChild variant="outline">
<Link href="/profile">Profile</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/parent/dashboard">
<LayoutDashboard className="h-4 w-4" />
Dashboard
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/parent/children">
<GraduationCap className="h-4 w-4" />
Children
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/parent/grades">
<ClipboardList className="h-4 w-4" />
Grades
</Link>
</Button>
<Button asChild variant="outline" className="gap-2">
<Link href="/parent/attendance">
<CalendarDays className="h-4 w-4" />
Attendance
</Link>
</Button>
</CardContent>
</Card>
)
return (
<SettingsView
description="Manage your preferences and family account access."
backHref="/parent/dashboard"
user={user}
notificationPreferences={notificationPreferences}
generalExtra={generalExtra}
/>
)
}

View File

@@ -18,10 +18,10 @@ import {
} from "@/shared/lib/password-policy"
import type { ActionState } from "@/shared/types/action-state"
const STRENGTH_META: Record<PasswordStrength, { value: number; label: string; barClass: string }> = {
weak: { value: 33, label: "Weak", barClass: "h-2 [&>div]:bg-red-500" },
medium: { value: 66, label: "Medium", barClass: "h-2 [&>div]:bg-yellow-500" },
strong: { value: 100, label: "Strong", barClass: "h-2 [&>div]:bg-green-500" },
const STRENGTH_META: Record<PasswordStrength, { value: number; label: string; barClassName: string; indicatorClassName: string }> = {
weak: { value: 33, label: "Weak", barClassName: "h-2", indicatorClassName: "bg-red-500" },
medium: { value: 66, label: "Medium", barClassName: "h-2", indicatorClassName: "bg-yellow-500" },
strong: { value: 100, label: "Strong", barClassName: "h-2", indicatorClassName: "bg-green-500" },
}
function SubmitButton() {
@@ -130,7 +130,7 @@ export function PasswordChangeForm() {
<span className="text-muted-foreground">Password strength</span>
<span className="font-medium">{meta.label}</span>
</div>
<Progress value={meta.value} className={meta.barClass} />
<Progress value={meta.value} className={meta.barClassName} indicatorClassName={meta.indicatorClassName} />
</div>
)}
</div>

View File

@@ -1,19 +1,34 @@
"use client"
import Link from "next/link"
import type { ReactNode } from "react"
import { User, Palette, Lock, Bell } from "lucide-react"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, type ReactNode } from "react"
import { User, Palette, Lock, Bell, Sparkles } from "lucide-react"
import { signOut } from "next-auth/react"
import { ThemePreferencesCard } from "@/modules/settings/components/theme-preferences-card"
import { ProfileSettingsForm } from "@/modules/settings/components/profile-settings-form"
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 { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog"
import { UserProfile } from "@/modules/users/data-access"
import type { NotificationPreferences } from "@/modules/notifications/types"
import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
interface SettingsViewProps {
/** 页面副标题描述 */
@@ -28,24 +43,52 @@ interface SettingsViewProps {
generalExtra?: ReactNode
}
const VALID_TABS = ["general", "notifications", "appearance", "security", "ai"] as const
type TabValue = (typeof VALID_TABS)[number]
function isTabValue(value: string | null): value is TabValue {
return value !== null && (VALID_TABS as readonly string[]).includes(value)
}
/**
* 统一设置页视图
*
* 消除 admin / teacher / student 个设置视图的重复布局:
* 消除 admin / teacher / student / parent 四个设置视图的重复布局:
* - 相同的页面头部(标题 + 描述 + 返回按钮)
* - 相同的 4 个标签页General / Notifications / Appearance / Security
* - 相同的标签页General / Notifications / Appearance / Security / AI
* - 相同的 Notifications / Appearance / Security 标签页内容
* - 相同的 Session 卡片(登出)
*
* 角色差异通过 `description`、`backHref` 和 `generalExtra` 三个 props 注入。
* 当前激活的标签页通过 URL `?tab=` 参数持久化。
*/
export function SettingsView({
function SettingsViewInner({
description,
backHref,
user,
notificationPreferences,
generalExtra,
}: SettingsViewProps) {
const router = useRouter()
const searchParams = useSearchParams()
const { hasPermission } = usePermission()
const tabParam = searchParams.get("tab")
const activeTab: TabValue = isTabValue(tabParam) ? tabParam : "general"
const handleTabChange = (value: string) => {
const params = new URLSearchParams(searchParams.toString())
if (value === "general") {
params.delete("tab")
} else {
params.set("tab", value)
}
const query = params.toString()
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">
@@ -60,7 +103,7 @@ export function SettingsView({
</div>
</div>
<Tabs defaultValue="general" className="w-full">
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="w-full justify-start">
<TabsTrigger value="general" className="gap-2">
<User className="h-4 w-4" />
@@ -78,6 +121,12 @@ export function SettingsView({
<Lock className="h-4 w-4" />
Security
</TabsTrigger>
{canConfigureAi ? (
<TabsTrigger value="ai" className="gap-2">
<Sparkles className="h-4 w-4" />
AI
</TabsTrigger>
) : null}
</TabsList>
<TabsContent value="general" className="mt-6 space-y-6">
@@ -105,13 +154,43 @@ export function SettingsView({
<div className="text-sm font-medium">Sign out</div>
<div className="text-sm text-muted-foreground">Return to the login screen.</div>
</div>
<Button variant="outline" onClick={() => signOut({ callbackUrl: "/login" })}>
Log out
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline">Log out</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm sign out</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to sign out? You will be returned to the login screen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => signOut({ callbackUrl: "/login" })}>
Sign out
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
</TabsContent>
{canConfigureAi ? (
<TabsContent value="ai" className="mt-6 space-y-6">
<AiProviderSettingsCard />
</TabsContent>
) : null}
</Tabs>
</div>
)
}
export function SettingsView(props: SettingsViewProps) {
return (
<Suspense fallback={null}>
<SettingsViewInner {...props} />
</Suspense>
)
}