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 ? ( + + ) : ( + + )} + +
+
+
+ +
+ + ) +} 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 ( +
+ + + codedang + + {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

+ )} +
+ +
+
+ ) +} 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!

+ )} + +
+ ) +} 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