Skip to content

Commit

Permalink
[feat] 구글 oauth 로그인 기능 구현 (#66)
Browse files Browse the repository at this point in the history
* [feat] Login UI 구현

* [feat] ContentLayout UI 구현
- 로그인 화면, 온보딩과 같은 싱글 컨텐츠 레이아웃 UI 구현

* [feat] LoginPage UI 구현

* [feat] signin api 기능 및 msw 구현

* [feat] google oauth 로그인 후 헤더 UI 구현

* [feat] recoil 관련 storybook 설정

* [feat] 커먼성 UserInfo UI 구현
- User 의 정보를 나타내는 UserInfo 컴포넌트 구현

* [feat] 로그인 성공후 유저 정보를 클릭 시 나타나는 UserMenu UI 구현

* [feat] User UI 클릭 시 유저메뉴 보이는 기능 구현

* [feat] recoil localstorage effect 기능 구현
- 로그인 성공시 해당 user 정보를 localstorage 에 저장하여 영구적으로 사용할 수 있도록 설정

* [feat] google oauth redirect 기능 구현

* [fix] 🐛 nextjs Hydration failed error 수정
- recoil effect 로 localstorage 에 user 값이 있냐 없냐에 따라 로그인 상태 여부를 판단하는데 해당 기능을 추가할 때 Hydration failed 에러 발생
=> 유추한 결과 window undefined 를 판단하는데 이때 초기 렌더링 UI 와 달라 발생하는 거 같음
- 해당 URL 참고하여 수정 https://stackoverflow.com/questions/71706064/react-18-hydration-failed-because-the-initial-ui-does-not-match-what-was-render/71797054#71797054

* [feat] api 없는 로그아웃 기능 구현

* [fix] 🐛 recoil 0.7.6  Duplicate atom key error 수정
- atomKey 가 고유한데 해당 이슈 발생해서 찾아보니 recoil 0.7.6 이슈 같음
https://stackoverflow.com/questions/65506656/recoil-duplicate-atom-key-in-nextjs
facebookexperimental/Recoil#733 (comment)

* [refactor] type 수정

* [feat] env-sample 에 NEXT_PUBLIC_GOOGLE_CLIENT_ID 형식 추가

* [chore] chromatic 배포를 위한 환경설정 값 추가

* [chore] 실수로 올린 .env 파일 제거

* [chore] 실수로 변경한 config 설정 수정

* [refactor] 코드리뷰 적용
- auth Type 수정
- resetUser 네이밍 수정
- UserInfo 컴포넌트 default type 수정
- 불필요한 코드 제거

* [refactor] login page 구조 변경
  • Loading branch information
KIMSEUNGGYU committed Jan 29, 2023
1 parent 96aabdb commit 403b016
Show file tree
Hide file tree
Showing 36 changed files with 596 additions and 10 deletions.
4 changes: 3 additions & 1 deletion .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ API_PORT=3000
API_VERSION=v1

API_URL=http://$API_HOSTNAME:$API_PORT/api/$API_VERSION
NEXT_PUBLIC_API_URL=$API_URL
NEXT_PUBLIC_API_URL=$API_URL

NEXT_PUBLIC_GOOGLE_CLIENT_ID=
3 changes: 2 additions & 1 deletion .github/workflows/chromatic.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,14 @@ jobs:
echo "API_VERSION=$API_VERSION" >> .env
echo "API_URL=$API_URL" >> .env
echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL" >> .env
echo "NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID" >> .env
env:
API_HOSTNAME: ${{ secrets.API_HOSTNAME }}
API_PORT: ${{ secrets.API_PORT }}
API_VERSION: ${{ secrets.API_VERSION }}
API_URL: ${{ secrets.API_URL }}
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}

NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${{ secrets.NEXT_PUBLIC_GOOGLE_CLIENT_ID }}

- name: Publish to Chromatic
uses: chromaui/action@v1
Expand Down
9 changes: 6 additions & 3 deletions .storybook/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { initialize, mswDecorator } from 'msw-storybook-addon';
import { RouterContext } from 'next/dist/shared/lib/router-context';
import * as NextImage from 'next/image';
import { RecoilRoot } from 'recoil';

import '@src/styles/reset.css';

Expand All @@ -16,9 +17,11 @@ initAxiosConfig();
export const decorators = [
mswDecorator,
(Story) => (
<QueryClientProvider client={new QueryClient()}>
<Story />
</QueryClientProvider>
<RecoilRoot>
<QueryClientProvider client={new QueryClient()}>
<Story />
</QueryClientProvider>
</RecoilRoot>
),
];

