Closed12

簡易版WeCALL(非公開)

jyasukawajyasukawa

簡易版WeCALLのセットアップ

1. 新規プロジェクトのリポジトリの作成

git cloneしたリポジトリにREADMEのみを追加してmainブランチにfirst commit
以降ブランチを切って開発を進める。

2. プロジェクトのルートに以下を追加

  • .gitignore
  • .env
  • docker-compose.yml

3. フロントエンドの追加

https://zenn.dev/link/comments/875f82bf330b77 を参照

4. サーバーサイドの追加

https://zenn.dev/link/comments/ec28348456da8e を参照

5. LIFF SDKのインストール

cd frontend
npm install --save @line/liff

6. SDKをアプリに組み込む

-> https://zenn.dev/link/comments/400aa6cb3c2a9b (修正)
liff SDKの組み込みは、Vue.jsのライフサイクルに従って正しいタイミングで初期化することが重要。liff.initは主要なVueコンポーネントのmountedフックで実行するのが適切。

例)
//App.vue
<script setup lang="ts">
import { onMounted } from 'vue';
import liff from '@line/liff'; //TypeScriptの型の定義は@line/liffパッケージに含まれている
// コンポーネントがマウントされた後にLIFFを初期化
onMounted(() => {
  liff.init({
    liffId: '1234567890-AbcdEfgh', // あなたのliffIdをここに設定
  })
  .then(() => {
    console.log('LIFF initialized successfully');
  })
  .catch((error) => {
    console.error('LIFF initialization failed', error);
  });
});
</script>

実際はVue.js(フロントエンド)でLIFFを使う場合は liff.init() の設定はクライアントサイドのコード内にあるので、.envファイルはfrontendディレクトリに配置する。Vue.jsアプリケーションでは、環境変数を使うときに .env ファイルに変数名を VITE_ で始める必要がある。
cd frontend
touch .env

liff.init({
    liffId: import.meta.env.VITE_LIFF_ID // 環境変数を使う
  })
jyasukawajyasukawa

Vue.js プロジェクトの典型的なファイル構成

/project-root
│
├── /node_modules        # npmでインストールされた依存パッケージ
├── /public              # 公開される静的ファイル
│   ├── favicon.ico      # faviconファイル
│   └── index.html       # メインのHTMLファイル(Vueアプリケーションがマウントされる)
│
├── /src                 # ソースコードのメインディレクトリ
│   ├── /assets          # 画像やスタイルシートなどのアセット
│   ├── /components      # 再利用可能なVueコンポーネント
│   │   ├── Header.vue   # ヘッダーコンポーネントなど
│   │   └── Footer.vue   # フッターコンポーネントなど
│   │
│   ├── /views           # ルーティングで使用されるページ単位のビュー
│   │   ├── Home.vue     # ホームページのビュー
│   │   └── About.vue    # Aboutページのビュー
│   │
│   ├── App.vue          # ルートコンポーネント(アプリケーション全体の構成)
│   ├── main.ts          # アプリケーションのエントリーポイント(TypeScriptの場合)
│   └── router.ts        # Vue Routerの設定
│
├── /store               # Vuexの状態管理(必要に応じて)
│   └── index.ts         # Vuexストアのエントリーポイント
│
├── /tests               # テストコード
│   ├── /unit            # 単体テスト用
│   └── /e2e             # エンドツーエンドテスト用
│
├── package.json         # npmの依存パッケージとスクリプト
├── vite.config.ts       # Viteの設定ファイル(Viteを使用している場合)
├── tsconfig.json        # TypeScriptの設定ファイル(TypeScriptを使用している場合)
├── .eslintrc.js         # ESLintの設定ファイル(リントツール)
└── .gitignore           # Gitで追跡しないファイルやディレクトリのリスト
jyasukawajyasukawa

Vue Routerの使い方

Vue Routerは、Vue.jsアプリケーションでページの遷移やルーティング(URLの管理)を行うための公式ライブラリ。

