Open7

SvelteKitでログイン機能を作成する

ryo13chanryo13chan

最終的なディクレクトリ構成

https://tree.nathanfriend.io/

src/
├── lib/
│   ├── components/
│   │   └── ui/
│   │       ├── button/
│   │       ├── card/
│   │       ├── form/
│   │       ├── input/
│   │       ├── label/
│   │       └── sonner/
│   ├── schemas/
│   │   ├── ja-zod.ts
│   │   └── login.ts
│   └── types/
│       └── user.ts
├── routes/
│   ├── (loggedIn)/
│   │   ├── logout/
│   │   │   └── +page.server.ts
│   │   ├── +layout.server.ts
│   │   ├── +layout.svelte
│   │   ├── +page.server.ts
│   │   └── +page.svelte
│   ├── (notLogged)/
│   │   └── login/
│   │       ├── +layout.svelte
│   │       ├── +page.server.ts
│   │       ├── +page.svelte
│   │       └── LoginForm.svelte
│   ├── +layout.server.ts
│   └── +layout.svelte
├── app.d.ts
└── hooks.server.ts
ryo13chanryo13chan

ログインフォームの作成

エラーメッセージの日本語化

# インストール
$ bun add i18next zod-i18n-map

zodを日本語化してexport
https://qiita.com/fsd-tetsu/items/5ee9a56c742ca2106025

schemas/ja-zod.ts
import i18next from 'i18next'
import { z } from 'zod'
import { makeZodI18nMap } from 'zod-i18n-map'
import translation from 'zod-i18n-map/locales/ja/zod.json' assert { type: 'json' }

i18next.init({
  lng: 'ja',
  resources: {
  ja: { translation }
  }
})

z.setErrorMap(makeZodI18nMap({ t: i18next.t, ns: 'translation' }))

export { z }

ログインフォームのスキーマを作成

schemas/login.ts
import { z } from './ja-zod'

export const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
})

export type LoginSchema = typeof loginSchema

サーバーサイドでフォームを定義

routes/(notLogged)/login/+page.server.ts
import { loginSchema } from '$lib/schemas/login'
import { superValidate } from 'sveltekit-superforms'
import { zod } from 'sveltekit-superforms/adapters'
import type { PageServerLoad } from './$types'

export const load: PageServerLoad = async () => {
  const form = await superValidate(zod(loginSchema))

  return { form }
}

フォームで使用するコンポーネントを追加しておく

$ bunx shadcn-svelte@latest add button card form input sonner

ログインフォームのコンポーネントを作成

コード全文
routes/(notLogged)/login/LoginForm.svelte
<script lang="ts">
import * as Card from '$lib/components/ui/card'
import * as Form from '$lib/components/ui/form'
import { Input } from '$lib/components/ui/input'
import type { LoginSchema } from '$lib/schemas/login'
import type { Infer, SuperValidated } from 'sveltekit-superforms'
import { superForm } from 'sveltekit-superforms'

let {
  data,
}: {
  data: SuperValidated<Infer<LoginSchema>>
} = $props()

const form = superForm(data)
const { form: formData, enhance } = form
</script>

<form method="POST" use:enhance>
  <Card.Root class="min-w-96">
    <Card.Header>
      <Card.Title>ログイン</Card.Title>
      <Card.Description>ログイン機能のデモ用</Card.Description>
    </Card.Header>
    <Card.Content>
      <div class="grid items-center gap-4">
        <Form.Field {form} name="email">
          <Form.Control let:attrs>
            <Form.Label>メールアドレス</Form.Label>
            <Input {...attrs} bind:value={$formData.email} />
          </Form.Control>
          <Form.Description>メールアドレスの形式</Form.Description>
          <Form.FieldErrors />
        </Form.Field>
        <Form.Field {form} name="password">
          <Form.Control let:attrs>
            <Form.Label>パスワード</Form.Label>
            <Input {...attrs} bind:value={$formData.password} type="password" />
          </Form.Control>
          <Form.Description>8文字以上</Form.Description>
          <Form.FieldErrors />
        </Form.Field>
      </div>
    </Card.Content>
    <Card.Footer class="flex justify-end">
      <Form.Button>ログイン</Form.Button>
    </Card.Footer>
  </Card.Root>
</form>

ページコンポーネントでログインフォームを表示

routes/(notLogged)/login/+page.svelte
<script lang="ts">
  import LoginForm from './LoginForm.svelte'

  let { data } = $props()
</script>

<LoginForm data={data.form} />

レイアウトコンポーネントでレイアウトの調整

routes/(notLogged)/login/+layout.svelte
<script lang="ts">
let { children } = $props()
</script>

<div class="grid place-content-center h-[100vh]">
  {@render children()}
</div>

ログイン処理を仮実装

