From 1a9573a5eb91bc21d22bc00178cbf89f0493376c Mon Sep 17 00:00:00 2001 From: chaerim kim <89721027+chaaerim@users.noreply.github.com> Date: Sun, 5 Mar 2023 14:33:15 +0900 Subject: [PATCH] feat: login (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 로그인, 회원가입 레이아웃 및 로그인 로직 구현 * feat: axios 에러 커스텀 * chore: 주석 추가 * feat: axios request interceptors 추가 --- src/apis/client.ts | 170 +++++++++++++++++--- src/apis/index.ts | 25 ++- src/hooks/form/useCheckLoginForm.ts | 61 +++++++ src/hooks/form/useCheckSignUpForm.tsx | 52 +++--- src/pages/index.tsx | 4 +- src/pages/login/index.tsx | 49 +++++- src/pages/signup/index.tsx | 91 ++++++----- src/pages/signup/password/[email]/index.tsx | 76 +++++---- 8 files changed, 394 insertions(+), 134 deletions(-) create mode 100644 src/hooks/form/useCheckLoginForm.ts diff --git a/src/apis/client.ts b/src/apis/client.ts index 8492955..3e37d3e 100644 --- a/src/apis/client.ts +++ b/src/apis/client.ts @@ -4,41 +4,159 @@ import axios from 'axios'; const DEV_SERVER_URL = process.env.NEXT_PUBLIC_DEVELOPMENT_SERVER_URL; const PROD_SERVER_URL = process.env.NEXT_PUBLIC_PRODUCTION_SERVER_URL; -const axiosClient = axios.create({ +export const authToken = { + access: (() => { + try { + return localStorage.getItem('accessToken'); + } catch (err) { + return null; + } + })(), + refresh: (() => { + try { + return localStorage.getItem('refreshToken'); + } catch (err) { + return null; + } + })(), +}; + +export const axiosClient = axios.create({ baseURL: process.env.NODE_ENV === 'development' ? DEV_SERVER_URL : PROD_SERVER_URL, + headers: { + Authorization: `Bearer ${authToken.access}`, + 'Content-Type': 'application/json; charset=utf-8', + }, }); -const interceptorResponseFulfilled = (res: AxiosResponse) => { - if (200 <= res.status && res.status < 300) { - return res.data; +//에러 커스텀 로직 +// effective-interview 에러 타입 +export interface EffErrorResponse { + code: number; + message: string; +} + +// 커스텀 에러 타입 +export interface EffError extends AxiosError { + response: AxiosResponse; + isEffError: true; + message: string; + code: string; + status: number; + errorCode: number; +} + +// effective-interview에서 정의한 에러인지 확인 +export function isEffError(error: unknown): error is EffError { + try { + return (error as EffError).isEffError === true; + } catch { + return false; } +} - return Promise.reject(res.data); -}; +//axios 에러인지 확인 +function isAxiosErrorWithResponseData( + error: unknown +): error is AxiosError & { response: AxiosResponse } { + try { + return axios.isAxiosError(error) && error.response?.data != null; + } catch { + return false; + } +} -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const interceptorResponseRejected = (error: AxiosError) => { - return Promise.reject(new Error(error.response?.data?.message ?? error)); -}; +//axios에러를 커스텀한 에러로 변환 +function createEffErrorFromAxiosError(error: AxiosError): EffErrorResponse | AxiosError { + if (isAxiosErrorWithResponseData(error)) { + const effError = error as EffError; -axiosClient.interceptors.response.use(interceptorResponseFulfilled, interceptorResponseRejected); + effError.isEffError = true; + effError.message = effError.response.data.message ?? ''; + effError.errorCode = effError.response.data.code; + } -export const get = (...args: Parameters) => { - return axiosClient.get(...args); -}; + return error; +} -export const post = (...args: Parameters) => { - return axiosClient.post(...args); -}; +// 매 응답마다 axios error를 커스텀한 에러로 변환 +axiosClient.interceptors.response.use( + response => response, + originalError => + Promise.reject( + isAxiosErrorWithResponseData(originalError) + ? createEffErrorFromAxiosError(originalError) + : originalError + ) +); -export const put = (...args: Parameters) => { - return axiosClient.put(...args); -}; +// 1. 요청 인터셉터 +axiosClient.interceptors.request.use( + function (config) { + if (config?.headers == null) { + throw new Error(`config.header is undefined`); + } + config.headers['Content-Type'] = 'application/json; charset=utf-8'; + config.headers['Authorization'] = authToken.access; -export const patch = (...args: Parameters) => { - return axiosClient.patch(...args); -}; + return config; + }, + function (error) { + return Promise.reject(error); + } +); -export const del = (...args: Parameters) => { - return axiosClient.delete(...args); -}; +// 2. 응답 인터셉터 +axiosClient.interceptors.response.use( + response => response.data, + async function (error: EffError) { + if (isEffError(error) && error.errorCode === 401) { + //refresh 토큰 처리 로직 추가 + // redirectToLoginPage(); + // redirect가 완료되고, API가 종료될 수 있도록 delay를 추가합니다. + await delay(500); + return null; + } else { + throw error; + } + } +); + +function delay(time: number) { + return new Promise(res => setTimeout(res, time)); +} + +// const interceptorResponseFulfilled = (res: AxiosResponse) => { +// if (200 <= res.status && res.status < 300) { +// return res.data; +// } + +// return Promise.reject(res.data); +// }; + +// // eslint-disable-next-line @typescript-eslint/no-explicit-any +// const interceptorResponseRejected = (error: AxiosError) => { +// return Promise.reject(new Error(error.response?.data?.message ?? error)); +// }; + +// axiosClient.interceptors.response.use(interceptorResponseFulfilled, interceptorResponseRejected); + +// export const get = (...args: Parameters) => { +// return axiosClient.get(...args); +// }; + +// // export const post = (...args: Parameters) => { +// // return axiosClient.post(...args); +// // }; + +// export const put = (...args: Parameters) => { +// return axiosClient.put(...args); +// }; + +// export const patch = (...args: Parameters) => { +// return axiosClient.patch(...args); +// }; + +// export const del = (...args: Parameters) => { +// return axiosClient.delete(...args); +// }; diff --git a/src/apis/index.ts b/src/apis/index.ts index a0a8f71..37d9241 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -1,17 +1,26 @@ -import axios from 'axios'; - -const axiosClient = axios.create({ - baseURL: 'http://fteam-env-1.eba-ciibaeid.ap-northeast-2.elasticbeanstalk.com/api', -}); +import { axiosClient } from './client'; +export type LoginResponse = { + memberId: number; + accessToken: string; + refreshToken: string; +}; +//signup and login apis export async function postEmail(email: string) { - return await axiosClient.post('/v1/auth/email/send', { email }); + return await axiosClient.post('/auth/email/send', { email }); } export async function postEmailAndCode(email: string, verificationCode: number) { - return await axiosClient.post('/v1/auth/email/authenticate', { email, verificationCode }); + return await axiosClient.post('/auth/email/authenticate', { email, verificationCode }); } export async function postSignUp(email: string, password: string, confirmPassword: string) { - return await axiosClient.post('/v1/signup', { email, password, confirmPassword }); + return await axiosClient.post('/signup', { email, password, confirmPassword }); +} + +export async function postLogin(email: string, password: string) { + const { + data: { memberId, accessToken, refreshToken }, + } = await axiosClient.post('/auth/login', { email, password }); + return { memberId, accessToken, refreshToken }; } diff --git a/src/hooks/form/useCheckLoginForm.ts b/src/hooks/form/useCheckLoginForm.ts new file mode 100644 index 0000000..deb9b50 --- /dev/null +++ b/src/hooks/form/useCheckLoginForm.ts @@ -0,0 +1,61 @@ +import { useCallback } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { useForm } from 'react-hook-form'; + +import { postLogin } from '~/apis'; +import { isEffError } from '~/apis/client'; +import { emailPattern, passwordPattern } from '~/constants/validationPattern'; + +interface CheckLoginForm { + email: string; + password: string; +} + +export const useCheckLoginForm = () => { + const { + register, + getValues, + formState: { isDirty, isValid }, + } = useForm({ mode: 'onBlur' }); + + const isEmailPattern = useCallback(() => { + return { + value: emailPattern, + message: '올바른 이메일을 입력해주세요', + }; + }, []); + + const isPasswordPattern = useCallback(() => { + return { + value: passwordPattern, + message: '숫자와 한글만 입력해주세요.', + }; + }, []); + + const isDisabled = !isDirty || !isValid; + + const isRequiredText = useCallback( + (text: string) => (text === '비밀번호' ? `${text}를 입력해주세요.` : `${text}을 입력해주세요.`), + [] + ); + + const { mutate: loginMutation } = useMutation(async () => { + const { email, password } = getValues(); + try { + const data = await postLogin(email, password); + if (data) { + const { accessToken, refreshToken } = data; + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken); + } + // 로컬스토리지에 제대로 aT, rT 들어갔으면 카테고리 선택 페이지로 라우팅 + } catch (error: unknown) { + if (isEffError(error)) { + // TODO: toast 추가 + console.log(error.message, error.errorCode); + } + } + }); + + return { register, isDisabled, isEmailPattern, isPasswordPattern, loginMutation, isRequiredText }; +}; diff --git a/src/hooks/form/useCheckSignUpForm.tsx b/src/hooks/form/useCheckSignUpForm.tsx index 3788cb6..a002a14 100644 --- a/src/hooks/form/useCheckSignUpForm.tsx +++ b/src/hooks/form/useCheckSignUpForm.tsx @@ -4,6 +4,7 @@ import { useMutation } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { postEmail, postEmailAndCode, postSignUp } from '~/apis'; +import { isEffError } from '~/apis/client'; import { ConfirmModal } from '~/components/common/ConfirmModal'; import { emailPattern, passwordPattern } from '~/constants/validationPattern'; @@ -30,7 +31,10 @@ export const useCheckSignUpForm = () => { const isDisabled = !isDirty || !isValid; //validation check - const isRequiredText = useCallback((text: string) => `${text}을 입력해주세요.`, []); + const isRequiredText = useCallback( + (text: string) => (text === '비밀번호' ? `${text}를 입력해주세요.` : `${text}을 입력해주세요.`), + [] + ); const isMinLength = useCallback((minLength: number) => { return { @@ -56,44 +60,50 @@ export const useCheckSignUpForm = () => { const isPasswordPattern = useCallback(() => { return { value: passwordPattern, - message: '숫자와 한글만 입력해주세요.', + message: '숫자와 영문만 입력해주세요.', }; }, []); // submit - const { mutate: createVerificationCode } = useMutation(async () => { + const { mutate: createVerificationCodeMutation } = useMutation(async () => { const { email } = getValues(); try { postEmail(email); } catch (error: unknown) { - console.log(error); + if (isEffError(error)) { + // TODO: toast 추가 + console.log(error.message, error.errorCode); + } } }); - const { mutate: checkVerificationCode } = useMutation(async () => { + const { mutate: checkVerificationCodeMutation } = useMutation(async () => { const { email, verificationCode } = getValues(); try { - const res = postEmailAndCode(email, verificationCode); - if (await res) { - router.push(`/signup/password/${email}`); - } + await postEmailAndCode(email, verificationCode); + router.push(`/signup/password/${email}`); } catch (error: unknown) { - console.log(error); + if (isEffError(error)) { + // TODO: toast 추가 + console.log(error.message, error.errorCode); + } } }); - const { mutate: completeSignUp } = useMutation(async (email: string) => { + const { mutate: completeSignUpMutation } = useMutation(async (email: string) => { const { password, confirmPassword } = getValues(); try { - const res = postSignUp(email, password, confirmPassword); - if (await res) { - await openModal({ - children: , - }); - } + await postSignUp(email, password, confirmPassword); + + await openModal({ + children: , + }); } catch (error: unknown) { - console.log(error); + if (isEffError(error)) { + // TODO: toast 추가 + console.log(error.message, error.errorCode); + } } }); @@ -103,9 +113,9 @@ export const useCheckSignUpForm = () => { setError, isDisabled, getValues, - createVerificationCode, - checkVerificationCode, - completeSignUp, + createVerificationCodeMutation, + checkVerificationCodeMutation, + completeSignUpMutation, errors, isRequiredText, isMinLength, diff --git a/src/pages/index.tsx b/src/pages/index.tsx index ef8cfaf..ba7cff9 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -22,7 +22,9 @@ export default function Home() { {/* 메인 화면 이미지 */} - + ); } diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index db73bef..a4de689 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -1,23 +1,60 @@ +import { useRouter } from 'next/router'; import { Flex, Spacing } from '@toss/emotion-utils'; import Button from '~/components/common/Button'; +import { Header } from '~/components/common/Header'; import { Input } from '~/components/common/Input'; import Text from '~/components/common/Text'; +import { useCheckLoginForm } from '~/hooks/form/useCheckLoginForm'; export default function Login() { + const router = useRouter(); + + const { isDisabled, register, isEmailPattern, isPasswordPattern, loginMutation, isRequiredText } = + useCheckLoginForm(); + const onClick = () => { + router.push('/signup'); + }; return ( - // 헤더 자리 <> -
- +
+ + { + e.preventDefault(); + loginMutation(); + }} + > + - + - + - + 회원가입 비밀번호 찾기 diff --git a/src/pages/signup/index.tsx b/src/pages/signup/index.tsx index 3155da0..e355af2 100644 --- a/src/pages/signup/index.tsx +++ b/src/pages/signup/index.tsx @@ -1,7 +1,9 @@ import { Spacing } from '@toss/emotion-utils'; import Button from '~/components/common/Button'; +import { Header } from '~/components/common/Header'; import { Input } from '~/components/common/Input'; +import Text from '~/components/common/Text'; import { useCheckSignUpForm } from '~/hooks/form/useCheckSignUpForm'; export default function SignUp() { @@ -9,54 +11,65 @@ export default function SignUp() { register, isDisabled, errors, - createVerificationCode, - checkVerificationCode, + createVerificationCodeMutation, + checkVerificationCodeMutation, isRequiredText, isEmailPattern, } = useCheckSignUpForm(); const sendCode = (e: React.SyntheticEvent) => { e.preventDefault(); - createVerificationCode(); + createVerificationCodeMutation(); }; return ( -
{ - e.preventDefault(); - checkVerificationCode(); - }} - > - sendCode(e)} - width={20} - height={3} - backgroundColor={'gray000'} - color={'gray800'} - > - 인증코드 전송 - - } - /> - - + <> +
- - +
{ + e.preventDefault(); + checkVerificationCodeMutation(); + }} + > + sendCode(e)} + width={10} + height={3.1} + variant="smallButton" + > + 인증코드 전송 + + } + /> + + + + + + + 인증번호가 오지 않나요? + + + 스팸메일함을 확인해주세요. 스팸메일함에도 없다면 다시한번 ‘인증번호 전송’을 눌러주세요. + + ); } diff --git a/src/pages/signup/password/[email]/index.tsx b/src/pages/signup/password/[email]/index.tsx index bb063a9..4c19895 100644 --- a/src/pages/signup/password/[email]/index.tsx +++ b/src/pages/signup/password/[email]/index.tsx @@ -1,6 +1,7 @@ import { Spacing } from '@toss/emotion-utils'; import Button from '~/components/common/Button'; +import { Header } from '~/components/common/Header'; import { Input } from '~/components/common/Input'; import { useCheckSignUpForm } from '~/hooks/form/useCheckSignUpForm'; @@ -20,46 +21,55 @@ export async function getServerSideProps({ params }: { params: IParams }) { export default function Password({ email }: { email: string }) { const { register, - completeSignUp, + completeSignUpMutation, isPasswordPattern, isMinLength, getValues, isMaxLength, + isRequiredText, errors, + isDisabled, } = useCheckSignUpForm(); return ( -
{ - e.preventDefault(); - completeSignUp(email); - }} - > - - - { - const { password } = getValues(); - return password === value || '비밀번호가 일치하지 않습니다. '; + <> +
+ { + e.preventDefault(); + completeSignUpMutation(email); + }} + > + + + { + const { password } = getValues(); + return password === value || '비밀번호가 일치하지 않습니다. '; + }, }, - }, - })} - /> - - - + })} + /> + + + + ); }