1. Vue Routerをインストールする

cd frontend
npm install vue-router@next

2. ルーティングの設定

プロジェクトの中で、router.tsというファイルを作成して、そこにルート情報を設定する。

例)
//router.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import Home from './components/Home.vue';
import About from './components/About.vue';
const routes: Array<RouteRecordRaw> = [
  {
    path: '/',
    name: 'Home',
    component: Home,
  },
  {
    path: '/about',
    name: 'About',
    component: About,
  },
];
const router = createRouter({
  history: createWebHistory(),
  routes,
});
export default router;

3. main.tsにルーターを登録する

作成したルーターをVueアプリケーションに登録する。
例)
// main.ts

import { createApp } from 'vue';
import App from './App.vue';
import router from './router'; // 先ほど作成したルーターをインポート
const app = createApp(App);
app.use(router); // ルーターをアプリケーションに適用
app.mount('#app');

4. ルーターリンクを作成する

Vueコンポーネント内で、ページ遷移を行うリンクを作成する。<router-link>タグを使うことで、ページ遷移を行うリンクを簡単に作成できる。

例)
// example.vue
<template>
  <div>
    <nav>
      <router-link to="/">Home</router-link>
      <router-link to="/about">About</router-link>
    </nav>
    <router-view></router-view> <!-- ルートに対応するコンポーネントがここに表示されます -->
  </div>
</template>
例)
// App.vue
<script setup lang="ts">
import { onMounted } from 'vue';
import liff from '@line/liff';
onMounted(() => { // コンポーネントがマウントされた後にLIFFを初期化
  liff.init({
    liffId: import.meta.env.VITE_LIFF_ID // 環境変数を使う
  })
  .then(() => {
    console.log('LIFF initialized successfully');
  })
  .catch((error) => {
    console.error('LIFF initialization failed', error);
  });
});
</script>
<template>
  <router-view></router-view>
</template>

5. ルートごとのコンポーネントを作成する

それぞれのページに対応するコンポーネントを作成する。
例)
// Home.vue

<template>
  <div>
    <h1>Home Page</h1>
    <p>Welcome to the home page!</p>
  </div>
</template>

6. 動的ルート (オプション)

パラメータ付きのルートも作成できる。例えば、ユーザープロファイルページのようなものには以下のような動的ルートを作成する。

const routes = [
  {
    path: '/user/:id',
    name: 'User',
    component: User,
  },
];

コンポーネント内でパラメータにアクセスするには、$route.paramsを使う。

<template>
  <div>
    <h1>User Profile</h1>
    <p>User ID: {{ $route.params.id }}</p>
  </div>
</template>
jyasukawajyasukawa

liff.init() と onMounted() の修正

liff.init(LINE Front-end Frameworkの初期化)を App.vue ではなく main.ts で行う理由

  1. アプリ全体の初期化を担当する場所として適切
    main.ts は通常、アプリケーション全体の初期化や設定を行うファイル。liff.init はLINEプラットフォームにアプリケーションを接続するための初期化処理であり、これはアプリケーションの全体に関わるため、アプリのエントリーポイントで行うのが適切。
    一方、App.vue はコンポーネントの一部としてUIロジックを中心に扱う。App.vue の役割は、アプリケーションの初期化というよりも、UIの構築に関連する部分。liff.init はUIに直接関係ないため、アプリケーション全体の設定である main.ts で行うほうが整合性が取れる。

  2. ロジックの分離
    liff.init はアプリ全体で一度だけ呼び出されるべきもので、通常複数のコンポーネントで何度も行うべき処理ではない。App.vue ではなく main.ts で初期化することで、アプリケーション全体のセットアップと個々のコンポーネントの役割を分離し、コードの可読性と保守性が向上する。

  3. ライフサイクルの違い
    main.ts はアプリケーション全体がロードされる前に実行されますが、App.vue のコンポーネントライフサイクルはアプリがすでに動作している状態の一部。liff.init はアプリケーションが動作する前に行うべき初期化なので、Vueのライフサイクルを考慮しても、main.ts で行うのが適している。

  4. onMounted()
    onMounted() はVueコンポーネントのライフサイクルフックの一つで、コンポーネントがDOMにマウントされた後に実行される処理を記述するために使われる。これは主に、特定のコンポーネントがDOMに表示された後に行いたい操作を記述するために使用されるのでmain.tsでは必要ない。
