Logo VelaStack

Enable authentication

Drop a complete authentication stack into your SvelteKit app. Scaffolds login, signup, password-reset, email-change, email-verification, and OTP flows backed by PocketBase, alongside a dashboard layout, a sidebar, and a settings page for profile, email, and password updates. Password, one-time code, and OAuth2 providers are all supported, with provider buttons rendered from the auth methods PocketBase reports at runtime. An oauth_accounts collection is provisioned for provider tokens, and every route ships with a server test.

$ vela enable auth
src/lib/components/app-sidebar.svelte
<script lang="ts">
  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,
    meta,
    ref = $bindable(null),
    ...restProps
  }: ComponentProps<typeof Sidebar.Root> & { user: any; meta: 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.svg" alt="logo" class="size-6" />
              </div>
              <div class="grid flex-1 text-left text-sm leading-tight">
                <span class="truncate font-medium">{meta.appName}</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
<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
<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.name?.charAt(0).toUpperCase() ||
                  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 || 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.name?.charAt(0).toUpperCase() ||
                  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 || 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
import { z } from "zod";

export const changeEmailSchema = z.object({
  email: z.email(),
});
src/lib/schemas/changePassword.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
import { z } from "zod";

export const confirmEmailChangeSchema = z.object({
  password: z.string(),
});
src/lib/schemas/confirmReset.ts
import { z } from "zod";

export const confirmResetSchema = z.object({
  password: z.string(),
  passwordConfirm: z.string(),
});
src/lib/schemas/login.ts
import { z } from "zod";

const otpSchema = z.object({
  type: z.literal("otp"),
  email: z.email(),
});

const passwordSchema = z.object({
  type: z.literal("password"),
  email: z.email(),
  password: z.string(),
});

const oAuth2Schema = z.object({
  type: z.literal("oauth2"),
  email: z.email(),
});

export const loginSchema = z.discriminatedUnion("type", [
  otpSchema,
  passwordSchema,
  oAuth2Schema,
]);
src/lib/schemas/otp.ts
import { z } from "zod";

export const otpSchema = z.object({
  otp: z.string(),
});
src/lib/schemas/profile.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
import { z } from "zod";

export const resetSchema = z.object({
  email: z.email(),
});
src/lib/schemas/signup.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
export const load = ({ locals }) => {
  const user = locals.pb.authStore.record!;
  const breadcrumbs = [{ title: "Home", url: "/dashboard" }];

  return { user, breadcrumbs };
};
src/routes/(app)/+layout.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} meta={data.meta} />
    <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
src/routes/(app)/dashboard/+page.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
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/pocketbase";
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
export const load = async ({ parent }) => {
  const { breadcrumbs } = await parent();

  return {
    breadcrumbs: [...breadcrumbs, { title: "Settings", url: "/settings" }],
  };
};
src/routes/(app)/settings/+page.server.ts
import { superValidate, fail, withFiles } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
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 load = async ({ parent }) => {
  const { user } = await parent();

  const profileForm = await superValidate(
    {
      avatar: user.avatar,
      name: user.name,
      emailVisibility: user.emailVisibility,
    },
    zod4(profileSchema),
  );
  const emailForm = await superValidate(zod4(changeEmailSchema));
  const passwordForm = await superValidate(zod4(changePasswordSchema));

  return { profileForm, emailForm, passwordForm, user };
};

