티끌모아 태산

회원가입 & 로그인 기능 구현(3) 본문

카테고리 없음

회원가입 & 로그인 기능 구현(3)

goldpig 2023. 9. 1. 11:52
728x90

1. NavBar & Navbar Item

모든 페이지에 공통으로 들어가는 Navbar를 만들어 보겠다. frontEnd/src/layout/Navbar/index.jsx. 그리고 Navbar 안에 Sections 폴더를 만들어주어서 Navbar Item을 관리해준다. 

// eslint-disable-next-line no-unused-vars
import React, { useState } from 'react'
import { Link } from 'react-router-dom'
import Navitem from './Sections/NavItem';


const Navbar = () => {

  const [menu, setMenu] = useState(false); // menu가 처음에는 false로 초기화

  // eslint-disable-next-line no-unused-vars
  const handleMenu = () => {
    // false면 true, true면 false, 토글
    setMenu(!menu)
  }

  return (
    // z-10은 relative를 위해서 주는 것  
    <section className='relative z-10 text-white bg-gray-900'>
      <div className='w-full'>
        {/* mx = margin left, right sm보다 크면 mx-10준다는 의미 */}
        <div className='flex items-center justify-between mx-5 sm:mx-10 lg:mx-20'>
          {/* logo */}
          <div className='flex items-center text-2xl h-14'>
            <Link to='/'>Logo</Link>
          </div>

          {/* menu */}
          <div className='text-2xl sm:hidden'>
            {/* 메뉴가 true이면 -, false이면 +, onClick하면 handleMenu 함수 호출 */}
            <button onClick={handleMenu}>{menu ? '-' : '+'}</button> 
          </div>

          {/* nav-items large screen 즉, small 보다 큰 사이즈 일 때 display:block처리, 기본이 display: none */}
          <div className='hidden sm:block'>
            <Navitem />
          </div>

        </div>
        {/* nav-items mobile 즉, small보다 작은 사이즈 일 때 display:hidden */}
        <div className='block sm:hidden'>
          {/* menu가 true일때만 보여주는 것 */}
          {menu && <Navitem mobile />}
        </div>

      </div>

    </section>
  )
}

export default Navbar

이제 NavItem 소스코드를 작성해보자

// eslint-disable-next-line no-unused-vars
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Link, useNavigate } from 'react-router-dom';
import { logoutUser } from '../../../store/thunkFunctions';


const routes = [
  { to: '/login', name: '로그인', auth: false},
  { to: '/register', name: '회원가입', auth: false},
  { to: '', name: '로그아웃', auth: true },

]

// eslint-disable-next-line react/prop-types
const Navitem = ({ mobile }) => { mobile을 props로 내려받는다. 부모는 Navbar.jsx

  const navigate = useNavigate();
  //Redux 상태를 컴포넌트에서 선택적으로 가져오는 역할, 유저가 있을때만 가져온다는 뜻
  const isAuth = useSelector(state => state.user?.isAuth)

  //Redux 액션을 디스패치(dispatch)하는 함수를 가져오는 역할
  const dispatch = useDispatch();

  const handleLogout = async () => {
    try {
      dispatch(logoutUser()) // 1. 요청 보내기 2. 썽크함수 만들기 3. 썽크함수 등록하기
        .then(() => {
          navigate("/login") 
        })      
       } catch (error) {
      console.error(error);
    }
  };
  return (
  // 모바일이 true 이면 flex-col
    <ul className={`text-md justify-center w-full flex gap-4 ${mobile && "flex-col bg-gray-900 h-full"} items-center`}>
    // map() 순회를 할 때 유니크한 키 값을 주어야 한다! 여기서는 name이 유니크하다.
      {routes.map(({ to, name, auth, icon }) => { // 이렇게 중괄호를 해줘야 로직 작성가능, 소괄호()는 바로 렌더링하는 부분 
        if(isAuth !==auth) return null;

        if(name === '로그아웃') {
          
          return <li key={name} className='py-2 text-center border-b-4 cursor-pointer'>
            <Link onClick={handleLogout}>{name}</Link>
          </li>
        }  else {
          // 고유 값으로 key={} 넣어줘야한다. 여기서는 name이 고유하므로 name을 넣는다.       
          return <li key={name} className='py-2 text-center border-b-4 cursor-pointer'>
            <Link to={to}>{name}</Link>
          </li>
        }
      })}
    </ul>

  )
}