//修正後のmain.ts
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from './router';
import { initializeLiff } from './lib/liffInit';
initializeLiff(); //関数を切り出してすっきりさせる
const app = createApp(App);
app.use(router); // ルーターをアプリケーションに適用
app.mount('#app');
//修正後のApp.vue
<template>
  <router-view></router-view> //これだけでいい
</template>
// lib/liffInit.ts
import liff from "@line/liff";
export const initializeLiff = async (): Promise<void> => {
    try {
        await liff.init({
        liffId: import.meta.env.VITE_LIFF_ID as string // 環境変数を使う
        });
        console.log('LIFF initialized successfully');
    } catch (error) {
        console.error('LIFF initialization failed', error);
    }
};
jyasukawajyasukawa

Server(バックエンド)側の作成

フロント側のUIが形になったら、データをcrud処理するためのバックエンド側を実装する。

1.Controllerファイルを作る

2.Serviceファイルを作る

3.Repositoryを作る

4.Module化する

5.appモジュールに登録する

6. Prisma のインストール

npm install prisma --save-dev
npm install @prisma/client
npx prisma init

これで prisma ディレクトリが生成され、prisma/schema.prisma というファイルが作成される。

7. Prisma スキーマの設定

prisma/schema.prisma ファイルを開き、MySQL データベースへの接続情報を設定。

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  name      String
  email     String   @unique
  createdAt DateTime @default(now())
}

ここでは、User というモデルを作成している。このモデルは MySQL のテーブルとして作成される。

8. .envファイルの設定

Prisma は .env ファイルを使ってデータベースの接続情報を管理する。DATABASE_URL の環境変数に、MySQL の接続文字列を設定する。

DATABASE_URL="mysql://user:password@localhost:3306/mydatabase"
DATABASE_URL="mysql://root:root@db:3306/nest_db"

user, password, mydatabase , portはそれぞれ MySQL の設定に合わせて変更する。

9. Prisma マイグレーションの実行

Prisma のスキーマを MySQL に反映させるために、マイグレーションを実行する。

npx prisma migrate dev --name init

これで、User モデルに基づいて MySQL に User テーブルが作成される。
注)上記のコマンドはコンテナ内で行う必要がある。
docker exec -it wecall-test-server-1 /bin/sh
また、ユーザーにテーブル作成の権限を付与しておく。
リセットするには
npx prisma migrate reset

10. Prisma Client の生成

Prisma Client を使って NestJS アプリケーションからデータベースを操作できるようにするために、Prisma Client を生成する。
npx prisma generate
これで、@prisma/client を使ってデータベースにアクセスできるようになる。

11. PrismaService の作成

NestJS では、Prisma Client を PrismaService として提供することが一般的。src/prisma フォルダを作成し、その中に prisma.service.ts ファイルを作成する。

prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}

12. PrismaModule の作成

次に、PrismaService を他のモジュールで使えるように、PrismaModule を作成する。src/prisma/prisma.module.ts に以下のコードを追加する。

prisma.module.ts
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

13. (User)Module の作成

src/user/user.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { User } from '@prisma/client';

@Injectable()
export class UserService {
  constructor(private prisma: PrismaService) {}

  async createUser(name: string, email: string): Promise<User> {
    return this.prisma.user.create({
      data: {
        name,
        email,
      },
    });
  }

