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 favicon from "$lib/assets/favicon.svg";
  import { site } from "$lib/site";

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

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

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

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

// A flat shape (rather than a discriminated union) keeps
// `loginForm.fields.password` addressable in the page regardless of mode.
export const loginSchema = z
  .object({
    type: z.enum(["password", "otp", "oauth2"]),
    email: z.email(),
    password: z.string().optional(),
  })
  .refine((data) => data.type !== "password" || !!data.password, {
    message: "Password is required",
    path: ["password"],
  });
src/lib/schemas/otp.ts
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} />
    <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/kit";
import type { RouteId } from "./$types";

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

  it("authenticated user should be able to access the dashboard", async (context) => {
    await context.agent.authenticateUser();
    const response = await context.agent.get(
      "/dashboard" satisfies Match<RouteId>,
    );
    expect(response.status).toBe(200);
  });
});
src/routes/(app)/settings/+layout.server.ts
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();

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

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

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

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

  <div class="grid grid-cols-1 gap-x-8 gap-y-8 py-10 md:grid-cols-3">
    <div class="px-4 sm:px-0">
      <h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
        Email
      </h2>
      <p class="mt-1 text-sm/6 text-gray-600 dark:text-gray-400">
        The email address associated with your account.
      </p>
    </div>

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

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

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

    <form {...changePasswordForm} class="md:col-span-2">
      <Card.Root>
        <Card.Content class="flex flex-col gap-6">
          <input
            type="email"
            value={data.user.email}
            autocomplete="username"
            readonly
            tabindex="-1"
            aria-hidden="true"
            class="sr-only"
          />
          <div class="grid gap-6">
            <div class="grid gap-3">
              <label for="password" class="text-sm font-medium"
                >New password</label
              >
              <Input
                id="password"
                {...changePasswordForm.fields.password.as("text")}
                type="password"
                autocomplete="new-password"
                required
                minlength={8}
              />
              {#each changePasswordForm.fields.password.issues() as issue}
                <p class="text-destructive text-sm">{issue.message}</p>
              {/each}
            </div>
            <div class="grid gap-3">
              <label for="passwordConfirm" class="text-sm font-medium"
                >Confirm new password</label
              >
              <Input
                id="passwordConfirm"
                {...changePasswordForm.fields.passwordConfirm.as("text")}
                type="password"
                autocomplete="new-password"
                required
                minlength={8}
              />
              {#each changePasswordForm.fields.passwordConfirm.issues() as issue}
                <p class="text-destructive text-sm">{issue.message}</p>
              {/each}
            </div>
          </div>
        </Card.Content>
        <Card.Footer class="border-t justify-end">
          <Button type="submit" class="w-fit">Update password</Button>
        </Card.Footer>
      </Card.Root>
    </form>
  </div>
</div>
src/routes/(app)/settings/+page.ts
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)/(auth)/confirm-email-change/[token]/+page.server.ts
export const load = async () => {
  return {};
};
src/routes/(public)/(auth)/confirm-email-change/[token]/+page.svelte
<script lang="ts">
  import favicon from "$lib/assets/favicon.svg";
  import { site } from "$lib/site";

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

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

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

                <Button type="submit" class="w-full"
                  >Confirm email change</Button
                >
              </div>
            </div>
          </form>
        </Card.Content>
      </Card.Root>
    </div>
  </div>
</div>
src/routes/(public)/(auth)/confirm-email-change/[token]/+page.ts
import { definePageMetaTags } from "svelte-meta-tags";

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

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

  return { ...data, pageMetaTags };
};
src/routes/(public)/(auth)/confirm-email-change/[token]/form.remote.ts
import { form, getRequestEvent } from "$app/server";
import { redirect } from "@sveltejs/kit";
import { setFlash } from "sveltekit-flash-message/server";
import { confirmEmailChangeSchema } from "$lib/schemas/confirmEmailChange";

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

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

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

    setFlash(
      { type: "toast", message: "Email changed successfully." },
      cookies,
    );
    redirect(303, "/login");
  },
);
src/routes/(public)/(auth)/confirm-email-change/[token]/server.test.ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";

