📌

Nuxt 3, Pinia, Vuetify 3 TypeScriptでログイン機能を実装する

2023/04/30に公開

はじめに

Nuxt 3, Pinia, Vuetify 3の記事がかなり少なく、RestAPIと連携したSPAのログイン機能実装に苦戦したため記事にします。
フロントエンドの進化は日進月歩であるため、1年後には無用の産物と化している可能性があります。

前提

以下の仕様のログインAPI(/api/auth/login)をローカルで実装済みです。

正常レスポンス

{
	"access_token": "アクセストークン"
	"refresh_token": "リフレッシュトークン"
}

401レスポンス

{
	"message": "メールアドレスまたはパスワードが誤っています。"
}

また、npx nuxi create {プロジェクト名}でプロジェクトを作成し、npm installを実行済した上で、Vuetifyのセットアップ、nuxt.config.tsの記述、layoutsの作成、app.vueの記述等を済ませた状態です。

環境

言語: TypeScript
パッケージ:

  • nuxt: 3.4系
  • sass: 1.62系
  • @pinia/nuxt: 0.4系
  • pinia: 2.0系
  • vuetify: 3.2系

実装

Pinia パッケージのインストール

npm install pinia pinia@nuxt

Pinia の設定

nuxt.config.ts に以下を追記します。

export default defineNuxtConfig({
    /* 省略 */
    modules: ['@pinia/nuxt'],
    // 環境変数のAPI_URLが存在しない場合は`http://localhost:8080/api`をapiURLとする。
    runtimeConfig : {
      public : {
          apiUrl : process.env.API_URL || 'http://localhost:8080/api'
      }
    },
});

AuthStore の作成

次にフロント側でログイン状態を管理するためのStoreを作成します。
プロジェクトのルートディレクトリにstoreディレクトリを作成し、auth.tsを作成します。

sotre/auth.ts

import { defineStore } from 'pinia';


interface AuthState {
    loggedIn: boolean;
}

/**
 * リロード時ログイン済みかどうかを判定する
 */
const isDefaultLoggedIn = () => {
    const accessToken = useCookie('accessToken', { secure: true, sameSite: true });
    return accessToken.value !== null && accessToken.value !== '' && accessToken.value !== undefined;
}

export const useAuthStore = defineStore('auth', {
    state: (): AuthState => ({
      loggedIn: isDefaultLoggedIn(),
    }),
    actions: {
      setLoginStatus(status: boolean) : void{
        this.loggedIn = status;
      },
    },
});

loggedInステートがtrueの場合は、フロントエンド側でのログイン状態とします。
setLoginStatusloggedInステートを変更するための関数です。
isDefaultLoggedInloggedInステートのデフォルト値を設定するための関数です。Nuxt3のuseCookieを使ってCookieのaccessTokenが存在するかを検証しその真偽値を返します。

useLoginの作成

プロジェクトのルートディレクトリにcomposablesディレクトリを作成します。これはコンポーネント間でロジックを共有するためのスクリプトを記述するためのディレクトリです。
composables内にはuseLogin.tsを作成します。

composables/useLogin.ts

import { ref } from 'vue';
import { useAuthStore } from '~/store/auth';

/**
 * ログイン処理を行う
 * @returns {Object} { email, password, handleLogin }
 */
interface UseLoginReturnType {
    email: Ref<string>;
    password: Ref<string>;
    handleLogin: () => Promise<void>;
    errorMessage: Ref<string | null>;
  }
  
  export const useLogin = (): UseLoginReturnType => {
    const router = useRouter();
    const accessToken = useCookie('accessToken', { secure: true, sameSite: true });
    const refreshToken = useCookie('refreshToken', { secure: true, sameSite: true });
    const email = ref('');
    const password = ref('');
    const { public: publicConfig } = useRuntimeConfig();
    const authStore = useAuthStore();
    const errorMessage = ref<string | null>(null);
  
    const handleLogin = async () => {
      // バックエンドログイン処理
      const response = await fetch(`${publicConfig.apiUrl}/auth/login`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          email: email.value,
          password: password.value,
        }),
      });
  
      if (!response.ok) {
        if (response.status >=400 && response.status < 500) {
          const errorData = await response.json();
          errorMessage.value = errorData.message;
        } else if (response.status >= 500) {
            router.push('/error');
        } else {
            router.push('/error');
        }
        return;
      }
  
      const data = await response.json();
      // フロントエンドログイン処理
      authStore.setLoginStatus(true);
      // アクセストークンとリフレッシュトークンをCookieに保存
      accessToken.value = data.access_token;
      refreshToken.value = data.refresh_token;
  
      router.push('/');
    };
  
    return { email, password, handleLogin, errorMessage };
  };

