# Enable authentication (remote functions)
Enables authentication using SvelteKit remote functions for login, signup, reset, and settings forms.
Tags: sveltekit, authentication, auth, velastack, oauth, pocketbase, remote-functions

$ vela enable auth --remote

## Tutorials

- [How to add login with SvelteKit remote functions](https://velastack.dev/tutorials/sveltekit-login-remote-functions)

## src/lib/components/app-sidebar.svelte

```svelte
<script lang="ts">
  import favicon from "#lib/assets/favicon.svg";
  import { site } from "#lib/site.js";

  import type { ComponentProps } from "svelte";
  import HomeIcon from "@lucide/svelte/icons/home";
  import * as Sidebar from "#lib/components/ui/sidebar/index.js";
  import NavMain from "./nav-main.svelte";
  import NavUser from "./nav-user.svelte";

  let data = {
    navMain: [
      {
        title: "Dashboard",
        url: "/dashboard",
        icon: HomeIcon,
      },
    ],
  };

  let {
    user,
    ref = $bindable(null),
    ...restProps
  }: ComponentProps<typeof Sidebar.Root> & { user: any } = $props();
</script>

<Sidebar.Root bind:ref variant="inset" {...restProps}>
  <Sidebar.Header>
    <Sidebar.Menu>
      <Sidebar.MenuItem>
        <Sidebar.MenuButton size="lg">
          {#snippet child({ props })}
            <a href="/dashboard" {...props}>
              <div
                class="bg-sidebar-accent text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"
              >
                <img src={favicon} alt="logo" class="size-6" />
              </div>
              <div class="grid flex-1 text-left text-sm leading-tight">
                <span class="truncate font-medium">{site.name}</span>
              </div>
            </a>
          {/snippet}
        </Sidebar.MenuButton>
      </Sidebar.MenuItem>
    </Sidebar.Menu>
  </Sidebar.Header>
  <Sidebar.Content>
    <NavMain items={data.navMain} />
  </Sidebar.Content>
  <Sidebar.Footer>
    <NavUser {user} />
  </Sidebar.Footer>
</Sidebar.Root>
```

## src/lib/components/nav-main.svelte

```svelte
<script lang="ts">
  import type { Component } from "svelte";
  import * as Sidebar from "#lib/components/ui/sidebar/index.js";
  import { page } from "$app/state";

  const isActive = (url: string) => {
    return page.url.pathname.startsWith(url);
  };

  let {
    items,
  }: {
    items: {
      title: string;
      url: string;
      icon: Component;
      items?: {
        title: string;
        url: string;
      }[];
    }[];
  } = $props();
</script>

<Sidebar.Group>
  <Sidebar.Menu>
    {#each items as item (item.title)}
      <Sidebar.MenuItem>
        <Sidebar.MenuButton isActive={isActive(item.url)}>
          {#snippet child({ props })}
            <a href={item.url} {...props}>
              <item.icon />
              <span>{item.title}</span>
            </a>
          {/snippet}
        </Sidebar.MenuButton>
      </Sidebar.MenuItem>
    {/each}
  </Sidebar.Menu>
</Sidebar.Group>
```

## src/lib/components/nav-user.svelte

```svelte
<script lang="ts">
  import { toggleMode, mode } from "mode-watcher";
  import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
  import Settings2Icon from "@lucide/svelte/icons/settings-2";
  import LogOutIcon from "@lucide/svelte/icons/log-out";
  import MoonIcon from "@lucide/svelte/icons/moon";
  import SunIcon from "@lucide/svelte/icons/sun";
  import UserAvatar from "#lib/components/user-avatar.svelte";
  import { displayName, type UserIdentity } from "#lib/user.js";
  import * as DropdownMenu from "#lib/components/ui/dropdown-menu/index.js";
  import * as Sidebar from "#lib/components/ui/sidebar/index.js";

  let data = {
    navUser: [
      {
        title: "Settings",
        url: "/settings",
        icon: Settings2Icon,
      },
    ],
  };

  let {
    user,
  }: {
    user: UserIdentity & {
      id: string;
      avatar: string;
    };
  } = $props();

  const sidebar = Sidebar.useSidebar();
</script>

<Sidebar.Menu>
  <Sidebar.MenuItem>
    <DropdownMenu.Root>
      <DropdownMenu.Trigger>
        {#snippet child({ props })}
          <Sidebar.MenuButton
            size="lg"
            class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
            {...props}
          >
            <UserAvatar
              {user}
              class="size-8 rounded-lg"
              fallbackClass="rounded-lg"
            />
            <div class="grid flex-1 text-left text-sm leading-tight">
              <span class="truncate font-medium">{displayName(user)}</span>
              {#if user.name}
                <span class="truncate text-xs">{user.email || user.phone}</span>
              {/if}
            </div>
            <ChevronsUpDownIcon class="ml-auto size-4" />
          </Sidebar.MenuButton>
        {/snippet}
      </DropdownMenu.Trigger>
      <DropdownMenu.Content
        class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
        side={sidebar.isMobile ? "bottom" : "right"}
        align="end"
        sideOffset={4}
      >
        <DropdownMenu.Label class="p-0 font-normal">
          <div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
            <UserAvatar
              {user}
              class="size-8 rounded-lg"
              fallbackClass="rounded-lg"
            />
            <div class="grid flex-1 text-left text-sm leading-tight">
              <span class="truncate font-medium">{displayName(user)}</span>
              {#if user.name}
                <span class="truncate text-xs">{user.email || user.phone}</span>
              {/if}
            </div>
          </div>
        </DropdownMenu.Label>
        <DropdownMenu.Separator />
        <DropdownMenu.Group>
          <DropdownMenu.Item onclick={toggleMode}>
            {#if mode.current === "dark"}
              <SunIcon />
              Light mode
            {:else}
              <MoonIcon />
              Dark mode
            {/if}
          </DropdownMenu.Item>
          {#each data.navUser as item}
            <DropdownMenu.Item class="p-0">
              <a
                href={item.url}
                class="flex cursor-default items-center gap-2 px-2 py-1.5 w-full"
              >
                <item.icon />
                {item.title}
              </a>
            </DropdownMenu.Item>
          {/each}
        </DropdownMenu.Group>
        <DropdownMenu.Separator />
        <DropdownMenu.Item class="p-0">
          <form action="/logout" method="post" class="w-full">
            <button
              type="submit"
              class="flex cursor-default items-center gap-2 px-2 py-1.5 w-full"
            >
              <LogOutIcon />
              Log out
            </button>
          </form>
        </DropdownMenu.Item>
      </DropdownMenu.Content>
    </DropdownMenu.Root>
  </Sidebar.MenuItem>
</Sidebar.Menu>
```

## src/lib/components/user-avatar.svelte

```svelte
<script lang="ts">
  import UserRoundIcon from "@lucide/svelte/icons/user-round";
  import * as Avatar from "#lib/components/ui/avatar/index.js";
  import { displayName, userInitial, type UserIdentity } from "#lib/user.js";

  let {
    user,
    class: className,
    fallbackClass,
  }: {
    /** `avatar` is the file name on the record, when one was uploaded. */
    user: (UserIdentity & { id: string; avatar?: string }) | null | undefined;
    class?: string;
    fallbackClass?: string;
  } = $props();

  const initial = $derived(user ? userInitial(user) : null);
</script>

<Avatar.Root class={className}>
  {#if user?.avatar}
    <Avatar.Image
      src="/api/files/users/{user.id}/{user.avatar}"
      alt={displayName(user)}
    />
  {/if}
  <Avatar.Fallback class={fallbackClass}>
    {#if initial}
      {initial}
    {:else}
      <UserRoundIcon class="size-1/2" />
    {/if}
  </Avatar.Fallback>
</Avatar.Root>
```

## src/lib/schemas/changeEmail.ts

```ts
import { z } from "zod";

export const changeEmailSchema = z.object({
  email: z.email(),
});
```

## src/lib/schemas/changePassword.ts

```ts
import { z } from "zod";

export const changePasswordSchema = z
  .object({
    password: z.string(),
    passwordConfirm: z.string(),
  })
  .refine((data) => data.password === data.passwordConfirm, {
    message: "Passwords don't match",
    path: ["passwordConfirm"],
  });
```

## src/lib/schemas/confirmEmailChange.ts

```ts
import { z } from "zod";

export const confirmEmailChangeSchema = z.object({
  password: z.string(),
});
```

## src/lib/schemas/confirmReset.ts

```ts
import { z } from "zod";

export const confirmResetSchema = z.object({
  password: z.string(),
  passwordConfirm: z.string(),
});
```

## src/lib/schemas/login.ts

```ts
import { z } from "zod";

// A flat shape (rather than a discriminated union) keeps
// `loginForm.fields.password` addressable in the page regardless of mode.
export const loginSchema = z
  .object({
    type: z.enum(["password", "otp", "oauth2"]),
    email: z.email(),
    password: z.string().optional(),
  })
  .refine((data) => data.type !== "password" || !!data.password, {
    message: "Password is required",
    path: ["password"],
  });
```

## src/lib/schemas/otp.ts

```ts
import { z } from "zod";

export const otpSchema = z.object({
  otp: z.string(),
});
```

## src/lib/schemas/profile.ts

```ts
import { z } from "zod";

export const profileSchema = z.object({
  name: z.string().optional(),
  avatar: z.union([z.instanceof(File), z.string()]).optional(),
  emailVisibility: z.boolean().optional(),
});
```

## src/lib/schemas/reset.ts

```ts
import { z } from "zod";

export const resetSchema = z.object({
  email: z.email(),
});
```

## src/lib/schemas/signup.ts

```ts
import { z } from "zod";

// Flat, like the login schema, so every field stays addressable in the page.
export const signupSchema = z
  .object({
    type: z.enum(["password", "otp"]),
    email: z.email(),
    password: z.string().optional(),
    passwordConfirm: z.string().optional(),
  })
  .refine((data) => data.type !== "password" || !!data.password, {
    message: "Password is required",
    path: ["password"],
  });
```

## src/lib/schemas/welcome.ts

```ts
import { z } from "zod";

// Same as enable-auth's, where a `.trim()` would eat spaces as you type
// (superforms writes the parsed value back). The form handler trims on save.
export const welcomeSchema = z.object({
  name: z
    .string()
    .max(255)
    .refine((name) => name.trim() !== "", "Enter your name"),
});
```

## src/lib/user.ts

```ts
/**
 * The fields that say who a user is. Accounts made with a phone number may
 * have no email, and nobody has a name until they add one.
 */
export type UserIdentity = {
  name?: string;
  email?: string;
  phone?: string;
};

/** The name, else the email, else the phone number. */
export function displayName(user: UserIdentity): string | undefined {
  return user.name?.trim() || user.email || user.phone || undefined;
}

/**
 * The avatar letter, from the name, else the email. A phone number has no
 * useful one ("+"), so it gets `null` and the avatar shows an icon instead.
 */
export function userInitial(user: UserIdentity): string | null {
  const source = user.name?.trim() || user.email;
  return source ? Array.from(source)[0].toUpperCase() : null;
}
```

## src/routes/(app)/+layout.server.ts

```ts
export const load = ({ locals }) => {
  const user = locals.pb.authStore.record!;
  const breadcrumbs = [{ title: "Home", url: "/dashboard" }];

  return { user, breadcrumbs };
};
```

## src/routes/(app)/+layout.svelte

```svelte
<script lang="ts">
  import * as Sidebar from "#lib/components/ui/sidebar/index.js";
  import { Separator } from "#lib/components/ui/separator/index.js";
  import AppSidebar from "#lib/components/app-sidebar.svelte";
  import * as Breadcrumb from "#lib/components/ui/breadcrumb/index.js";
  import { page } from "$app/state";

  let breadcrumbs = $derived(page.data.breadcrumbs || []) as {
    title: string;
    url: string;
  }[];
  let { data, children } = $props();
</script>

<div class="min-h-dvh dashboard">
  <Sidebar.Provider>
    <AppSidebar user={data.user} />
    <Sidebar.Inset>
      <header class="flex h-16 shrink-0 items-center gap-2">
        <div class="flex items-center gap-2 px-4">
          <Sidebar.Trigger class="-ml-1" />
          <Separator
            orientation="vertical"
            class="mr-2 data-[orientation=vertical]:h-4"
          />
          {#if breadcrumbs.length > 0}
            <Breadcrumb.Root>
              <Breadcrumb.List>
                {#if breadcrumbs.length > 1}
                  {#each breadcrumbs.slice(0, -1) as breadcrumb}
                    <Breadcrumb.Item>
                      <Breadcrumb.Link href={breadcrumb.url}
                        >{breadcrumb.title}</Breadcrumb.Link
                      >
                    </Breadcrumb.Item>
                    <Breadcrumb.Separator />
                  {/each}
                {/if}
                <Breadcrumb.Page>
                  {breadcrumbs[breadcrumbs.length - 1].title}
                </Breadcrumb.Page>
              </Breadcrumb.List>
            </Breadcrumb.Root>
          {/if}
        </div>
      </header>
      <div class="flex flex-1 flex-col p-4 pt-0">
        {@render children?.()}
      </div>
    </Sidebar.Inset>
  </Sidebar.Provider>
</div>

<style>
  :global(body:has(div.dashboard)) {
    background-color: var(--color-sidebar);
  }
</style>
```

## src/routes/(app)/dashboard/+page.svelte

```svelte
```

## src/routes/(app)/dashboard/+page.ts

```ts
import { definePageMetaTags } from "svelte-meta-tags";

export const load = async ({ parent }) => {
  await parent();

  const { pageMetaTags } = definePageMetaTags({
    title: "Dashboard",
  });

  return { pageMetaTags };
};
```

## src/routes/(app)/dashboard/server.test.ts

```ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";

describe("dashboard", () => {
  it("should redirect to the login page", async (context) => {
    const response = await context.request.get(
      "/dashboard" satisfies Match<RouteId>,
    );
    expect(response.status).toBe(302);
    expect(response.headers.location).toBe("/login?redirect=%2Fdashboard");
  });

  it("authenticated user should be able to access the dashboard", async (context) => {
    await context.agent.authenticateUser();
    const response = await context.agent.get(
      "/dashboard" satisfies Match<RouteId>,
    );
    expect(response.status).toBe(200);
  });
});
```

## src/routes/(app)/settings/+layout.server.ts

```ts
export const load = async ({ parent }) => {
  const { breadcrumbs } = await parent();

  return {
    breadcrumbs: [...breadcrumbs, { title: "Settings", url: "/settings" }],
  };
};
```

## src/routes/(app)/settings/+page.server.ts

```ts
export const load = async ({ parent }) => {
  const { user } = await parent();

  return { user };
};
```

## src/routes/(app)/settings/+page.svelte

```svelte
<script lang="ts">
  import { Button, buttonVariants } from "#lib/components/ui/button/index.js";
  import * as Card from "#lib/components/ui/card/index.js";
  import { Input } from "#lib/components/ui/input/index.js";
  import { Badge } from "#lib/components/ui/badge/index.js";
  import * as Dialog from "#lib/components/ui/dialog/index.js";
  import { Checkbox } from "#lib/components/ui/checkbox/index.js";
  import UserAvatar from "#lib/components/user-avatar.svelte";
  import {
    updateProfileForm,
    changeEmailForm,
    changePasswordForm,
    resendVerificationForm,
  } from "./form.remote";

  let { data } = $props();

  // bits-ui's Checkbox renders a <button>, so drop the native type="checkbox"
  // from the field props before spreading them.
  const emailVisibilityProps = $derived.by(() => {
    const { type, ...props } =
      updateProfileForm.fields.emailVisibility.as("checkbox");
    return props;
  });
</script>

<div class="divide-y divide-gray-900/10 dark:divide-white/10">
  <div class="grid grid-cols-1 gap-x-8 gap-y-8 py-10 md:grid-cols-3">
    <div class="px-4 sm:px-0">
      <h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
        Profile
      </h2>
      <p class="mt-1 text-sm/6 text-gray-600 dark:text-gray-400">
        Your profile information.
      </p>
    </div>

    <form
      {...updateProfileForm}
      enctype="multipart/form-data"
      class="md:col-span-2"
    >
      <Card.Root>
        <Card.Content class="flex flex-col gap-6">
          <div class="flex items-center gap-3 justify-between">
            <div class="flex items-center gap-3">
              <UserAvatar user={data.user} class="size-16" />
              <div>
                <span class="text-sm font-medium leading-none">Avatar</span>
                <p class="text-muted-foreground text-sm">
                  Upload a new avatar for your profile.
                </p>
              </div>
            </div>
            <label
              class="{buttonVariants({
                variant: 'outline',
              })} has-focus-visible:border-ring has-focus-visible:ring-3 has-focus-visible:ring-ring/50"
            >
              Upload new picture
              <input
                {...updateProfileForm.fields.avatar.as("file")}
                type="file"
                class="sr-only"
              />
            </label>
          </div>
          {#each updateProfileForm.fields.avatar.issues() as issue}
            <p class="text-destructive text-sm">{issue.message}</p>
          {/each}

          <div class="grid gap-6">
            <div class="grid gap-3">
              <label for="name" class="text-sm font-medium">Name</label>
              <Input
                id="name"
                {...updateProfileForm.fields.name.as("text")}
                type="text"
                autocomplete="name"
              />
              {#each updateProfileForm.fields.name.issues() as issue}
                <p class="text-destructive text-sm">{issue.message}</p>
              {/each}
            </div>
          </div>
          {#if data.user.email}
            <div class="grid gap-6">
              <div class="grid gap-3">
                <div class="flex items-start gap-2">
                  <Checkbox id="emailVisibility" {...emailVisibilityProps} />
                  <div class="grid gap-2">
                    <label
                      for="emailVisibility"
                      class="text-sm font-medium leading-none"
                      >Display email publicly</label
                    >
                    <p class="text-muted-foreground text-sm">
                      By clicking this checkbox, your email will be displayed
                      publicly.
                    </p>
                  </div>
                </div>
                {#each updateProfileForm.fields.emailVisibility.issues() as issue}
                  <p class="text-destructive text-sm">{issue.message}</p>
                {/each}
              </div>
            </div>
          {/if}
        </Card.Content>
        <Card.Footer class="border-t justify-end">
          <Button type="submit" class="w-fit">Save changes</Button>
        </Card.Footer>
      </Card.Root>
    </form>
  </div>

  <!-- Accounts without an email (made with a phone number) have no
         email to verify or change, and no password to log in with. -->
  {#if data.user.email}
    <div class="grid grid-cols-1 gap-x-8 gap-y-8 py-10 md:grid-cols-3">
      <div class="px-4 sm:px-0">
        <h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
          Email
        </h2>
        <p class="mt-1 text-sm/6 text-gray-600 dark:text-gray-400">
          The email address associated with your account.
        </p>
      </div>

      <Card.Root class="md:col-span-2">
        <Card.Content class="flex flex-col gap-6">
          <div class="flex flex-wrap justify-between items-center gap-3">
            <span class="text-sm flex items-center gap-2">
              {data.user.email}
              <Badge variant="secondary"
                >{data.user.verified ? "Verified" : "Unverified"}</Badge
              >
            </span>
            <div class="flex items-center gap-3">
              {#if !data.user.verified}
                <form {...resendVerificationForm}>
                  <Button type="submit" variant="outline" class="w-fit"
                    >Resend verification email</Button
                  >
                </form>
              {/if}

              <Dialog.Root>
                <Dialog.Trigger class={buttonVariants({ variant: "outline" })}>
                  Change email
                </Dialog.Trigger>
                <Dialog.Content>
                  <Dialog.Header>
                    <Dialog.Title>Change email</Dialog.Title>
                    <Dialog.Description>
                      Enter your new email address. You will receive a
                      verification email.
                    </Dialog.Description>
                  </Dialog.Header>
                  <form {...changeEmailForm} class="grid gap-4">
                    <div class="grid gap-6">
                      <div class="grid gap-3">
                        <label for="email" class="text-sm font-medium"
                          >Email</label
                        >
                        <Input
                          id="email"
                          {...changeEmailForm.fields.email.as("text")}
                          type="email"
                          required
                          autocomplete="email"
                        />
                        {#each changeEmailForm.fields.email.issues() as issue}
                          <p class="text-destructive text-sm">
                            {issue.message}
                          </p>
                        {/each}
                      </div>
                    </div>
                    <Dialog.Footer>
                      <Button type="submit" class="w-fit">Change email</Button>
                    </Dialog.Footer>
                  </form>
                </Dialog.Content>
              </Dialog.Root>
            </div>
          </div>
        </Card.Content>
      </Card.Root>
    </div>

    <div class="grid grid-cols-1 gap-x-8 gap-y-8 py-10 md:grid-cols-3">
      <div class="px-4 sm:px-0">
        <h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
          Password
        </h2>
        <p class="mt-1 text-sm/6 text-gray-600 dark:text-gray-400">
          Your password is used to log in to your account.
        </p>
      </div>

      <form {...changePasswordForm} class="md:col-span-2">
        <Card.Root>
          <Card.Content class="flex flex-col gap-6">
            <input
              type="email"
              value={data.user.email}
              autocomplete="username"
              readonly
              tabindex="-1"
              aria-hidden="true"
              class="sr-only"
            />
            <div class="grid gap-6">
              <div class="grid gap-3">
                <label for="password" class="text-sm font-medium"
                  >New password</label
                >
                <Input
                  id="password"
                  {...changePasswordForm.fields.password.as("text")}
                  type="password"
                  autocomplete="new-password"
                  required
                  minlength={8}
                />
                {#each changePasswordForm.fields.password.issues() as issue}
                  <p class="text-destructive text-sm">{issue.message}</p>
                {/each}
              </div>
              <div class="grid gap-3">
                <label for="passwordConfirm" class="text-sm font-medium"
                  >Confirm new password</label
                >
                <Input
                  id="passwordConfirm"
                  {...changePasswordForm.fields.passwordConfirm.as("text")}
                  type="password"
                  autocomplete="new-password"
                  required
                  minlength={8}
                />
                {#each changePasswordForm.fields.passwordConfirm.issues() as issue}
                  <p class="text-destructive text-sm">{issue.message}</p>
                {/each}
              </div>
            </div>
          </Card.Content>
          <Card.Footer class="border-t justify-end">
            <Button type="submit" class="w-fit">Update password</Button>
          </Card.Footer>
        </Card.Root>
      </form>
    </div>
  {/if}
</div>
```

## src/routes/(app)/settings/+page.ts

```ts
import { definePageMetaTags } from "svelte-meta-tags";

export const load = async ({ parent, data }) => {
  await parent();

  const { pageMetaTags } = definePageMetaTags({
    title: "Settings",
  });

  return { ...data, pageMetaTags };
};
```

## src/routes/(app)/settings/form.remote.ts

```ts
import { form, getRequestEvent } from "$app/server";
import { error } from "@sveltejs/kit";
import { setFlash } from "sveltekit-flash-message/server";
import { profileSchema } from "#lib/schemas/profile.js";
import { changeEmailSchema } from "#lib/schemas/changeEmail.js";
import { changePasswordSchema } from "#lib/schemas/changePassword.js";

export const updateProfileForm = form(profileSchema, async (data) => {
  const { locals, cookies } = getRequestEvent();

  await locals.pb.collection("users").update(locals.pb.authStore.record!.id, {
    name: data.name,
    avatar: data.avatar,
    emailVisibility: data.emailVisibility,
  });

  setFlash(
    { type: "toast", message: "Profile updated successfully." },
    cookies,
  );
  return { success: true };
});

export const changeEmailForm = form(changeEmailSchema, async (data) => {
  const { locals, cookies } = getRequestEvent();

  await locals.pb.collection("users").requestEmailChange(data.email);
  setFlash(
    {
      type: "toast",
      message: "We sent a confirmation link to your new email.",
    },
    cookies,
  );
  return { success: true };
});

export const changePasswordForm = form(changePasswordSchema, async (data) => {
  const { locals, cookies } = getRequestEvent();

  await locals.admin
    .collection("users")
    .update(locals.pb.authStore.record!.id, {
      password: data.password,
      passwordConfirm: data.passwordConfirm,
    });

  setFlash(
    { type: "toast", message: "Password updated successfully." },
    cookies,
  );
  return { success: true };
});

export const resendVerificationForm = form(async () => {
  const { locals, cookies } = getRequestEvent();
  // Accounts made with a phone number may have no email to verify.
  const email = locals.pb.authStore.record?.email;
  if (!email) {
    error(400, "Your account has no email.");
  }

  await locals.pb.collection("users").requestVerification(email);
  setFlash(
    { type: "toast", message: "We sent a verification email to your email." },
    cookies,
  );
  return { success: true };
});
```

## src/routes/(public)/(auth)/confirm-email-change/[token]/+page.server.ts

```ts
export const load = async () => {
  return {};
};
```

## src/routes/(public)/(auth)/confirm-email-change/[token]/+page.svelte

```svelte
<script lang="ts">
  import favicon from "#lib/assets/favicon.svg";
  import { site } from "#lib/site.js";

  import { Button } from "#lib/components/ui/button/index.js";
  import * as Card from "#lib/components/ui/card/index.js";
  import { Input } from "#lib/components/ui/input/index.js";
  import { confirmEmailChangeForm } from "./form.remote";
</script>

<div class="h-full flex flex-col items-center justify-center gap-6 p-6 md:p-10">
  <div class="flex w-full max-w-sm flex-col gap-6">
    <a href="/" class="flex items-center gap-2 self-center font-medium">
      <div
        class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"
      >
        <img src={favicon} alt="logo" class="size-4" />
      </div>
      {site.name}
    </a>

    <div class="flex flex-col gap-6">
      <Card.Root>
        <Card.Header class="text-center">
          <Card.Title class="text-xl">Confirm your email change</Card.Title>
          <Card.Description
            >Enter your password to confirm your email change</Card.Description
          >
        </Card.Header>
        <Card.Content>
          <form {...confirmEmailChangeForm}>
            <div class="grid gap-6">
              <div class="grid gap-2">
                <div class="space-y-2 col-span-1">
                  <label for="password" class="text-sm font-medium"
                    >Password</label
                  >
                  <Input
                    id="password"
                    {...confirmEmailChangeForm.fields.password.as("text")}
                    type="password"
                    required
                    autocomplete="current-password"
                    autofocus
                  />
                  {#each confirmEmailChangeForm.fields.password.issues() as issue}
                    <p class="text-destructive text-sm">{issue.message}</p>
                  {/each}
                </div>

                <Button type="submit" class="w-full"
                  >Confirm email change</Button
                >
              </div>
            </div>
          </form>
        </Card.Content>
      </Card.Root>
    </div>
  </div>
</div>
```

## src/routes/(public)/(auth)/confirm-email-change/[token]/+page.ts

```ts
import { definePageMetaTags } from "svelte-meta-tags";

export const load = async ({ parent, data }) => {
  await parent();

  const { pageMetaTags } = definePageMetaTags({
    title: "Confirm email change",
  });

  return { ...data, pageMetaTags };
};
```

## src/routes/(public)/(auth)/confirm-email-change/[token]/form.remote.ts

```ts
import { form, getRequestEvent } from "$app/server";
import { redirect } from "@sveltejs/kit";
import { setFlash } from "sveltekit-flash-message/server";
import { confirmEmailChangeSchema } from "#lib/schemas/confirmEmailChange.js";

export const confirmEmailChangeForm = form(
  confirmEmailChangeSchema,
  async (data) => {
    const { locals, cookies, params } = getRequestEvent();

    await locals.admin
      .collection("users")
      .confirmEmailChange(params.token as string, data.password);

    locals.pb.authStore.clear();
    locals.pb.authStore.clearCookie(cookies);

    setFlash(
      { type: "toast", message: "Email changed successfully." },
      cookies,
    );
    redirect(303, "/login");
  },
);
```

## src/routes/(public)/(auth)/confirm-email-change/[token]/server.test.ts

```ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";

describe("GET /confirm-email-change/[token]", () => {
  it("should render the confirm-email-change page", async (context) => {
    const response = await context.request.get(
      "/confirm-email-change/123456" satisfies Match<RouteId>,
    );
    expect(response.status).toBe(200);
  });
});
```

## src/routes/(public)/(auth)/confirm-reset/[token]/+page.server.ts

```ts
import { getTokenPayload } from "pocketbase-sveltekit";

export const load = async ({ params }) => {
  // The reset token is a JWT carrying the account email. Expose it so the
  // page can render a hidden username field for password managers.
  const { email } = getTokenPayload(params.token);

  return { email: typeof email === "string" ? email : null };
};
```

## src/routes/(public)/(auth)/confirm-reset/[token]/+page.svelte

```svelte
<script lang="ts">
  import favicon from "#lib/assets/favicon.svg";
  import { site } from "#lib/site.js";

  import { Button } from "#lib/components/ui/button/index.js";
  import * as Card from "#lib/components/ui/card/index.js";
  import { Input } from "#lib/components/ui/input/index.js";
  import { confirmResetForm } from "./form.remote";

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

<div class="h-full flex flex-col items-center justify-center gap-6 p-6 md:p-10">
  <div class="flex w-full max-w-sm flex-col gap-6">
    <a href="/" class="flex items-center gap-2 self-center font-medium">
      <div
        class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"
      >
        <img src={favicon} alt="logo" class="size-4" />
      </div>
      {site.name}
    </a>

    <div class="flex flex-col gap-6">
      <Card.Root>
        <Card.Header class="text-center">
          <Card.Title class="text-xl">Reset your password</Card.Title>
          <Card.Description>Enter your new password</Card.Description>
        </Card.Header>
        <Card.Content>
          <form {...confirmResetForm}>
            <div class="grid gap-6">
              <div class="grid gap-2">
                {#if data.email}
                  <input
                    type="email"
                    value={data.email}
                    autocomplete="username"
                    readonly
                    tabindex="-1"
                    aria-hidden="true"
                    class="sr-only"
                  />
                {/if}

                <div class="space-y-2 col-span-1">
                  <label for="password" class="text-sm font-medium"
                    >Password</label
                  >
                  <Input
                    id="password"
                    {...confirmResetForm.fields.password.as("text")}
                    type="password"
                    required
                    autocomplete="new-password"
                    minlength={8}
                    autofocus
                  />
                  {#each confirmResetForm.fields.password.issues() as issue}
                    <p class="text-destructive text-sm">{issue.message}</p>
                  {/each}
                </div>

                <div class="space-y-2 col-span-1">
                  <label for="passwordConfirm" class="text-sm font-medium"
                    >Confirm Password</label
                  >
                  <Input
                    id="passwordConfirm"
                    {...confirmResetForm.fields.passwordConfirm.as("text")}
                    type="password"
                    required
                    autocomplete="new-password"
                    minlength={8}
                  />
                  {#each confirmResetForm.fields.passwordConfirm.issues() as issue}
                    <p class="text-destructive text-sm">{issue.message}</p>
                  {/each}
                </div>

                <Button type="submit" class="w-full">Reset password</Button>
              </div>
            </div>
          </form>
        </Card.Content>
      </Card.Root>
    </div>
  </div>
</div>
```

## src/routes/(public)/(auth)/confirm-reset/[token]/+page.ts

```ts
import { definePageMetaTags } from "svelte-meta-tags";

export const load = async ({ parent, data }) => {
  await parent();

  const { pageMetaTags } = definePageMetaTags({
    title: "Confirm password reset",
  });

  return { ...data, pageMetaTags };
};
```

## src/routes/(public)/(auth)/confirm-reset/[token]/form.remote.ts

```ts
import { form, getRequestEvent } from "$app/server";
import { redirect } from "@sveltejs/kit";
import { setFlash } from "sveltekit-flash-message/server";
import { confirmResetSchema } from "#lib/schemas/confirmReset.js";

export const confirmResetForm = form(confirmResetSchema, async (data) => {
  const { locals, cookies, params } = getRequestEvent();

  await locals.admin
    .collection("users")
    .confirmPasswordReset(
      params.token as string,
      data.password,
      data.passwordConfirm,
    );

  locals.pb.authStore.clear();
  locals.pb.authStore.clearCookie(cookies);

  setFlash({ type: "toast", message: "Password reset successfully." }, cookies);
  redirect(303, "/login");
});
```

## src/routes/(public)/(auth)/confirm-reset/[token]/server.test.ts

```ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";

describe("GET /confirm-reset/[token]", () => {
  it("should render the confirm-reset page", async (context) => {
    const response = await context.request.get(
      "/confirm-reset/123456" satisfies Match<RouteId>,
    );
    expect(response.status).toBe(200);
  });
});
```

## src/routes/(public)/(auth)/confirm-verification/[token]/+server.ts

```ts
import { redirect } from "@sveltejs/kit";
import { setFlash } from "sveltekit-flash-message/server";

export const GET = async ({ params, locals, cookies }) => {
  let message = "";
  let redirectPath = "";

  try {
    await locals.admin.collection("users").confirmVerification(params.token);
    message = "Email verified successfully.";
    redirectPath = "/dashboard";
  } catch (error: any) {
    message =
      "Invalid or expired verification token. Please request a new verification email.";
    redirectPath = "/login";
  }

  setFlash({ type: "toast", message }, cookies);
  redirect(303, redirectPath);
};
```

## src/routes/(public)/(auth)/confirm-verification/[token]/server.test.ts

```ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";

describe("GET /confirm-verification/[token]", () => {
  it("should redirect if the token is invalid", async (context) => {
    const response = await context.request.get(
      "/confirm-verification/123456" satisfies Match<RouteId>,
    );
    expect(response.status).toBe(303);
    expect(response.headers["location"]).toBe("/login");
  });
});
```

## src/routes/(public)/(auth)/login/+page.server.ts

```ts
import { redirect } from "@sveltejs/kit";

export const load = async ({ locals }) => {
  if (locals.pb.authStore.isValid) {
    redirect(303, "/dashboard");
  }

  const authMethods = await locals.admin.collection("users").listAuthMethods();

  return { authMethods };
};
```

## src/routes/(public)/(auth)/login/+page.svelte

```svelte
<script lang="ts">
  import favicon from "#lib/assets/favicon.svg";
  import { site } from "#lib/site.js";

  import { Button } from "#lib/components/ui/button/index.js";
  import * as Card from "#lib/components/ui/card/index.js";
  import { Input } from "#lib/components/ui/input/index.js";
  import PocketBase from "pocketbase-sveltekit";
  import { goto } from "$app/navigation";
  import { page } from "$app/state";
  import { loginForm } from "./form.remote";

  let { data } = $props();
  let authMethods = $derived(data.authMethods);

  const redirect = page.url.searchParams.get("redirect");
  const hasOAuth2 = $derived(
    authMethods.oauth2.enabled && authMethods.oauth2.providers.length > 0,
  );
  const hasAuthMethods = $derived(
    authMethods.password.enabled || authMethods.otp.enabled || hasOAuth2,
  );

  const initialType: "password" | "otp" | "oauth2" = $derived(
    authMethods.otp.enabled
      ? "otp"
      : authMethods.password.enabled
        ? "password"
        : "oauth2",
  );
  let mode = $state<"password" | "otp" | "oauth2">("password");
  $effect(() => {
    mode = initialType;
  });

  const handleOAuth2 = (provider: string) => {
    const pb = new PocketBase("/");

    pb.collection("users")
      .authWithOAuth2({ provider, createData: {} })
      .then(() => goto("/dashboard"));
  };
</script>

<div class="h-full flex flex-col items-center justify-center gap-6 p-6 md:p-10">
  <div class="flex w-full max-w-sm flex-col gap-6">
    <a href="/" class="flex items-center gap-2 self-center font-medium">
      <div
        class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"
      >
        <img src={favicon} alt="logo" class="size-4" />
      </div>
      {site.name}
    </a>

    <div class="flex flex-col gap-6">
      <Card.Root>
        {#if hasAuthMethods}
          <Card.Header class="text-center">
            <Card.Title class="text-xl">Welcome back</Card.Title>
            {#if hasOAuth2}
              <Card.Description>Choose a login method</Card.Description>
            {:else}
              <Card.Description>Use your email to login</Card.Description>
            {/if}
          </Card.Header>
        {:else}
          <Card.Header class="text-center">
            <Card.Title class="text-xl">Login is disabled</Card.Title>
            <Card.Description
              >Check back later for login options</Card.Description
            >
          </Card.Header>
        {/if}
        <Card.Content>
          <form {...loginForm}>
            <div class="grid gap-6">
              {#if hasOAuth2}
                <div class="flex flex-col gap-4">
                  {#each authMethods.oauth2.providers as provider}
                    <Button
                      variant="outline"
                      class="w-full"
                      onclick={() => handleOAuth2(provider.name)}
                    >
                      <img
                        src="/admin/_/images/oauth2/{provider.name}.svg"
                        class="size-5 bg-white p-0.5 rounded-sm"
                        alt=""
                      />
                      Login with {provider.displayName}
                    </Button>
                  {/each}
                </div>
              {/if}

              {#if authMethods.password.enabled || authMethods.otp.enabled}
                {#if hasOAuth2}
                  <div
                    class="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t"
                  >
                    <span
                      class="bg-card text-muted-foreground relative z-10 px-2"
                    >
                      Or continue with
                    </span>
                  </div>
                {/if}

                <div class="grid gap-2">
                  <div class="space-y-2 col-span-1">
                    <label for="email" class="text-sm font-medium">Email</label>
                    <Input
                      id="email"
                      {...loginForm.fields.email.as("text")}
                      type="email"
                      required
                      autocomplete="username"
                      autofocus
                    />
                    {#each loginForm.fields.email.issues() as issue}
                      <p class="text-destructive text-sm">{issue.message}</p>
                    {/each}
                  </div>

                  {#if mode === "password" && authMethods.password.enabled}
                    <div class="space-y-2 col-span-1">
                      <div class="grid grid-cols-[1fr_auto] gap-2">
                        <label for="password" class="text-sm font-medium"
                          >Password</label
                        >
                        <Input
                          id="password"
                          {...loginForm.fields.password.as("text")}
                          type="password"
                          required
                          autocomplete="current-password"
                          class="col-span-2"
                        />
                        <a
                          href="/reset"
                          class="col-start-2 row-start-1 text-sm underline-offset-4 hover:underline"
                          >Forgot your password?</a
                        >
                      </div>
                      {#each loginForm.fields.password.issues() as issue}
                        <p class="text-destructive text-sm">{issue.message}</p>
                      {/each}
                    </div>

                    {#if loginForm.result?.message}
                      <div class="text-destructive text-sm font-medium -mt-2">
                        {loginForm.result.message}
                      </div>
                    {/if}

                    <Button type="submit" class="w-full">Login</Button>
                    {#if authMethods.otp.enabled}
                      <Button
                        variant="outline"
                        class="w-full"
                        onclick={() => (mode = "otp")}
                        type="button">Use one-time code instead</Button
                      >
                    {/if}
                  {:else if mode === "otp" && authMethods.otp.enabled}
                    <Button type="submit" class="w-full"
                      >Send one-time code</Button
                    >
                    {#if authMethods.password.enabled}
                      <Button
                        variant="outline"
                        class="w-full"
                        onclick={() => (mode = "password")}
                        type="button">Continue with password</Button
                      >
                    {/if}
                  {/if}
                </div>

                <input {...loginForm.fields.type.as("hidden", mode)} />
              {/if}

              <div class="text-center text-sm">
                Don&apos;t have an account?
                <a
                  href="/signup{redirect
                    ? `?redirect=${encodeURIComponent(redirect)}`
                    : ''}"
                  class="underline underline-offset-4">Sign up</a
                >
              </div>
            </div>
          </form>
        </Card.Content>
      </Card.Root>
      <div
        class="text-muted-foreground *:[a]:hover:text-primary *:[a]:underline *:[a]:underline-offset-4 text-balance text-center text-xs"
      >
        By clicking continue, you agree to our <a href="/terms"
          >Terms of Service</a
        >
        and <a href="/privacy">Privacy Policy</a>.
      </div>
    </div>
  </div>
</div>
```

## src/routes/(public)/(auth)/login/+page.ts

```ts
import { definePageMetaTags } from "svelte-meta-tags";

export const load = async ({ parent, data }) => {
  await parent();

  const { pageMetaTags } = definePageMetaTags({
    title: "Log in",
  });

  return { ...data, pageMetaTags };
};
```

## src/routes/(public)/(auth)/login/form.remote.ts

```ts
import { form, getRequestEvent } from "$app/server";
import { redirect } from "@sveltejs/kit";
import { dev } from "$app/env";
import { loginSchema } from "#lib/schemas/login.js";

export const loginForm = form(loginSchema, async (data) => {
  const { locals, cookies, url } = getRequestEvent();

  // Follow only a same-site path (`/x`, not `//host` or `/\host`):
  // `redirect()` throws on an external URL.
  const next = url.searchParams.get("redirect");
  const redirectParam = next && /^\/(?![/\\])/.test(next) ? next : null;
  if (data.type === "otp") {
    const req = await locals.pb.collection("users").requestOTP(data.email);
    redirect(
      303,
      `/otp/${req.otpId}${redirectParam ? `?redirect=${encodeURIComponent(redirectParam)}` : ""}`,
    );
  } else if (data.type === "password") {
    try {
      await locals.pb
        .collection("users")
        .authWithPassword(data.email, data.password ?? "");
    } catch (err: any) {
      return {
        message: (err.response?.message as string) ?? "Failed to authenticate.",
      };
    }
  }

  const redirectUrl = redirectParam ?? "/dashboard";
  const cookie = locals.pb.authStore.getCookie();
  cookies.set("pb_auth", cookie, {
    path: "/",
    httpOnly: true,
    sameSite: "lax",
    secure: !dev,
    maxAge: 60 * 60 * 24 * 30,
  });

  redirect(303, redirectUrl);
});
```

## src/routes/(public)/(auth)/login/server.test.ts

```ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";

