Skip to content

Commit

Permalink
Feat: #142 이메일 인증 기능 구현 (#143)
Browse files Browse the repository at this point in the history
* Chore: #142 useNavigate 변수명 변경 및 useFormContext 타입 추가

* Feat: #142 이메일 인증번호 요청 네트워크 로직 작성 및 기존 인증 처리 로직 수정

* Feat: #142 이메일 인증코드 발급 관련 코드를 더미데이터로 대체

* Refactor: #142 상위 컴포넌트에서 이메일 인증 관련 코드를 관리하도록 수정

* Refactor: #142 인증번호 불일치시 후처리 함수 분리

* Feat: #142 인증번호 불일치 시 필드에러만 표시하도록 수정

* Refactor: #142 SearchDataForm로 전달할 데이터를 객체로 묶어 전달하도록 수정
  • Loading branch information
Yoonyesol authored Sep 23, 2024
1 parent 47943d2 commit 1efb5f5
Show file tree
Hide file tree
Showing 12 changed files with 121 additions and 68 deletions.
6 changes: 3 additions & 3 deletions src/components/user/auth-form/FooterLinks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ const links = {
};

export default function FooterLinks({ type }: FooterLinksProps) {
const nav = useNavigate();
const navigate = useNavigate();

return (
<>
<div className="flex flex-row justify-center">
{links[type].map((link, index) => (
<div key={link.text} className="flex flex-row">
<button type="button" className="cursor-pointer bg-inherit font-bold" onClick={() => nav(link.path)}>
<button type="button" className="cursor-pointer bg-inherit font-bold" onClick={() => navigate(link.path)}>
{link.text}
</button>
{index < links[type].length - 1 && <p className="mx-8">|</p>}
Expand All @@ -48,7 +48,7 @@ export default function FooterLinks({ type }: FooterLinksProps) {
</div>
<div className="mt-15 flex flex-row items-center justify-center gap-8">
<p className="items-center font-bold">회원이 아니신가요?</p>
<button type="button" className="auth-btn" onClick={() => nav('/signup')}>
<button type="button" className="auth-btn" onClick={() => navigate('/signup')}>
회원가입
</button>
</div>
Expand Down
26 changes: 18 additions & 8 deletions src/components/user/auth-form/SearchDataForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,51 @@ import ValidationInput from '@components/common/ValidationInput';
import FooterLinks from '@components/user/auth-form/FooterLinks';
import VerificationButton from '@components/user/auth-form/VerificationButton';
import { USER_AUTH_VALIDATION_RULES } from '@constants/formValidationRules';
import useEmailVerification from '@hooks/useEmailVerification';
import { SearchPasswordForm } from '@/types/UserType';

type SearchDataFormProps = {
formType: 'searchId' | 'searchPassword';
isVerificationRequested: boolean;
requestVerificationCode: (email: string) => Promise<void>;
expireVerificationCode: () => void;
};

export default function SearchDataForm({ formType }: SearchDataFormProps) {
export default function SearchDataForm({
formType,
isVerificationRequested,
requestVerificationCode,
expireVerificationCode,
}: SearchDataFormProps) {
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
} = useFormContext();
const { isVerificationRequested, requestVerificationCode, expireVerificationCode } = useEmailVerification();
} = useFormContext<SearchPasswordForm>();

return (
<>
{/* 아이디 */}
{formType === 'searchPassword' && (
<ValidationInput
placeholder="아이디"
errors={errors.username?.message?.toString()}
errors={errors.username?.message}
register={register('username', USER_AUTH_VALIDATION_RULES.ID)}
/>
)}

{/* 이메일 */}
<ValidationInput
placeholder="이메일"
errors={errors.email?.message?.toString()}
errors={errors.email?.message}
register={register('email', USER_AUTH_VALIDATION_RULES.EMAIL)}
/>

{/* 이메일 인증 */}
{isVerificationRequested && (
<ValidationInput
placeholder="인증번호"
errors={errors.code?.message?.toString()}
errors={errors.code?.message}
register={register('code', USER_AUTH_VALIDATION_RULES.CERTIFICATION)}
/>
)}
Expand All @@ -48,11 +57,12 @@ export default function SearchDataForm({ formType }: SearchDataFormProps) {
<VerificationButton
isVerificationRequested={isVerificationRequested}
isSubmitting={isSubmitting}
requestCode={handleSubmit(requestVerificationCode)}
requestCode={handleSubmit(() => requestVerificationCode(watch('email')))}
expireVerificationCode={expireVerificationCode}
buttonLabel={formType === 'searchId' ? '아이디 찾기' : '비밀번호 찾기'}
/>
</div>

<FooterLinks type={formType} />
</>
);
Expand Down
4 changes: 2 additions & 2 deletions src/components/user/auth-form/SearchResultSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ type SearchResultSectionProps = {
};

export default function SearchResultSection({ label, result }: SearchResultSectionProps) {
const nav = useNavigate();
const navigate = useNavigate();

return (
<section className="space-y-20 text-center">
Expand All @@ -16,7 +16,7 @@ export default function SearchResultSection({ label, result }: SearchResultSecti
<strong>{result}</strong>
</p>
</div>
<button type="button" className="auth-btn w-full" onClick={() => nav('/signin')}>
<button type="button" className="auth-btn w-full" onClick={() => navigate('/signin')}>
로그인으로 돌아가기
</button>
</section>
Expand Down
32 changes: 13 additions & 19 deletions src/hooks/useEmailVerification.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,29 @@
import { useState } from 'react';
import { UseFormSetError } from 'react-hook-form';
import { AxiosError } from 'axios';
import { sendEmailCode } from '@services/authService';
import useToast from '@hooks/useToast';
import type { UserSignUpForm } from '@/types/UserType';

export default function useEmailVerification() {
const [isVerificationRequested, setIsVerificationRequested] = useState(false);
const { toastSuccess, toastError } = useToast();

// 이메일 인증번호 요청 함수
const requestVerificationCode = () => {
if (!isVerificationRequested) {
const requestVerificationCode = async (email: string) => {
if (isVerificationRequested) return;

try {
await sendEmailCode({ email });
setIsVerificationRequested(true);
toastSuccess('인증번호가 발송되었습니다. 이메일을 확인해 주세요.');
} catch (error) {
if (error instanceof AxiosError && error.response) {
toastError(error.response.data.message);
} else {
toastError('예상치 못한 에러가 발생했습니다.');
}
}
};

// 인증번호 확인 함수
const verifyCode = (verificationCode: string, setError: UseFormSetError<UserSignUpForm>) => {
// ToDo: 이메일 인증 API 추가
if (verificationCode === '1234') return true;

// 인증번호 불일치
setError('code', {
type: 'manual',
message: '인증번호가 일치하지 않습니다.',
});
toastError('인증번호가 유효하지 않습니다. 다시 시도해 주세요.');
return false;
};

// 인증 코드 만료
const expireVerificationCode = () => {
setIsVerificationRequested(false);
Expand All @@ -38,7 +33,6 @@ export default function useEmailVerification() {
return {
isVerificationRequested,
requestVerificationCode,
verifyCode,
expireVerificationCode,
};
}
2 changes: 2 additions & 0 deletions src/mocks/mockData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ type TaskFile = {

export const JWT_TOKEN_DUMMY = 'mocked-header.mocked-payload-4.mocked-signature';

export const emailVerificationCode = '1234';

export const USER_INFO_DUMMY = {
provider: 'LOCAL',
userId: 4,
Expand Down
27 changes: 21 additions & 6 deletions src/mocks/services/authServiceHandler.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import Cookies from 'js-cookie';
import { http, HttpResponse } from 'msw';
import { AUTH_SETTINGS } from '@constants/settings';
import { USER_INFO_DUMMY } from '@mocks/mockData';
import { EmailVerificationForm, SearchPasswordForm, UpdatePasswordRequest, UserSignInForm } from '@/types/UserType';
import { emailVerificationCode, USER_INFO_DUMMY } from '@mocks/mockData';
import {
EmailVerificationForm,
RequestEmailCode,
SearchPasswordForm,
UpdatePasswordRequest,
UserSignInForm,
} from '@/types/UserType';

const BASE_URL = import.meta.env.VITE_BASE_URL;
const refreshTokenExpiryDate = new Date(Date.now() + AUTH_SETTINGS.REFRESH_TOKEN_EXPIRATION).toISOString();
Expand Down Expand Up @@ -112,20 +118,29 @@ const authServiceHandler = [
return new HttpResponse(null, { status: 401 });
}),

// 이메일 인증 API
http.post(`${BASE_URL}/user/verify/send`, async ({ request }) => {
const { email } = (await request.json()) as RequestEmailCode;

if (email !== USER_INFO_DUMMY.email)
return HttpResponse.json({ message: '이메일을 다시 확인해 주세요.' }, { status: 400 });

return HttpResponse.json(null, { status: 200 });
}),

// 아이디 찾기 API
http.post(`${BASE_URL}/user/recover/username`, async ({ request }) => {
const { email, code } = (await request.json()) as EmailVerificationForm;

if (code !== '1234') {
if (code !== emailVerificationCode) {
return HttpResponse.json(
{ message: '이메일 인증 번호가 일치하지 않습니다. 다시 확인해 주세요.' },
{ status: 401 },
);
}

if (email !== USER_INFO_DUMMY.email) {
if (email !== USER_INFO_DUMMY.email)
return HttpResponse.json({ message: '이메일을 다시 확인해 주세요.' }, { status: 400 });
}

return HttpResponse.json({ username: USER_INFO_DUMMY.username }, { status: 200 });
}),
Expand All @@ -136,7 +151,7 @@ const authServiceHandler = [

const tempPassword = '!1p2l3nqlz';

if (code !== '1234') {
if (code !== emailVerificationCode) {
return HttpResponse.json(
{ message: '이메일 인증 번호가 일치하지 않습니다. 다시 확인해 주세요.' },
{ status: 401 },
Expand Down
9 changes: 2 additions & 7 deletions src/pages/setting/UserAuthenticatePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,18 @@ import VerificationButton from '@components/user/auth-form/VerificationButton';
import { EmailVerificationForm } from '@/types/UserType';

function UserAuthenticatePage() {
const { isVerificationRequested, requestVerificationCode, verifyCode, expireVerificationCode } =
useEmailVerification();
const { isVerificationRequested, requestVerificationCode, expireVerificationCode } = useEmailVerification();

const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
watch,
} = useForm<EmailVerificationForm>({
mode: 'onChange',
});

const onSubmit = async (data: EmailVerificationForm) => {
const verifyResult = verifyCode(watch('code'), setError);
if (!verifyResult) return;

// TODO: 인증 성공 후 전역 상태관리 및 리다이렉트 로직 작성
console.log(data);
};
Expand Down Expand Up @@ -56,7 +51,7 @@ function UserAuthenticatePage() {
<VerificationButton
isVerificationRequested={isVerificationRequested}
isSubmitting={isSubmitting}
requestCode={handleSubmit(requestVerificationCode)}
requestCode={handleSubmit(() => requestVerificationCode(watch('email')))}
expireVerificationCode={expireVerificationCode}
buttonLabel="확인"
/>
Expand Down
29 changes: 21 additions & 8 deletions src/pages/user/SearchIdPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@ import { useForm, FormProvider } from 'react-hook-form';
import Spinner from '@components/common/Spinner';
import SearchResultSection from '@components/user/auth-form/SearchResultSection';
import SearchDataForm from '@components/user/auth-form/SearchDataForm';
import useEmailVerification from '@hooks/useEmailVerification';
import useToast from '@hooks/useToast';
import AuthFormLayout from '@layouts/AuthFormLayout';
import { searchUserId } from '@services/authService';
import { generateSecureUserId } from '@utils/converter';
import useEmailVerification from '@hooks/useEmailVerification';
import { EmailVerificationForm } from '@/types/UserType';

export default function SearchIdPage() {
const { verifyCode } = useEmailVerification();
const [searchIdResult, setSearchIdResult] = useState<null | string>(null);
const [loading, setLoading] = useState(false);
const { isVerificationRequested, requestVerificationCode, expireVerificationCode } = useEmailVerification();
const { toastError } = useToast();
const methods = useForm<EmailVerificationForm>({
mode: 'onChange',
Expand All @@ -23,20 +23,33 @@ export default function SearchIdPage() {
code: '',
},
});
const { watch, handleSubmit, setError } = methods;
const { handleSubmit, setError, setValue } = methods;

const emailVerificationProps = {
isVerificationRequested,
requestVerificationCode,
expireVerificationCode,
};

// ToDo: useAxios 훅 적용 후 해당 함수 수정 및 삭제하기
const handleVerificationError = () => {
setError('code', {
type: 'manual',
message: '인증번호가 일치하지 않습니다.',
});
setValue('code', '');
};

// ToDo: useAxios 훅을 이용한 네트워크 로직으로 변경
const onSubmit = async (data: EmailVerificationForm) => {
const verifyResult = verifyCode(watch('code'), setError);
if (!verifyResult) return;

setLoading(true);
try {
const fetchData = await searchUserId(data);
setSearchIdResult(fetchData.data.username);
} catch (error) {
if (error instanceof AxiosError && error.response) {
toastError(error.response.data.message);
if (error.response.status === 401) handleVerificationError();
else toastError(error.response.data.message);
} else {
toastError('예상치 못한 에러가 발생했습니다.');
}
Expand All @@ -54,7 +67,7 @@ export default function SearchIdPage() {
<SearchResultSection label="아이디" result={generateSecureUserId(searchIdResult)} />
)}

{!loading && !searchIdResult && <SearchDataForm formType="searchId" />}
{!loading && !searchIdResult && <SearchDataForm formType="searchId" {...emailVerificationProps} />}
</AuthFormLayout>
</FormProvider>
);
Expand Down
Loading

0 comments on commit 1efb5f5

Please sign in to comment.