Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 1 addition & 25 deletions firestore.indexes.json
Original file line number Diff line number Diff line change
@@ -1,28 +1,4 @@
{
"indexes": [
{
"collectionGroup": "gradableFrqSubmissions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "submittedAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "gradableFrqSubmissions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "studentId", "order": "ASCENDING" },
{ "fieldPath": "submittedAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "gradedFrqSubmissions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "studentId", "order": "ASCENDING" },
{ "fieldPath": "gradedAt", "order": "DESCENDING" }
]
}
],
"indexes": [],
"fieldOverrides": []
}
66 changes: 55 additions & 11 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,6 @@ service cloud.firestore {
return isAuthenticated() && (accessLevel == "admin" || accessLevel == "member" || accessLevel == "grader");
}

function isGraderOrAdmin() {
let accessLevel = get(/databases/$(database)/documents/users/$(request.auth.uid)).data.access;
return isAuthenticated() &&
(accessLevel == "admin" || accessLevel == "grader");
}

// Allow read/write access to users for their own user data
match /users/{userId} {
allow read: if isAuthenticated() && request.auth.uid == userId;
Expand Down Expand Up @@ -79,7 +73,7 @@ service cloud.firestore {
// `resource` is null when the document does not exist, and dereferencing it
// raises an evaluation error rather than denying, which surfaces to the
// client as "insufficient permissions" for what is really a missing doc.
allow get, list: if isGraderOrAdmin() ||
allow get, list: if isGraderOrMemberOrAdmin() ||
(isAuthenticated() && resource != null && resource.data.studentId == request.auth.uid);

allow create: if isAuthenticated()
Expand All @@ -100,14 +94,21 @@ service cloud.firestore {
/databases/$(database)/documents/subjects/$(request.resource.data.subject)/units/$(request.resource.data.unitId)/frqs/$(request.resource.data.templateId)
);

allow delete: if isGraderOrAdmin();
// The owner is included because issuing a grade deletes the queue entry in
// the same transaction, and a student self-grading their own submission runs
// that transaction as themselves.
allow delete: if isGraderOrMemberOrAdmin() ||
(isAuthenticated() && resource != null && resource.data.studentId == request.auth.uid);
}

match /graded-frqs/{resultId} {
allow get, list: if isGraderOrAdmin() ||
allow get, list: if isGraderOrMemberOrAdmin() ||
(isAuthenticated() && resource != null && resource.data.studentId == request.auth.uid);

allow create: if isGraderOrAdmin()
// Staff only. This collection is what an official FiveHive grade means, so a
// student cannot write here even about their own attempt — a self-assessment
// goes to `self-graded-frqs` instead, where it carries no such authority.
allow create: if isGraderOrMemberOrAdmin()
&& resultId == request.resource.data.sourceSubmissionId
&& request.resource.data.keys().hasOnly([
'sourceSubmissionId',
Expand All @@ -134,7 +135,50 @@ service cloud.firestore {
&& request.resource.data.feedback is string
&& request.resource.data.graderId == request.auth.uid;

allow update, delete: if isGraderOrAdmin();
allow update, delete: if isGraderOrMemberOrAdmin();
}

// A student's own assessment of their own attempt. Deliberately a separate
// collection from `graded-frqs`: anyone may score themselves however they
// like, so nothing stored here carries a grader's authority, and no query
// for official results has to remember to filter these back out.
match /self-graded-frqs/{resultId} {
allow get, list: if isGraderOrMemberOrAdmin() ||
(isAuthenticated() && resource != null && resource.data.studentId == request.auth.uid);

// Only the author of the attempt, and only as themselves: both ids must be
// the caller's own uid, so a self-assessment cannot be filed against
// somebody else's work or signed as though staff had graded it.
allow create: if isAuthenticated()
&& request.resource.data.studentId == request.auth.uid
&& request.resource.data.graderId == request.auth.uid
&& resultId == request.resource.data.sourceSubmissionId
&& request.resource.data.keys().hasOnly([
'sourceSubmissionId',
'templateId',
'subject',
'unitId',
'studentId',
'responses',
'submittedAt',
'score',
'feedback',
'grades',
'graderId',
'gradedAt'
])
&& request.resource.data.grades is list
&& request.resource.data.sourceSubmissionId is string
&& request.resource.data.templateId is string
&& request.resource.data.subject is string
&& request.resource.data.unitId is string
&& request.resource.data.responses is map
&& request.resource.data.score is string
&& request.resource.data.feedback is string;

// Write-once, like an official result: a student may not quietly restate
// yesterday's self-assessment. Staff can clear one out.
allow update, delete: if isGraderOrMemberOrAdmin();
Comment thread
Famousmaster206 marked this conversation as resolved.
}

// Admins can read/write all user documents
Expand Down
99 changes: 51 additions & 48 deletions src/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,7 @@ import { useUser } from "../../components/hooks/UserContext";
import Link from "next/link";
import { cn, formatSlug } from "@/lib/utils";
import { Ban, ClipboardPen, PencilRuler, ShieldUser, X } from "lucide-react";
import {
doc,
getDocs,
updateDoc,
} from "firebase/firestore";
import { doc, getDocs, updateDoc } from "firebase/firestore";
import { db } from "@/lib/firebase";
import { Button } from "@/components/ui/button";

Expand All @@ -27,27 +23,33 @@ const Page = () => {
const { user } = useUser();
const router = useRouter();

const [ungradedFrqCount, setUngradedFrqCount] = useState<number | null>(
null,
);
const [ungradedFrqCount, setUngradedFrqCount] = useState<number | null>(null);

useEffect(() => {
if (!user || user.access !== "admin") return;
// Every FiveHive staff role grades FRQs, not just admins. Gating this on
// "admin" alone is why a member who was told to grade saw no queue, no count,
// and no link to either.
const canGradeFrqs =
user?.access === "admin" ||
user?.access === "member" ||
user?.access === "grader";

const fetchUngradedFrqCount = async () => {
try {
const collectionRef = getUngradedFrqsCollectionRef();
const snapshot = await getDocs(collectionRef);
useEffect(() => {
if (!canGradeFrqs) return;

setUngradedFrqCount(snapshot.size);
} catch (error) {
console.error("Failed to fetch ungraded FRQ count:", error);
setUngradedFrqCount(null);
}
};
const fetchUngradedFrqCount = async () => {
try {
const collectionRef = getUngradedFrqsCollectionRef();
const snapshot = await getDocs(collectionRef);

setUngradedFrqCount(snapshot.size);
} catch (error) {
console.error("Failed to fetch ungraded FRQ count:", error);
setUngradedFrqCount(null);
}
};

void fetchUngradedFrqCount();
}, [user]);
void fetchUngradedFrqCount();
}, [canGradeFrqs]);

if (!user) {
return (
Expand All @@ -69,38 +71,35 @@ useEffect(() => {
<div className="mx-auto flex max-w-3xl flex-col p-8">
<h1 className="text-5xl font-extrabold lg:text-6xl">Admin Dashboard</h1>

{user.access === "admin" && (
<>
<AdminPanel user={user} />
{/* User management is genuinely admin-only; the grading queue is not. */}
{user.access === "admin" && <AdminPanel user={user} />}

<div className="mb-4 flex flex-col gap-4 rounded-lg border-4 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide opacity-70">
Ungraded FRQs
</p>
{canGradeFrqs && (
<div className="mb-4 flex flex-col gap-4 rounded-lg border-4 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide opacity-70">
Ungraded FRQs
</p>

<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">
{ungradedFrqCount ?? "—"}
</span>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">
{ungradedFrqCount ?? "—"}
</span>

<span className="text-sm opacity-70">currently ungraded</span>
</div>
<span className="text-sm opacity-70">currently ungraded</span>
</div>

<Link href="/frq-grading">
<Button className="w-full sm:w-auto">
Open FRQ Grading List
</Button>
</Link>
</div>
</>

<Link href="/frq-grading">
<Button className="w-full sm:w-auto">
Open FRQ Grading List
</Button>
</Link>
</div>
)}

<Link href="/admin/feedback" className="hover:text-yellow-600">
<Button className="w-full">
Check Feedback & Bug Reports
</Button>
<Button className="w-full">Check Feedback & Bug Reports</Button>
</Link>
<br></br>
<SelectCourse />
Expand Down Expand Up @@ -175,8 +174,12 @@ function AdminPanel({ user }: { user: User }) {

const filteredUsers = users.filter(
(user) =>
user.displayName?.toLowerCase().includes(searchTermUsers.toLowerCase().trim()) ||
user.email?.toLowerCase().includes(searchTermUsers.toLowerCase().trim()) ||
user.displayName
?.toLowerCase()
.includes(searchTermUsers.toLowerCase().trim()) ||
user.email
?.toLowerCase()
.includes(searchTermUsers.toLowerCase().trim()) ||
user.access?.toLowerCase().includes(searchTermUsers.toLowerCase().trim()),
);

Expand Down
35 changes: 7 additions & 28 deletions src/app/admin/subject/[slug]/[unit]/test/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { syncedQuestions } from "@/components/article-creator/custom_questions/Q
import { useUser } from "@/components/hooks/UserContext";
import { db } from "@/lib/firebase";
import { doc, getDoc, setDoc } from "firebase/firestore";
import { Button, buttonVariants } from "@/components/ui/button";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";
import type { UnitTest } from "@/types/firestore";
import { usePathname } from "next/navigation";
Expand All @@ -15,8 +15,7 @@ import { processQuestions } from "@/components/article-creator/FetchArticleFunct
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Link } from "@/app/admin/subject/link";
import { ArrowLeft, UserRoundCog } from "lucide-react";
import { AdminEditorBackLinks } from "@/app/admin/subject/link";
import { cn } from "@/lib/utils";
import { Blocker } from "@/app/admin/subject/navigation-block";

Expand Down Expand Up @@ -131,31 +130,11 @@ const Page = () => {
return (
<div className="relative min-h-screen">
{unsavedChanges && <Blocker />}
<div className="grid gap-2 pl-4 pt-6">
<Link
className={cn(
buttonVariants({
variant: unsavedChanges ? "destructive" : "outline",
}),
"w-min",
)}
href={`/admin`}
>
<UserRoundCog className="mr-2" />
Return to Admin Dashboard
</Link>
<Link
className={cn(
buttonVariants({
variant: unsavedChanges ? "destructive" : "outline",
}),
"w-min",
)}
href={`/admin/subject/${subject}`}
>
<ArrowLeft className="mr-2" />
Return to Subject
</Link>
<div className="pl-4 pt-6">
<AdminEditorBackLinks
subjectSlug={subject}
unsavedChanges={unsavedChanges}
/>
</div>
<div className="flex gap-2 p-4 pb-0">
<div className="grid place-content-start gap-1.5">
Expand Down
8 changes: 4 additions & 4 deletions src/app/admin/subject/[slug]/_components/unit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,8 @@ interface UnitComponentProps {
frqTemplates: FRQTemplate[];
onFrqAdd: (unitId: string, title: string) => Promise<void>;
onFrqRename: (frqId: string, title: string) => Promise<void>;
onFrqVisibilityChange: (
frqId: string,
isPublic: boolean,
) => Promise<void>;
onFrqDelete: (frqId: string) => Promise<void>;
onFrqVisibilityChange: (frqId: string, isPublic: boolean) => Promise<void>;
}

/**
Expand All @@ -64,6 +62,7 @@ function UnitComponent({
frqTemplates,
onFrqAdd,
onFrqRename,
onFrqDelete,
onFrqVisibilityChange,
}: UnitComponentProps) {
const [expanded, setExpanded] = useState<boolean>(false);
Expand Down Expand Up @@ -426,6 +425,7 @@ function UnitComponent({
frqs={frqTemplates}
onFrqAdd={(title) => onFrqAdd(unit.id, title)}
onFrqUpdate={onFrqRename}
onFrqDelete={(frqId) => void onFrqDelete(frqId)}
onFrqVisibilityChange={onFrqVisibilityChange}
/>
</div>
Expand Down
Loading