  async getUsers(): Promise<User[]> {
    return this.prisma.user.findMany();
  }
}
src/user/user.controller.ts
import { Controller, Get, Post, Body } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('users')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post()
  async createUser(
    @Body('name') name: string,
    @Body('email') email: string,
  ) {
    return this.userService.createUser(name, email);
  }

  @Get()
  async getUsers() {
    return this.userService.getUsers();
  }
}
src/user/user.module.ts
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';
import { PrismaModule } from 'src/prisma/prisma.module';

@Module({
  imports: [PrismaModule],
  controllers: [UserController],
  providers: [UserService],
})
export class UserModule {}

14. AppModule への登録

app.module.ts に PrismaModule と UserModule をインポートし、登録する。

app.module.ts
import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma/prisma.module';
import { UserModule } from './user/user.module';

@Module({
  imports: [PrismaModule, UserModule],
})
export class AppModule {}

15.データベースに初期データを入れたい

Prismaでは、スクリプトを使って初期データを挿入できる。prisma/seed.ts ファイルを作成し、以下のように記述する。

project-root/
├── prisma/
│   ├── schema.prisma
│   └── seed.ts
└── src/
prisma/seed.ts
例)
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function main() {
  // Userの初期データを挿入
  await prisma.user.createMany({
    data: [
      { name: 'Alice', email: 'alice@example.com', age: 25 },
      { name: 'Bob', email: 'bob@example.com', age: 30 },
    ],
  });
}

main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

次に、パッケージに以下のコマンドを追加し、初期データを実行できるようにする。

package.json
"scripts": {
  "seed": "ts-node prisma/seed.ts"
}

スクリプトを実行して初期データを挿入。
npm run seed(dockerコンテナ内で)
これで、データベースに User テーブルが作成され、初期データが挿入された。Prisma Studioを使用してデータを確認することもできる。
npx prisma studio

prisma公式では

package.jsonの中でprismaセクションを新たに加え、以下のように記述:

"prisma": {
  "seed": "ts-node prisma/seed.ts"
}

この設定はscriptsセクションの外に配置。これにより、
npx prisma db seedコマンドが正しく動作する。

jyasukawajyasukawa

「コンポーザブル」とは?

Vue の Composition API を活用して状態を持つロジックをカプセル化して再利用するための関数。
状態のあるロジックは時間とともに変化する状態の管理が伴う。ページ上のマウスの現在位置をトラッキングするようなものがシンプルな例。実際、タッチジェスチャーやデータベースへの接続状態など、より複雑なロジックになる場合もある。

(対照に、共通のタスクのためにロジックを再利用しないといけないときのための、再利用可能な関数を集めたものー>ライブラリー。これは状態のないロジックをカプセル化し、ある入力を受け取ったら即座に期待される出力を返す。)

ユーザー情報入力フォームの各項目でエラーが表示されていないか管理したい場合の例

1. エラーステートを一元管理するコンポーザブルを作成

まず、エラーメッセージを一元管理するコンポーザブルを作成。これにより、各フォームコンポーネントで発生したエラーメッセージを追跡できる。

/composables/useFormErrorTracker.ts
import { ref } from 'vue';

// 全てのフォームのエラーメッセージを管理するコンポーザブル
export function useFormErrorTracker() {
  // 各フォームのエラーメッセージを管理する配列
  const errorMessages = ref<{ id: string, message: string }[]>([]);

  // エラーメッセージを追加
  const addError = (id: string, message: string) => {
    const existingError = errorMessages.value.find(error => error.id === id);
    if (existingError) {
      existingError.message = message;
    } else {
      errorMessages.value.push({ id, message });
    }
  };

  // エラーメッセージを削除
  const removeError = (id: string) => {
    errorMessages.value = errorMessages.value.filter(error => error.id !== id);
  };

  // 特定のIDにエラーメッセージが存在するか確認
  const hasError = (id: string) => {
    return errorMessages.value.some(error => error.id === id);
  };

  // 全体にエラーメッセージが存在するか確認
  const hasAnyError = () => {
    return errorMessages.value.length > 0;
  };

  return {
    errorMessages,
    addError,
    removeError,
    hasError,
    hasAnyError,
  };
}

