👌

SvelteKit+Superforms+Prisma+Luciaでログイン機能を爆速で実装する

2023/05/08に公開
3

はじめに

こんにちは!最近SvelteKitにハマっている素人エンジニアの菅原です。
本記事はSvelteKitでログイン機能を実装する流れを雑に書いた備忘録です。
全体的に詳しい説明はしていなかったり、例外処理を厳密にやっていない部分があるため、参考程度に見ていただければ幸いです。

作成するもの

Github: https://github.com/MichitoSugawara/sveltekit-lucia-daisyui
Vercel: https://sveltekit-lucia-daisyui.vercel.app/login

主な使用技術

SvelteKit

SvelteKitは、Svelteベースのフレームワークで、Webアプリケーションの構築を簡単かつ迅速に行うことができます。魅力が多すぎるのでとりあえず触ってみてください。
公式チュートリアルもかなり優秀です。
https://learn.svelte.dev/tutorial/welcome-to-svelte

Superforms

Superformsは、フォームのサーバーサイドバリデーションとクライアントサイドの表示をサポートするSvelteKitのライブラリです。
tRPCと同じようにバリデーションにZodスキーマを使用しており、クライアント・サーバー間で型安全にデータをやり取りできますが、それに加えてフォームのスナップショットやイベントハンドリングなど、正直一回使うともう離れられないような便利なライブラリです。
https://superforms.vercel.app/

Prisma

Typescript製の型安全なORMです。もうSQL文なんて書きたくない、そんなあなたに。
もうEloquentには戻れない。
https://www.prisma.io/

Lucia

SvelteKit・Next.js等に対応している認証ライブラリ。
かなりシンプルに安全に認証・認可を実装できる。
Auth.js(NextAuth.js)同様複数プロバイダでの認証に対応している。
https://lucia-auth.com/?sveltekit

Sveltekitのプロジェクト作成

$ pnpm create svelte@latest sveltekit-lucia
◇  Which Svelte app template?
│  Skeleton project
│
◇  Add type checking with TypeScript?
│  Yes, using TypeScript syntax
│
◇  Select additional options (use arrow keys/space bar)
│  Add ESLint for code linting, Add Prettier for code formatting
 code linting, Add Prettier for code formatting

ひとまず必要な画面をマークアップ

実装に取り掛かる前に、ひとまず認証機能で必要な以下3つの画面をマークアップしておきます。

  1. ログインページ(非ログインユーザーのみ)
  2. 新規登録ページ(非ログインユーザーのみ)
  3. ユーザーページ(ログインユーザーのみ)

本プロジェクトでは、認証によってアクセスできる/できないページを明確に分けるために(loggedIn)/(loggedOut)というフォルダを作りグループ分けしています。
SvelteKitはファイスシステムベースのルーターを採用していますが、()で囲むことでURLに影響を与えずにグループ化できます。
https://kit.svelte.jp/docs/advanced-routing#advanced-layouts

ログインページと新規登録ページ

src/routes/(loggedOut)/login/+page.svelte
<div>
	<h1>ログイン</h1>
	<form method="POST">
		<label for="username">ユーザー名</label>
		<input type="text" name="username" />

		<label for="password">パスワード</label>
		<input type="password" name="password" />

		<div><button>ログイン</button></div>
		<a href="/signup">サインアップ</a>
	</form>
</div>

src/routes/(loggedOut)/signup/+page.svelte
<div>
	<h1>サインアップ</h1>
	<form method="POST">
		<label for="username">ユーザー名</label>
		<input type="text" name="username" />

		<label for="password">パスワード</label>
		<input type="password" name="password" />

			<label for="confirmPassword">パスワード(確認)</label>
			<input type="password" name="confirmPassword"/>
		
		<div><button>サインアップ</button></div>
		<a href="/login">ログイン</a>
	</form>
</div>

ユーザーページ

ただページを用意しておくだけ。

