wordpress_blog

This is a dynamic to static website.

MERN Authentication Tutorial

Learning From Youtube Channel: Net Ninja
Video: MERN Authentication Tutorial
Thank you.

#1 – Intro & Starter Project

Learn how to implement authentication (using JSON web tokens), within the MERN stack, in this tutorial series.

  • 下載初始專案、講解專案檔案結構、內容
  • 移動到 backend 資料夾 – cd backend
  • 安裝 npm 套件 – npm install
  • 移動到 frontend 資料夾 – cd frontend
  • 安裝 npm 套件 – npm install
  • 執行後端 backend – npm run dev
  • 執行前端 frontend – npm start

#2 – User Routes, Controller & Model

In this MERN auth tutorial, you’ll make a user controller & model, and set up some routes for authentication.

  • 在 routes 資料夾裡面建立 user.js 檔案
  • 修改 user.js 檔案
  • 在 controllers 資料夾裡面建立 userController.js 檔案
  • 修改 userController.js 檔案
  • 修改 user.js 檔案,controller functions
  • 修改 server.js 檔案
  • 在 models 資料夾裡面建立 userModel.js 檔案
  • 修改 useModel.js 檔案
  • 修改 userController.js 檔案,User
  • 使用 API 測試工具 Postman
    http://localhost:4000/api/user/login,POST 方法
    Body > raw > JSON
  • 使用 API 測試工具
    http://localhost:4000/api/user/signup,POST 方法
    Body > raw > JSON
// backend/routes/user.js
const express = require('express')

// controller functions
const { signupUser, loginUser } = require('../controllers/userController')

const router = express.Router()

// login route
router.post('/login', loginUser)

// signup route
router.post('/signup', signupUser)

module.exports = router
// backend/controllers/userController.js
const User = require('../models/userModel')

// login user
const loginUser = async (req, res) => {
  res.json({mssg: 'login user'})
}

// signup user
const signupUser = async (req, res) => {
  res.json({mssg: 'signup user'})
}

module.exports = { signupUser, loginUser }
// backend/server.js
require('dotenv').config()

const express = require('express')
const mongoose = require('mongoose')
const workoutRoutes = require('./routes/workouts')
const userRoutes = require('./routes/user')

// express app
const app = express()

// middleware
app.use(express.json())

app.use((req, res, next) => {
  console.log(req.path, req.method)
  next()
})

// routes
app.use('/api/workouts', workoutRoutes)
app.use('/api/user', userRoutes)

// connect to db
mongoose.connect(process.env.MONGO_URI)
  .then(() => {
    // listen for requests
    app.listen(process.env.PORT, () => {
      console.log('connected to db & listening on port', process.env.PORT)
    })
  })
  .catch((error) => {
    console.log(error)
  })
// backend/models/userModel.js
const mongoose = require('mongoose')

const Schema = mongoose.Schema

const userSchema = new Schema({
  email: {
    type: String,
    required: true,
    unique: true
  },
  password: {
    type: String,
    required: true,
  }
})

module.exports = mongoose.model('User', userSchema)
// Body > raw > JSON - login
{
  "email": "yoshi@netninja.dev",
  "password": "abc123"
}
// Body > raw > JSON - signup
{
  "email": "yoshi@netninja.dev",
  "password": "abc123"
}

#3 – Signing Up & Hashing Passwords

  • 修改 userModel.js 檔案,static signup method
  • 安裝 bcrypt 套件 – npm install bcrypt
    修改 userModel.js 檔案
  • 修改 userController.js 檔案
  • 修改 userModels.js 檔案,function
  • 重新執行後端 server – nodemon server
  • 使用 API 測試工具 Postman
    http://localhost:4000/api/user/signup,POST 方法
    Body > raw > JSON
// backend/models/userModel.js
const mongoose = require('mongoose')
const bcrypt = require('bcrypt')

const Schema = mongoose.Schema

const userSchema = new Schema({
  email: {
    type: String,
    required: true,
    unique: true
  },
  password: {
    type: String,
    required: true,
  }
})

// static signup method
userSchema.statics.signup = async function (email, password) {

  const exists = await this.findOne({ email })

  if (exists) {
    throw Error('Email already in use')
  }

  const salt = await bcrypt.genSalt(10)
  const hash = await bcrypt.hash(password, salt)

  const user = await this.create({ email, password: hash })

  return user
}

module.exports = mongoose.model('User', userSchema)
// backend/controllers/userController.js
const User = require('../models/userModel')

// login user
const loginUser = async (req, res) => {
  res.json({mssg: 'login user'})
}

// signup user
const signupUser = async (req, res) => {
  const { email, password } = req.body

  try {
    const user = await User.signup(email, password)

    res.status(200).json({ email, user })
  } catch (error) {
    res.status(400).json({ error: error.message })
  }
}

module.exports = { signupUser, loginUser }
// Body > raw > JSON
{
  "email": "yoshi@netninja.dev",
  "password": "abc123"
}

#4 – Email & Password Validation

  • 使用 API 測試工具 Postman
    http://localhost:4000/api/user/signup,POST 方法
    使用錯誤的 email 格式測試,沒有完整格式、空值
  • 安裝 validator 套件 – npm install validator
  • 修改 userModel.js 檔案,validator
  • 使用 API 測試工具 Postman
    http://localhost:4000/api/user/signup,POST 方法
    使用錯誤的 email 格式測試,沒有完整格式、空值