export default Navitem

이제 로그아웃 기능을 위한 dispatch(logoutUser())에 대한 썽크 함수를 작성해주자.

import { createAsyncThunk } from "@reduxjs/toolkit";
import axiosInstance from "../utils/axios";

export const registerUser = createAsyncThunk(
    "user/registerUser", 
    async (body, thunkAPI) => {
        try {
            const response = await axiosInstance.post(
                `/users/register`,
                body
            )
            return response.data;
        } catch (error) {
            console.log(error);
            return thunkAPI.rejectWithValue(error.response.data || error.message);
        }
    }
)

export const loginUser = createAsyncThunk(
    "user/loginUser",
    async (body, thunkAPI) => {
        try {
            const response = await axiosInstance.post(
                `/users/login`,
                body
            )
            return response.data;
        } catch (error) {
            console.log(error);
            return thunkAPI.rejectWithValue(error.response.data || error.message);
        }
    }
)

export const authUser = createAsyncThunk(
    // typePrefix
    "user/authUser",
    async (_, thunkAPI) => {
        try {
            const response = await axiosInstance.get(
                `/users/auth`
            );

            return response.data;
        } catch (error) {
            console.log(error);
            return thunkAPI.rejectWithValue(error.response.data || error.message);
        }
    }
)

export const logoutUser = createAsyncThunk(
    "user/logoutUser",
    async (_, thunkAPI) => {
        try {
            const response = await axiosInstance.post(
                `/users/logout`
            );

            return response.data;
        } catch (error) {
            console.log(error);
            return thunkAPI.rejectWithValue(error.response.data || error.message);
        }
    }
)

그러면 이 요청을 처리해주는 백엔드 라우트 부분을 작성해주어야 한다.

const express = require('express');
const User = require('../models/User');
const router = express.Router();
const jwt = require('jsonwebtoken');
const auth = require('../middleware/auth');
const async = require('async');

// 로그인이된 유저가 페이지를 이동할 때마다 여기로 요청이 온다. 로그인이 잘 된 유저면 데이터베이스에 있는 데이터를 클라이언트한테 전달하는 부분
router.get('/auth', auth, async (req, res, next) => {

    return res.json({
        id: req.user._id,
        email: req.user.email,
        name: req.user.name,
        role: req.user.role,
        image: req.user.image,
        cart: req.user.cart,
        history: req.user.history
    })
})

router.post('/register', async (req, res, next) => {
    try {
        const user = new User(req.body); // 유저 객체 만들기, req.body안에 유저 정보가들어있다.
        await user.save(); // 만들어진 유저객체에 save하면 자동으로 몽고디비에 유저정보가 저장된다.
        return res.sendStatus(200); // 성공했다고 클라이언트에게 전달한다.
    } catch (error) {
        console.log('에러가 발생하였습니다.', error)
        next(error)
    }
})

router.post('/login', async (req, res, next) => {
    // req.body   password , email
    try {

        // 존재하는 유저인지 체크
        const user = await User.findOne({ email: req.body.email });

        if (!user) {
            return res.status(400).send("Auth failed, email not found");
        }

        // 비밀번로가 올바른 것인지 체크
        const isMatch = await user.comparePassword(req.body.password);
        if (!isMatch) {
            return res.status(400).send('Wrong password');
        }

        const payload = {
            userId: user._id.toHexString(),
        }

        // token을 생성
        const accessToken = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '1h' })

        return res.json({ user, accessToken })

    } catch (error) {
        next(error)
    }
})

// auth 미들웨어 통과하면 올바른 유저라는 뜻.
router.post('/logout', auth, async (req, res, next) => {
    try {
        return res.sendStatus(200);

    } catch(error) {
        next(error)
    }
})

마지막으로 썽크함수를 userSlice.js 파일에 등록해주면 된다!

import {createSlice} from '@reduxjs/toolkit'
import { loginUser, registerUser, authUser, logoutUser, addToCart, getCartItems, removeCartItem, payProducts } from './thunkFunctions';
import { toast } from 'react-toastify';
// eslint-disable-next-line no-unused-vars
const initialState = {
    userData: {
        id: '',
        email: '',
        name: '',
        role: '0', // 0은 일반유저, 1은 관리자
        image: '',
    },
    isAuth: false,
    isLoading: false, // 가져오는 중에는 True, 가져왔으면 false
    error: ""
}

