Skip to content

Commit

Permalink
feat: 인증 관련 기능 구현 마무리 (#34)
Browse files Browse the repository at this point in the history
* feat: 인증 관련 기능 구현 마무리

- access token 만료 시, refresh token을 통해 access token을 새로 발급받고, 이전 요청을 다시 요청하도록 구현

- refresh token이 유효하지 않을 경우, 자동으로 로그아웃되도록 구현

- 로그인된 상태라면 로그인 페이지에서 자동으로 홈 페이지로 리다이렉트되도록 구현

* feat: useLogout 구현

* refactor: @boolti/api의 상수 export 형태 변경

* feat: 로그아웃 기능 적용

* refactor: useShowDetail을 queries 디렉토리로 이동

* refactor: ky의 afterResponse에 try catch 구문 추가
  • Loading branch information
Puterism authored Feb 3, 2024
1 parent f5d95cc commit a405d55
Show file tree
Hide file tree
Showing 17 changed files with 350 additions and 45 deletions.
94 changes: 69 additions & 25 deletions apps/admin/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { QueryClientProvider } from '@boolti/api';
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
import { LOCAL_STORAGE, QueryClientProvider } from '@boolti/api';
import { createBrowserRouter, RouterProvider, Navigate, Outlet } from 'react-router-dom';
import { BooltiUIProvider } from '@boolti/ui';
import LoginPage from './pages/Login/LoginPage';
import SignUpCompletePage from './pages/SignUpComplete/SignUpCompletePage';
Expand All @@ -9,34 +9,78 @@ import OAuthApplePage from './pages/OAuth/OAuthApplePage';
import 'the-new-css-reset/css/reset.css';
import './index.css';
import { PATH } from './constants/routes';
import AuthErrorBoundary from './components/ErrorBoundary/AuthErrorBoundary';

const router = createBrowserRouter([
{
path: PATH.INDEX,
element: <Navigate to={PATH.LOGIN} replace />, // Note: 이후 랜딩 페이지로 교체 필요
},
{
path: PATH.LOGIN,
element: <LoginPage />,
},
{
path: PATH.OAUTH_KAKAO,
element: <OAuthKakaoPage />,
},
{
path: PATH.OAUTH_APPLE,
element: <OAuthApplePage />,
},
const PublicRoute = () => {
const isLogin =
window.localStorage.getItem(LOCAL_STORAGE.ACCESS_TOKEN) &&
window.localStorage.getItem(LOCAL_STORAGE.REFRESH_TOKEN);

if (isLogin) {
return <Navigate to={PATH.HOME} replace />;
}

return <Outlet />;
};

const publicRoutes = [
{
path: PATH.SIGNUP_COMPLETE,
element: <SignUpCompletePage />,
element: <PublicRoute />,
children: [
{
path: PATH.INDEX,
element: <Navigate to={PATH.LOGIN} replace />, // Note: 이후 랜딩 페이지로 교체 필요
},
{
path: PATH.LOGIN,
element: <LoginPage />,
},
{
path: PATH.OAUTH_KAKAO,
element: <OAuthKakaoPage />,
},
{
path: PATH.OAUTH_APPLE,
element: <OAuthApplePage />,
},
{
path: '*',
element: <Navigate to={PATH.INDEX} replace />,
},
],
},
{ path: PATH.HOME, element: <HomePage /> },
];

const PrivateRoute = () => {
const isLogin =
window.localStorage.getItem(LOCAL_STORAGE.ACCESS_TOKEN) &&
window.localStorage.getItem(LOCAL_STORAGE.REFRESH_TOKEN);

if (!isLogin) {
return <Navigate to={PATH.LOGIN} replace />;
}

return <Outlet />;
};

const privateRoutes = [
{
path: '*',
element: <Navigate to={PATH.INDEX} replace />,
element: (
<AuthErrorBoundary>
<PrivateRoute />
</AuthErrorBoundary>
),
children: [
{
path: PATH.SIGNUP_COMPLETE,
element: <SignUpCompletePage />,
},
{ path: PATH.HOME, element: <HomePage /> },
],
},
]);
];

const router = createBrowserRouter([...publicRoutes, ...privateRoutes]);

const App = () => {
return (
Expand Down
47 changes: 47 additions & 0 deletions apps/admin/src/components/ErrorBoundary/AuthErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { BooltiHTTPError, LOCAL_STORAGE } from '@boolti/api';
import React from 'react';
import { Navigate } from 'react-router-dom';
import { PATH } from '../../constants/routes';

interface AuthErrorBoundaryProps {
children?: React.ReactNode;
}

interface AuthErrorBoundaryState {
status: BooltiHTTPError['status'] | null;
}

const initialState: AuthErrorBoundaryState = {
status: null,
};

class AuthErrorBoundary extends React.Component<AuthErrorBoundaryProps, AuthErrorBoundaryState> {
public state: AuthErrorBoundaryState = initialState;

public static getDerivedStateFromError(error: Error): AuthErrorBoundaryState {
if (error instanceof BooltiHTTPError) {
return {
status: error.status,
};
}

return {
status: null,
};
}

public render() {
if (this.state.status !== null) {
this.setState(initialState);

window.localStorage.removeItem(LOCAL_STORAGE.ACCESS_TOKEN);
window.localStorage.removeItem(LOCAL_STORAGE.REFRESH_TOKEN);

return <Navigate to={PATH.LOGIN} replace />;
}

return this.props.children;
}
}

export default AuthErrorBoundary;
22 changes: 19 additions & 3 deletions apps/admin/src/pages/HomePage/HomePage.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,35 @@
import Header from '~/components/Header/Header';
import Layout from '~/components/Layout/Layout';
import { PATH } from '~/constants/routes';
import Styled from './HomePage.styles';
import { Footer } from '@boolti/ui';
import { Footer, TextButton } from '@boolti/ui';
import UserProfile from '~/components/UserProfile';
import AccountInfo from '~/components/AccountInfo';
import ShowList from '~/components/ShowList';
import { useLogout } from '@boolti/api';
import { useNavigate } from 'react-router-dom';
import { PATH } from '~/constants/routes';

const HomePage = () => {
const navigate = useNavigate();

const logout = useLogout();

return (
<Layout
header={
<Header
left={<Styled.Logo>Boolti Logo</Styled.Logo>}
right={<Styled.LogoutLink to={PATH.LOGIN}>로그아웃</Styled.LogoutLink>}
right={
<TextButton
onClick={async () => {
await logout.mutateAsync();

navigate(PATH.LOGIN, { replace: true });
}}
>
로그아웃
</TextButton>
}
/>
}
>
Expand Down
9 changes: 7 additions & 2 deletions apps/admin/src/pages/OAuth/OAuthKakaoPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { useKakaoLogin, useKakaoToken, useKakaoUserInfo, useSignUp } from '@boolti/api';
import {
LOCAL_STORAGE,
useKakaoLogin,
useKakaoToken,
useKakaoUserInfo,
useSignUp,
} from '@boolti/api';
import { useEffect, useCallback, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import LOCAL_STORAGE from '~/constants/localStorage';
import { PATH } from '~/constants/routes';

const OAuthKakaoPage = () => {
Expand Down
32 changes: 32 additions & 0 deletions packages/api/src/BooltiHTTPError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { HTTPError, NormalizedOptions } from 'ky';
import { ERROR_CODE } from './constants';

interface BooltiHTTPErrorOptions {
errorTraceId: string;
type: keyof typeof ERROR_CODE;
detail: string;
}

class BooltiHTTPError extends HTTPError {
public errorTraceId?: string;
public type?: keyof typeof ERROR_CODE;
public detail?: string;
public status: number;

constructor(
response: Response,
request: Request,
options: NormalizedOptions,
customOptions?: BooltiHTTPErrorOptions,
) {
super(response, request, options);

this.name = 'BooltiHTTPError';
this.errorTraceId = customOptions?.errorTraceId;
this.type = customOptions?.type;
this.detail = customOptions?.detail;
this.status = response.status;
}
}

export default BooltiHTTPError;
19 changes: 18 additions & 1 deletion packages/api/src/QueryClientProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,26 @@
import { QueryClientProvider as BaseQueryClientProvider, QueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import BooltiHTTPError from './BooltiHTTPError';

export function QueryClientProvider({ children }: React.PropsWithChildren) {
const [queryClient] = useState(() => new QueryClient());
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
useErrorBoundary: (error) => {
// 인증 관련 에러일 때만 ErrorBoundary를 사용한다.
return (
error instanceof BooltiHTTPError && (error.status === 401 || error.status === 403)
);
},
},
},
}),
);

return (
<BaseQueryClientProvider client={queryClient}>
{children}
Expand Down
14 changes: 14 additions & 0 deletions packages/api/src/constants/errorCode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const ERROR_CODE = {
UNKNOWN: {
type: 'UNKNOWN',
status: 500,
},
INVALID_OAUTH_TOKEN: {
type: 'INVALID_OAUTH_TOKEN',
status: 400,
},
TOKEN_REFRESH_FAILED: {
type: 'TOKEN_REFRESH_FAILED',
status: 400,
},
};
4 changes: 4 additions & 0 deletions packages/api/src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { ERROR_CODE } from './errorCode';
import { LOCAL_STORAGE } from './localStorage';

export { ERROR_CODE, LOCAL_STORAGE };
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
const LOCAL_STORAGE = {
export const LOCAL_STORAGE = {
ACCESS_TOKEN: 'accessToken',
REFRESH_TOKEN: 'refreshToken',
};

export default LOCAL_STORAGE;
72 changes: 66 additions & 6 deletions packages/api/src/fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,88 @@
import ky, { Options, ResponsePromise } from 'ky';
import BooltiHTTPError from './BooltiHTTPError';
import { LOCAL_STORAGE } from './constants';

// TODO 환경 변수로 API 베이스 설정
const API_URL = 'https://dev.api.boolti.in';

interface PostRefreshTokenResponse {
accessToken: string;
refreshToken: string;
}

const postRefreshToken = async () => {
const response = await ky.post(`${API_URL}/web/papi/v1/login/refresh`, {
json: {
refreshToken: window.localStorage.getItem(LOCAL_STORAGE.REFRESH_TOKEN),
},
});

return await response.json<PostRefreshTokenResponse>();
};

export const instance = ky.create({
prefixUrl: API_URL,
headers: {
'content-type': 'application/json',
},
hooks: {
// TODO 인증 관련 헤더 검증 로직 추가
beforeRequest: [],
// TODO 서버 에러 처리
beforeError: [],
beforeRequest: [
(request) => {
const accessToken = window.localStorage.getItem(LOCAL_STORAGE.ACCESS_TOKEN);

if (accessToken) {
request.headers.set('Authorization', `Bearer ${accessToken}`);
}
},
],
afterResponse: [
async (request, options, response) => {
// access token이 만료되었을 때, refresh token으로 새로운 access token을 발급받는다.
if (!response.ok && response.status === 401) {
try {
const { accessToken, refreshToken } = await postRefreshToken();

window.localStorage.setItem(LOCAL_STORAGE.ACCESS_TOKEN, accessToken);
window.localStorage.setItem(LOCAL_STORAGE.REFRESH_TOKEN, refreshToken);

request.headers.set('Authorization', `Bearer ${accessToken}`);

return ky(request);
} catch (error) {
throw new BooltiHTTPError(response, request, options);
}
}

try {
if (!response.ok && response.bodyUsed) {
const body = await response.json();

throw new BooltiHTTPError(response, request, options, {
errorTraceId: body.errorTraceId,
type: body.type,
detail: body.detail,
});
}
} catch (error) {
throw new BooltiHTTPError(response, request, options);
}

if (!response.ok) {
throw new BooltiHTTPError(response, request, options);
}
},
],
},
retry: 0,
});

export async function resultify<T>(response: ResponsePromise) {
try {
// TODO 바디가 없는 경우 어떻게 할지 논의 필요
return await response.json<T>();
} catch (e) {
} catch (error) {
console.error('[fetcher.ts] resultify에서 JSON 파싱을 하는 도중 오류 발생');
throw e;
throw error;
}
}

Expand Down
Loading

0 comments on commit a405d55

Please sign in to comment.