src/routes/(loggedIn)/user/+page.svelte
<h1>ユーザーページ</h1>

スタイルはとりあえずSakuraCSSを入れておく

ログイン・新規登録ページのデザインはのちのちDaisyUIで作成しますが、ひとまずSakuraCSSを入れておいていい感じにしておきます。
テーマも複数あり、入れるだけで割とそれっぽくなるので、CSSを書きたくない・時間がないときにはかなり重宝しますね...!
https://github.com/oxalorg/sakura

src/app.html
	<head>
		<meta charset="utf-8" />
		<link rel="icon" href="%sveltekit.assets%/favicon.png" />
		<meta name="viewport" content="width=device-width" />
+		<!-- Normalize CSS -->
+		<link rel="stylesheet" href="https://unpkg.com/sakura.css/css/normalize.css" />
+		<!-- Sakura CSS -->
+		<link rel="stylesheet" href="https://unpkg.com/sakura.css/css/sakura-vader.css" />
		%sveltekit.head%
	</head>


Superformsの導入

以下SuperformsのGet Startedを参考に、フォームデータの送信処理・バリデーションを実装していきます。
https://superforms.vercel.app/get-started

sveltekit-superformsとzodをインストール

SuperformsではZodスキーマを用いてバリデーションをここなうため、superformsとzodをインストールします。

$ pnpm i -D sveltekit-superforms zod

ログインフォーム用のZodスキーマ作成とSuperformsの適用

まずログインフォームのバリデーションに使うZodスキーマを作成します。
ログインフォームでは細かいバリデーションはせず、必須(1文字以上)のみ指定しておきます。
また、このままではZodスキーマでデフォルトで定義されている英語のエラーメッセージが返されてしまうので、メッセージは日本語で指定しておきます。
(デフォルトのエラーメッセージの日本語化については、zod-i18nというライブラリを使うことにより可能ですが、ここは別記事でのちのち紹介したいと思います。)

src/lib/schemas/loginSchema.ts
import { z } from 'zod';

export const loginSchema = z.object({
	username: z.string().min(1, { message: 'ユーザー名を入力してください' }),
	password: z.string().min(1, { message: 'パスワードを入力してください' })
});

export type loginSchemaType = typeof loginSchema;

次にサーバー側のファイルを作成します。
SvelteKitでは、レンダリング前に取得したいデータ(ローディングデータ)をload関数に、フォームデータを受け取った際の処理(フォームアクション)をactions関数として定義できます。
Superformsを使用する場合、ローディングデータとしてsuperValidateメソッドで初期化したインスタンスをページデータとしてクライアント側に渡します。
これにより、クライアント側で再度フォームデータの定義をすることなく、フォームへデータバインドするだけで済みます。
また、フォームアクションでも、同じsuperValidateメソッドにリクエストイベントを渡して使用することで、送信されたフォームデータをバリデーションした上で、バリデーション結果を含んだインスタンスが返されます。
これも同じようにクライアント側に返却することにより、バリデーション結果をクライアント側に表示させることができます。
以下ではバリデーションに失敗した場合は、ステータスBad Requestでインスタンスをそのまま返却します。
成功した場合には、ログイン処理を行うこととなりますが、ここは後ほど実装します。

src/routes/(loggedOut)/login/+page.server.ts
import type { Actions, PageServerLoad } from './$types';
import { loginSchema } from '$lib/schemas/userSchema';
import { superValidate } from 'sveltekit-superforms/server';
import { fail } from '@sveltejs/kit';

const schema = loginSchema;

export const load = (async () => {
	const form = await superValidate(schema);
	return { form };
}) satisfies PageServerLoad;

export const actions: Actions = {
	default: async (event) => {
		// フォームデータのバリデーション
		const form = await superValidate(event, schema);
		if (!form.valid) {
			return fail(400, { form });
		}
		// TODO: バリデーション後の処理
		return { form };
	}
};

