79
Help us understand the problem. What are the problem?

posted at

updated at

Rails API + React + devise_token_authでログイン機能を実装する

概要

主に個人開発においてRails(APIモード)+ReactでSPA(シングルページアプリケーション)を作成する事が多いのですが、ログイン(認証)部分の実装で毎度ごちゃごちゃと調べ直す事になるので、ちゃんとドキュメントとして残しておきたいと思います。

手順通りに進めれば最低限のものが作れるようになっているため、同じ様な構成のものを作成したいと考えている方は参考にしてみてください。

完成イメージ

rails-react-login.gif

特に何の変哲もない光景ですが、SPAでこれをやろうとするとそれなりに手間がかかったりします。

使用技術

  • バックエンド
    • Ruby3
    • Rails6(APIモード)
    • MySQL8
    • Docker
  • フロントエンド
    • React
    • TypeScript
    • Material-UI

今回は再現性を考慮してバックエンドのみDockerで環境構築していきます。

バックエンド

早速コードを書いていきましょう。まずバックエンドから。

作業ディレクトリ&各種ファイルを作成

$ mkdir rails-react-auth && cd rails-react-auth
$ mkdir backend && cd backend
$ touch Dockerfile docker-compose.yml entrypoint.sh Gemfile Gemfile.lock
./Dockerfile
FROM ruby:3.0

RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs

ENV APP_PATH /myapp

RUN mkdir $APP_PATH
WORKDIR $APP_PATH

COPY Gemfile $APP_PATH/Gemfile
COPY Gemfile.lock $APP_PATH/Gemfile.lock
RUN bundle install

COPY . $APP_PATH

COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000

CMD ["rails", "server", "-b", "0.0.0.0"]
./docker-compose.yml
version: "3"
services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: password
    command: --default-authentication-plugin=mysql_native_password
    volumes:
      - mysql-data:/var/lib/mysql
      - /tmp/dockerdir:/etc/mysql/conf.d/
    ports:
      - 3306:3306
  api:
    build:
      context: .
      dockerfile: Dockerfile
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/myapp
      - ./vendor/bundle:/myapp/vendor/bundle
    environment:
      TZ: Asia/Tokyo
      RAILS_ENV: development
    ports:
      - "3001:3000"
    depends_on:
      - db
volumes:
  mysql-data:
./entrypoint.sh
#!/bin/bash
set -e

# Remove a potentially pre-existing server.pid for Rails.
rm -f /myapp/tmp/pids/server.pid

# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"
./Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "rails", "~> 6"
/Gemfile.lock
# 空欄でOK

最終的に次のような構成になっていればOK。

rails-react-auth
└── backend
    ├── Dockerfile
    ├── Gemfile
    ├── Gemfile.lock
    ├── docker-compose.yml
    └── entrypoint.sh

rails new

おなじみのコマンドでプロジェクトを作成します。

$ docker-compose run api rails new . --force --no-deps -d mysql --api

Gemfileが更新されたので再ビルド。

$ docker-compose build

database.ymlを編集

./config/database.yml
default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password: password # デフォルトだと空欄になっているはずなので変更
  host: db # デフォルトだとlocalhostになっているはずなので変更

development:
  <<: *default
  database: myapp_development

test:
  <<: *default
  database: myapp_test

production:
  <<: *default
  database: <%= ENV["DATABASE_NAME"] %>
  username: <%= ENV["DATABASE_USERNAME"] %>
  password: <%= ENV["DATABASE_PASSWORD"] %>
  host: <%= ENV["DATABASE_HOST"] %>

データベースを作成

$ docker-compose run api rails db:create

動作確認

$ docker-compose up -d

スクリーンショット 2021-05-28 22.57.00.png

localhost:3001 にアクセスしていつもの画面が表示されればOK。

テストAPIを作成

動作確認用のテストAPiを作成します。

$ docker-compose run api rails g controller api/v1/test index
./app/controllers/api/v1/test_controller.rb
class Api::V1::TestController < ApplicationController
  def index
    render json: { message: "Hello World!"}
  end
end
./config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :test, only: %i[index]
    end
  end
end

curlコマンドで呼び出してみましょう。

$ curl http://localhost:3001/api/v1/test

{
  "message": "Hello World!"
}

