Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: #142 이메일 인증 기능 구현 #143

Merged
merged 8 commits into from
Sep 23, 2024
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';
Yoonyesol marked this conversation as resolved.
Show resolved Hide resolved

export const USER_INFO_DUMMY = {
provider: 'LOCAL',
userId: 1,
Expand Down
19 changes: 15 additions & 4 deletions src/mocks/services/authServiceHandler.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
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, UserSignInForm } from '@/types/UserType';
import { emailVerificationCode, USER_INFO_DUMMY } from '@mocks/mockData';
import { EmailVerificationForm, RequestEmailCode, SearchPasswordForm, 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,11 +112,22 @@ 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 },
Expand All @@ -136,7 +147,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
28 changes: 21 additions & 7 deletions src/pages/user/SearchIdPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,14 @@ 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 { toastError } = useToast();
Expand All @@ -23,20 +22,28 @@ export default function SearchIdPage() {
code: '',
},
});
const { watch, handleSubmit, setError } = methods;
const { handleSubmit, setError, setValue } = methods;
const { isVerificationRequested, requestVerificationCode, expireVerificationCode } = useEmailVerification();

// 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();
Yoonyesol marked this conversation as resolved.
Show resolved Hide resolved
} else {
toastError('예상치 못한 에러가 발생했습니다.');
}
Expand All @@ -54,7 +61,14 @@ export default function SearchIdPage() {
<SearchResultSection label="아이디" result={generateSecureUserId(searchIdResult)} />
)}

{!loading && !searchIdResult && <SearchDataForm formType="searchId" />}
{!loading && !searchIdResult && (
<SearchDataForm
formType="searchId"
isVerificationRequested={isVerificationRequested}
requestVerificationCode={requestVerificationCode}
expireVerificationCode={expireVerificationCode}
/>
)}
</AuthFormLayout>
</FormProvider>
);
Expand Down
28 changes: 21 additions & 7 deletions src/pages/user/SearchPasswordPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@ import { FormProvider, useForm } 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 AuthFormLayout from '@layouts/AuthFormLayout';
import { searchUserPassword } from '@services/authService';
import useToast from '@hooks/useToast';
import useEmailVerification from '@hooks/useEmailVerification';
import type { SearchPasswordForm } from '@/types/UserType';

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

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

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

setLoading(true);
try {
const fetchData = await searchUserPassword(data);
setTempPassword(fetchData.data.password);
} catch (error) {
if (error instanceof AxiosError && error.response) {
toastError(error.response.data.message);
if (error.response.status === 401) handleVerificationError();
Yoonyesol marked this conversation as resolved.
Show resolved Hide resolved
} else {
toastError('예상치 못한 에러가 발생했습니다.');
}
Expand All @@ -52,7 +59,14 @@ export default function SearchPasswordPage() {

{!loading && tempPassword && <SearchResultSection label="임시 비밀번호" result={tempPassword} />}

{!loading && !tempPassword && <SearchDataForm formType="searchPassword" />}
{!loading && !tempPassword && (
<SearchDataForm
formType="searchPassword"
isVerificationRequested={isVerificationRequested}
requestVerificationCode={requestVerificationCode}
expireVerificationCode={expireVerificationCode}
/>
Yoonyesol marked this conversation as resolved.
Show resolved Hide resolved
)}
</AuthFormLayout>
</FormProvider>
);
Expand Down
Loading