クライアント側ではsuperFormメソッドによりクライアント側で使用する以下プロパティ・メソッドを初期化します。

  • form: フォームへバインドする用のフォームデータ
  • message: サーバー側で任意に指定できるメッセージ
  • errors: バリデーションの結果
  • submitting: フォームが送信中かどうかの真偽値(Superformsのenhanceアクションを使用中のみ)
  • capture/restore: フォームデータのsnapshotに使うメソッド(地味に助かりますね...)
    https://superforms.vercel.app/concepts/snapshots
  • enhance: Superformsのカスタムenhanceアクション(本当に助かる...)
    https://superforms.vercel.app/concepts/enhance

今回は詳しい説明を省きますが、Superformsについても別途記事を書く予定です。

src/routes/(loggedOut)/login/+page.svelte
<script lang="ts">
  import type { PageData } from './$types';
  import { superForm } from 'sveltekit-superforms/client';

  export let data: PageData;
  const { form, message, errors, submitting, capture, restore, enhance } = superForm(data.form, {taintedMessage: false});
  export const snapshot = { capture, restore };
</script>

<div>
  <h1>ログイン</h1>
  {#if $message}<span class="invalid">{$message}</span>{/if}
  <form method="POST" use:enhance>
    <label for="username">ユーザー名</label>
    <input type="text" name="username" bind:value={$form.username} disabled={$submitting} />
    {#if $errors.username}<span class="invalid">{$errors.username[0]}</span>{/if}

    <label for="password">パスワード</label>
    <input type="password" name="password" bind:value={$form.password} disabled={$submitting} />
    {#if $errors.password}<span class="invalid">{$errors.password[0]}</span>{/if}

    <div><button disabled={$submitting}>ログイン</button></div>
    <a href="/signup">サインアップ</a>
  </form>
</div>

<style>
  .invalid {
    color: red;
  }
</style>


以上でログインフォームのサーバーサイドバリデーションとその表示が実装できました。
Superforms最高です。

新規登録フォーム用のZodスキーマ作成とSuperformsの適用

ログインフォームと同様に新規登録フォームにもZodスキーマの作成とSuperformsを適用を行います。

src/lib/schemas/signupSchema.ts
import { z } from 'zod';

export const signupSchema = z
	.object({
		username: z.string().min(3, { message: 'ユーザー名は3文字以上で入力してください' }),
		password: z
			.string()
			.min(8, { message: 'パスワードは8文字以上で入力してください' })
			.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9]).{8,}$/, {
				message: 'パスワードには、小文字・大文字・数字を一文字以上含めて8文字以上で入力してください'
			}),
		confirmPassword: z.string().min(8, { message: 'パスワードは8文字以上で入力してください' })
	})
	.refine((data) => data.password === data.confirmPassword, {
		message: 'パスワードが一致していません',
		path: ['confirmPassword']
	});

export type signupSchemaType = typeof signupSchema;
src/routes/(loggedOut)/signup/+page.server.ts
import type { Actions, PageServerLoad } from './$types';
import { signupSchema } from '$lib/schemas/signupSchema';
import { superValidate } from 'sveltekit-superforms/server';
import { fail } from '@sveltejs/kit';

const schema = signupSchema;

export const load = (async () => {
	const form = await superValidate(schema);
	return { form };
}) satisfies PageServerLoad;

export const actions: Actions = {
	default: async (event) => {
		// フォームデータのバリデーション
		const form = await superValidate(event, schema);
		if (!form.valid) {
			return fail(400, { form });
		}
		// TODO: バリデーション後の処理
		return { form };
	}
};
src/routes/(loggedOut)/signup/+page.svelte
<script lang="ts">
  import type { PageData } from './$types';
  import { superForm } from 'sveltekit-superforms/client';

  export let data: PageData;
  const { form, message, errors, submitting, capture, restore, enhance } = superForm(data.form, {
    taintedMessage: false
  });
  export const snapshot = { capture, restore };
