概要
主に個人開発においてRails(APIモード)+ReactでSPA(シングルページアプリケーション)を作成する事が多いのですが、ログイン(認証)部分の実装で毎度ごちゃごちゃと調べ直す事になるので、ちゃんとドキュメントとして残しておきたいと思います。
手順通りに進めれば最低限のものが作れるようになっているため、同じ様な構成のものを作成したいと考えている方は参考にしてみてください。
完成イメージ
特に何の変哲もない光景ですが、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
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"]
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:
#!/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 "$@"
# frozen_string_literal: true
source "https://rubygems.org"
git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
gem "rails", "~> 6"
# 空欄で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を編集
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
localhost:3001 にアクセスしていつもの画面が表示されればOK。
テストAPIを作成
動作確認用のテストAPiを作成します。
$ docker-compose run api rails g controller api/v1/test index
class Api::V1::TestController < ApplicationController
def index
render json: { message: "Hello World!"}
end
end
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が存在するのでそちらを使いましょう。
gem 'rack-cors'
今回はAPIモードで作成しているため、すでにGemfile内に記載されているはず。そちらのコメントアウトを外すだけでOKです。
$ docker-compose build
Gemfileを更新したので再度ビルド。
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 でログイン機能を作成していきます。
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モデルを作成します。
# ...省略...
# ヘッダー名の設定
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行目あたりにある記述のコメントアウトを外してください。
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.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
# アカウント作成用コントローラー
class Api::V1::Auth::RegistrationsController < DeviseTokenAuth::RegistrationsController
private
def sign_up_params
params.permit(:email, :password, :password_confirmation, :name)
end
end
# ログイン状態確認用コントローラー
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
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」としなければならない点に注意です。
ルーティングを設定
ルーティングの設定もお忘れなく。
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を修正
{
"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」を次のように変更します。
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")
)
import React from "react"
const App: React.FC = () => {
return (
<h1>Hello World!</h1>
)
}
export default App
一旦、動作確認してみましょう。
$ yarn start
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/◯○
- 型定義用のライブラリ
型定義
// サインアップ
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クライアントを作成
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」というライブラリを使わせてもらっています。
import client from "lib/api/client"
// 動作確認用
export const execTest = () => {
return client.get("/test")
}
今回は特に使用しませんが、もしRails ⇄ React間で通信が上手くいかない場合、テスト用のAPIを使ってデバッグに役立ててください。
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内に含める事とします。
ビューを作成
それぞれの記述が何を表しているのか、出来る限りコメントを付けたので何とか解読してください。
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
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
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
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
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?
<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
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
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
動作確認
サインアップ
サインイン
トップページ
アラートメッセージ
特に問題が無さそうであれば完成です。
あとがき
以上、Rails API × React × devise_token_authでログイン機能を実装してみました。
長い事うろ覚えだった手順を今回しっかりまとめられて良かったです。同じ様な構成でポートフォリオなどを作成しようと考えている方もぜひ参考にしてみてください。