Django API를 바탕으로, 리액트에서 로그인 및 회원가입 기능을 구현해보겠습니다.
이제 첫 화면에서는 오류가 나겠죠? 401 Unauthorized가 나옵니다.
권한 설정을 했는데, 저희는 Header에 Authorization을 주지 않아서 그런건데요, 일단 이 오류는 무시하고 로그인 회원가입 기능을 구현하겠습니다.
다음 파일들을 생성해줍니다.
components/auth/AuthForm/AuthForm.js
import React from "react";
import styles from "./AuthForm.scss";
import classNames from "classnames/bind";
import { Link } from "react-router-dom";
const cx = classNames.bind(styles);
const AuthForm = ({ kind }) => {
return (
<div className={cx("auth-form")}>
<div className={cx("title")}>{kind.toUpperCase()}</div>
<div className={cx("line-wrapper")}>
<div className={cx("input-title")}>username</div>
<input type="text" name="username" />
</div>
<div className={cx("line-wrapper")}>
<div className={cx("input-title")}>password</div>
<input type="password" name="password" />
</div>
<div className={cx("auth-button")}>{kind.toUpperCase()}</div>
{kind === "register" ? (
<Link to={`/auth/login`} className={cx("description")}>
if you already have account...
</Link>
) : (
<Link to={`/auth/register`} className={cx("description")}>
if you don't have an account...
</Link>
)}
</div>
);
};
export default AuthForm;
components/auth/AuthForm/AuthForm.scss
@import "utils";
.auth-form {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: 3px solid $oc-gray-6;
border-radius: 3px;
width: 512px;
@include media("<medium") {
width: 100%;
border: none;
top: 0%;
transform: translate(-50%, 0%);
}
padding: 1rem;
.title {
text-align: center;
font-size: 2rem;
font-weight: 700;
user-select: none;
padding-top: 1rem;
padding-bottom: 1rem;
}
.error {
display: flex;
justify-content: center;
.message {
font-size: 1.25rem;
font-weight: 500;
color: $oc-red-7;
}
}
.line-wrapper {
display: flex;
flex-direction: column;
.input-title {
font-size: 1.6rem;
font-weight: 600;
margin-bottom: 1rem;
}
input {
height: 3rem;
outline: none;
border: 1px solid $oc-gray-6;
border-radius: 2px;
font-size: 1.5rem;
padding: 0.5rem;
font-weight: 600;
}
}
.line-wrapper + .line-wrapper {
margin-top: 1.5rem;
}
.auth-button {
// height: 3rem;
background: $oc-violet-7;
margin-top: 2rem;
border: 1px solid $oc-violet-7;
border-radius: 2px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-left: 0.25rem;
padding-right: 0.25rem;
text-align: center;
color: white;
font-weight: 600;
font-size: 1.5rem;
cursor: pointer;
user-select: none;
&:hover {
background: $oc-violet-8;
}
&:active {
background: $oc-violet-7;
}
}
.description {
padding-top: 1rem;
padding-bottom: 0.5rem;
display: flex;
flex-direction: row-reverse;
font-size: 1.2rem;
color: $oc-blue-7;
@include media("<medium") {
justify-content: center;
}
}
}
components/auth/AuthForm/index.js
export { default } from './AuthForm';
그리고 pages의 Auth를 손봐야합니다.
pages/Auth.js
import React from "react";
import AuthForm from "components/auth/AuthForm";
const Auth = ({ match }) => {
// App.js /:kind로 설정해둔 값입니다.
const { kind } = match.params;
return (
<div>
<AuthForm kind={kind} />
</div>
);
};
export default Auth;
이제 redux의 auth module을 생성해볼까요?
일단 다음 액션들만 처리해주겠습니다.
modules/auth.js
const INITIALIZE_INPUT = "auth/INITIALIZE_INPUT";
const CHANGE_INPUT = "auth/CHANGE_INPUT";
export const initializeInput = () => ({
type: INITIALIZE_INPUT
});
export const changeInput = ({ name, value }) => ({
type: CHANGE_INPUT,
payload: {
name,
value
}
});
const initialState = {
form: {
username: "",
password: ""
}
};
export const auth = (state = initialState, action) => {
switch (action.type) {
case INITIALIZE_INPUT:
return {
...state,
form: {
username: "",
password: ""
}
};
case CHANGE_INPUT:
let newForm = state.form;
newForm[action.payload.name] = action.payload.value;
return {
...state,
form: newForm
};
default:
return state;
}
};
modules/index.js
import { notes, notesEpics } from "./notes";
import { auth } from "./auth";
import { combineReducers } from "redux";
import { combineEpics } from "redux-observable";
export const rootReducers = combineReducers({ notes, auth });
export const rootEpics = combineEpics(
notesEpics.addNoteEpic,
notesEpics.getNotesEpic,
notesEpics.updateNoteEpic,
notesEpics.deleteNoteEpic
);
containers/AuthContainer.js
import React, { Component } from "react";
import { connect } from "react-redux";
import AuthForm from "components/auth/AuthForm";
import { withRouter } from "react-router-dom";
import * as authActions from "store/modules/auth";
export class AuthContainer extends Component {
componentDidMount() {
const { initializeInput } = this.props;
initializeInput();
}
componentDidUpdate(prevProps, prevState) {
const { initializeInput } = this.props;
if (prevProps.kind !== this.props.kind) {
initializeInput();
}
}
handleChangeInput = ({ name, value }) => {
const { changeInput } = this.props;
changeInput({ name, value });
};
render() {
const { kind, username, password } = this.props;
const { handleChangeInput } = this;
return (
<AuthForm
kind={kind}
username={username}
password={password}
onChangeInput={handleChangeInput}
/>
);
}
}
const mapStateToProps = state => ({
username: state.auth.form.username,
password: state.auth.form.password
});
const mapDispatchToProps = dispatch => {
return {
initializeInput: () => {
dispatch(authActions.initializeInput());
},
changeInput: ({ name, value }) => {
dispatch(authActions.changeInput({ name, value }));
}
};
};
export default withRouter(
connect(
mapStateToProps,
mapDispatchToProps
)(AuthContainer)
);
pages/Auth.js
import React from "react";
import AuthContainer from "containers/AuthContainer";
const Auth = ({ match }) => {
const { kind } = match.params;
return (
<div>
<AuthContainer kind={kind} />
</div>
);
};
export default Auth;
components/auth/AuthForm/AuthForm.js
import React from "react";
import styles from "./AuthForm.scss";
import classNames from "classnames/bind";
import { Link } from "react-router-dom";
const cx = classNames.bind(styles);
const AuthForm = ({ kind, onChangeInput, username, password }) => {
const handleChange = e => {
const { name, value } = e.target;
onChangeInput({ name, value });
};
return (
<div className={cx("auth-form")}>
<div className={cx("title")}>{kind.toUpperCase()}</div>
<div className={cx("line-wrapper")}>
<div className={cx("input-title")}>username</div>
<input
type="text"
name="username"
value={username}
onChange={handleChange}
/>
</div>
<div className={cx("line-wrapper")}>
<div className={cx("input-title")}>password</div>
<input
type="password"
name="password"
value={password}
onChange={handleChange}
/>
</div>
<div className={cx("auth-button")}>{kind.toUpperCase()}</div>
{kind === "register" ? (
<Link to={`/auth/login`} className={cx("description")}>
if you already have account...
</Link>
) : (
<Link to={`/auth/register`} className={cx("description")}>
if you don't have an account...
</Link>
)}
</div>
);
};
export default AuthForm;
이렇게 까지하면, 폼인풋들을 상태관리 할수 있고, 레지스터와 로그인 페이지간 이동할때, 모든 인풋밸류가 초기화 됩니다.
그렇다면 이제 API통신을 통해 login 과 register기능을 구현해보겠습니다.
그리고 그에따른 에러처리를 해보도록 하죠.
modules/auth.js
....
const REGISTER = "auth/REGISTER";
const REGISTER_SUCCESS = "auth/REGISTER_SUCCESS";
const REGISTER_FAILURE = "auth/REGISTER_FAILURE";
const LOGIN = "auth/LOGIN";
const LOGIN_SUCCESS = "auth/LOGIN_SUCCESS";
const LOGIN_FAILURE = "auth/LOGIN_FAILURE";
const INITIALIZE_ERROR = "auth/INITIALIZE_ERROR";
...
export const register = () => ({
type: REGISTER
});
export const registerSuccess = ({ user, token }) => ({
type: REGISTER_SUCCESS,
payload: {
user,
token
}
});
export const registerFailure = error => ({
type: REGISTER_FAILURE,
payload: {
error
}
});
export const login = () => ({
type: LOGIN
});
export const loginSuccess = ({ user, token }) => ({
type: LOGIN_SUCCESS,
payload: {
user,
token
}
});
export const loginFailure = error => ({
type: LOGIN_FAILURE,
payload: {
error
}
});
export const initializeError = () => ({
type: INITIALIZE_ERROR
});
...
const registerEpic = (action$, state$) => {
return action$.pipe(
ofType(REGISTER),
withLatestFrom(state$),
mergeMap(([action, state]) => {
const { username, password } = state.auth.form;
return ajax.post(`/api/auth/register/`, { username, password }).pipe(
map(response => {
const { user, token } = response.response;
return registerSuccess({ user, token });
}),
catchError(error =>
of({
type: REGISTER_FAILURE,
payload: error,
error: true
})
)
);
})
);
};
const loginEpic = (action$, state$) => {
return action$.pipe(
ofType(LOGIN),
withLatestFrom(state$),
mergeMap(([action, state]) => {
const { username, password } = state.auth.form;
return ajax.post(`/api/auth/login/`, { username, password }).pipe(
map(response => {
const { user, token } = response.response;
return loginSuccess({ user, token });
}),
catchError(error =>
of({
type: LOGIN_FAILURE,
payload: error,
error: true
})
)
);
})
);
};
...
const initialState = {
form: {
username: "",
password: ""
},
error: {
triggered: false,
message: ""
},
logged: false,
userInfo: {
id: null,
username: "",
token: null
}
};
export const auth = (state = initialState, action) => {
switch (action.type) {
case INITIALIZE_INPUT:
return {
...state,
form: {
username: "",
password: ""
}
};
case CHANGE_INPUT:
let newForm = state.form;
newForm[action.payload.name] = action.payload.value;
return {
...state,
form: newForm
};
case INITIALIZE_ERROR:
return {
...state,
error: {
triggered: false,
message: ""
}
};
case REGISTER_SUCCESS:
return {
...state,
logged: true,
userInfo: {
id: action.payload.user.id,
username: action.payload.user.username,
token: action.payload.token
}
};
case REGISTER_FAILURE:
switch (action.payload.status) {
case 400:
return {
...state,
error: {
triggered: true,
message: "WRONG USERNAME OR PASSWORD"
}
};
case 500:
return {
...state,
error: {
triggered: true,
message: "TOO SHORT USERNAME OR PASSWORD"
}
};
default:
return {
...state
};
}
case LOGIN_SUCCESS:
return {
...state,
logged: true,
userInfo: {
id: action.payload.user.id,
username: action.payload.user.username,
token: action.payload.token
}
};
case LOGIN_FAILURE:
switch (action.payload.status) {
case 400:
return {
...state,
error: {
triggered: true,
message: "WRONG USERNAME OR PASSWORD"
}
};
case 500:
return {
...state,
error: {
triggered: true,
message: "PLEASE TRY AGAIN"
}
};
default:
return {
...state
};
}
default:
return state;
}
};
modules/index.js
import { notes, notesEpics } from "./notes";
import { auth, authEpics } from "./auth";
import { combineReducers } from "redux";
import { combineEpics } from "redux-observable";
export const rootReducers = combineReducers({ notes, auth });
export const rootEpics = combineEpics(
notesEpics.addNoteEpic,
notesEpics.getNotesEpic,
notesEpics.updateNoteEpic,
notesEpics.deleteNoteEpic,
authEpics.loginEpic,
authEpics.registerEpic
);
containers/AuthContainer.js
import React, { Component } from "react";
import { connect } from "react-redux";
import AuthForm from "components/auth/AuthForm";
import { withRouter } from "react-router-dom";
import * as authActions from "store/modules/auth";
export class AuthContainer extends Component {
componentDidMount() {
this.initialize();
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.kind !== this.props.kind) {
this.initialize();
}
}
initialize = () => {
const { initializeInput, initializeError } = this.props;
initializeError();
initializeInput();
};
handleChangeInput = ({ name, value }) => {
const { changeInput } = this.props;
changeInput({ name, value });
};
handleLogin = () => {
const { login } = this.props;
login();
};
handleRegister = () => {
const { register } = this.props;
register();
};
render() {
const { kind, username, password, error } = this.props;
const { handleChangeInput, handleLogin, handleRegister } = this;
return (
<AuthForm
kind={kind}
username={username}
password={password}
onChangeInput={handleChangeInput}
onLogin={handleLogin}
onRegister={handleRegister}
error={error}
/>
);
}
}
const mapStateToProps = state => ({
username: state.auth.form.username,
password: state.auth.form.password,
userInfo: state.auth.userInfo,
logged: state.auth.logged,
error: state.auth.error
});
const mapDispatchToProps = dispatch => {
return {
initializeInput: () => {
dispatch(authActions.initializeInput());
},
changeInput: ({ name, value }) => {
dispatch(authActions.changeInput({ name, value }));
},
initializeError: () => {
dispatch(authActions.initializeError());
},
register: () => {
dispatch(authActions.register());
},
login: () => {
dispatch(authActions.login());
}
};
};
export default withRouter(
connect(
mapStateToProps,
mapDispatchToProps
)(AuthContainer)
);
components/auth/AuthForm/AuthForm.js
import React from "react";
import styles from "./AuthForm.scss";
import classNames from "classnames/bind";
import { Link } from "react-router-dom";
const cx = classNames.bind(styles);
const AuthForm = ({
kind,
onChangeInput,
username,
password,
onLogin,
onRegister,
error
}) => {
const handleChange = e => {
const { name, value } = e.target;
onChangeInput({ name, value });
};
const handleKeyPress = e => {
if (e.key === "Enter") {
switch (kind) {
case "register":
onRegister();
return;
case "login":
onLogin();
return;
default:
return;
}
}
};
return (
<div className={cx("auth-form")}>
<div className={cx("title")}>{kind.toUpperCase()}</div>
<div className={cx("error")}>
{error.triggered && (
<div className={cx("message")}>{error.message}</div>
)}
</div>
<div className={cx("line-wrapper")}>
<div className={cx("input-title")}>username</div>
<input
type="text"
name="username"
value={username}
onChange={handleChange}
onKeyPress={handleKeyPress}
/>
</div>
<div className={cx("line-wrapper")}>
<div className={cx("input-title")}>password</div>
<input
type="password"
name="password"
value={password}
onChange={handleChange}
onKeyPress={handleKeyPress}
/>
</div>
{kind === "register" ? (
<div className={cx("auth-button")} onClick={onRegister}>
{kind.toUpperCase()}
</div>
) : (
<div className={cx("auth-button")} onClick={onLogin}>
{kind.toUpperCase()}
</div>
)}
{kind === "register" ? (
<Link to={`/auth/login`} className={cx("description")}>
if you already have account...
</Link>
) : (
<Link to={`/auth/register`} className={cx("description")}>
if you don't have an account...
</Link>
)}
</div>
);
};
export default AuthForm;
이렇게 되면 API 통신이 완료됩니다. 그에따른 오류처리도 됩니다.
다음 에서는 로그인을 유지하고, 로그인이 되지 않았을때에는, 로그인 페이지로 보내고, 로그아웃 기능을 구현해보겠습니다.
store/modules/auth.js에서 authEpics로 정의하지 않으셨고 loginEpic, registerEpic은 auth에 포함되어 있어요.
store/modules/index.js에서 authEpics부분은 auth로 수정하셔야 합니다.