Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(login): 로그인 화면에서 로그인 기능을 구현하고 정상 로그인 시 홈 페이지로 이동되도록 합니다. #151

Merged
merged 16 commits into from
Oct 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
9a7c615
feat(app): CurrentUser 타입 추가
innocarpe Sep 23, 2023
9868ef6
feat(app): RegisterService 에서 RegisteredUserResponse 를 제거하고 CurrentUs…
innocarpe Sep 23, 2023
579b2f8
feat(app): LoginService 작성
innocarpe Sep 23, 2023
1284bbe
refactor(app): LoginForm.styled.ts 를 추가하고 LoginErrorMessage 이동
innocarpe Sep 23, 2023
9eb220e
refactor(app): StyledLoginButton 작성
innocarpe Sep 23, 2023
fdac338
refactor(app): 로그인과 관련된 처리를 전담할 useLogin 커스텀 훅 작성
innocarpe Sep 23, 2023
ac6d053
feat(login): 로그인 폼에서 props 로 로그인 관련 추가 정보를 받아 상태를 변경하도록 함
innocarpe Sep 23, 2023
30cfad8
feat(login): 로그인 폼에서 로그인을 시도할 때 로그인 페이지 컨테이너에서 실제로 로그인을 진행하도록 함
innocarpe Sep 23, 2023
6002116
feat(login): 로그인에 성공하면 홈 화면으로 이동하도록 함
innocarpe Sep 23, 2023
0c3a6cf
feat(login): 로그인에 실패할 때 폼 영역에서 에러 내용을 표시하도록 함
innocarpe Sep 23, 2023
66ba3ef
refactor(login): 로그인에 필요한 정보를 UserCredentials 타입으로 선언
innocarpe Oct 5, 2023
7f5a0e3
refactor(login): 기존 코드를 UserCredentials 를 사용하도록 리팩토링
innocarpe Oct 5, 2023
dae1e01
fix(login): Path alias 변경으로 인한 코드 이슈 해소
innocarpe Oct 5, 2023
884d2ee
fix(app): Path alias 미반영 부분 모두 적용
innocarpe Oct 5, 2023
d1261dc
feat(login): LoginButtonProps 를 ButtonHTMLAttributes 를 상속하도록 변경
innocarpe Oct 5, 2023
ea1387e
feat(login): useLogin 을 State Reducer Pattern 을 활용하도록 구조 개선
innocarpe Oct 5, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/react-world/src/apis/login/LoginService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { isAxiosError } from 'axios';
import { api } from '../apiInstances';
import type { LoginUserParams, LoginUserResponse } from './LoginService.types';

class LoginService {
static async loginUser(
userData: LoginUserParams,
): Promise<LoginUserResponse> {
try {
const response = await api.post('/users/login', {
user: userData,
});
return response.data;
} catch (error) {
if (isAxiosError(error) && error.response) {
console.error('Axios error occurred:', error.response.data);
throw error.response.data;
}
console.error('An unexpected error occurred:', error);
throw error;
}
}
}

export default LoginService;
16 changes: 16 additions & 0 deletions apps/react-world/src/apis/login/LoginService.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// LoginService.types.ts

import type CurrentUser from '@/app-types/CurrentUser';

export interface LoginUserParams {
email: string;
password: string;
}

export interface LoginUserErrors {
[key: string]: string[];
}

export interface LoginUserResponse {
user: CurrentUser & { jwtToken: string };
}
12 changes: 3 additions & 9 deletions apps/react-world/src/apis/register/RegisterService.types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type CurrentUser from '@/app-types/CurrentUser';