describe("GET /login", () => {
  it("should render the login page", async (context) => {
    const response = await context.agent.get("/login" satisfies Match<RouteId>);
    expect(response.status).toBe(200);
  });

  it("should redirect to the dashboard if the user is already authenticated", async (context) => {
    await context.agent.authenticateUser();
    const response = await context.agent.get("/login" satisfies Match<RouteId>);
    expect(response.status).toBe(303);
    expect(response.headers.location).toBe("/dashboard");
  });
});
```

## src/routes/(public)/(auth)/logout/+server.ts

```ts
import { redirect } from "@sveltejs/kit";

export const POST = ({ cookies, locals }) => {
  locals.pb.authStore.clear();
  locals.pb.authStore.clearCookie(cookies);
  redirect(303, "/");
};
```

## src/routes/(public)/(auth)/logout/server.test.ts

```ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";

describe("POST /logout", () => {
  it("should logout the user", async (context) => {
    await context.agent.authenticateUser();
    const response = await context.agent.post(
      "/logout" satisfies Match<RouteId>,
    );
    expect(response.status).toBe(303);
    expect(response.headers.location).toBe("/");
  });
});
```

## src/routes/(public)/(auth)/otp/[token]/+page.server.ts

```ts
import { redirect } from "@sveltejs/kit";

