Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/vs2961/react contexts #80

Merged
merged 4 commits into from
Apr 8, 2024
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
4 changes: 3 additions & 1 deletion backend/src/controllers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ export const loginUser = async (
if (!user) {
throw ValidationError.USER_NOT_FOUND;
}
res.status(200).json({ uid: user._id, approvalStatus: user.approvalStatus });
res
.status(200)
.json({ uid: user._id, role: user.accountType, approvalStatus: user.approvalStatus });
return;
} catch (e) {
nxt();
Expand Down
1 change: 1 addition & 0 deletions frontend/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
// "@typescript-eslint/ban-ts-comment": "off",
// "@typescript-eslint/no-explicit-any": "off",
// "@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-empty-function": "off",

"@typescript-eslint/no-unsafe-assignment": "warn",

Expand Down
21 changes: 21 additions & 0 deletions frontend/src/api/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { APIResult, GET, handleAPIError } from "@/api/requests";

export type User = {
_id: string;
uid: string;
role: string;
};

export const createAuthHeader = (firebaseToken: string) => ({
Authorization: `Bearer ${firebaseToken}`,
});

export const verifyUser = async (firebaseToken: string): Promise<APIResult<User>> => {
try {
const response = await GET("/user", createAuthHeader(firebaseToken));
const json = (await response.json()) as User;
return { success: true, data: json };
} catch (error) {
return handleAPIError(error);
}
};
31 changes: 31 additions & 0 deletions frontend/src/constants/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,35 @@ export const navigation: NavigationEntry[] = [
</svg>
),
},
{
title: "Log Out",
href: "/logout",
icon: (
<svg
fill="inherit"
height="100%"
width="100%"
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 320.002 320.002"
data-darkreader-inline-fill=""
>
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g id="SVGRepo_tracerCarrier" strokeLinecap="round" strokeLinejoin="round"></g>
<g id="SVGRepo_iconCarrier">
<g id="XMLID_6_">
<path
id="XMLID_7_"
d="M51.213,175.001h173.785c8.284,0,15-6.716,15-15c0-8.284-6.716-15-15-15H51.213l19.394-19.394 c5.858-5.858,5.858-15.355,0-21.213c-5.857-5.858-15.355-5.858-21.213,0L4.396,149.393c-0.351,0.351-0.683,0.719-0.997,1.103 c-0.137,0.167-0.256,0.344-0.385,0.515c-0.165,0.22-0.335,0.435-0.488,0.664c-0.14,0.209-0.261,0.426-0.389,0.64 c-0.123,0.206-0.252,0.407-0.365,0.619c-0.118,0.22-0.217,0.446-0.323,0.67c-0.104,0.219-0.213,0.435-0.306,0.659 c-0.09,0.219-0.164,0.442-0.243,0.664c-0.087,0.24-0.179,0.477-0.253,0.722c-0.067,0.222-0.116,0.447-0.172,0.672 c-0.063,0.249-0.133,0.497-0.183,0.751c-0.051,0.259-0.082,0.521-0.119,0.782c-0.032,0.223-0.075,0.443-0.097,0.669 c-0.048,0.484-0.073,0.971-0.074,1.457c0,0.007-0.001,0.015-0.001,0.022c0,0.007,0.001,0.015,0.001,0.022 c0.001,0.487,0.026,0.973,0.074,1.458c0.022,0.223,0.064,0.44,0.095,0.661c0.038,0.264,0.069,0.528,0.121,0.79 c0.05,0.252,0.119,0.496,0.182,0.743c0.057,0.227,0.107,0.456,0.175,0.681c0.073,0.241,0.164,0.474,0.248,0.71 c0.081,0.226,0.155,0.453,0.247,0.675c0.091,0.22,0.198,0.431,0.3,0.646c0.108,0.229,0.21,0.46,0.33,0.685 c0.11,0.205,0.235,0.4,0.354,0.599c0.131,0.221,0.256,0.444,0.4,0.659c0.146,0.219,0.309,0.424,0.466,0.635 c0.136,0.181,0.262,0.368,0.407,0.544c0.299,0.364,0.616,0.713,0.947,1.048c0.016,0.016,0.029,0.034,0.045,0.05l45,45.001 c2.93,2.929,6.768,4.394,10.607,4.394c3.838-0.001,7.678-1.465,10.606-4.393c5.858-5.858,5.858-15.355,0.001-21.213L51.213,175.001 z"
></path>
<path
id="XMLID_8_"
d="M305.002,25h-190c-8.284,0-15,6.716-15,15v60c0,8.284,6.716,15,15,15s15-6.716,15-15V55h160v210.001h-160 v-45.001c0-8.284-6.716-15-15-15s-15,6.716-15,15v60.001c0,8.284,6.716,15,15,15h190c8.284,0,15-6.716,15-15V40 C320.002,31.716,313.286,25,305.002,25z"
></path>
</g>
</g>
</svg>
),
},
];
81 changes: 81 additions & 0 deletions frontend/src/contexts/user.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"use client";

