diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index e1682316..587a6a53 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -25,7 +25,7 @@ jobs: run: | cd frontend touch .env - echo APP_FIREBASE_CONFIG=${{ secrets.APP_FIREBASE_CONFIG }} >> .env + echo NEXT_PUBLIC_FIREBASE_CONFIG=${{ secrets.NEXT_PUBLIC_FIREBASE_CONFIG }} >> .env - name: Create Backend .env run: | cd backend diff --git a/backend/package.json b/backend/package.json index f9b91b4f..c26220e6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,6 +15,9 @@ "keywords": [], "author": "", "license": "ISC", + "engines": { + "node": "18" + }, "dependencies": { "cors": "^2.8.5", "dotenv": "^16.3.1", diff --git a/backend/src/controllers/user.ts b/backend/src/controllers/user.ts index cae44f39..4ee568a0 100644 --- a/backend/src/controllers/user.ts +++ b/backend/src/controllers/user.ts @@ -1,11 +1,10 @@ import { NextFunction, Request, Response } from "express"; import { validationResult } from "express-validator"; -import { signInWithEmailAndPassword } from "firebase/auth"; import admin from "firebase-admin"; -import { AuthError } from "../errors/auth"; +import { ValidationError } from "../errors/validation"; import UserModel from "../models/user"; -import { firebaseAdminAuth, firebaseAuth } from "../util/firebase"; +import { firebaseAdminAuth } from "../util/firebase"; import validationErrorParser from "../util/validationErrorParser"; // Define the type for req.body @@ -17,8 +16,7 @@ type CreateUserRequestBody = { }; type LoginUserRequestBody = { - email: string; - password: string; + uid: string; }; export const createUser = async ( @@ -65,31 +63,20 @@ export const loginUser = async ( nxt: NextFunction, ) => { try { - // Check for validation errors - const errors = validationResult(req); - validationErrorParser(errors); - - const { email, password } = req.body; - // Sign user into Firebase - await signInWithEmailAndPassword(firebaseAuth, email, password) - .then((userCredential) => { - const user = userCredential.user; - console.log(user); - return UserModel.findById(user.uid); - }) - .then((user) => { - if (user !== null) { - res.status(200).json({ uid: user._id, approvalStatus: user.approvalStatus }); - } - throw AuthError.LOGIN_ERROR; - }) - .catch(() => { - throw AuthError.LOGIN_ERROR; - }); - } catch (error) { - console.error(error); - nxt(error); + const uid = req.body.uid; + const user = await UserModel.findById(uid); + if (!user) { + throw ValidationError.USER_NOT_FOUND; + } + res + .status(200) + .json({ uid: user._id, role: user.accountType, approvalStatus: user.approvalStatus }); + return; + } catch (e) { + nxt(); + console.log(e); + return res.status(400).json({ + error: e, + }); } - - return; }; diff --git a/backend/src/errors/auth.ts b/backend/src/errors/auth.ts index ad2f5055..bfbdb748 100644 --- a/backend/src/errors/auth.ts +++ b/backend/src/errors/auth.ts @@ -4,8 +4,6 @@ import { CustomError } from "./errors"; -const LOGIN_ERROR = "Login Failed. Please check the username and password."; - const DECODE_ERROR = "Error decoding the auth token. Make sure the auth token is valid"; const TOKEN_NOT_IN_HEADER = "Token was not found in the header. Be sure to use Bearer syntax"; @@ -17,6 +15,4 @@ export class AuthError extends CustomError { static TOKEN_NOT_IN_HEADER = new AuthError(1, 401, TOKEN_NOT_IN_HEADER); static INVALID_AUTH_TOKEN = new AuthError(2, 401, INVALID_AUTH_TOKEN); - - static LOGIN_ERROR = new AuthError(3, 401, LOGIN_ERROR); } diff --git a/backend/src/errors/validation.ts b/backend/src/errors/validation.ts index 608fa7c5..dca8d63a 100644 --- a/backend/src/errors/validation.ts +++ b/backend/src/errors/validation.ts @@ -1,10 +1,12 @@ import { CustomError } from "./errors"; const USER_CREATION_UNSUCCESSFUL = "User not created successfully"; +const USER_NOT_FOUND = "User not found in database"; export class ValidationError extends CustomError { constructor(code: number, status: number, message: string) { super(code, status, "VALIDATION ERROR: " + message); } static USER_CREATION_UNSUCCESSFUL = new ValidationError(1, 400, USER_CREATION_UNSUCCESSFUL); + static USER_NOT_FOUND = new ValidationError(1, 400, USER_NOT_FOUND); } diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index a16bc7cc..b229ac62 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -1,13 +1,14 @@ import express from "express"; import * as UserController from "../controllers/user"; +import { verifyAuthToken } from "../validators/auth"; import * as UserValidator from "../validators/user"; const router = express.Router(); router.use(express.json()); -router.post("/login", UserValidator.loginUser, UserController.loginUser); -router.post("/", UserValidator.createUser, UserController.createUser); +router.post("/create", UserValidator.createUser, UserController.createUser); +router.get("/", [verifyAuthToken], UserController.loginUser); export default router; diff --git a/backend/src/util/firebase.ts b/backend/src/util/firebase.ts index 50740372..a4bfdff1 100644 --- a/backend/src/util/firebase.ts +++ b/backend/src/util/firebase.ts @@ -5,12 +5,10 @@ * firebase to for authentication. */ -import * as firebase from "firebase/app"; -import { getAuth } from "firebase/auth"; import * as firebaseAdmin from "firebase-admin/app"; import { getAuth as getAdminAuth } from "firebase-admin/auth"; -import { firebaseConfig, serviceAccountKey } from "../config"; +import { serviceAccountKey } from "../config"; /** * This will initialize the firebase app to store @@ -21,9 +19,6 @@ firebaseAdmin.initializeApp({ credential: firebaseAdmin.cert(JSON.parse(serviceAccountKey) as string), }); -const firebaseApp = firebase.initializeApp(JSON.parse(firebaseConfig) as object); - const firebaseAdminAuth = getAdminAuth(); -const firebaseAuth = getAuth(firebaseApp); -export { firebaseAdminAuth, firebaseAuth }; +export { firebaseAdminAuth }; diff --git a/backend/src/validators/user.ts b/backend/src/validators/user.ts index c93ebf15..100898a1 100644 --- a/backend/src/validators/user.ts +++ b/backend/src/validators/user.ts @@ -26,7 +26,7 @@ export const createUser: ValidationChain[] = [ ]; export const loginUser: ValidationChain[] = [ - body("email") + body("uid") .notEmpty() .withMessage("Email cannot be empty.") .isEmail() diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index 7156b51b..aa014a29 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -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", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 08d6d10b..0cea64c1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,9 +21,12 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "date-fns": "^3.2.0", + "dotenv": "^16.4.5", + "envalid": "^8.0.0", "firebase": "^10.7.1", "lucide-react": "^0.311.0", - "next": "^14.0.4", + "next": "14.0.4", + "next-auth": "^4.24.7", "react": "^18", "react-day-picker": "^8.10.0", "react-dom": "^18", @@ -2142,6 +2145,14 @@ "node": ">= 8" } }, + "node_modules/@panva/hkdf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.1.1.tgz", + "integrity": "sha512-dhPeilub1NuIG0X5Kvhh9lH4iW3ZsHlnzwgwbOlgwQ2wG1IqFzsgHqmKPk3WzsdWAeaxKJxgM0+W433RmN45GA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "license": "MIT", @@ -4110,6 +4121,14 @@ "license": "MIT", "peer": true }, + "node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/create-jest": { "version": "29.7.0", "devOptional": true, @@ -4339,6 +4358,17 @@ "version": "0.5.16", "license": "MIT" }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "license": "MIT" @@ -4376,6 +4406,17 @@ "node": ">=10.13.0" } }, + "node_modules/envalid": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/envalid/-/envalid-8.0.0.tgz", + "integrity": "sha512-PGeYJnJB5naN0ME6SH8nFcDj9HVbLpYIfg1p5lAyM9T4cH2lwtu2fLbozC/bq+HUUOIFxhX/LP0/GmlqPHT4tQ==", + "dependencies": { + "tslib": "2.6.2" + }, + "engines": { + "node": ">=8.12" + } + }, "node_modules/error-ex": { "version": "1.3.2", "devOptional": true, @@ -6602,6 +6643,14 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "4.15.5", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", + "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "license": "MIT" @@ -6788,7 +6837,6 @@ }, "node_modules/lru-cache": { "version": "6.0.0", - "devOptional": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -6988,6 +7036,33 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.7", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.7.tgz", + "integrity": "sha512-iChjE8ov/1K/z98gdKbn2Jw+2vLgJtVV39X+rCP5SGnVQuco7QOr19FRNGMIrD8d3LYhHWV9j9sKLzq1aDWWQQ==", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.5.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "next": "^12.2.5 || ^13 || ^14", + "nodemailer": "^6.6.5", + "react": "^17.0.2 || ^18", + "react-dom": "^17.0.2 || ^18" + }, + "peerDependenciesMeta": { + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "funding": [ @@ -7052,6 +7127,11 @@ "node": ">=8" } }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" + }, "node_modules/object-assign": { "version": "4.1.1", "license": "MIT", @@ -7178,6 +7258,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/once": { "version": "1.4.0", "devOptional": true, @@ -7201,6 +7289,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openid-client": { + "version": "5.6.5", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.5.tgz", + "integrity": "sha512-5P4qO9nGJzB5PI0LFlhj4Dzg3m4odt0qsJTfyEtZyOlkgpILwEioOhVVJOrS1iVH494S4Ee5OCjjg6Bf5WOj3w==", + "dependencies": { + "jose": "^4.15.5", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/optionator": { "version": "0.9.3", "dev": true, @@ -7561,6 +7671,31 @@ "version": "4.2.0", "license": "MIT" }, + "node_modules/preact": { + "version": "10.20.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.20.1.tgz", + "integrity": "sha512-JIFjgFg9B2qnOoGiYMVBtrcFxHqn+dNXbq76bVmcaHYJFYR4lW67AOcXgAYQQTDYXDOg/kTZrKPNCdRgJ2UJmw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/preact-render-to-string/node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==" + }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -8968,6 +9103,14 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.2.0", "devOptional": true, @@ -9161,7 +9304,6 @@ }, "node_modules/yallist": { "version": "4.0.0", - "devOptional": true, "license": "ISC" }, "node_modules/yaml": { diff --git a/frontend/package.json b/frontend/package.json index 3c4f11ad..2546c583 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,8 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "date-fns": "^3.2.0", + "dotenv": "^16.4.5", + "envalid": "^8.0.0", "firebase": "^10.7.1", "lucide-react": "^0.311.0", "next": "^14.0.4", diff --git a/frontend/src/api/.keep b/frontend/src/api/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts new file mode 100644 index 00000000..78592719 --- /dev/null +++ b/frontend/src/api/user.ts @@ -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> => { + 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); + } +}; diff --git a/frontend/src/components/StudentsTable/StudentsTable.tsx b/frontend/src/components/StudentsTable/StudentsTable.tsx index 83a6ce68..400d7d68 100644 --- a/frontend/src/components/StudentsTable/StudentsTable.tsx +++ b/frontend/src/components/StudentsTable/StudentsTable.tsx @@ -32,6 +32,7 @@ export default function StudentsTable() { useEffect(() => { getAllStudents().then( (result) => { + console.log(result); if (result.success) { // Convert student array to object with keys as ids and values as corresponding student const studentsObject = result.data.reduce((obj, student) => { diff --git a/frontend/src/components/Textfield.tsx b/frontend/src/components/Textfield.tsx index 3f9e48e4..5da24eb8 100644 --- a/frontend/src/components/Textfield.tsx +++ b/frontend/src/components/Textfield.tsx @@ -1,5 +1,12 @@ import { useEffect, useState } from "react"; -import { FieldValues, Path, PathValue, UseFormRegister, UseFormSetValue } from "react-hook-form"; +import { + FieldValues, + Path, + PathValue, + RegisterOptions, + UseFormRegister, + UseFormSetValue, +} from "react-hook-form"; import { Calendar } from "../components/ui/calendar"; import { Popover, PopoverContent, PopoverTrigger } from "../components/ui/popover"; @@ -15,6 +22,7 @@ type BaseProps = { defaultValue?: string; className?: string; mode?: "filled" | "outlined"; + registerOptions?: RegisterOptions; }; type WithCalendarProps = BaseProps & { @@ -36,13 +44,12 @@ export function Textfield({ name, //Must be a key in form data type specified in useForm hook placeholder, calendar = false, + handleInputChange = () => {}, className, - handleInputChange = () => { - /* do nothing */ - }, type = "text", defaultValue = "", mode = "outlined", + registerOptions = {}, }: TextFieldProps) { const [date, setDate] = useState(); @@ -66,11 +73,10 @@ export function Textfield({ )} > )} + {...register(name as Path, registerOptions)} className="focus-visible:out w-full appearance-none bg-inherit px-2 placeholder-pia_accent outline-none" id={label + placeholder} type={type} - onChange={handleInputChange} placeholder={placeholder} defaultValue={defaultValue} /> diff --git a/frontend/src/constants/navigation.tsx b/frontend/src/constants/navigation.tsx index a956d5cb..fc230b11 100644 --- a/frontend/src/constants/navigation.tsx +++ b/frontend/src/constants/navigation.tsx @@ -105,4 +105,35 @@ export const navigation: NavigationEntry[] = [ ), }, + { + title: "Log Out", + href: "/logout", + icon: ( + + + + + + + + + + + ), + }, ]; diff --git a/frontend/src/contexts/user.tsx b/frontend/src/contexts/user.tsx new file mode 100644 index 00000000..05a4466b --- /dev/null +++ b/frontend/src/contexts/user.tsx @@ -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({ + 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(null); + const [initialLoading, setInitialLoading] = useState(true); + const [piaUser, setpiaUser] = useState(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 ( + + {children} + + ); +}; diff --git a/frontend/src/firebase/firebase.ts b/frontend/src/firebase/firebase.ts new file mode 100644 index 00000000..7ecd2249 --- /dev/null +++ b/frontend/src/firebase/firebase.ts @@ -0,0 +1,22 @@ +import dotenv from "dotenv"; +import { FirebaseOptions, initializeApp } from "firebase/app"; +import { getAuth } from "firebase/auth"; + +dotenv.config(); + +export const initFirebase = () => { + if (!process.env.NEXT_PUBLIC_FIREBASE_CONFIG) { + throw new Error("Firebase configuration not found."); + } + + const firebaseConfig = JSON.parse(process.env.NEXT_PUBLIC_FIREBASE_CONFIG) as FirebaseOptions; + + const app = initializeApp(firebaseConfig); + const auth = getAuth(app); + + return { app, auth }; +}; + +const { app, auth } = initFirebase(); + +export { app, auth }; diff --git a/frontend/src/hooks/redirect.tsx b/frontend/src/hooks/redirect.tsx new file mode 100644 index 00000000..f7f7f0f2 --- /dev/null +++ b/frontend/src/hooks/redirect.tsx @@ -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, + }); +}; diff --git a/frontend/src/pages/404.tsx b/frontend/src/pages/404.tsx new file mode 100644 index 00000000..9e6829a7 --- /dev/null +++ b/frontend/src/pages/404.tsx @@ -0,0 +1,9 @@ +import { ReactElement } from "react"; + +export default function NotFound() { + return

404 - Page Not Found

; +} + +NotFound.getLayout = function getLayout(page: ReactElement) { + return page; +}; diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 64659a9d..f55fba6a 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -10,6 +10,7 @@ import Navigation from "../components/Navigation"; // eslint-disable-next-line import/order import { NextPage } from "next"; +import { UserContextProvider } from "@/contexts/user"; export type NextPageWithLayout

