From 4fefb808aa9bc201c27d15017236e6572cf7cd6a Mon Sep 17 00:00:00 2001 From: Serena Li <40174697+acrantel@users.noreply.github.com> Date: Tue, 15 Aug 2023 19:06:12 -0400 Subject: [PATCH] Add state for current user (#665) --- frontend2/src/App.tsx | 25 +- .../src/components/CurrentUserProvider.tsx | 63 ++++ frontend2/src/components/PrivateRoute.tsx | 23 ++ frontend2/src/contexts/CurrentUserContext.ts | 33 ++ frontend2/src/utils/api.ts | 26 +- frontend2/src/utils/apiTypes.ts | 284 ++++++++++++++++++ frontend2/src/utils/auth.ts | 8 +- frontend2/src/utils/cookies.ts | 12 + 8 files changed, 464 insertions(+), 10 deletions(-) create mode 100644 frontend2/src/components/CurrentUserProvider.tsx create mode 100644 frontend2/src/components/PrivateRoute.tsx create mode 100644 frontend2/src/contexts/CurrentUserContext.ts create mode 100644 frontend2/src/utils/apiTypes.ts create mode 100644 frontend2/src/utils/cookies.ts diff --git a/frontend2/src/App.tsx b/frontend2/src/App.tsx index cbdbf57f5..1417ce3f4 100644 --- a/frontend2/src/App.tsx +++ b/frontend2/src/App.tsx @@ -17,13 +17,17 @@ import { import { DEFAULT_EPISODE } from "./utils/constants"; import NotFound from "./views/NotFound"; import Rankings from "./views/Rankings"; +import { CurrentUserProvider } from "./components/CurrentUserProvider"; +import PrivateRoute from "./components/PrivateRoute"; const App: React.FC = () => { const [episodeId, setEpisodeId] = useState(DEFAULT_EPISODE); return ( - - - + + + + + ); }; @@ -44,10 +48,19 @@ const router = createBrowserRouter([ { path: "/:episodeId/quickstart", element: }, { path: "/:episodeId/*", element: }, { path: "/:episodeId/rankings", element: }, - // Pages that should only be visible when logged in - // TODO: /:episodeId/team, /:episodeId/submissions, /:episodeId/scrimmaging + ], + }, + // Pages that should only be visible when logged in + { + element: , + children: [ + { + element: , + children: [ + // TODO: /:episodeId/team, /:episodeId/submissions, /:episodeId/scrimmaging + ], + }, { path: "/account", element: }, - // etc ], }, // Pages that should redirect diff --git a/frontend2/src/components/CurrentUserProvider.tsx b/frontend2/src/components/CurrentUserProvider.tsx new file mode 100644 index 000000000..59f2cc2fd --- /dev/null +++ b/frontend2/src/components/CurrentUserProvider.tsx @@ -0,0 +1,63 @@ +import React, { useState, useEffect } from "react"; +import { type UserPrivate } from "../utils/types"; +import { + AuthStateEnum, + type AuthState, + CurrentUserContext, +} from "../contexts/CurrentUserContext"; +import * as Api from "../utils/api"; +import { removeApiTokens, doApiTokensExist } from "../utils/cookies"; + +export const CurrentUserProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [userData, setUserData] = useState<{ + user?: UserPrivate; + authState: AuthState; + }>({ + authState: AuthStateEnum.LOADING, + }); + + const login = (user: UserPrivate): void => { + setUserData({ + user, + authState: AuthStateEnum.AUTHENTICATED, + }); + }; + const logout = (): void => { + setUserData({ + authState: AuthStateEnum.NOT_AUTHENTICATED, + }); + }; + + useEffect(() => { + const checkLoggedIn = async (): Promise => { + // check if cookies exist before attempting to load user + if (!doApiTokensExist()) { + logout(); + return; + } + try { + const user = await Api.getUserUserProfile(); + login(user); + } catch (error) { + logout(); + removeApiTokens(); + } + }; + void checkLoggedIn(); + }, []); + + return ( + + {children} + + ); +}; diff --git a/frontend2/src/components/PrivateRoute.tsx b/frontend2/src/components/PrivateRoute.tsx new file mode 100644 index 000000000..d8872dccb --- /dev/null +++ b/frontend2/src/components/PrivateRoute.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { AuthStateEnum, useCurrentUser } from "../contexts/CurrentUserContext"; +import { Outlet, useNavigate } from "react-router-dom"; +import Spinner from "./Spinner"; + +const PrivateRoute: React.FC = () => { + const { authState } = useCurrentUser(); + const navigate = useNavigate(); + if (authState === AuthStateEnum.AUTHENTICATED) { + return ; + } else if (authState === AuthStateEnum.NOT_AUTHENTICATED) { + navigate("/login"); + return null; + } else { + return ( +
+ +
+ ); + } +}; + +export default PrivateRoute; diff --git a/frontend2/src/contexts/CurrentUserContext.ts b/frontend2/src/contexts/CurrentUserContext.ts new file mode 100644 index 000000000..7ab9d51c8 --- /dev/null +++ b/frontend2/src/contexts/CurrentUserContext.ts @@ -0,0 +1,33 @@ +import { createContext, useContext } from "react"; +import { type UserPrivate } from "../utils/types/model/models"; + +export enum AuthStateEnum { + LOADING = "loading", + AUTHENTICATED = "authenticated", + NOT_AUTHENTICATED = "not_authenticated", +} + +export type AuthState = `${AuthStateEnum}`; + +interface CurrentUserContextType { + authState: AuthState; + user?: UserPrivate; + login: (user: UserPrivate) => void; + logout: () => void; +} + +export const CurrentUserContext = createContext( + null +); + +export const useCurrentUser = (): CurrentUserContextType => { + const currentUserContext = useContext(CurrentUserContext); + + if (currentUserContext === null) { + throw new Error( + "useCurrentUser has to be used within " + ); + } + + return currentUserContext; +}; diff --git a/frontend2/src/utils/api.ts b/frontend2/src/utils/api.ts index 8febbda1f..e932878ef 100644 --- a/frontend2/src/utils/api.ts +++ b/frontend2/src/utils/api.ts @@ -2,6 +2,7 @@ import { ApiApi } from "./types/api/ApiApi"; import Cookies from "js-cookie"; import * as $ from "jquery"; import * as models from "./types/model/models"; +import type * as customModels from "./apiTypes"; // hacky, fall back to localhost for now const baseUrl = process.env.REACT_APP_BACKEND_URL ?? "http://localhost:8000"; @@ -297,9 +298,30 @@ export const getAllUserTournamentSubmissions = async ( * @param user The user's info. */ export const createUser = async ( - user: models.UserCreate + user: customModels.CreateUserInput ): Promise => { - return (await API.apiUserUCreate(user)).body; + const defaultUser = { + id: -1, + isStaff: false, + profile: { + avatarUrl: "", + hasAvatar: false, + hasResume: false, + }, + }; + // convert our input into models.UserCreate. + return ( + await API.apiUserUCreate({ + ...defaultUser, + ...user, + profile: { + ...defaultUser.profile, + ...user.profile, + gender: user.profile.gender as unknown as models.GenderEnum, + country: user.profile.country as unknown as models.CountryEnum, + }, + }) + ).body; }; /** diff --git a/frontend2/src/utils/apiTypes.ts b/frontend2/src/utils/apiTypes.ts new file mode 100644 index 000000000..6667bd6aa --- /dev/null +++ b/frontend2/src/utils/apiTypes.ts @@ -0,0 +1,284 @@ +export enum GenderEnum { + FEMALE = "F", + MALE = "M", + NONBINARY = "N", + SELF_DESCRIBED = "*", + RATHER_NOT_SAY = "?", +} + +export type Gender = `${GenderEnum}`; + +const countries = [ + "AF", + "AX", + "AL", + "DZ", + "AS", + "AD", + "AO", + "AI", + "AQ", + "AG", + "AR", + "AM", + "AW", + "AU", + "AT", + "AZ", + "BS", + "BH", + "BD", + "BB", + "BY", + "BE", + "BZ", + "BJ", + "BM", + "BT", + "BO", + "BQ", + "BA", + "BW", + "BV", + "BR", + "IO", + "BN", + "BG", + "BF", + "BI", + "CV", + "KH", + "CM", + "CA", + "KY", + "CF", + "TD", + "CL", + "CN", + "CX", + "CC", + "CO", + "KM", + "CG", + "CD", + "CK", + "CR", + "CI", + "HR", + "CU", + "CW", + "CY", + "CZ", + "DK", + "DJ", + "DM", + "DO", + "EC", + "EG", + "SV", + "GQ", + "ER", + "EE", + "SZ", + "ET", + "FK", + "FO", + "FJ", + "FI", + "FR", + "GF", + "PF", + "TF", + "GA", + "GM", + "GE", + "DE", + "GH", + "GI", + "GR", + "GL", + "GD", + "GP", + "GU", + "GT", + "GG", + "GN", + "GW", + "GY", + "HT", + "HM", + "VA", + "HN", + "HK", + "HU", + "IS", + "IN", + "ID", + "IR", + "IQ", + "IE", + "IM", + "IL", + "IT", + "JM", + "JP", + "JE", + "JO", + "KZ", + "KE", + "KI", + "KW", + "KG", + "LA", + "LV", + "LB", + "LS", + "LR", + "LY", + "LI", + "LT", + "LU", + "MO", + "MG", + "MW", + "MY", + "MV", + "ML", + "MT", + "MH", + "MQ", + "MR", + "MU", + "YT", + "MX", + "FM", + "MD", + "MC", + "MN", + "ME", + "MS", + "MA", + "MZ", + "MM", + "NA", + "NR", + "NP", + "NL", + "NC", + "NZ", + "NI", + "NE", + "NG", + "NU", + "NF", + "KP", + "MK", + "MP", + "NO", + "OM", + "PK", + "PW", + "PS", + "PA", + "PG", + "PY", + "PE", + "PH", + "PN", + "PL", + "PT", + "PR", + "QA", + "RE", + "RO", + "RU", + "RW", + "BL", + "SH", + "KN", + "LC", + "MF", + "PM", + "VC", + "WS", + "SM", + "ST", + "SA", + "SN", + "RS", + "SC", + "SL", + "SG", + "SX", + "SK", + "SI", + "SB", + "SO", + "ZA", + "GS", + "KR", + "SS", + "ES", + "LK", + "SD", + "SR", + "SJ", + "SE", + "CH", + "SY", + "TW", + "TJ", + "TZ", + "TH", + "TL", + "TG", + "TK", + "TO", + "TT", + "TN", + "TR", + "TM", + "TC", + "TV", + "UG", + "UA", + "AE", + "GB", + "UM", + "US", + "UY", + "UZ", + "VU", + "VE", + "VN", + "VG", + "VI", + "WF", + "EH", + "YE", + "ZM", + "ZW", +]; + +export type Country = (typeof countries)[number]; +export interface CreateUserInput { + profile: { + gender: Gender; + genderDetails?: string; + school?: string; + country: Country; + }; + + /** + * Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only. + */ + username: string; + + password: string; + + email: string; + + firstName: string; + + lastName: string; +} diff --git a/frontend2/src/utils/auth.ts b/frontend2/src/utils/auth.ts index 8f1ffc077..429128a53 100644 --- a/frontend2/src/utils/auth.ts +++ b/frontend2/src/utils/auth.ts @@ -2,6 +2,7 @@ import * as Api from "./api"; import Cookies from "js-cookie"; import type * as models from "./types/model/models"; import { ApiApi } from "./types/api/ApiApi"; +import type * as customModels from "./apiTypes"; /** This file contains all frontend authentication functions. Responsible for interacting with Cookies and expiring/setting JWT tokens. */ @@ -73,9 +74,12 @@ export const loginCheck = async (): Promise => { * Register a new user. * @param user The user to register. */ -export const register = async (user: models.UserCreate): Promise => { - await Api.createUser(user); +export const register = async ( + user: customModels.CreateUserInput +): Promise => { + const returnedUser = await Api.createUser(user); await login(user.username, user.password); + return returnedUser; }; /** diff --git a/frontend2/src/utils/cookies.ts b/frontend2/src/utils/cookies.ts new file mode 100644 index 000000000..ce72dd622 --- /dev/null +++ b/frontend2/src/utils/cookies.ts @@ -0,0 +1,12 @@ +import Cookies from "js-cookie"; + +export const removeApiTokens = (): void => { + Cookies.remove("access"); + Cookies.remove("refresh"); +}; + +export const doApiTokensExist = (): boolean => { + return ( + Cookies.get("access") !== undefined && Cookies.get("refresh") !== undefined + ); +};