export const actions = {
  updateProfile: async ({ locals, request, cookies }) => {
    const form = await superValidate(request, zod4(profileSchema));

    if (!form.valid) {
      return fail(400, { profileForm: form });
    }

    await locals.pb
      .collection("users")
      .update(locals.pb.authStore.record!.id, form.data);

    setFlash(
      { type: "toast", message: "Profile updated successfully." },
      cookies,
    );
    return withFiles({ profileForm: form });
  },
  changeEmail: async ({ locals, request, cookies }) => {
    const form = await superValidate(request, zod4(changeEmailSchema));

    if (!form.valid) {
      return fail(400, { emailForm: form });
    }

    await locals.pb.collection("users").requestEmailChange(form.data.email);
    setFlash(
      {
        type: "toast",
        message: "We sent a confirmation link to your new email.",
      },
      cookies,
    );
    return { emailForm: form };
  },
  changePassword: async ({ locals, request, cookies }) => {
    const form = await superValidate(request, zod4(changePasswordSchema));

    if (!form.valid) {
      return fail(400, { passwordForm: form });
    }

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

    setFlash(
      { type: "toast", message: "Password updated successfully." },
      cookies,
    );
    return { passwordForm: form };
  },
  resendVerificationEmail: async ({ locals, request, cookies }) => {
    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 {};
  },
};
src/routes/(app)/settings/+page.svelte
<script lang="ts">
  import { untrack } from "svelte";
  import { superForm } from "sveltekit-superforms";
  import { zod4Client } from "sveltekit-superforms/adapters";
  import { changeEmailSchema } from "$lib/schemas/changeEmail";
  import { changePasswordSchema } from "$lib/schemas/changePassword";
  import { profileSchema } from "$lib/schemas/profile";
  import { Button, buttonVariants } from "$lib/components/ui/button";
  import * as Form from "$lib/components/ui/form";
  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 * as FileForm from "$lib/components/ui/file-form";

  let { data } = $props();

  const profileForm = superForm(
    untrack(() => data.profileForm),
    {
      validators: zod4Client(profileSchema),
      id: "profileForm",
    },
  );

  const emailForm = superForm(
    untrack(() => data.emailForm),
    {
      validators: zod4Client(changeEmailSchema),
      id: "emailForm",
    },
  );

  const passwordForm = superForm(
    untrack(() => data.passwordForm),
    {
      validators: zod4Client(changePasswordSchema),
      id: "passwordForm",
    },
  );

  const { form: profileFormData } = profileForm;
  const { form: emailFormData } = emailForm;
  const { form: passwordFormData } = passwordForm;