</script>

<div>
  <h1>サインアップ</h1>
  {#if $message}<span class="invalid">{$message}</span>{/if}
  <form method="POST" use:enhance>
    <label for="username">ユーザー名</label>
    <input type="text" name="username" bind:value={$form.username} disabled={$submitting} />
    {#if $errors.username}<span class="invalid">{$errors.username[0]}</span>{/if}

    <label for="password">パスワード</label>
    <input type="password" name="password" bind:value={$form.password} disabled={$submitting} />
    {#if $errors.password}<span class="invalid">{$errors.password[0]}</span>{/if}

    <label for="confirmPassword">パスワード(確認)</label>
    <input
      type="password"
      name="confirmPassword"
      bind:value={$form.confirmPassword}
      disabled={$submitting}
    />
    {#if $errors.confirmPassword}<span class="invalid">{$errors.confirmPassword[0]}</span>{/if}

    <div><button disabled={$submitting}>サインアップ</button></div>
    <a href="/login">ログイン</a>
  </form>
</div>

<style>
  .invalid {
    color: red;
  }
</style>

データベースの作成とPrismaのセットアップ

PostgreSQLのコンテナ立ち上げ

データベースには後々無料プランのあるVercel PostgreSQL, Neon, Supabase等のクラウドデータベースにデプロイすることを考え、PostgreSQLを使います。
以下Composeファイルを用意してPostgreSQLのDockerコンテナを立ち上げます。

docker-compose.yml
version: '3.8'

services:
  db:
    image: postgres:latest
    restart: unless-stopped
    volumes:
      - db-data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_USER: postgres
      POSTGRES_DB: postgres
    ports:
      - '5432:5432'

volumes:
  db-data:

バックグランドで実行してもらいたいので、dオプションを指定しておきます。

$ docker-compose up -d

Prismaのインストール

PostgreSQL用にPrismaのセットアップを行います。

$ pnpm i -D prisma && pnpm i @prisma/client
$ prisma init --datasource-provider postgresql
.env
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"

また、Prismaクライアントはよく使うので、モジュール化しておきます。
ただ、DBクライアントはサーバー側で使用するもので、クライアント側のファイルとして含まれないようにサーバー専用モジュールとしてlib/server内に配置しています。
https://kit.svelte.dev/docs/server-only-modules

src/lib/server/db.ts
import { PrismaClient } from '@prisma/client';

export const db = new PrismaClient();

Luciaの導入と認証機能の実装

lucia-authとprisma用のアダプターをインストールします。
※2023/10/20追記: Lucia2.0以降書き方が変わっているため、下記コマンドにバージョンを追記しました。

$ pnpm i lucia-auth@1.5.0 @lucia-auth/adapter-prisma@2.0.0

以下ドキュメントを参考にLuciaのインスタンスとその型をエクスポートするファイルを作成します。
Prisma同様、クライアント側に含まれないようにlib/server内に配置します。
https://lucia-auth.com/start-here/getting-started

src/lib/server/lucia.ts
import lucia from "lucia-auth";
import { sveltekit } from "lucia-auth/middleware";
import prisma from "@lucia-auth/adapter-prisma";
import { db } from "$lib/server/db";
import { dev } from "$app/environment";

export const auth = lucia({
	adapter: prisma(db),
	env: dev ? "DEV" : "PROD",
	middleware: sveltekit()
});

export type Auth = typeof auth;

src/app.d.tsに認証ユーザーの型定義と後々使うlocals.authの型定義を追加します。

src/app.d.ts
/// <reference types="@sveltejs/kit" />
/// <reference types="unplugin-icons/types/svelte" />
/// <reference types="lucia-auth" />
declare namespace Lucia {
	type Auth = import('$lib/server/lucia').Auth;
	type UserAttributes = import('@prisma/client').AuthUser;
}

declare global {
	namespace App {
		interface Locals {
			auth: import('lucia-auth').AuthRequest;
		}
	}
}

export {};

スキーマ定義

Luciaでは認証のために以下3つのテーブルが必要になります。

  • AuthUser
    ユーザー情報を保存するために使用されます。このテーブルにより、ユーザーの基本情報を一元管理することができます。
  • AuthSession
    ユーザーのセッション情報を保存するために使用されます。このテーブルにより、ユーザーのログイン状態やセッションの有効期限などを管理することができます。
  • AuthKey
    ユーザーの認証方法との関係を表すキーを保存するために使用されます。ユーザー情報と分けることで、メール/パスワードやOAuthといった複数の認証方法を使うことが可能になります。

以下公式ドキュメントを参考にPrismaスキーマファイル作成します。
https://lucia-auth.com/adapters/prisma

prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

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

model AuthUser {
  id           String        @id @unique
  auth_session AuthSession[]
  auth_key     AuthKey[]
  username     String?       @unique

  @@map("auth_user")
}

model AuthSession {
  id             String   @id @unique
  user_id        String
  active_expires BigInt
  idle_expires   BigInt
  auth_user      AuthUser @relation(references: [id], fields: [user_id], onDelete: Cascade)

  @@index([user_id])
  @@map("auth_session")
}

model AuthKey {
  id              String   @id @unique
  hashed_password String?
  user_id         String
  primary_key     Boolean
  expires         BigInt?
  auth_user       AuthUser @relation(references: [id], fields: [user_id], onDelete: Cascade)

  @@index([user_id])
  @@map("auth_key")
}

以下コマンドでPrismaスキーマをデータベースに同期しておきます。

$ prisma db push

認証ミドルウェア

HooksでLuciaのリクエストハンドラをミドルウェアとして設定しておき、event.locals.authからセッション情報を取得できるようにします。

hooks.server.ts
import { auth } from '$lib/server/lucia';
import type { Handle } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';

const authHandler: Handle = async ({ event, resolve }) => {
	event.locals.auth = auth.handleRequest(event);

	return await resolve(event);
};

export const handle = sequence(authHandler);

ログイン処理の実装

LuciaではuseKeyメソッドを使用して複数認証方法で認証することが可能です。
今回はユーザー名とパスワードで認証を行いたいため、'usersame'を指定してバリデーションされたユーザー名とパスワードを渡します。
認証が成功したら、createSessionメソッドでデータベースにセッション情報を作成し、setSessionメソッドでクッキーにセッションIDを保存します。

src/routes/(loggedOut)/login/+page.server.ts
import type { Actions, PageServerLoad } from './$types';
import { loginSchema } from '$lib/schemas/userSchema';
import { superValidate } from 'sveltekit-superforms/server';
import { fail } from '@sveltejs/kit';
+import { auth } from '$lib/server/lucia';

const schema = loginSchema;

export const load = (async () => {
	const form = await superValidate(schema);
	return { form };
}) satisfies PageServerLoad;

export const actions: Actions = {
	default: async (event) => {
		// フォームデータのバリデーション
		const form = await superValidate(event, schema);
		if (!form.valid) {
			return fail(400, { form });
		}
-		// TODO: バリデーション後の処理
+		// ログイン処理
+		try {
+			const key = await auth.useKey('username', form.data.username, form.data.password);
+			const session = await auth.createSession(key.userId);
+			event.locals.auth.setSession(session);
+		} catch {
+			return fail(400, { form: { ...form, message: 'ログインエラー' } });
+		}
		return { form };
	}
};

新規登録処理の実装

新規登録処理ではcreateUserメソッドを使用してユーザーを作成します。
また、ログイン同様にセッションの作成とクッキーへのセッションIDの保存を行います。

src/routes/(loggedOut)/signup/+page.server.ts
import type { Actions, PageServerLoad } from './$types';
import { signupSchema } from '$lib/schemas/signupSchema';
import { superValidate } from 'sveltekit-superforms/server';
import { fail } from '@sveltejs/kit';
+import { auth } from '$lib/server/lucia';

const schema = signupSchema;

export const load = (async () => {
	const form = await superValidate(schema);
	return { form };
}) satisfies PageServerLoad;

export const actions: Actions = {
	default: async (event) => {
		// フォームデータのバリデーション
		const form = await superValidate(event, schema);
		if (!form.valid) {
			return fail(400, { form });
		}
-		// TODO: バリデーション後の処理
+		// サインアップ処理
+		try {
+			const user = await auth.createUser({
+				primaryKey: {
+					providerId: 'username',
+					providerUserId: form.data.username,
+					password: form.data.password
+				},
+				attributes: {
+					username: form.data.username
+				}
+			});
+			const session = await auth.createSession(user.userId);
+			event.locals.auth.setSession(session);
+		} catch {
+			return fail(400, { form: { ...form, message: 'サインアップエラー' } });
+		}
		return { form };
	}
};

ログアウト処理の実装

ログアウト処理は本来POSTメソッドで作成するのが望ましいですが、今回はGETメソッドで実装して簡単にログアウトできるようにしておきます。
データベースにあるセッション情報を無効化し、クッキーからセッションIDを削除することでログアウトが完了します。

src/routes/(loggedIn)/logout/+server.ts
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async ({ locals }) => {
	const session = await locals.auth.validate();
	if (!session) throw error(404);
	// セッションの無効化
	await auth.invalidateSession(session.sessionId);
	// クッキーからセッションID削除
	locals.auth.setSession(null);
	throw redirect(302, '/login');
};

ユーザーの認可

以上で認証の実装は完了しましたが、まだユーザーによってアクセスを制限する認可の実装が必要です。
以下で(loggedIn)と(loggedOut)配下のページに対するリクエストに対して、解決前に割り込んで適切にリダイレクトさせる認可の処理を実装しています。
これにより、ログインしていない状態でユーザーページへリクエストした際、解決前に強制的にログインページへリダイレクトされます。
また逆に、ログインしている状態でログイン・新規登録ページへリクエストした際、解決前に強制的にユーザーページへリダイレクトされます。

hooks.server.ts
import { auth } from '$lib/server/lucia';
import type { Handle } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';

const authHandler: Handle = async ({ event, resolve }) => {
	event.locals.auth = auth.handleRequest(event);
+	const user = await event.locals.auth.validate();
+
+	if (event.route.id?.startsWith('/(loggedIn)') && !user) {
+		return Response.redirect(`${event.url.origin}/login`, 302);
+	}
+
+	if (event.route.id?.startsWith('/(loggedOut)') && user) {
+		return Response.redirect(`${event.url.origin}/user`, 302);
+	}

	return await resolve(event);
};

export const handle = sequence(authHandler);

ただ、ユーザーページは現状サーバー側での処理がないため、クライアントサイドナビゲーションではサーバー側のHooksは呼び出されず、ユーザーページにアクセスできてしまいます。
サーバー側のファイルを追加して、サーバー側のHooksが機能するようにしましょう。

src/routes/(loggedIn)/user/+page.server.ts
import type { PageServerLoad } from './$types';

export const load = (async () => {
	return {};
}) satisfies PageServerLoad;

これでログイン・サインアップ・認証済みページの保護が実装できました。
認証機能の実装は完了しましたが、SakuraCSSだけでは少し味気ないのでDaisyUIを導入してUIを調整していきます。

DaisyUIの導入とUIの調整

SakuraCSSのCDN削除

DaisyUIを使用するので、使用していたSakuraCSSを削除します。

src/app.html
	<head>
		<meta charset="utf-8" />
		<link rel="icon" href="%sveltekit.assets%/favicon.png" />
		<meta name="viewport" content="width=device-width" />
-		<!-- Normalize CSS -->
-		<link rel="stylesheet" href="https://unpkg.com/sakura.css/css/normalize.css" />
-		<!-- Sakura CSS -->
-		<link rel="stylesheet" href="https://unpkg.com/sakura.css/css/sakura-vader.css" />
		%sveltekit.head%
	</head>

DaisyUIのインストール

DaisyUIはTailwindCSSをベースとしているので、svelte-addを使用してTailwindCSSを追加します。

$ pnpx svelte-add@latest tailwindcss

以下を参考にDaisyUIをインストールし、tailwind.config.cjsのpluginsに追加します。
https://daisyui.com/docs/install/
テーマはいろいろありますが、緑色が好きなのでlemonadeにしておきます。

tailwind.config.cjs
const config = {
	content: ['./src/**/*.{html,js,svelte,ts}'],

	theme: {
		extend: {}
	},

+	daisyui: {
+		themes: ['lemonade']
+	},

-	plugins: []
+	plugins: [require("daisyui")]
};

module.exports = config;

ローディングアニメーションの実装

現状フォームの送信は非同期、ナビゲーションはクライアントサイドで行っているためUXをよくするためにローディングアニメーションが欲しくなりますよね。
こちらも爆速で作れます。SvelteKitならね。

ローディングコンポーネントの作成

ここはお好みで作成してください。
以下は画面を少し暗くして真ん中に緑色の何かが回ります。

src/lib/components/Loading.svelte
<div class="absolute w-full h-full bg-black z-10 opacity-10" />
<div class="absolute w-full h-full z-10 flex justify-center items-center">
	<div class="animate-spin h-16 w-8 bg-primary rounded-xl" />
</div>

ストアの作成

まずグローバルにローディングとフォーム送信のステータスを状態管理するために、下記ストアファイル作成します。

src/lib/stores/index.ts
import { writable } from 'svelte/store';

export const loading = writable(false);
export const submitting = writable(false);

レイアウトにローディングコンポーネント反映

以下で遷移中もしくはsubmittingストアがtrueのときにローディングコンポーネントが表示されます。
そのため、各フォームがあるページから送信時にsubmittingストアをtrueにする必要があります。

src/routes/+layout.svelte
<script>
	import '../app.postcss';
	import { navigating } from '$app/stores';
	import Loading from '$lib/components/Loading.svelte';
	import { loading, submitting } from '$lib/stores';
	// submittingストアがtrue時にページ遷移が起きた場合はsubmittingストアをリセット
	$: if ($navigating) $submitting = false;
	// ページ遷移中もしくはsubmittingストア
	$: $loading = !!$navigating || $submitting;
</script>

{#if $loading}
	<Loading />
{/if}

<slot />

ログイン画面の調整

src/routes/(loggedOut)/login/+page.svelte
<script lang="ts">
  import { submitting as submittingStore } from '$lib/stores';
  import type { PageData } from './$types';
  import { superForm } from 'sveltekit-superforms/client';

  export let data: PageData;
  const { form, message, errors, submitting, enhance, capture, restore } = superForm(data.form, {
    taintedMessage: false
  });
  $: $submittingStore = $submitting;
  export const snapshot = { capture, restore };
</script>

<div class="relative flex flex-col items-center justify-center h-screen overflow-hidden">
  <div class="w-full lg:max-w-lg p-6 m-auto rounded-md shadow-2xl">
    <h1 class="text-3xl font-semibold text-center text-primary">ログイン</h1>
    {#if $message}<span class="text-sm text-red-600">{$message}</span>{/if}
    <form class="space-y-4" method="POST" use:enhance>
      <div>
        <label class="label" for="username"
          ><span class="text-base label-text">ユーザー名</span></label
        >
        <input
          class="w-full input input-bordered input-primary {$errors.username || $message
            ? 'input-error'
            : ''}"
          type="text"
          name="username"
          bind:value={$form.username}
          disabled={$submitting}
        />
        {#if $errors.username}<span class="text-xs text-red-600">{$errors.username[0]}</span>{/if}
      </div>
      <div>
        <label class="label" for="password"
          ><span class="text-base label-text">パスワード</span></label
        >
        <input
          class="w-full input input-bordered input-primary {$errors.password || $message
            ? 'input-error'
            : ''}"
          type="password"
          name="password"
          bind:value={$form.password}
          disabled={$submitting}
        />
        {#if $errors.password}<span class="text-xs text-red-600">{$errors.password[0]}</span>{/if}
      </div>
      <div><button class="btn btn-block btn-primary" disabled={$submitting}>ログイン</button></div>
      <div>
        <span class="text-sm">アカウントをお持ちでない方は</span>
        <a class="text-sm text-blue-600 hover:underline hover:text-blue-400" href="/signup"
          >新規登録</a
        >
      </div>
    </form>
  </div>
</div>

サインアップ画面の調整

src/routes/(loggedOut)/signup/+page.svelte
<script lang="ts">
  import { submitting as submittingStore } from '$lib/stores';
  import type { PageData } from './$types';
  import { superForm } from 'sveltekit-superforms/client';

  export let data: PageData;
  const { form, message, errors, submitting, enhance, capture, restore } = superForm(data.form, {
    taintedMessage: false
  });
  $: $submittingStore = $submitting;
  export const snapshot = { capture, restore };
</script>

<div class="relative flex flex-col items-center justify-center h-screen overflow-hidden">
  <div class="w-full lg:max-w-lg p-6 m-auto rounded-md shadow-2xl">
    <h1 class="text-3xl font-semibold text-center text-primary">新規登録</h1>
    {#if $message}<span class="text-sm text-red-600">{$message}</span>{/if}
    <form class="space-y-4" method="POST" use:enhance>
      <div>
        <label class="label" for="username"
          ><span class="text-base label-text">ユーザー名</span></label
        >
        <input
          class="w-full input input-bordered input-primary {$errors.username || $message
            ? 'input-error'
            : ''}"
          type="text"
          name="username"
          bind:value={$form.username}
        />
        {#if $errors.username}<span class="text-xs text-red-600">{$errors.username[0]}</span>{/if}
      </div>
      <div>
        <label class="label" for="password"
          ><span class="text-base label-text">パスワード</span></label
        >
        <input
          class="w-full input input-bordered input-primary {$errors.password || $message
            ? 'input-error'
            : ''}"
          type="password"
          name="password"
          bind:value={$form.password}
          disabled={$submitting}
        />
        {#if $errors.password}<span class="text-xs text-red-600">{$errors.password[0]}</span>{/if}
      </div>

      <div>
        <label class="label" for="confirmPassword"
          ><span class="text-base label-text">パスワード(確認)</span></label
        >
        <input
          class="w-full input input-bordered input-primary {$errors.confirmPassword || $message
            ? 'input-error'
            : ''}"
          type="password"
          name="confirmPassword"
          bind:value={$form.confirmPassword}
          disabled={$submitting}
        />
        {#if $errors.confirmPassword}<span class="text-xs text-red-600"
            >{$errors.confirmPassword[0]}</span
          >{/if}
      </div>

      <div><button class="btn btn-block btn-primary" disabled={$submitting}>新規登録</button></div>
      <div>
        <span class="text-sm">すでにアカウントをお持ちの方は</span>
        <a class="text-sm text-blue-600 hover:underline hover:text-blue-400" href="/login"
          >ログイン</a
        >
      </div>
    </form>
  </div>
</div>

おわりに

型安全って最高ですよね。

Discussion

melodyclue_routermelodyclue_router

アカウントをリンクさせることって可能ですか?

Michito SugawaraMichito Sugawara

ありがとうございます!
Luciaの認証の仕様についてでしょうか?
1アカウントに複数のログイン手段をリンクさせることは可能です!