// backend/models/userModel.js
const mongoose = require('mongoose')
const bcrypt = require('bcrypt')
const validator = require('validator')

const Schema = mongoose.Schema

const userSchema = new Schema({
  email: {
    type: String,
    required: true,
    unique: true
  },
  password: {
    type: String,
    required: true,
  }
})

// static signup method
userSchema.statics.signup = async function (email, password) {

  // validation
    if (!email || !password) {
      throw Error('All fields must be filled')
    }
    if (!validator.isEmail(email)) {
      throw Error('Email not valid')
    }
    if (!validator.isStrongPassword(password)) {
      throw Error('Password not strong enough')
    }

  const exists = await this.findOne({ email })

  if (exists) {
    throw Error('Email already in use')
  }

  const salt = await bcrypt.genSalt(10)
  const hash = await bcrypt.hash(password, salt)

  const user = await this.create({ email, password: hash })

  return user
}

module.exports = mongoose.model('User', userSchema)

#5 – JSON Web Tokens (theory)

In this MERN Authentication tutorial you’ll learn about JSON Web Tokens (JWT’s) and how they work under the hood when it comes to authentication.

  • 講解 JSON Web Tokens
  • 介紹 JWT 的 Encoded、Decoded
  • 講解 Header、Payload、Signature

JSON Web Tokens

Header
Contains the algorithm used for the JWT

Payload
Contains non-sensitive user data (e.g. a user id)

Signature
Used to verify the token by the server

#6 – Signing Tokens

In this MERN Authentication tutorial, we’ll see how to sign tokens and send them back to the client.

  • 安裝 jsonwebtoken 套件 – npm install jsonwebtoken
  • 修改 userController.js 檔案
  • 修改 .env 檔案,避免敏感、重要的資料上傳到 GitHub
  • 使用 API 測試工具 Postman
    http://localhost:4000/api/user/signup,POST 方法
    Body > raw > JSON
// backend/controllers/userController.js
const User = require('../models/userModel')
const jwt = require('jsonwebtoken')

const createToken = (_id) => {
  return jwt.sign({_id}, process.env.SECRET, { expiresIn: '3d' })
}

// login user
const loginUser = async (req, res) => {
  res.json({mssg: 'login user'})
}

// signup user
const signupUser = async (req, res) => {
  const { email, password } = req.body

  try {
    const user = await User.signup(email, password)

    // create a token
    const token = createToken(user._id)

    res.status(200).json({ email, token })
  } catch (error) {
    res.status(400).json({ error: error.message })
  }
}

module.exports = { signupUser, loginUser }
// backend/.env
PORT=4000
MONGO_URI=mongodb+srv://mario:test1234@mernapp.bldt4v0.mongodb.net/?retryWrites=true&w=majority&appName=MERNapp
SECRET =learnfromnetninjathankyou
// Body > raw > JSON
{
  "email": "luigi@netninja.dev",
  "password": "ABCabc123!"
}

#7 – Logging Users In

  • 修改 userModel.js 檔案,static login method
  • 修改 userController.js 檔案,login user
  • 使用 API 測試工具 Postman,測試各種資料情形
    http://localhost:4000/api/user/login,POST 方法
    Body > raw > JSON
// backend/models/userModel.js
const mongoose = require('mongoose')
const bcrypt = require('bcrypt')
const validator = require('validator')

const Schema = mongoose.Schema

const userSchema = new Schema({
  email: {
    type: String,
    required: true,
    unique: true
  },
  password: {
    type: String,
    required: true,
  }
})

// static signup method
userSchema.statics.signup = async function (email, password) {

  // validation
    if (!email || !password) {
      throw Error('All fields must be filled')
    }
    if (!validator.isEmail(email)) {
      throw Error('Email not valid')
    }
    if (!validator.isStrongPassword(password)) {
      throw Error('Password not strong enough')
    }

  const exists = await this.findOne({ email })

  if (exists) {
    throw Error('Email already in use')
  }

  const salt = await bcrypt.genSalt(10)
  const hash = await bcrypt.hash(password, salt)

  const user = await this.create({ email, password: hash })

  return user
}

// static login method
userSchema.statics.login = async function (email, password) {

  if (!email || !password) {
    throw Error('All fields must be filled')
  }

  const user = await this.findOne({ email })

  if (!user) {
    throw Error('Incorrect email')
  }

  const match = await bcrypt.compare(password, user.password)

  if (!match) {
    throw Error('Incorrect password')
  }

  return user
}

module.exports = mongoose.model('User', userSchema)
// backend/controllers/userController.js
const User = require('../models/userModel')
const jwt = require('jsonwebtoken')

const createToken = (_id) => {
  return jwt.sign({_id}, process.env.SECRET, { expiresIn: '3d' })
}

// login user
const loginUser = async (req, res) => {
  const { email, password } = req.body

  try {
    const user = await User.login(email, password)

    // create a token
    const token = createToken(user._id)

    res.status(200).json({ email, token })
  } catch (error) {
    res.status(400).json({ error: error.message })
  }
}

