Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
68 changes: 68 additions & 0 deletions src/components/auth/protected-content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import Spinner from "@ui/spinner";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
import { type ReactNode, useEffect } from "react";

type UserRole = "STUDENT" | "INSTRUCTOR" | "ADMIN" | "MENTOR";

interface ProtectedContentProps {
children: ReactNode;
/** Override the default centered-spinner placeholder. */
fallback?: ReactNode;
/** When set, signed-in users without this role see a "not authorized" message. */
requiredRole?: UserRole;
}

/**
* Wraps a protected page body. While the session resolves and during a
* redirect to /login, renders `fallback` (default: a centered spinner) — never
* the page shell. That prevents the flash-of-unauthenticated-content pattern
* where a page renders fully and then bounces from a `useEffect`.
*
* Prefer `requireAuthSSR` in `getServerSideProps` when possible — server-side
* redirects don't render at all. Use this component only when the page is
* client-rendered (no `getServerSideProps`) or when an inner section needs
* gating below an unauthenticated layout.
*/
export default function ProtectedContent({
children,
fallback,
requiredRole,
}: ProtectedContentProps) {
const { data: session, status } = useSession();
const router = useRouter();

useEffect(() => {
if (status === "unauthenticated") {
const callbackUrl = encodeURIComponent(router.asPath);
router.replace(`/login?callbackUrl=${callbackUrl}`);
}
}, [status, router]);

if (status === "loading" || status === "unauthenticated") {
return <>{fallback ?? <DefaultLoadingFallback />}</>;
}

if (requiredRole && session?.user?.role !== requiredRole) {
return <DefaultUnauthorizedFallback />;
}

return <>{children}</>;
}

function DefaultLoadingFallback() {
return (
<div className="tw-flex tw-min-h-[60vh] tw-items-center tw-justify-center">
<Spinner />
</div>
);
}

function DefaultUnauthorizedFallback() {
return (
<div className="tw-container tw-py-24 tw-text-center">
<h1 className="tw-mb-3 tw-text-3xl tw-font-bold tw-text-navy">Not authorized</h1>
<p className="tw-text-navy/70">You don't have access to this section.</p>
</div>
);
}
57 changes: 13 additions & 44 deletions src/pages/assessment.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import ProtectedContent from "@components/auth/protected-content";
import Breadcrumb from "@components/breadcrumb";
import CodeEditor from "@components/code-editor";
import SEO from "@components/seo/page-seo";
import { useMount } from "@hooks";
import Layout01 from "@layout/layout-01";
import Spinner from "@ui/spinner";
import type { GetStaticProps, NextPage } from "next";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
import React, { useEffect, useState } from "react";
import {
type AssessmentQuestion,
Expand All @@ -27,10 +24,6 @@ type PageWithLayout = NextPage<PageProps> & {
};

const Assessment: PageWithLayout = () => {
const mounted = useMount();
const { data: session, status } = useSession();
const router = useRouter();

const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [userCode, setUserCode] = useState("");
const [answers, setAnswers] = useState<Record<number, string>>({});
Expand All @@ -47,12 +40,6 @@ const Assessment: PageWithLayout = () => {
message: string;
} | null>(null);

useEffect(() => {
if (status === "unauthenticated") {
router.replace("/login");
}
}, [status, router]);

const currentQuestion: AssessmentQuestion = assessmentQuestions[currentQuestionIndex];

// Initialize code with starter code when question changes
Expand All @@ -64,22 +51,6 @@ const Assessment: PageWithLayout = () => {
}
}, [currentQuestionIndex, currentQuestion, answers]);

if (!mounted || status === "loading") {
return (
<div className="tw-fixed tw-top-0 tw-z-50 tw-flex tw-h-screen tw-w-screen tw-items-center tw-justify-center tw-bg-white">
<Spinner />
</div>
);
}