2. 各フォームコンポーネントでエラー管理を統合

各フォームコンポーネントにて、上記で作成した useFormErrorTracker を使い、エラーメッセージの管理を行う。

例えば、以下のようにインポートして使用できる

/components/input-form/InputFormItem.vue
<script setup lang="ts">
import { ref } from 'vue';
import { useFormErrorTracker } from '../../composables/form-error-tracker';

interface Props {
  label?: string;
  type?: string;
  placeholder?: string;
}

const props = withDefaults(defineProps<Props>(), {
  label: '',
  type: 'text',
  placeholder: ''
});

const inputValue = ref('');
const { addError, removeError, hasError } = useFormErrorTracker();

const validate = () => {
  if (!inputValue.value) {
    addError('inputForm', `${props.label}は必須です`);
  } else {
    removeError('inputForm');
  }
};
</script>

<template>
  <div class="form-item">
    <p>{{ props.label }}</p>
    <input :type="props.type" :placeholder="props.placeholder" v-model="inputValue" @blur="validate" />
    <p v-if="hasError('inputForm')" class="error-message">{{ hasError('inputForm') }}</p>
  </div>
</template>

3. 複数のフォームでエラーの状態を確認

全てのフォームのエラーステートを追跡して、全体のエラーがあるかどうかをチェックすることもできる。

/views/InputForm.vue
<template>
  <div>
    <InputFormName />
    <InputFormBirthday />
    <!-- 他のフォーム -->

    <div v-if="hasAnyError()" class="global-error-message">
      入力内容に誤りがあります。全てのフォームを確認してください。
    </div>
  </div>
</template>

<script setup>
import { useFormErrorTracker } from '../composables/form-error-tracker';
const { hasAnyError } = useFormErrorTracker();
</script>
jyasukawajyasukawa

Pinia(ピニア)とは

Vue.jsの公式の状態管理ライブラリ。Piniaは、Vuexに代わる軽量でシンプルな代替として設計されており、Vue 3での使用に特に適している。Piniaを使うと、Vueアプリケーション全体でデータ(状態)を一元管理し、コンポーネント間でのデータの共有や同期を容易に行うことができる。

主な特徴

1.シンプルで直感的 - APIがシンプルで、Vue 3の構文に沿っているため、学習コストが低い。
2.型サポート - TypeScriptを利用する場合、Piniaは型定義がしっかりしているため、コードの保守性が高まる。
3.リアクティブな状態管理 - Piniaの状態はリアクティブであり、Vueのリアクティブシステムを活用して、コンポーネントの再レンダリングを効率的に行う。
4.モジュール化されたストア - 状態(store)を複数作成し、それらを独立して管理できる。これにより、アプリケーションの構造が整理されやすくなる。

基本的な使用方法

1.Piniaのインストール

npm install pinia

1.5 main.tsファイルの設定

main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';

const app = createApp(App);
const pinia = createPinia();

app.use(pinia); // PiniaをVueインスタンスに追加
app.mount('#app');

2.ストアの作成

ストアはPiniaの定義関数で作成する。例えば、カウンター用のストアを作成する場合、次のように定義できる。

// src/stores/counterStore.ts
import { defineStore } from 'pinia';

export const useCounterStore = defineStore('counter', {
  state: () => {
    return { count: 0 }
  },
  actions: {
    increment() {
      this.count++;
    },
  },
});

3.ストアの利用

Vueコンポーネント内でストアをインポートして使用する。

<script setup lang="ts">
import { useCounterStore } from '@/stores/counterStore';

// ストアを使用
const counterStore = useCounterStore();
</script>