export const load = async ({ locals }) => {
  if (locals.pb.authStore.isValid) {
    redirect(303, "/dashboard");
  }

  return {};
};
```

## src/routes/(public)/(auth)/otp/[token]/+page.svelte

```svelte
<script lang="ts">
  import favicon from "#lib/assets/favicon.svg";
  import { site } from "#lib/site.js";

  import { Button } from "#lib/components/ui/button/index.js";
  import * as Card from "#lib/components/ui/card/index.js";
  import { Input } from "#lib/components/ui/input/index.js";
  import { otpForm } from "./form.remote";
</script>

<div class="h-full flex flex-col items-center justify-center gap-6 p-6 md:p-10">
  <div class="flex w-full max-w-sm flex-col gap-6">
    <a href="/" class="flex items-center gap-2 self-center font-medium">
      <div
        class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"
      >
        <img src={favicon} alt="logo" class="size-4" />
      </div>
      {site.name}
    </a>

    <div class="flex flex-col gap-6">
      <Card.Root>
        <Card.Header class="text-center">
          <Card.Title class="text-xl">Enter your OTP code</Card.Title>
          <Card.Description>Enter the code sent to your email</Card.Description>
        </Card.Header>
        <Card.Content>
          <form {...otpForm}>
            <div class="grid gap-6">
              <div class="grid gap-2">
                <div class="space-y-2 col-span-1">
                  <label for="otp" class="text-sm font-medium">OTP code</label>
                  <Input
                    id="otp"
                    {...otpForm.fields.otp.as("text")}
                    type="text"
                    inputmode="numeric"
                    autocomplete="one-time-code"
                    required
                    autofocus
                  />
                  {#each otpForm.fields.otp.issues() as issue}
                    <p class="text-destructive text-sm">{issue.message}</p>
                  {/each}
                </div>

                {#if otpForm.result?.message}
                  <div class="text-destructive text-sm font-medium -mt-2">
                    {otpForm.result.message}
                  </div>
                {/if}

                <Button type="submit" class="w-full">Verify code</Button>
              </div>
            </div>
          </form>
        </Card.Content>
      </Card.Root>
    </div>
  </div>
</div>
```

## src/routes/(public)/(auth)/otp/[token]/+page.ts

```ts
import { definePageMetaTags } from "svelte-meta-tags";

export const load = async ({ parent, data }) => {
  await parent();

  const { pageMetaTags } = definePageMetaTags({
    title: "Verify code",
  });

  return { ...data, pageMetaTags };
};
```

## src/routes/(public)/(auth)/otp/[token]/form.remote.ts

```ts
import { form, getRequestEvent } from "$app/server";
import { redirect } from "@sveltejs/kit";
import { dev } from "$app/env";
import { otpSchema } from "#lib/schemas/otp.js";

export const otpForm = form(otpSchema, async (data) => {
  const { locals, cookies, params, url } = getRequestEvent();

  try {
    await locals.pb
      .collection("users")
      .authWithOTP(params.token as string, data.otp);
  } catch (err: any) {
    return {
      message: (err.response?.message as string) ?? "Failed to verify code.",
    };
  }

  // Follow only a same-site path (`/x`, not `//host` or `/\host`):
  // `redirect()` throws on an external URL.
  const next = url.searchParams.get("redirect");
  const redirectUrl = next && /^\/(?![/\\])/.test(next) ? next : "/dashboard";
  const cookie = locals.pb.authStore.getCookie();
  cookies.set("pb_auth", cookie, {
    path: "/",
    httpOnly: true,
    sameSite: "lax",
    secure: !dev,
    maxAge: 60 * 60 * 24 * 30,
  });

  redirect(303, redirectUrl);
});
```

## src/routes/(public)/(auth)/otp/[token]/server.test.ts

```ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";

describe("GET /otp/[token]", () => {
  it("should render the otp page", async (context) => {
    const response = await context.request.get(
      "/otp/123456" satisfies Match<RouteId>,
    );
    expect(response.status).toBe(200);
  });
});
```

## src/routes/(public)/(auth)/reset/+page.server.ts

```ts
import { redirect } from "@sveltejs/kit";

export const load = async ({ locals }) => {
  if (locals.pb.authStore.isValid) {
    redirect(303, "/dashboard");
  }

  return {};
};
```

## src/routes/(public)/(auth)/reset/+page.svelte

```svelte
<script lang="ts">
  import favicon from "#lib/assets/favicon.svg";
  import { site } from "#lib/site.js";

  import { Button } from "#lib/components/ui/button/index.js";
  import * as Card from "#lib/components/ui/card/index.js";
  import { Input } from "#lib/components/ui/input/index.js";
  import { resetForm } from "./form.remote";
</script>

<div class="h-full flex flex-col items-center justify-center gap-6 p-6 md:p-10">
  <div class="flex w-full max-w-sm flex-col gap-6">
    <a href="/" class="flex items-center gap-2 self-center font-medium">
      <div
        class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"
      >
        <img src={favicon} alt="logo" class="size-4" />
      </div>
      {site.name}
    </a>

    <div class="flex flex-col gap-6">
      <Card.Root>
        <Card.Header class="text-center">
          <Card.Title class="text-xl">Reset your password</Card.Title>
          <Card.Description
            >Enter your email to reset your password</Card.Description
          >
        </Card.Header>
        <Card.Content>
          <form {...resetForm}>
            <div class="grid gap-6">
              <div class="grid gap-2">
                <div class="space-y-2 col-span-1">
                  <label for="email" class="text-sm font-medium">Email</label>
                  <Input
                    id="email"
                    {...resetForm.fields.email.as("text")}
                    type="email"
                    required
                    autocomplete="username"
                    autofocus
                  />
                  {#each resetForm.fields.email.issues() as issue}
                    <p class="text-destructive text-sm">{issue.message}</p>
                  {/each}
                </div>

                <Button type="submit" class="w-full">Send reset link</Button>
              </div>

              <div class="text-center text-sm">
                Remembered your password?
                <a href="/login" class="underline underline-offset-4">Log in</a>
              </div>
            </div>
          </form>
        </Card.Content>
      </Card.Root>
    </div>
  </div>
</div>
```

## src/routes/(public)/(auth)/reset/+page.ts

```ts
import { definePageMetaTags } from "svelte-meta-tags";

export const load = async ({ parent, data }) => {
  await parent();

  const { pageMetaTags } = definePageMetaTags({
    title: "Reset password",
  });

  return { ...data, pageMetaTags };
};
```

## src/routes/(public)/(auth)/reset/form.remote.ts

```ts
import { form, getRequestEvent } from "$app/server";
import { setFlash } from "sveltekit-flash-message/server";
import { resetSchema } from "#lib/schemas/reset.js";

export const resetForm = form(resetSchema, async (data) => {
  const { locals, cookies } = getRequestEvent();

  await locals.pb.collection("users").requestPasswordReset(data.email);
  setFlash(
    {
      type: "toast",
      message: "We sent a password reset link to your email.",
    },
    cookies,
  );

  return { success: true };
});
```

## src/routes/(public)/(auth)/reset/server.test.ts

```ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";

describe("GET /reset", () => {
  it("should render the reset page", async (context) => {
    const response = await context.request.get(
      "/reset" satisfies Match<RouteId>,
    );
    expect(response.status).toBe(200);
  });
});
```

## src/routes/(public)/(auth)/signup/+page.server.ts

```ts
import { redirect } from "@sveltejs/kit";

export const load = async ({ locals }) => {
  const authMethods = await locals.admin.collection("users").listAuthMethods();

  if (locals.pb.authStore.isValid) {
    redirect(303, "/dashboard");
  }

  return { authMethods };
};
```

## src/routes/(public)/(auth)/signup/+page.svelte

```svelte
<script lang="ts">
  import favicon from "#lib/assets/favicon.svg";
  import { site } from "#lib/site.js";

  import { Button } from "#lib/components/ui/button/index.js";
  import * as Card from "#lib/components/ui/card/index.js";
  import { Input } from "#lib/components/ui/input/index.js";
  import PocketBase from "pocketbase-sveltekit";
  import { goto } from "$app/navigation";
  import { page } from "$app/state";
  import { signupForm } from "./form.remote";

  let { data } = $props();
  let authMethods = $derived(data.authMethods);

  const redirect = page.url.searchParams.get("redirect");
  const hasOAuth2 = $derived(
    authMethods.oauth2.enabled && authMethods.oauth2.providers.length > 0,
  );
  const hasEmail = $derived(
    authMethods.password.enabled || authMethods.otp.enabled,
  );
  const hasAuthMethods = $derived(hasEmail || hasOAuth2);

  type Mode = "password" | "otp";
  const initialType: Mode = $derived(
    !authMethods.password.enabled && authMethods.otp.enabled
      ? "otp"
      : "password",
  );
  let mode = $state<Mode>("password");
  $effect(() => {
    mode = initialType;
  });

  const handleOAuth2 = (provider: string) => {
    const pb = new PocketBase("/");

    pb.collection("users")
      .authWithOAuth2({ provider, createData: {} })
      .then(() => goto("/dashboard"));
  };
</script>

<div class="h-full flex flex-col items-center justify-center gap-6 p-6 md:p-10">
  <div class="flex w-full max-w-sm flex-col gap-6">
    <a href="/" class="flex items-center gap-2 self-center font-medium">
      <div
        class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"
      >
        <img src={favicon} alt="logo" class="size-4" />
      </div>
      {site.name}
    </a>

    <div class="flex flex-col gap-6">
      <Card.Root>
        {#if hasAuthMethods}
          <Card.Header class="text-center">
            <Card.Title class="text-xl">Create an account</Card.Title>
            {#if hasOAuth2}
              <Card.Description>Choose a sign up method</Card.Description>
            {:else}
              <Card.Description>Use your email to sign up</Card.Description>
            {/if}
          </Card.Header>
        {:else}
          <Card.Header class="text-center">
            <Card.Title class="text-xl">Signups are disabled</Card.Title>
            <Card.Description
              >Check back later for signup options</Card.Description
            >
          </Card.Header>
        {/if}
        <Card.Content>
          <form {...signupForm}>
            <div class="grid gap-6">
              {#if hasOAuth2}
                <div class="flex flex-col gap-4">
                  {#each authMethods.oauth2.providers as provider}
                    <Button
                      variant="outline"
                      class="w-full"
                      onclick={() => handleOAuth2(provider.name)}
                    >
                      <img
                        src="/admin/_/images/oauth2/{provider.name}.svg"
                        class="size-5 bg-white p-0.5 rounded-sm"
                        alt=""
                      />
                      Sign up with {provider.displayName}
                    </Button>
                  {/each}
                </div>
              {/if}

              {#if hasEmail}
                {#if hasOAuth2}
                  <div
                    class="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t"
                  >
                    <span
                      class="bg-card text-muted-foreground relative z-10 px-2"
                    >
                      Or continue with
                    </span>
                  </div>
                {/if}

                <div class="grid gap-2">
                  <div class="space-y-2 col-span-1">
                    <label for="email" class="text-sm font-medium">Email</label>
                    <Input
                      id="email"
                      {...signupForm.fields.email.as("text")}
                      type="email"
                      required
                      autocomplete="username"
                      autofocus
                    />
                    {#each signupForm.fields.email.issues() as issue}
                      <p class="text-destructive text-sm">{issue.message}</p>
                    {/each}
                  </div>

                  {#if mode === "password" && authMethods.password.enabled}
                    <div class="space-y-2 col-span-1">
                      <label for="password" class="text-sm font-medium"
                        >Password</label
                      >
                      <Input
                        id="password"
                        {...signupForm.fields.password.as("text")}
                        type="password"
                        required
                        autocomplete="new-password"
                        minlength={8}
                      />
                      {#each signupForm.fields.password.issues() as issue}
                        <p class="text-destructive text-sm">
                          {issue.message}
                        </p>
                      {/each}
                    </div>

                    <div class="space-y-2 col-span-1">
                      <label for="passwordConfirm" class="text-sm font-medium"
                        >Confirm Password</label
                      >
                      <Input
                        id="passwordConfirm"
                        {...signupForm.fields.passwordConfirm.as("text")}
                        type="password"
                        required
                        autocomplete="new-password"
                        minlength={8}
                      />
                      {#each signupForm.fields.passwordConfirm.issues() as issue}
                        <p class="text-destructive text-sm">
                          {issue.message}
                        </p>
                      {/each}
                    </div>

                    {#if signupForm.result?.message}
                      <div class="text-destructive text-sm font-medium -mt-2">
                        {signupForm.result.message}
                      </div>
                    {/if}

                    <Button type="submit" class="w-full">Create account</Button>
                    {#if authMethods.otp.enabled}
                      <Button
                        variant="outline"
                        class="w-full"
                        onclick={() => (mode = "otp")}
                        type="button">Use one-time code instead</Button
                      >
                    {/if}
                  {:else if mode === "otp" && authMethods.otp.enabled}
                    {#if signupForm.result?.message}
                      <div class="text-destructive text-sm font-medium -mt-2">
                        {signupForm.result.message}
                      </div>
                    {/if}

                    <Button type="submit" class="w-full"
                      >Send one-time code</Button
                    >
                    {#if authMethods.password.enabled}
                      <Button
                        variant="outline"
                        class="w-full"
                        onclick={() => (mode = "password")}
                        type="button">Continue with password</Button
                      >
                    {/if}
                  {/if}
                </div>

                <input {...signupForm.fields.type.as("hidden", mode)} />
              {/if}

              <div class="text-center text-sm">
                Already have an account?
                <a
                  href="/login{redirect
                    ? `?redirect=${encodeURIComponent(redirect)}`
                    : ''}"
                  class="underline underline-offset-4"
                >
                  Log in
                </a>
              </div>
            </div>
          </form>
        </Card.Content>
      </Card.Root>
      <div
        class="text-muted-foreground *:[a]:hover:text-primary *:[a]:underline *:[a]:underline-offset-4 text-balance text-center text-xs"
      >
        By clicking continue, you agree to our <a href="/terms"
          >Terms of Service</a
        >
        and <a href="/privacy">Privacy Policy</a>.
      </div>
    </div>
  </div>
</div>
```

## src/routes/(public)/(auth)/signup/+page.ts

```ts
import { definePageMetaTags } from "svelte-meta-tags";

export const load = async ({ parent, data }) => {
  await parent();

  const { pageMetaTags } = definePageMetaTags({
    title: "Sign up",
  });

  return { ...data, pageMetaTags };
};
```

## src/routes/(public)/(auth)/signup/form.remote.ts

```ts
import { form, getRequestEvent } from "$app/server";
import { redirect } from "@sveltejs/kit";
import { setFlash } from "sveltekit-flash-message/server";
import { dev } from "$app/env";
import { signupSchema } from "#lib/schemas/signup.js";

/** The first field error, or the response message, of a PocketBase error. */
function errorMessage(err: any, fallback: string): string {
  const response = err?.response ?? {};
  const fieldError = response?.data
    ? Object.values(response.data)[0]
    : undefined;
  return (fieldError as any)?.message ?? response?.message ?? fallback;
}

export const signupForm = form(signupSchema, async (data) => {
  const { locals, cookies, url } = getRequestEvent();

  // Follow only a same-site path (`/x`, not `//host` or `/\host`):
  // `redirect()` throws on an external URL.
  const next = url.searchParams.get("redirect");
  const redirectParam = next && /^\/(?![/\\])/.test(next) ? next : null;
  const redirectQuery = redirectParam
    ? `?redirect=${encodeURIComponent(redirectParam)}`
    : "";

  const { email } = data;

  if (data.type === "otp") {
    // PocketBase only sends codes to existing accounts, so create it first,
    // with a password nobody knows (a reset sets a real one). Checking the
    // code marks the email verified.
    const password = crypto.randomUUID();
    try {
      await locals.admin.collection("users").create({
        email,
        password,
        passwordConfirm: password,
      });
    } catch (err: any) {
      // An existing account gets a code too, the same as logging in with one.
      if (err?.response?.data?.email?.code !== "validation_not_unique") {
        return { message: errorMessage(err, "Failed to create account.") };
      }
    }

    let otpId: string;
    try {
      ({ otpId } = await locals.pb.collection("users").requestOTP(email));
    } catch (err: any) {
      return { message: errorMessage(err, "Failed to send the code.") };
    }
    // A new account has no name yet: ask for one once the code checks out.
    const welcome = `/welcome${redirectQuery}`;
    redirect(303, `/otp/${otpId}?redirect=${encodeURIComponent(welcome)}`);
  }

  try {
    await locals.admin.collection("users").create({
      email,
      password: data.password,
      passwordConfirm: data.passwordConfirm,
    });
  } catch (err: any) {
    return { message: errorMessage(err, "Failed to create account.") };
  }

  await locals.pb.collection("users").requestVerification(email);
  await locals.pb
    .collection("users")
    .authWithPassword(email, data.password ?? "");

  const redirectUrl = redirectParam ?? "/dashboard";
  const cookie = locals.pb.authStore.getCookie();
  cookies.set("pb_auth", cookie, {
    path: "/",
    httpOnly: true,
    sameSite: "lax",
    secure: !dev,
    maxAge: 60 * 60 * 24 * 30,
  });

  setFlash(
    { type: "toast", message: "We sent a confirmation link to your email." },
    cookies,
  );
  redirect(303, redirectUrl);
});
```

## src/routes/(public)/(auth)/signup/server.test.ts

```ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";

describe("GET /signup", () => {
  it("should render the signup page", async (context) => {
    const response = await context.request.get(
      "/signup" satisfies Match<RouteId>,
    );
    expect(response.status).toBe(200);
  });
});
```

## src/routes/(public)/(auth)/welcome/+page.server.ts

```ts
import { redirect } from "@sveltejs/kit";
import { nextUrl } from "./next-url";

// Accounts made with a one-time code start without a name, so signup sends
// them here once the code checks out, to pick the name the app shows.
export const load = async ({ locals, url }) => {
  if (!locals.pb.authStore.isValid) {
    redirect(303, "/login");
  }
  if (locals.pb.authStore.record?.name) {
    redirect(303, nextUrl(url));
  }

  return { next: nextUrl(url) };
};
```

## src/routes/(public)/(auth)/welcome/+page.svelte

```svelte
<script lang="ts">
  import favicon from "#lib/assets/favicon.svg";
  import { site } from "#lib/site.js";

  import { Button } from "#lib/components/ui/button/index.js";
  import * as Card from "#lib/components/ui/card/index.js";
  import { Input } from "#lib/components/ui/input/index.js";
  import { welcomeForm } from "./form.remote";

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

<div class="h-full flex flex-col items-center justify-center gap-6 p-6 md:p-10">
  <div class="flex w-full max-w-sm flex-col gap-6">
    <a href="/" class="flex items-center gap-2 self-center font-medium">
      <div
        class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"
      >
        <img src={favicon} alt="logo" class="size-4" />
      </div>
      {site.name}
    </a>

    <div class="flex flex-col gap-6">
      <Card.Root>
        <Card.Header class="text-center">
          <Card.Title class="text-xl">Welcome!</Card.Title>
          <Card.Description>What should we call you?</Card.Description>
        </Card.Header>
        <Card.Content>
          <form {...welcomeForm}>
            <div class="grid gap-6">
              <div class="grid gap-2">
                <div class="space-y-2 col-span-1">
                  <label for="name" class="text-sm font-medium">Name</label>
                  <Input
                    id="name"
                    {...welcomeForm.fields.name.as("text")}
                    autocomplete="name"
                    required
                    autofocus
                  />
                  {#each welcomeForm.fields.name.issues() as issue}
                    <p class="text-destructive text-sm">{issue.message}</p>
                  {/each}
                </div>

                <Button type="submit" class="w-full">Continue</Button>
                <Button href={data.next} variant="ghost" class="w-full"
                  >Skip for now</Button
                >
              </div>
            </div>
          </form>
        </Card.Content>
      </Card.Root>
    </div>
  </div>
</div>
```

## src/routes/(public)/(auth)/welcome/+page.ts

```ts
import { definePageMetaTags } from "svelte-meta-tags";

export const load = async ({ parent, data }) => {
  await parent();

  const { pageMetaTags } = definePageMetaTags({
    title: "Welcome",
  });

  return { ...data, pageMetaTags };
};
```

## src/routes/(public)/(auth)/welcome/form.remote.ts

```ts
import { form, getRequestEvent } from "$app/server";
import { redirect } from "@sveltejs/kit";
import { welcomeSchema } from "#lib/schemas/welcome.js";
import { nextUrl } from "./next-url";

export const welcomeForm = form(welcomeSchema, async (data) => {
  const { locals, url } = getRequestEvent();

  if (!locals.pb.authStore.isValid) {
    redirect(303, "/login");
  }

  await locals.pb
    .collection("users")
    .update(locals.pb.authStore.record!.id, { name: data.name.trim() });

  redirect(303, nextUrl(url));
});
```

## src/routes/(public)/(auth)/welcome/next-url.ts

```ts
/**
 * Where to go after the name step. Follows only a same-site path (`/x`, not
 * `//host` or `/\host`): `redirect()` throws on an external URL.
 */
export function nextUrl(url: URL) {
  const next = url.searchParams.get("redirect");
  return next && /^\/(?![/\\])/.test(next) ? next : "/dashboard";
}
```

## src/routes/(public)/(auth)/welcome/server.test.ts

```ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";

describe("GET /welcome", () => {
  it("should redirect a signed out visitor to login", async (context) => {
    const response = await context.request.get(
      "/welcome" satisfies Match<RouteId>,
    );
    expect(response.status).toBe(303);
    expect(response.headers.location).toBe("/login");
  });

  it("should ask a signed in user for their name", async (context) => {
    await context.agent.authenticateUser();
    const response = await context.agent.get(
      "/welcome" satisfies Match<RouteId>,
    );
    expect(response.status).toBe(200);
  });
});
```