// eslint-disable-next-line no-undef
const userSlice = createSlice({
    name: "user",
    initialState,
    reducers: {},
    // eslint-disable-next-line no-unused-vars
    extraReducers: (builder) => {
        builder
            .addCase(registerUser.pending, (state) => {
                state.isLoading = true;
            })
            .addCase(registerUser.fulfilled, (state) => {
                state.isLoading = false;
                toast.info('회원가입을 성공했습니다.');
            })
            .addCase(registerUser.rejected, (state, action) => {
                state.isLoading = false;
                state.error = action.payload;
                toast.error(action.payload);
            })
            .addCase(loginUser.pending, (state) => {
                state.isLoading = true;
            })
            .addCase(loginUser.fulfilled, (state, action) => {
                state.isLoading = false;
                state.userData = action.payload
                state.isAuth = true;
                localStorage.setItem('accessToken', action.payload.accessToken)
            })
            .addCase(loginUser.rejected, (state, action) => {
                state.isLoading = false;
                state.error = action.payload;
                toast.error(action.payload);
            })
            .addCase(authUser.pending, (state) => {
                state.isLoading = true;
            })
            .addCase(authUser.fulfilled, (state, action) => {
                state.isLoading = false;
                state.userData = action.payload
                state.isAuth = true;
               
            })
            .addCase(authUser.rejected, (state, action) => {
                state.isLoading = false;
                state.error = action.payload;
                // 유저 데이터 초기화
                state.userData = initialState.userData
                state.isAuth = false;
                // 만약 토큰이 만료된 사람이면
                localStorage.removeItem('accessToken')
            })
            .addCase(logoutUser.pending, (state) => {
                state.isLoading = true;
            })
            .addCase(logoutUser.fulfilled, (state) => {
                state.isLoading = false;
                state.userData = initialState.userData;
                state.isAuth = false;
                localStorage.removeItem('accessToken')
               
            })
            .addCase(logoutUser.rejected, (state, action) => {
                state.isLoading = false;
                state.error = action.payload;
                toast.error(action.payload)
            })

2. Token 만료되었다면?

처음에 토큰 만료시간을 1시간으로 주었기 때문에 1시간이 지나면 만료가 된다. 따라서 리로드 해주는 함수를 작성해보자. axios.js에서 작성.

import axios from 'axios';

// eslint-disable-next-line no-unused-vars
const axiosInstance = axios.create({
    baseURL: import.meta.env.PROD ?
        "" : "http://localhost:4000",
})

axiosInstance.interceptors.request.use(function (config) {
    // 요청을 보내기 전에 어떤것을 하고 싶을 때
    config.headers.Authorization = 'Bearer ' + localStorage.getItem('accessToken');
    return config;
}, function (error) { //에러 발생시 여기서 처리
    return Promise.reject(error);
})

// 토큰이 1시간 후에 만료되는데, 이를 갱신하기 위한 코드
axiosInstance.interceptors.response.use(function (response) {
    return response;
}, function (error) {
    if(error.response.data === 'jwt expired') {
        window.location.reload()
    }
    return Promise.reject(error);
})



export default axiosInstance;

 

https://www.inflearn.com/course/%EB%94%B0%EB%9D%BC%ED%95%98%EB%A9%B0-%EB%B0%B0%EC%9A%B0%EB%8A%94-%EB%85%B8%EB%93%9C-%EB%A6%AC%EC%95%A1%ED%8A%B8-%EC%87%BC%ED%95%91%EB%AA%B0#curriculum

 

따라하며 배우는 노드, 리액트 시리즈 - 쇼핑몰 사이트 만들기[전체 리뉴얼] - 인프런 | 강의

이 강의를 통해서 쇼핑몰 웹사이트를 처음부터 하나하나 만들어 보실 수 있습니다., 노드 + 리액트 개발, 한단계 레벨 업!쇼핑몰 웹사이트를 직접 만들어보세요. [사진]   안녕하세요. 이 강의가

www.inflearn.com

 

728x90