Skip to content

Commit e4985b4

Browse files
committedMay 19, 2024
feat(Auth): add temporary user-pass auth
·
v1.18.2v1.2.0
1 parent 16f058e commit e4985b4

36 files changed

+1248
-94
lines changed
 
File renamed without changes.

‎app/layout.tsx renamed to ‎app/(authorized)/layout.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
11
import { Inter } from "next/font/google";
2-
import "./globals.css";
2+
import "../globals.css";
33
import "bootstrap/dist/css/bootstrap.css";
44
import { Providers } from "./providers";
55
import classNames from "classnames";
66
import NavMenu from "@/components/NavMenu";
77
import DemoModeControls from "@/components/DemoModeControls";
8+
import { validateRequest } from "@/lib/auth";
9+
import { redirect } from "next/navigation";
810

911
const inter = Inter({ subsets: ["latin"] });
1012

11-
export default function RootLayout({
13+
export default async function RootLayout({
1214
children,
1315
}: Readonly<{
1416
children: React.ReactNode;
1517
}>) {
18+
const { user } = await validateRequest();
19+
if (!user) {
20+
redirect("/login");
21+
}
22+
1623
return (
1724
<html lang="en">
1825
<head>
File renamed without changes.

‎app/(authorized)/page.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"use client";
2+
import InstructorDashboard from "@/components/InstructorDashboard";
3+
import StudentDashboard from "@/components/StudentDashboard";
4+
import { IUser_Raw } from "@/lib/models/user";
5+
import { globalStateAtom } from "@/state/globalState";
6+
import axios from "axios";
7+
import { useAtom } from "jotai";
8+
import { useEffect } from "react";
9+
10+
export default function Dashboard() {
11+
const [globalState, setGlobalState] = useAtom(globalStateAtom);
12+
13+
useEffect(() => {
14+
async function fetchData() {
15+
const res = await axios.get("/api/auth/session");
16+
if (!res.data?.user) return;
17+
updateStateFromSession(res.data.user);
18+
}
19+
fetchData();
20+
}, []);
21+
22+
function updateStateFromSession(userData: IUser_Raw) {
23+
setGlobalState((prev) => ({
24+
...prev,
25+
role: userData?.role,
26+
viewAs: userData?.role,
27+
courseID: userData?.courses[0] || "",
28+
}));
29+
}
30+
31+
if (globalState.viewAs === "instructor") {
32+
return <InstructorDashboard course_id={globalState.courseID} />;
33+
}
34+
return <StudentDashboard course_id={globalState.courseID} />;
35+
}
File renamed without changes.
File renamed without changes.

‎app/(unauthorized)/login/layout.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Inter } from "next/font/google";
2+
import "../../globals.css";
3+
import "bootstrap/dist/css/bootstrap.css";
4+
import classNames from "classnames";
5+
6+
const inter = Inter({ subsets: ["latin"] });
7+
8+
export default function RootLayout({
9+
children,
10+
}: Readonly<{
11+
children: React.ReactNode;
12+
}>) {
13+
return (
14+
<html lang="en">
15+
<head>
16+
<title>Course Analytics - LibreTexts ADAPT</title>
17+
</head>
18+
<body
19+
className={classNames(inter.className, "container mt-4 default-layout")}
20+
>
21+
<div className="tw-flex tw-flex-row">
22+
<div className="tw-flex tw-ml-6 !tw-w-full">{children}</div>
23+
</div>
24+
</body>
25+
</html>
26+
);
27+
}

‎app/(unauthorized)/login/page.tsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { cookies } from "next/headers";
2+
import { lucia, validateRequest } from "@/lib/auth";
3+
import { redirect } from "next/navigation";
4+
import { Form, type ActionResult } from "@/lib/form";
5+
import { getUser, verifyPassword } from "@/utils/auth";
6+
7+
export default async function Page() {
8+
const { user } = await validateRequest();
9+
if (user) {
10+
return redirect("/"); // Redirect to the home page if the user is already signed in
11+
}
12+
13+
const INPUT_CLASSES = "tw-mt-1 tw-rounded-md tw-border-slate-500";
14+
return (
15+
<div className="tw-flex tw-flex-col tw-justify-center tw-items-center tw-align-center tw-w-full">
16+
<div className="!tw-border !tw-border-black tw-bg-white !tw-px-8 !tw-py-6 tw-rounded-md tw-shadow-md">
17+
<h1 className="tw-text-center">Sign in</h1>
18+
<Form action={login}>
19+
<div className="tw-flex tw-flex-col">
20+
<label htmlFor="email">Email</label>
21+
<input
22+
name="email"
23+
id="email"
24+
className={INPUT_CLASSES}
25+
placeholder="johndoe@mail.com"
26+
maxLength={31}
27+
/>
28+
<br />
29+
<label htmlFor="password">Password</label>
30+
<input
31+
type="password"
32+
name="password"
33+
id="password"
34+
placeholder="******"
35+
className={INPUT_CLASSES}
36+
maxLength={255}
37+
/>
38+
<br />
39+
<button className="tw-bg-libre-blue tw-text-white tw-rounded-md tw-border-none tw-shadow-md">
40+
Continue
41+
</button>
42+
</div>
43+
</Form>
44+
</div>
45+
</div>
46+
);
47+
}
48+
49+
async function login(_: any, formData: FormData): Promise<ActionResult> {
50+
"use server";
51+
const email = formData.get("email");
52+
if (typeof email !== "string" || email.length < 3 || email.length > 31) {
53+
return {
54+
error: "Invalid email",
55+
};
56+
}
57+
const password = formData.get("password");
58+
if (
59+
typeof password !== "string" ||
60+
password.length < 6 ||
61+
password.length > 255
62+
) {
63+
return {
64+
error: "Invalid password",
65+
};
66+
}
67+
68+
const existingUser = await getUser(email);
69+
if (!existingUser) {
70+
return {
71+
error: "Incorrect email or password",
72+
};
73+
}
74+
75+
const validPassword = await verifyPassword(password, existingUser.password);
76+
if (!validPassword) {
77+
return {
78+
error: "Incorrect email or password",
79+
};
80+
}
81+
82+
const session = await lucia.createSession(existingUser.id, {});
83+
const sessionCookie = lucia.createSessionCookie(session.id);
84+
cookies().set(
85+
sessionCookie.name,
86+
sessionCookie.value,
87+
sessionCookie.attributes
88+
);
89+
return redirect("/");
90+
}

‎app/api/assignments/route.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,25 @@
11
import Analytics from "@/lib/Analytics";
2+
import { validateRequest } from "@/lib/auth";
3+
import { queryObjFromSearchParams } from "@/utils/misc";
4+
import { GetAssignmentsSchema, validateZod } from "@/validators";
5+
import { NextRequest } from "next/server";
26

3-
export async function GET() {
7+
export async function GET(request: NextRequest) {
48
try {
5-
const adaptId = process.env.NEXT_PUBLIC_ADAPT_ID;
6-
const analytics = new Analytics(adaptId);
9+
// const { session } = await validateRequest();
10+
// if (!session) {
11+
// return Response.json({ error: "Unauthorized" }, { status: 401 });
12+
// }
13+
14+
const searchParams = request.nextUrl.searchParams;
15+
const queryObj = queryObjFromSearchParams(searchParams);
16+
await validateZod(GetAssignmentsSchema, {
17+
query: queryObj,
18+
});
19+
20+
const { courseID } = queryObj;
21+
22+
const analytics = new Analytics(courseID);
723
const assignments = await analytics.getAssignments();
824

925
return Response.json({ data: assignments });

‎app/api/auth/session/route.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { validateRequest } from "@/lib/auth";
2+
3+
export async function GET(request: Request) {
4+
try {
5+
const { user } = await validateRequest();
6+
if (!user) {
7+
return new Response(null, { status: 401 });
8+
}
9+
return Response.json({ user });
10+
} catch (err) {
11+
console.error(err);
12+
return new Response(null, { status: 500 });
13+
}
14+
}

‎app/middleware.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// middleware.ts
2+
import { verifyRequestOrigin } from "lucia";
3+
import { NextResponse } from "next/server";
4+
import type { NextRequest } from "next/server";
5+
6+
export async function middleware(request: NextRequest): Promise<NextResponse> {
7+
if (request.method === "GET") {
8+
return NextResponse.next();
9+
}
10+
const originHeader = request.headers.get("Origin");
11+
// NOTE: You may need to use `X-Forwarded-Host` instead
12+
const hostHeader = request.headers.get("Host");
13+
if (
14+
!originHeader ||
15+
!hostHeader ||
16+
!verifyRequestOrigin(originHeader, [hostHeader])
17+
) {
18+
return new NextResponse(null, {
19+
status: 403,
20+
});
21+
}
22+
return NextResponse.next();
23+
}

‎app/page.tsx

Lines changed: 0 additions & 15 deletions
This file was deleted.

‎components/CourseSettings/StudentPermissions.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import ToastContainer from "../ToastContainer";
77
import { Controller, useForm } from "react-hook-form";
88

99
interface StudentPermissionsProps {
10-
saveData: (data: Partial<GlobalState>) => Promise<void> | void;
10+
saveData: (
11+
course_id: string,
12+
data: Partial<GlobalState>
13+
) => Promise<void> | void;
1114
}
1215

1316
const StudentPermissions: React.FC<StudentPermissionsProps> = ({
@@ -33,7 +36,7 @@ const StudentPermissions: React.FC<StudentPermissionsProps> = ({
3336
try {
3437
setLoading(true);
3538

36-
await saveData({
39+
await saveData(globalState.courseID, {
3740
shareGradeDistribution: getValues().shareGradeDistribution,
3841
});
3942

@@ -102,9 +105,7 @@ const StudentPermissions: React.FC<StudentPermissionsProps> = ({
102105
<Toast.Header>
103106
<strong className="me-auto">Success!</strong>
104107
</Toast.Header>
105-
<Toast.Body
106-
107-
>Settings saved successfully.</Toast.Body>
108+
<Toast.Body>Settings saved successfully.</Toast.Body>
108109
</Toast>
109110
</ToastContainer>
110111
</>

‎components/DemoModeControls.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { useState } from "react";
66

77
const DemoModeControls = () => {
88
const [globalState, setGlobalState] = useAtom(globalStateAtom);
9-
const [courseId, setCourseId] = useState(globalState.adaptId);
9+
// const [courseId, setCourseId] = useState(globalState.adaptId);
1010

1111
return (
1212
<div className="tw-w-1/3 tw-flex tw-flex-col tw-items-center tw-border tw-border-solid tw-border-white tw-rounded-md tw-py-1 tw-mb-8 tw-bg-libre-blue tw-text-white">
@@ -26,7 +26,7 @@ const DemoModeControls = () => {
2626
Toggle
2727
</button>
2828
</p>
29-
<form
29+
{/* <form
3030
onSubmit={(e) => {
3131
e.preventDefault();
3232
setGlobalState((prev) => ({
@@ -53,7 +53,7 @@ const DemoModeControls = () => {
5353
className="tw-ml-2 tw-bg-gray-200 tw-rounded-md tw-text-xs tw-w-16 tw-text-center"
5454
/>
5555
</p>
56-
</form>
56+
</form> */}
5757
</div>
5858
</div>
5959
);

‎components/InstructorDashboard.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import ADAPTPerformance from "./Visualizations/ADAPTPerformance";
2525
import InstructorDashboardControls from "./InstructorDashboardControls";
2626
import GradeDistribution from "./Visualizations/GradeDistribution";
2727

28-
const InstructorDashboard = () => {
28+
const InstructorDashboard = ({course_id}:{course_id: string}) => {
2929
return (
3030
<GenericPageContainer>
3131
<PageHeader
@@ -40,7 +40,7 @@ const InstructorDashboard = () => {
4040
title="Performance Per Assignment"
4141
description="Class average vs. selected student's scores"
4242
>
43-
<PerfPerAssignment getData={getPerformancePerAssignment} />
43+
<PerfPerAssignment getData={(student_id) => getPerformancePerAssignment(course_id, student_id)} />
4444
</VisualizationContainer>
4545
{/* <VisualizationContainer
4646
title="Textbook Activity"
@@ -59,7 +59,7 @@ const InstructorDashboard = () => {
5959
title="Submission Timeline"
6060
description="Timeline of student submissions for selected assignment"
6161
>
62-
<SubmissionTimeline getData={getSubmissionTimeline} />
62+
<SubmissionTimeline getData={(assignment_id) => getSubmissionTimeline(course_id, assignment_id)} />
6363
</VisualizationContainer>
6464
{/* <VisualizationContainer
6565
title="Textbook Engagment"
@@ -71,13 +71,13 @@ const InstructorDashboard = () => {
7171
title="ADAPT Performance"
7272
description="Distribution of student scores by selected assignment"
7373
>
74-
<ADAPTPerformance getData={getADAPTPerformance} />
74+
<ADAPTPerformance getData={(assignment_id) => getADAPTPerformance(course_id, assignment_id)} />
7575
</VisualizationContainer>
7676
<VisualizationContainer
7777
title="Grade Distribution"
7878
description="Distribution of student scores across all assignments"
7979
>
80-
<GradeDistribution getData={getGradeDistribution} />
80+
<GradeDistribution getData={() => getGradeDistribution(course_id)} />
8181
</VisualizationContainer>
8282
<VisualizationContainer
8383
title="Learning Curve"

‎components/InstructorDashboardControls.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const InstructorDashboardControls = () => {
1414
const elementRef = useRef<HTMLDivElement>(null);
1515

1616
const { data: students, status: studentsStatus } = useQuery<IDWithName[]>({
17-
queryKey: ["students"],
17+
queryKey: ["students", globalState.courseID, globalState.ferpaPrivacy],
1818
queryFn: fetchStudents,
1919
staleTime: 1000 * 60 * 15, // 15 minutes
2020
refetchOnWindowFocus: false,
@@ -23,7 +23,7 @@ const InstructorDashboardControls = () => {
2323
const { data: assignments, status: assignmentsStatus } = useQuery<
2424
IDWithName[]
2525
>({
26-
queryKey: ["assignments"],
26+
queryKey: ["assignments", globalState.courseID],
2727
queryFn: fetchAssignments,
2828
staleTime: 1000 * 60 * 15, // 15 minutes
2929
refetchOnWindowFocus: false,
@@ -45,7 +45,8 @@ const InstructorDashboardControls = () => {
4545

4646
async function fetchStudents(): Promise<IDWithName[]> {
4747
try {
48-
const data = await getStudents(1, 100, globalState.ferpaPrivacy);
48+
if(!globalState.courseID) return [];
49+
const data = await getStudents(globalState.courseID, 1, 100, globalState.ferpaPrivacy);
4950
return data;
5051
} catch (err) {
5152
console.error(err);
@@ -55,7 +56,8 @@ const InstructorDashboardControls = () => {
5556

5657
async function fetchAssignments(): Promise<IDWithName[]> {
5758
try {
58-
const data = await getAssignments();
59+
if(!globalState.courseID) return [];
60+
const data = await getAssignments(globalState.courseID);
5961
return data;
6062
} catch (err) {
6163
console.error(err);

‎components/InvalidCourseID.tsx

Whitespace-only changes.

‎components/NavMenu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ const NavMenu = () => {
7171
</ListGroup>
7272
{process.env.NODE_ENV === "development" && (
7373
<p className="tw-text-center tw-mt-2">
74-
Course ID: {globalState.adaptId}
74+
Course ID: {globalState.courseID}
7575
</p>
7676
)}
7777
</>

‎components/StudentDashboard.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { globalStateAtom } from "@/state/globalState";
88
import StudentQuickMetrics from "@/components/StudentQuickMetrics";
99
import ActivityAccessed from "./Visualizations/ActivityAccessed";
1010

11-
const StudentDashboard = () => {
11+
const StudentDashboard = ({course_id}:{course_id: string}) => {
1212
const [globalState] = useAtom(globalStateAtom);
1313

1414
return (
@@ -23,14 +23,14 @@ const StudentDashboard = () => {
2323
description="Your scores vs. class average for each assignment."
2424
studentMode
2525
>
26-
<PerfPerAssignment getData={getPerformancePerAssignment} />
26+
<PerfPerAssignment getData={(student_id) => getPerformancePerAssignment(course_id, student_id)} />
2727
</VisualizationContainer>
2828
<VisualizationContainer
2929
title="Activity Accessed"
3030
description="Compare your engagement with the course material."
3131
studentMode
3232
>
33-
<ActivityAccessed getData={getActivityAccessed} />
33+
<ActivityAccessed getData={(student_id) => getActivityAccessed(course_id, student_id)} />
3434
</VisualizationContainer>
3535
</GenericPageContainer>
3636
);

‎lib/Analytics.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ class Analytics {
3939
if (!_adaptID) {
4040
throw new Error("ADAPT ID is required");
4141
}
42-
const parsed = parseInt(_adaptID.trim());
42+
const parsed = parseInt(_adaptID.toString().trim());
4343
if (isNaN(parsed)) {
4444
throw new Error("Invalid ADAPT ID");
4545
}

‎lib/analytics-functions.ts

Lines changed: 31 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
StudentQuickMetrics,
99
} from "./types";
1010
import { ICourseAnalyticsSettings_Raw } from "./models/courseAnalyticsSettings";
11+
import { validateRequest } from "./auth";
1112

1213
export async function getInstructorQuickMetrics(): Promise<InstructorQuickMetrics> {
1314
const adapt_id = process.env.NEXT_PUBLIC_ADAPT_ID; // Get ADAPT ID from env
@@ -50,84 +51,77 @@ export async function getStudentQuickMetrics(
5051
};
5152
}
5253

53-
export async function getActivityAccessed(student_id: string): Promise<ActivityAccessed>{
54-
const adapt_id = process.env.NEXT_PUBLIC_ADAPT_ID; // Get ADAPT ID from env
55-
const analytics = new Analytics(adapt_id);
54+
export async function getActivityAccessed(
55+
course_id: string,
56+
student_id: string
57+
): Promise<ActivityAccessed> {
58+
const analytics = new Analytics(course_id);
5659

5760
const activityAccessed = await analytics.getADAPTActivity(student_id);
5861

5962
return activityAccessed;
6063
}
6164

62-
export async function getADAPTPerformance(assignment_id: string) {
63-
const adapt_id = process.env.NEXT_PUBLIC_ADAPT_ID; // Get ADAPT ID from env
64-
const analytics = new Analytics(adapt_id);
65-
65+
export async function getADAPTPerformance(
66+
course_id: string,
67+
assignment_id: string
68+
) {
69+
const analytics = new Analytics(course_id);
6670
const performance = await analytics.getADAPTPerformance(assignment_id);
67-
6871
return performance;
6972
}
7073

71-
export async function getAssignments() {
72-
const adaptId = process.env.NEXT_PUBLIC_ADAPT_ID;
73-
const analytics = new Analytics(adaptId);
74-
74+
export async function getAssignments(course_id: string) {
75+
const analytics = new Analytics(course_id);
7576
const assignments = await analytics.getAssignments();
7677
return assignments;
7778
}
7879

79-
export async function getGradeDistribution() {
80-
const adaptId = process.env.NEXT_PUBLIC_ADAPT_ID;
81-
const analytics = new Analytics(adaptId);
80+
export async function getGradeDistribution(course_id: string) {
81+
const analytics = new Analytics(course_id);
8282
const gradeDistribution = await analytics.getGradeDistribution();
8383
return gradeDistribution;
8484
}
8585

86-
export async function getTextbookEngagement() {
87-
const adapt_id = process.env.NEXT_PUBLIC_ADAPT_ID; // Get ADAPT ID from env
88-
const analytics = new Analytics(adapt_id);
89-
86+
export async function getTextbookEngagement(course_id: string) {
87+
const analytics = new Analytics(course_id);
9088
const engagement = await analytics.getTextbookEngagement();
91-
9289
return engagement;
9390
}
9491

95-
export async function getPerformancePerAssignment(student_id: string) {
96-
const adapt_id = process.env.NEXT_PUBLIC_ADAPT_ID; // Get ADAPT ID from env
97-
const analytics = new Analytics(adapt_id);
98-
92+
export async function getPerformancePerAssignment(
93+
courseID: string,
94+
student_id: string
95+
) {
96+
const analytics = new Analytics(courseID);
9997
const performance = await analytics.getPerformancePerAssignment(student_id);
100-
10198
return performance;
10299
}
103100

104101
export async function getStudents(
102+
course_id: string,
105103
page: number,
106104
limit: number,
107105
privacyMode: boolean
108106
) {
109-
const adapt_id = process.env.NEXT_PUBLIC_ADAPT_ID; // Get ADAPT ID from env
110-
const analytics = new Analytics(adapt_id);
111-
107+
const analytics = new Analytics(course_id);
112108
const students = await analytics.getStudents(page, limit, privacyMode);
113-
114109
return students;
115110
}
116111

117-
export async function getSubmissionTimeline(assignment_id: string) {
118-
const adapt_id = process.env.NEXT_PUBLIC_ADAPT_ID; // Get ADAPT ID from env
119-
const analytics = new Analytics(adapt_id);
120-
112+
export async function getSubmissionTimeline(
113+
course_id: string,
114+
assignment_id: string
115+
) {
116+
const analytics = new Analytics(course_id);
121117
const timeline = await analytics.getSubmissionTimeline(assignment_id);
122-
123118
return timeline;
124119
}
125120

126121
export async function updateCourseAnalyticsSettings(
122+
course_id: string,
127123
newSettings: Partial<ICourseAnalyticsSettings_Raw>
128124
) {
129-
const adapt_id = process.env.NEXT_PUBLIC_ADAPT_ID; // Get ADAPT ID from env
130-
const analytics = new Analytics(adapt_id);
131-
125+
const analytics = new Analytics(course_id);
132126
await analytics.updateCourseAnalyticsSettings(newSettings);
133127
}

‎lib/auth.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { Lucia, type Session, type User } from "lucia";
2+
import { MongodbAdapter } from "@lucia-auth/adapter-mongodb";
3+
import connectDB from "./database";
4+
import user, { IUser_Raw } from "./models/user";
5+
import { cookies } from "next/headers";
6+
import { cache } from "react";
7+
import { webcrypto } from "crypto";
8+
import session from "./models/session";
9+
globalThis.crypto = webcrypto as Crypto;
10+
11+
// Initialize Lucia after adapter is available
12+
export const lucia = new Lucia(
13+
// @ts-ignore
14+
new MongodbAdapter(session.collection, user.collection),
15+
{
16+
sessionCookie: {
17+
expires: false,
18+
attributes: {
19+
secure: process.env.NODE_ENV === "production",
20+
},
21+
},
22+
23+
getUserAttributes: (attributes) => {
24+
return {
25+
email: attributes.email,
26+
role: attributes.role,
27+
};
28+
},
29+
}
30+
);
31+
32+
export const validateRequest = cache(
33+
async (): Promise<
34+
{ user: User; session: Session } | { user: null; session: null }
35+
> => {
36+
const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null;
37+
if (!sessionId) {
38+
return {
39+
user: null,
40+
session: null,
41+
};
42+
}
43+
44+
await connectDB();
45+
const aggResult = await session.aggregate([
46+
{
47+
$match: {
48+
_id: "vc7ipsjit636h424cdjvhqa4fpmuz6pe7o3jsqvt",
49+
},
50+
},
51+
{
52+
$addFields: {
53+
userId: {
54+
$toObjectId: "$user_id",
55+
},
56+
},
57+
},
58+
{
59+
$lookup: {
60+
from: "user",
61+
localField: "userId",
62+
foreignField: "_id",
63+
as: "user",
64+
},
65+
},
66+
{
67+
$project: {
68+
_id: 0,
69+
user: {
70+
$arrayElemAt: ["$user", 0],
71+
},
72+
session: "$$ROOT",
73+
},
74+
},
75+
{
76+
$project: {
77+
"user.password": 0, // Remove password from user object
78+
}
79+
}
80+
]);
81+
82+
const result = aggResult[0];
83+
if (!result?.user || !result?.session) {
84+
return {
85+
user: null,
86+
session: null,
87+
};
88+
}
89+
90+
// next.js throws when you attempt to set cookie when rendering page
91+
try {
92+
if (result.session && result.session.fresh) {
93+
const sessionCookie = lucia.createSessionCookie(result.session.id);
94+
cookies().set(
95+
sessionCookie.name,
96+
sessionCookie.value,
97+
sessionCookie.attributes
98+
);
99+
}
100+
if (!result.session) {
101+
const sessionCookie = lucia.createBlankSessionCookie();
102+
cookies().set(
103+
sessionCookie.name,
104+
sessionCookie.value,
105+
sessionCookie.attributes
106+
);
107+
}
108+
} catch {}
109+
return result;
110+
}
111+
);
112+
113+
declare module "lucia" {
114+
interface Register {
115+
Lucia: typeof lucia;
116+
DatabaseUserAttributes: IUser_Raw;
117+
}
118+
}

‎lib/form.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"use client";
2+
3+
import { useFormState } from "react-dom";
4+
5+
export function Form({
6+
children,
7+
action,
8+
}: {
9+
children: React.ReactNode;
10+
action: (prevState: any, formData: FormData) => Promise<ActionResult>;
11+
}) {
12+
const [state, formAction] = useFormState(action, {
13+
error: null,
14+
});
15+
return (
16+
<form action={formAction}>
17+
{children}
18+
<p style={{ color: "red", textAlign: 'center', marginTop: '10px' }}>{state.error}</p>
19+
</form>
20+
);
21+
}
22+
23+
export interface ActionResult {
24+
error: string | null;
25+
}

‎lib/models/calcADAPTAssignments.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const CalcADAPTAssignmentsSchema = new Schema<ICalcADAPTAssignments>(
3030

3131
CalcADAPTAssignmentsSchema.index({ actor: 1, courseID: 1 }, { unique: true });
3232

33-
export default models.CalcADAPTAssignments ||
33+
export default models?.CalcADAPTAssignments ||
3434
model<ICalcADAPTAssignments>(
3535
"CalcADAPTAssignments",
3636
CalcADAPTAssignmentsSchema,

‎lib/models/enrollments.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export interface IEnrollments extends IEnrollmentsRaw, Document {}
1212
const EnrollmentsSchema = new Schema<IEnrollments>(
1313
{
1414
email: String,
15-
courseID: Number,
15+
courseID: String,
1616
created_at: String,
1717
},
1818
{

‎lib/models/session.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Document, Schema, model, models } from "mongoose";
2+
3+
export interface ISession_Raw {
4+
user_id: string;
5+
expires_at: Date;
6+
}
7+
8+
export interface ISession extends ISession_Raw, Document {}
9+
10+
const SessionSchema = new Schema<ISession>(
11+
{
12+
user_id: { type: String, required: true },
13+
expires_at: { type: Date, required: true },
14+
},
15+
{
16+
collection: "session",
17+
}
18+
);
19+
20+
SessionSchema.index({ user_id: 1 });
21+
22+
export default models?.Session ||
23+
model<ISession>("Session", SessionSchema, "session");

‎lib/models/user.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Document, Schema, model, models } from "mongoose";
2+
3+
export interface IUser_Raw {
4+
email: string;
5+
password: string;
6+
role: "instructor" | "student";
7+
courses: string[];
8+
}
9+
10+
export interface IUser extends IUser_Raw, Document {}
11+
12+
const UserSchema = new Schema<IUser>(
13+
{
14+
_id: { type: String, required: true },
15+
email: { type: String, required: true },
16+
password: { type: String, required: true },
17+
role: { type: String, required: true },
18+
courses: { type: [String], required: true },
19+
},
20+
{
21+
collection: "user",
22+
}
23+
);
24+
25+
export default models?.User || model<IUser>("User", UserSchema, "user");

‎lib/types/misc.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { ZodError, z } from "zod";
2+
13
export type AnalyticsAPIResponse<T> = {
24
data: T;
35
error?: string;
@@ -6,8 +8,8 @@ export type AnalyticsAPIResponse<T> = {
68
export type IDWithName = { id: string; name: string };
79

810
export type ADAPT_CourseScoresAPIResponse = {
9-
body: string[][]
10-
}
11+
body: string[][];
12+
};
1113

1214
export type ArrayElement<A> = A extends (infer T)[] ? T : never;
1315

@@ -16,8 +18,12 @@ export type VisualizationBaseProps = {
1618
height?: number;
1719
tableView?: boolean;
1820
innerRef?: React.RefObject<VisualizationInnerRef>;
19-
}
21+
};
2022

2123
export type VisualizationInnerRef = {
2224
getSVG: () => SVGSVGElement | null;
23-
}
25+
};
26+
27+
export type ZodSafeParseResult<T extends z.ZodTypeAny> =
28+
| { success: true; data: z.infer<T> }
29+
| { success: false; error: ZodError };

‎package-lock.json

Lines changed: 698 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,28 +11,33 @@
1111
"prepare": "husky"
1212
},
1313
"dependencies": {
14+
"@lucia-auth/adapter-mongodb": "^1.0.3",
1415
"@tanstack/react-query": "^5.20.5",
1516
"@tanstack/react-query-devtools": "^5.21.0",
1617
"@tanstack/react-query-next-experimental": "^5.20.5",
1718
"@tanstack/react-table": "^8.15.0",
1819
"axios": "^1.6.8",
20+
"bcryptjs": "^2.4.3",
1921
"bootstrap": "^5.3.2",
2022
"classnames": "^2.5.1",
2123
"d3": "^7.9.0",
2224
"date-fns": "^3.6.0",
2325
"jotai": "^2.8.0",
26+
"lucia": "^3.2.0",
2427
"mongoose": "^8.3.0",
2528
"next": "14.1.1",
29+
"next-auth": "^5.0.0-beta.18",
2630
"react": "^18",
2731
"react-bootstrap": "^2.10.1",
2832
"react-bootstrap-icons": "^1.11.3",
2933
"react-dom": "^18",
3034
"react-hook-form": "^7.51.4",
31-
"zod": "^3.22.4"
35+
"zod": "^3.23.8"
3236
},
3337
"devDependencies": {
3438
"@commitlint/cli": "^19.3.0",
3539
"@commitlint/config-conventional": "^19.2.2",
40+
"@types/bcryptjs": "^2.4.6",
3641
"@types/d3": "^7.4.3",
3742
"@types/node": "^20",
3843
"@types/react": "^18",

‎state/globalState.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { SetStateAction, atom } from "jotai";
33

44
export type GlobalState = {
55
ferpaPrivacy: boolean;
6-
adaptId: string;
6+
role: string;
77
viewAs: string;
88
studentId: string;
99
assignmentId: string;
@@ -13,7 +13,7 @@ type GlobalStateSetAction = SetStateAction<GlobalState>;
1313

1414
export const initGlobalStateAtom = atom({
1515
ferpaPrivacy: false,
16-
adaptId: "",
16+
role: "student",
1717
viewAs: "student",
1818
studentId: "",
1919
assignmentId: "",

‎utils/auth.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import user, { IUser } from "@/lib/models/user";
2+
import bcrypt from "bcryptjs";
3+
4+
export const hashPassword = async (password: string) => {
5+
try {
6+
const salt = await bcrypt.genSalt(12);
7+
return bcrypt.hash(password, salt);
8+
} catch (err) {
9+
console.error(err);
10+
return null;
11+
}
12+
};
13+
14+
export const verifyPassword = async (
15+
rawInputPassword: string,
16+
hashedPassword: string
17+
) => {
18+
return await bcrypt.compare(rawInputPassword, hashedPassword);
19+
};
20+
21+
export const getUser = async (email: string): Promise<IUser | null> => {
22+
try {
23+
const found = await user.findOne({ email });
24+
return found;
25+
} catch (err) {
26+
console.error(err);
27+
return null;
28+
}
29+
};

‎utils/misc.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,12 @@ export const getPaginationOffset = (page: number, limit: number) => {
55

66
export const debugADP = (msg: string) => {
77
console.log(`[ADP]: ${msg}`);
8+
}
9+
10+
export const queryObjFromSearchParams = (searchParams: URLSearchParams) => {
11+
const queryObj: { [key: string]: string } = {};
12+
searchParams.forEach((value, key) => {
13+
queryObj[key] = value;
14+
});
15+
return queryObj;
816
}

‎validators/analytics.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { z } from "zod";
2+
3+
export const _CourseIDSchema = z.object({
4+
courseID: z.string(),
5+
});
6+
7+
export const GetAssignmentsSchema = z.object({
8+
query: _CourseIDSchema,
9+
});

‎validators/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { z } from "zod";
2+
import { ZodSafeParseResult } from "@/lib/types";
3+
import { NextApiRequest } from "next";
4+
import { NextRequest } from "next/server";
5+
6+
export * from "./analytics";
7+
8+
export async function validateZod<T extends z.ZodTypeAny>(
9+
schema: T,
10+
req: NextRequest | { body?: any; query?: any }
11+
): Promise<ZodSafeParseResult<T>> {
12+
if ("body" in req || "query" in req) {
13+
return await schema.safeParseAsync({
14+
...("body" in req && { body: req.body }),
15+
...("query" in req && { query: req.query }),
16+
});
17+
}
18+
19+
throw new Error("Request object must have either a body or query property");
20+
}

0 commit comments

Comments
 (0)
Please sign in to comment.