<template>
  <div>
    <p>Count: {{ counterStore.count }}</p>
    <button @click="counterStore.increment">Increment</button>
  </div>
</template>
jyasukawajyasukawa

Vuetifyを使ってダイアログを簡単に作成する方法

Vuetifyには、ダイアログコンポーネントが組み込まれており、デザインや機能を簡単に導入できる。

https://qiita.com/to3izo/items/6f162e26f07ac1eef701参考

1.Vuetifyのインストール

プロジェクトにVuetifyがインストールされていない場合は、以下のコマンドでインストール.
npm install vuetify
その後、main.tsでVuetifyをインポートして設定を追加。

main.ts
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'

const vuetify = createVuetify({
  components,
  directives,
})

app.use(vuetify);

2.ダイアログの基本的な設定

Vuetifyのv-dialogコンポーネントを使って、ダイアログを作成。以下は、ボタンをクリックするとダイアログが開閉する基本的な例。

DialogExample.vue
<script setup lang="ts">
import { ref } from 'vue';

// ダイアログの表示状態を管理するref
const dialog = ref(false);
</script>

<template>
  <div>
    <!-- ダイアログを開くボタン -->
    <v-btn color="primary" @click="dialog = true">ダイアログを開く</v-btn>

    <!-- Vuetifyのダイアログコンポーネント -->
    <v-dialog v-model="dialog" max-width="500px">
      <v-card>
        <v-card-title class="headline">ダイアログのタイトル</v-card-title>

        <v-card-text>
          ここにダイアログの内容が入ります。
        </v-card-text>

        <v-card-actions>
          <v-spacer></v-spacer>
          <v-btn color="blue darken-1" text @click="dialog = false">閉じる</v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
  </div>
</template>

<style scoped>
/* 必要に応じてスタイルを追加 */
</style>

3.コンポーネントの説明

v-dialog: v-modelでダイアログの表示・非表示を制御しています。max-width属性でダイアログの幅を指定できます。

v-card: ダイアログの内容を表示するためのカードコンポーネントです。v-card-titleでタイトル、v-card-textで内容を表示します。

v-btn: ダイアログを開閉するボタンです。@clickイベントでdialogの状態を切り替えます。

4.カスタマイズ

ダイアログのスタイルや機能はVuetifyの属性やクラスを使って簡単にカスタマイズできます。たとえば、persistent属性を追加すると、外側をクリックしてもダイアログが閉じないようにできます。また、アニメーションやトランジションもVuetifyのプロパティで調整可能です。

これで、Vuetifyを使ったダイアログの基本的な作成方法が完成です。

jyasukawajyasukawa

「JWT認証」HTTP-onlyクッキーを使ったJWT認証を行うための設定

NestJSではJWT認証が簡単に導入でき、APIベースのアプリケーションに最適です。以下は簡単な実装手順。

1.必要なパッケージのインストール

npm install @nestjs/jwt @nestjs/passport passport passport-jwt cookie-parser
必要に応じてnode_moduleの再インストール

2.AuthModuleの作成

Nest CLIを使って認証モジュールを作成します。

nest g module auth
nest g service auth
nest g controller auth
ファイル構成
src
├── auth
│   ├── auth.module.ts          // 認証モジュールの定義
│   ├── auth.controller.ts       // 認証用のコントローラ
│   ├── auth.service.ts          // 認証ロジックを提供するサービス
│   ├── jwt.strategy.ts          // JWTの検証戦略を定義
│   ├── jwt-auth.guard.ts        // JWTガードの定義
│   └── dto
│       └── login.dto.ts         // ログイン用のDTO(データ転送オブジェクト)
├── app.module.ts                // メインアプリケーションモジュール

3.JWT認証モジュールの設定(auth.module.ts)

AuthModuleでJWTを設定します。AuthServiceでトークンを発行し、JwtStrategyで認証を行います。
AuthModuleにJwtStrategyを登録します。これにより、NestJSはこの戦略を自動的に利用し、ガードで使用する準備が整います。

auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { PrismaModule } from 'src/prisma/prisma.module';

@Module({
  imports: [
    PassportModule,
    JwtModule.register({
      secret: process.env.JWT_SECRET || 'default_secret_key', // 本番環境では環境変数にJWTの秘密鍵を設定
      signOptions: { expiresIn: '1h' }, // JWTの有効期限を設定(HTTP-onlyクッキーの有効期限とは別)
    }),
    PrismaModule, // PrismaModuleを追加(AuthService内で使うため)
  ],
  providers: [AuthService, JwtStrategy],
  controllers: [AuthController],
})
export class AuthModule {}

4. JWT戦略の設定 (jwt.strategy.ts)

JWTトークンを検証するための戦略を設定します。
JwtStrategyは、JWTトークンを使った認証を行うための戦略(Strategy)で、NestJSのPassportモジュールと共に利用されます。これにより、各エンドポイントでJWT認証を使えるようになり、ユーザーが適切なJWTを持っているかどうかの検証を行います。
JwtStrategyは、認証を必要とするルートで自動的にJWTトークンを検証し、トークンが有効な場合にそのユーザー情報をreq.userとして利用できるようにします。以下の流れで利用されます。

  1. JwtStrategyの設定:JwtStrategyは、PassportStrategyを継承して、validateメソッドでJWTの検証ロジックを記述します。NestJSの依存性注入により、AuthModuleでJwtStrategyが登録されると自動的にPassportがこの戦略を認識します。
  2. ガードでの利用:JwtAuthGuardを利用して、特定のエンドポイントで認証をかけます。これにより、エンドポイントにアクセスする前にJwtStrategyを使ってトークンが検証されます。
jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Request } from 'express';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([
        // クッキーから JWT を抽出(Authorizationヘッダーからではなく)
        (request: Request) => {
          const jwt = request?.cookies?.jwt;
          if (!jwt) {
            return null;
          }
          return jwt;
        },
      ]),
      ignoreExpiration: false, // トークンの有効期限を確認
      secretOrKey: process.env.JWT_SECRET || 'default_secret_key', //auth.module.tsと一致させる
    });
  }

  async validate(payload: any) {
    return { userId: payload.sub, username: payload.username }; // トークンが有効な場合の処理
  }
}

5.サインインの実装(auth.controller.ts)

サインインエンドポイントでJWTを発行し、クライアントに返します。
JWTをHTTP-onlyクッキーとして設定します。
「エンドポイントの設定時はプロキシ設定を忘れず!!」

vite.config.ts
'/auth': {
        target: 'http://server:3000', // NestJSバックエンドのURL
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/auth/, '/auth'), // 必要に応じてパスを変更
      },
auth.controller.ts
import { Controller, Post, Body, Res, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { Response } from 'express';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

    @Post('login')
      async signIn(
        @Body() loginDto: { email: string; password: string },
        @Res() res: Response,
      ) {
        const token = await this.authService.validateUser(
          loginDto.email,
          loginDto.password,
        );
        if (!token) throw new UnauthorizedException();
    
        // HTTP-onlyクッキーとしてJWTをセット
        res.cookie('jwt', token, {
          httpOnly: true,
          secure: true,
          // maxAge: 24 * 60 * 60 * 1000, //=1日
          maxAge: 60 * 1000, //=1分
        }); // HTTP OnlyクッキーにJWTを設定
        return res.send({ message: 'Login successful' });
      }
}

5.5. トークンの検証エンドポイントの実装(これもauth.controller.ts)

/auth/verifyエンドポイントでJWTトークンの検証を行います。(すでに登録してある人用)
以下を追加する