= NextPage & { getLayout?: (page: ReactElement) => ReactNode; @@ -22,7 +23,7 @@ type AppPropsWithLayout = AppProps & { //Unless specified, the default layout will have the Navigation bar function App({ Component, pageProps }: AppPropsWithLayout) { const getLayout = Component.getLayout ?? ((page) => {page}); - return getLayout(); + return {getLayout()}; } export default App; diff --git a/frontend/src/pages/create_user.tsx b/frontend/src/pages/create_user.tsx index 03f4d325..6414c915 100644 --- a/frontend/src/pages/create_user.tsx +++ b/frontend/src/pages/create_user.tsx @@ -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; diff --git a/frontend/src/pages/create_user_2.tsx b/frontend/src/pages/create_user_2.tsx index 70184f04..ecdf7f67 100644 --- a/frontend/src/pages/create_user_2.tsx +++ b/frontend/src/pages/create_user_2.tsx @@ -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(); diff --git a/frontend/src/pages/create_user_3.tsx b/frontend/src/pages/create_user_3.tsx index 3cc5a8a2..99550c95 100644 --- a/frontend/src/pages/create_user_3.tsx +++ b/frontend/src/pages/create_user_3.tsx @@ -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 = (data) => { diff --git a/frontend/src/pages/home.tsx b/frontend/src/pages/home.tsx index 64bac628..5cf731be 100644 --- a/frontend/src/pages/home.tsx +++ b/frontend/src/pages/home.tsx @@ -1,5 +1,8 @@ import StudentsTable from "../components/StudentsTable/StudentsTable"; +import { useRedirectToLoginIfNotSignedIn } from "@/hooks/redirect"; + export default function Home() { + useRedirectToLoginIfNotSignedIn(); return ; } diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index 2f8e2ce4..af604239 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -1,19 +1,53 @@ +import { signInWithEmailAndPassword } from "firebase/auth"; +import { AlertCircle } from "lucide-react"; import Image from "next/image"; -import { ReactElement } from "react"; +import { useRouter } from "next/navigation"; +import { ReactElement, useState } from "react"; import { FieldValues, SubmitHandler, useForm } from "react-hook-form"; +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() { - const { register, setValue, handleSubmit } = useForm(); + useRedirectToHomeIfSignedIn(); + + const { + register, + setValue, + handleSubmit, + formState: { errors }, + } = useForm(); const _setValue = setValue; + const [firebaseError, setFirebaseError] = useState(""); + const router = useRouter(); + + const login = async (email: string, password: string) => { + return await signInWithEmailAndPassword(auth, email, password) + .then((userCredential) => { + return userCredential.user.getIdToken(); + }) + .then((token) => { + return token; + }); + }; + const onSubmit: SubmitHandler = (data) => { console.log(data); + login(data.email as string, data.password as string) + .then((token: string) => { + void verifyUser(token); + router.push("/home"); + }) + .catch((_) => { + setFirebaseError("Invalid login. Please check your username and password."); + }); }; const { isMobile, isTablet } = useWindowSize(); @@ -76,16 +110,37 @@ export default function Login() { label={""} type="email" placeholder="name@email.com" + registerOptions={{ required: "Email cannot be empty" }} /> + {errors.email && ( +

+ {" "} + {typeof errors.email.message === "string" ? errors.email.message : null} +

+ )}