routes/(notLogged)/login/+page.server.ts
import { loginSchema } from '$lib/schemas/login'
import { fail } from '@sveltejs/kit'
import { redirect } from 'sveltekit-flash-message/server'
import { superValidate } from 'sveltekit-superforms'
import { zod } from 'sveltekit-superforms/adapters'
import type { Actions } from './$types'

export const actions: Actions = {
  default: async (event) => {
    const form = await superValidate(event, zod(loginSchema))
    if (!form.valid) {
      return fail(400, { form })
    }

    // ログイン処理を後で実装

    //  取り急ぎ常に成功させる
    return redirect(
      '/',
      { message: 'ログインしました', type: 'success' },
      event,
    )
  },
}

バリデーションエラーの表示

ryo13chanryo13chan

ログイン後のホーム画面の作成

ページコンポーネントを追加

routes/(loggedIn)/+page.svelte
<h1 class="text-3xl font-bold underline">
  Home
</h1>

レイアウトファイルで余白の調整

routes/(loggedIn)/+layout.svelte
<script lang="ts">
let { children } = $props()
</script>

<div class="p-4">
  {@render children()}
</div>

ryo13chanryo13chan

ログイン機能の作成

ログイン処理を追加

routes/(notLogged)/login/+page.server.ts
export const actions: Actions = {
  default: async (event) => {
    const form = await superValidate(event, zod(loginSchema))
    if (!form.valid) {
      return fail(400, { form })
    }

    // ログイン処理
    // 本来はAPIを実行してログインする

    // 特定の値のみ受け付けてエラーを再現する
    if (
      form.data.email !== 'test@test.com' ||
      form.data.password !== 'password'
    ) {
      setFlash({ message: 'ログインに失敗しました', type: 'error' }, event)
      return fail(400, { form })
    }

    // 今回はcookieにダミー値を直接セットする
    event.cookies.set('logged_in', 'true', {
      httpOnly: true,
      maxAge: 60 * 60 * 24 * 365,
      secure: true,
      path: '/',
    })

    return redirect(
      '/',
      { message: 'ログインしました', type: 'success' },
      event,
    )
  },
}

受け付ける値を表示しておく

routes/(notLogged)/login/LoginForm.svelte
<Form.Description>test@test.com と入力してください</Form.Description>
<Form.Description>password と入力してください</Form.Description>

エラーの検証

成功したらホームにリダイレクト

cookieに値がセットされる

ryo13chanryo13chan

ログアウト機能の作成

ホーム画面にログアウト用のリンクを追加

routes/(loggedIn)/+page.svelte
<!-- ホバーしたときにログアウトしないようにプリロードは切っておく -->
<a
  href='/logout'
  data-sveltekit-preload-data='off'
>
  ログアウト
</a>

ログアウト用のルートを追加

routes/(loggedIn)/login/+page.server.ts
import { redirect } from '@sveltejs/kit'
import type { PageServerLoad } from './$types'

export const load: PageServerLoad = async ({ cookies }) => {
  // 本来はAPIを実行してログアウトする
  // 今回はcookieの値を削除して、ログアウトとみなす
  cookies.delete('logged_in', { path: '/' })
  redirect(302, '/login')
}

ログアウトを実行

cookieの値が削除され、ログイン画面にリダイレクト

ryo13chanryo13chan

ログイン状態のチェック

- ログイン状態によってリダイレクト処理を実行したい
  - ログイン不要な画面
    - ログイン済みの場合はホームへリダイレクト
  - ログインが必要な画面
    - 未ログインの場合はログイン画面へリダイレクト
- ルートのグループ化を行い、グループによって各種チェックを実行する

ログイン画面などログインが不要な画面はroutes/(notLogged)配下にルートを配置する
ホーム画面などログインが必要な画面はroutes/(loggedin)配下にルートを配置する
hooks.server.tsで各リクエスト時にログイン状態のチェックを行う

src/
├── routes/
│   ├── (loggedIn)/
│   ├── (notLogged)/
└── hooks.server.ts
hooks.server.ts
import type { Handle } from '@sveltejs/kit'

export const handle: Handle = async ({ resolve, event }) => {
  const cookieLoggedIn = event.cookies.get('logged_in')
  const loggedIn = cookieLoggedIn === 'true'

  // ログインが必要な画面で未ログイン
  if (!loggedIn && event.route.id?.startsWith('/(loggedIn)')) {
    return Response.redirect(`${event.url.origin}/login`, 302)
  }
  // ログイン不要な画面でログイン済み
  if (loggedIn && event.route.id?.startsWith('/(notLogged)')) {
    return Response.redirect(`${event.url.origin}/`, 302)
  }

  // 実際は必要に応じてログインユーザー情報の取得やアクセストークンの検証を実施する

  return await resolve(event)
}

ホーム画面でcookieの値を削除してリロード

未ログインなのでログイン画面へリダイレクトされる

ログイン画面でcookieに値を直接セットしてリロード

ログイン済みなのでホーム画面へリダイレクトされる