</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
      method="POST"
      action="?/updateProfile"
      enctype="multipart/form-data"
      class="md:col-span-2"
    >
      <Card.Root>
        <Card.Content class="flex flex-col gap-6">
          <FileForm.Field form={profileForm} name="avatar" class="col-span-1">
            <Form.Control>
              {#snippet children({ props })}
                <div class="flex items-center gap-3 justify-between">
                  {#if !$profileFormData.avatar}
                    <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>
                  {/if}
                  <FileForm.Single>
                    {#snippet display({ file, filename, onremove })}
                      <div
                        class="flex-1 flex gap-2 justify-between items-center"
                      >
                        <div class="flex items-center gap-3">
                          <FileForm.Thumb
                            {file}
                            {filename}
                            collectionId="users"
                            id={data.user.id}
                            class="w-16 h-16 object-cover rounded-full"
                          />
                          <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>
                        <Button
                          variant="outline"
                          onclick={() => onremove(file)}
                        >
                          Remove avatar
                        </Button>
                      </div>
                    {/snippet}
                    {#snippet input()}
                      <Form.Label
                        class={buttonVariants({ variant: "outline" })}
                      >
                        Upload new picture
                      </Form.Label>
                      <FileForm.Input class="hidden" {...props} />
                    {/snippet}
                  </FileForm.Single>
                </div>
              {/snippet}
            </Form.Control>
            <Form.FieldErrors class="contents text-destructive" />
          </FileForm.Field>

          <div class="grid gap-6">
            <div class="grid gap-3">
              <Form.Field form={profileForm} name="name">
                <Form.Control>
                  {#snippet children({ props })}
                    <Form.Label>Name</Form.Label>
                    <Input
                      {...props}
                      type="text"
                      bind:value={$profileFormData.name}
                    />
                  {/snippet}
                </Form.Control>
                <Form.FieldErrors class="contents text-destructive" />
              </Form.Field>
            </div>
          </div>
          <div class="grid gap-6">
            <div class="grid gap-3">
              <Form.Field
                form={profileForm}
                name="emailVisibility"
                class="flex items-start gap-2"
              >
                <Form.Control>
                  {#snippet children({ props })}
                    <Checkbox
                      {...props}
                      bind:checked={$profileFormData.emailVisibility}
                    />
                    <div class="grid gap-2">
                      <Form.Label>Display email publicly</Form.Label>
                      <p class="text-muted-foreground text-sm">
                        By clicking this checkbox, your email will be displayed
                        publicly.
                      </p>
                    </div>
                  {/snippet}
                </Form.Control>
                <Form.FieldErrors class="contents text-destructive" />
              </Form.Field>
            </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 method="POST" action="?/resendVerificationEmail">
                <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 method="POST" action="?/changeEmail">
                  <div class="grid gap-6">
                    <div class="grid gap-3">
                      <Form.Field form={emailForm} name="email">
                        <Form.Control>
                          {#snippet children({ props })}
                            <Form.Label>Email</Form.Label>
                            <Input
                              {...props}
                              type="email"
                              bind:value={$emailFormData.email}
                              required
                            />
                          {/snippet}
                        </Form.Control>
                        <Form.FieldErrors class="contents text-destructive" />
                      </Form.Field>
                    </div>
                  </div>
                </form>
                <Dialog.Footer>
                  <Button type="submit" class="w-fit">Change email</Button>
                </Dialog.Footer>
              </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 method="POST" action="?/changePassword" class="md:col-span-2">
      <Card.Root>
        <Card.Content class="flex flex-col gap-6">
          <div class="grid gap-6">
            <div class="grid gap-3">
              <Form.Field form={passwordForm} name="password">
                <Form.Control>
                  {#snippet children({ props })}
                    <Form.Label>New password</Form.Label>
                    <Input
                      {...props}
                      type="password"
                      bind:value={$passwordFormData.password}
                      autocomplete="new-password"
                      required
                    />
                  {/snippet}
                </Form.Control>
                <Form.FieldErrors class="contents text-destructive" />
              </Form.Field>
            </div>
            <div class="grid gap-3">
              <Form.Field form={passwordForm} name="passwordConfirm">
                <Form.Control>
                  {#snippet children({ props })}
                    <Form.Label>Confirm new password</Form.Label>
                    <Input
                      {...props}
                      type="password"
                      bind:value={$passwordFormData.passwordConfirm}
                      autocomplete="new-password"
                      required
                    />
                  {/snippet}
                </Form.Control>
                <Form.FieldErrors class="contents text-destructive" />
              </Form.Field>
            </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
import { definePageMetaTags } from "svelte-meta-tags";

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

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

  return { ...data, pageMetaTags };
};
src/routes/(public)/confirm-email-change/[token]/+page.server.ts
import { fail, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { confirmEmailChangeSchema } from "$lib/schemas/confirmEmailChange";
import { redirect } from "@sveltejs/kit";
import { setFlash } from "sveltekit-flash-message/server";

export const load = async () => {
  return { form: await superValidate(zod4(confirmEmailChangeSchema)) };
};

export const actions = {
  default: async ({ locals, params, request, cookies }) => {
    const form = await superValidate(request, zod4(confirmEmailChangeSchema));

    if (!form.valid) {
      return fail(400, { form });
    }

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

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

    setFlash(
      { type: "toast", message: "Email changed successfully." },
      cookies,
    );
    return redirect(303, "/login");
  },
};
src/routes/(public)/confirm-email-change/[token]/+page.svelte
<script lang="ts">
  import { untrack } from "svelte";
  import { Button } from "$lib/components/ui/button";
  import * as Card from "$lib/components/ui/card";
  import { superForm } from "sveltekit-superforms";
  import { zod4Client } from "sveltekit-superforms/adapters";
  import { confirmEmailChangeSchema } from "$lib/schemas/confirmEmailChange";
  import * as Form from "$lib/components/ui/form";
  import { Input } from "$lib/components/ui/input";

  let { data } = $props();

  const form = superForm(
    untrack(() => data.form),
    {
      validators: zod4Client(confirmEmailChangeSchema),
    },
  );

  const { form: formData } = form;
</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.svg" alt="logo" class="size-4" />
      </div>
      {data.meta.appName}
    </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 method="POST">
            <div class="grid gap-6">
              <div class="grid gap-2">
                <Form.Field {form} name="password" class="col-span-1">
                  <Form.Control>
                    {#snippet children({ props })}
                      <Form.Label>Password</Form.Label>
                      <Input
                        {...props}
                        type="password"
                        bind:value={$formData.password}
                        required
                        autocomplete="current-password"
                      />
                    {/snippet}
                  </Form.Control>
                  <Form.FieldErrors class="contents text-destructive" />
                </Form.Field>

                <Button type="submit" class="w-full"
                  >Confirm email change</Button
                >
              </div>
            </div>
          </form>
        </Card.Content>
      </Card.Root>
    </div>
  </div>
</div>
src/routes/(public)/confirm-email-change/[token]/+page.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)/confirm-email-change/[token]/server.test.ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/pocketbase";
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)/confirm-reset/[token]/+page.server.ts
import { fail, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { confirmResetSchema } from "$lib/schemas/confirmReset";
import { redirect } from "@sveltejs/kit";
import { setFlash } from "sveltekit-flash-message/server";

export const load = async () => {
  return { form: await superValidate(zod4(confirmResetSchema)) };
};

export const actions = {
  default: async ({ locals, request, params, cookies }) => {
    const form = await superValidate(request, zod4(confirmResetSchema));

    if (!form.valid) {
      return fail(400, { form });
    }

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

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

    setFlash(
      { type: "toast", message: "Password reset successfully." },
      cookies,
    );
    return redirect(303, "/login");
  },
};
src/routes/(public)/confirm-reset/[token]/+page.svelte
<script lang="ts">
  import { untrack } from "svelte";
  import { Button } from "$lib/components/ui/button";
  import * as Card from "$lib/components/ui/card";
  import { superForm } from "sveltekit-superforms";
  import { zod4Client } from "sveltekit-superforms/adapters";
  import { confirmResetSchema } from "$lib/schemas/confirmReset";
  import * as Form from "$lib/components/ui/form";
  import { Input } from "$lib/components/ui/input";

  let { data } = $props();

  const form = superForm(
    untrack(() => data.form),
    {
      validators: zod4Client(confirmResetSchema),
    },
  );

  const { form: formData } = form;
</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.svg" alt="logo" class="size-4" />
      </div>
      {data.meta.appName}
    </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 method="POST">
            <div class="grid gap-6">
              <div class="grid gap-2">
                <Form.Field {form} name="password" class="col-span-1">
                  <Form.Control>
                    {#snippet children({ props })}
                      <Form.Label>Password</Form.Label>
                      <Input
                        {...props}
                        type="password"
                        bind:value={$formData.password}
                        required
                        autocomplete="new-password"
                      />
                    {/snippet}
                  </Form.Control>
                  <Form.FieldErrors class="contents text-destructive" />
                </Form.Field>

                <Form.Field {form} name="passwordConfirm" class="col-span-1">
                  <Form.Control>
                    {#snippet children({ props })}
                      <Form.Label>Confirm Password</Form.Label>
                      <Input
                        {...props}
                        type="password"
                        bind:value={$formData.passwordConfirm}
                        required
                        autocomplete="new-password"
                      />
                    {/snippet}
                  </Form.Control>
                  <Form.FieldErrors class="contents text-destructive" />
                </Form.Field>

                <Button type="submit" class="w-full">Reset password</Button>
              </div>
            </div>
          </form>
        </Card.Content>
      </Card.Root>
    </div>
  </div>
</div>
src/routes/(public)/confirm-reset/[token]/+page.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)/confirm-reset/[token]/server.test.ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/pocketbase";
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)/confirm-verification/[token]/+server.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)/confirm-verification/[token]/server.test.ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/pocketbase";
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)/login/+page.server.ts
import { fail, superValidate, message } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { loginSchema } from "$lib/schemas/login";
import { redirect } from "@sveltejs/kit";
import { dev } from "$app/environment";

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

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

  const type: "password" | "otp" | "oauth2" = authMethods.otp.enabled
    ? "otp"
    : authMethods.password.enabled
      ? "password"
      : "oauth2";

  return {
    form: await superValidate(
      zod4(loginSchema.default({ type, email: "", password: "" })),
    ),
    authMethods,
  };
};

export const actions = {
  default: async ({ locals, request, cookies, url }) => {
    const form = await superValidate(request, zod4(loginSchema));

    if (!form.valid) {
      return fail(400, { form });
    }

    const redirectParam = url.searchParams.get("redirect");
    if (form.data.type === "otp") {
      const req = await locals.pb
        .collection("users")
        .requestOTP(form.data.email);
      return redirect(
        303,
        `/otp/${req.otpId}${redirectParam ? `?redirect=${encodeURIComponent(redirectParam)}` : ""}`,
      );
    } else if (form.data.type === "password") {
      try {
        await locals.pb
          .collection("users")
          .authWithPassword(form.data.email, form.data.password);
      } catch (error: any) {
        return message(
          form,
          { type: "error", text: error.response.message },
          { status: 400 },
        );
      }
    }

    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)/login/+page.svelte
<script lang="ts">
  import { untrack } from "svelte";
  import { Button } from "$lib/components/ui/button";
  import * as Card from "$lib/components/ui/card";
  import { superForm } from "sveltekit-superforms";
  import { zod4Client } from "sveltekit-superforms/adapters";
  import { loginSchema } from "$lib/schemas/login";
  import * as Form from "$lib/components/ui/form";
  import { Input } from "$lib/components/ui/input";
  import PocketBase from "pocketbase-sveltekit";
  import { goto } from "$app/navigation";
  import { page } from "$app/state";

  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 form = superForm(
    untrack(() => data.form),
    {
      validators: zod4Client(loginSchema),
    },
  );

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

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

  const { form: formData, message } = form;
</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.svg" alt="logo" class="size-4" />
      </div>
      {data.meta.appName}
    </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 method="POST">
            <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">
                  <Form.Field {form} name="email" class="col-span-1">
                    <Form.Control>
                      {#snippet children({ props })}
                        <Form.Label>Email</Form.Label>
                        <Input
                          {...props}
                          type="email"
                          bind:value={$formData.email}
                          required
                        />
                      {/snippet}
                    </Form.Control>
                    <Form.FieldErrors class="contents text-destructive" />
                  </Form.Field>

                  {#if $formData.type === "password" && authMethods.password.enabled}
                    <Form.Field {form} name="password" class="col-span-1">
                      <Form.Control>
                        {#snippet children({ props })}
                          <div class="flex justify-between">
                            <Form.Label>Password</Form.Label>
                            <a
                              href="/reset"
                              class="ml-auto text-sm underline-offset-4 hover:underline"
                              >Forgot your password?</a
                            >
                          </div>
                          <Input
                            {...props}
                            type="password"
                            bind:value={$formData.password}
                            required
                            autocomplete="current-password"
                          />
                        {/snippet}
                      </Form.Control>
                      <Form.FieldErrors class="contents text-destructive" />
                    </Form.Field>

                    {#if $message && $message.type === "error"}
                      <div class="text-destructive text-sm font-medium -mt-2">
                        {$message.text}
                      </div>
                    {/if}

                    <Button type="submit" class="w-full">Login</Button>
                    {#if authMethods.otp.enabled}
                      <Button
                        variant="outline"
                        class="w-full"
                        onclick={() => ($formData.type = "otp" as "password")}
                        >Use one-time code instead</Button
                      >
                    {/if}
                  {:else if $formData.type === "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={() => ($formData.type = "password" as "otp")}
                        >Continue with password</Button
                      >
                    {/if}
                  {/if}
                </div>

                <input type="hidden" name="type" bind:value={$formData.type} />
              {/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)/login/+page.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)/login/server.test.ts
import { describe, it, expect } from "vitest";
import * as devalue from "devalue";
import type { Match } from "@velastack/pocketbase";
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");
  });
});

describe("POST /login", () => {
  it("should login with password", async (context) => {
    const response = await context.agent
      .post("/login" satisfies Match<RouteId>)
      .type("form")
      .send({
        type: "password",
        email: context.user.email,
        password: "password",
      });
    expect(response.body.status).toBe(303);
    expect(response.body.location).toBe("/dashboard");
  });

  it("should error if the password is incorrect", async (context) => {
    const response = await context.agent
      .post("/login" satisfies Match<RouteId>)
      .type("form")
      .send({
        type: "password",
        email: context.user.email,
        password: "incorrect",
      });
    const data = devalue.parse(response.body.data);
    expect(response.body.status).toBe(400);
    expect(data.form.message.text).toBe("Failed to authenticate.");
  });
});
src/routes/(public)/logout/+server.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)/logout/server.test.ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/pocketbase";
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)/otp/[token]/+page.server.ts
import { fail, message, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { otpSchema } from "$lib/schemas/otp";
import { redirect } from "@sveltejs/kit";
import { dev } from "$app/environment";

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

  return { form: await superValidate(zod4(otpSchema)) };
};

export const actions = {
  default: async ({ locals, request, params, cookies, url }) => {
    const form = await superValidate(request, zod4(otpSchema));

    if (!form.valid) {
      return fail(400, { form });
    }

    try {
      await locals.pb
        .collection("users")
        .authWithOTP(params.token, form.data.otp);
    } catch (error: any) {
      return message(
        form,
        { type: "error", text: error.response.message },
        { status: 400 },
      );
    }

    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)/otp/[token]/+page.svelte
<script lang="ts">
  import { untrack } from "svelte";
  import { Button } from "$lib/components/ui/button";
  import * as Card from "$lib/components/ui/card";
  import { superForm } from "sveltekit-superforms";
  import { zod4Client } from "sveltekit-superforms/adapters";
  import { otpSchema } from "$lib/schemas/otp";
  import * as Form from "$lib/components/ui/form";
  import { Input } from "$lib/components/ui/input";

  let { data } = $props();

  const form = superForm(
    untrack(() => data.form),
    {
      validators: zod4Client(otpSchema),
    },
  );

  const { form: formData, message } = form;
</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.svg" alt="logo" class="size-4" />
      </div>
      {data.meta.appName}
    </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 method="POST">
            <div class="grid gap-6">
              <div class="grid gap-2">
                <Form.Field {form} name="otp" class="col-span-1">
                  <Form.Control>
                    {#snippet children({ props })}
                      <Form.Label>OTP code</Form.Label>
                      <Input
                        {...props}
                        type="text"
                        bind:value={$formData.otp}
                        required
                      />
                    {/snippet}
                  </Form.Control>
                  <Form.FieldErrors class="contents text-destructive" />
                </Form.Field>

                {#if $message && $message.type === "error"}
                  <div class="text-destructive text-sm font-medium -mt-2">
                    {$message.text}
                  </div>
                {/if}

                <Button type="submit" class="w-full">Verify code</Button>
              </div>
            </div>
          </form>
        </Card.Content>
      </Card.Root>
    </div>
  </div>
</div>
src/routes/(public)/otp/[token]/+page.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)/otp/[token]/server.test.ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/pocketbase";
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);
  });
});

describe("POST /otp/[token]", () => {
  it("should throw an error if the otp is invalid", async (context) => {
    const response = await context.agent
      .post("/otp/123456" satisfies Match<RouteId>)
      .type("form")
      .send({ otp: "123456" });
    expect(response.body.status).toBe(400);
  });
});
src/routes/(public)/reset/+page.server.ts
import { fail, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { resetSchema } from "$lib/schemas/reset";
import { redirect } from "@sveltejs/kit";
import { setFlash } from "sveltekit-flash-message/server";

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

  return { form: await superValidate(zod4(resetSchema)) };
};

export const actions = {
  default: async ({ locals, request, cookies }) => {
    const form = await superValidate(request, zod4(resetSchema));

    if (!form.valid) {
      return fail(400, { form });
    }

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

    return { form };
  },
};
src/routes/(public)/reset/+page.svelte
<script lang="ts">
  import { untrack } from "svelte";
  import { Button } from "$lib/components/ui/button";
  import * as Card from "$lib/components/ui/card";
  import { superForm } from "sveltekit-superforms";
  import { zod4Client } from "sveltekit-superforms/adapters";
  import { resetSchema } from "$lib/schemas/reset";
  import * as Form from "$lib/components/ui/form";
  import { Input } from "$lib/components/ui/input";

  let { data } = $props();

  const form = superForm(
    untrack(() => data.form),
    {
      validators: zod4Client(resetSchema),
    },
  );

  const { form: formData } = form;
</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.svg" alt="logo" class="size-4" />
      </div>
      {data.meta.appName}
    </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 method="POST">
            <div class="grid gap-6">
              <div class="grid gap-2">
                <Form.Field {form} name="email" class="col-span-1">
                  <Form.Control>
                    {#snippet children({ props })}
                      <Form.Label>Email</Form.Label>
                      <Input
                        {...props}
                        type="email"
                        bind:value={$formData.email}
                        required
                      />
                    {/snippet}
                  </Form.Control>
                  <Form.FieldErrors class="contents text-destructive" />
                </Form.Field>

                <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)/reset/+page.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)/reset/server.test.ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/pocketbase";
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);
  });
});

