Logo VelaStack

Enable authentication (remote functions)

The same complete auth stack — login, signup, password reset, email change, OTP, email verification, and settings — but built on SvelteKit remote functions. Each form posts through a typed form.remote.ts handler validated with a Zod schema, and the dashboard layout, sidebar, and logout endpoint come along with it. Password, OTP, and OAuth2 providers are all supported, wired to 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 --remote
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.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
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
export const load = async ({ parent }) => {
  const { user } = await parent();

  return { user };
};
src/routes/(app)/settings/+page.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();
</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" })}>
              Upload new picture
              <input
                {...updateProfileForm.fields.avatar.as("file")}
                type="file"
                class="hidden"
              />
            </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"
              />
              {#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
                  {...updateProfileForm.fields.emailVisibility.as("checkbox")}
                />
                <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}>
                  <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
                      />
                      {#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">
          <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
              />
              {#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
              />
              {#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
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
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)/confirm-email-change/[token]/+page.server.ts
export const load = async () => {
  return {};
};
src/routes/(public)/confirm-email-change/[token]/+page.svelte
<script lang="ts">
  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";

  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.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 {...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"
                  />
                  {#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)/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]/form.remote.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)/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
export const load = async () => {
  return {};
};
src/routes/(public)/confirm-reset/[token]/+page.svelte
<script lang="ts">
  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.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 {...confirmResetForm}>
            <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"
                    {...confirmResetForm.fields.password.as("text")}
                    type="password"
                    required
                    autocomplete="new-password"
                  />
                  {#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"
                  />
                  {#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)/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]/form.remote.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)/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 { 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)/login/+page.svelte
<script lang="ts">
  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.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 {...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
                    />
                    {#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="flex justify-between">
                        <label for="password" class="text-sm font-medium"
                          >Password</label
                        >
                        <a
                          href="/reset"
                          class="ml-auto text-sm underline-offset-4 hover:underline"
                          >Forgot your password?</a
                        >
                      </div>
                      <Input
                        id="password"
                        {...loginForm.fields.password.as("text")}
                        type="password"
                        required
                        autocomplete="current-password"
                      />
                      {#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)/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/form.remote.ts
import { form, getRequestEvent } from "$app/server";
import { error, 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) {
      error(400, err.response?.message ?? "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)/login/server.test.ts
import { describe, it, expect } from "vitest";
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");
  });
});
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 { redirect } from "@sveltejs/kit";

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

  return {};
};
src/routes/(public)/otp/[token]/+page.svelte
<script lang="ts">
  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";

  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.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 {...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"
                    required
                  />
                  {#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)/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]/form.remote.ts
import { form, getRequestEvent } from "$app/server";
import { error, 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) {
    error(400, err.response?.message ?? "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)/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);
  });
});
src/routes/(public)/reset/+page.server.ts
import { redirect } from "@sveltejs/kit";

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

  return {};
};
src/routes/(public)/reset/+page.svelte
<script lang="ts">
  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";

  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.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 {...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
                  />
                  {#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)/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/form.remote.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)/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);
  });
});
src/routes/(public)/signup/+page.server.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)/signup/+page.svelte
<script lang="ts">
  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.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 {...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=""
                      />
                      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">
                  <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
                    />
                    {#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"
                    />
                    {#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"
                    />
                    {#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)/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/form.remote.ts
import { form, getRequestEvent } from "$app/server";
import { error, 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;

  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 =
      (fieldError as any)?.message ??
      response?.message ??
      "Failed to create account.";
    error(400, 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)/signup/server.test.ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/pocketbase";
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);
  });
});