// signup user
const signupUser = async (req, res) => {
  const { email, password } = req.body

  try {
    const user = await User.signup(email, password)

    // create a token
    const token = createToken(user._id)

    res.status(200).json({ email, token })
  } catch (error) {
    res.status(400).json({ error: error.message })
  }
}

module.exports = { signupUser, loginUser }
// Body > raw > JSON
{
  "email": "luigi@netninja.dev",
  "password": "ABCabc123!"
}

#8 – React Auth Context

  • 在 context 資料夾裡面建立 AuthContext.js 檔案
  • 修改 AuthContext.js 檔案
  • 修改 index.js 檔案,匯入 AuthContextProvider
  • 在 hook 資料夾裡面建立 useAuthContext.js 檔案
  • 修改 useAuthContext.js 檔案
// backend/src/context/AuthContext.js
import { createContext, useReducer } from 'react'

export const AuthContext = createContext()

export const authReducer = (state, action) => {
  switch (action.type) {
    case 'LOGIN':
      return { user: action.payload }
    case 'LOGOUT':
      return { user: null}
    default:
      return state
  }
}

export const AuthContextProvider = ({ children }) => {
  const [state, dispatch] = useReducer(authReducer, {
    user: null
  })

  console.log('AuthContext State: ', state)

  return (
    <AuthContext.Provider value={{...state, dispatch}}>
      { children }
    </AuthContext.Provider>
  )
}
// frontend/src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { WorkoutsContextProvider } from './context/WorkoutContext';
import { AuthContextProvider } from './context/AuthContext';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <AuthContextProvider>
      <WorkoutsContextProvider>
        <App />
      </WorkoutsContextProvider>
    </AuthContextProvider>
  </React.StrictMode>
);
// frontend/src/hooks/useAuthContext.js
import { AuthContext } from "../context/AuthContext";
import { useContext } from 'react'

export const useAuthContext = () => {
  const context = useContext(AuthContext)

  if (!context) {
    throw Error('useAuthContext must be used inside an AuthContextProvider')
  }

  return context
}

#9 – Login & Signup Forms

In this MERN auth tutorial, we’ll flesh out the login and signup forms.

  • 在 pages 資料夾裡面建立 Signup.js 檔案
  • 修改 Signup.js 檔案
  • 在 pages 資料夾裡面建立 Login.js 檔案
  • 修改 Login.js 檔案,從 Signup.js 複製修改
  • 修改 App.js 檔案
  • 修改 Navbar.js 檔案
  • 修改 index.css 檔案
  • 測試網頁表單 Sign up 關於 console 查詢顯示
// frontend/src/pages/Signup.js
import { useState } from 'react'

const Signup = () => {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  const handleSubmit = async (e) => {
    e.preventDefault()

    console.log(email, password)
  }

  return (
    <form className='signup' onSubmit={handleSubmit}>
      <h3>Sign up</h3>

      <label>Email:</label>
      <input
      type="email"
      onChange={(e) => setEmail(e.target.value)}
      value={email}
      />
      <label>Password:</label>
      <input
      type="password"
      onChange={(e) => setPassword(e.target.value)}
      value={password}
      />
      
      <button>Sign up</button>
    </form>
  )
}

export default Signup
// frontend/src/pages/Login.js
import { useState } from 'react'

const Login = () => {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')

  const handleSubmit = async (e) => {
    e.preventDefault()

    console.log(email, password)
  }

  return (
    <form className='login' onSubmit={handleSubmit}>
      <h3>Log in</h3>

      <label>Email:</label>
      <input
      type="email"
      onChange={(e) => setEmail(e.target.value)}
      value={email}
      />
      <label>Password:</label>
      <input
      type="password"
      onChange={(e) => setPassword(e.target.value)}
      value={password}
      />
      
      <button>Log in</button>
    </form>
  )
}

export default Login
// frontend/src/App.js
import { BrowserRouter , Routes, Route } from 'react-router-dom'

// pages & components
import Home from './pages/Home'
import Login from './pages/Login';
import Signup from './pages/Signup';
import Navbar from './components/Navbar'

function App() {
  return (
    <div className="App">
        <BrowserRouter>
          <Navbar />
          <div className="pages">
            <Routes>
              <Route
                path="/"
                element={<Home />} 
              />
              <Route
                path="/login"
                element={<Login />} 
              />
              <Route
                path="/signup"
                element={<Signup />} 
              />
            </Routes>
          </div>
        </BrowserRouter>
    </div>
  );
}

export default App;
// frontend/src/components/Navbar.js
import { Link } from "react-router-dom"

const Navbar = () => {

  return (
    <header>
      <div className="container">
        <Link to="/">
          <h1>Workout Buddy</h1>
        </Link>
        <nav>
          <div>
            <Link to="/login">Login</Link>
            <Link to="/signup">Signup</Link>
          </div>
        </nav>
      </div>
    </header>
  )
}

export default Navbar
// frontend/src/index.css
/* google font */
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;800&family=VT323&display=swap');

