From 634e1b4a03f486147069f60b30f9a4a261e32da8 Mon Sep 17 00:00:00 2001 From: Kwon Seo Jin <97675977+B0XERCAT@users.noreply.github.com> Date: Mon, 11 Mar 2024 14:17:43 +0900 Subject: [PATCH] feat(fe): implement recover account modal (#1526) * feat(fe): add modal layout * feat(fe): connect find userid api * feat(fe): add resetpassword * feat(fe): add reset password page * fix: add credential include, change fetcher to fetch * fix(fe): remove unnecessary onClick event handler * chore: replace fetch with fetcher --- apps/frontend/components/auth/AuthModal.tsx | 12 ++ apps/frontend/components/auth/FindUserId.tsx | 156 ++++++++++++++++++ .../components/auth/RecoverAccount.tsx | 37 +++++ .../components/auth/ResetPassword.tsx | 154 +++++++++++++++++ .../auth/ResetPasswordEmailVerify.tsx | 107 ++++++++++++ apps/frontend/components/auth/SignIn.tsx | 3 +- apps/frontend/stores/authModal.ts | 7 + apps/frontend/stores/recoverAccountModal.ts | 49 ++++++ 8 files changed, 524 insertions(+), 1 deletion(-) create mode 100644 apps/frontend/components/auth/FindUserId.tsx create mode 100644 apps/frontend/components/auth/RecoverAccount.tsx create mode 100644 apps/frontend/components/auth/ResetPassword.tsx create mode 100644 apps/frontend/components/auth/ResetPasswordEmailVerify.tsx create mode 100644 apps/frontend/stores/recoverAccountModal.ts diff --git a/apps/frontend/components/auth/AuthModal.tsx b/apps/frontend/components/auth/AuthModal.tsx index 6cf1c04637..70a74565db 100644 --- a/apps/frontend/components/auth/AuthModal.tsx +++ b/apps/frontend/components/auth/AuthModal.tsx @@ -1,5 +1,6 @@ import useAuthModalStore from '@/stores/authModal' import { Transition } from '@headlessui/react' +import RecoverAccount from './RecoverAccount' import SignIn from './SignIn' import SignUp from './SignUp' @@ -29,6 +30,17 @@ export default function AuthModal() { > + + + > ) } diff --git a/apps/frontend/components/auth/FindUserId.tsx b/apps/frontend/components/auth/FindUserId.tsx new file mode 100644 index 0000000000..55652a1cbd --- /dev/null +++ b/apps/frontend/components/auth/FindUserId.tsx @@ -0,0 +1,156 @@ +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { cn, fetcher } from '@/lib/utils' +import useAuthModalStore from '@/stores/authModal' +import useRecoverAccountModalStore from '@/stores/recoverAccountModal' +import { zodResolver } from '@hookform/resolvers/zod' +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import { z } from 'zod' + +interface FindUserIdInput { + email: string +} +const schema = z.object({ + email: z.string().email({ message: 'Invalid email address' }) +}) + +export default function FindUserId() { + const [userId, setUserId] = useState('') + const [emailError, setEmailError] = useState('') + const [sentEmail, setSentEmail] = useState(false) + const { nextModal, setFormData } = useRecoverAccountModalStore( + (state) => state + ) + const { showSignIn, showSignUp } = useAuthModalStore((state) => state) + + const { + handleSubmit, + register, + trigger, + getValues, + formState: { errors } + } = useForm({ + resolver: zodResolver(schema) + }) + + const onSubmit = async (data: FindUserIdInput) => { + const { email } = data + try { + const data: { username: string } = await fetcher + .get('user/email', { + searchParams: { email } + }) + .json() + if (data.username) { + setUserId(data.username) + setEmailError('') + } else setEmailError('* No account confirmed with this email') + } catch { + setEmailError('* No account confirmed with this email') + } + } + + const sendEmail = async () => { + const { email } = getValues() + setFormData({ + email, + verificationCode: '', + headers: { + 'email-auth': '' + } + }) + await trigger('email') + if (!errors.email && !sentEmail) { + await fetcher + .post('email-auth/send-email/password-reset', { + json: { email } + }) + .then((res) => { + if (res.status === 401) { + setEmailError( + 'Email authentication pin is sent to your email address' + ) + } else if (res.status === 201) { + setSentEmail(true) + setEmailError('') + } + }) + .catch(() => { + setEmailError('Something went wrong!') + }) + } + } + + return ( + <> + + + + Find User ID + + trigger('email') + })} + disabled={!!userId} + /> + {errors.email && ( + {errors.email?.message} + )} + {emailError} + {userId ? ( + + your User ID is {userId} + + ) : ( + + your User ID is ___________ + + )} + + + + {userId ? ( + showSignIn()} type="button"> + Log in + + ) : ( + Find User ID + )} + { + sendEmail() + .then(() => { + nextModal() + }) + .catch(() => { + console.log('error') + }) + }} + className={cn(!userId && 'bg-gray-400')} + disabled={!userId} + > + Reset Password + + + + + showSignUp()} + variant={'link'} + className="h-5 w-fit p-0 py-2 text-xs text-gray-500" + > + Register now + + + > + ) +} diff --git a/apps/frontend/components/auth/RecoverAccount.tsx b/apps/frontend/components/auth/RecoverAccount.tsx new file mode 100644 index 0000000000..4a9f8890f3 --- /dev/null +++ b/apps/frontend/components/auth/RecoverAccount.tsx @@ -0,0 +1,37 @@ +'use client' + +import CodedangLogo from '@/public/codedang.svg' +import useAuthModalStore from '@/stores/authModal' +import useRecoverAccountModalStore from '@/stores/recoverAccountModal' +import Image from 'next/image' +import { IoMdArrowBack } from 'react-icons/io' +import FindUserId from './FindUserId' +import ResetPassword from './ResetPassword' +import ResetPasswordEmailVerify from './ResetPasswordEmailVerify' + +export default function RecoverAccount() { + const { showSignIn } = useAuthModalStore((state) => state) + const { modalPage, backModal } = useRecoverAccountModalStore((state) => state) + + return ( + + + + + + + + {modalPage === 0 && } + {modalPage === 1 && } + {modalPage === 2 && } + + ) +} diff --git a/apps/frontend/components/auth/ResetPassword.tsx b/apps/frontend/components/auth/ResetPassword.tsx new file mode 100644 index 0000000000..2322e13030 --- /dev/null +++ b/apps/frontend/components/auth/ResetPassword.tsx @@ -0,0 +1,154 @@ +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { cn, fetcher } from '@/lib/utils' +import useRecoverAccountModalStore from '@/stores/recoverAccountModal' +import { zodResolver } from '@hookform/resolvers/zod' +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import { FaEye, FaEyeSlash } from 'react-icons/fa' +import { toast } from 'sonner' +import { z } from 'zod' + +interface ResetPasswordInput { + password: string + passwordAgain: string +} + +const schema = z + .object({ + password: z + .string() + .min(8) + .refine((data) => { + const invalidPassword = /^([a-z]*|[A-Z]*|[0-9]*|[^a-zA-Z0-9]*)$/ + return !invalidPassword.test(data) + }), + passwordAgain: z.string().min(8) + }) + .refine( + (data: { password: string; passwordAgain: string }) => + data.password === data.passwordAgain, + { + path: ['passwordAgain'] + } + ) + +export default function ResetPassword() { + const { + handleSubmit, + register, + trigger, + formState: { errors, isValid } + } = useForm({ + resolver: zodResolver(schema) + }) + const [passwordShow, setPasswordShow] = useState(false) + const [passwordAgainShow, setPasswordAgainShow] = useState(false) + const [inputFocus, setInputFocus] = useState(0) + const { formData } = useRecoverAccountModalStore((state) => state) + + const onSubmit = async (data: ResetPasswordInput) => { + try { + const response = await fetcher.patch('user/password-reset', { + headers: formData.headers, + json: { + newPassword: data.password + } + }) + if (response.ok) { + document.getElementById('closeDialog')?.click() + toast.success('Password reset successfully') + } + } catch { + toast.error('Password reset failed') + } + } + + return ( + + + Reset Password + + + + + trigger('password') + })} + type={passwordShow ? 'text' : 'password'} + onFocus={() => { + setInputFocus(0) + }} + /> + setPasswordShow(!passwordShow)} + > + {passwordShow ? ( + + ) : ( + + )} + + + {inputFocus === 0 && ( + + + + Your password must be at least 8 characters + + and include two of the followings: + Capital letters, Small letters, or Numbers + + + )} + + + + + trigger('passwordAgain') + })} + placeholder="Re-enter password" + type={passwordAgainShow ? 'text' : 'password'} + onFocus={() => { + setInputFocus(1) + }} + /> + setPasswordAgainShow(!passwordAgainShow)} + > + {passwordAgainShow ? ( + + ) : ( + + )} + + + {errors.passwordAgain && ( + Incorrect + )} + + + Save + + + + ) +} diff --git a/apps/frontend/components/auth/ResetPasswordEmailVerify.tsx b/apps/frontend/components/auth/ResetPasswordEmailVerify.tsx new file mode 100644 index 0000000000..59804758bd --- /dev/null +++ b/apps/frontend/components/auth/ResetPasswordEmailVerify.tsx @@ -0,0 +1,107 @@ +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { cn, fetcher } from '@/lib/utils' +import useRecoverAccountModalStore from '@/stores/recoverAccountModal' +import { zodResolver } from '@hookform/resolvers/zod' +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import { z } from 'zod' + +interface EmailVerifyInput { + verificationCode: string +} + +const schema = z.object({ + verificationCode: z + .string() + .min(6, { message: 'Code must be 6 characters long' }) + .max(6, { message: 'Code must be 6 characters long' }) +}) + +export default function ResetPasswordEmailVerify() { + const { nextModal, formData, setFormData } = useRecoverAccountModalStore( + (state) => state + ) + const { + handleSubmit, + register, + getValues, + trigger, + formState: { errors } + } = useForm({ + resolver: zodResolver(schema) + }) + const [emailVerified, setEmailVerified] = useState(false) + const [emailAuthToken, setEmailAuthToken] = useState('') + const [codeError, setCodeError] = useState('') + + const onSubmit = (data: EmailVerifyInput) => { + setFormData({ + email: formData.email, + ...data, + headers: { + 'email-auth': emailAuthToken + } + }) + nextModal() + } + const verifyCode = async () => { + const { verificationCode } = getValues() + await trigger('verificationCode') + if (!errors.verificationCode) { + try { + const response = await fetcher.post('email-auth/verify-pin', { + json: { + pin: verificationCode, + email: formData.email + }, + credentials: 'include' + }) + if (response.status === 201) { + setEmailVerified(true) + setCodeError('') + setEmailAuthToken(response.headers.get('email-auth') || '') + } else { + setCodeError('Verification code is not valid!') + } + } catch { + setCodeError('Email verification failed!') + } + } + } + + return ( + + + Reset Password + + + {formData.email} + + verifyCode() + })} + /> + + {errors.verificationCode ? errors.verificationCode?.message : codeError} + + {!errors.verificationCode && codeError === '' && !emailVerified && ( + We've sent an email! + )} + + Next + + + ) +} diff --git a/apps/frontend/components/auth/SignIn.tsx b/apps/frontend/components/auth/SignIn.tsx index 4ec096cd82..831c503def 100644 --- a/apps/frontend/components/auth/SignIn.tsx +++ b/apps/frontend/components/auth/SignIn.tsx @@ -23,7 +23,7 @@ interface Inputs { export default function SignIn() { const [disableButton, setDisableButton] = useState(false) - const { showSignUp } = useAuthModalStore((state) => state) + const { showSignUp, showRecoverAccount } = useAuthModalStore((state) => state) const router = useRouter() const { register, handleSubmit } = useForm() const onSubmit: SubmitHandler = async (data) => { @@ -96,6 +96,7 @@ export default function SignIn() { Sign Up showRecoverAccount()} variant={'link'} className="h-5 w-fit p-0 py-2 text-xs text-gray-500" > diff --git a/apps/frontend/stores/authModal.ts b/apps/frontend/stores/authModal.ts index ab2630915f..e44b3dc217 100644 --- a/apps/frontend/stores/authModal.ts +++ b/apps/frontend/stores/authModal.ts @@ -4,6 +4,7 @@ interface AuthModalStore { currentModal: string showSignIn: () => void showSignUp: () => void + showRecoverAccount: () => void hideModal: () => void } const useAuthModalStore = create( @@ -21,6 +22,12 @@ const useAuthModalStore = create( setTimeout(() => { set({ currentModal: 'signUp' }) }, 180) + }, + showRecoverAccount: () => { + set({ currentModal: '' }) + setTimeout(() => { + set({ currentModal: 'recoverAccount' }) + }, 180) } }) ) diff --git a/apps/frontend/stores/recoverAccountModal.ts b/apps/frontend/stores/recoverAccountModal.ts new file mode 100644 index 0000000000..1164189cfd --- /dev/null +++ b/apps/frontend/stores/recoverAccountModal.ts @@ -0,0 +1,49 @@ +import { create } from 'zustand' + +interface FormData { + email: string + verificationCode: string + headers: { + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + 'email-auth': string + } +} + +interface RecoverAccountModalStore { + modalPage: number + formData: { + email: string + verificationCode: string + headers: { + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + 'email-auth': string + } + } + setModalPage: (page: number) => void + setFormData: (data: FormData) => void + nextModal: () => void + backModal: () => void +} +const useRecoverAccountModalStore = create((set) => ({ + modalPage: 0, + formData: { + email: '', + verificationCode: '', + headers: { + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + 'email-auth': '' + } + }, + setModalPage: (page: number) => set({ modalPage: page }), + setFormData: (data: FormData) => set({ formData: data }), + nextModal: () => + set((state: { modalPage: number }) => ({ + modalPage: state.modalPage + 1 + })), + backModal: () => + set((state: { modalPage: number }) => ({ + modalPage: state.modalPage - 1 + })) +})) + +export default useRecoverAccountModalStore
+ Find User ID +
{errors.email?.message}
{emailError}
+ your User ID is {userId} +
+ your User ID is ___________ +
+ Reset Password +
Incorrect
+ {errors.verificationCode ? errors.verificationCode?.message : codeError} +
We've sent an email!