if (!session) {
return (
<div className="tw-fixed tw-top-0 tw-z-50 tw-flex tw-h-screen tw-w-screen tw-items-center tw-justify-center tw-bg-white">
<Spinner />
</div>
);
}

const handleCodeChange = (newCode: string) => {
setUserCode(newCode);
};
Expand Down Expand Up @@ -213,7 +184,7 @@ const Assessment: PageWithLayout = () => {

if (isComplete) {
return (
<>
<ProtectedContent>
<SEO title="Assessment Complete" />
<Breadcrumb
pages={[
Expand Down Expand Up @@ -276,31 +247,29 @@ const Assessment: PageWithLayout = () => {
</div>

<div className="tw-flex tw-flex-col tw-gap-3 sm:tw-flex-row sm:tw-justify-center">
<button
type="button"
onClick={() => router.push("/profile")}
className="tw-rounded-lg tw-bg-primary tw-px-6 tw-py-3 tw-font-semibold tw-text-white tw-transition-colors hover:tw-bg-primary/90"
<a
href="/profile"
className="tw-rounded-lg tw-bg-primary tw-px-6 tw-py-3 tw-font-semibold tw-text-white tw-transition-colors hover:tw-bg-primary/90 tw-inline-block"
>
<i className="fas fa-user tw-mr-2" />
Back to Profile
</button>
<button
type="button"
onClick={() => router.push("/programs/core-curriculum")}
className="tw-rounded-lg tw-border tw-border-gray-300 tw-bg-white tw-px-6 tw-py-3 tw-font-semibold tw-text-gray-200 tw-transition-colors hover:tw-bg-gray-50"
</a>
<a
href="/programs/core-curriculum"
className="tw-rounded-lg tw-border tw-border-gray-300 tw-bg-white tw-px-6 tw-py-3 tw-font-semibold tw-text-gray-200 tw-transition-colors hover:tw-bg-gray-50 tw-inline-block"
>
<i className="fas fa-book tw-mr-2" />
View Curriculum
</button>
</a>
</div>
</div>
</div>
</>
</ProtectedContent>
);
}

return (
<>
<ProtectedContent>
<SEO title="Coding Assessment" />
<Breadcrumb
pages={[
Expand Down Expand Up @@ -510,7 +479,7 @@ const Assessment: PageWithLayout = () => {
</div>
</div>
</div>
</>
</ProtectedContent>
);
};

Expand Down
24 changes: 1 addition & 23 deletions src/pages/assignments/submit/[assignmentId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@ import SEO from "@components/seo/page-seo";
import Layout01 from "@layout/layout-01";
import type { GetServerSideProps, NextPage } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import { getServerSession } from "next-auth/next";
import { useSession } from "next-auth/react";
import React, { useEffect, useState } from "react";
import React, { useState } from "react";
import prisma from "@/lib/prisma";
import { options } from "@/pages/api/auth/options";

Expand Down Expand Up @@ -40,8 +38,6 @@ type PageWithLayout = NextPage<PageProps> & {
};

const AssignmentSubmissionPage: PageWithLayout = ({ assignment }) => {
const { data: session, status } = useSession();
const router = useRouter();
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -97,24 +93,6 @@ const AssignmentSubmissionPage: PageWithLayout = ({ assignment }) => {
setFiles(e.target.files);
};

useEffect(() => {
if (status !== "loading" && !session) {
const callbackUrl = encodeURIComponent(router.asPath);
router.replace(`/login?callbackUrl=${callbackUrl}`);
}
}, [session, status, router]);

if (status === "loading" || !session) {
return (
<div className="tw-container tw-py-16">
<div className="tw-text-center">
<div className="tw-mx-auto tw-h-32 tw-w-32 tw-animate-spin tw-rounded-full tw-border-b-2 tw-border-primary" />
<p className="tw-mt-4 tw-text-gray-300">Loading assignment...</p>
</div>
</div>
);
}

if (submitted) {
return (
<>
Expand Down
Loading
Loading