describe("POST /reset", () => {
  it("should request a password reset", async (context) => {
    const response = await context.agent
      .post("/reset" satisfies Match<RouteId>)
      .type("form")
      .send({ email: context.user.email });
    expect(response.body.status).toBe(200);
  });
});
src/routes/(public)/signup/+page.server.ts
import { redirect } from "@sveltejs/kit";
import { fail, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { setFlash } from "sveltekit-flash-message/server";
import { setPocketbaseErrors } from "@velastack/pocketbase";
import { dev } from "$app/environment";
import { signupSchema } from "$lib/schemas/signup";

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

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

  return { form: await superValidate(zod4(signupSchema)), authMethods };
};

export const actions = {
  default: async ({ locals, request, cookies, url }) => {
    const form = await superValidate(request, zod4(signupSchema));

    if (!form.valid) {
      return fail(400, { form });
    }

    let user;

    try {
      user = await locals.admin.collection("users").create({
        email: form.data.email,
        password: form.data.password,
        passwordConfirm: form.data.passwordConfirm,
      });
    } catch (error) {
      setPocketbaseErrors(form, error);
      return fail(400, { form });
    }

    await locals.pb.collection("users").requestVerification(user.email);
    await locals.pb
      .collection("users")
      .authWithPassword(form.data.email, form.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,
    );
    return redirect(303, redirectUrl);
  },
};
src/routes/(public)/signup/+page.svelte
<script lang="ts">
  import { untrack } from "svelte";
  import { Button } from "$lib/components/ui/button";
  import * as Card from "$lib/components/ui/card";
  import { superForm } from "sveltekit-superforms";
  import { zod4Client } from "sveltekit-superforms/adapters";
  import { signupSchema } from "$lib/schemas/signup";
  import * as Form from "$lib/components/ui/form";
  import { Input } from "$lib/components/ui/input";
  import PocketBase from "pocketbase-sveltekit";
  import { goto } from "$app/navigation";
  import { page } from "$app/state";

  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 form = superForm(
    untrack(() => data.form),
    {
      validators: zod4Client(signupSchema),
    },
  );

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

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

  const { form: formData } = form;