import { User as FirebaseUser, onAuthStateChanged } from "firebase/auth";
import { ReactNode, createContext, useEffect, useState } from "react";

import { User, verifyUser } from "@/api/user";
import { initFirebase } from "@/firebase/firebase";

type IUserContext = {
firebaseUser: FirebaseUser | null;
piaUser: User | null;
loadingUser: boolean;
reloadUser: () => unknown;
};

/**
* A context that provides the current Firebase and PAP (MongoDB) user data,
* automatically fetching them when the page loads.
*/
export const UserContext = createContext<IUserContext>({
firebaseUser: null,
piaUser: null,
loadingUser: true,
reloadUser: () => {},
});

/**
* A provider component that handles the logic for supplying the context
* with its current user & loading state variables.
*/
export const UserContextProvider = ({ children }: { children: ReactNode }) => {
const [firebaseUser, setFirebaseUser] = useState<FirebaseUser | null>(null);
const [initialLoading, setInitialLoading] = useState(true);
const [piaUser, setpiaUser] = useState<User | null>(null);
const [loadingUser, setLoadingUser] = useState(true);

const { auth } = initFirebase();

/**
* Callback triggered by Firebase when the user logs in/out, or on page load
*/
onAuthStateChanged(auth, (user) => {
setFirebaseUser(user);
setInitialLoading(false);
});

const reloadUser = () => {
if (initialLoading) {
return;
}
setLoadingUser(true);
setpiaUser(null);
if (firebaseUser === null) {
setLoadingUser(false);
} else {
firebaseUser
.getIdToken()
.then((token) =>
verifyUser(token).then((res) => {
if (res.success) {
setpiaUser(res.data);
} else {
setpiaUser(null);
}
setLoadingUser(false);
}),
)
.catch((error) => {
console.error(error);
});
}
};

useEffect(reloadUser, [initialLoading, firebaseUser]);

return (
<UserContext.Provider value={{ firebaseUser, piaUser, loadingUser, reloadUser }}>
{children}
</UserContext.Provider>
);
};
81 changes: 81 additions & 0 deletions frontend/src/hooks/redirect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { User as FirebaseUser } from "firebase/auth";
import { useRouter } from "next/navigation";
import { useContext, useEffect } from "react";

import { User } from "@/api/user";
import { UserContext } from "@/contexts/user";

export const LOGIN_URL = "/login";
export const HOME_URL = "/home";
export const NOT_FOUND_URL = "/404-not-found";

/**
* An interface for the user's current authentication credentials
*/
export type AuthCredentials = {
firebaseUser: FirebaseUser | null;
piaUser: User | null;
};

/**
* A type for a function that determines whether the user should be redirected
* based on their current credentials
*/
export type CheckShouldRedirect = (authCredentials: AuthCredentials) => boolean;

export type UseRedirectionProps = {
checkShouldRedirect: CheckShouldRedirect;
redirectURL: string;
};

/**
* A base hook that redirects the user to redirectURL if checkShouldRedirect returns true
*/
export const useRedirection = ({ checkShouldRedirect, redirectURL }: UseRedirectionProps) => {
const { firebaseUser, piaUser, loadingUser } = useContext(UserContext);
const router = useRouter();

useEffect(() => {
// Don't redirect if we are still loading the current user
if (loadingUser) {
return;
}

if (checkShouldRedirect({ firebaseUser, piaUser })) {
router.push(redirectURL);
}
}, [firebaseUser, piaUser, loadingUser]);
};

/**
* A hook that redirects the user to the staff/admin home page if they are already signed in
*/
export const useRedirectToHomeIfSignedIn = () => {
useRedirection({
checkShouldRedirect: ({ firebaseUser, piaUser }) => firebaseUser !== null && piaUser !== null,
redirectURL: HOME_URL,
});
};

/**
* A hook that redirects the user to the login page if they are not signed in
*/
export const useRedirectToLoginIfNotSignedIn = () => {
useRedirection({
checkShouldRedirect: ({ firebaseUser, piaUser }) => {
console.log(firebaseUser);
return firebaseUser === null || piaUser === null;
},
redirectURL: LOGIN_URL,
});
};

/**
* A hook that redirects the user to the 404 page if they are not an admin
*/
export const useRedirectTo404IfNotAdmin = () => {
useRedirection({
checkShouldRedirect: ({ firebaseUser, piaUser }) => firebaseUser === null || piaUser === null,
redirectURL: NOT_FOUND_URL,
});
};
9 changes: 9 additions & 0 deletions frontend/src/pages/404.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ReactElement } from "react";

