Rails API + Vue.js のSPAにdevise_token_authを使ってユーザー登録&ログイン機能を実装する

#はじめに
この記事ではバックエンドにRailsのAPIモード、フロントエンドにVue.jsを使ったSPAにdevise_token_authを用いて、ユーザー登録とログイン機能を実装していきます。
本記事ではdevise_token_authの導入方法などの説明は省いていますので、以下の記事を参照してください。

* [Rails APIモード + devise_token_auth + Vue.js 3 で認証機能付きのSPAを作る(Rails編)](https://qiita.com/kk-icare/items/c4cd040824c7d6b434d6)
* [[Rails] devise token auth を使う](https://qiita.com/Masahiro_T/items/6bc49a625b437a7c2f45)
* [deviseのルーティングをカスタマイズする](https://qiita.com/yuki_0920/items/14594e697a8aa44b725c)
* [【翻訳】devise-auth-token公式ドキュメント](https://sainu.hatenablog.jp/entry/2018/08/11/194319)

#環境

* Ruby 3.0.0
* Rails 6.1.3
* Vue.js 2.6.12
* Vue CLI 4.5.1

#Rails側の設定
APIモードでrailsアプリケーションを作成してdevise、devise_token_auth、rack-corsを導入してあります。
プロジェクトはspaという名前です。
ルーティングは少しカスタマイズしています。

````ruby:spa/config/routes.rb
Rails.application.routes.draw do
  mount_devise_token_auth_for 'User', at: 'auth', skip: [:registrations], controllers: {
    sessions: 'auth/sessions'
  }
  devise_scope :user do
    post '/auth/sign_up', to: 'auth/registrations#create', as: :user_registration
    patch '/auth/account/edit', to: 'auth/registrations#update', as: :edit_user_registration
    delete '/auth/account/delete', to: 'auth/registrations#destroy', as: :destroy_user_registration
  end
end
````

````
$ rails routes
                    ..... ...    .....                          .....
             user_session POST   /auth/sign_in(.:format)        auth/sessions#create
     destroy_user_session DELETE /auth/sign_out(.:format)       auth/sessions#destroy
                    ..... ...    .....                          .....
        user_registration POST　　/auth/sign_up(.:format)        auth/registrations#create
   edit_user_registration PATCH  /auth/account/edit(.:format)    auth/registrations#update
destroy_user_registration DELETE /auth/account/delete(.:format)  auth/registrations#destroy
                    ..... ...    .....                           .....
````

ルーティングで指定したコントローラを作成して、デフォルトのDeviseTokenAuth::RegistrationsControllerとDeviseTokenAuth::SessionsControllerをそれぞれ継承します。
ユーザー登録にはデフォルトの:email、:password以外に:nameも受け取れるようにします。

````ruby:spa/app/controllers/auth/registrations_controller.rb
class Auth::RegistrationsController < DeviseTokenAuth::RegistrationsController
  private
  
  def sign_up_params
    params.permit(:name, :email, :password)
  end
end
````


````ruby:spa/app/controllers/auth/sessions_controller.rb
class Auth::SessionsController < DeviseTokenAuth::SessionsController
end
````

#フロントエンドにVue.jsを導入
Vue CLIがインストールされている前提で始めます。
フロントエンドのプロジェクト名はfrontendで、railsアプリケーション(spa)の直下に作ります。

````
# 現在spaにいない場合はspaに移動します。
$ cd spa

$ vue create frontend

$ cd frontend
````

APIにアクセスするためにaxiosをインストールします。

````
$ npm install axios
````

srcディレクトリにaxios.jsファイルを作成します。環境変数を取得してAPIにアクセスできるようにします。

````javascript:spa/frontend/src/axios.js
import axios from 'axios'

export default axios.create({
  baseURL: process.env.VUE_APP_API_BASE
})
````

frontendの直下に.env.developmentファイルを作成して、環境変数を定義します。
また、このファイルを.gitignoreに追加します。

````spa/frontend/.env.development
VUE_APP_API_BASE=http://localhost:3000
````
````spa/frontend/.gitignore
# 追加
.env.development
````

#フロントエンドのルーティング

````
$ npm install vue-router
````

srcディレクトリにrouter.jsファイルを作ります。
今回はユーザー登録とログイン機能のみなのでHome、SignUp、LogInの３つのコンポーネントを作成します。

````javascript:spa/frontend/src/router.js
import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home'
import SignUp from './views/SignUp'
import LogIn from './views/LogIn'

Vue.use(Router)

export default new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      name: 'Home',
      component: Home
    },
    {
      path: '/signup',
      name: 'SignUp',
      component: SignUp
    },
    {
      path: '/login',
      name: 'Login',
      component: LogIn
    }
  ]
})
````

main.jsにrouterを追加します。

````javascript:spa/src/main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'

Vue.config.productionTip = false

new Vue({
  router,
  render: h => h(App),
}).$mount('#app')
````
#表示するページを作成
computedやmethodsは後ほど実装します。

````vue:spa/frontend/src/App.vue
<template>
  <div id="app">
    <header>
      <router-link to="/">Home</router-link>
      <template v-if="!isAuthenticated">
        <router-link to="/signup">Sign Up</router-link>
        <router-link to="/login">Log In</router-link>
      </template>
      <template v-else>
        <span  class="logout" @click="logOut">Log Out</span>
      </template>
    </header>
    <router-view></router-view>
  </div>
</template>