/* layout */
:root {
  --primary: #1aac83;
  --error: #e7195a;
}
body {
  background: #f1f1f1;
  margin: 0;
  font-family: "Poppins";
}
header {
  background: #fff;
}
header .container {
  max-width: 1400px;
  margin: 0 auto;
  padding: 10px 20px;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
header a {
  color: #333;
  text-decoration: none;
}
.pages{
  max-width: 1400px;
  padding: 20px;
  margin: 0 auto;
}

/* homepage */
.home {
  display: grid;
  grid-template-columns: 3fr 1fr;
  gap: 100px;
}

.workout-details {
  background: #fff;
  border-radius: 4px;
  margin: 20px auto;
  padding: 20px;
  position: relative;
  box-shadow: 2px 2px 5px rgba(0,0,0,0.05);
}

.workout-details h4 {
  margin: 0 0 10px 0;
  font-size: 1.2em;
  color: var(--primary);
}

.workout-details p {
  margin: 0;
  font-size: 0.9em;
  color: #555;
}

.workout-details span {
  position: absolute;
  top: 20px;
  right: 20px;
  cursor: pointer;
  background: #f1f1f1;
  padding: 6px;
  border-radius: 50%;
  color: #333;
}

/* new workout form */
label, input {
  display: block;
}

input {
  padding: 10px;
  margin-top: 10px;
  margin-bottom: 20px;
  width: 100%;
  border: 1px solid #ddd;
  border-radius: 4px;
  box-sizing: border-box;
}

form button {
  background: var(--primary);
  border: 0;
  color: #fff;
  padding: 10px;
  font-family: "Poppins";
  border-radius: 4px;
  cursor: pointer;
}

div.error {
  padding: 10px;
  background: #ffefef;
  border: 1px solid var(--error);
  color: var(--error);
  border-radius: 4px;
  margin: 20px 0;
}

input.error {
  border: 1px solid var(--error);
}

/* navbar */
nav {
  display: flex;
  align-items: center;
}

nav a {
  margin-left: 10px;
}

/* auth forms */
form.signup, form.login {
  max-width: 400px;
  margin: 40px auto;
  padding: 20px;
  background: #fff;
  border-radius: 4px;
}

#10 – Making a useSignup Hook

In this MERN auth lesson, you’ll create a custom, reusable React hook that we can use to sign new users up to the application.

  • 在 hooks 資料夾裡面建立 useSignup.js 檔案
  • 修改 useSignup.js 檔案
  • 修改 Signup.js 檔案,useSignup
  • 測試 Sign up 表單能否正常運作
// frontend/src/hooks/useSignup.js
import { useState } from 'react'
import { useAuthContext } from './useAuthContext'

export const useSignup = () => {
  const [error, setError] = useState(null)
  const [isLoading, setIsLoading] = useState(null)
  const { dispatch } = useAuthContext()

  const signup = async (email, password) => {
    setIsLoading(true)
    setError(null)

    const response = await fetch('/api/user/signup', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({email, password})
    })
    const json = await response.json()

    if (!response.ok) {
      setIsLoading(false)
      setError(json.error)
    }
    if (response.ok) {
      // save the user to local storage
      localStorage.setItem('user', JSON.stringify(json))

      // update the auth context
      dispatch({type: 'LOGIN', payload: json})

      setIsLoading(false)
    }
  }

  return { signup, isLoading, error}
} 
// frontend/src/pages/Signup.js
import { useState } from 'react'
import { useSignup } from '../hooks/useSignup'

const Signup = () => {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const {signup, error, isLoading} = useSignup()

  const handleSubmit = async (e) => {
    e.preventDefault()

    await signup(email, password)
  }

  return (
    <form className='signup' onSubmit={handleSubmit}>
      <h3>Sign up</h3>

      <label>Email:</label>
      <input
      type="email"
      onChange={(e) => setEmail(e.target.value)}
      value={email}
      />
      <label>Password:</label>
      <input
      type="password"
      onChange={(e) => setPassword(e.target.value)}
      value={password}
      />
      
      <button disabled={isLoading}>Sign up</button>
      {error && <div className='error'>{error}</div>}
    </form>
  )
}

export default Signup

#11 – Making a useLogout Hook

In this MERN auth tutorial, we’ll make a custom useLogout hook to log users out of the application.

  • 在 hooks 資料夾裡面建立 useLogout.js 檔案
  • 修改 useLogout.js 檔案
  • 修改 Navbar.js 檔案
  • 修改 index.css 檔案
  • 測試 Log out 能否正確刪除 Local Storage 資料
    console 檢視狀態
// frontend/src/hooks/useLogout.js
import { useAuthContext } from './useAuthContext'

export const useLogout = () => {
  const { dispatch } = useAuthContext()

  const logout = () => {
    // remove user from storage
    localStorage.removeItem('user')

    // dispatch logout action
    dispatch({type: 'LOGOUT'})
  }

  return {logout}
}
// frontend/src/components/Navbar.js
import { Link } from "react-router-dom"
import { useLogout } from '../hooks/useLogout'

const Navbar = () => {
  const { logout } = useLogout()

  const handleClick = () => {
    logout()
  }

  return (
    <header>
      <div className="container">
        <Link to="/">
          <h1>Workout Buddy</h1>
        </Link>
        <nav>
          <div>
            <button onClick={handleClick}>Log out</button>
          </div>
          <div>
            <Link to="/login">Login</Link>
            <Link to="/signup">Signup</Link>
          </div>
        </nav>
      </div>
    </header>
  )
}