</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.svg" alt="logo" class="size-4" />
      </div>
      {data.meta.appName}
    </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 method="POST">
            <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}
                {#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">
                  <Form.Field {form} name="email" class="col-span-1">
                    <Form.Control>
                      {#snippet children({ props })}
                        <Form.Label>Email</Form.Label>
                        <Input
                          {...props}
                          type="email"
                          bind:value={$formData.email}
                          required
                        />
                      {/snippet}
                    </Form.Control>
                    <Form.FieldErrors class="contents text-destructive" />
                  </Form.Field>

                  <Form.Field {form} name="password" class="col-span-1">
                    <Form.Control>
                      {#snippet children({ props })}
                        <Form.Label>Password</Form.Label>
                        <Input
                          {...props}
                          type="password"
                          bind:value={$formData.password}
                          required
                          autocomplete="new-password"
                        />
                      {/snippet}
                    </Form.Control>
                    <Form.FieldErrors class="contents text-destructive" />
                  </Form.Field>

                  <Form.Field {form} name="passwordConfirm" class="col-span-1">
                    <Form.Control>
                      {#snippet children({ props })}
                        <Form.Label>Confirm Password</Form.Label>
                        <Input
                          {...props}
                          type="password"
                          bind:value={$formData.passwordConfirm}
                          required
                          autocomplete="new-password"
                        />
                      {/snippet}
                    </Form.Control>
                    <Form.FieldErrors class="contents text-destructive" />
                  </Form.Field>
                  <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)/signup/+page.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)/signup/server.test.ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/pocketbase";
import * as devalue from "devalue";
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);
  });
});