<script>
export default {
  name: 'App',
  computed: {
    isAuthenticated () { /*ログインしていたらtrueを返す*/ }
  },
  methods: {
    logOut () {}
  }
}
</script>

<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}

.logout {
  cursor: pointer;
}
</style>
````

srcディレクトリにviewsディレクトリを作り、Home.vue、SignUp.vue、LogIn.vueの３つのファイルを作ります。

````vue:spa/frontend/src/views/Home.vue
<template>
  <div>
    <h1>HOME</h1>
  </div>
</template>
````

````vue:spa/frontend/src/views/SignUp.vue
<template>
  <div class="signup">
    <h1>Sign Up</h1>
    <div class="form">
      <label for="name">Name</label>
      <input type="text" v-model="name" id="name">
    </div>
    <div class="form">
      <label for="email">Email</label>
      <input type="email" v-model="email" id="email">
    </div>
    <div class="form">
      <label for="password">Password</label>
      <input type="password" v-model="password" id="password">
    </div>
    <button @click="signUp">Sign Up</button>
  </div>
</template>

<script>
export default {
  data () {
    return {
      name: '',
      email: '',
      password: ''
    }
  },
  methods: {
    signUp () {}
  }
}
</script>
````

````vue:spa/frontend/src/views/LogIn.vue
<template>
  <div class="login">
    <h1>Log In</h1>
    <div class="form">
      <label for="email">Email</label>
      <input type="email" v-model="email" id="email">
    </div>
    <div class="form">
      <label for="password">Password</label>
      <input type="password" v-model="password" id="password">
    </div>
    <button @click="logIn">Log In</button>
  </div>
</template>

<script>
export default {
  data () {
    return {
      email: '',
      password: ''
    }
  },
  methods: {
    logIn () {}
  }
}
</script>
````

#フロントエンドにユーザー登録とログイン機能を実装
ユーザー登録やログインの際に取得するトークンを保持して、複数のコンポーネントで使いたいのでvuexをインストールします。

````
$ npm install vuex
````

main.jsにstoreを追加します。

````javascript:spa/frontend/src/main.js
....
import store from './store'
....
new Vue({
  store,
  ....
````

srcディレクトリにstore.jsを作ります。

* トークンをstateのauthDataで保持
* gettersで現在保持しているトークンを参照
* mutationsで取得したトークンをstateに反映(同期処理)
* actionsでAPIにアクセス(非同期処理)

````javascript:spa/frontend/src/store.js
import Vue from 'vue'
import Vuex from 'vuex'
import router from './router'
import axios from './axios'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    authData: null
  },
  getters: {
    authData: state => state.authData
  },
  mutations: {
    updateAuthData (state, responseData) {
      state.authData = responseData
    }
  },
  actions: {
    signup ({commit}, authParams) {
      axios.post('/auth/sign_up', {
        name: authParams.name,
        email: authParams.email,
        password: authParams.password
      })
        .then(response => {
          commit('updateAuthData', response.headers)
          router.replace('/')
        })
    },
    login ({commit}, authParams) {
      axios.post('/auth/sign_in', {
        email: authParams.email,
        password: authParams.password
      })
        .then(response => {
          commit('updateAuthData', response.headers)
          router.replace('/')
        })
    },
    logout ({commit}, authParams) {
      axios.delete('/auth/sign_out', {
        headers: authParams
      })
      commit('updateAuthData', null)
      router.replace('/', () => {})
    }
  }
})

````

各コンポーネントで、APIにアクセスするときに必要なパラメータを取得して各actionsを実行します。

````vue:spa/frontend/src/views/SignUp.vue
....
<script>
export default {
  ...
  methods: {
    signUp () {
      this.$store.dispatch('signup', {
        name: this.name,
        email: this.email,
        password: this.password
      })
    }
  }
}
</script>
````

````vue:spa/frontend/src/views/LogIn.vue
....
<script>
export default {
  ...
  methods: {
    logIn () {
      this.$store.dispatch('login', {
        email: this.email,
        password: this.password
      })
    }
  }
}
</script>
````

最後に

* ログインしていない場合はSign UpとLog Inを、ログインしている場合はLog Outを表示させる機能
* ログアウト機能

を完成させます。

`````vue:spa/frontend/src/App.vue
....
<script>
export default {
  name: 'App',
  computed: {
    isAuthenticated () {
      return this.$store.getters.authData !== null
    }
  },
  methods: {
    logOut () {
      this.$store.dispatch('logout', this.$store.getters.authData)
    }
  }
}
</script>
....
````

#動作チェック
アプリケーションを起動させます。
ターミナルを２つ開いて、片方はspaに移動して`$ rails s`を、もう片方はspa/frontendに移動して`$ npm run serve`を入力します。
http\://localhost:8080/にアクセスします。

現状でSign UpやLog Inは可能ですが、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1171931/cd56e441-72be-0ad6-cf5e-7a1d4b0431a8.png)
のように、"registration"や"session"というパラメータが送られてUnpermitted parameter:が表示されると思います。
これはrailsのデフォルトの挙動のようです。
[paramsとwrap_parameters](https://qiita.com/kazutosato/items/fbaa2fc0443611c627fc)
この挙動をやめたい場合は上のリンクにあるように

````ruby:spa/config/initializers/wrap_parameters.rb
ActiveSupport.on_load(:action_controller) do
  wrap_parameters format: []    # [:json]から[]に変更
end
````
として、railsを再起動すると解消されます。