export default Navbar
// frontend/src/index.css
/* google font */
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;800&family=VT323&display=swap');

/* layout */
:root {
  --primary: #1aac83;
  --error: #e7195a;
}
body {
  background: #f1f1f1;
  margin: 0;
  font-family: "Poppins";
}
header {
  background: #fff;
}
header .container {
  max-width: 1400px;
  margin: 0 auto;
  padding: 10px 20px;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
header a {
  color: #333;
  text-decoration: none;
}
.pages{
  max-width: 1400px;
  padding: 20px;
  margin: 0 auto;
}

/* homepage */
.home {
  display: grid;
  grid-template-columns: 3fr 1fr;
  gap: 100px;
}

.workout-details {
  background: #fff;
  border-radius: 4px;
  margin: 20px auto;
  padding: 20px;
  position: relative;
  box-shadow: 2px 2px 5px rgba(0,0,0,0.05);
}

.workout-details h4 {
  margin: 0 0 10px 0;
  font-size: 1.2em;
  color: var(--primary);
}

.workout-details p {
  margin: 0;
  font-size: 0.9em;
  color: #555;
}

.workout-details span {
  position: absolute;
  top: 20px;
  right: 20px;
  cursor: pointer;
  background: #f1f1f1;
  padding: 6px;
  border-radius: 50%;
  color: #333;
}

/* new workout form */
label, input {
  display: block;
}

input {
  padding: 10px;
  margin-top: 10px;
  margin-bottom: 20px;
  width: 100%;
  border: 1px solid #ddd;
  border-radius: 4px;
  box-sizing: border-box;
}

form button {
  background: var(--primary);
  border: 0;
  color: #fff;
  padding: 10px;
  font-family: "Poppins";
  border-radius: 4px;
  cursor: pointer;
}

div.error {
  padding: 10px;
  background: #ffefef;
  border: 1px solid var(--error);
  color: var(--error);
  border-radius: 4px;
  margin: 20px 0;
}

input.error {
  border: 1px solid var(--error);
}

/* navbar */
nav {
  display: flex;
  align-items: center;
}

nav a {
  margin-left: 10px;
}

nav button {
  background: #fff;
  color: var(--primary);
  border: 2px solid var(--primary);
  padding: 6px 10px;
  border-radius: 4px;
  font-family: "Poppins";
  cursor: pointer;
  font-size: 1em;
}

/* auth forms */
form.signup, form.login {
  max-width: 400px;
  margin: 40px auto;
  padding: 20px;
  background: #fff;
  border-radius: 4px;
}

#12 – Making a useLogin Hook

In this MERN auth tutorial, we’ll make a custom useLogin hook to log users in to the application.

  • 在 hooks 資料夾裡面建立 useLogin.js 檔案
  • 修改 useLogin.js 檔案,複製 useSignup.js 程式碼做修改
  • 修改 Login.js 檔案
  • 註冊好帳號,測試登入帳號
    講解 Local Storage、Token、Refresh
// frontend/src/hooks/useLogin.js
import { useState } from 'react'
import { useAuthContext } from './useAuthContext'

export const useLogin = () => {
  const [error, setError] = useState(null)
  const [isLoading, setIsLoading] = useState(null)
  const { dispatch } = useAuthContext()

  const login = async (email, password) => {
    setIsLoading(true)
    setError(null)

    const response = await fetch('/api/user/login', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({email, password})
    })
    const json = await response.json()

    if (!response.ok) {
      setIsLoading(false)
      setError(json.error)
    }
    if (response.ok) {
      // save the user to local storage
      localStorage.setItem('user', JSON.stringify(json))

      // update the auth context
      dispatch({type: 'LOGIN', payload: json})

      setIsLoading(false)
    }
  }

  return { login, isLoading, error}
} 
// frontend/src/pages/Login.js
import { useState } from 'react'
import { useLogin } from '../hooks/useLogin'

const Login = () => {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const {login, error, isLoading} = useLogin()

  const handleSubmit = async (e) => {
    e.preventDefault()

    await login(email, password)
  }

  return (
    <form className='login' onSubmit={handleSubmit}>
      <h3>Log in</h3>

      <label>Email:</label>
      <input
      type="email"
      onChange={(e) => setEmail(e.target.value)}
      value={email}
      />
      <label>Password:</label>
      <input
      type="password"
      onChange={(e) => setPassword(e.target.value)}
      value={password}
      />
      
      <button disabled={isLoading}>Log in</button>
      {error && <div className='error'>{error}</div>}
    </form>
  )
}

export default Login

#13 – Setting the Initial Auth Status

  • 修改 Navbar.js 檔案,匯入 useAuthContext
  • 測試登入表單確認功能是否正常
  • 重整頁面後會回到登入頁面
  • 修改 AuthContext.js 檔案,useEffect
  • 重整頁面後仍會保持在登入狀態
  • 測試登出、登入功能是否正常
// frontend/src/components/Navbar.js
import { Link } from "react-router-dom"
import { useLogout } from '../hooks/useLogout'
import { useAuthContext } from "../hooks/useAuthContext"

