Skip to content

Commit

Permalink
�[FE] issue81: 로그인, 로그아웃 구현 (#93)
Browse files Browse the repository at this point in the history
* feat: 로그인, 로그아웃 커스텀 훅 구현

* feat: 토큰 조회 모킹 서버 구현

* feat: LoginRedirectPage 컴포넌트 구현

* feat: 로그인 리다이렉트 페이지 라우터 적용

* chore: 클라이언트 아이디 환경변수 설정

* feat: login, logout 기능 구현
  • Loading branch information
nan-noo authored Jul 20, 2022
1 parent af69fd9 commit 856a216
Show file tree
Hide file tree
Showing 20 changed files with 205 additions and 17 deletions.
1 change: 1 addition & 0 deletions frontend/.prettierrc.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/.storybook/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ module.exports = {
'@context': resolve(__dirname, '../src/context'),
'@detail-page': resolve(__dirname, '../src/pages/detail-page'),
'@layout': resolve(__dirname, '../src/layout'),
'@hooks': resolve(__dirname, '../src/hooks'),
};

config.module.rules[0].use[0].options.presets = [
Expand Down
15 changes: 9 additions & 6 deletions frontend/.storybook/preview.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { BrowserRouter } from 'react-router-dom';

import { ThemeProvider } from '@emotion/react';

Expand All @@ -20,12 +21,14 @@ export const parameters = {
export const decorators = [
(Story, context) => {
return (
<ThemeProvider theme={theme}>
<SearchProvider>
<GlobalStyles />
<Story {...context} />
</SearchProvider>
</ThemeProvider>
<BrowserRouter>
<ThemeProvider theme={theme}>
<SearchProvider>
<GlobalStyles />
<Story {...context} />
</SearchProvider>
</ThemeProvider>
</BrowserRouter>
);
},
];
1 change: 1 addition & 0 deletions frontend/env/.env.local
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
API_URL=""
CLIENT_ID="cb83d95cd5644436b090"
10 changes: 9 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { useContext } from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';

import { css } from '@emotion/react';

import { LoginContext } from '@context/login/LoginProvider';

import Footer from '@layout/footer/Footer';
import Header from '@layout/header/Header';

import LoginRedirectPage from '@pages/login-redirect-page/LoginRedirectPage';
import MainPage from '@pages/main-page/MainPage';

import DetailPage from '@detail-page/DetailPage';

const App = () => {
const { isLoggedIn } = useContext(LoginContext);

return (
<BrowserRouter>
<Header
Expand All @@ -30,6 +36,8 @@ const App = () => {
<Routes>
<Route path="/" element={<MainPage />} />
<Route path="/study/:studyId" element={<DetailPage />} />
<Route path="/login" element={isLoggedIn ? <Navigate to="/" replace={true} /> : <LoginRedirectPage />} />
<Route path="*" element={<div>에러 페이지</div>} />
</Routes>
</main>
<Footer />
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/api/getAccessToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import axiosInstance from '@api/axiosInstance';

export const getAccessToken = async (code: string) => {
const response = await axiosInstance.get(`/api/login/token?code=${code}`);
return response.data;
};
24 changes: 24 additions & 0 deletions frontend/src/context/login/LoginProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { ReactNode, createContext, useState } from 'react';

import noop from '@utils/noop';

interface LoginProviderProps {
children: ReactNode;
}

interface ContextType {
isLoggedIn: boolean;
setIsLoggedIn: React.Dispatch<React.SetStateAction<boolean>>;
}

const hasAccessToken = !!window.localStorage.getItem('accessToken');

export const LoginContext = createContext<ContextType>({
isLoggedIn: false,
setIsLoggedIn: noop,
});

export const LoginProvider = ({ children }: LoginProviderProps) => {
const [isLoggedIn, setIsLoggedIn] = useState(hasAccessToken);
return <LoginContext.Provider value={{ isLoggedIn, setIsLoggedIn }}>{children}</LoginContext.Provider>;
};
1 change: 1 addition & 0 deletions frontend/src/custom-types/common.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ declare module '*.json';
declare namespace NodeJS {
export interface ProcessEnv {
API_URL: string;
CLIENT_ID: string;
}
}
4 changes: 4 additions & 0 deletions frontend/src/custom-types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,7 @@ export type Filter = {
export type FilterListQueryData = {
filters: Array<Filter>;
};

export type TokenQueryData = {
token: string;
};
19 changes: 19 additions & 0 deletions frontend/src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useContext } from 'react';

import { LoginContext } from '@context/login/LoginProvider';

export const useAuth = () => {
const { setIsLoggedIn } = useContext(LoginContext);

const login = (accesssToken: string) => {
localStorage.setItem('accessToken', accesssToken);
setIsLoggedIn(true);
};

const logout = () => {
localStorage.removeItem('accessToken');
setIsLoggedIn(false);
};

return { login, logout };
};
11 changes: 7 additions & 4 deletions frontend/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ThemeProvider } from '@emotion/react';
import GlobalStyles from '@styles/Globalstyles';
import { theme } from '@styles/theme';

import { LoginProvider } from '@context/login/LoginProvider';
import { SearchProvider } from '@context/search/SearchProvider';

import App from './App';
Expand All @@ -29,10 +30,12 @@ if ($root) {
root.render(
<ThemeProvider theme={theme}>
<QueryClientProvider client={queryClient}>
<SearchProvider>
<GlobalStyles />
<App />
</SearchProvider>
<LoginProvider>
<SearchProvider>
<GlobalStyles />
<App />
</SearchProvider>
</LoginProvider>
</QueryClientProvider>
</ThemeProvider>,
);
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/layout/header/Header.style.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,30 @@ export const Row = styled.header`
}
`}
`;

export const Nav = styled.nav`
display: flex;
`;

export const NavButton = styled.button`
${({ theme }) => css`
display: flex;
justify-content: center;
align-items: center;
column-gap: 4px;
padding: 8px 4px;
color: ${theme.colors.primary.base};
border: none;
background-color: transparent;
&:hover {
border-bottom: 1px solid ${theme.colors.secondary.base};
}
& > svg {
fill: ${theme.colors.primary.base};
}
`}
`;
32 changes: 27 additions & 5 deletions frontend/src/layout/header/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { useContext } from 'react';
import { MdOutlineLogin, MdOutlineLogout } from 'react-icons/md';

import { useAuth } from '@hooks/useAuth';

import { LoginContext } from '@context/login/LoginProvider';
import { SearchContext } from '@context/search/SearchProvider';

import * as S from '@layout/header/Header.style';
Expand All @@ -13,8 +17,11 @@ type HeaderProps = {
};

const Header: React.FC<HeaderProps> = ({ className }) => {
const { isLoggedIn } = useContext(LoginContext);
const { setKeyword } = useContext(SearchContext);

const { logout } = useAuth();

const handleKeywordSubmit = (e: React.FormEvent<HTMLFormElement>, inputName: string) => {
e.preventDefault();
const value = (e.target as any)[inputName].value;
Expand All @@ -33,11 +40,26 @@ const Header: React.FC<HeaderProps> = ({ className }) => {
<S.SearchBarContainer>
<SearchBar onSubmit={handleKeywordSubmit} />
</S.SearchBarContainer>
<Avatar
// TODO: Context에서 정보를 가져온다
profileImg="https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1770&q=80"
profileAlt="프로필 이미지"
/>
{isLoggedIn ? (
<S.Nav>
<S.NavButton onClick={logout}>
<MdOutlineLogout />
<span>로그아웃</span>
</S.NavButton>
<Avatar
// TODO: Context에서 정보를 가져온다
profileImg="https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1770&q=80"
profileAlt="프로필 이미지"
/>
</S.Nav>
) : (
<a href={`https://github.com/login/oauth/authorize?client_id=${process.env.CLIENT_ID}`}>
<S.NavButton>
<MdOutlineLogin />
<span>로그인</span>
</S.NavButton>
</a>
)}
</S.Row>
);
};
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/mocks/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { rest } from 'msw';

import detailStudyHandlers from './detail-study-handlers';
import { filterHandlers } from './filterHandlers';
import studyJSON from './studies.json';
import { tokenHandlers } from './tokenHandlers';

export const handlers = [
rest.get('/api/studies', (req, res, ctx) => {
Expand Down Expand Up @@ -85,4 +87,6 @@ export const handlers = [
);
}),
...detailStudyHandlers,
...filterHandlers,
...tokenHandlers,
];
12 changes: 12 additions & 0 deletions frontend/src/mocks/tokenHandlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { rest } from 'msw';

export const tokenHandlers = [
rest.get('/api/login/token', (req, res, ctx) => {
const code = req.url.searchParams.get('code');

if (!code) {
return res(ctx.status(400), ctx.json({ message: '잘못된 요청입니다.' }));
}
return res(ctx.status(200), ctx.json({ token: 'asddfasdfassdf' }));
}),
];
48 changes: 48 additions & 0 deletions frontend/src/pages/login-redirect-page/LoginRedirectPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useQuery } from 'react-query';
import { Navigate, useSearchParams } from 'react-router-dom';