export default function NotFound() {
return <h1>404 - Page Not Found</h1>;
}

NotFound.getLayout = function getLayout(page: ReactElement) {
return page;
};
3 changes: 2 additions & 1 deletion frontend/src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Navigation from "@/components/Navigation";

// eslint-disable-next-line import/order
import { NextPage } from "next";
import { UserContextProvider } from "@/contexts/user";

// import Navigation from "../components/Navigation";
export type NextPageWithLayout<P = unknown, IP = P> = NextPage<P, IP> & {
Expand All @@ -20,7 +21,7 @@ type AppPropsWithLayout = AppProps & {
//Unless specified, the default layout will have the Navigation bar
function App({ Component, pageProps }: AppPropsWithLayout) {
const getLayout = Component.getLayout ?? ((page) => <Navigation>{page}</Navigation>);
return getLayout(<Component {...pageProps} />);
return <UserContextProvider>{getLayout(<Component {...pageProps} />)}</UserContextProvider>;
}

export default App;
3 changes: 3 additions & 0 deletions frontend/src/pages/create_user.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ import { FieldValues, SubmitHandler, useForm } from "react-hook-form";

import Landing from "@/components/Landing";
import { Textfield } from "@/components/Textfield";
import { useRedirectToHomeIfSignedIn } from "@/hooks/redirect";
import { useWindowSize } from "@/hooks/useWindowSize";
import { cn } from "@/lib/utils";

export default function CreateUser() {
useRedirectToHomeIfSignedIn();

const { register, setValue, handleSubmit } = useForm();
const _setValue = setValue;

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/create_user_2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { FieldValues, SubmitHandler } from "react-hook-form";

import { Button } from "@/components/Button";
import Landing from "@/components/Landing";
import { useRedirectToHomeIfSignedIn } from "@/hooks/redirect";
import { useWindowSize } from "@/hooks/useWindowSize";
import { cn } from "@/lib/utils";

export default function CreateUser() {
useRedirectToHomeIfSignedIn();
const [isAdmin, setIsAdmin] = useState(true);

const router = useRouter();
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/create_user_3.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { ReactElement } from "react";
import { FieldValues, SubmitHandler } from "react-hook-form";

import Landing from "@/components/Landing";
import { useRedirectToHomeIfSignedIn } from "@/hooks/redirect";
import { useWindowSize } from "@/hooks/useWindowSize";
import { cn } from "@/lib/utils";

export default function CreateUser() {
useRedirectToHomeIfSignedIn();
const router = useRouter();

const onBack: SubmitHandler<FieldValues> = (data) => {
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/pages/home.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import StudentsTable from "../components/StudentsTable/StudentsTable";

import { useRedirectToLoginIfNotSignedIn } from "@/hooks/redirect";

export default function Home() {
useRedirectToLoginIfNotSignedIn();
return <StudentsTable />;
}
25 changes: 5 additions & 20 deletions frontend/src/pages/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ import { useRouter } from "next/navigation";
import { ReactElement, useState } from "react";
import { FieldValues, SubmitHandler, useForm } from "react-hook-form";

import { GET } from "@/api/requests";
import { verifyUser } from "@/api/user";
import Landing from "@/components/Landing";
import { Textfield } from "@/components/Textfield";
import { Button } from "@/components/ui/button";
import { auth } from "@/firebase/firebase";
import { useRedirectToHomeIfSignedIn } from "@/hooks/redirect";
import { useWindowSize } from "@/hooks/useWindowSize";
import { cn } from "@/lib/utils";

export default function Login() {
useRedirectToHomeIfSignedIn();

const {
register,
setValue,
Expand All @@ -23,7 +26,6 @@ export default function Login() {
const _setValue = setValue;

const [firebaseError, setFirebaseError] = useState("");

const router = useRouter();

const login = async (email: string, password: string) => {
Expand All @@ -36,28 +38,11 @@ export default function Login() {
});
};

const sendTokenToBackend = async (token: string) => {
try {
const response = await GET(`/user/`, {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
});

if (response.ok) {
console.log(await response.json());
} else {
console.error("Failed to get user info from JWT Token");
}
} catch (error) {
console.error("error sending JWT token to backend", error);
}
};

const onSubmit: SubmitHandler<FieldValues> = (data) => {
console.log(data);
login(data.email as string, data.password as string)
.then((token: string) => {
void sendTokenToBackend(token);
void verifyUser(token);
router.push("/home");
})
.catch((_) => {
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/pages/logout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { signOut } from "firebase/auth";
import { useRouter } from "next/navigation";

import { initFirebase } from "@/firebase/firebase";

export default function LogOut() {
const { auth } = initFirebase();
const router = useRouter();
signOut(auth)
.then(() => {
router.push("/login");
})
.catch((error) => {
console.error(error);
});
}
Loading
Loading