const Navbar = () => {
  const { logout } = useLogout()
  const { user } = useAuthContext()

  const handleClick = () => {
    logout()
  }

  return (
    <header>
      <div className="container">
        <Link to="/">
          <h1>Workout Buddy</h1>
        </Link>
        <nav>
          {user && (
            <div>
              <span>{user.email}</span>
              <button onClick={handleClick}>Log out</button>
            </div>
          )}
          {!user && (
            <div>
              <Link to="/login">Login</Link>
              <Link to="/signup">Signup</Link>
            </div>
          )}
        </nav>
      </div>
    </header>
  )
}

export default Navbar
// frontend/src/context/AuthContext.js
import { createContext, useReducer, useEffect } from 'react'

export const AuthContext = createContext()

export const authReducer = (state, action) => {
  switch (action.type) {
    case 'LOGIN':
      return { user: action.payload }
    case 'LOGOUT':
      return { user: null}
    default:
      return state
  }
}

export const AuthContextProvider = ({ children }) => {
  const [state, dispatch] = useReducer(authReducer, {
    user: null
  })

  useEffect(() => {
    const user = JSON.parse(localStorage.getItem('user'))

    if (user) {
      dispatch({ type: 'LOGIN', payload: user })
    }
  }, [])

  console.log('AuthContext State: ', state)

  return (
    <AuthContext.Provider value={{...state, dispatch}}>
      { children }
    </AuthContext.Provider>
  )
}

#14 – Protecting API Routes

In this MERN auth tutorial, you’ll learn how to protect certain API routes from unauthenticated users.

  • 在 backend 資料夾建立 middleware 資料夾
  • 在 middleware 資料夾裡面建立 requireAuth.js 檔案
  • 查看 workouts.js 檔案
  • 修改 requireAuth.js 檔案
  • 修改 workouts.js 檔案,requireAuth
  • 使用 API 測試工具 Postman
    http://localhost:4000/api/workouts/,GET 方法
    Auth > Bearer > Bearer Token
    http://localhost:4000/api/user/login,POST 方法
    取得 token 資料
// backend/middleware/requireAuth.js
const jwt = require('jsonwebtoken')
const User = require('../models/userModel')

const requireAuth = async (req, res, next) => {

  // verify authentication
  const { authorization } = req.headers

  if (!authorization) {
    return res.status(401).json({error: 'Authorization token required'})
  }

  const token = authorization.split(' ')[1]

  try {
    const {_id} = jwt.verify(token, process.env.SECRET)

    req.user = await User.findOne({ _id }).select('_id')
    next()

  } catch (error) {
    console.log(error);
    res.status(401).json({error: 'Request is not authorized'})
  }

}

module.exports = requireAuth
// backend/routes/workouts.js
const express = require('express')
const {
  createWorkout,
  getWorkouts,
  getWorkout,
  deleteWorkout,
  updateWorkout
} = require('../controllers/workoutController')
const requireAuth = require('../middleware/requireAuth')

const router = express.Router()

// require auth for all workout routes
router.use(requireAuth)

// GET all workouts
router.get('/', getWorkouts)

// GET a single workout
router.get('/:id', getWorkout)

// POST a new workout
router.post('/', createWorkout)

// DELETE a workout
router.delete('/:id', deleteWorkout)

// UPDATE a workout
router.patch('/:id', updateWorkout)

module.exports = router

#15 – Making Authorized Requests

  • 需要調整的有三個地方,Home.js、WorkoutForm.js、WorkoutDetails.js 檔案
  • 修改 Home.js 檔案
  • 修改 WorkoutForm.js 檔案
  • 修改 WorkoutDetails.js 檔案
  • 使用 Add a New Workout 測試功能是否正常
  • 登入帳號才能新增、刪除 Workout
// frontend/src/pages/Home.js
import { useEffect } from 'react'
import { useWorkoutsContext } from '../hooks/useWorkoutsContext'
import { useAuthContext } from '../hooks/useAuthContext'

// components
import WorkoutDetails from '../components/WorkoutDetails'
import WorkoutForm from '../components/WorkoutForm'

const Home = () => {
  const {workouts, dispatch} = useWorkoutsContext()
  const {user} = useAuthContext()
  
  useEffect(() => {
    const fetchWorkouts = async () => {
      const response = await fetch('/api/workouts', {
        headers: {
          'Authorization': `Bearer ${user.token}`
        }
      })
      const json = await response.json()

      if (response.ok) {
        dispatch({type: 'SET_WORKOUTS', payload: json})
      }
    }

    if (user) {
      fetchWorkouts()
    }
  }, [dispatch, user])
  
  return (
    <div className="home">
      <div className="workouts">
        {workouts && workouts.map((workout) => {
          return <WorkoutDetails key={workout._id} workout={workout} />
        })}
      </div>
      <WorkoutForm />
    </div>
  )
}

export default Home
// frontend/src/components/WorkoutForm.js
import { useState } from "react"
import { useWorkoutsContext } from "../hooks/useWorkoutsContext"
import { useAuthContext } from "../hooks/useAuthContext"

