Files
NextEdu/src/proxy.ts
SpecialX 1a9377222c 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
2026-06-23 17:38:28 +08:00

136 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { getToken } from "next-auth/jwt"
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_CREATE,
"/student": Permissions.HOMEWORK_SUBMIT,
"/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,
}
function resolveDefaultPath(roles: string[]): string {
if (roles.includes("admin")) return "/admin/dashboard"
if (roles.includes("grade_head") || roles.includes("teaching_head")) return "/teacher/dashboard"
if (roles.includes("teacher")) return "/teacher/dashboard"
if (roles.includes("student")) return "/student/dashboard"
if (roles.includes("parent")) return "/parent/dashboard"
return "/dashboard"
}
// Next.js 16 renamed `middleware` to `proxy`.
// See: https://nextjs.org/docs/messages/middleware-to-proxy
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
// Skip static assets and auth pages
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/api/auth") ||
pathname === "/login" ||
pathname === "/register" ||
pathname === "/favicon.ico"
) {
return NextResponse.next()
}
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
})
// Not authenticated → redirect to login
if (!token) {
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("callbackUrl", request.url)
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[]) ?? []
// Check API route permissions
for (const [prefix, requiredPerm] of Object.entries(API_PERMISSIONS)) {
if (pathname.startsWith(prefix)) {
if (!permissions.includes(requiredPerm)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
break
}
}
// 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)) {
const defaultPath = resolveDefaultPath(roles)
// Carry original path + reason in URL so the target page can explain
// why the user was redirected (Web Interface Guidelines: URL reflects state).
const redirectUrl = new URL(defaultPath, request.url)
redirectUrl.searchParams.set("from", pathname)
redirectUrl.searchParams.set("reason", "forbidden")
return NextResponse.redirect(redirectUrl)
}
break
}
}
return NextResponse.next()
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
}