describe("GET /confirm-email-change/[token]", () => {
  it("should render the confirm-email-change page", async (context) => {
    const response = await context.request.get(
      "/confirm-email-change/123456" satisfies Match<RouteId>,
    );
    expect(response.status).toBe(200);
  });
});
src/routes/(public)/(auth)/confirm-reset/[token]/+page.server.ts
import { getTokenPayload } from "pocketbase-sveltekit";

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

  return { email: typeof email === "string" ? email : null };
};
src/routes/(public)/(auth)/confirm-reset/[token]/+page.svelte
<script lang="ts">
  import favicon from "$lib/assets/favicon.svg";
  import { site } from "$lib/site";

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

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

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

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

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

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

                <Button type="submit" class="w-full">Reset password</Button>
              </div>
            </div>
          </form>
        </Card.Content>
      </Card.Root>
    </div>
  </div>
</div>
src/routes/(public)/(auth)/confirm-reset/[token]/+page.ts
import { definePageMetaTags } from "svelte-meta-tags";

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

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

  return { ...data, pageMetaTags };
};
src/routes/(public)/(auth)/confirm-reset/[token]/form.remote.ts
import { form, getRequestEvent } from "$app/server";
import { redirect } from "@sveltejs/kit";
import { setFlash } from "sveltekit-flash-message/server";
import { confirmResetSchema } from "$lib/schemas/confirmReset";

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

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

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

  setFlash({ type: "toast", message: "Password reset successfully." }, cookies);
  redirect(303, "/login");
});
src/routes/(public)/(auth)/confirm-reset/[token]/server.test.ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";

describe("GET /confirm-reset/[token]", () => {
  it("should render the confirm-reset page", async (context) => {
    const response = await context.request.get(
      "/confirm-reset/123456" satisfies Match<RouteId>,
    );
    expect(response.status).toBe(200);
  });
});
src/routes/(public)/(auth)/confirm-verification/[token]/+server.ts
import { redirect } from "@sveltejs/kit";
import { setFlash } from "sveltekit-flash-message/server";

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

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

  setFlash({ type: "toast", message }, cookies);
  redirect(303, redirectPath);
};
src/routes/(public)/(auth)/confirm-verification/[token]/server.test.ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";

describe("GET /confirm-verification/[token]", () => {
  it("should redirect if the token is invalid", async (context) => {
    const response = await context.request.get(
      "/confirm-verification/123456" satisfies Match<RouteId>,
    );
    expect(response.status).toBe(303);
    expect(response.headers["location"]).toBe("/login");
  });
});
src/routes/(public)/(auth)/login/+page.server.ts
import { redirect } from "@sveltejs/kit";

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

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

  return { authMethods };
};
src/routes/(public)/(auth)/login/+page.svelte
<script lang="ts">
  import favicon from "$lib/assets/favicon.svg";
  import { site } from "$lib/site";

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  return { ...data, pageMetaTags };
};
src/routes/(public)/(auth)/login/form.remote.ts
import { form, getRequestEvent } from "$app/server";
import { redirect } from "@sveltejs/kit";
import { dev } from "$app/environment";
import { loginSchema } from "$lib/schemas/login";

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

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

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

  redirect(303, redirectUrl);
});
src/routes/(public)/(auth)/login/server.test.ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";

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

  it("should redirect to the dashboard if the user is already authenticated", async (context) => {
    await context.agent.authenticateUser();
    const response = await context.agent.get("/login" satisfies Match<RouteId>);
    expect(response.status).toBe(303);
    expect(response.headers.location).toBe("/dashboard");
  });
});
src/routes/(public)/(auth)/logout/+server.ts
import { redirect } from "@sveltejs/kit";

export const POST = ({ cookies, locals }) => {
  locals.pb.authStore.clear();
  locals.pb.authStore.clearCookie(cookies);
  throw redirect(303, "/");
};
src/routes/(public)/(auth)/logout/server.test.ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";