describe("POST /signup", () => {
  it("should signup with password", async (context) => {
    const response = await context.agent
      .post("/signup" satisfies Match<RouteId>)
      .type("form")
      .send({
        email: `test-${Math.random().toString(36).slice(2)}@example.com`,
        password: "password",
        passwordConfirm: "password",
      });
    expect(response.body.status).toBe(303);
    expect(response.body.location).toBe("/dashboard");
  });

  it("should error if the password does not match", async (context) => {
    const response = await context.agent
      .post("/signup" satisfies Match<RouteId>)
      .type("form")
      .send({
        email: `test-${Math.random().toString(36).slice(2)}@example.com`,
        password: "password",
        passwordConfirm: "password2",
      });
    expect(response.body.status).toBe(400);
    const data = devalue.parse(response.body.data);
    expect(data.form.errors.passwordConfirm).toBeDefined();
    expect(data.form.errors.passwordConfirm[0]).toBe("Values don't match.");
  });

  it("should error if the email is already taken", async (context) => {
    const response = await context.agent
      .post("/signup" satisfies Match<RouteId>)
      .type("form")
      .send({
        email: context.user.email,
        password: "password",
        passwordConfirm: "password",
      });
    expect(response.body.status).toBe(400);
    const data = devalue.parse(response.body.data);
    expect(data.form.errors.email).toBeDefined();
    expect(data.form.errors.email[0]).toBe("Value must be unique.");
  });
});