From 650e3970c66f6b42b69c4d552c2e3e17280ff73f Mon Sep 17 00:00:00 2001 From: Lowell Torola <44183219+lowtorola@users.noreply.github.com> Date: Fri, 4 Oct 2024 15:52:50 -0400 Subject: [PATCH] Password Reset (#805) Co-authored-by: Serena Li :shipit: --- frontend2/.eslintrc.js | 1 + frontend2/package.json | 4 +- frontend2/src/App.tsx | 2 + frontend2/src/api/auth/authApi.ts | 4 +- .../src/api/loaders/passwordForgotLoader.ts | 15 ++ frontend2/src/api/user/useUser.ts | 51 ++++++- frontend2/src/api/user/userApi.ts | 11 ++ frontend2/src/api/user/userFactories.ts | 34 ++++- frontend2/src/api/user/userKeys.ts | 20 ++- frontend2/src/views/PasswordChange.tsx | 140 +++++++++++++++++- frontend2/src/views/PasswordForgot.tsx | 77 +++++++++- 11 files changed, 340 insertions(+), 19 deletions(-) create mode 100644 frontend2/src/api/loaders/passwordForgotLoader.ts diff --git a/frontend2/.eslintrc.js b/frontend2/.eslintrc.js index 3c7ee67f6..b70971692 100644 --- a/frontend2/.eslintrc.js +++ b/frontend2/.eslintrc.js @@ -29,6 +29,7 @@ module.exports = { plugins: ["react"], rules: { semi: ["error", "always"], // require semicolons ending statements + "@typescript-eslint/no-unused-vars": ["warn", { varsIgnorePattern: "^_" }], }, settings: { react: { diff --git a/frontend2/package.json b/frontend2/package.json index a057fab7a..4aa5e072f 100644 --- a/frontend2/package.json +++ b/frontend2/package.json @@ -31,8 +31,8 @@ "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", - "lint": "npm run _eslint && npm run _prettier -- --check", - "format": "npm run _eslint -- --fix && npm run _prettier -- --write", + "lint": "npm run _eslint -- --max-warnings=0 && npm run _prettier -- --check", + "format": "npm run _eslint -- --max-warnings=0 --fix && npm run _prettier -- --write", "_eslint": "eslint --ext .ts,.tsx ./src", "_prettier": "prettier \"**/*.{ts,js,tsx,md,json}\"" }, diff --git a/frontend2/src/App.tsx b/frontend2/src/App.tsx index e311eeda9..403c43cd8 100644 --- a/frontend2/src/App.tsx +++ b/frontend2/src/App.tsx @@ -51,6 +51,7 @@ import ErrorBoundary from "./views/ErrorBoundary"; import { searchTeamsFactory } from "api/team/teamFactories"; import PageNotFound from "views/PageNotFound"; import TeamProfile from "views/TeamProfile"; +import { passwordForgotLoader } from "api/loaders/passwordForgotLoader"; const queryClient = new QueryClient({ queryCache: new QueryCache({ @@ -131,6 +132,7 @@ const router = createBrowserRouter([ path: "/password_forgot", element: , errorElement: , + loader: passwordForgotLoader(queryClient), }, { path: "/password_change", diff --git a/frontend2/src/api/auth/authApi.ts b/frontend2/src/api/auth/authApi.ts index 7aeeb4f27..9b14de08e 100644 --- a/frontend2/src/api/auth/authApi.ts +++ b/frontend2/src/api/auth/authApi.ts @@ -46,7 +46,7 @@ export const logout = async (queryClient: QueryClient): Promise => { }); }; -export const tokenVerify = async (): Promise => { +export const loginTokenVerify = async (): Promise => { const accessToken = Cookies.get("access"); if (accessToken === undefined) { return false; @@ -71,7 +71,7 @@ export const tokenVerify = async (): Promise => { export const loginCheck = async ( queryClient: QueryClient, ): Promise => { - const verified = await tokenVerify(); + const verified = await loginTokenVerify(); if (!verified) { await logout(queryClient); } diff --git a/frontend2/src/api/loaders/passwordForgotLoader.ts b/frontend2/src/api/loaders/passwordForgotLoader.ts new file mode 100644 index 000000000..cbca9a911 --- /dev/null +++ b/frontend2/src/api/loaders/passwordForgotLoader.ts @@ -0,0 +1,15 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { loginCheck } from "api/auth/authApi"; +import { type LoaderFunction } from "react-router-dom"; +import { DEFAULT_EPISODE } from "utils/constants"; + +export const passwordForgotLoader = + (queryClient: QueryClient): LoaderFunction => + async () => { + // Check if user is logged in + if (await loginCheck(queryClient)) { + // If user is logged in, redirect to home + window.location.href = `/${DEFAULT_EPISODE}/home`; + } + return null; + }; diff --git a/frontend2/src/api/user/useUser.ts b/frontend2/src/api/user/useUser.ts index c91ad8270..b03f9026e 100644 --- a/frontend2/src/api/user/useUser.ts +++ b/frontend2/src/api/user/useUser.ts @@ -16,11 +16,14 @@ import type { UserPasswordResetConfirmCreateRequest, UserUAvatarCreateRequest, UserUResumeUpdateRequest, + UserPasswordResetValidateTokenCreateRequest, + ResetToken, } from "../_autogen"; import { userMutationKeys, userQueryKeys } from "./userKeys"; import { createUser, doResetPassword, + forgotPassword, resumeUpload, updateCurrentUser, downloadResume, @@ -32,7 +35,8 @@ import { myUserInfoFactory, otherUserInfoFactory, otherUserTeamsFactory, - tokenVerifyFactory, + loginTokenVerifyFactory, + passwordResetTokenVerifyFactory, } from "./userFactories"; import { buildKey } from "../helpers"; @@ -44,9 +48,26 @@ export const useIsLoggedIn = ( queryClient: QueryClient, ): UseQueryResult => useQuery({ - queryKey: buildKey(tokenVerifyFactory.queryKey, { queryClient }), - queryFn: async () => await tokenVerifyFactory.queryFn({ queryClient }), + queryKey: buildKey(loginTokenVerifyFactory.queryKey, { queryClient }), + queryFn: async () => await loginTokenVerifyFactory.queryFn({ queryClient }), staleTime: Infinity, + refetchOnWindowFocus: false, + }); + +export const usePasswordResetTokenValid = ({ + resetTokenRequest, +}: UserPasswordResetValidateTokenCreateRequest): UseQueryResult< + ResetToken, + Error +> => + useQuery({ + queryKey: buildKey(passwordResetTokenVerifyFactory.queryKey, { + resetTokenRequest, + }), + queryFn: async () => + await passwordResetTokenVerifyFactory.queryFn({ resetTokenRequest }), + staleTime: Infinity, + refetchOnWindowFocus: false, }); /** @@ -157,7 +178,28 @@ export const useUpdateCurrentUserInfo = ( }); /** - * For resetting a user's password. If successful, logs in the user. + * For requesting a password reset token to be sent to the provided email. If successful, sends an email. + */ +export const useForgotPassword = ({ + episodeId, +}: { + episodeId: string; +}): UseMutationResult => + useMutation({ + mutationKey: userMutationKeys.forgotPassword({ episodeId }), + mutationFn: async (email: string) => { + await toast.promise(forgotPassword({ emailRequest: { email } }), { + loading: "Sending password reset email...", + success: + "Sent password reset email!\nWait a few minutes for it to arrive.", + error: + "Error sending password reset email.\nDid you enter the correct email?", + }); + }, + }); + +/** + * For resetting a user's password. If successful, navigates to the login page. */ export const useResetPassword = ({ episodeId, @@ -179,6 +221,7 @@ export const useResetPassword = ({ success: "Reset password!", error: "Error resetting password.", }); + window.location.href = "/login"; }, }); diff --git a/frontend2/src/api/user/userApi.ts b/frontend2/src/api/user/userApi.ts index f5eb1ce09..992c98f95 100644 --- a/frontend2/src/api/user/userApi.ts +++ b/frontend2/src/api/user/userApi.ts @@ -12,6 +12,8 @@ import { type UserUMePartialUpdateRequest, type UserUAvatarCreateRequest, type UserUResumeUpdateRequest, + type UserPasswordResetValidateTokenCreateRequest, + type ResetToken, } from "../_autogen"; import { DEFAULT_API_CONFIGURATION, downloadFile } from "../helpers"; @@ -28,6 +30,15 @@ export const createUser = async ({ }: UserUCreateRequest): Promise => await API.userUCreate({ userCreateRequest }); +/** + * Verify that a password reset token is valid and not expired. + * @param resetTokenRequest The password reset token to verify. + */ +export const passwordResetTokenVerify = async ( + resetTokenRequest: UserPasswordResetValidateTokenCreateRequest, +): Promise => + await API.userPasswordResetValidateTokenCreate(resetTokenRequest); + /** * Confirm resetting a user's password. * @param passwordTokenRequest The new password and password reset token. diff --git a/frontend2/src/api/user/userFactories.ts b/frontend2/src/api/user/userFactories.ts index 18b5b0c9d..55465bb32 100644 --- a/frontend2/src/api/user/userFactories.ts +++ b/frontend2/src/api/user/userFactories.ts @@ -1,22 +1,46 @@ import type { QueryClient } from "@tanstack/react-query"; import type { QueryFactory } from "../apiTypes"; import { userQueryKeys } from "./userKeys"; -import { tokenVerify } from "../auth/authApi"; +import { loginTokenVerify } from "../auth/authApi"; import type { + ResetToken, TeamPublic, + UserPasswordResetValidateTokenCreateRequest, UserPrivate, UserPublic, UserURetrieveRequest, UserUTeamsRetrieveRequest, } from "../_autogen"; -import { getCurrentUserInfo, getTeamsByUser, getUserInfoById } from "./userApi"; +import { + getCurrentUserInfo, + getTeamsByUser, + getUserInfoById, + passwordResetTokenVerify, +} from "./userApi"; +import toast from "react-hot-toast"; -export const tokenVerifyFactory: QueryFactory< +export const loginTokenVerifyFactory: QueryFactory< { queryClient: QueryClient }, boolean > = { - queryKey: userQueryKeys.tokenVerify, - queryFn: async () => await tokenVerify(), + queryKey: userQueryKeys.loginTokenVerify, + queryFn: async () => await loginTokenVerify(), +} as const; + +export const passwordResetTokenVerifyFactory: QueryFactory< + UserPasswordResetValidateTokenCreateRequest, + ResetToken +> = { + queryKey: userQueryKeys.passwordResetTokenVerify, + queryFn: async ({ resetTokenRequest }) => { + const toastFn = async (): Promise => + await passwordResetTokenVerify({ resetTokenRequest }); + return await toast.promise(toastFn(), { + loading: "Verifying token...", + success: "Token verified!", + error: "Error verifying token. Is it expired?", + }); + }, } as const; export const myUserInfoFactory: QueryFactory = { diff --git a/frontend2/src/api/user/userKeys.ts b/frontend2/src/api/user/userKeys.ts index aba7bb0ef..550992823 100644 --- a/frontend2/src/api/user/userKeys.ts +++ b/frontend2/src/api/user/userKeys.ts @@ -1,11 +1,13 @@ import type { + UserPasswordResetValidateTokenCreateRequest, UserURetrieveRequest, UserUTeamsRetrieveRequest, } from "../_autogen"; import type { QueryKeyBuilder, QueryKeyHolder } from "../apiTypes"; interface UserKeys { - tokenVerify: QueryKeyHolder; + loginTokenVerify: QueryKeyHolder; + passwordResetTokenVerify: QueryKeyBuilder; meBase: QueryKeyHolder; myInfo: QueryKeyHolder; otherBase: QueryKeyBuilder; @@ -19,8 +21,17 @@ export const userQueryKeys: UserKeys = { key: () => ["user", "me"] as const, }, - tokenVerify: { - key: () => [...userQueryKeys.meBase.key(), "tokenVerify"] as const, + loginTokenVerify: { + key: () => [...userQueryKeys.meBase.key(), "loginTokenVerify"] as const, + }, + + passwordResetTokenVerify: { + key: ({ resetTokenRequest }: UserPasswordResetValidateTokenCreateRequest) => + [ + ...userQueryKeys.meBase.key(), + "passwordResetTokenVerify", + resetTokenRequest.token, + ] as const, }, myInfo: { @@ -49,6 +60,9 @@ export const userMutationKeys = { updateCurrent: ({ episodeId }: { episodeId: string }) => ["user", "update", episodeId] as const, + forgotPassword: ({ episodeId }: { episodeId: string }) => + ["user", "forgotPass", episodeId] as const, + resetPassword: ({ episodeId }: { episodeId: string }) => ["user", "resetPass", episodeId] as const, diff --git a/frontend2/src/views/PasswordChange.tsx b/frontend2/src/views/PasswordChange.tsx index b0fb8144b..bf6dc32f8 100644 --- a/frontend2/src/views/PasswordChange.tsx +++ b/frontend2/src/views/PasswordChange.tsx @@ -1,7 +1,143 @@ -import React from "react"; +import { usePasswordResetTokenValid, useResetPassword } from "api/user/useUser"; +import Spinner from "components/Spinner"; +import Button from "components/elements/Button"; +import Input from "components/elements/Input"; +import { useEpisodeId } from "contexts/EpisodeContext"; +import React, { useMemo } from "react"; +import { useForm } from "react-hook-form"; +import { Link, useSearchParams } from "react-router-dom"; +import { FIELD_REQUIRED_ERROR_MSG } from "utils/constants"; + +interface PasswordChangeFormInput { + password: string; + passwordConfirm: string; +} + +interface QueryParams { + token: string; +} const PasswordChange: React.FC = () => { - return

passwordchange page

; + const { + register, + handleSubmit, + formState: { isSubmitting, isDirty, errors }, + reset, + getValues, + } = useForm(); + const { episodeId } = useEpisodeId(); + + const [searchParams, _] = useSearchParams(); + const queryParams: QueryParams = useMemo(() => { + return { + token: searchParams.get("token") ?? "", + }; + }, [searchParams]); + + const resetTokenValid = usePasswordResetTokenValid({ + resetTokenRequest: { token: queryParams.token }, + }); + const resetPassword = useResetPassword({ episodeId }); + + return ( +
+ +
BATTLECODE
+
+
{ + resetPassword.mutate({ + passwordTokenRequest: { + password: data.password, + token: queryParams.token, + }, + }); + reset(); + })} + className="flex w-11/12 flex-col gap-5 rounded-lg bg-gray-100 p-6 shadow-md sm:w-[350px]" + > + {(resetTokenValid.isLoading || resetTokenValid.isError) && ( + <> + {resetTokenValid.isLoading && ( +
+ Verifying Token... + +
+ )} + {resetTokenValid.isError && ( +
+ Invalid Token! +
+ Your password reset token may be expired. Try requesting a new + token or contact battlecode@mit.edu if you encounter further + issues. +
+
+ )} + + )} + {resetTokenValid.isSuccess && ( + <> +
+ Reset Password +
+ Enter a new password below to reset your password. +
+
+ + { + if (getValues("password") !== getValues("passwordConfirm")) { + return "Confirmation password does not match"; + } + return true; + }, + })} + type="password" + /> +
+ ); }; export default PasswordChange; diff --git a/frontend2/src/views/PasswordForgot.tsx b/frontend2/src/views/PasswordForgot.tsx index b0ca92151..a76a43758 100644 --- a/frontend2/src/views/PasswordForgot.tsx +++ b/frontend2/src/views/PasswordForgot.tsx @@ -1,7 +1,82 @@ +import { useForgotPassword } from "api/user/useUser"; +import Button from "components/elements/Button"; +import Input from "components/elements/Input"; +import { useEpisodeId } from "contexts/EpisodeContext"; import React from "react"; +import { useForm } from "react-hook-form"; +import { Link } from "react-router-dom"; +import { FIELD_REQUIRED_ERROR_MSG } from "utils/constants"; + +interface PasswordForgotFormInput { + email: string; +} const PasswordForgot: React.FC = () => { - return

passwordforgot page

; + const { + register, + handleSubmit, + formState: { isSubmitting, isDirty, errors }, + reset, + } = useForm(); + const { episodeId } = useEpisodeId(); + const forgot = useForgotPassword({ episodeId }); + + return ( +
+ +
BATTLECODE
+
+
{ + forgot.mutate(data.email); + reset(); + })} + className="flex w-11/12 flex-col gap-5 rounded-lg bg-gray-100 p-6 shadow-md sm:w-[350px]" + > +
+ Forgot Password +
+ Enter your email below to receive a password reset email. Contact + battlecode@mit.edu if you encounter any issues. +
+
+ +
+ ); }; export default PasswordForgot;