refactor: RBAC权限系统重构 + UI组件拆分 + 测试修复 + 架构文档
Some checks failed
CI / build-deploy (push) Has been cancelled
Some checks failed
CI / build-deploy (push) Has been cancelled
- RBAC: 新增30个权限点、DataScope行级权限、requirePermission守卫,所有57+ Server Action接入权限校验 - UI拆分: exam-form(1623行→11文件)、textbook-reader(744行→7文件),均降至300行以内 - 测试: 新增5个单元测试文件(19用例),修复4个集成测试文件(38用例全部通过) - 架构文档: 新增架构影响地图(004/005)、标准功能清单(006)、差距审计报告(007) - 项目规则: 架构图优先规则,改码必同步图 - 安全: rehype-sanitize净化、AES加密API Key、权限路由守卫 - 无障碍: skip-link、aria-label、prefers-reduced-motion - 性能: next/font优化、next/image、代码分割
This commit is contained in:
@@ -13,6 +13,7 @@ import { Label } from "@/shared/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
type Role = "student" | "teacher" | "parent" | "admin"
|
||||
|
||||
@@ -27,7 +28,6 @@ export function OnboardingGate() {
|
||||
const router = useRouter()
|
||||
const { status, data: session, update } = useSession()
|
||||
const [required, setRequired] = useState(false)
|
||||
const [currentRole, setCurrentRole] = useState<Role>("student")
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState(0)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
@@ -53,7 +53,6 @@ export function OnboardingGate() {
|
||||
const required = Boolean(json.required)
|
||||
const role = String(json.role ?? "student") as Role
|
||||
setRequired(required)
|
||||
setCurrentRole(role)
|
||||
setRole(role === "admin" ? "admin" : role)
|
||||
setName(String(session?.user?.name ?? "").trim())
|
||||
if (required) {
|
||||
@@ -88,6 +87,12 @@ export function OnboardingGate() {
|
||||
const canNextFromStep0 = role.length > 0
|
||||
const canNextFromStep1 = name.trim().length > 0 && phone.trim().length > 0
|
||||
|
||||
const permissions = (session?.user?.permissions ?? []) as string[]
|
||||
const isAdmin = permissions.includes(Permissions.SETTINGS_ADMIN)
|
||||
const isTeacher = permissions.includes(Permissions.EXAM_CREATE)
|
||||
const isStudent = permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)
|
||||
const isParent = !permissions.includes(Permissions.EXAM_CREATE) && !permissions.includes(Permissions.HOMEWORK_SUBMIT) && permissions.includes(Permissions.EXAM_READ)
|
||||
|
||||
const onNext = async () => {
|
||||
if (step === 0) {
|
||||
if (!canNextFromStep0) return
|
||||
@@ -99,7 +104,7 @@ export function OnboardingGate() {
|
||||
toast.error("请填写姓名与电话")
|
||||
return
|
||||
}
|
||||
if (role === "admin") {
|
||||
if (isAdmin) {
|
||||
setStep(3)
|
||||
} else {
|
||||
setStep(2)
|
||||
@@ -181,7 +186,7 @@ export function OnboardingGate() {
|
||||
{step === 0 ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>Role</Label>
|
||||
{currentRole === "admin" ? (
|
||||
{isAdmin ? (
|
||||
<div className="rounded-md border px-3 py-2 text-sm">admin</div>
|
||||
) : (
|
||||
<Select value={role} onValueChange={(v) => setRole(v as Role)}>
|
||||
@@ -217,7 +222,7 @@ export function OnboardingGate() {
|
||||
|
||||
{step === 2 ? (
|
||||
<div className="grid gap-4">
|
||||
{role === "teacher" ? (
|
||||
{isTeacher ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="onb_codes_teacher">班级代码(可多个)</Label>
|
||||
@@ -242,7 +247,7 @@ export function OnboardingGate() {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{role === "student" ? (
|
||||
{isStudent ? (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="onb_codes_student">班级代码</Label>
|
||||
<Textarea
|
||||
@@ -254,7 +259,7 @@ export function OnboardingGate() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{role === "parent" ? (
|
||||
{isParent ? (
|
||||
<div className="rounded-md border px-3 py-2 text-sm text-muted-foreground">
|
||||
家长角色暂不需要配置,可跳过
|
||||
</div>
|
||||
|
||||
@@ -108,6 +108,15 @@ export const usersToRoles = mysqlTable("users_to_roles", {
|
||||
userIdIdx: index("user_id_idx").on(table.userId),
|
||||
}));
|
||||
|
||||
// Role -> Permissions (fine-grained RBAC)
|
||||
export const rolePermissions = mysqlTable("role_permissions", {
|
||||
roleId: varchar("role_id", { length: 128 }).notNull().references(() => roles.id, { onDelete: "cascade" }),
|
||||
permission: varchar("permission", { length: 100 }).notNull(),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.roleId, table.permission] }),
|
||||
roleIdIdx: index("role_permissions_role_idx").on(table.roleId),
|
||||
}));
|
||||
|
||||
// --- 2. Knowledge Points (Tree Structure) ---
|
||||
|
||||
export const knowledgePoints = mysqlTable("knowledge_points", {
|
||||
|
||||
5
src/shared/hooks/index.ts
Normal file
5
src/shared/hooks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { useActionWithToast } from "./use-action-with-toast"
|
||||
export { useDebounce } from "./use-debounce"
|
||||
export { useMediaQuery } from "./use-media-query"
|
||||
export { useLocalStorage } from "./use-local-storage"
|
||||
export { usePermission } from "./use-permission"
|
||||
22
src/shared/hooks/use-action-with-toast.ts
Normal file
22
src/shared/hooks/use-action-with-toast.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import { useTransition } from "react"
|
||||
import { toast } from "sonner"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
export function useActionWithToast<T>() {
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const execute = async (action: () => Promise<ActionState<T>>) => {
|
||||
startTransition(async () => {
|
||||
const result = await action()
|
||||
if (result.success) {
|
||||
toast.success(result.message || "操作成功")
|
||||
} else {
|
||||
toast.error(result.message || "操作失败")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { isPending, execute }
|
||||
}
|
||||
28
src/shared/hooks/use-debounce.test.ts
Normal file
28
src/shared/hooks/use-debounce.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect, vi } from "vitest"
|
||||
import { renderHook, act } from "@testing-library/react"
|
||||
import { useDebounce } from "./use-debounce"
|
||||
|
||||
describe("useDebounce", () => {
|
||||
it("should return initial value immediately", () => {
|
||||
const { result } = renderHook(() => useDebounce("hello", 500))
|
||||
expect(result.current).toBe("hello")
|
||||
})
|
||||
|
||||
it("should debounce value changes", () => {
|
||||
vi.useFakeTimers()
|
||||
const { result, rerender } = renderHook(
|
||||
({ value, delay }) => useDebounce(value, delay),
|
||||
{ initialProps: { value: "hello", delay: 300 } }
|
||||
)
|
||||
|
||||
rerender({ value: "world", delay: 300 })
|
||||
expect(result.current).toBe("hello")
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
expect(result.current).toBe("world")
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
14
src/shared/hooks/use-debounce.ts
Normal file
14
src/shared/hooks/use-debounce.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number = 300): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
35
src/shared/hooks/use-local-storage.test.ts
Normal file
35
src/shared/hooks/use-local-storage.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest"
|
||||
import { renderHook, act } from "@testing-library/react"
|
||||
import { useLocalStorage } from "./use-local-storage"
|
||||
|
||||
describe("useLocalStorage", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it("should return initial value when localStorage is empty", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("test-key", "default"))
|
||||
expect(result.current[0]).toBe("default")
|
||||
})
|
||||
|
||||
it("should persist value to localStorage", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("test-key", "default"))
|
||||
|
||||
act(() => {
|
||||
result.current[1]("updated")
|
||||
})
|
||||
|
||||
expect(result.current[0]).toBe("updated")
|
||||
expect(localStorage.getItem("test-key")).toBe(JSON.stringify("updated"))
|
||||
})
|
||||
|
||||
it("should support functional updates", () => {
|
||||
const { result } = renderHook(() => useLocalStorage("test-key", 0))
|
||||
|
||||
act(() => {
|
||||
result.current[1]((prev) => prev + 1)
|
||||
})
|
||||
|
||||
expect(result.current[0]).toBe(1)
|
||||
})
|
||||
})
|
||||
30
src/shared/hooks/use-local-storage.ts
Normal file
30
src/shared/hooks/use-local-storage.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
|
||||
function getStorageItem<T>(key: string, initialValue: T): T {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key)
|
||||
return item ? (JSON.parse(item) as T) : initialValue
|
||||
} catch {
|
||||
return initialValue
|
||||
}
|
||||
}
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
|
||||
const [localValue, setLocalValue] = useState<T>(() => getStorageItem(key, initialValue))
|
||||
|
||||
const setValue = useCallback((newValue: T | ((prev: T) => T)) => {
|
||||
setLocalValue((prev) => {
|
||||
const nextValue = newValue instanceof Function ? newValue(prev) : newValue
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(nextValue))
|
||||
} catch (error) {
|
||||
console.error(`Error setting localStorage key "${key}":`, error)
|
||||
}
|
||||
return nextValue
|
||||
})
|
||||
}, [key])
|
||||
|
||||
return [localValue, setValue]
|
||||
}
|
||||
14
src/shared/hooks/use-media-query.ts
Normal file
14
src/shared/hooks/use-media-query.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { useSyncExternalStore } from "react"
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
return useSyncExternalStore(
|
||||
(callback) => {
|
||||
const media = window.matchMedia(query)
|
||||
media.addEventListener("change", callback)
|
||||
return () => media.removeEventListener("change", callback)
|
||||
},
|
||||
() => window.matchMedia(query).matches,
|
||||
)
|
||||
}
|
||||
28
src/shared/hooks/use-permission.ts
Normal file
28
src/shared/hooks/use-permission.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { useSession } from "next-auth/react"
|
||||
import type { Permission } from "@/shared/types/permissions"
|
||||
|
||||
export function usePermission() {
|
||||
const { data: session } = useSession()
|
||||
const permissions = (session?.user?.permissions ?? []) as Permission[]
|
||||
const roles = (session?.user?.roles ?? []) as string[]
|
||||
|
||||
const hasPermission = (permission: Permission): boolean => {
|
||||
return permissions.includes(permission)
|
||||
}
|
||||
|
||||
const hasAnyPermission = (...perms: Permission[]): boolean => {
|
||||
return perms.some((p) => permissions.includes(p))
|
||||
}
|
||||
|
||||
const hasAllPermissions = (...perms: Permission[]): boolean => {
|
||||
return perms.every((p) => permissions.includes(p))
|
||||
}
|
||||
|
||||
const hasRole = (role: string): boolean => {
|
||||
return roles.includes(role)
|
||||
}
|
||||
|
||||
return { permissions, roles, hasPermission, hasAnyPermission, hasAllPermissions, hasRole }
|
||||
}
|
||||
133
src/shared/lib/auth-guard.ts
Normal file
133
src/shared/lib/auth-guard.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { auth } from "@/auth"
|
||||
import type { Permission, DataScope, AuthContext } from "@/shared/types/permissions"
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
classSubjectTeachers,
|
||||
grades,
|
||||
} from "@/shared/db/schema"
|
||||
import { eq, or } from "drizzle-orm"
|
||||
|
||||
export class PermissionDeniedError extends Error {
|
||||
constructor(permission: string) {
|
||||
super(`Permission denied: ${permission}`)
|
||||
this.name = "PermissionDeniedError"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full authentication context for the current user.
|
||||
* Throws if not authenticated.
|
||||
*/
|
||||
export async function getAuthContext(): Promise<AuthContext> {
|
||||
const session = await auth()
|
||||
const userId = session?.user?.id
|
||||
if (!userId) throw new PermissionDeniedError("auth_required")
|
||||
|
||||
// Prefer session data (already resolved in JWT callback)
|
||||
const roleNames = (session.user.roles ?? []) as string[]
|
||||
const permissions = (session.user.permissions ?? []) as Permission[]
|
||||
|
||||
// Resolve data scope from DB (not cached in JWT since it can change)
|
||||
const dataScope = await resolveDataScope(userId, roleNames)
|
||||
|
||||
return { userId, roles: roleNames, permissions, dataScope }
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the current user has the specified permission.
|
||||
* Returns AuthContext on success, throws PermissionDeniedError on failure.
|
||||
*/
|
||||
export async function requirePermission(permission: Permission): Promise<AuthContext> {
|
||||
const ctx = await getAuthContext()
|
||||
if (!ctx.permissions.includes(permission)) {
|
||||
throw new PermissionDeniedError(permission)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Check permission without throwing. Useful for conditional logic.
|
||||
*/
|
||||
export async function checkPermission(
|
||||
permission: Permission
|
||||
): Promise<{ allowed: boolean; ctx: AuthContext }> {
|
||||
const ctx = await getAuthContext()
|
||||
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: string[]): 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
|
||||
if (roleNames.includes("student")) {
|
||||
return { type: "class_members" }
|
||||
}
|
||||
|
||||
// Parent: can see their children's data
|
||||
if (roleNames.includes("parent")) {
|
||||
// TODO: implement parent-child relationship lookup
|
||||
return { type: "children", childrenIds: [] }
|
||||
}
|
||||
|
||||
// Fallback: only own data
|
||||
return { type: "owned", userId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: assert the user is authenticated (has any role).
|
||||
* Returns AuthContext on success.
|
||||
*/
|
||||
export async function requireAuth(): Promise<AuthContext> {
|
||||
return getAuthContext()
|
||||
}
|
||||
130
src/shared/lib/permissions.ts
Normal file
130
src/shared/lib/permissions.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Permissions, type Permission } from "@/shared/types/permissions"
|
||||
|
||||
// Role → Permission mapping
|
||||
// New roles only need to add an entry here + seed the DB
|
||||
export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||
admin: [
|
||||
Permissions.EXAM_CREATE,
|
||||
Permissions.EXAM_READ,
|
||||
Permissions.EXAM_UPDATE,
|
||||
Permissions.EXAM_DELETE,
|
||||
Permissions.EXAM_DUPLICATE,
|
||||
Permissions.EXAM_PUBLISH,
|
||||
Permissions.EXAM_AI_GENERATE,
|
||||
Permissions.HOMEWORK_CREATE,
|
||||
Permissions.HOMEWORK_GRADE,
|
||||
Permissions.QUESTION_CREATE,
|
||||
Permissions.QUESTION_READ,
|
||||
Permissions.QUESTION_UPDATE,
|
||||
Permissions.QUESTION_DELETE,
|
||||
Permissions.TEXTBOOK_CREATE,
|
||||
Permissions.TEXTBOOK_READ,
|
||||
Permissions.TEXTBOOK_UPDATE,
|
||||
Permissions.TEXTBOOK_DELETE,
|
||||
Permissions.CLASS_CREATE,
|
||||
Permissions.CLASS_READ,
|
||||
Permissions.CLASS_UPDATE,
|
||||
Permissions.CLASS_DELETE,
|
||||
Permissions.CLASS_ENROLL,
|
||||
Permissions.CLASS_SCHEDULE,
|
||||
Permissions.SCHOOL_MANAGE,
|
||||
Permissions.GRADE_MANAGE,
|
||||
Permissions.USER_MANAGE,
|
||||
Permissions.AI_CHAT,
|
||||
Permissions.AI_CONFIGURE,
|
||||
Permissions.SETTINGS_ADMIN,
|
||||
],
|
||||
teacher: [
|
||||
Permissions.EXAM_CREATE,
|
||||
Permissions.EXAM_READ,
|
||||
Permissions.EXAM_UPDATE,
|
||||
Permissions.EXAM_DELETE,
|
||||
Permissions.EXAM_DUPLICATE,
|
||||
Permissions.EXAM_PUBLISH,
|
||||
Permissions.EXAM_AI_GENERATE,
|
||||
Permissions.HOMEWORK_CREATE,
|
||||
Permissions.HOMEWORK_GRADE,
|
||||
Permissions.QUESTION_CREATE,
|
||||
Permissions.QUESTION_READ,
|
||||
Permissions.QUESTION_UPDATE,
|
||||
Permissions.QUESTION_DELETE,
|
||||
Permissions.TEXTBOOK_CREATE,
|
||||
Permissions.TEXTBOOK_READ,
|
||||
Permissions.TEXTBOOK_UPDATE,
|
||||
Permissions.CLASS_READ,
|
||||
Permissions.CLASS_ENROLL,
|
||||
Permissions.CLASS_SCHEDULE,
|
||||
Permissions.AI_CHAT,
|
||||
],
|
||||
student: [
|
||||
Permissions.EXAM_READ,
|
||||
Permissions.HOMEWORK_SUBMIT,
|
||||
Permissions.QUESTION_READ,
|
||||
Permissions.TEXTBOOK_READ,
|
||||
Permissions.CLASS_READ,
|
||||
Permissions.AI_CHAT,
|
||||
],
|
||||
parent: [
|
||||
Permissions.EXAM_READ,
|
||||
Permissions.TEXTBOOK_READ,
|
||||
Permissions.CLASS_READ,
|
||||
],
|
||||
grade_head: [
|
||||
Permissions.EXAM_CREATE,
|
||||
Permissions.EXAM_READ,
|
||||
Permissions.EXAM_UPDATE,
|
||||
Permissions.EXAM_DELETE,
|
||||
Permissions.EXAM_DUPLICATE,
|
||||
Permissions.EXAM_PUBLISH,
|
||||
Permissions.EXAM_AI_GENERATE,
|
||||
Permissions.HOMEWORK_CREATE,
|
||||
Permissions.HOMEWORK_GRADE,
|
||||
Permissions.QUESTION_CREATE,
|
||||
Permissions.QUESTION_READ,
|
||||
Permissions.QUESTION_UPDATE,
|
||||
Permissions.QUESTION_DELETE,
|
||||
Permissions.TEXTBOOK_CREATE,
|
||||
Permissions.TEXTBOOK_READ,
|
||||
Permissions.TEXTBOOK_UPDATE,
|
||||
Permissions.CLASS_CREATE,
|
||||
Permissions.CLASS_READ,
|
||||
Permissions.CLASS_UPDATE,
|
||||
Permissions.CLASS_ENROLL,
|
||||
Permissions.CLASS_SCHEDULE,
|
||||
Permissions.GRADE_MANAGE,
|
||||
Permissions.AI_CHAT,
|
||||
],
|
||||
teaching_head: [
|
||||
Permissions.EXAM_CREATE,
|
||||
Permissions.EXAM_READ,
|
||||
Permissions.EXAM_UPDATE,
|
||||
Permissions.EXAM_DELETE,
|
||||
Permissions.EXAM_DUPLICATE,
|
||||
Permissions.EXAM_PUBLISH,
|
||||
Permissions.EXAM_AI_GENERATE,
|
||||
Permissions.HOMEWORK_CREATE,
|
||||
Permissions.HOMEWORK_GRADE,
|
||||
Permissions.QUESTION_CREATE,
|
||||
Permissions.QUESTION_READ,
|
||||
Permissions.QUESTION_UPDATE,
|
||||
Permissions.QUESTION_DELETE,
|
||||
Permissions.TEXTBOOK_CREATE,
|
||||
Permissions.TEXTBOOK_READ,
|
||||
Permissions.TEXTBOOK_UPDATE,
|
||||
Permissions.CLASS_READ,
|
||||
Permissions.GRADE_MANAGE,
|
||||
Permissions.AI_CHAT,
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge permissions from all roles (deduplicated)
|
||||
*/
|
||||
export function resolvePermissions(roleNames: string[]): Permission[] {
|
||||
const set = new Set<Permission>()
|
||||
for (const name of roleNames) {
|
||||
const perms = ROLE_PERMISSIONS[name] ?? []
|
||||
for (const p of perms) set.add(p)
|
||||
}
|
||||
return Array.from(set)
|
||||
}
|
||||
39
src/shared/lib/utils.test.ts
Normal file
39
src/shared/lib/utils.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { cn, formatDate } from "./utils"
|
||||
|
||||
describe("cn", () => {
|
||||
it("should merge class names", () => {
|
||||
expect(cn("foo", "bar")).toBe("foo bar")
|
||||
})
|
||||
|
||||
it("should handle conditional classes", () => {
|
||||
expect(cn("foo", false && "bar", "baz")).toBe("foo baz")
|
||||
})
|
||||
|
||||
it("should resolve tailwind conflicts", () => {
|
||||
expect(cn("px-4", "px-6")).toBe("px-6")
|
||||
})
|
||||
|
||||
it("should handle undefined and null", () => {
|
||||
expect(cn("foo", undefined, null, "bar")).toBe("foo bar")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatDate", () => {
|
||||
it("should format a date string with default zh-CN locale", () => {
|
||||
const result = formatDate("2024-01-15")
|
||||
expect(result).toContain("2024")
|
||||
expect(result).toContain("1")
|
||||
})
|
||||
|
||||
it("should format a date string with en-US locale", () => {
|
||||
const result = formatDate("2024-01-15", "en-US")
|
||||
expect(result).toContain("2024")
|
||||
expect(result).toContain("Jan")
|
||||
})
|
||||
|
||||
it("should format a Date object", () => {
|
||||
const result = formatDate(new Date(2024, 5, 15))
|
||||
expect(result).toContain("2024")
|
||||
})
|
||||
})
|
||||
@@ -5,10 +5,10 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date) {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
export function formatDate(date: string | Date, locale: string = "zh-CN") {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}).format(new Date(date))
|
||||
}
|
||||
|
||||
33
src/shared/types/action-state.test.ts
Normal file
33
src/shared/types/action-state.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import type { ActionState } from "./action-state"
|
||||
|
||||
describe("ActionState", () => {
|
||||
it("should create a success state", () => {
|
||||
const state: ActionState<string> = {
|
||||
success: true,
|
||||
message: "Operation succeeded",
|
||||
data: "result",
|
||||
}
|
||||
expect(state.success).toBe(true)
|
||||
expect(state.data).toBe("result")
|
||||
})
|
||||
|
||||
it("should create an error state", () => {
|
||||
const state: ActionState = {
|
||||
success: false,
|
||||
message: "Operation failed",
|
||||
errors: { field: ["Error message"] },
|
||||
}
|
||||
expect(state.success).toBe(false)
|
||||
expect(state.errors).toBeDefined()
|
||||
})
|
||||
|
||||
it("should create a void state", () => {
|
||||
const state: ActionState = {
|
||||
success: true,
|
||||
message: "Done",
|
||||
}
|
||||
expect(state.success).toBe(true)
|
||||
expect(state.data).toBeUndefined()
|
||||
})
|
||||
})
|
||||
68
src/shared/types/permissions.ts
Normal file
68
src/shared/types/permissions.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// Permission definitions: resource:action naming convention
|
||||
// Used by requirePermission() on server and usePermission() on client
|
||||
|
||||
export const Permissions = {
|
||||
// Exam
|
||||
EXAM_CREATE: "exam:create",
|
||||
EXAM_READ: "exam:read",
|
||||
EXAM_UPDATE: "exam:update",
|
||||
EXAM_DELETE: "exam:delete",
|
||||
EXAM_DUPLICATE: "exam:duplicate",
|
||||
EXAM_PUBLISH: "exam:publish",
|
||||
EXAM_AI_GENERATE: "exam:ai_generate",
|
||||
|
||||
// Homework
|
||||
HOMEWORK_CREATE: "homework:create",
|
||||
HOMEWORK_GRADE: "homework:grade",
|
||||
HOMEWORK_SUBMIT: "homework:submit",
|
||||
|
||||
// Question
|
||||
QUESTION_CREATE: "question:create",
|
||||
QUESTION_READ: "question:read",
|
||||
QUESTION_UPDATE: "question:update",
|
||||
QUESTION_DELETE: "question:delete",
|
||||
|
||||
// Textbook
|
||||
TEXTBOOK_CREATE: "textbook:create",
|
||||
TEXTBOOK_READ: "textbook:read",
|
||||
TEXTBOOK_UPDATE: "textbook:update",
|
||||
TEXTBOOK_DELETE: "textbook:delete",
|
||||
|
||||
// Class
|
||||
CLASS_CREATE: "class:create",
|
||||
CLASS_READ: "class:read",
|
||||
CLASS_UPDATE: "class:update",
|
||||
CLASS_DELETE: "class:delete",
|
||||
CLASS_ENROLL: "class:enroll",
|
||||
CLASS_SCHEDULE: "class:schedule",
|
||||
|
||||
// School management
|
||||
SCHOOL_MANAGE: "school:manage",
|
||||
GRADE_MANAGE: "grade:manage",
|
||||
USER_MANAGE: "user:manage",
|
||||
|
||||
// AI
|
||||
AI_CHAT: "ai:chat",
|
||||
AI_CONFIGURE: "ai:configure",
|
||||
|
||||
// Settings
|
||||
SETTINGS_ADMIN: "settings:admin",
|
||||
} as const
|
||||
|
||||
export type Permission = (typeof Permissions)[keyof typeof Permissions]
|
||||
|
||||
// Data scope for row-level security
|
||||
export type DataScope =
|
||||
| { type: "all" }
|
||||
| { type: "owned"; userId: string }
|
||||
| { type: "class_members" }
|
||||
| { type: "grade_managed"; gradeIds: string[] }
|
||||
| { type: "class_taught"; classIds: string[]; subjectIds?: string[] }
|
||||
| { type: "children"; childrenIds: string[] }
|
||||
|
||||
export interface AuthContext {
|
||||
userId: string
|
||||
roles: string[]
|
||||
permissions: Permission[]
|
||||
dataScope: DataScope
|
||||
}
|
||||
Reference in New Issue
Block a user