Password

+ {errors.password && ( +

+ {" "} + {typeof errors.password.message === "string" ? errors.password.message : null} +

+ )}

Sign In + {firebaseError && ( +

+ {firebaseError} +

+ )} {isMobile && (

diff --git a/frontend/src/pages/logout.tsx b/frontend/src/pages/logout.tsx new file mode 100644 index 00000000..d7cf103e --- /dev/null +++ b/frontend/src/pages/logout.tsx @@ -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); + }); +} diff --git a/frontend/src/pages/notifications.tsx b/frontend/src/pages/notifications.tsx index af2a0ada..5fb7a819 100644 --- a/frontend/src/pages/notifications.tsx +++ b/frontend/src/pages/notifications.tsx @@ -1,4 +1,8 @@ +import { useRedirectTo404IfNotAdmin, useRedirectToLoginIfNotSignedIn } from "@/hooks/redirect"; + export default function Notifications() { + useRedirectToLoginIfNotSignedIn(); + useRedirectTo404IfNotAdmin(); return (

PIA Notifications Page!

diff --git a/frontend/src/pages/profile.tsx b/frontend/src/pages/profile.tsx index f38a5d45..543b4bc7 100644 --- a/frontend/src/pages/profile.tsx +++ b/frontend/src/pages/profile.tsx @@ -4,7 +4,10 @@ import { Checkbox } from "../components/Checkbox"; import Radio from "../components/Radio"; import { Textfield } from "../components/Textfield"; +import { useRedirectToLoginIfNotSignedIn } from "@/hooks/redirect"; + export default function Profile() { + useRedirectToLoginIfNotSignedIn(); const dietaryList = ["Nuts", "Eggs", "Seafood", "Pollen", "Dairy", "Other"]; const { register, setValue, handleSubmit } = useForm(); diff --git a/frontend/src/pages/programs.tsx b/frontend/src/pages/programs.tsx index 107389df..8c5f998a 100644 --- a/frontend/src/pages/programs.tsx +++ b/frontend/src/pages/programs.tsx @@ -2,8 +2,11 @@ import { ProgramData } from "../components/ProgramForm/types"; import ProgramFormButton from "../components/ProgramFormButton"; import sampleProgramData from "../sampleProgramData.json"; +import { useRedirectTo404IfNotAdmin, useRedirectToLoginIfNotSignedIn } from "@/hooks/redirect"; + export default function Programs() { - console.log(sampleProgramData); + //useRedirectToLoginIfNotSignedIn(); + //useRedirectTo404IfNotAdmin(); return (