From 04b905e06f803f86ac4a62dc5e9c596a77a9c23f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=88=98=EB=AF=BC?= Date: Sun, 23 Jun 2024 22:25:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20API=20=EC=97=B0=EA=B2=B0=20(#196)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 비밀번호 변경 api 구현 * feat: 비밀번호 변경 api 로그인 정보 및 유저 정보 확인 * feat: 변경 성공 시 새로운 accessToken 쿠키 설정 * refactor: 이전 비밀번호와 같은 비밀번호 입력 시 새로운 에러메세지 출력 * fix: 비밀번호 확인 시 에러메세지 표시되지 않는 버그 수정 * refactor: state 줄이고 if return 문으로 구조 변경 * fix: package-lock.json 제거 * refactor: newPasswordForm 컴포넌트 react-hook-form으로 리팩토링 * refactor: 비밀번호 확인 유효성 검사 로직 수정 및 api 호출부 함수 분리 * refactor : mutation으로 api 호출 후 동작 위임, 맞춰서 PostChangePassword api 수정 * fix: import 오류 fix, 비밀번호 정규식 통일 * fix: api error throw 삭제 * fix: 사용하지 않는 import 삭제 * fix: userState 사용하는 로직 삭제 --- src/home/apis/getUserPasswordMatch.ts | 2 +- src/home/apis/postChangePassword.ts | 23 ++++ .../CurrentPasswordForm.tsx | 3 +- .../useCurrentPasswordForm.ts | 6 +- .../NewPasswordForm/NewPasswordForm.tsx | 52 +++------ .../NewPasswordForm/useNewPasswordForm.ts | 104 ++++++++++-------- src/home/hooks/usePostChangePassword.ts | 21 ++++ .../pages/ChangePassword/ChangePassword.tsx | 7 +- .../{GetPassword.type.ts => password.type.ts} | 5 + 9 files changed, 136 insertions(+), 87 deletions(-) create mode 100644 src/home/apis/postChangePassword.ts create mode 100644 src/home/hooks/usePostChangePassword.ts rename src/home/types/{GetPassword.type.ts => password.type.ts} (73%) diff --git a/src/home/apis/getUserPasswordMatch.ts b/src/home/apis/getUserPasswordMatch.ts index 2154c5a4..375b0769 100644 --- a/src/home/apis/getUserPasswordMatch.ts +++ b/src/home/apis/getUserPasswordMatch.ts @@ -2,7 +2,7 @@ import { AxiosError } from 'axios'; import { authClient } from '@/apis'; import { AuthErrorData } from '@/home/types/Auth.type'; -import { GetPasswordResponse } from '@/home/types/GetPassword.type'; +import { GetPasswordResponse } from '@/home/types/password.type'; import { api } from '@/service/TokenService'; interface getPasswordProps { diff --git a/src/home/apis/postChangePassword.ts b/src/home/apis/postChangePassword.ts new file mode 100644 index 00000000..c1fb8989 --- /dev/null +++ b/src/home/apis/postChangePassword.ts @@ -0,0 +1,23 @@ +import { authClient } from '@/apis'; +import { SessionTokenType } from '@/home/types/password.type'; + +import { PostAuthSignInData } from '../types/Auth.type'; + +interface changePasswordProps { + email: string; + newPassword: string; + sessionToken: SessionTokenType; +} + +export const postChangePassword = async ({ + email, + sessionToken, + newPassword, +}: changePasswordProps): Promise => { + const res = await authClient.post('/auth/change-password', { + email: email, + newPassword: newPassword, + sessionToken: sessionToken.sessionToken, + }); + return res.data; +}; diff --git a/src/home/components/ChangePasswordContents/CurrentPasswordForm/CurrentPasswordForm.tsx b/src/home/components/ChangePasswordContents/CurrentPasswordForm/CurrentPasswordForm.tsx index 551e0032..d54e9ce5 100644 --- a/src/home/components/ChangePasswordContents/CurrentPasswordForm/CurrentPasswordForm.tsx +++ b/src/home/components/ChangePasswordContents/CurrentPasswordForm/CurrentPasswordForm.tsx @@ -1,7 +1,7 @@ import { BoxButton, PasswordTextField } from '@yourssu/design-system-react'; import { useCurrentPasswordForm } from '@/home/components/ChangePasswordContents/CurrentPasswordForm/useCurrentPasswordForm'; -import { SessionTokenType } from '@/home/types/GetPassword.type'; +import { SessionTokenType } from '@/home/types/password.type'; import { StyledInputContainer, @@ -14,6 +14,7 @@ import { interface CurrentPasswordFormProps { onConfirm: () => void; setSessionToken: ({ sessionToken }: SessionTokenType) => void; + setPreviousPassword: (password: string) => void; } export const CurrentPasswordForm = (props: CurrentPasswordFormProps) => { diff --git a/src/home/components/ChangePasswordContents/CurrentPasswordForm/useCurrentPasswordForm.ts b/src/home/components/ChangePasswordContents/CurrentPasswordForm/useCurrentPasswordForm.ts index b7d49022..9c62b2fd 100644 --- a/src/home/components/ChangePasswordContents/CurrentPasswordForm/useCurrentPasswordForm.ts +++ b/src/home/components/ChangePasswordContents/CurrentPasswordForm/useCurrentPasswordForm.ts @@ -5,16 +5,18 @@ import { useRecoilValue } from 'recoil'; import { getUserPasswordMatch } from '@/home/apis/getUserPasswordMatch'; import { LogInState } from '@/home/recoil/LogInState'; -import { SessionTokenType } from '@/home/types/GetPassword.type'; +import { SessionTokenType } from '@/home/types/password.type'; interface CurrentPasswordFormProps { onConfirm: () => void; setSessionToken: ({ sessionToken }: SessionTokenType) => void; + setPreviousPassword: (password: string) => void; } export const useCurrentPasswordForm = ({ onConfirm, setSessionToken, + setPreviousPassword, }: CurrentPasswordFormProps) => { const [currentPassword, setCurrentPassword] = useState(''); const [isError, setIsError] = useState(false); @@ -23,7 +25,6 @@ export const useCurrentPasswordForm = ({ const checkCurrentPassword = async () => { if (!isLoggedIn) { - alert('로그인이 필요합니다.'); navigate('/Login'); return; } @@ -35,6 +36,7 @@ export const useCurrentPasswordForm = ({ if (data) { setIsError(false); setSessionToken(data as SessionTokenType); + setPreviousPassword(currentPassword); onConfirm(); } else if (error) setIsError(true); }; diff --git a/src/home/components/ChangePasswordContents/NewPasswordForm/NewPasswordForm.tsx b/src/home/components/ChangePasswordContents/NewPasswordForm/NewPasswordForm.tsx index 903f993e..facedd31 100644 --- a/src/home/components/ChangePasswordContents/NewPasswordForm/NewPasswordForm.tsx +++ b/src/home/components/ChangePasswordContents/NewPasswordForm/NewPasswordForm.tsx @@ -1,7 +1,7 @@ import { BoxButton, PasswordTextField } from '@yourssu/design-system-react'; import { useNewPasswordForm } from '@/home/components/ChangePasswordContents/NewPasswordForm/useNewPasswordForm'; -import { SessionTokenType } from '@/home/types/GetPassword.type'; +import { NewPasswordFormProps } from '@/home/types/password.type'; import { StyledInputContainer, @@ -12,25 +12,11 @@ import { StyledInputAnimation, } from './NewPasswordForm.style'; -interface NewPasswordFormProps { - sessionToken: SessionTokenType; -} +export const NewPasswordForm = (props: NewPasswordFormProps) => { + const { isFirstRender, register, passwordValidate, errors, onSubmit } = useNewPasswordForm(props); -export const NewPasswordForm = ({ sessionToken }: NewPasswordFormProps) => { - const { - newPassword, - newPasswordCheck, - isNewPasswordError, - isNewPasswordCheckError, - isFirstRender, - validationAttempted, - setNewPasswordCheck, - handleNewPasswordChange, - handleSubmit, - } = useNewPasswordForm(sessionToken); - - const isNewPasswordFieldNegative = !isFirstRender && isNewPasswordError; - const isRepeatPasswordFieldNegative = isNewPasswordCheckError && validationAttempted; + const isInvalidPassword = !isFirstRender && !!errors.newPassword; + const isValidPassword = !isFirstRender && !errors.newPassword; return ( @@ -39,24 +25,18 @@ export const NewPasswordForm = ({ sessionToken }: NewPasswordFormProps) => { 새로운 비밀번호를 입력해주세요. handleNewPasswordChange(e.target.value)} - isNegative={isNewPasswordFieldNegative} - helperLabel={ - isNewPasswordFieldNegative - ? '숫자, 영문자, 특수문자 조합으로 8자 이상 입력해주세요' - : '' - } + {...register('newPassword', { + validate: passwordValidate, + })} + isNegative={isInvalidPassword} + helperLabel={isInvalidPassword ? (errors.newPassword?.message as string) : ''} /> - = 8 ? 'active' : ''} - > + 비밀번호를 한번 더 입력해주세요. setNewPasswordCheck(e.target.value)} - isNegative={isRepeatPasswordFieldNegative} - helperLabel={isRepeatPasswordFieldNegative ? '비밀번호가 일치하지 않습니다.' : ''} + {...register('newPasswordCheck')} + isNegative={!!errors.newPasswordCheck} + helperLabel={errors.newPasswordCheck?.message as string | undefined} /> @@ -65,8 +45,8 @@ export const NewPasswordForm = ({ sessionToken }: NewPasswordFormProps) => { rounding={8} size="large" variant="filled" - onClick={handleSubmit} - disabled={isFirstRender || isNewPasswordError} + onClick={onSubmit} + disabled={!isValidPassword} > 변경하기 diff --git a/src/home/components/ChangePasswordContents/NewPasswordForm/useNewPasswordForm.ts b/src/home/components/ChangePasswordContents/NewPasswordForm/useNewPasswordForm.ts index 6f6bf248..14a35214 100644 --- a/src/home/components/ChangePasswordContents/NewPasswordForm/useNewPasswordForm.ts +++ b/src/home/components/ChangePasswordContents/NewPasswordForm/useNewPasswordForm.ts @@ -1,69 +1,81 @@ import { useState, useEffect } from 'react'; +import { hasNumberAndEnglishWithSymbols } from '@yourssu/utils'; +import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; +import { useRecoilValue } from 'recoil'; -import { SessionTokenType } from '@/home/types/GetPassword.type'; -import { api } from '@/service/TokenService'; +import { useGetUserData } from '@/home/hooks/useGetUserData'; +import { usePostChangePassword } from '@/home/hooks/usePostChangePassword'; +import { LogInState } from '@/home/recoil/LogInState'; +import { NewPasswordFormProps } from '@/home/types/password.type'; -export const useNewPasswordForm = (sessionToken: SessionTokenType) => { - const [newPassword, setNewPassword] = useState(''); - const [newPasswordCheck, setNewPasswordCheck] = useState(''); - const [isNewPasswordError, setIsNewPasswordError] = useState(false); - const [isNewPasswordCheckError, setIsNewPasswordCheckError] = useState(false); - const [isFirstRender, setIsFirstRender] = useState(true); - const [validationAttempted, setValidationAttempted] = useState(false); +export const useNewPasswordForm = (props: NewPasswordFormProps) => { + const { sessionToken, previousPassword } = props; + const [isFirstRender, setIsFirstRender] = useState(true); + const { + register, + watch, + formState: { errors }, + getValues, + setValue, + setError, + } = useForm({ + mode: 'onChange', + }); + + const isLoggedIn = useRecoilValue(LogInState); + const { data: currentUser } = useGetUserData(); const navigate = useNavigate(); + const postChangePassword = usePostChangePassword(); + + const newPassword = watch('newPassword'); - const regexp = new RegExp('^(?=.*[a-zA-Z])(?=.*[0-9]).{8,}$'); + useEffect(() => { + setIsFirstRender(!newPassword || newPassword.length < 8); + }, [newPassword]); - const handleNewPasswordChange = (password: string) => { - setNewPassword(password); - if (password.length >= 8) { - setIsFirstRender(false); - setIsNewPasswordError(!regexp.test(password)); - } else { - setIsNewPasswordError(true); + const passwordValidate = (newPassword: string) => { + if (newPassword === previousPassword) { + setValue('newPasswordCheck', ''); + return '현재 비밀번호와 다른 비밀번호를 입력해주세요.'; } + + if (hasNumberAndEnglishWithSymbols(newPassword) && newPassword.length <= 100) return true; + + setValue('newPasswordCheck', ''); + return '숫자, 영문자, 특수문자 조합으로 8자 이상 입력해주세요.'; }; - const handleSubmit = () => { - if (sessionToken === null) { + const onSubmit = () => { + const newPasswordCheck = getValues('newPasswordCheck'); + + if (newPassword !== newPasswordCheck) { + setError('newPasswordCheck', { + type: 'manual', + message: '비밀번호가 일치하지 않습니다.', + }); return; } - setValidationAttempted(true); - const isValid = regexp.test(newPassword); - if (isValid && newPassword === newPasswordCheck) { - const accessToken = api.getAccessToken(); - if (!accessToken) { - alert('로그인이 필요합니다.'); - navigate('/Login'); - return; - } - setIsNewPasswordCheckError(false); + if (!isLoggedIn || !currentUser) { + navigate('/Login'); + return; } - if (!isValid) setIsNewPasswordError(true); - if (newPassword !== newPasswordCheck) setIsNewPasswordCheckError(true); + postChangePassword.mutate({ + email: currentUser.email, + newPassword, + sessionToken, + }); }; - useEffect(() => { - if (newPassword.length < 8) { - setIsNewPasswordCheckError(false); - setNewPasswordCheck(''); - setValidationAttempted(false); - } - }, [newPassword]); - return { newPassword, - newPasswordCheck, - isNewPasswordError, - isNewPasswordCheckError, isFirstRender, - validationAttempted, - setNewPasswordCheck, - handleNewPasswordChange, - handleSubmit, + register, + onSubmit, + passwordValidate, + errors, }; }; diff --git a/src/home/hooks/usePostChangePassword.ts b/src/home/hooks/usePostChangePassword.ts new file mode 100644 index 00000000..b9ace5b2 --- /dev/null +++ b/src/home/hooks/usePostChangePassword.ts @@ -0,0 +1,21 @@ +import { useMutation } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; + +import { postChangePassword } from '@/home/apis/postChangePassword'; +import { api } from '@/service/TokenService'; + +export const usePostChangePassword = () => { + const navigate = useNavigate(); + + return useMutation({ + mutationFn: postChangePassword, + onSuccess: (data) => { + api.setAccessToken(data.accessToken, data.accessTokenExpiredIn); + api.setRefreshToken(data.refreshToken, data.refreshTokenExpiredIn); + navigate('/mypage'); + }, + onError: () => { + navigate('/Login'); + }, + }); +}; diff --git a/src/home/pages/ChangePassword/ChangePassword.tsx b/src/home/pages/ChangePassword/ChangePassword.tsx index a460c88e..8147a2cf 100644 --- a/src/home/pages/ChangePassword/ChangePassword.tsx +++ b/src/home/pages/ChangePassword/ChangePassword.tsx @@ -11,6 +11,7 @@ type ChangePasswordFunnelStepsType = '현재비밀번호입력' | '새비밀번 export const ChangePassword = () => { const [Funnel, setStep] = useFunnel('현재비밀번호입력'); const [sessionToken, setSessionToken] = useState(null); + const [previousPassword, setPreviousPassword] = useState(''); return ( @@ -19,10 +20,14 @@ export const ChangePassword = () => { setStep('새비밀번호입력')} setSessionToken={setSessionToken} + setPreviousPassword={setPreviousPassword} /> - + diff --git a/src/home/types/GetPassword.type.ts b/src/home/types/password.type.ts similarity index 73% rename from src/home/types/GetPassword.type.ts rename to src/home/types/password.type.ts index 3ce0b549..e2cdb946 100644 --- a/src/home/types/GetPassword.type.ts +++ b/src/home/types/password.type.ts @@ -11,3 +11,8 @@ export interface GetPasswordResponse { data?: SessionTokenType; error?: AxiosError; } + +export interface NewPasswordFormProps { + sessionToken: SessionTokenType; + previousPassword: string; +}