Skip to content
Merged
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
10 changes: 10 additions & 0 deletions app/(dashboard)/faculty/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
import { RoleGuard } from "@/components/auth/role-guard";

type FacultyLayoutProps = {
children: ReactNode;
};

export default function FacultyLayout({ children }: FacultyLayoutProps) {
return <RoleGuard allowedRoles={["FACULTY"]}>{children}</RoleGuard>;
}
17 changes: 10 additions & 7 deletions app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ReactNode } from "react"

import { AppSidebar } from "@/components/app-sidebar"
import { AuthGuard } from "@/components/auth/auth-guard"
import { DashboardContentHeader } from "@/components/dashboard-content-header"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"

Expand All @@ -10,12 +11,14 @@ type DashboardLayoutProps = {

export default function DashboardLayout({ children }: DashboardLayoutProps) {
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<DashboardContentHeader />
<main className="flex flex-1 flex-col p-4">{children}</main>
</SidebarInset>
</SidebarProvider>
<AuthGuard>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<DashboardContentHeader />
<main className="flex flex-1 flex-col p-4">{children}</main>
</SidebarInset>
</SidebarProvider>
</AuthGuard>
)
}
10 changes: 10 additions & 0 deletions app/(dashboard)/student/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
import { RoleGuard } from "@/components/auth/role-guard";

type StudentLayoutProps = {
children: ReactNode;
};

export default function StudentLayout({ children }: StudentLayoutProps) {
return <RoleGuard allowedRoles={["STUDENT"]}>{children}</RoleGuard>;
}
57 changes: 56 additions & 1 deletion app/auth/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
"use client";

import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useLogin } from "@/hooks/api/use-login"
import { LoginRequest, loginRequestSchema } from "@/types/kubb/gen";
import { Button } from "@/components/ui/button";
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { BackgroundGradientAnimation } from "@/components/ui/background-gradient-animation";
import { fetchMe } from "@/lib/auth-api";
import { clearSession, hasStoredSession, setSession } from "@/lib/auth-storage";
import { resolveHomePathFromRoles } from "@/lib/auth-roles";

export default function AuthPage() {
const router = useRouter();
const { mutate, isPending } = useLogin();
const [isBootstrapping, setIsBootstrapping] = useState(true);

const form = useForm<LoginRequest>({
resolver: zodResolver(loginRequestSchema),
Expand All @@ -20,12 +27,60 @@ export default function AuthPage() {
}
});

useEffect(() => {
let cancelled = false;

const bootstrapSession = async () => {
if (!hasStoredSession()) {
if (!cancelled) setIsBootstrapping(false);
return;
}

const me = await fetchMe();
if (cancelled) return;

if (me) {
router.replace(resolveHomePathFromRoles(me.roles));
return;
}

clearSession();
setIsBootstrapping(false);
};

void bootstrapSession();

return () => {
cancelled = true;
};
}, [router]);

function onSubmit(values: LoginRequest) {
mutate({
body: values,
}, {
onSuccess: async (response) => {
setSession(response);
const me = await fetchMe();

if (me) {
router.replace(resolveHomePathFromRoles(me.roles));
return;
}

clearSession();
},
});
}

if (isBootstrapping) {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Checking session...
</div>
);
}

return (
<div className="h-screen grid grid-cols-1 lg:grid-cols-10">

Expand Down Expand Up @@ -133,7 +188,7 @@ export default function AuthPage() {
<Button
type="submit"
className="w-full bg-brand-yellow hover:bg-brand-yellow/90 text-black font-semibold cursor-pointer"
disabled={isPending}
disabled={isPending || isBootstrapping}
>
{isPending ? "Logging in..." : "Login"}
</Button>
Expand Down
55 changes: 0 additions & 55 deletions app/dashboard/page.tsx

This file was deleted.

38 changes: 38 additions & 0 deletions components/auth/auth-guard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"use client";

import type { ReactNode } from "react";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useMe } from "@/hooks/api/use-me";

type AuthGuardProps = {
children: ReactNode;
};

export function AuthGuard({ children }: AuthGuardProps) {
const router = useRouter();
const { data, isPending, isError } = useMe();
const hasRoles = Boolean(data?.roles?.length);

useEffect(() => {
if (!isPending && isError) {
router.replace("/auth");
}
}, [isError, isPending, router]);

useEffect(() => {
if (!isPending && data && !hasRoles) {
router.replace("/auth");
}
}, [data, hasRoles, isPending, router]);

if (isPending) {
return <div className="p-4 text-sm text-muted-foreground">Checking session...</div>;
}

if (!data || !hasRoles) {
return null;
}

return <>{children}</>;
}
41 changes: 41 additions & 0 deletions components/auth/role-guard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"use client";

import type { ReactNode } from "react";
import { useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { useMe } from "@/hooks/api/use-me";
import { resolveHomePathFromRoles } from "@/lib/auth-roles";

type RoleGuardProps = {
allowedRoles: string[];
children: ReactNode;
};

export function RoleGuard({ allowedRoles, children }: RoleGuardProps) {
const router = useRouter();
const { data, isPending, isError } = useMe();

const roles = useMemo(() => data?.roles ?? [], [data?.roles]);
const isAllowed = roles.some((role) => allowedRoles.includes(role));

useEffect(() => {
if (!isPending && isError) {
router.replace("/auth");
return;
}

if (!isPending && data && !isAllowed) {
router.replace(resolveHomePathFromRoles(roles));
}
}, [data, isAllowed, isError, isPending, roles, router]);

if (isPending) {
return <div className="p-4 text-sm text-muted-foreground">Checking permissions...</div>;
}

if (!data || !isAllowed) {
return null;
}

return <>{children}</>;
}
5 changes: 5 additions & 0 deletions hooks/api/use-logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { $api } from "@/lib/api-client";

export function useLogout() {
return $api.useMutation("post", "/api/v1/auth/logout");
}
5 changes: 5 additions & 0 deletions hooks/api/use-me.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { $api } from "@/lib/api-client";

export function useMe() {
return $api.useQuery("get", "/api/v1/auth/me");
}
5 changes: 5 additions & 0 deletions hooks/api/use-refresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { $api } from "@/lib/api-client";

export function useRefresh() {
return $api.useMutation("post", "/api/v1/auth/refresh");
}
Loading