import type { TokenQueryData } from '@custom-types/index';

import { getAccessToken } from '@api/getAccessToken';

import { useAuth } from '@hooks/useAuth';

import Wrapper from '@components/wrapper/Wrapper';

const LoginRedirectPage: React.FC = () => {
const [searchParams] = useSearchParams();
const codeParam = searchParams.get('code') as string;
const { login } = useAuth();

const { data, isSuccess, isError, error } = useQuery<TokenQueryData, Error>(
['redirect', codeParam],
() => getAccessToken(codeParam),
{
enabled: !!codeParam,
cacheTime: 0,
},
);

if (!codeParam) {
alert('잘못된 접근입니다.');
return <Navigate to="/" replace={true} />;
}

if (isSuccess) {
login(data.token);
return <Navigate to="/" replace={true} />;
}

if (isError) {
alert(error.message);
return <Navigate to="/" replace={true} />;
}

return (
<Wrapper>
<div>로그인 진행 중입니다...</div>
</Wrapper>
);
};

export default LoginRedirectPage;
3 changes: 2 additions & 1 deletion frontend/tsconfig.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/webpack/webpack.common.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ module.exports = {
'@main-page': resolve(__dirname, '../src/pages/main-page'),
'@detail-page': resolve(__dirname, '../src/pages/detail-page'),
'@layout': resolve(__dirname, '../src/layout'),
'@hooks': resolve(__dirname, '../src/hooks'),
},
},
};
1 change: 1 addition & 0 deletions frontend/webpack/webpack.dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ module.exports = merge(common, {
plugins: [
new webpack.DefinePlugin({
'process.env.API_URL': JSON.stringify(process.env.API_URL),
'process.env.CLIENT_ID': JSON.stringify(process.env.CLIENT_ID),
}),
],
});
1 change: 1 addition & 0 deletions frontend/webpack/webpack.prod.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ module.exports = merge(common, {
plugins: [
new webpack.DefinePlugin({
'process.env.API_URL': JSON.stringify(process.env.API_URL),
'process.env.CLIENT_ID': JSON.stringify(process.env.CLIENT_ID),
}),
],
});

0 comments on commit 856a216

Please sign in to comment.