Expand Down
30 changes: 30 additions & 0 deletions __mocks__/apis/handlers/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ResponseComposition, RestContext, RestRequest, rest } from 'msw';

import { PostSigninResponse } from '@src/apis/auth';
import { BASE_URL } from '@src/configs/axios';

const NEW_MEMBER = false;
const MEMBER = true;

const AUTH_DATA = {
isMember: MEMBER,
jwtTokens: {
accessToken: 'access-token',
refreshToken: 'refresh-token',
},
};

const signin = (req: RestRequest, res: ResponseComposition, ctx: RestContext) => {
return res(
ctx.status(201),
ctx.json<PostSigninResponse>({
code: 'SUCCESS',
message: '성공',
data: AUTH_DATA,
}),
);
};

export const authHandler = [
rest.post(`${BASE_URL}/auth/signin`, signin), //
];
4 changes: 3 additions & 1 deletion __mocks__/apis/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { authHandler } from './auth';
import { commentHandler } from './comment';
import { topicDetailHandler } from './topic';

export const handlers = [
...topicDetailHandler, //
...authHandler, //
...topicDetailHandler,
...commentHandler,
];
25 changes: 25 additions & 0 deletions src/apis/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import axios from 'axios';

import { BaseResponse } from '.';

export interface Auth {
isMember: boolean;
jwtTokens: {
accessToken: string;
refreshToken: string;
};
}

/**
* 로그인
*/
export type PostSigninResponse = BaseResponse<Auth>;
export const signin = async (authCode: string) => {
const res = await axios.post<PostSigninResponse>(`/auth/signin`, null, {
headers: {
'auth-code': authCode,
},
});

return res.data.data;
};
1 change: 1 addition & 0 deletions src/apis/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './topic';
export * from './comment';
export * from './auth';