describe("POST /logout", () => {
  it("should logout the user", async (context) => {
    await context.agent.authenticateUser();
    const response = await context.agent.post(
      "/logout" satisfies Match<RouteId>,
    );
    expect(response.status).toBe(303);
    expect(response.headers.location).toBe("/");
  });
});
src/routes/(public)/(auth)/otp/[token]/+page.server.ts
import { redirect } from "@sveltejs/kit";

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

  return {};
};
src/routes/(public)/(auth)/otp/[token]/+page.svelte
<script lang="ts">
  import favicon from "$lib/assets/favicon.svg";
  import { site } from "$lib/site";

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

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

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

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

                <Button type="submit" class="w-full">Verify code</Button>
              </div>
            </div>
          </form>
        </Card.Content>
      </Card.Root>
    </div>
  </div>
</div>
src/routes/(public)/(auth)/otp/[token]/+page.ts
import { definePageMetaTags } from "svelte-meta-tags";

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

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

  return { ...data, pageMetaTags };
};
src/routes/(public)/(auth)/otp/[token]/form.remote.ts
import { form, getRequestEvent } from "$app/server";
import { redirect } from "@sveltejs/kit";
import { dev } from "$app/environment";
import { otpSchema } from "$lib/schemas/otp";

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

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

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

  redirect(303, redirectUrl);
});
src/routes/(public)/(auth)/otp/[token]/server.test.ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";

describe("GET /otp/[token]", () => {
  it("should render the otp page", async (context) => {
    const response = await context.request.get(
      "/otp/123456" satisfies Match<RouteId>,
    );
    expect(response.status).toBe(200);
  });
});
src/routes/(public)/(auth)/reset/+page.server.ts
import { redirect } from "@sveltejs/kit";

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

  return {};
};
src/routes/(public)/(auth)/reset/+page.svelte
<script lang="ts">
  import favicon from "$lib/assets/favicon.svg";
  import { site } from "$lib/site";

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

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

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

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

              <div class="text-center text-sm">
                Remembered your password?
                <a href="/login" class="underline underline-offset-4">Log in</a>
              </div>
            </div>
          </form>
        </Card.Content>
      </Card.Root>
    </div>
  </div>
</div>
src/routes/(public)/(auth)/reset/+page.ts
import { definePageMetaTags } from "svelte-meta-tags";

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

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

  return { ...data, pageMetaTags };
};
src/routes/(public)/(auth)/reset/form.remote.ts
import { form, getRequestEvent } from "$app/server";
import { setFlash } from "sveltekit-flash-message/server";
import { resetSchema } from "$lib/schemas/reset";

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

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

  return { success: true };
});
src/routes/(public)/(auth)/reset/server.test.ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";

describe("GET /reset", () => {
  it("should render the reset page", async (context) => {
    const response = await context.request.get(
      "/reset" satisfies Match<RouteId>,
    );
    expect(response.status).toBe(200);
  });
});
src/routes/(public)/(auth)/signup/+page.server.ts
import { redirect } from "@sveltejs/kit";

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

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

  return { authMethods };
};
src/routes/(public)/(auth)/signup/+page.svelte
<script lang="ts">
  import favicon from "$lib/assets/favicon.svg";
  import { site } from "$lib/site";

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

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

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

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

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

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

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

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

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

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

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

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

                  <Button type="submit" class="w-full">Create account</Button>
                </div>
              {/if}

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

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

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

  return { ...data, pageMetaTags };
};
src/routes/(public)/(auth)/signup/form.remote.ts
import { form, getRequestEvent } from "$app/server";
import { redirect } from "@sveltejs/kit";
import { setFlash } from "sveltekit-flash-message/server";
import { dev } from "$app/environment";
import { signupSchema } from "$lib/schemas/signup";

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

  let user: { email: string };

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

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

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

  setFlash(
    { type: "toast", message: "We sent a confirmation link to your email." },
    cookies,
  );
  redirect(303, redirectUrl);
});
src/routes/(public)/(auth)/signup/server.test.ts
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";

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