auth.controller.ts
import { Get, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from './jwt-auth.guard';

@Controller('auth')
export class AuthController {
  // ログイン部分は省略
  
  @Get('verify')
  @UseGuards(JwtAuthGuard)
  verify() {
    return { isAuthenticated: true };  // 認証が成功した場合のレスポンス
  }
//@UseGuards(JwtAuthGuard) を使っている場合、認証が成功しなかったときは verify メソッドに到達せず、自動的にエラーレスポンスが送信されます。
}

6. JWTガードの設定(jwt-auth.guard.ts)

JWTを使ったガードを設定します。

jwt-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

7.認証ロジックの実装(auth.service.ts)

AuthServiceでJWTトークンの発行とユーザーの認証を行います。

auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../prisma/prisma.service'; // Prisma Serviceが必要
import * as bcrypt from 'bcrypt';

@Injectable()
export class AuthService {
  constructor(
    private readonly jwtService: JwtService,
    private readonly prisma: PrismaService,
  ) {}

  async validateUser(email: string, password: string) {
    const user = await this.prisma.user.findUnique({ where: { email } });
    const isPasswordValid = await bcrypt.compare(password, user.password); //seed.tsでパスワードを暗号化して格納しているため
    if (user && isPasswordValid) {
      // パスワードの確認を行う
      return this.jwtService.sign({ email: user.email, sub: user.id });
    }
    throw new UnauthorizedException('Invalid credentials');
  }
}

8.ミドルウェア設定

JWTのクッキー処理のために、NestJSのmain.tsでcookie-parserを使います。

main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as cookieParser from 'cookie-parser';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.use(cookieParser()); //フロントエンド側では JWT をクッキーで受け取っているためJWT の抽出方法をクッキーベースに変更
  await app.listen(3000);
}
bootstrap();

9.フロントエンド(Vue.js)でのログイン処理

login.vueのsignInメソッドで、エンドポイント/auth/loginにリクエストを送信します。

login.vue
const signIn = async () => {
  try {
    const response = await axios.post('/auth/login', {
      email: email.value,
      password: password.value
    });
    console.log(response.data);
    router.push({ name: ' 次のページのpath' });
  } catch (error) {
    console.error('Login failed:', error);
  }
}

onMounted(async () => { //次回のアクセス時には/auth/verifyでトークンの検証が行われる。
  try {
    const response = await axios.get('/auth/verify'); // JWTを検証

    if (response.data.isAuthenticated) {
      router.push({ name: '次のページのpath' }); // 認証済みならダッシュボードへ
    }
  } catch (error) {
    console.error('Authentication check failed:', error);
  }
});
jyasukawajyasukawa

Vuetifyを使った予約管理画面の作り方

Vuetifyの公式を参考にテンプレートで簡単に作れる
注)公式のサンプルコードはバージョンが古いので注意!

1.@mdi/font パッケージのインストール

もしまだ @mdi/font パッケージがインストールされていない場合は、以下のコマンドでインストールします。
npm install @mdi/font
注)インストールはフロントエンドで

2.Vuetify 設定でアイコンセットを指定

plugins/vuetify.ts
import 'vuetify/styles'
import { createVuetify } from 'vuetify'
import { aliases, mdi } from 'vuetify/iconsets/mdi'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'

const vuetify = createVuetify({
  components,
  directives,
  icons: {
    defaultSet: 'mdi',
    aliases,
    sets: {
      mdi,
    },
  },
})

export default vuetify

3.アイコンセットのインポート

プロジェクト全体でアイコンを使えるように、main.ts にアイコンフォントのCSSをインポートします。

main.ts
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from './router'
import { initializeLiff } from './composables/liff-init'
import { createPinia } from 'pinia'
import vuetify from './plugins/vuetify'
import '@mdi/font/css/materialdesignicons.css'

initializeLiff()

const app = createApp(App)
const pinia = createPinia()

app.use(router) // ルーターをアプリケーションに適用
app.use(pinia)  // PiniaをVueインスタンスに追加
app.use(vuetify) // Vuetifyを使用
app.mount('#app')
このスクラップは1ヶ月前にクローズされました