export interface BaseResponse<T> {
message: string;
Expand Down
6 changes: 6 additions & 0 deletions src/assets/icon/google_logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/icon/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export { ReactComponent as Paint } from './emoji_paint.svg';
export { ReactComponent as Paper } from './emoji_paper.svg';
export { ReactComponent as Smile } from './emoji_smile.svg';
export { ReactComponent as ThumbsUp } from './emoji_thumbs_up.svg';
export { ReactComponent as GoogleLogo } from './google_logo.svg';
export { ReactComponent as Image } from './image.svg';
export { ReactComponent as Logo } from './logo.svg';
export { ReactComponent as More } from './more.svg';
Expand Down
22 changes: 22 additions & 0 deletions src/components/common/Header/Header.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { RecoilRoot } from 'recoil';

import $userSession from '@src/recoil/userSession';

import Header from '.';

Expand All @@ -12,3 +15,22 @@ const Template: ComponentStory<typeof Header> = (args) => <Header {...args} />;

export const 기본 = Template.bind({});
기본.args = {};

export const 로그인 = Template.bind({});
로그인.decorators = [
(Story) => (
<RecoilRoot
initializeState={({ set }) => {
set($userSession, {
isMember: true,
jwtTokens: {
accessToken: 'access-token',
refreshToken: 'refresh-token',
},
});
}}
>
<Story />
</RecoilRoot>
),
];
15 changes: 14 additions & 1 deletion src/components/common/Header/Header.style.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ export const HeaderWrapper = styled.header`
z-index: 1;
height: 5rem;
color: ${theme.color.Primary1};
background-color: ${theme.color.G1};
border-bottom: 1px solid ${theme.color.Primary1};
`;

export const HeaderContents = styled.div`
position: relative;
margin: 0 auto;
max-width: 1200px;
display: flex;
Expand All @@ -43,4 +43,17 @@ export const Menu = styled.li`
font-weight: 700;
font-size: 15px;
line-height: 150%;
text-decoration: none;
color: ${theme.color.Primary1};
`;

export const UserInfoWrapper = styled.div`
cursor: pointer;
`;

export const UserMenuWrapper = styled.div`
position: absolute;
top: 70px;
right: 0;
`;
35 changes: 32 additions & 3 deletions src/components/common/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
import Link from 'next/link';
import { FC } from 'react';
import { FC, useState } from 'react';
import { useRecoilValue, useResetRecoilState } from 'recoil';

import Icon from '@src/components/common/Icon';
import UserInfo from '@src/components/common/UserInfo';
import $userSession from '@src/recoil/userSession';

import * as S from './Header.style';
import UserMenu from './UserMenu';

const Header: FC = () => {
const userSession = useRecoilValue($userSession);
const resetUser = useResetRecoilState($userSession);
const [viewUserMenu, setViewUserMenu] = useState(false);

const isLogin = !!userSession;

const handleLogout = () => {
resetUser();
setViewUserMenu(false);
};

// TODO-GYU: backend 와 논의 후 user 정보를 어떻게 받아올지 처리
return (
<S.HeaderWrapper>
<S.HeaderContents>
Expand All @@ -14,10 +30,23 @@ const Header: FC = () => {
</Link>
<S.Menus>
<S.Menu>
<Icon name="Search" size={35} />
<Icon name="Search" size={30} />
</S.Menu>
<S.Menu>로그인</S.Menu>
{isLogin ? (
<S.UserInfoWrapper onClick={() => setViewUserMenu((prev) => !prev)}>
<UserInfo type="simple" />
</S.UserInfoWrapper>
) : (
<Link href="/login">
<S.Menu>로그인</S.Menu>
</Link>
)}
</S.Menus>
{viewUserMenu && (
<S.UserMenuWrapper>
<UserMenu onLogout={handleLogout} />
</S.UserMenuWrapper>
)}
</S.HeaderContents>
</S.HeaderWrapper>
);
Expand Down
14 changes: 14 additions & 0 deletions src/components/common/Header/UserMenu/UserMenu.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';

import UserMenu from './UserMenu';

export default {
title: 'common/UserMenu',
component: UserMenu,
args: {},
} as ComponentMeta<typeof UserMenu>;

const Template: ComponentStory<typeof UserMenu> = ({ ...args }) => <UserMenu {...args} />;

export const Default = Template.bind({});
Default.args = {};
45 changes: 45 additions & 0 deletions src/components/common/Header/UserMenu/UserMenu.styles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import styled from '@emotion/styled';

import theme from '@src/styles/theme';

export const Wrapper = styled.div`
display: flex;
flex-direction: column;
width: 227px;
height: 182px;
box-shadow: 0px 4px 30px 5px rgba(0, 0, 0, 0.15);
border-radius: 8px;
`;

export const UserItem = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 16px;
height: 76px;
border-radius: 8px 8px 0 0;
background: ${theme.color.G4};
&:hover {
background: ${theme.color.G5};
cursor: pointer;
}
`;

export const MenuItem = styled.div`
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 16px;
height: 53px;
background: ${theme.color.G4};
&:hover {
background: ${theme.color.G5};
cursor: pointer;
}
&:last-child {
border-radius: 0 0 8px 8px;
}
`;
26 changes: 26 additions & 0 deletions src/components/common/Header/UserMenu/UserMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React, { FC } from 'react';

import Icon from '@src/components/common/Icon';
import UserInfo from '@src/components/common/UserInfo';

import * as S from './UserMenu.styles';

interface Props {
onLogout: () => void;
}
const UserMenu: FC<Props> = (props) => {
const { onLogout } = props;

return (
<S.Wrapper>
<S.UserItem>
<UserInfo />
<Icon name="ArrowRight" size={24} />
</S.UserItem>
<S.MenuItem>문의하기</S.MenuItem>
<S.MenuItem onClick={onLogout}>로그아웃</S.MenuItem>
</S.Wrapper>
);
};

export default UserMenu;
1 change: 1 addition & 0 deletions src/components/common/Header/UserMenu/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './UserMenu';
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ComponentStory } from '@storybook/react';
import React from 'react';

import SingleContentLayout from '.';

export default {
component: SingleContentLayout,
title: 'common/SingleContentLayout',
};

const Template: ComponentStory<typeof SingleContentLayout> = (args) => <SingleContentLayout {...args} />;

export const Default = Template.bind({});
Default.args = {
children: '컨텐츠',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import styled from '@emotion/styled';

export const Wrapper = styled.div`
display: flex;
justify-content: center;
margin-top: 206px;
`;
18 changes: 18 additions & 0 deletions src/components/common/SingleContentLayout/SingleContentLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React, { FC, PropsWithChildren } from 'react';

import Header from '@src/components/common/Header';

import * as S from './SingleContentLayout.styles';

const ContentLayout: FC<PropsWithChildren> = (props) => {
const { children } = props;

return (
<>
<Header />
<S.Wrapper>{children}</S.Wrapper>
</>
);
};

export default ContentLayout;
1 change: 1 addition & 0 deletions src/components/common/SingleContentLayout/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './SingleContentLayout';
Loading

0 comments on commit 403b016

Please sign in to comment.