ref('')はリアクティブなデータを生成するために使用される関数です。ref を使用することで、プリミティブな値(文字列、数値、ブール値など)をリアクティブに扱うことができます。ref に渡された値は、内部的に Vue.js のリアクティブシステムによって監視され、変更が検出されると、関連するコンポーネントが自動的に更新されます。後にformのv-model="email"等でフォームに関連づけられます。

処理は以下の流れです。

  1. RestAPIへのログイン実行
    apiUrlはnuxt.config.tsで定義したruntimeConfigから取得しています。
  2. ステータスチェック
    ステータスが400番台の時はerrorMessageにレスポンスボディのjsonのmessageを格納しています。
    ステータスが500番台やそれ以外の場合は/errorページにルーティングし、サーバーエラーが発生した旨をユーザーに伝えます。
  3. フロントエンドログイン処理
    先ほど定義したauthStore.setLoginStatus(true);を使用して、フロントエンドのログイン状態をtrueにしています。
  4. アクセストークンとリフレッシュトークンをCookieに保存
  5. ホームへルーティング

LoginForm.vue(コンポーネント)の作成

components/formsディレクトリ内にLoginForm.vueを作成します。

components/forms/LoginForm.vue

<script setup lang="ts">
  import { useLogin } from '../../composables/useLogin'
  const { email, password, handleLogin, errorMessage } = useLogin()

  const emailRules = [
    (value) => {
      if (value) return true
      return 'メールアドレスは必須です。'
    },
    (value) => {
      const pattern = /^[^@\s]+@[^@\s]+\.[^@\s]+$/
      if (pattern.test(value)) return true
      return '有効なメールアドレスを入力してください。'
    },
  ]

  const passwordRules = [
    (value) => {
      if (value) return true
      return 'パスワードは必須です。'
    },
    (value) => {
      if (value.length >= 8) return true
      return 'パスワードは8文字以上である必要があります。'
    },
  ]
</script>

<template>
  <v-alert v-if="errorMessage" type="error" class="mb-3">{{ errorMessage }}</v-alert>
  <v-sheet width="300" class="mx-auto" style="background-color: white;">
    <h2 class="text-center">ログイン</h2>
    <v-form @submit.prevent="handleLogin" class="mt-3">
      <v-text-field
        v-model="email"
        type="email"
        :rules="emailRules"
        label="メールアドレス"
      ></v-text-field>
      <v-text-field
        v-model="password"
        type="password"
        :rules="passwordRules"
        label="パスワード"
      ></v-text-field>
      <v-btn type="submit" block class="mt-2" color="secondary">ログイン</v-btn>
    </v-form>
  </v-sheet>
</template>

<script lang="ts">
  export default {
    name: "LoginForm",
  }
</script>

emailRulesPasswordRulesはそれぞれ、フォームに値が入力された時のバリデーションを実装しています。
v-modelには先ほど定義した、emailとpasswordが指定されています。

<v-form @submit.prevent="handleLogin" class="mt-3">

でフォームがsubmitされた際に、先ほど定義したhandleLoginを実行しています。

login.vue の作成

ここまで実装してきたログインフォームコンポーネントをもとにログインページを実装します。
pages/authディレクトリ内にlogin.vueを作成します。

login.vue

<template>
  <div>
    <login-form></login-form>
  </div>
  <v-card-text class="text-center mt-2">
          アカウントをお持ちでない方は
          <nuxt-link to="/auth/register" class="underline">サインアップ</nuxt-link>
    </v-card-text>
</template>

<script lang="ts">
import LoginForm from '../../components/forms/LoginForm.vue'
export default {
  components: {
    LoginForm
  }
}
</script>

先ほど定義したログインフォームを使用しつつ、サインアップページへのリンクを表示しています。

完成品

このようなフォームが完成するはずです。

参考文献

Discussion