export interface RegisterUserParams {
username: string;
email: string;
Expand All @@ -8,14 +10,6 @@ export interface RegisterUserErrors {
[key: string]: string[];
}

export interface RegisteredUserResponse {
email: string;
token: string;
username: string;
bio: string | null;
image: string | null;
}

export interface RegisterUserResponse {
user: RegisteredUserResponse;
user: CurrentUser;
}
4 changes: 2 additions & 2 deletions apps/react-world/src/components/home/ArticlePreview.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ArticlePreviewData } from '../../apis/article/ArticlePreviewService.types';
import { formatDate } from '../../utils/dateUtils';
import type { ArticlePreviewData } from '@/apis/article/ArticlePreviewService.types';
import { formatDate } from '@/utils/dateUtils';
import {
ArticlePreviewContainer,
ArticlePreviewMeta,
Expand Down
10 changes: 5 additions & 5 deletions apps/react-world/src/components/home/HomeFeedContents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import ArticlePreview from './ArticlePreview';
import PopularArticleTagList from './PopularArticleTagList';
import HomeFeedTab from './HomeFeedTab';
import Pagination from './Pagination';
import ArticleService from '../../apis/article/ArticleService';
import useArticlePreviewQuery from '../../quries/useArticlePreviewQuery';
import usePopularArticleTagsQuery from '../../quries/usePopularArticleTagsQuery';
import { ARTICLE_PREVIEW_FETCH_LIMIT } from '../../apis/article/ArticlePreviewService';
import { ARTICLE_DETAIL_CACHE_KEY } from '../../quries/useArticleDetailQuery';
import ArticleService from '@/apis/article/ArticleService';
import useArticlePreviewQuery from '@/quries/useArticlePreviewQuery';
import usePopularArticleTagsQuery from '@/quries/usePopularArticleTagsQuery';
import { ARTICLE_PREVIEW_FETCH_LIMIT } from '@/apis/article/ArticlePreviewService';
import { ARTICLE_DETAIL_CACHE_KEY } from '@/quries/useArticleDetailQuery';

type StandardFeedType = 'my' | 'global';
type ArticleTagFeedType = string; // 아티클 태그는 어떤 문자열이든 가능
Expand Down
17 changes: 17 additions & 0 deletions apps/react-world/src/components/login/LoginForm.styled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import styled from '@emotion/styled';

export const LoginErrorMessage = styled.p`
color: red;
margin-top: 5px;
margin-left: 5px;
font-size: 14px;
`;

export const StyledLoginButton = styled.button<{ disabled: boolean }>`
background-color: ${props => (props.disabled ? '#bbb' : '#5cb85c')};
border-color: ${props => (props.disabled ? '#bbb' : '#5cb85c')};
color: white;

cursor: ${props => (props.disabled ? 'not-allowed' : 'pointer')};
padding: 10px 20px; // 기본 버튼 패딩
`;
57 changes: 40 additions & 17 deletions apps/react-world/src/components/login/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import styled from '@emotion/styled';
import type { LoginUserErrors } from '@/apis/login/LoginService.types';
import type { LoginStatus } from '@/hooks/useLogin';
import { useForm } from 'react-hook-form';
import { LoginErrorMessage, StyledLoginButton } from './LoginForm.styled';
import { type ReactNode } from 'react';
import type { UserCredentials } from '@/app-types/UserCredentials';

/*
- ^로 시작합니다.
Expand All @@ -12,24 +16,24 @@ import { useForm } from 'react-hook-form';
const EMAIL_REGEX = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i;

interface LoginFormProps {
onLoginSubmit: (data: { email: string; password: string }) => void;
loginError: LoginUserErrors | null;
loginStatus: LoginStatus;
onLoginSubmit: (data: UserCredentials) => void;
}

const LoginForm = (props: LoginFormProps) => {
const { onLoginSubmit } = props;
const { loginError, loginStatus, onLoginSubmit } = props;
const { register, handleSubmit, formState } = useForm<{
email: string;
password: string;
}>();
const { errors } = formState;

const onSubmit = (data: { email: string; password: string }) => {
console.log('onSubmit: ' + JSON.stringify(data, null, 2));
onLoginSubmit(data);
};
const shouldLoginButtonDisabled = loginStatus === 'loggingIn';
const buttonText = loginStatus === 'loggingIn' ? 'Signing in...' : 'Sign in';

return (
<form onSubmit={handleSubmit(onSubmit)}>
<form onSubmit={handleSubmit(onLoginSubmit)}>
<fieldset className="form-group">
<input
{...register('email', {
Expand Down Expand Up @@ -73,18 +77,37 @@ const LoginForm = (props: LoginFormProps) => {
<LoginErrorMessage>{errors.password.message}</LoginErrorMessage>
)}
</fieldset>
<button type="submit" className="btn btn-lg btn-primary pull-xs-right">
Sign in
</button>
{loginError && (
<LoginErrorMessage>
{Object.entries(loginError)
.map(([key, value]) => `${key} ${value[0]}`)
.join('. ')}
</LoginErrorMessage>
)}
<LoginButton disabled={shouldLoginButtonDisabled}>
{buttonText}
</LoginButton>
</form>
);
};

const LoginErrorMessage = styled.p`
color: red;
margin-top: 5px;
margin-left: 5px;
font-size: 14px;
`;
interface LoginButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children?: ReactNode;
}
const LoginButton = (props: LoginButtonProps) => {
const { disabled = false, children, ...restProps } = props;

return (
<StyledLoginButton
type="submit"
className={`btn btn-lg pull-xs-right`}
disabled={disabled}
{...restProps}
>
{children}
</StyledLoginButton>
);
};

export default LoginForm;
20 changes: 16 additions & 4 deletions apps/react-world/src/components/login/LoginPageContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import LoginForm from './LoginForm';
import LoginHeader from './LoginHeader';
import useLogin from '@/hooks/useLogin';

const LoginPageContainer = () => {
const handleLogin = (data: { email: string; password: string }) => {
console.log('handleLogin: ' + JSON.stringify(data, null, 2));
};
const { loginError, loginStatus, handleLogin } = useLogin();
const navigate = useNavigate();

useEffect(() => {
if (loginStatus === 'success') {
navigate('/'); // 홈페이지로 리다이렉트
}
}, [loginStatus, navigate]);
Comment on lines +8 to +15
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로그인 여부에 따라 리다이렉트라면 useLogin에서 하나의 함수로 묶어줘도 좋을 것 같아요. 동일한 관심사지 않나 싶습니다! handleValidAccess 라는 네이밍은 마음에 안 들지만, 이런 형태로 useLogin이 핸들링하는 것은 어떻게 생각하시나요?

Suggested change
const { loginError, loginStatus, handleLogin } = useLogin();
const navigate = useNavigate();
useEffect(() => {
if (loginStatus === 'success') {
navigate('/'); // 홈페이지로 리다이렉트
}
}, [loginStatus, navigate]);
const { loginError, loginStatus, handleValidAccess, handleLogin } = useLogin();
handleValidAccess();

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

음 이 부분은 코드 수정하면서 고민을 계속 해 보았는데, 제 선호이기는 하지만 저는 현재의 형태가 더 좋지 않을까 생각이 듭니다..! 아래 사항을 고려하였어요.

1. 책임 분리 원칙 (Separation of Concerns):

useLogin 훅을 최초 구현했던 의도는 로그인 기능에 대한 책임이었습니다. (이름도 이걸 반영하기도 했구요)
리다이렉트는 로그인 후의 화면 플로우(=네비게이션)와 관련된 부분이므로, 컴포넌트나 다른 훅에서 처리하는 것이 더 좋지 않을까 싶었어요.
그리고 추후에 로그인이 필요한 다른 페이지나 모달에서 useLogin 훅을 사용할 경우 리다이렉트 '/' 가 아닌 곳으로 처리해야 할 수도 있을 것 같기도 합니다.

2. 재사용성과 확장성:

추후에 로그인 로직이 변경되어도 리다이렉트와 관련된 코드는 영향을 받지 않도록 하고 싶습니다.
또한, 다른 로그인 방식 (예: 소셜 로그인)이 추가되더라도 기존의 로그인 훅을 재사용하기 쉽도록 만드는 방향이 좋지 않을까 싶었습니다. (최소한의 로직만 담기는)
대신에 로그인 성공 여부나 상태를 반환해 이를 사용하는 컴포넌트에서 리다이렉트를 처리하는 것이 좋을 것이라 생각했습니다!


return (
<div className="auth-page">
<div className="container page">
<div className="row">
<div className="col-md-6 offset-md-3 col-xs-12">
<LoginHeader />
<LoginForm onLoginSubmit={handleLogin} />
<LoginForm
loginError={loginError}
loginStatus={loginStatus}
onLoginSubmit={handleLogin}
/>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import React from 'react';
import styled from '@emotion/styled';

export const NavbarBrand = () => {
Expand Down
75 changes: 75 additions & 0 deletions apps/react-world/src/hooks/useLogin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useReducer } from 'react';
import type { LoginUserErrors } from '@/apis/login/LoginService.types'; // 로그인 관련 타입으로 수정
import LoginService from '@/apis/login/LoginService'; // LoginService로 수정
import { saveTokenToCookie } from '@/utils/jwtUtils';
import type { UserCredentials } from '@/app-types/UserCredentials';

export type LoginStatus = 'idle' | 'loggingIn' | 'success' | 'failed';

type LoginState = {
loginError: LoginUserErrors | null;
loginStatus: LoginStatus;
};

type LoginAction =
| { type: 'LOGIN_START' }
| { type: 'LOGIN_SUCCESS'; token: string }
| { type: 'LOGIN_ERROR'; error: LoginUserErrors }
| { type: 'LOGIN_FAILED' };

const loginReducer = (state: LoginState, action: LoginAction): LoginState => {
switch (action.type) {
case 'LOGIN_START':
return { ...state, loginStatus: 'loggingIn', loginError: null };
case 'LOGIN_SUCCESS':
return { ...state, loginStatus: 'success', loginError: null };
case 'LOGIN_ERROR':
return { ...state, loginStatus: 'failed', loginError: action.error };
case 'LOGIN_FAILED':
return { ...state, loginStatus: 'failed' };
default:
return state;
}
};

const useLogin = () => {
const [state, dispatch] = useReducer(loginReducer, {
loginError: null,
loginStatus: 'idle',
});

const handleLogin = async (data: UserCredentials) => {
dispatch({ type: 'LOGIN_START' });

try {
const response = await LoginService.loginUser(data);
// JWT 토큰을 쿠키에 저장
if (response && response.user && response.user.token) {
saveTokenToCookie(response.user.token);
dispatch({ type: 'LOGIN_SUCCESS', token: response.user.token });
console.log('login suceess');
}
return response;
} catch (error) {
if (error && typeof error === 'object' && 'errors' in error) {
dispatch({
type: 'LOGIN_ERROR',
error: error.errors as LoginUserErrors,
});
console.error('LoginUserErrors: ', error.errors);
} else {
dispatch({ type: 'LOGIN_FAILED' });
console.error('An unexpected error occurred:', error);
}
return null;
}
};

return {
loginError: state.loginError,
loginStatus: state.loginStatus,
handleLogin,
};
};

export default useLogin;
4 changes: 2 additions & 2 deletions apps/react-world/src/hooks/useRegister.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import type {
RegisterUserErrors,
RegisterUserParams,
RegisterUserResponse,
} from '../apis/register/RegisterService.types';
import RegisterService from '../apis/register/RegisterService';
} from '@/apis/register/RegisterService.types';
import RegisterService from '@/apis/register/RegisterService';
import { saveTokenToCookie } from '@/utils/jwtUtils';

const useRegister = () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/react-world/src/pages/article.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useParams } from 'react-router-dom';
import ArticlePageContainer from '../components/article/ArticlePageContainer';
import ArticlePageContainer from '@/components/article/ArticlePageContainer';

export const ArticlePage = () => {
const { articleSlug } = useParams<{ articleSlug: string }>() as {
Expand Down
2 changes: 1 addition & 1 deletion apps/react-world/src/pages/login.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import LoginPageContainer from '@components/login/LoginPageContainer';
import LoginPageContainer from '@/components/login/LoginPageContainer';

export const LoginPage = () => {
return <LoginPageContainer></LoginPageContainer>;
Expand Down
2 changes: 1 addition & 1 deletion apps/react-world/src/pages/register.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import useRegister from '../hooks/useRegister';
import useRegister from '@/hooks/useRegister';

export const RegisterPage = () => {
const { userData, error, isLoading, handleInputChange, handleSubmit } =
Expand Down
4 changes: 2 additions & 2 deletions apps/react-world/src/quries/useArticleDetailQuery.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import ArticleService from '../apis/article/ArticleService';
import type { ArticleDetailResponse } from '../apis/article/ArticleService.types';
import ArticleService from '@/apis/article/ArticleService';
import type { ArticleDetailResponse } from '@/apis/article/ArticleService.types';

export const ARTICLE_DETAIL_CACHE_KEY = '@article/detail';

Expand Down
2 changes: 1 addition & 1 deletion apps/react-world/src/quries/useArticlePreviewQuery.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import ArticlePreviewService from '../apis/article/ArticlePreviewService';
import ArticlePreviewService from '@/apis/article/ArticlePreviewService';

export const ARTICLE_PREVIEW_CACHE_KEY = '@article/preview';

Expand Down
2 changes: 1 addition & 1 deletion apps/react-world/src/quries/usePopularArticleTagsQuery.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import PopularArticleTagService from '../apis/article/PopularArticleTagService';
import PopularArticleTagService from '@/apis/article/PopularArticleTagService';

export const POPULAR_ARTICLE_TAG_CACHE_KEY = '@article/popular_tags';

Expand Down
9 changes: 9 additions & 0 deletions apps/react-world/src/types/CurrentUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
interface CurrentUser {
email: string;
token: string;
username: string;
bio: string | null;
image: string | null;
}

export default CurrentUser;
4 changes: 4 additions & 0 deletions apps/react-world/src/types/UserCredentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type UserCredentials = {
email: string;
password: string;
};