From 47a450b0223b78e0c872b2c34c8701dc34cb17ee Mon Sep 17 00:00:00 2001 From: Dongja Date: Fri, 24 Nov 2023 15:48:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EC=A0=84=EC=97=AD=EC=97=90=20=EC=A0=80=EC=9E=A5=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20(#172)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(shelter): zustand로 authstore 생성, axiosInterceptor에 토큰 추가하는 로직 추가 * feat(shared): access token 갱신 로직 추가 * feat(shelter): accessToken hook 추가, index에 들어갔을 때 volunteers로 리다이렉트 * fix(shared): import error 해결 * fix(shared): 오타 수정 * chore(shared): react query 의존성 추가 * feat(shelter): 어플 처음 접속했을 시 로그인 상태 확인하는 기능추가 * feat(common): 로그인 했을때 유저 정보 스토어에 저장하는 로직 추가 * feat(shared): 어플 접속시 깜빡이는 현상 해결 * feat(shared): 주석 제거 및 보호소 어플일때만 리다이렉트 하도록 실행 * feat(volunteer): withLogin 컴포넌트 추가 * feat(volunteer): 권한이 있는 페이지 WithLogin으로 보호 * fix(common): 보호소 앱에 중복된 훅 제거, 봉사자 앱에 accessToken 다시 받는 mock api 추가, shard layout의 주석 제거 * rename(volunteer): withLogin 파일 이름 수정 --- apps/shelter/src/mocks/handlers/auth.ts | 11 +++++++ .../shelter/src/mocks/handlers/recruitment.ts | 11 +++---- apps/shelter/src/pages/signin/index.tsx | 6 +++- apps/volunteer/src/components/WithLogin.tsx | 13 ++++++++ apps/volunteer/src/mocks/handlers/auth.ts | 11 +++++++ .../src/mocks/handlers/recruitment.ts | 11 +++---- apps/volunteer/src/pages/signin/index.tsx | 6 +++- apps/volunteer/src/routes/index.tsx | 31 ++++++++++++++++--- packages/shared/apis/axiosInterceptor.ts | 10 +++++- packages/shared/apis/common/AccessToken.ts | 5 +++ .../shared/hooks/useAccessTokenMutation.tsx | 24 ++++++++++++++ packages/shared/layout/index.tsx | 28 ++++++++++++++++- packages/shared/package.json | 1 + packages/shared/store/authStore.ts | 23 ++++++++++++++ packages/shared/types/apis/auth.ts | 2 +- pnpm-lock.yaml | 3 ++ 16 files changed, 174 insertions(+), 22 deletions(-) create mode 100644 apps/volunteer/src/components/WithLogin.tsx create mode 100644 packages/shared/apis/common/AccessToken.ts create mode 100644 packages/shared/hooks/useAccessTokenMutation.tsx create mode 100644 packages/shared/store/authStore.ts diff --git a/apps/shelter/src/mocks/handlers/auth.ts b/apps/shelter/src/mocks/handlers/auth.ts index 4af2723a..a8580232 100644 --- a/apps/shelter/src/mocks/handlers/auth.ts +++ b/apps/shelter/src/mocks/handlers/auth.ts @@ -79,4 +79,15 @@ export const handlers = [ await delay(200); return HttpResponse.json({}, { status: 200 }); }), + http.post('/auth/refresh', async () => { + await delay(500); + return HttpResponse.json( + { + accessToken: 'access token', + userId: 1, + role: 'role', + }, + { status: 200 }, + ); + }), ]; diff --git a/apps/shelter/src/mocks/handlers/recruitment.ts b/apps/shelter/src/mocks/handlers/recruitment.ts index 98219ebe..9f1c39bd 100644 --- a/apps/shelter/src/mocks/handlers/recruitment.ts +++ b/apps/shelter/src/mocks/handlers/recruitment.ts @@ -11,11 +11,6 @@ const DUMMY_RECRUITMENT = { recruitmentCapacity: 15, }; -const DUMMY_RECRUITMENT_LIST = Array.from( - { length: 4 }, - () => DUMMY_RECRUITMENT, -); - export const handlers = [ http.get('/shelters/recruitments', async () => { await delay(1000); @@ -25,7 +20,11 @@ export const handlers = [ totalElements: 100, hasNext: true, }, - recruitments: DUMMY_RECRUITMENT_LIST, + recruitments: Array.from({ length: 4 }, () => ({ + ...DUMMY_RECRUITMENT, + recruitmentId: Math.random(), + shelterId: Math.random(), + })), }, { status: 200 }, ); diff --git a/apps/shelter/src/pages/signin/index.tsx b/apps/shelter/src/pages/signin/index.tsx index 6daef48e..be57d2f1 100644 --- a/apps/shelter/src/pages/signin/index.tsx +++ b/apps/shelter/src/pages/signin/index.tsx @@ -22,6 +22,7 @@ import AnimalfriendsLogo from 'shared/assets/image-anifriends-logo.png'; import IoEyeOff from 'shared/assets/IoEyeOff'; import IoEyeSharp from 'shared/assets/IoEyeSharp'; import useToggle from 'shared/hooks/useToggle'; +import useAuthStore from 'shared/store/authStore'; import { SigninRequestData } from 'shared/types/apis/auth'; import * as z from 'zod'; @@ -42,6 +43,7 @@ export default function SigninPage() { const navigate = useNavigate(); const toast = useToast(); const [isShow, toggleInputShow] = useToggle(); + const { setUser } = useAuthStore(); const { register, handleSubmit, @@ -52,10 +54,12 @@ export default function SigninPage() { }); const { mutate } = useMutation({ mutationFn: (data: SigninRequestData) => signinShelter(data), - onSuccess: () => { + onSuccess: ({ data: { userId, accessToken } }) => { + setUser({ userId, accessToken }); navigate(`/${PATH.VOLUNTEERS.INDEX}`); }, onError: (error) => { + setUser(null); toast({ position: 'top', description: error.response?.data.message, diff --git a/apps/volunteer/src/components/WithLogin.tsx b/apps/volunteer/src/components/WithLogin.tsx new file mode 100644 index 00000000..1cf3b81e --- /dev/null +++ b/apps/volunteer/src/components/WithLogin.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from 'react'; +import { Navigate } from 'react-router-dom'; +import useAuthStore from 'shared/store/authStore'; + +export default function WithLogin({ children }: { children: ReactNode }) { + const { user } = useAuthStore(); + + if (user) { + children; + } + + return ; +} diff --git a/apps/volunteer/src/mocks/handlers/auth.ts b/apps/volunteer/src/mocks/handlers/auth.ts index bbd4f59d..e3c18bba 100644 --- a/apps/volunteer/src/mocks/handlers/auth.ts +++ b/apps/volunteer/src/mocks/handlers/auth.ts @@ -69,4 +69,15 @@ export const handlers = [ await delay(200); return HttpResponse.json({}, { status: 200 }); }), + http.post('/auth/refresh', async () => { + await delay(500); + return HttpResponse.json( + { + accessToken: 'access token', + userId: 1, + role: 'role', + }, + { status: 200 }, + ); + }), ]; diff --git a/apps/volunteer/src/mocks/handlers/recruitment.ts b/apps/volunteer/src/mocks/handlers/recruitment.ts index 3dbe76c1..4fd66ca9 100644 --- a/apps/volunteer/src/mocks/handlers/recruitment.ts +++ b/apps/volunteer/src/mocks/handlers/recruitment.ts @@ -13,11 +13,6 @@ const DUMMY_RECRUITMENT = { shelterImageUrl: 'https://source.unsplash.com/random', }; -const DUMMY_RECRUITMENT_LIST = Array.from( - { length: 4 }, - () => DUMMY_RECRUITMENT, -); - export const handlers = [ http.get('/recruitments', async () => { await delay(1000); @@ -27,7 +22,11 @@ export const handlers = [ totalElements: 100, hasNext: true, }, - recruitments: DUMMY_RECRUITMENT_LIST, + recruitments: Array.from({ length: 4 }, () => ({ + ...DUMMY_RECRUITMENT, + recruitmentId: Math.random(), + shelterId: Math.random(), + })), }, { status: 200 }, ); diff --git a/apps/volunteer/src/pages/signin/index.tsx b/apps/volunteer/src/pages/signin/index.tsx index a2593332..c32a5e39 100644 --- a/apps/volunteer/src/pages/signin/index.tsx +++ b/apps/volunteer/src/pages/signin/index.tsx @@ -22,6 +22,7 @@ import AnimalfriendsLogo from 'shared/assets/image-anifriends-logo.png'; import IoEyeOff from 'shared/assets/IoEyeOff'; import IoEyeSharp from 'shared/assets/IoEyeSharp'; import useToggle from 'shared/hooks/useToggle'; +import useAuthStore from 'shared/store/authStore'; import { SigninRequestData } from 'shared/types/apis/auth'; import * as z from 'zod'; @@ -42,6 +43,7 @@ export default function SigninPage() { const navigate = useNavigate(); const toast = useToast(); const [isShow, toggleInputShow] = useToggle(); + const { setUser } = useAuthStore(); const { register, handleSubmit, @@ -52,10 +54,12 @@ export default function SigninPage() { }); const { mutate } = useMutation({ mutationFn: (data: SigninRequestData) => signinVolunteer(data), - onSuccess: () => { + onSuccess: ({ data: { userId, accessToken } }) => { + setUser({ userId, accessToken }); navigate(`/${PATH.VOLUNTEERS.INDEX}`); }, onError: (error) => { + setUser(null); toast({ position: 'top', description: error.response?.data.message, diff --git a/apps/volunteer/src/routes/index.tsx b/apps/volunteer/src/routes/index.tsx index 5ec6d84e..5e3d3f18 100644 --- a/apps/volunteer/src/routes/index.tsx +++ b/apps/volunteer/src/routes/index.tsx @@ -3,6 +3,7 @@ import APP_TYPE from 'shared/constants/appType'; import PAGE_TYPE from 'shared/constants/pageType'; import Layout from 'shared/layout'; +import WithLogin from '@/components/WithLogin'; import PATH from '@/constants/path'; import AnimalsPage from '@/pages/animals'; import AnimalsDetailPage from '@/pages/animals/detail'; @@ -82,7 +83,11 @@ export const router: RouterProviderProps['router'] = createBrowserRouter([ { id: PAGE_TYPE.MYPAGE, path: PATH.MYPAGE.INDEX, - element: , + element: ( + + + + ), }, { path: PATH.SETTINGS.INDEX, @@ -91,12 +96,20 @@ export const router: RouterProviderProps['router'] = createBrowserRouter([ { id: PAGE_TYPE.SETTINGS_ACCOUNT, path: PATH.SETTINGS.ACCOUNT, - element: , + element: ( + + + + ), }, { id: PAGE_TYPE.SETTINGS_PASSWORD, path: PATH.SETTINGS.PASSWORD, - element: , + element: ( + + + + ), }, ], }, @@ -111,12 +124,20 @@ export const router: RouterProviderProps['router'] = createBrowserRouter([ { id: PAGE_TYPE.SHELTERS_REVIEWS_WRITE, path: PATH.SHELTERS.REVIEWS_WRITE, - element: , + element: ( + + + + ), }, { id: PAGE_TYPE.SHELTERS_REVIEWS_UPDATE, path: PATH.SHELTERS.REVIEWS_UPDATE, - element: , + element: ( + + + + ), }, ], }, diff --git a/packages/shared/apis/axiosInterceptor.ts b/packages/shared/apis/axiosInterceptor.ts index ce4e3ef7..968284cd 100644 --- a/packages/shared/apis/axiosInterceptor.ts +++ b/packages/shared/apis/axiosInterceptor.ts @@ -1,6 +1,14 @@ import { AxiosResponse, InternalAxiosRequestConfig } from 'axios'; -export const onRequest = (config: InternalAxiosRequestConfig) => config; +import useAuthStore from '../store/authStore'; + +const getAccessToken = () => + `bearer ${useAuthStore.getState().user?.accessToken}`; + +export const onRequest = (config: InternalAxiosRequestConfig) => { + config.headers.Authorization = getAccessToken(); + return config; +}; export const onErrorRequest = (error: Error) => { return Promise.reject(error); diff --git a/packages/shared/apis/common/AccessToken.ts b/packages/shared/apis/common/AccessToken.ts new file mode 100644 index 00000000..ffe563b3 --- /dev/null +++ b/packages/shared/apis/common/AccessToken.ts @@ -0,0 +1,5 @@ +import type { SigninResponseData } from '../../types/apis/auth'; +import axiosInstance from '../axiosInstance'; + +export const getAccessTokenAPI = () => + axiosInstance.post('/auth/refresh'); diff --git a/packages/shared/hooks/useAccessTokenMutation.tsx b/packages/shared/hooks/useAccessTokenMutation.tsx new file mode 100644 index 00000000..4c5cb87e --- /dev/null +++ b/packages/shared/hooks/useAccessTokenMutation.tsx @@ -0,0 +1,24 @@ +import { useMutation } from '@tanstack/react-query'; + +import { getAccessTokenAPI } from '../apis/common/AccessToken'; +import useAuthStore from '../store/authStore'; + +export default function useAccessTokenMutation() { + const { setUser } = useAuthStore(); + return useMutation({ + mutationFn: async () => { + const { data } = await getAccessTokenAPI(); + return data; + }, + onSuccess: ({ accessToken, userId }) => { + setUser({ + accessToken, + userId, + }); + }, + onError: (error) => { + console.warn(error); + setUser(null); + }, + }); +} diff --git a/packages/shared/layout/index.tsx b/packages/shared/layout/index.tsx index 02d7a7f5..518c96a6 100644 --- a/packages/shared/layout/index.tsx +++ b/packages/shared/layout/index.tsx @@ -1,6 +1,8 @@ import { Box, Container } from '@chakra-ui/react'; -import { Outlet } from 'react-router-dom'; +import { useEffect } from 'react'; +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; +import useAccessTokenMutation from '../hooks/useAccessTokenMutation'; import { AppType } from '../types/app'; import BottomNavBar from './BottomNavBar'; import Header from './Header'; @@ -10,6 +12,30 @@ type LayoutProps = { }; export default function Layout({ appType }: LayoutProps) { + const navigate = useNavigate(); + const { pathname } = useLocation(); + const { mutate, isPending } = useAccessTokenMutation(); + + useEffect(() => { + mutate(undefined, { + onSuccess: () => { + if (appType === 'SHELTER_APP' && pathname === '/') { + navigate('/volunteers'); + } + }, + onError: (error) => { + console.warn(error); + if (appType === 'SHELTER_APP') { + navigate('/signin'); + } + }, + }); + }, [mutate]); + + if (isPending) { + return

...로딩중

; + } + return (
diff --git a/packages/shared/package.json b/packages/shared/package.json index 488ccc79..2a0479f6 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -11,6 +11,7 @@ "@chakra-ui/react": "^2.8.1", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", + "@tanstack/react-query": "^5.4.3", "axios": "^1.6.0", "framer-motion": "^10.16.4", "react": "^18.2.0", diff --git a/packages/shared/store/authStore.ts b/packages/shared/store/authStore.ts new file mode 100644 index 00000000..e3382deb --- /dev/null +++ b/packages/shared/store/authStore.ts @@ -0,0 +1,23 @@ +import { create } from 'zustand'; + +type User = { + accessToken: string; + userId: number; +}; + +interface AuthState { + user: User | null; +} + +interface AuthActions { + setUser: (user: User | null) => void; +} + +const useAuthStore = create((set) => ({ + user: null, + setUser: (user: User | null) => { + set({ user }); + }, +})); + +export default useAuthStore; diff --git a/packages/shared/types/apis/auth.ts b/packages/shared/types/apis/auth.ts index 680c6059..2a9724e8 100644 --- a/packages/shared/types/apis/auth.ts +++ b/packages/shared/types/apis/auth.ts @@ -13,7 +13,7 @@ export type ChangePasswordRequestData = { export type SigninResponseData = { accessToken: string; - useId: number; + userId: number; role: string; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f1d6e2f..bdf9173a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,6 +260,9 @@ importers: '@emotion/styled': specifier: ^11.11.0 version: 11.11.0(@emotion/react@11.11.1)(@types/react@18.2.33)(react@18.2.0) + '@tanstack/react-query': + specifier: ^5.4.3 + version: 5.4.3(react-dom@18.2.0)(react@18.2.0) axios: specifier: ^1.6.0 version: 1.6.0