feat(app): add error/loading boundaries and update dashboard routes

- Add error.tsx and loading.tsx boundaries for admin, parent, student, teacher routes

- Add dashboard-error-fallback and dashboard-loading-skeleton components

- Add student/learning page, parent/leave routes, teacher textbook components

- Update existing app routes across auth, dashboard, and API endpoints

- Update proxy middleware and next-auth type declarations
This commit is contained in:
SpecialX
2026-06-23 17:38:28 +08:00
parent c4d3433cc9
commit 1a9377222c
90 changed files with 1690 additions and 741 deletions

View File

@@ -7,14 +7,26 @@ import { Permissions } from "@/shared/types/permissions"
// Route prefix → minimum required permission
// Note: /admin/announcements is covered by /admin prefix (requires school:manage)
// Note: /announcements is accessible to all authenticated users (no permission entry needed)
// P0 修复:原先 /teacher 和 /parent 都使用 EXAM_READ但 student/parent 也有 EXAM_READ
// 导致跨角色访问漏洞(学生可访问 /teacher/*,教师可访问 /parent/*)。
// 改为使用各角色独有的权限点,确保跨角色访问被拒绝。
const ROUTE_PERMISSIONS: Record<string, string> = {
"/admin": Permissions.SCHOOL_MANAGE,
"/teacher": Permissions.EXAM_READ,
"/teacher": Permissions.EXAM_CREATE,
"/student": Permissions.HOMEWORK_SUBMIT,
"/parent": Permissions.EXAM_READ,
"/parent": Permissions.DASHBOARD_PARENT_READ,
"/management": Permissions.GRADE_MANAGE,
}
// 仪表盘路由的细粒度权限(覆盖 ROUTE_PERMISSIONS 的前缀匹配)
// 防止拥有 EXAM_READ 的学生/家长访问 /teacher/dashboard 等
const DASHBOARD_ROUTE_PERMISSIONS: Record<string, string> = {
"/admin/dashboard": Permissions.DASHBOARD_ADMIN_READ,
"/teacher/dashboard": Permissions.DASHBOARD_TEACHER_READ,
"/student/dashboard": Permissions.DASHBOARD_STUDENT_READ,
"/parent/dashboard": Permissions.DASHBOARD_PARENT_READ,
}
// API route prefix → required permission
const API_PERMISSIONS: Record<string, string> = {
"/api/ai/chat": Permissions.AI_CHAT,
@@ -57,6 +69,22 @@ export async function proxy(request: NextRequest) {
return NextResponse.redirect(loginUrl)
}
// Onboarding gate: 未完成引导的用户只能访问 /onboarding 与白名单路径
// 修复 P2-1用 middleware 强制重定向替代客户端 Dialog
const onboarded = Boolean(token.onboarded)
const isOnboardingPath = pathname === "/onboarding" || pathname.startsWith("/onboarding/")
const isWhitelistedApi = pathname.startsWith("/api/auth") || pathname.startsWith("/api/onboarding")
if (!onboarded && !isOnboardingPath && !isWhitelistedApi) {
const onboardingUrl = new URL("/onboarding", request.url)
return NextResponse.redirect(onboardingUrl)
}
// 已完成 onboarding 的用户不应停留在 /onboarding
if (onboarded && isOnboardingPath) {
const roles: string[] = (token.roles as string[]) ?? []
const defaultPath = resolveDefaultPath(roles)
return NextResponse.redirect(new URL(defaultPath, request.url))
}
const permissions: string[] = (token.permissions as string[]) ?? []
const roles: string[] = (token.roles as string[]) ?? []
@@ -71,6 +99,19 @@ export async function proxy(request: NextRequest) {
}
// Check page route permissions
// 优先检查仪表盘路由的细粒度权限(防止跨角色访问仪表盘)
if (Object.prototype.hasOwnProperty.call(DASHBOARD_ROUTE_PERMISSIONS, pathname)) {
const requiredPerm = DASHBOARD_ROUTE_PERMISSIONS[pathname]
if (!permissions.includes(requiredPerm)) {
const defaultPath = resolveDefaultPath(roles)
const redirectUrl = new URL(defaultPath, request.url)
redirectUrl.searchParams.set("from", pathname)
redirectUrl.searchParams.set("reason", "forbidden")
return NextResponse.redirect(redirectUrl)
}
return NextResponse.next()
}
for (const [prefix, requiredPerm] of Object.entries(ROUTE_PERMISSIONS)) {
if (pathname.startsWith(prefix)) {
if (!permissions.includes(requiredPerm)) {