2019.01.12

Vue + Vue Router + Vuex + Laravelで写真共有アプリを作ろう (7) 認証機能とVuex Part.2


UPDATED:2020/01/03
PHP 7.4 および Laravel 6 に対応しました 🎉

この連載記事では、フロントエンドに Vue.js + Vue Router + Vuex とサーバーサイドに Laravel を使用したシングルページ Web アプリケーションの開発方法を紹介します。実際に写真共有アプリを開発する手順を通して SPA 開発のエッセンスを学ぶことができます。

今回のチュートリアルで扱うツールなどのバージョンは以下の通りです。

Node npm Vue.js Vue Router Vuex PHP Laravel
10.15 6.4 2.6 3.1 3.1 7.4 6.9

この章では、前章に引き続いて状態管理ライブラリ Vuex を用いて認証の3つの機能「会員登録」「ログイン」「ログアウト」を完成させます。

ステートの値による要素の出し分け

まず認証状態によってナビゲーションバーとフッターの要素を出し分ける処理を実装します。

ゲッターを追加する

user ステートを参照して認証状態を確認してもいいのですが、ステートそのものではなくステートを元に演算した結果が欲しい場合はゲッターのちょうど良い使いどころです。

auth.js に2つのゲッターを追加します。

auth.js
const getters = {
  check: state => !! state.user,
  username: state => state.user ? state.user.name : ''
}

check はログインチェックに使用します。確実に真偽値を返すために二重否定しています。

username はログインユーザーの name です。仮に usernull の場合に呼ばれてもエラーが発生しないように空文字を返すようにしています。

ナビゲーションバー

上記のゲッターを使用して、まずはナビゲーションバーの要素出し分けを実装しましょう。

Navbar.vue に以下のスクリプトブロックを追加します。

Navbar.vue
<script>
export default {
  computed: {
    isLogin () {
      return this.$store.getters['auth/check']
    },
    username () {
      return this.$store.getters['auth/username']
    }
  }
}
</script>

ストアのステートまたはゲッターを参照する場合は、直接テンプレートで $store を参照してもいいのですが、computed から参照すれば冗長な記述を避けることができます。

あとは上記の算出プロパティを v-if の条件に使えば要素を出し分けられます。
テンプレートを以下のように編集してください。

Navbar.vue
<div class="navbar__menu">
  <div v-if="isLogin" class="navbar__item">
    <!-- 中略 -->
  </div>
  <span v-if="isLogin" class="navbar__item">
    {{ username }}
  </span>
  <div v-else class="navbar__item">
    <!-- 中略 -->
  </div>
</div>
  • 写真投稿ボタンの外側の <div>v-if="isLogin" を追記
  • ログインユーザー名の外側の <span>v-if="isLogin" を追記
  • ログインユーザー名の箇所を {{ username }} に変更
  • ログインリンクの外側の <div>v-else を追記

隣接した要素の v-if と反対の条件で出し分けする場合は v-else が使えます。

フッター

フッターもナビゲーションバーと同じパターンです。

まず Footer.vue にログインチェックの算出プロパティを追加しましょう。

Footer.vue
computed: {
  isLogin () {
    return this.$store.getters['auth/check']
  }
},

そしてテンプレートを以下のように編集します。

Footer.vue
<button v-if="isLogin" class="button button--link" @click="logout">
  Logout
</button>
<RouterLink v-else class="button button--link" to="/login">
  Login / Register
</RouterLink>

コードが書けたらブラウザで動作を確認してみましょう。
ログイン状態と非ログイン状態で意図通り要素が出し分けされているでしょうか。


ここからは現状の認証機能の実装が抱える2つの課題を解決していきます。

認証状態を維持する

課題

一つ目の課題は、ページをリロードしたときに認証状態が維持できない点です。

試しにログインした状態でページをリロードしてみてください。ヘッダーとフッターにログインリンクが表示され、ログアウト状態に戻ってしまうはずです。

ページをリロードすると Vue アプリケーションが再作成されるので、当然 user ステートも初期値である null に戻ってしまいます。それで見た目はログアウトしてしまうのです。

ただしサーバサイドのセッション的にはまだログインしているので、もう一度ログインしようとするとユーザーデータではなく /home へのリダイレクトレスポンスが返却されます。そのためログインしているような見た目だがユーザー名が表示されないという中途半端な表示になります。

この挙動を修正するために、ログインユーザーを返却する API を追加して、最初にログインユーザーを取得してから Vue アプリケーションを生成するように起動スクリプトを変更しましょう。

また、一応 /home へリダイレクトさせるミドルウェアも修正しておきます。

ユーザー取得 API

テスト

例によってテストコードを作成します。

$ php artisan make:test UserApiTest

雛形 tests/Feature/UserApiTest.php を以下の通り編集してください。

UserApiTest.php
<?php

namespace Tests\Feature;

use App\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class UserApiTest extends TestCase
{
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();

        // テストユーザー作成
        $this->user = factory(User::class)->create();
    }

    /**
     * @test
     */
    public function should_ログイン中のユーザーを返却する()
    {
        $response = $this->actingAs($this->user)->json('GET', route('user'));

        $response
            ->assertStatus(200)
            ->assertJson([
                'name' => $this->user->name,
            ]);
    }

    /**
     * @test
     */
    public function should_ログインされていない場合は空文字を返却する()
    {
        $response = $this->json('GET', route('user'));

        $response->assertStatus(200);
        $this->assertEquals("", $response->content());
    }
}

