# 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";

  import type { ComponentProps } from "svelte";
  import HomeIcon from "@lucide/svelte/icons/home";
  import * as Sidebar from "$lib/components/ui/sidebar";
  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";
  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 * as Avatar from "$lib/components/ui/avatar";
  import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
  import * as Sidebar from "$lib/components/ui/sidebar";

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

  let {
    user,
  }: {
    user: {
      id: string;
      name: string;
      email: 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}
          >
            <Avatar.Root class="size-8 rounded-lg">
              {#if user.avatar}
                <Avatar.Image
                  src="/api/files/users/{user.id}/{user.avatar}"
                  alt={user.name}
                />
              {/if}
              <Avatar.Fallback class="rounded-lg">
                {user.email?.charAt(0).toUpperCase()}
              </Avatar.Fallback>
            </Avatar.Root>
            <div class="grid flex-1 text-left text-sm leading-tight">
              <span class="truncate font-medium">{user.name}</span>
              <span class="truncate text-xs">{user.email}</span>
            </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">
            <Avatar.Root class="size-8 rounded-lg">
              {#if user.avatar}
                <Avatar.Image
                  src="/api/files/users/{user.id}/{user.avatar}"
                  alt={user.name}
                />
              {/if}
              <Avatar.Fallback class="rounded-lg">
                {user.email?.charAt(0).toUpperCase()}
              </Avatar.Fallback>
            </Avatar.Root>
            <div class="grid flex-1 text-left text-sm leading-tight">
              <span class="truncate font-medium">{user.name}</span>
              <span class="truncate text-xs">{user.email}</span>
            </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/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";

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

## 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";
  import { Separator } from "$lib/components/ui/separator";
  import AppSidebar from "$lib/components/app-sidebar.svelte";
  import * as Breadcrumb from "$lib/components/ui/breadcrumb";
  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";
  import * as Card from "$lib/components/ui/card";
  import { Input } from "$lib/components/ui/input";
  import { Badge } from "$lib/components/ui/badge";
  import * as Dialog from "$lib/components/ui/dialog";
  import { Checkbox } from "$lib/components/ui/checkbox";
  import * as Avatar from "$lib/components/ui/avatar";
  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">
              <Avatar.Root class="size-16">
                <Avatar.Fallback>
                  {data.user.email.charAt(0).toUpperCase()}
                </Avatar.Fallback>
              </Avatar.Root>
              <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>
          <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>
        </Card.Content>
        <Card.Footer class="border-t justify-end">
          <Button type="submit" class="w-fit">Save changes</Button>
        </Card.Footer>
      </Card.Root>
    </form>
  </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">
        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 justify-between items-center">
          <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>
</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 { setFlash } from "sveltekit-flash-message/server";
import { profileSchema } from "$lib/schemas/profile";
import { changeEmailSchema } from "$lib/schemas/changeEmail";
import { changePasswordSchema } from "$lib/schemas/changePassword";

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();
  await locals.pb
    .collection("users")
    .requestVerification(locals.pb.authStore.record!.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";

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

<div class="flex flex-col flex-1 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";

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";

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

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

<div class="flex flex-col flex-1 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";

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";

  import { Button } from "$lib/components/ui/button";
  import * as Card from "$lib/components/ui/card";
  import { Input } from "$lib/components/ui/input";
  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
                  type="hidden"
                  {...loginForm.fields.type.as("text")}
                  value={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/environment";
import { loginSchema } from "$lib/schemas/login";

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

  const redirectParam = url.searchParams.get("redirect");
  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);
  throw 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";

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

<div class="flex flex-col flex-1 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/environment";
import { otpSchema } from "$lib/schemas/otp";

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.",
    };
  }

  const redirectUrl = url.searchParams.get("redirect") ?? "/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";

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

<div class="flex flex-col flex-1 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";

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";

  import { Button } from "$lib/components/ui/button";
  import * as Card from "$lib/components/ui/card";
  import { Input } from "$lib/components/ui/input";
  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 hasAuthMethods = $derived(
    authMethods.password.enabled || authMethods.otp.enabled || hasOAuth2,
  );

  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 authMethods.password.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"
                      {...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>

                  <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>
                </div>
              {/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/environment";
import { signupSchema } from "$lib/schemas/signup";

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

  let user: { email: string };

  try {
    user = await locals.admin.collection("users").create({
      email: data.email,
      password: data.password,
      passwordConfirm: data.passwordConfirm,
    });
  } catch (err: any) {
    const response = err?.response ?? {};
    const fieldError = response?.data
      ? Object.values(response.data)[0]
      : undefined;
    const message: string =
      (fieldError as any)?.message ??
      response?.message ??
      "Failed to create account.";
    return { message };
  }

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

  const redirectUrl = url.searchParams.get("redirect") ?? "/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);
  });
});
```