正常にJSONが返ってくればOK。

CORS設定

今のままの状態でReact(クライアント)から直接呼び出そうとするとCORSエラーで弾かれてしまうため、その辺の設定を行います。

参照記事: CORSとは?

Railsの場合、CORS設定を簡単に行えるgemが存在するのでそちらを使いましょう。

rb./Gemfile
gem 'rack-cors'

今回はAPIモードで作成しているため、すでにGemfile内に記載されているはず。そちらのコメントアウトを外すだけでOKです。

$ docker-compose build

Gemfileを更新したので再度ビルド。

./config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "localhost:3000" # React側はポート番号3000で作るので「localhost:3000」を指定

    resource "*",
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

あとは「./config/initializers/cors.rb」をいじくって外部ドメインからアクセス可能なようにしておきます。

deviseをインストール

今回は devise でログイン機能を作成していきます。

./Gemfile
gem 'devise'
gem 'devise_token_auth'

「devise_token_auth」も忘れずに記述。

$ docker-compose build

Gemfileを更新したので再度ビルド。

$ docker-compose run api rails g devise:install
$ docker-compose run api rails g devise_token_auth:install User auth
$ docker-compose run api rails db:migrate

devise本体とdevise_token_authをインストールし、Userモデルを作成します。

./app/config/initializers/devise_token_auth.rb
# ...省略...

# ヘッダー名の設定
config.headers_names = {:'access-token' => 'access-token',
                         :'client' => 'client',
                         :'expiry' => 'expiry',
                         :'uid' => 'uid',
                         :'token-type' => 'token-type' }

# ...省略...

「./app/config/initializers/devise_token_auth.rb」という設定ファイルが自動生成されているはずなので、45行目あたりにある記述のコメントアウトを外してください。

./config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "localhost:3000" # React側はポート番号3000で作るので「localhost:3000」を指定

    resource "*",
      headers: :any,
      expose: ["access-token", "expiry", "token-type", "uid", "client"], # 追記
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

また、ヘッダー情報を外部に公開するため、「./config/initializers/cors.rb」を上記のように修正します。

メール認証設定

今回はサンプルという事もあり、簡略化のためアカウント作成時にメール認証は行わない方向で進めますが、後ほど実運用を想定した場合は必ず必要になると思うので一応やっておきます。

./config/environments/development.rb
config.action_mailer.default_url_options = { host: 'localhost', port: 3001 }

なお、開発環境でメール確認を行う場合は letter_opener_web などが便利だと思います。

各種コントローラーを作成&修正

各種コントローラーの作成および修正を行います。

$ docker-compose run api rails g controller api/v1/auth/registrations
$ docker-compose run api rails g controller api/v1/auth/sessions 
./app/controllers/api/v1/auth/registrations_controller.rb
# アカウント作成用コントローラー
class Api::V1::Auth::RegistrationsController < DeviseTokenAuth::RegistrationsController
  private

    def sign_up_params
      params.permit(:email, :password, :password_confirmation, :name)
    end
end
./app/controllers/api/v1/auth/sessions_controller.rb
# ログイン状態確認用コントローラー
class Api::V1::Auth::SessionsController < ApplicationController
  def index
    if current_api_v1_user
      render json: { is_login: true, data: current_api_v1_user }
    else
      render json: { is_login: false, message: "ユーザーが存在しません" }
    end
  end
end
./app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include DeviseTokenAuth::Concerns::SetUserByToken

  skip_before_action :verify_authenticity_token
  helper_method :current_user, :user_signed_in?
end

deviseにおいて「current_user」というヘルパーメソッドは定番ですが、今回はルーティングの部分で「api/v1/」というnamespaceを切る(後述)ので「current_api_v1_user」としなければならない点に注意です。

ルーティングを設定

ルーティングの設定もお忘れなく。

./app/config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :test, only: %i[index]

      mount_devise_token_auth_for 'User', at: 'auth', controllers: {
        registrations: 'api/v1/auth/registrations'
      }

      namespace :auth do
        resources :sessions, only: %i[index]
      end
    end
  end
end

動作確認

早速ですが、curlコマンドでアカウント作成およびログインができるか試しみてましょう。

アカウント作成

