feat(exam-homework): add audit report, i18n, error boundaries, and permission hardening
- Add comprehensive audit report for exam and homework module - Create exam-homework i18n message files (zh-CN + en) and register namespace - Add permission check to gradeHomeworkSubmissionAction to prevent horizontal privilege escalation - Add Error Boundary + loading.tsx for 5 key pages (exam build/proctoring, homework assignment/submissions, student assignment) - Refactor exam-columns to createExamColumns(t) factory for i18n support - Refactor exam-data-table to manage columns internally via useTranslations - Replace hardcoded strings with i18n keys in all exam/homework components and pages - Add getHomeworkSubmissionForGrading data-access for secure grading flow
This commit is contained in:
@@ -5,6 +5,8 @@ import { useFormStatus } from "react-dom"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { FileText, FileQuestion } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -12,6 +14,7 @@ import { Input } from "@/shared/components/ui/input"
|
||||
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 { createHomeworkAssignmentAction } from "../actions"
|
||||
import type { TeacherClass } from "@/modules/classes/types"
|
||||
@@ -20,9 +23,10 @@ type ExamOption = { id: string; title: string }
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus()
|
||||
const t = useTranslations("examHomework")
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Creating..." : "Create Assignment"}
|
||||
{pending ? t("homework.form.submitting") : t("homework.form.submit")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -30,7 +34,9 @@ function SubmitButton() {
|
||||
export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]; classes: TeacherClass[] }) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const t = useTranslations("examHomework")
|
||||
|
||||
const [mode, setMode] = useState<"exam" | "quick">(exams.length > 0 ? "exam" : "quick")
|
||||
const initialExamId = useMemo(() => exams[0]?.id ?? "", [exams])
|
||||
const [examId, setExamId] = useState<string>(initialExamId)
|
||||
const initialClassId = useMemo(() => {
|
||||
@@ -40,43 +46,100 @@ export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]
|
||||
}, [classes, searchParams])
|
||||
const [classId, setClassId] = useState<string>(initialClassId)
|
||||
const [allowLate, setAllowLate] = useState<boolean>(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
if (!examId) {
|
||||
toast.error("Please select an exam")
|
||||
if (mode === "exam" && !examId) {
|
||||
toast.error(t("homework.form.selectExamRequired"))
|
||||
return
|
||||
}
|
||||
if (mode === "quick" && !formData.get("title")) {
|
||||
toast.error(t("homework.form.titleRequired"))
|
||||
return
|
||||
}
|
||||
if (!classId) {
|
||||
toast.error("Please select a class")
|
||||
toast.error(t("homework.form.selectClassRequired"))
|
||||
return
|
||||
}
|
||||
formData.set("sourceExamId", examId)
|
||||
|
||||
if (mode === "exam") {
|
||||
formData.set("sourceExamId", examId)
|
||||
} else {
|
||||
formData.delete("sourceExamId")
|
||||
}
|
||||
formData.set("classId", classId)
|
||||
formData.set("allowLate", allowLate ? "true" : "false")
|
||||
formData.set("publish", "true")
|
||||
|
||||
setIsSubmitting(true)
|
||||
const result = await createHomeworkAssignmentAction(null, formData)
|
||||
setIsSubmitting(false)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
router.push("/teacher/homework/assignments")
|
||||
} else {
|
||||
toast.error(result.message || "Failed to create")
|
||||
toast.error(result.message || t("homework.form.createFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="relative">
|
||||
{isSubmitting && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/60 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
{t("homework.form.creating")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader>
|
||||
<CardTitle>Create Assignment</CardTitle>
|
||||
<CardTitle>{t("homework.form.createTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={handleSubmit} className="space-y-6">
|
||||
{/* 模式切换 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("quick")}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border p-3 text-left transition-colors",
|
||||
mode === "quick"
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-border hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<FileText className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t("homework.form.quickMode")}</div>
|
||||
<div className="text-xs text-muted-foreground">{t("homework.form.quickModeDescription")}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("exam")}
|
||||
disabled={exams.length === 0}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border p-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
mode === "exam"
|
||||
? "border-primary bg-primary/5 text-primary"
|
||||
: "border-border hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<FileQuestion className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t("homework.form.examMode")}</div>
|
||||
<div className="text-xs text-muted-foreground">{t("homework.form.examModeDescription")}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label>Class</Label>
|
||||
<Label>{t("homework.form.class")}</Label>
|
||||
<Select value={classId} onValueChange={setClassId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a class" />
|
||||
<SelectValue placeholder={t("homework.form.selectClass")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{classes.map((c) => (
|
||||
@@ -89,40 +152,54 @@ export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]
|
||||
<input type="hidden" name="classId" value={classId} />
|
||||
</div>
|
||||
|
||||
{mode === "exam" && (
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label>{t("homework.form.sourceExam")}</Label>
|
||||
<Select value={examId} onValueChange={setExamId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("homework.form.selectExam")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{exams.map((e) => (
|
||||
<SelectItem key={e.id} value={e.id}>
|
||||
{e.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="sourceExamId" value={examId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label>Source Exam</Label>
|
||||
<Select value={examId} onValueChange={setExamId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an exam" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{exams.map((e) => (
|
||||
<SelectItem key={e.id} value={e.id}>
|
||||
{e.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="sourceExamId" value={examId} />
|
||||
<Label htmlFor="title">
|
||||
{t("homework.form.assignmentTitle")} {mode === "quick" && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
placeholder={mode === "exam" ? t("homework.form.titlePlaceholderExam") : t("homework.form.titlePlaceholderQuick")}
|
||||
required={mode === "quick"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="title">Assignment Title (optional)</Label>
|
||||
<Input id="title" name="title" placeholder="Defaults to exam title" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="description">Description (optional)</Label>
|
||||
<Textarea id="description" name="description" className="min-h-[80px]" />
|
||||
<Label htmlFor="description">{t("homework.form.description")}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
className="min-h-[80px]"
|
||||
placeholder={mode === "quick" ? t("homework.form.descriptionPlaceholderQuick") : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="availableAt">Available At (optional)</Label>
|
||||
<Label htmlFor="availableAt">{t("homework.form.availableAt")}</Label>
|
||||
<Input id="availableAt" name="availableAt" type="datetime-local" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="dueAt">Due At (optional)</Label>
|
||||
<Label htmlFor="dueAt">{t("homework.form.dueAt")}</Label>
|
||||
<Input id="dueAt" name="dueAt" type="datetime-local" />
|
||||
</div>
|
||||
|
||||
@@ -133,29 +210,19 @@ export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]
|
||||
checked={allowLate}
|
||||
onChange={(e) => setAllowLate(e.target.checked)}
|
||||
/>
|
||||
<Label htmlFor="allowLate">Allow late submissions</Label>
|
||||
<Label htmlFor="allowLate">{t("homework.form.allowLate")}</Label>
|
||||
<input type="hidden" name="allowLate" value={allowLate ? "true" : "false"} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="lateDueAt">Late Due At (optional)</Label>
|
||||
<Label htmlFor="lateDueAt">{t("homework.form.lateDueAt")}</Label>
|
||||
<Input id="lateDueAt" name="lateDueAt" type="datetime-local" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="maxAttempts">Max Attempts</Label>
|
||||
<Label htmlFor="maxAttempts">{t("homework.form.maxAttempts")}</Label>
|
||||
<Input id="maxAttempts" name="maxAttempts" type="number" min={1} max={20} defaultValue={1} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label htmlFor="targetStudentIdsText">Target student IDs (optional)</Label>
|
||||
<Textarea
|
||||
id="targetStudentIdsText"
|
||||
name="targetStudentIdsText"
|
||||
placeholder="Optional. If provided, targets will be limited to students in the selected class."
|
||||
className="min-h-[90px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardFooter className="justify-end">
|
||||
|
||||
Reference in New Issue
Block a user