Vue.js で簡単なログイン画面 (トークン認証) を作ってみた

ログインに成功すると token を取得できて
以降、認証が必要な API と通信する際は header に token をセットする、という想定です

![login.gif](https://qiita-image-store.s3.amazonaws.com/0/106789/0c90b2c4-2c05-71c8-bd31-5fbf5a46fb5f.gif)

ちなみにバックエンドの API は Rails で作っています。
[Rails でトークン認証 API を 15 分で実装する](https://qiita.com/kumagaias/items/250e7d6cf46966e4c8c2)

## ディレクトリ構成

store の使い方が肝でしょうか
うーん、状態管理って難しいですね

~~~
src/
├── App.vue
├── assets
│   └── logo.png
├── components
│   ├── error.vue
│   ├── login.vue
│   ├── logout.vue
│   ├── menu.vue
│   └── top.vue
├── lang
│   ├── index.js
│   └── messages.json # エラーメッセージ等、文言をここに
├── main.js
├── router
│   └── index.js # ルーティング定義
└── store
    ├── index.js
    └── modules
        ├── auth.js # token を管理
        ├── http.js # API と通信する際はコレを使う
        └── message.js # エラーメッセージの管理
~~~

## vue-cli のインストール

[Vue CLI](https://cli.vuejs.org/) が大変便利ですね
サクっと環境構築してくれます

~~~
$ npm install -g vue-cli
$ vue init webpack my-project
$ cd !$
$ npm run dev
~~~

http://localhost:8080 にアクセスするとこの画面が見れると思います

<img width="1438" alt="Screen Shot 2019-01-23 at 9.56.20.png" src="https://qiita-image-store.s3.amazonaws.com/0/106789/1c529101-b468-001d-1b46-2d645d419de9.png">


# 各ファイルのソース

主要なソースを載せてみます
まだ開発中で、改善の余地が多分にあると思います
何かありましたらコメント頂けるとありがたいです！

## main.js

- router (画面遷移管理) や store (状態管理) を注入しています
- vee-validate は入力フォームのバリデーションにとても便利ですね

~~~js
// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue'
import VeeValidate from 'vee-validate'
import App from '@/App'
import router from '@/router'
import store from '@/store'
import lang from '@/lang'

Vue.use(VeeValidate)
Vue.config.productionTip = false

/* eslint-disable no-new */
new Vue({
  components: { App },
  el: '#app',
  i18n: lang,
  router,
  store,
  template: '<App/>'
})
~~~

## router

### router/index.js

- 1 つずつコンポーネントをロードして URL と紐づけています
- meta でメタ情報を設定できます。isPublic: true じゃない時は token が必要、という風に作ってみました
- router.beforeEach でログインが必要な画面かどうか、ログイン済かどうかを判定しています

~~~js
import Vue from 'vue'
import Router from 'vue-router'

// components
import Top from '@/components/top.vue'
import Login from '@/components/login.vue'
import Logout from '@/components/logout.vue'
import Error from '@/components/error.vue'

// store
import Store from '@/store/index.js'

Vue.use(Router)

const router = new Router({
  routes: [
    {
      path: '/',
      name: 'top',
      component: Top,
      meta: {
        isPublic: true
      }
    },
    {
      path: '/login',
      name: 'login',
      component: Login,
      meta: {
        isPublic: true
      }
    },
    {
      path: '/logout',
      name: 'logout',
      component: Logout
    },
    {
      path: '/error',
      name: 'error',
      component: Error
    },
    {
      path: '*',
      redirect: '/error'
    }
  ]
})

router.beforeEach((to, from, next) => {
  if (to.matched.some(page => page.meta.isPublic) || Store.state.auth.token) {
    next()
  } else {
    next('/login')
  }
})

export default router
~~~

## store

### store/index.js

- store が膨大になりそうなのでモジュールで分割してみました
- vuex-persistedstate を入れてブラウザをリロードしても store が保持されるようにしています

~~~js
import Vue from 'vue'
import Vuex from 'vuex'

import createPersistedState from 'vuex-persistedstate'

import auth from '@/store/modules/auth'
import http from '@/store/modules/http'
import message from '@/store/modules/message'

Vue.use(Vuex)

export default new Vuex.Store({
  modules: {
    auth,
    http,
    message
  },
  plugins: [createPersistedState({
    key: 'example',
    storage: window.sessionStorage
  })]
})
~~~

### store/modules/auth.js

ここをゴニョゴニョがんばってみました

- token を管理するモジュールです
- `commit('create', res.data)` で自分の mutations をコールしています
- `dispatch('http/delete',　{ url: '/auth', data },　{ root: true })` で他の actions をコールしています。他のモジュールをコールする時は第 3 引数で root: true しないとエラーになります。ちなみに別モジュールにした理由は http リクエスト処理を共通化したかったためです。
- 大体の流れとして他から actions がコールされる -> actions が mutations をコール -> mutations が state の値を変更する、みたいな流れになるのかな？ mutations はセッターみたいな感じですね

~~~js
export default {
  namespaced: true,
  state: {
    tenant: '',
    userId: '',
    token: ''
  },
  mutations: {
    create (state, data) {
      state.tenant = ''
      state.token = data.token
      state.userId = data.user_id
    },
    destroy (state) {
      state.tenant = ''
      state.userId = ''
      state.token = ''
    }
  },
  actions: {
    create ({ commit, dispatch }, data) {
      dispatch(
        'http/post',
        { url: '/auth', data, error: 'message.unauthorized' },
        { root: true }
      ).then(res => commit('create', res.data))
        .catch(err => err)
    },
    destroy ({ commit, dispatch }, data) {
      dispatch(
        'http/delete',
        { url: '/auth', data },
        { root: true }
      ).then(res => commit('create', res.data))
        .catch(err => err)
        // logout anyway ...
        .finally(res => commit('destroy'))
    }
  }
}
~~~

### store/modules/http.js

- http リクエストのモジュールです
- ヘッダーの`headers['Authorization']`に発行された token をセットします
- `request ({ dispatch, rootState }, ...)` の第 1 引数に rootState を入れ、auth.js の rootState.auth.token を引っ張ってきています
- async request() の下にわざわざ async post() とか async delete() とか作っているのはコールする時に `dispatch('http/post', ...)``dispatch('http/delete', ...)` したかったからです (好み)

~~~js
import axios from 'axios'

export default {
  namespaced: true,
  actions: {
    async request ({ dispatch, rootState }, { method, url, data, error }) {
      const headers = {}
      headers['Content-Type'] = 'application/json'
      if (rootState.auth.token) {
        headers['Authorization'] = `Token ${rootState.auth.token}`
        headers['User-Id'] = rootState.auth.userId
      }

      const options = {
        method,
        url: `${process.env.API_URL}${url}`,
        headers,
        data,
        timeout: 15000
      }

      return axios(options)
        .then(res => res)
        .catch(err => {
          dispatch(
            'message/create',
            { error: error, err },
            { root: true }
          )
        })
    },
    async post ({ dispatch }, requests) {
      requests.method = 'post'
      return dispatch('request', requests)
    },
    async delete ({ dispatch }, requests) {
      requests.method = 'delete'
      return dispatch('request', requests)
    }
  }
}
~~~

## components

### components/login.vue

- watch で store の token を監視し、ログイン済 (token に値が入る) になったら画面遷移させています
- 既にログイン済だった場合もとりあえずトップに飛ばしています
- `isValidated ()`で入力フォームのバリデーションが通った場合にボタンをアクティブにするようにしています
- `{{ $t(errorMessage) }}`で lang/messages.json の文言を引っ張ってきています

~~~js
<template>
  <div id="app">
    <section class="hero is-light is-fullheight">
      <div class="hero-body">
        <div class="container has-text-centered">
          <article v-show="errorMessage" class="message is-warning">
            <div class="message-body">
              {{ $t(errorMessage) }}
            </div>
          </article>
          <div class="column is-4 is-offset-4">
            <div class="box">
               <div class="field">
                 <div class="control">
                   <input class="input is-large" type="email" placeholder="Eメール" v-model="email" autofocus="" v-validate="'required|email'" name="email">
                 </div>
              </div>
              <div class="field">
                <div class="control">
                  <input class="input is-large" type="password" placeholder="パスワード" v-model="password" v-validate="'required|min:6|max:20'" maxlength="20" name="password">
                </div>
              </div>
              <div class="field">
                <label class="checkbox">
                <input type="checkbox">
                ログインしたままにする
                </label>
               </div>
               <button class="button is-block is-info is-large is-fullwidth" @click="login()" :disabled="!isValidated" >„É≠„Ç∞„Ç§„É≥</button>
              </div>
              <p class="has-text-grey">
                <a href="..">パスワードを忘れた方はこちら</a>
              </p>
            </div>
          </div>
        </div>
    </section>
  </div>
</template>

<script>
export default {
  data () {
    return {
      email: '',
      password: ''
    }
  },
  methods: {
    login () {
      this.$store.dispatch(
        'auth/create',
        {
          'user': {
            email: this.email,
            password: this.password
          }
        }
      )
    }
  },
  computed: {
    isValidated () {
      return Object.keys(this.fields).every(k => this.fields[k].validated) &&
        Object.keys(this.fields).every(k => this.fields[k].valid)
    },
    token () {
      return this.$store.state.auth.token
    },
    errorMessage () {
      return this.$store.state.message.error
    }
  },
  created: function () {
    this.$store.dispatch('message/destroy')
    // already logined
    if (this.$store.state.auth.token) {
      this.$router.push('/')
    }
  },
  watch: {
    token (newToken) {
      this.$router.push('/')
    }
  }
}
</script>
~~~