const WorkoutForm = () => {
  const { dispatch } = useWorkoutsContext()
  const { user } = useAuthContext()

  const [title, setTitle] = useState('')
  const [load, setLoad] = useState('')
  const [reps, setReps] = useState('')
  const [error, setError] = useState(null)
  const [emptyFields, setEmptyFields] = useState([])

  const handleSubmit = async (e) => {
    e.preventDefault()

    if (!user) {
      setError('You must be logged in')
      return
    }

    const workout = {title, load, reps}

    const response = await fetch('/api/workouts', {
      method: 'POST',
      body: JSON.stringify(workout),
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${user.token}`
      }
    })
    const json = await response.json()

    if (!response.ok) {
      setError(json.error)
      setEmptyFields(json.emptyFields)
    }
    if (response.ok) {
      setTitle('')
      setLoad('')
      setReps('')
      setError(null)
      setEmptyFields([])
      console.log('new workout added', json);
      dispatch({type: 'CREATE_WORKOUT', payload: json})
    }
  }

  return (
    <form className="create" onSubmit={handleSubmit}>
      <h3>Add a New Workout</h3>

      <label>Exercise Title:</label>
      <input
        type="text"
        onChange={(e) => setTitle(e.target.value)}
        value={title}
        className={emptyFields.includes('title') ? 'error' : ''}
      />

      <label>Load (in kg):</label>
      <input
        type="number"
        onChange={(e) => setLoad(e.target.value)}
        value={load}
        className={emptyFields.includes('load') ? 'error' : ''}
      />

      <label>Reps:</label>
      <input
        type="number"
        onChange={(e) => setReps(e.target.value)}
        value={reps}
        className={emptyFields.includes('reps') ? 'error' : ''}
      />

      <button>Add Workout</button>
      {error && <div className="error">{error}</div>}
    </form>
  )
}

export default WorkoutForm
// frontend/src/components/WorkoutDetails.js
import { useWorkoutsContext } from '../hooks/useWorkoutsContext'
import { useAuthContext } from '../hooks/useAuthContext'

// date fns
import formatDistanceToNow from 'date-fns/formatDistanceToNow'

const WorkoutDetails = ({ workout }) => {
  const { dispatch } = useWorkoutsContext()
  const { user } = useAuthContext()

  const handleClick = async () => {
    if (!user) {
      return
    }
    const response = await fetch('/api/workouts/' + workout._id, {
      method: 'DELETE',
      headers: {
        'Authorization': `Bearer ${user.token}`
      }
    })
    const json = await response.json()

    if (response.ok) {
      dispatch({type: 'DELETE_WORKOUT', payload: json})
    }
  }

  return (
    <div className="workout-details">
      <h4>{workout.title}</h4>
      <p><strong>Load (kg): </strong>{workout.load}</p>
      <p><strong>Reps: </strong>{workout.reps}</p>
      <p>{formatDistanceToNow(new Date(workout.createdAt), { addSuffix: true })}</p>
      <span className='material-symbols-outlined' onClick={handleClick}>delete</span>
    </div>
  )
}

export default WorkoutDetails

#16 – Protecting React Routes

In this MERN auth tutorial we’ll protect some of the React routes from users that are not authenticated.

  • 修改 App.js 檔案,匯入 Navigate、useAuthContext
  • 修改 index.css 檔案
// frontend/src/App.js
import { BrowserRouter , Routes, Route, Navigate } from 'react-router-dom'
import { useAuthContext } from './hooks/useAuthContext'

// pages & components
import Home from './pages/Home'
import Login from './pages/Login';
import Signup from './pages/Signup';
import Navbar from './components/Navbar'

function App() {
  const { user } = useAuthContext()

  return (
    <div className="App">
        <BrowserRouter>
          <Navbar />
          <div className="pages">
            <Routes>
              <Route
                path="/"
                element={user ? <Home /> : <Navigate to="/login" />} 
              />
              <Route
                path="/login"
                element={!user ? <Login /> : <Navigate to="/" />} 
              />
              <Route
                path="/signup"
                element={!user ? <Signup /> : <Navigate to="/" />} 
              />
            </Routes>
          </div>
        </BrowserRouter>
    </div>
  );
}

export default App;
// frontend/src/index.css
/* google font */
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;800&family=VT323&display=swap');

/* layout */
:root {
  --primary: #1aac83;
  --error: #e7195a;
}
body {
  background: #f1f1f1;
  margin: 0;
  font-family: "Poppins";
}
header {
  background: #fff;
}
header .container {
  max-width: 1400px;
  margin: 0 auto;
  padding: 10px 20px;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
header a {
  color: #333;
  text-decoration: none;
}
.pages{
  max-width: 1400px;
  padding: 20px;
  margin: 0 auto;
}

/* homepage */
.home {
  display: grid;
  grid-template-columns: 3fr 1fr;
  gap: 100px;
}

.workout-details {
  background: #fff;
  border-radius: 4px;
  margin: 20px auto;
  padding: 20px;
  position: relative;
  box-shadow: 2px 2px 5px rgba(0,0,0,0.05);
}

.workout-details h4 {
  margin: 0 0 10px 0;
  font-size: 1.2em;
  color: var(--primary);
}

.workout-details p {
  margin: 0;
  font-size: 0.9em;
  color: #555;
}

.workout-details span {
  position: absolute;
  top: 20px;
  right: 20px;
  cursor: pointer;
  background: #f1f1f1;
  padding: 6px;
  border-radius: 50%;
  color: #333;
}

/* new workout form */
label, input {
  display: block;
}

input {
  padding: 10px;
  margin-top: 10px;
  margin-bottom: 20px;
  width: 100%;
  border: 1px solid #ddd;
  border-radius: 4px;
  box-sizing: border-box;
}

form button {
  background: var(--primary);
  border: 0;
  color: #fff;
  padding: 10px;
  font-family: "Poppins";
  border-radius: 4px;
  cursor: pointer;
}

div.error {
  padding: 10px;
  background: #ffefef;
  border: 1px solid var(--error);
  color: var(--error);
  border-radius: 4px;
  margin: 20px 0;
}

input.error {
  border: 1px solid var(--error);
}

/* navbar */
nav {
  display: flex;
  align-items: center;
}

nav a {
  margin-left: 10px;
}

nav button {
  background: #fff;
  color: var(--primary);
  border: 2px solid var(--primary);
  padding: 6px 10px;
  border-radius: 4px;
  font-family: "Poppins";
  cursor: pointer;
  font-size: 1em;
  margin-left: 10px;
}

/* auth forms */
form.signup, form.login {
  max-width: 400px;
  margin: 40px auto;
  padding: 20px;
  background: #fff;
  border-radius: 4px;
}

#17 – Assigning Workouts to Users

  • 刪除所有的 Workouts
  • 修改 workoutModel.js 檔案,user_id
  • 修改 workoutController.js 檔案,user_id
  • 增加 Workout 內容
    目前還是會在不同帳號看到相同內容
    分別在不同帳號增加 Workout 內容
  • 使用 API 測試工具 Postman,GET 方法
    http://localhost:4000/api/workouts/
    必須加上 Bearer
    可以看到 Response 的 user_id 的差異
  • 修改 workoutController.js,get all workouts 加入 user_id
  • 登入不同的帳號確認 Workout 內容
    問題: 會短暫看到其他人的 Workout 內容
  • clear global workouts state
    修改 useLogout.js 檔案,匯入 useWorkoutsContext
// backend/models/workoutModel.js
const mongoose = require('mongoose')

const Schema =  mongoose.Schema

const workoutSchema = new Schema({
  title: {
    type:String,
    required: true
  },
  reps: {
    type: Number,
    required: true
  },
  load: {
    type: Number,
    required: true
  },
  user_id: {
    type: String,
    required: true
  }
}, { timestamps: true })

module.exports = mongoose.model('Workout', workoutSchema)
// backend/controllers/workoutController.js
const Workout = require('../models/workoutModel')
const mongoose = require('mongoose')

// get all workouts
const getWorkouts = async (req, res) => {
  const user_id = req.user._id

  const workouts = await Workout.find({ user_id }).sort({createdAt: -1})

  res.status(200).json(workouts)
}


// get a single workout
const getWorkout = async (req, res) => {
  const { id } = req.params

  if (!mongoose.Types.ObjectId.isValid(id)) {
    return res.status(404).json({error: 'No such workout'})
  }

  const workout = await Workout.findById(id)

  if (!workout) {
    return res.status(404).json({error: 'No such workout'})
  }

  res.status(200).json(workout)
}


// create new workout
const createWorkout = async (req, res) => {
  const { title, load, reps } = req.body

  let emptyFields = []

  if(!title) {
    emptyFields.push('title')
  }
  if(!load) {
    emptyFields.push('load')
  }
  if(!reps) {
    emptyFields.push('reps')
  }
  if(emptyFields.length > 0) {
    return res.status(400).json({ error: 'Please fill in all fields', emptyFields })
  }

  // add doc to db
  try {
    const user_id = req.user._id
    const workout = await Workout.create({title, load, reps, user_id})
    res.status(200).json(workout)
  } catch (error) {
    res.status(400).json({ error: error.message })
  }
}

// delete a workout
const deleteWorkout = async (req, res) => {
  const { id } = req.params

  if (!mongoose.Types.ObjectId.isValid(id)) {
    return res.status(404).json({error: 'No such workout'})
  }

  const workout = await Workout.findOneAndDelete({_id: id})

  if (!workout) {
    return res.status(404).json({error: 'No such workout'})
  }

  res.status(200).json(workout)
}

// update a workout
const updateWorkout = async (req, res) => {
  const { id } = req.params

  if (!mongoose.Types.ObjectId.isValid(id)) {
    return res.status(404).json({error: 'No such workout'})
  }

  const workout = await Workout.findOneAndUpdate({_id: id}, {
    ...req.body
  })

  if (!workout) {
    return res.status(404).json({error: 'No such workout'})
  }

  res.status(200).json(workout)
}


module.exports = {
  getWorkouts,
  getWorkout,
  createWorkout,
  deleteWorkout,
  updateWorkout
}
// frontend/src/hooks/useLogout.js
import { useAuthContext } from './useAuthContext'
import { useWorkoutsContext } from './useWorkoutsContext'

export const useLogout = () => {
  const { dispatch } = useAuthContext()
  const { dispatch: workoutsDispatch } = useWorkoutsContext()

  const logout = () => {
    // remove user from storage
    localStorage.removeItem('user')

    // dispatch logout action
    dispatch({type: 'LOGOUT'})
    workoutsDispatch({type: 'SET_WORKOUTS', payload: null})
  }

  return {logout}
}