実装

API の実装は単純です。routes/api.php に以下の記述を追加してください。

api.php
// ログインユーザー
Route::get('/user', fn() => Auth::user())->name('user');

ただログインユーザーを返すだけなので、コントローラーは作成しません。

ちなみにテストコードにもありましたが、ログインしていない場合は API は空文字("")を返却します。ログインしていないと Auth::user()null を返しますが、HTTP レスポンスに変換されるときに null は空文字に変わります。HTTP メッセージはただの文字列なので nullfalse などのプログラミング言語的な表現は存在しないためです。

コードが書けたらテストが通ることを確認しておきましょう。

$ ./vendor/bin/phpunit --testdox

起動時にログインチェック

API ができたのでアプリ起動時のログインチェックを実装します。

ストアにアクションを追加

auth ストアに上記の API を呼び出す currentUser アクションを追加します。

auth.js
const actions = {
  async register (context, data) {/* 中略 */},
  async login (context, data) {/* 中略 */},
  async logout (context) {/* 中略 */},
  async currentUser (context) {
    const response = await axios.get('/api/user')
    const user = response.data || null
    context.commit('setUser', user)
  }
}

上で説明した通りログインしていなければ response.data は空文字です。ログインしていないときの user ステートは初期値の null に揃えておいたほうが予期しやすいコードになると思ったのでそのまま setUser しないで真偽値チェックで偽の場合は null を入れるようにしました。

ログインチェックしてからアプリを生成する

アプリ起動時、Vue インスタンス生成前に currentUser アクション呼び出します。
app.js を以下の通り編集してください。

app.js
const createApp = async () => {
  await store.dispatch('auth/currentUser')

  new Vue({
    el: '#app',
    router,
    store,
    components: { App },
    template: '<App />'
  })
}

createApp()

すでに store はインポートしているので、Vue インスタンス生成前でもアクションを dispatch メソッドで呼び出すことはできます。

currentUser アクションの非同期処理が終わってから Vue インスタンスが生成させるため、そして非同期処理を await するためには async メソッドの内部にいる必要があるため、起動処理を createApp 関数にまとめ、最後に呼び出す記述にしました。


ここまでできたらブラウザで挙動を確認しましょう。ログインしてからブラウザをリロードしてください。ログイン状態が維持されているはずです。

ミドルウェア

課題として挙げた内容についての対策としては上記までで OK なのですが、ミドルウェアもついでに直しておきます。

RedirectIfAuthenticated ミドルウェアによって、ログイン状態で非ログイン状態でのみアクセスできる機能にリクエストを送信した場合に /home へのリダイレクトが返却されています。SPA 的にはページへのリダイレクト(HTML)が返るのは相応しくないので、先ほど作成したログインユーザー返却 API にリダイレクトするように修正しましょう。

app/Http/Middleware/RedirectIfAuthenticated.php を以下の通り編集してください。

RedirectIfAuthenticated.php
public function handle($request, Closure $next, $guard = null)
{
    if (Auth::guard($guard)->check()) {
        return redirect()->route('user'); // ★ 変更
    }

    return $next($request);
}

ナビゲーションガード

課題

二つ目の課題は、ログインされている状態でもログインページにアクセスできる点です。

ログイン状態であればページ上にリンクは表示されませんが、アドレスバーに直接 URL を打ち込めばアクセスできてしまいます。

Vue Router のナビゲーションガード機能を使ってログイン状態でログインページへアクセスした際はトップページに移動させることにしましょう。

参考:グローバルガード

ルート定義にナビゲーションガードを追加

要素の切り替えをしたときと同様に auth ストアの check ゲッターを使用しますので、まずは router.js でストアをインポートします。

router.js
import Vue from 'vue'
import VueRouter from 'vue-router'

import PhotoList from './pages/PhotoList.vue'
import Login from './pages/Login.vue'

import store from './store' // ★ 追加

次にログインページのルート定義に、以下の通り beforeEnter を追加してください。

router.js
{
  path: '/login',
  component: Login,
  beforeEnter (to, from, next) {
    if (store.getters['auth/check']) {
      next('/')
    } else {
      next()
    }
  }
}

beforeEnter は定義されたルートにアクセスされてページコンポーネントが切り替わる直前に呼び出される関数です。beforeEnter の第一引数 to はアクセスされようとしているルートのルートオブジェクト、第二引数 from はアクセス元のルート、そして第三引数 next はページの移動先(切り替わり先)を決めるための関数です。

next() を引数なしで呼ぶとそのままページコンポーネントが切り替わります。引数ありで next() を呼ぶと切り替わるはずだったページコンポーネントは生成されず、引数のページに切り替わり、リダイレクトのような動きになります。

今回は auth/check ゲッターでログイン状態をチェックし、ログインしていれば / つまりトップページに切り替え、ログインしていなければそのままログインページを表示しています。

👾 👾 👾

この章はこれでおしまいです。
本章までのソースコードはリポジトリの ch-7 ブランチに置いてあります。

次の章では、フォームのエラー処理を実装します。

関連記事

連載記事(全16回)

Vue + Vue Router + Vuex + Laravelで写真共有アプリを作ろう

その他