Nuxt 3, Pinia, Vuetify 3 TypeScriptでログイン機能を実装する
はじめに
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の場合は、フロントエンド側でのログイン状態とします。
setLoginStatus
はloggedIn
ステートを変更するための関数です。
isDefaultLoggedIn
はloggedIn
ステートのデフォルト値を設定するための関数です。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"等でフォームに関連づけられます。
処理は以下の流れです。
- RestAPIへのログイン実行
apiUrlはnuxt.config.ts
で定義したruntimeConfig
から取得しています。 - ステータスチェック
ステータスが400番台の時はerrorMessageにレスポンスボディのjsonのmessageを格納しています。
ステータスが500番台やそれ以外の場合は/errorページにルーティングし、サーバーエラーが発生した旨をユーザーに伝えます。 - フロントエンドログイン処理
先ほど定義したauthStore.setLoginStatus(true);
を使用して、フロントエンドのログイン状態をtrue
にしています。 - アクセストークンとリフレッシュトークンをCookieに保存
- ホームへルーティング
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>
emailRules
とPasswordRules
はそれぞれ、フォームに値が入力された時のバリデーションを実装しています。
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