$ curl -X POST http://localhost:3001/api/v1/auth -d '[name]=test&[email]=test@example.com&[password]=password&[password_confirmation]=password'

{
  "status": "success",
  "data": {
    "email": "test@example.com",
    "uid": "test@example.com",
    "id": 1,
    "provider": "email",
    "allow_password_change": false,
    "name": "test",
    "nickname": null,
    "image": null,
    "created_at": "2021-05-28T14:58:54.421Z",
    "updated_at": "2021-05-28T14:58:54.541Z"
  }
}

ログイン

$ curl -X POST -v http://localhost:3001/api/v1/auth/sign_in -d '[email]=test@example.com&[password]=password'

Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 3001 (#0)
> POST /api/v1/auth/sign_in HTTP/1.1
> Host: localhost:3001
> User-Agent: curl/7.64.1
> Accept: */*
> Content-Length: 44
> Content-Type: application/x-www-form-urlencoded
> 
* upload completely sent off: 44 out of 44 bytes
< HTTP/1.1 200 OK
< X-Frame-Options: SAMEORIGIN
< X-XSS-Protection: 1; mode=block
< X-Content-Type-Options: nosniff
< X-Download-Options: noopen
< X-Permitted-Cross-Domain-Policies: none
< Referrer-Policy: strict-origin-when-cross-origin
< Content-Type: application/json; charset=utf-8
< Vary: Accept, Origin
< access-token: *************
< token-type: Bearer
< client: *************
< expiry: 1623423903
< uid: test@example.com
< ETag: W/"fc564a9145d11564e827b204d5a4ce36"
< Cache-Control: max-age=0, private, must-revalidate
< X-Request-Id: e466f4a8-003e-4d06-8467-6b74f1bd91de
< X-Runtime: 0.341324
< Transfer-Encoding: chunked
< 
* Connection #0 to host localhost left intact

{
  "data": {
    "email": "test@example.com",
    "uid": "test@example.com",
    "id": 1,
    "provider": "email",
    "allow_password_change": false,
    "name": "test",
    "nickname": null,
    "image": null
  }
}

* Closing connection 0

それぞれこんな感じで返ってくれば無事成功です。

もしこれまでの流れで何かエラーが発生している場合、大体はコンテナの再起動を行っていない事が原因だと思うので、

$ docker-compose restart

などで再起動をかけてください。

※「./config/initializers/」などをいじくっているため、それらを反映させるためには再起動が必要になります。

なお、ログイン時に返ってくる

  • access-token
  • client
  • uid

この3つは後ほどReact側で認証を行う際に必要となる値なので、重要なものだと頭に入れておきましょう。

フロントエンド

ログイン機能の作成が終わったので、次はフロントエンドの実装に入ります。

create-react-app

おなじみの「create-react-app」でアプリの雛形を作ります。

# ルートディレクトリに移動した後
$ mkdir frontend && cd frontend
$ yarn create react-app . --template typescript

tsconfig.jsonを修正

./tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,

    ...省略...

    "baseUrl": "src" // 追加
  },
  "include": [
    "src"
  ]
}

「./tsconfig.json」内に「"baseUrl": "src"」という記述を追加してください。

これにより、インポート先を「./tsconfig.json」が配置されているディレクトリから相対パスで指定できるようになるので非常に楽です。

baseUrlを指定しない場合

import Hoge from "../../components/Hoge" // 呼び出すファイルからの相対パス

baseUrlを指定した場合

import Hoge from "components/Hoge" // baseUrlからの相対パス

不要なファイルを整理

この先使う事の無いファイルは邪魔なので今のうちに消しておきましょう。

$ rm src/App.css src/App.test.tsx src/logo.svg src/reportWebVitals.ts src/setupTests.ts 

「./src/index.tsx」と「./src/App.tsx」を次のように変更します。

./src/index.tsx
import React from "react"
import ReactDOM from "react-dom"
import "./index.css"
import App from "./App"

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
)
./src/App.tsx
import React from "react"

const App: React.FC = () => {
  return (
    <h1>Hello World!</h1>
  )
}

export default App

一旦、動作確認してみましょう。

$ yarn start

スクリーンショット 2021-05-04 0.37.55.png

localhost:3000 にアクセスして「Hello World!」と返ってくればOK。

各種ディレクトリ・ファイルを準備

$ mkdir components
$ mkdir components/layouts
$ mkdir components/pages
$ mkdir components/utils
$ mkdir interfaces
$ mkdir lib
$ mkdir lib/api

$ touch components/layouts/CommonLayout.tsx
$ touch components/layouts/Header.tsx

$ touch components/pages/Home.tsx
$ touch components/pages/SignIn.tsx
$ touch components/pages/SignUp.tsx

$ touch components/utils/AlertMessage.tsx

$ touch interfaces/index.ts

$ touch lib/api/client.ts
$ touch lib/api/auth.ts
$ touch lib/api/test.ts

$ mv components interfaces lib src

最終的に次のような構成になっていればOK。

rails-react-auth
├── backend
└── frontend
    ├── public
    │   ├── favicon.ico
    │   ├── index.html
    │   ├── logo192.png
    │   ├── logo512.png
    │   ├── manifest.json
    │   └── robots.txt
    ├── src
    │   ├── components
    │   │   ├── layouts
    │   │   │   ├── CommonLayout.tsx
    │   │   │   └── Header.tsx
    │   │   ├── pages
    │   │   │   ├── Home.tsx
    │   │   │   ├── SignIn.tsx
    │   │   │   └── SignUp.tsx
    │   │   └── utils
    │   │       └── AlertMessage.tsx
    │   ├── interfaces
    │   │   └── index.ts
    │   ├── lib
    │   │   └── api
    │   │       ├── auth.ts
    │   │       ├── client.ts
    │   │       └── test.ts
    │   ├── App.tsx
    │   ├── index.css
    │   ├── index.tsx
    │   └── react-app-env.d.ts
    ├── .gitignore
    ├── package.json
    ├── README.md
    ├── tsconfig.json
    └── yarn.lock

各種ライブラリをインストール

$ yarn add @material-ui/core @material-ui/icons @material-ui/lab axios axios-case-converter js-cookie react-router-dom
$ yarn add -D @types/axios @types/js-cookie @types/react-router-dom
  • material-ui
    • UIを整える用のライブラリ
  • axios
    • HTTPクライアント用のライブラリ
  • js-cookie
    • Cookie操作用のライブラリ
  • react-router-dom
    • ルーティング設定用のライブラリ
  • @types/◯○
    • 型定義用のライブラリ

型定義

./src/interfaces/index.ts
// サインアップ
export interface SignUpParams {
  name: string
  email: string
  password: string
  passwordConfirmation: string
}

// サインイン
export interface SignInParams {
  email: string
  password: string
}

// ユーザー
export interface User {
  id: number
  uid: string
  provider: string
  email: string
  name: string
  nickname?: string
  image?: string
  allowPasswordChange: boolean
  created_at: Date
  updated_at: Date
}

APIクライアントを作成

./src/lib/api/client.ts
import applyCaseMiddleware from "axios-case-converter"
import axios from "axios"

// applyCaseMiddleware:
// axiosで受け取ったレスポンスの値をスネークケース→キャメルケースに変換
// または送信するリクエストの値をキャメルケース→スネークケースに変換してくれるライブラリ

// ヘッダーに関してはケバブケースのままで良いので適用を無視するオプションを追加
const options = {
  ignoreHeaders: true 
}

const client = applyCaseMiddleware(axios.create({
  baseURL: "http://localhost:3001/api/v1"
}), options)

export default client

慣習的にRubyなどの言語がスネークケースが基本であるのに対し、JavaScriptはキャメルケースが基本となるため、足並みを揃える(スネークケース→キャメルケースへの変換もしくはその逆)ために「applyCaseMiddleware」というライブラリを使わせてもらっています。

./src/lib/api/test.ts
import client from "lib/api/client"

// 動作確認用
export const execTest = () => {
  return client.get("/test")
}

今回は特に使用しませんが、もしRails ⇄ React間で通信が上手くいかない場合、テスト用のAPIを使ってデバッグに役立ててください。

./src/lib/api/auth.ts
import client from "lib/api/client"
import Cookies from "js-cookie"

import { SignUpParams, SignInParams } from "interfaces/index"

// サインアップ(新規アカウント作成)
export const signUp = (params: SignUpParams) => {
  return client.post("auth", params)
}

// サインイン(ログイン)
export const signIn = (params: SignInParams)  => {
  return client.post("auth/sign_in", params)
}

// サインアウト(ログアウト)
export const signOut = () => {
  return client.delete("auth/sign_out", { headers: {
    "access-token": Cookies.get("_access_token"),
    "client": Cookies.get("_client"),
    "uid": Cookies.get("_uid")
  }})  
}

// 認証済みのユーザーを取得
export const getCurrentUser = () => {
  if (!Cookies.get("_access_token") || !Cookies.get("_client") || !Cookies.get("_uid")) return
  return client.get("/auth/sessions", { headers: {
    "access-token": Cookies.get("_access_token"),
    "client": Cookies.get("_client"),
    "uid": Cookies.get("_uid")
  }})
}

ログイン中かどうかを判別するための各値(access-token、client、uid)をどこで保持するかについて、色々と議論の余地はあるようですが、今回はCookie内に含める事とします。

参照: React(SPA)での認証についてまとめ

ビューを作成

それぞれの記述が何を表しているのか、出来る限りコメントを付けたので何とか解読してください。

./src/App.tsx
import React, { useState, useEffect, createContext } from "react"
import { BrowserRouter as Router, Switch, Route, Redirect } from "react-router-dom"

import CommonLayout from "components/layouts/CommonLayout"
import Home from "components/pages/Home"
import SignUp from "components/pages/SignUp"
import SignIn from "components/pages/SignIn"

import { getCurrentUser } from "lib/api/auth"
import { User } from "interfaces/index"

// グローバルで扱う変数・関数
export const AuthContext = createContext({} as {
  loading: boolean
  setLoading: React.Dispatch<React.SetStateAction<boolean>>
  isSignedIn: boolean
  setIsSignedIn: React.Dispatch<React.SetStateAction<boolean>>
  currentUser: User | undefined
  setCurrentUser: React.Dispatch<React.SetStateAction<User | undefined>>
})

const App: React.FC = () => {
  const [loading, setLoading] = useState<boolean>(true)
  const [isSignedIn, setIsSignedIn] = useState<boolean>(false)
  const [currentUser, setCurrentUser] = useState<User | undefined>()

  // 認証済みのユーザーがいるかどうかチェック
  // 確認できた場合はそのユーザーの情報を取得
  const handleGetCurrentUser = async () => {
    try {
      const res = await getCurrentUser()

      if (res?.data.isLogin === true) {
        setIsSignedIn(true)
        setCurrentUser(res?.data.data)

        console.log(res?.data.data)
      } else {
        console.log("No current user")
      }
    } catch (err) {
      console.log(err)
    }

    setLoading(false)
  }

  useEffect(() => {
    handleGetCurrentUser()
  }, [setCurrentUser])


  // ユーザーが認証済みかどうかでルーティングを決定
  // 未認証だった場合は「/signin」ページに促す
  const Private = ({ children }: { children: React.ReactElement }) => {
    if (!loading) {
      if (isSignedIn) {
        return children
      } else {
        return <Redirect to="/signin" />
      }
    } else {
      return <></>
    }
  }

  return (
    <Router>
      <AuthContext.Provider value={{ loading, setLoading, isSignedIn, setIsSignedIn, currentUser, setCurrentUser}}>
        <CommonLayout>
          <Switch>
            <Route exact path="/signup" component={SignUp} />
            <Route exact path="/signin" component={SignIn} />
            <Private>
              <Route exact path="/" component={Home} />
            </Private>
          </Switch>
        </CommonLayout>
      </AuthContext.Provider>
    </Router>
  )
}

export default App
./src/components/layouts/Header.tsx
import React, { useContext } from "react"
import { useHistory, Link } from "react-router-dom"
import Cookies from "js-cookie"

import { makeStyles, Theme } from "@material-ui/core/styles"

import AppBar from "@material-ui/core/AppBar"
import Toolbar from "@material-ui/core/Toolbar"
import Typography from "@material-ui/core/Typography"
import Button from "@material-ui/core/Button"
import IconButton from "@material-ui/core/IconButton"
import MenuIcon from "@material-ui/icons/Menu"

import { signOut } from "lib/api/auth"

import { AuthContext } from "App"

const useStyles = makeStyles((theme: Theme) => ({
  iconButton: {
    marginRight: theme.spacing(2),
  },
  title: {
    flexGrow: 1,
    textDecoration: "none",
    color: "inherit"
  },
  linkBtn: {
    textTransform: "none"
  }
}))

const Header: React.FC = () => {
  const { loading, isSignedIn, setIsSignedIn } = useContext(AuthContext)
  const classes = useStyles()
  const histroy = useHistory()

  const handleSignOut = async (e: React.MouseEvent<HTMLButtonElement>) => {
    try {
      const res = await signOut()

      if (res.data.success === true) {
        // サインアウト時には各Cookieを削除
        Cookies.remove("_access_token")
        Cookies.remove("_client")
        Cookies.remove("_uid")

        setIsSignedIn(false)
        histroy.push("/signin")

        console.log("Succeeded in sign out")
      } else {
        console.log("Failed in sign out")
      }
    } catch (err) {
      console.log(err)
    }
  }

  const AuthButtons = () => {
    // 認証完了後はサインアウト用のボタンを表示
    // 未認証時は認証用のボタンを表示
    if (!loading) {
      if (isSignedIn) {
        return (
          <Button
            color="inherit"
            className={classes.linkBtn}
            onClick={handleSignOut}
          >
            Sign out
          </Button>
        )
      } else {
        return (
          <>
            <Button
              component={Link}
              to="/signin"
              color="inherit"
              className={classes.linkBtn}
            >
              Sign in
            </Button>
            <Button
              component={Link}
              to="/signup"
              color="inherit"
              className={classes.linkBtn}
            >
              Sign Up
            </Button>
          </>
        )
      }
    } else {
      return <></>
    }
  }

  return (
    <>
      <AppBar position="static">
        <Toolbar>
          <IconButton
            edge="start"
            className={classes.iconButton}
            color="inherit"
          >
            <MenuIcon />
          </IconButton>
          <Typography
            component={Link}
            to="/"
            variant="h6"
            className={classes.title}
          >
            Sample
          </Typography>
          <AuthButtons />
        </Toolbar>
      </AppBar>
    </>
  )
}

export default Header
./src/components/layouts/CommonLayout.tsx
import React from "react"

import { Container, Grid } from "@material-ui/core"
import { makeStyles } from "@material-ui/core/styles"

import Header from "components/layouts/Header"

const useStyles = makeStyles(() => ({
  container: {
    marginTop: "3rem"
  }
}))

interface CommonLayoutProps {
  children: React.ReactElement
}

// 全てのページで共通となるレイアウト
const CommonLayout = ({ children }: CommonLayoutProps) => {
  const classes = useStyles()

  return (
    <>
      <header>
        <Header />
      </header>
      <main>
        <Container maxWidth="lg" className={classes.container}>
          <Grid container justify="center">
            <Grid item>
              {children}
            </Grid>   
          </Grid>
        </Container>
      </main>
    </>
  )
}

export default CommonLayout
./src/components/pages/Home.tsx
import React, { useContext } from "react"

import { AuthContext } from "App"

// とりあえず認証済みユーザーの名前やメールアドレスを表示
const Home: React.FC = () => {
  const { isSignedIn, currentUser } = useContext(AuthContext)

  return (
    <>
      {
        isSignedIn && currentUser ? (
          <>
            <h1>Signed in successfully!</h1>
            <h2>Email: {currentUser?.email}</h2>
            <h2>Name: {currentUser?.name}</h2>
          </>
        ) : (
          <h1>Not signed in</h1>
        )
      }
    </>
  )
}

export default Home
./src/components/pages/SignIn.tsx
import React, { useState, useContext } from "react"
import { useHistory, Link } from "react-router-dom"
import Cookies from "js-cookie"

import { makeStyles, Theme } from "@material-ui/core/styles"
import { Typography } from "@material-ui/core"
import TextField from "@material-ui/core/TextField"
import Card from "@material-ui/core/Card"
import CardContent from "@material-ui/core/CardContent"
import CardHeader from "@material-ui/core/CardHeader"
import Button from "@material-ui/core/Button"
import Box from "@material-ui/core/Box"

import { AuthContext } from "App"
import AlertMessage from "components/utils/AlertMessage"
import { signIn } from "lib/api/auth"
import { SignInParams } from "interfaces/index"

const useStyles = makeStyles((theme: Theme) => ({
  container: {
    marginTop: theme.spacing(6)
  },
  submitBtn: {
    marginTop: theme.spacing(2),
    flexGrow: 1,
    textTransform: "none"
  },
  header: {
    textAlign: "center"
  },
  card: {
    padding: theme.spacing(2),
    maxWidth: 400
  },
  box: {
    marginTop: "2rem"
  },
  link: {
    textDecoration: "none"
  }
}))

// サインイン用ページ
const SignIn: React.FC = () => {
  const classes = useStyles()
  const history = useHistory()

  const { setIsSignedIn, setCurrentUser } = useContext(AuthContext)

  const [email, setEmail] = useState<string>("")
  const [password, setPassword] = useState<string>("")
  const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false)

  const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault()

    const params: SignInParams = {
      email: email,
      password: password
    }

    try {
      const res = await signIn(params)
      console.log(res)

      if (res.status === 200) {
        // ログインに成功した場合はCookieに各値を格納
        Cookies.set("_access_token", res.headers["access-token"])
        Cookies.set("_client", res.headers["client"])
        Cookies.set("_uid", res.headers["uid"])

        setIsSignedIn(true)
        setCurrentUser(res.data.data)

        history.push("/")

        console.log("Signed in successfully!")
      } else {
        setAlertMessageOpen(true)
      }
    } catch (err) {
      console.log(err)
      setAlertMessageOpen(true)
    }
  }

  return (
    <>
      <form noValidate autoComplete="off">
        <Card className={classes.card}>
          <CardHeader className={classes.header} title="Sign In" />
          <CardContent>
            <TextField
              variant="outlined"
              required
              fullWidth
              label="Email"
              value={email}
              margin="dense"
              onChange={event => setEmail(event.target.value)}
            />
            <TextField
              variant="outlined"
              required
              fullWidth
              label="Password"
              type="password"
              placeholder="At least 6 characters"
              value={password}
              margin="dense"
              autoComplete="current-password"
              onChange={event => setPassword(event.target.value)}
            />
            <Button
              type="submit"
              variant="contained"
              size="large"
              fullWidth
              color="default"
              disabled={!email || !password ? true : false} // 空欄があった場合はボタンを押せないように
              className={classes.submitBtn}
              onClick={handleSubmit}
            >
              Submit
            </Button>
            <Box textAlign="center" className={classes.box}>
              <Typography variant="body2">
                Don't have an account? &nbsp;
                <Link to="/signup" className={classes.link}>
                  Sign Up now!
                </Link>
              </Typography>
            </Box>
          </CardContent>
        </Card>
      </form>
      <AlertMessage // エラーが発生した場合はアラートを表示
        open={alertMessageOpen}
        setOpen={setAlertMessageOpen}
        severity="error"
        message="Invalid emai or password"
      />
    </>
  )
}

export default SignIn
./src/components/pages/SignUp.tsx
import React, { useState, useContext } from "react"
import { useHistory } from "react-router-dom"
import Cookies from "js-cookie"

import { makeStyles, Theme } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import Card from "@material-ui/core/Card"
import CardContent from "@material-ui/core/CardContent"
import CardHeader from "@material-ui/core/CardHeader"
import Button from "@material-ui/core/Button"

import { AuthContext } from "App"
import AlertMessage from "components/utils/AlertMessage"
import { signUp } from "lib/api/auth"
import { SignUpParams } from "interfaces/index"

const useStyles = makeStyles((theme: Theme) => ({
  container: {
    marginTop: theme.spacing(6)
  },
  submitBtn: {
    marginTop: theme.spacing(2),
    flexGrow: 1,
    textTransform: "none"
  },
  header: {
    textAlign: "center"
  },
  card: {
    padding: theme.spacing(2),
    maxWidth: 400
  }
}))

// サインアップ用ページ
const SignUp: React.FC = () => {
  const classes = useStyles()
  const histroy = useHistory()

  const { setIsSignedIn, setCurrentUser } = useContext(AuthContext)

  const [name, setName] = useState<string>("")
  const [email, setEmail] = useState<string>("")
  const [password, setPassword] = useState<string>("")
  const [passwordConfirmation, setPasswordConfirmation] = useState<string>("")
  const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false)

  const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault()

    const params: SignUpParams = {
      name: name,
      email: email,
      password: password,
      passwordConfirmation: passwordConfirmation
    }

    try {
      const res = await signUp(params)
      console.log(res)

      if (res.status === 200) {
        // アカウント作成と同時にログインさせてしまう
        // 本来であればメール確認などを挟むべきだが、今回はサンプルなので
        Cookies.set("_access_token", res.headers["access-token"])
        Cookies.set("_client", res.headers["client"])
        Cookies.set("_uid", res.headers["uid"])

        setIsSignedIn(true)
        setCurrentUser(res.data.data)

        histroy.push("/")

        console.log("Signed in successfully!")
      } else {
        setAlertMessageOpen(true)
      }
    } catch (err) {
      console.log(err)
      setAlertMessageOpen(true)
    }
  }

  return (
    <>
      <form noValidate autoComplete="off">
        <Card className={classes.card}>
          <CardHeader className={classes.header} title="Sign Up" />
          <CardContent>
            <TextField
              variant="outlined"
              required
              fullWidth
              label="Name"
              value={name}
              margin="dense"
              onChange={event => setName(event.target.value)}
            />
            <TextField
              variant="outlined"
              required
              fullWidth
              label="Email"
              value={email}
              margin="dense"
              onChange={event => setEmail(event.target.value)}
            />
            <TextField
              variant="outlined"
              required
              fullWidth
              label="Password"
              type="password"
              value={password}
              margin="dense"
              autoComplete="current-password"
              onChange={event => setPassword(event.target.value)}
            />
            <TextField
              variant="outlined"
              required
              fullWidth
              label="Password Confirmation"
              type="password"
              value={passwordConfirmation}
              margin="dense"
              autoComplete="current-password"
              onChange={event => setPasswordConfirmation(event.target.value)}
            />
            <Button
              type="submit"
              variant="contained"
              size="large"
              fullWidth
              color="default"
              disabled={!name || !email || !password || !passwordConfirmation ? true : false}
              className={classes.submitBtn}
              onClick={handleSubmit}
            >
              Submit
            </Button>
          </CardContent>
        </Card>
      </form>
      <AlertMessage // エラーが発生した場合はアラートを表示
        open={alertMessageOpen}
        setOpen={setAlertMessageOpen}
        severity="error"
        message="Invalid emai or password"
      />
    </>
  )
}

export default SignUp
./src/components/utils/AlertMessage.tsx
import React from "react"
import Snackbar from "@material-ui/core/Snackbar"
import MuiAlert, { AlertProps } from "@material-ui/lab/Alert"

const Alert = React.forwardRef<HTMLDivElement, AlertProps>(function Alert(
  props,
  ref,
) {
  return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} />
})

interface AlertMessageProps {
  open: boolean
  setOpen: Function
  severity: "error" | "success" | "info" | "warning"
  message: string
}

// アラートメッセージ(何かアクションを行なった際の案内用に使い回す)
const AlertMessage = ({ open, setOpen, severity, message}: AlertMessageProps) => {
  const handleCloseAlertMessage = (e?: React.SyntheticEvent, reason?: string) => {
    if (reason === "clickaway") return

    setOpen(false)
  }

  return (
    <>
      <Snackbar
        open={open}
        autoHideDuration={6000}
        anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
        onClose={handleCloseAlertMessage}
      >
        <Alert onClose={handleCloseAlertMessage} severity={severity}>
          {message}
        </Alert>
      </Snackbar>
    </>
  )
}

export default AlertMessage

動作確認

サインアップ

スクリーンショット 2021-05-29 1.33.54.png

サインイン

スクリーンショット 2021-05-29 1.49.30.png

トップページ

スクリーンショット 2021-05-29 1.50.33.png

アラートメッセージ

スクリーンショット 2021-05-29 1.49.54.png

特に問題が無さそうであれば完成です。

あとがき

以上、Rails API × React × devise_token_authでログイン機能を実装してみました。

長い事うろ覚えだった手順を今回しっかりまとめられて良かったです。同じ様な構成でポートフォリオなどを作成しようと考えている方もぜひ参考にしてみてください。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
79
Help us understand the problem. What are the problem?