Rails APIモード + devise_token_auth + Vue.js 3 で認証機能付きのSPAを作る(provide/inject編)

# はじめに

本記事はAPIをRailsのAPIモードで開発し、フロント側をVue.js 3で開発して、認証基盤にdevise_token_authを用いてトークンベースの認証機能付きのSPAを作るチュートリアルのprovide/inject編の記事になります。

前回: [Rails APIモード + devise_token_auth + Vue.js 3 で認証機能付きのSPAを作る(2要素ログイン編)](https://qiita.com/kk-icare/items/1c298a4243058751b99b)



# provide/injectとは？

公式の説明を引用します。

>
通常、親コンポーネントから子コンポーネントにデータを渡すとき、props を使います。深くネストされたいくつかのコンポーネントがあり、深い階層にあるコンポーネントが浅い階層にあるコンポーネントの何かしらのデータのみを必要としている構造を想像してください。この場合でも、鎖のように繋ったコンポーネント全体にプロパティを渡す必要がありますが、時にそれは面倒になります。
>
そのような場合は、provide と inject のペアを利用できます。コンポーネント階層の深さに関係なく、親コンポーネントは、そのすべての子階層へ依存関係を提供するプロバイダとして機能することができます。この機能は 2 つの機能からなります: 親コンポーネントは、データを提供するためのオプション provide を持ち、子コンポーネントはそのデータを利用するためのオプション inject を持っています。
>
出典: https://v3.ja.vuejs.org/guide/component-provide-inject.html

平たく言うと、親子関係を飛び越えて、データ管理を行うことができる仕組みかと思います。

今回はこのprovide/injectの仕組みを使って、ユーザーの認証状態の管理を行いたいと思います。

# NavBarのボタンの表示 / 非表示 を制御する

現状の実装だと、ログインしていないのにもかかわらず、ナビゲーションバーに新規投稿へのリンクやログアウトボタンが表示されてしまっています。これを解決するために、provide/injectを使って認証状態に応じて表示の出し分けをできるように修正していきたいと思います。

## globalStateの定義

まずはglobalStateの定義から行います。

以下のコマンドを実行してください。

```terminal
$ mkdir src/state

$ touch src/state/global-state.ts
```

作成したファイルを以下のように修正します。

```typescript
import { reactive } from 'vue'
import { AuthHeaders, AccessToken } from '@/types/auth'

export const globalAuthState = () => {

  // リアクティブなプロパティとしてstateを定義。初期値はlocalStorageにアクセストークンが保存されていればトークンが、なければnull
  const authState = reactive<AccessToken>({
    'access-token': localStorage.getItem('access-token')
  })

  // authStateのaccess-tokenに、引数で与えられたHttpResponseHeaderの中のaccess-tokenを代入する処理。ログイン時にcallする
  const setAuthState = (headers: AccessToken) => {
    authState['access-token'] = headers['access-token']
  }

  // authStateのaccess-tokenをnullにする処理。ログアウト時にcallする
  const removeAuthState = () => {
    authState['access-token'] = null
  }

  return {
    authState,
    setAuthState,
    removeAuthState
  }
}
```

localStorageにaccess-tokenがあるかないかでログイン/未ログインを判断する実装にしています。

AccessToken型はまだ未定義なので、src/types/auth.tsを修正します。

```typescript
export type AuthHeaders = {
  'access-token': string | null;
  'uid': string | null;
  'client': string | null;
  'expiry': string | null;
  'Content-Type': string;
}

// 追加
export type AccessToken = Pick<AuthHeaders, 'access-token'>

```

## Stateをグローバルに使いまわせるように修正する

上記の実装のままだとStateをグローバルに使えないので、InjectionKeyを使ってグローバルに使えるように修正していきます。

src/state/global-state.tsを以下のように修正して下さい。

```typescript
// src/state/global-state.ts

import { reactive, InjectionKey } from 'vue' // InjectionKeyを追加

export const globalAuthState = () => {
  const authState = reactive<AccessToken>({
    'access-token': localStorage.getItem('access-token')
  })

  const setAuthState = (headers: AuthHeaders) => {
    authState['access-token'] = headers['access-token']
  }

  const removeAuthState = () => {
    authState['access-token'] = null
  }

  return {
    authState,
    setAuthState,
    removeAuthState
  }
}

// 追加
export type authStateType = ReturnType<typeof globalAuthState>;

// 追加
export const authStateKey: InjectionKey<authStateType> = Symbol('useAuthState')
```

authStateKeyはreactiveで定義したStateをGlobalStateとして使うために定義しています。

InjectionKeyとReturnTypeは、provide/injectを用いる時に型検査を効かせるようにするために使っています。

authStateKeyの型は以下のようになっています。

```typescript
const authStateKey: InjectionKey<{
    authState: {
        'access-token': string | null;
    };
    setAuthState: (headers: AuthHeaders) => void;
    removeAuthState: () => void;
}>
```

## main.tsの修正

定義したGlobalStateはアプリケーションの上位でprovide関数を使うことで、どこからでも呼び出せるようになります。

今回はmain.tsで読み込むことにします。

main.tsを以下のように修正します。

```typescript
// src/main.ts

import { createApp } from 'vue'
import App from '@/App.vue'
import router from '@/router'
import '@/assets/styles/tailwind.css'
import VueQrcode from '@chenfengyuan/vue-qrcode'
import { globalAuthState, authStateKey } from '@/state/global-state' // 追加

const app = createApp(App)
  .use(router)

if (VueQrcode.name) {
  app.component(VueQrcode.name, VueQrcode)
}

// 以下を追加
app.provide(authStateKey, globalAuthState()).mount('#app')
```

これであとは各種コンポーネントでinject関数を使ってGlobalStateを呼び出すだけです。

## componentでuseStateを呼び出す

都度injectして、問題なくinjectできればstateを返却して、というふうに各コンポーネントに記載するのはやや面倒なので、useStateという関数を作ることにします。

以下のコマンドを実行してください。

```terminal
$ touch src/state/use-state.ts
```

作成されたファイルを以下のように修正します。

```typescript
import { inject } from 'vue'
import { authStateKey } from '@/state/global-state'

export const useState = () => {
  const state = inject(authStateKey)

  if (!state) {
    throw new Error('failed inject state')
  }

  return state
}
```

## ナビゲーションバーの修正

GlobalStateが呼び出せるようになったので、ナビゲーションバーを修正します。

src/components/NavBar.vueを修正します。

```vue
// src/components/NavBar.vue

<template>
  <nav class="w-full bg-gray-800">
    <div class="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8">
      <div class="relative flex items-center justify-between h-16">
        <div class="flex-1 flex items-center justify-center sm:items-stretch sm:justify-start">
          <div class="flex-shrink-0 flex items-center">
          </div>
          <div class="hidden sm:block sm:ml-6">
            <div class="flex space-x-4">
              <button @click='movePosts()' class="bg-gray-900 text-white px-3 py-2 rounded-md text-sm font-medium">Top</button>
            </div>
          </div>
        </div>
        // v-ifを追加
        <div v-if="isUserSignedIn()" class="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
          <div class="ml-3 relative">
            <div>
              <button class="bg-gray-800 flex text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white" id="user-menu" aria-haspopup="true">
              <button @click='moveAccount()' class="bg-gray-900 text-white px-3 py-2 rounded-md text-sm font-medium">MyPage</button>
              </button>
            </div>
          </div>
        </div>
        // v-ifを追加
        <div v-if="isUserSignedIn()" class="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
          <div class="ml-3 relative">
            <div>
              <button @click='moveNewPost()' class="bg-gray-900 text-white px-3 py-2 rounded-md text-sm font-medium">create Post</button>
            </div>
          </div>
        </div>
        // v-ifを追加
        <div v-if="isUserSignedIn()" class="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
          <div class="ml-3 relative">
            <div>
              <button @click='handleLogOut()' class="bg-gray-900 text-white px-3 py-2 rounded-md text-sm font-medium">Log Out</button>
            </div>
          </div>
        </div>
      </div>
    </div>
  </nav>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import router from '@/router'
import { logout } from '@/api/auth'
import { useState } from '@/state/use-state'  // 追加

export default defineComponent({
  name: 'NavBar',

  setup () {
    const state = useState()

    const movePosts = () => {
      router.push('/')
    }

    const moveNewPost = () => {
      router.push('/posts/new')
    }

    const moveAccount = () => {
      router.push('/account')
    }

    const handleLogOut = async () => {
      await logout()
        .then(() => {
          state.removeAuthState() // 追加
          router.push('/login')
        })
    }

    // ここから追加
    const isUserSignedIn = () => {
      if (state.authState['access-token']) {
        return true
      } else {
        return false
      }
    }
    // ここまで

    return {
      movePosts,
      moveNewPost,
      moveAccount,
      handleLogOut,
      isUserSignedIn // 追加
    }
  }
})
</script>

```

## Login.vueの修正

ログインした後にauthStateの状態を変化させる関数を呼び出すようにします。

src/views/Login.vueを修正します。

```vue
<script lang="ts">
/* eslint-disable @typescript-eslint/camelcase */

import { defineComponent, reactive, ref, toRefs } from 'vue'
import { login } from '@/api/auth'
import router from '@/router'
import { useState } from '@/state/use-state' // 追加

export default defineComponent({
  name: 'Login',
  setup () {
    const state = useState() // 追加
    const formData = reactive({
      email: '',
      password: '',
      otp_code: ''
    })

    const requiredTwoFactorAuth = ref(false)

    const handleLogin = async () => {
      await login(formData.email, formData.password, formData.otp_code)
        .then((res) => {
          if (res?.data.two_factor_auth) {
            requiredTwoFactorAuth.value = true
          } else {
            if (res?.status === 200) {
              state.setAuthState(res?.headers) // 追加
              router.push('/')
            } else {
              alert('メールアドレスかパスワードが間違っています。')
            }
          }
        })
        .catch((e) => {
          console.error(e)
          alert('ログインに失敗しました。')
        })
    }

    return {
      ...toRefs(formData),
      requiredTwoFactorAuth,
      handleLogin
    }
  }
})
</script>
```

## 動作確認

ここまで編集できましたら、動作確認をしていきます。

ログイン前はlocalStorageの中身が空

![スクリーンショット 2021-07-11 14.29.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/e871021b-9d4f-76e1-cdb2-7513004a416e.png)

そのため、NavBarにはTOPボタンのみ表示されている

![スクリーンショット 2021-07-11 14.30.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/c2800c21-ff99-dbff-6817-6c4e8da2cce6.png)

ログインに成功した後はNavBarに各種ボタンが表示されている

![スクリーンショット 2021-07-11 14.30.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/b10d6d64-01d9-6e78-8999-5fa1c1c4b24f.png)

localStorageにもきちんと値が入っている

![スクリーンショット 2021-07-11 14.31.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/7e7c56a8-9b3d-8bf6-8efc-2e2745130060.png)

stateの挙動も追ってみましょう。

## stateの挙動を追う

```vue
// src/components/NavBar.vue

  setup () {
    const state = useState()

    console.log(state.authState['access-token']) // 追加
```

この状態でリロード。コンソールにaccess-tokenが表示される

![スクリーンショット 2021-07-11 14.34.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/b2bd7847-b654-4871-85f2-269232e00db6.png)

console.logの記述位置を変えて、ログアウトしてみる

```typescript
// src/components/NavBar.vue
// 先ほど書いたconsole.logは削除してください。

    const handleLogOut = async () => {
      await logout()
        .then(() => {
          state.removeAuthState()
          router.push('/login')
          console.log(`access-token: ${state.authState['access-token']}`) // 追加
        })
    }
```

removeAuthState関数が呼ばれたため、authStateのaccess-tokenがnullになっている

![スクリーンショット 2021-07-11 14.36.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/7171cf0d-d300-e19d-ce18-3e7adcaa6161.png)

次にログイン。Login.vueを修正します。先ほどNavBar.vueに記載したconsole.logは削除してください。

```typescript
// src/views/Login.vue

state.setAuthState(res?.headers)
router.push('/')
console.log(`access-token: ${state.authState['access-token']}`) // 追加
```

ログインに成功すると、authStateのaccess-tokenに値が入る。

![スクリーンショット 2021-07-11 14.39.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/884609/6f70dad8-2210-1323-451c-e6bb5ffb3f03.png)

これで一通り挙動の確認ができました。

# まとめ

ユーザーの認証状態を管理するくらいであれば、vuexを使わなくても実現できるため、provide/injectの方が学習コストが低くていいかもしれません。 アプリケーションの規模が中・大規模くらいに想定であれば、vuexで集中管理してしまう方が余計なディレクトリが増えない分、コードの見通しがよくなりそう。

次回は絶望的に残念なデザインを修正していきます。