feat(shared,tests): add error boundaries, lib utils, i18n messages, and integration tests
shared: - Add class-filter, error-state, route-error, section-error-boundary, widget-boundary components - Add ui/alert component - Add constants directory - Add breached-password, export-utils, permission-bitmap, rate-limit, resolve-action-error, route-permissions, route-resolver, type-guards lib - Add i18n messages (en, zh-CN) for invitation-codes, parent, questions, rbac tests: - Add integration tests for elective - Add tests/setup/empty-stub scripts: - Add update-md.cjs, tmp_append_en.ps1, tmp_merge_en.ps1 utilities
This commit is contained in:
@@ -1,19 +1,30 @@
|
||||
import type { Permission, DataScope, AuthContext, Role } from "@/shared/types/permissions"
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
classEnrollments,
|
||||
classSubjectTeachers,
|
||||
grades,
|
||||
parentStudentRelations,
|
||||
} from "@/shared/db/schema"
|
||||
import { eq, inArray, or } from "drizzle-orm"
|
||||
import { isPermission } from "@/shared/lib/type-guards"
|
||||
import { getSession } from "@/shared/lib/session"
|
||||
import { PermissionDeniedError } from "@/shared/lib/errors"
|
||||
|
||||
// Re-export for backward compatibility (other modules still import from here)
|
||||
export { PermissionDeniedError } from "@/shared/lib/errors"
|
||||
|
||||
/**
|
||||
* Resolve the data scope for a user based on their roles.
|
||||
*
|
||||
* Delegates to the RBAC module's configuration-driven resolver via dynamic
|
||||
* import, so the shared layer never statically depends on modules/*.
|
||||
* This is the same pattern used by shared/lib/session.ts to break the
|
||||
* shared ↔ auth circular dependency.
|
||||
*
|
||||
* P1-5/P1-6 audit fix: removed direct DB queries against classes,
|
||||
* classEnrollments, classSubjectTeachers, grades, parentStudentRelations
|
||||
* tables. The resolver now calls module data-access functions and is
|
||||
* driven by a configuration array (DATA_SCOPE_RULES) instead of hardcoded
|
||||
* role-name checks.
|
||||
*/
|
||||
async function resolveDataScope(userId: string, roleNames: Role[]): Promise<DataScope> {
|
||||
const { resolveDataScopeFromConfig } = await import("@/modules/rbac/lib/data-scope-resolver")
|
||||
return resolveDataScopeFromConfig(userId, roleNames)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full authentication context for the current user.
|
||||
* Throws if not authenticated.
|
||||
@@ -24,8 +35,11 @@ export async function getAuthContext(): Promise<AuthContext> {
|
||||
if (!userId) throw new PermissionDeniedError("auth_required")
|
||||
|
||||
// Prefer session data (already resolved in JWT callback)
|
||||
const roleNames = (session.user.roles ?? []) as Role[]
|
||||
const permissions = (session.user.permissions ?? []) as Permission[]
|
||||
// Use type guards to safely coerce session values
|
||||
const roleNames = (session.user.roles ?? []).filter(
|
||||
(r): r is Role => typeof r === "string"
|
||||
)
|
||||
const permissions = (session.user.permissions ?? []).filter(isPermission)
|
||||
|
||||
// Resolve data scope from DB (not cached in JWT since it can change)
|
||||
const dataScope = await resolveDataScope(userId, roleNames)
|
||||
@@ -55,118 +69,6 @@ export async function checkPermission(
|
||||
return { allowed: ctx.permissions.includes(permission), ctx }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the data scope for a user based on their roles.
|
||||
* Queries the DB for resource ownership information.
|
||||
*/
|
||||
async function resolveDataScope(userId: string, roleNames: Role[]): Promise<DataScope> {
|
||||
// Admin sees everything
|
||||
if (roleNames.includes("admin")) {
|
||||
return { type: "all" }
|
||||
}
|
||||
|
||||
// Grade head / teaching head: can manage their grades
|
||||
if (roleNames.includes("grade_head") || roleNames.includes("teaching_head")) {
|
||||
const managedGrades = await db
|
||||
.select({ id: grades.id })
|
||||
.from(grades)
|
||||
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
|
||||
|
||||
if (managedGrades.length > 0) {
|
||||
return { type: "grade_managed", gradeIds: managedGrades.map((g) => g.id) }
|
||||
}
|
||||
}
|
||||
|
||||
// Teacher: can see their own classes
|
||||
if (roleNames.includes("teacher")) {
|
||||
// Classes where user is the homeroom teacher
|
||||
const homeroomClasses = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(eq(classes.teacherId, userId))
|
||||
|
||||
// Classes where user is a subject teacher
|
||||
const subjectClasses = await db
|
||||
.selectDistinct({ classId: classSubjectTeachers.classId, subjectId: classSubjectTeachers.subjectId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(eq(classSubjectTeachers.teacherId, userId))
|
||||
|
||||
const classIds = [
|
||||
...new Set([
|
||||
...homeroomClasses.map((c) => c.id),
|
||||
...subjectClasses.map((c) => c.classId),
|
||||
]),
|
||||
]
|
||||
const subjectIds = subjectClasses
|
||||
.map((c) => c.subjectId)
|
||||
.filter((s): s is string => s !== null)
|
||||
|
||||
return {
|
||||
type: "class_taught",
|
||||
classIds,
|
||||
subjectIds: subjectIds.length > 0 ? subjectIds : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Student: can see data from their enrolled classes
|
||||
// Pre-resolve classIds and gradeIds here to avoid N+1 queries in data-access layer
|
||||
if (roleNames.includes("student")) {
|
||||
const enrolledClasses = await db
|
||||
.select({ classId: classEnrollments.classId, gradeId: classes.gradeId })
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
|
||||
.where(eq(classEnrollments.studentId, userId))
|
||||
|
||||
const gradeIds = [
|
||||
...new Set(
|
||||
enrolledClasses
|
||||
.map((c) => c.gradeId)
|
||||
.filter((g): g is string => g !== null),
|
||||
),
|
||||
]
|
||||
|
||||
return {
|
||||
type: "class_members",
|
||||
classIds: enrolledClasses.map((c) => c.classId),
|
||||
gradeIds: gradeIds.length > 0 ? gradeIds : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Parent: can see their children's data
|
||||
if (roleNames.includes("parent")) {
|
||||
const children = await db
|
||||
.select({ studentId: parentStudentRelations.studentId })
|
||||
.from(parentStudentRelations)
|
||||
.where(eq(parentStudentRelations.parentId, userId))
|
||||
|
||||
const childrenIds = children.map((c) => c.studentId)
|
||||
|
||||
// Pre-resolve gradeIds from children's enrolled classes
|
||||
let gradeIds: string[] | undefined
|
||||
if (childrenIds.length > 0) {
|
||||
const childrenClasses = await db
|
||||
.select({ gradeId: classes.gradeId })
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
|
||||
.where(inArray(classEnrollments.studentId, childrenIds))
|
||||
|
||||
const uniqueGradeIds = [
|
||||
...new Set(
|
||||
childrenClasses
|
||||
.map((c) => c.gradeId)
|
||||
.filter((g): g is string => g !== null),
|
||||
),
|
||||
]
|
||||
gradeIds = uniqueGradeIds.length > 0 ? uniqueGradeIds : undefined
|
||||
}
|
||||
|
||||
return { type: "children", childrenIds, gradeIds }
|
||||
}
|
||||
|
||||
// Fallback: only own data
|
||||
return { type: "owned", userId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: assert the user is authenticated (has any role).
|
||||
* Returns AuthContext on success.
|
||||
|
||||
Reference in New Issue
Block a user