Drop a complete authentication stack into your SvelteKit app. Scaffolds login, signup, password-reset, email-change, email-verification, and OTP flows backed by PocketBase, alongside a dashboard layout, a sidebar, and a settings page for profile, email, and password updates. Password, one-time code, and OAuth2 providers are all supported, with provider buttons rendered from the auth methods PocketBase reports at runtime. An oauth_accounts collection is provisioned for provider tokens, and every route ships with a server test.
<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>
<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>
<script lang="ts">
import { toggleMode, mode } from "mode-watcher";
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
import Settings2Icon from "@lucide/svelte/icons/settings-2";
import LogOutIcon from "@lucide/svelte/icons/log-out";
import MoonIcon from "@lucide/svelte/icons/moon";
import SunIcon from "@lucide/svelte/icons/sun";
import * as Avatar from "$lib/components/ui/avatar";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import * as Sidebar from "$lib/components/ui/sidebar";
let data = {
navUser: [
{
title: "Settings",
url: "/settings",
icon: Settings2Icon,
},
],
};
let {
user,
}: {
user: {
id: string;
name: string;
email: string;
avatar: string;
};
} = $props();
const sidebar = Sidebar.useSidebar();
</script>
<Sidebar.Menu>
<Sidebar.MenuItem>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
{...props}
>
<Avatar.Root class="size-8 rounded-lg">
{#if user.avatar}
<Avatar.Image
src="/api/files/users/{user.id}/{user.avatar}"
alt={user.name}
/>
{/if}
<Avatar.Fallback class="rounded-lg">
{user.name?.charAt(0).toUpperCase() ||
user.email?.charAt(0).toUpperCase()}
</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{user.name || user.email}</span
>
</div>
<ChevronsUpDownIcon class="ml-auto size-4" />
</Sidebar.MenuButton>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
side={sidebar.isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenu.Label class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar.Root class="size-8 rounded-lg">
{#if user.avatar}
<Avatar.Image
src="/api/files/users/{user.id}/{user.avatar}"
alt={user.name}
/>
{/if}
<Avatar.Fallback class="rounded-lg">
{user.name?.charAt(0).toUpperCase() ||
user.email?.charAt(0).toUpperCase()}
</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{user.name || user.email}</span
>
</div>
</div>
</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item onclick={toggleMode}>
{#if mode.current === "dark"}
<SunIcon />
Light mode
{:else}
<MoonIcon />
Dark mode
{/if}
</DropdownMenu.Item>
{#each data.navUser as item}
<DropdownMenu.Item class="p-0">
<a
href={item.url}
class="flex cursor-default items-center gap-2 px-2 py-1.5 w-full"
>
<item.icon />
{item.title}
</a>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item class="p-0">
<form action="/logout" method="post" class="w-full">
<button
type="submit"
class="flex cursor-default items-center gap-2 px-2 py-1.5 w-full"
>
<LogOutIcon />
Log out
</button>
</form>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
</Sidebar.Menu>
import { z } from "zod";
export const changeEmailSchema = z.object({
email: z.email(),
});
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"],
});
import { z } from "zod";
export const confirmEmailChangeSchema = z.object({
password: z.string(),
});
import { z } from "zod";
export const confirmResetSchema = z.object({
password: z.string(),
passwordConfirm: z.string(),
});
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,
]);
import { z } from "zod";
export const otpSchema = z.object({
otp: z.string(),
});
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(),
});
import { z } from "zod";
export const resetSchema = z.object({
email: z.email(),
});
import { z } from "zod";
export const signupSchema = z.object({
email: z.email(),
password: z.string(),
passwordConfirm: z.string(),
});
export const load = ({ locals }) => {
const user = locals.pb.authStore.record!;
const breadcrumbs = [{ title: "Home", url: "/dashboard" }];
return { user, breadcrumbs };
};
<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>
import { definePageMetaTags } from "svelte-meta-tags";
export const load = async ({ parent }) => {
await parent();
const { pageMetaTags } = definePageMetaTags({
title: "Dashboard",
});
return { pageMetaTags };
};
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);
});
});
export const load = async ({ parent }) => {
const { breadcrumbs } = await parent();
return {
breadcrumbs: [...breadcrumbs, { title: "Settings", url: "/settings" }],
};
};
import { superValidate, fail, withFiles } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { setFlash } from "sveltekit-flash-message/server";
import { profileSchema } from "$lib/schemas/profile";
import { changeEmailSchema } from "$lib/schemas/changeEmail";
import { changePasswordSchema } from "$lib/schemas/changePassword";
export const load = async ({ parent }) => {
const { user } = await parent();
const profileForm = await superValidate(
{
avatar: user.avatar,
name: user.name,
emailVisibility: user.emailVisibility,
},
zod4(profileSchema),
);
const emailForm = await superValidate(zod4(changeEmailSchema));
const passwordForm = await superValidate(zod4(changePasswordSchema));
return { profileForm, emailForm, passwordForm, user };
};
export const actions = {
updateProfile: async ({ locals, request, cookies }) => {
const form = await superValidate(request, zod4(profileSchema));
if (!form.valid) {
return fail(400, { profileForm: form });
}
await locals.pb
.collection("users")
.update(locals.pb.authStore.record!.id, form.data);
setFlash(
{ type: "toast", message: "Profile updated successfully." },
cookies,
);
return withFiles({ profileForm: form });
},
changeEmail: async ({ locals, request, cookies }) => {
const form = await superValidate(request, zod4(changeEmailSchema));
if (!form.valid) {
return fail(400, { emailForm: form });
}
await locals.pb.collection("users").requestEmailChange(form.data.email);
setFlash(
{
type: "toast",
message: "We sent a confirmation link to your new email.",
},
cookies,
);
return { emailForm: form };
},
changePassword: async ({ locals, request, cookies }) => {
const form = await superValidate(request, zod4(changePasswordSchema));
if (!form.valid) {
return fail(400, { passwordForm: form });
}
await locals.admin
.collection("users")
.update(locals.pb.authStore.record!.id, {
password: form.data.password,
passwordConfirm: form.data.passwordConfirm,
});
setFlash(
{ type: "toast", message: "Password updated successfully." },
cookies,
);
return { passwordForm: form };
},
resendVerificationEmail: async ({ locals, request, cookies }) => {
await locals.pb
.collection("users")
.requestVerification(locals.pb.authStore.record!.email);
setFlash(
{ type: "toast", message: "We sent a verification email to your email." },
cookies,
);
return {};
},
};
<script lang="ts">
import { untrack } from "svelte";
import { superForm } from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
import { changeEmailSchema } from "$lib/schemas/changeEmail";
import { changePasswordSchema } from "$lib/schemas/changePassword";
import { profileSchema } from "$lib/schemas/profile";
import { Button, buttonVariants } from "$lib/components/ui/button";
import * as Form from "$lib/components/ui/form";
import * as Card from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import { Badge } from "$lib/components/ui/badge";
import * as Dialog from "$lib/components/ui/dialog";
import { Checkbox } from "$lib/components/ui/checkbox";
import * as Avatar from "$lib/components/ui/avatar";
import * as FileForm from "$lib/components/ui/file-form";
let { data } = $props();
const profileForm = superForm(
untrack(() => data.profileForm),
{
validators: zod4Client(profileSchema),
id: "profileForm",
},
);
const emailForm = superForm(
untrack(() => data.emailForm),
{
validators: zod4Client(changeEmailSchema),
id: "emailForm",
},
);
const passwordForm = superForm(
untrack(() => data.passwordForm),
{
validators: zod4Client(changePasswordSchema),
id: "passwordForm",
},
);
const { form: profileFormData } = profileForm;
const { form: emailFormData } = emailForm;
const { form: passwordFormData } = passwordForm;
</script>
<div class="divide-y divide-gray-900/10 dark:divide-white/10">
<div class="grid grid-cols-1 gap-x-8 gap-y-8 py-10 md:grid-cols-3">
<div class="px-4 sm:px-0">
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Profile
</h2>
<p class="mt-1 text-sm/6 text-gray-600 dark:text-gray-400">
Your profile information.
</p>
</div>
<form
method="POST"
action="?/updateProfile"
enctype="multipart/form-data"
class="md:col-span-2"
>
<Card.Root>
<Card.Content class="flex flex-col gap-6">
<FileForm.Field form={profileForm} name="avatar" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<div class="flex items-center gap-3 justify-between">
{#if !$profileFormData.avatar}
<div class="flex items-center gap-3">
<Avatar.Root class="size-16">
<Avatar.Fallback>
{data.user.email.charAt(0).toUpperCase()}
</Avatar.Fallback>
</Avatar.Root>
<div>
<span class="text-sm font-medium leading-none"
>Avatar</span
>
<p class="text-muted-foreground text-sm">
Upload a new avatar for your profile.
</p>
</div>
</div>
{/if}
<FileForm.Single>
{#snippet display({ file, filename, onremove })}
<div
class="flex-1 flex gap-2 justify-between items-center"
>
<div class="flex items-center gap-3">
<FileForm.Thumb
{file}
{filename}
collectionId="users"
id={data.user.id}
class="w-16 h-16 object-cover rounded-full"
/>
<div>
<span class="text-sm font-medium leading-none"
>Avatar</span
>
<p class="text-muted-foreground text-sm">
Upload a new avatar for your profile.
</p>
</div>
</div>
<Button
variant="outline"
onclick={() => onremove(file)}
>
Remove avatar
</Button>
</div>
{/snippet}
{#snippet input()}
<Form.Label
class={buttonVariants({ variant: "outline" })}
>
Upload new picture
</Form.Label>
<FileForm.Input class="hidden" {...props} />
{/snippet}
</FileForm.Single>
</div>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</FileForm.Field>
<div class="grid gap-6">
<div class="grid gap-3">
<Form.Field form={profileForm} name="name">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Name</Form.Label>
<Input
{...props}
type="text"
bind:value={$profileFormData.name}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
</div>
</div>
<div class="grid gap-6">
<div class="grid gap-3">
<Form.Field
form={profileForm}
name="emailVisibility"
class="flex items-start gap-2"
>
<Form.Control>
{#snippet children({ props })}
<Checkbox
{...props}
bind:checked={$profileFormData.emailVisibility}
/>
<div class="grid gap-2">
<Form.Label>Display email publicly</Form.Label>
<p class="text-muted-foreground text-sm">
By clicking this checkbox, your email will be displayed
publicly.
</p>
</div>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
</div>
</div>
</Card.Content>
<Card.Footer class="border-t justify-end">
<Button type="submit" class="w-fit">Save changes</Button>
</Card.Footer>
</Card.Root>
</form>
</div>
<div class="grid grid-cols-1 gap-x-8 gap-y-8 py-10 md:grid-cols-3">
<div class="px-4 sm:px-0">
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Email
</h2>
<p class="mt-1 text-sm/6 text-gray-600 dark:text-gray-400">
The email address associated with your account.
</p>
</div>
<Card.Root class="md:col-span-2">
<Card.Content class="flex flex-col gap-6">
<div class="flex justify-between items-center">
<span class="text-sm flex items-center gap-2">
{data.user.email}
<Badge variant="secondary"
>{data.user.verified ? "Verified" : "Unverified"}</Badge
>
</span>
<div class="flex items-center gap-3">
{#if !data.user.verified}
<form method="POST" action="?/resendVerificationEmail">
<Button type="submit" variant="outline" class="w-fit"
>Resend verification email</Button
>
</form>
{/if}
<Dialog.Root>
<Dialog.Trigger class={buttonVariants({ variant: "outline" })}>
Change email
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Change email</Dialog.Title>
<Dialog.Description>
Enter your new email address. You will receive a
verification email.
</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/changeEmail">
<div class="grid gap-6">
<div class="grid gap-3">
<Form.Field form={emailForm} name="email">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Email</Form.Label>
<Input
{...props}
type="email"
bind:value={$emailFormData.email}
required
/>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
</div>
</div>
</form>
<Dialog.Footer>
<Button type="submit" class="w-fit">Change email</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
</div>
</div>
</Card.Content>
</Card.Root>
</div>
<div class="grid grid-cols-1 gap-x-8 gap-y-8 py-10 md:grid-cols-3">
<div class="px-4 sm:px-0">
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Password
</h2>
<p class="mt-1 text-sm/6 text-gray-600 dark:text-gray-400">
Your password is used to log in to your account.
</p>
</div>
<form method="POST" action="?/changePassword" class="md:col-span-2">
<Card.Root>
<Card.Content class="flex flex-col gap-6">
<div class="grid gap-6">
<div class="grid gap-3">
<Form.Field form={passwordForm} name="password">
<Form.Control>
{#snippet children({ props })}
<Form.Label>New password</Form.Label>
<Input
{...props}
type="password"
bind:value={$passwordFormData.password}
autocomplete="new-password"
required
/>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
</div>
<div class="grid gap-3">
<Form.Field form={passwordForm} name="passwordConfirm">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Confirm new password</Form.Label>
<Input
{...props}
type="password"
bind:value={$passwordFormData.passwordConfirm}
autocomplete="new-password"
required
/>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
</div>
</div>
</Card.Content>
<Card.Footer class="border-t justify-end">
<Button type="submit" class="w-fit">Update password</Button>
</Card.Footer>
</Card.Root>
</form>
</div>
</div>
import { definePageMetaTags } from "svelte-meta-tags";
export const load = async ({ parent, data }) => {
await parent();
const { pageMetaTags } = definePageMetaTags({
title: "Settings",
});
return { ...data, pageMetaTags };
};
import { fail, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { confirmEmailChangeSchema } from "$lib/schemas/confirmEmailChange";
import { redirect } from "@sveltejs/kit";
import { setFlash } from "sveltekit-flash-message/server";
export const load = async () => {
return { form: await superValidate(zod4(confirmEmailChangeSchema)) };
};
export const actions = {
default: async ({ locals, params, request, cookies }) => {
const form = await superValidate(request, zod4(confirmEmailChangeSchema));
if (!form.valid) {
return fail(400, { form });
}
await locals.admin
.collection("users")
.confirmEmailChange(params.token, form.data.password);
locals.pb.authStore.clear();
locals.pb.authStore.clearCookie(cookies);
setFlash(
{ type: "toast", message: "Email changed successfully." },
cookies,
);
return redirect(303, "/login");
},
};
<script lang="ts">
import { untrack } from "svelte";
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { superForm } from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
import { confirmEmailChangeSchema } from "$lib/schemas/confirmEmailChange";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
let { data } = $props();
const form = superForm(
untrack(() => data.form),
{
validators: zod4Client(confirmEmailChangeSchema),
},
);
const { form: formData } = form;
</script>
<div class="flex flex-col flex-1 items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-sm flex-col gap-6">
<a href="/" class="flex items-center gap-2 self-center font-medium">
<div
class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"
>
<img src="/favicon.svg" alt="logo" class="size-4" />
</div>
{data.meta.appName}
</a>
<div class="flex flex-col gap-6">
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">Confirm your email change</Card.Title>
<Card.Description
>Enter your password to confirm your email change</Card.Description
>
</Card.Header>
<Card.Content>
<form method="POST">
<div class="grid gap-6">
<div class="grid gap-2">
<Form.Field {form} name="password" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Password</Form.Label>
<Input
{...props}
type="password"
bind:value={$formData.password}
required
autocomplete="current-password"
/>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
<Button type="submit" class="w-full"
>Confirm email change</Button
>
</div>
</div>
</form>
</Card.Content>
</Card.Root>
</div>
</div>
</div>
import { definePageMetaTags } from "svelte-meta-tags";
export const load = async ({ parent, data }) => {
await parent();
const { pageMetaTags } = definePageMetaTags({
title: "Confirm email change",
});
return { ...data, pageMetaTags };
};
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);
});
});
import { fail, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { confirmResetSchema } from "$lib/schemas/confirmReset";
import { redirect } from "@sveltejs/kit";
import { setFlash } from "sveltekit-flash-message/server";
export const load = async () => {
return { form: await superValidate(zod4(confirmResetSchema)) };
};
export const actions = {
default: async ({ locals, request, params, cookies }) => {
const form = await superValidate(request, zod4(confirmResetSchema));
if (!form.valid) {
return fail(400, { form });
}
await locals.admin
.collection("users")
.confirmPasswordReset(
params.token,
form.data.password,
form.data.passwordConfirm,
);
locals.pb.authStore.clear();
locals.pb.authStore.clearCookie(cookies);
setFlash(
{ type: "toast", message: "Password reset successfully." },
cookies,
);
return redirect(303, "/login");
},
};
<script lang="ts">
import { untrack } from "svelte";
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { superForm } from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
import { confirmResetSchema } from "$lib/schemas/confirmReset";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
let { data } = $props();
const form = superForm(
untrack(() => data.form),
{
validators: zod4Client(confirmResetSchema),
},
);
const { form: formData } = form;
</script>
<div class="flex flex-col flex-1 items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-sm flex-col gap-6">
<a href="/" class="flex items-center gap-2 self-center font-medium">
<div
class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"
>
<img src="/favicon.svg" alt="logo" class="size-4" />
</div>
{data.meta.appName}
</a>
<div class="flex flex-col gap-6">
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">Reset your password</Card.Title>
<Card.Description>Enter your new password</Card.Description>
</Card.Header>
<Card.Content>
<form method="POST">
<div class="grid gap-6">
<div class="grid gap-2">
<Form.Field {form} name="password" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Password</Form.Label>
<Input
{...props}
type="password"
bind:value={$formData.password}
required
autocomplete="new-password"
/>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
<Form.Field {form} name="passwordConfirm" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Confirm Password</Form.Label>
<Input
{...props}
type="password"
bind:value={$formData.passwordConfirm}
required
autocomplete="new-password"
/>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
<Button type="submit" class="w-full">Reset password</Button>
</div>
</div>
</form>
</Card.Content>
</Card.Root>
</div>
</div>
</div>
import { definePageMetaTags } from "svelte-meta-tags";
export const load = async ({ parent, data }) => {
await parent();
const { pageMetaTags } = definePageMetaTags({
title: "Confirm password reset",
});
return { ...data, pageMetaTags };
};
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);
});
});
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);
};
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");
});
});
import { fail, superValidate, message } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { loginSchema } from "$lib/schemas/login";
import { redirect } from "@sveltejs/kit";
import { dev } from "$app/environment";
export const load = async ({ locals }) => {
if (locals.pb.authStore.isValid) {
redirect(303, "/dashboard");
}
const authMethods = await locals.admin.collection("users").listAuthMethods();
const type: "password" | "otp" | "oauth2" = authMethods.otp.enabled
? "otp"
: authMethods.password.enabled
? "password"
: "oauth2";
return {
form: await superValidate(
zod4(loginSchema.default({ type, email: "", password: "" })),
),
authMethods,
};
};
export const actions = {
default: async ({ locals, request, cookies, url }) => {
const form = await superValidate(request, zod4(loginSchema));
if (!form.valid) {
return fail(400, { form });
}
const redirectParam = url.searchParams.get("redirect");
if (form.data.type === "otp") {
const req = await locals.pb
.collection("users")
.requestOTP(form.data.email);
return redirect(
303,
`/otp/${req.otpId}${redirectParam ? `?redirect=${encodeURIComponent(redirectParam)}` : ""}`,
);
} else if (form.data.type === "password") {
try {
await locals.pb
.collection("users")
.authWithPassword(form.data.email, form.data.password);
} catch (error: any) {
return message(
form,
{ type: "error", text: error.response.message },
{ status: 400 },
);
}
}
const redirectUrl = redirectParam ?? "/dashboard";
const cookie = locals.pb.authStore.getCookie();
cookies.set("pb_auth", cookie, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: !dev,
maxAge: 60 * 60 * 24 * 30,
});
redirect(303, redirectUrl);
},
};
<script lang="ts">
import { untrack } from "svelte";
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { superForm } from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
import { loginSchema } from "$lib/schemas/login";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import PocketBase from "pocketbase-sveltekit";
import { goto } from "$app/navigation";
import { page } from "$app/state";
let { data } = $props();
let authMethods = $derived(data.authMethods);
const redirect = page.url.searchParams.get("redirect");
const hasOAuth2 = $derived(
authMethods.oauth2.enabled && authMethods.oauth2.providers.length > 0,
);
const hasAuthMethods = $derived(
authMethods.password.enabled || authMethods.otp.enabled || hasOAuth2,
);
const form = superForm(
untrack(() => data.form),
{
validators: zod4Client(loginSchema),
},
);
const handleOAuth2 = (provider: string) => {
const pb = new PocketBase("/");
pb.collection("users")
.authWithOAuth2({ provider, createData: {} })
.then(() => goto("/dashboard"));
};
const { form: formData, message } = form;
</script>
<div class="h-full flex flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-sm flex-col gap-6">
<a href="/" class="flex items-center gap-2 self-center font-medium">
<div
class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"
>
<img src="/favicon.svg" alt="logo" class="size-4" />
</div>
{data.meta.appName}
</a>
<div class="flex flex-col gap-6">
<Card.Root>
{#if hasAuthMethods}
<Card.Header class="text-center">
<Card.Title class="text-xl">Welcome back</Card.Title>
{#if hasOAuth2}
<Card.Description>Choose a login method</Card.Description>
{:else}
<Card.Description>Use your email to login</Card.Description>
{/if}
</Card.Header>
{:else}
<Card.Header class="text-center">
<Card.Title class="text-xl">Login is disabled</Card.Title>
<Card.Description
>Check back later for login options</Card.Description
>
</Card.Header>
{/if}
<Card.Content>
<form method="POST">
<div class="grid gap-6">
{#if hasOAuth2}
<div class="flex flex-col gap-4">
{#each authMethods.oauth2.providers as provider}
<Button
variant="outline"
class="w-full"
onclick={() => handleOAuth2(provider.name)}
>
<img
src="/admin/_/images/oauth2/{provider.name}.svg"
class="size-5 bg-white p-0.5 rounded-sm"
alt=""
/>
Login with {provider.displayName}
</Button>
{/each}
</div>
{/if}
{#if authMethods.password.enabled || authMethods.otp.enabled}
{#if hasOAuth2}
<div
class="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t"
>
<span
class="bg-card text-muted-foreground relative z-10 px-2"
>
Or continue with
</span>
</div>
{/if}
<div class="grid gap-2">
<Form.Field {form} name="email" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Email</Form.Label>
<Input
{...props}
type="email"
bind:value={$formData.email}
required
/>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
{#if $formData.type === "password" && authMethods.password.enabled}
<Form.Field {form} name="password" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<div class="flex justify-between">
<Form.Label>Password</Form.Label>
<a
href="/reset"
class="ml-auto text-sm underline-offset-4 hover:underline"
>Forgot your password?</a
>
</div>
<Input
{...props}
type="password"
bind:value={$formData.password}
required
autocomplete="current-password"
/>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
{#if $message && $message.type === "error"}
<div class="text-destructive text-sm font-medium -mt-2">
{$message.text}
</div>
{/if}
<Button type="submit" class="w-full">Login</Button>
{#if authMethods.otp.enabled}
<Button
variant="outline"
class="w-full"
onclick={() => ($formData.type = "otp" as "password")}
>Use one-time code instead</Button
>
{/if}
{:else if $formData.type === "otp" && authMethods.otp.enabled}
<Button type="submit" class="w-full"
>Send one-time code</Button
>
{#if authMethods.password.enabled}
<Button
variant="outline"
class="w-full"
onclick={() => ($formData.type = "password" as "otp")}
>Continue with password</Button
>
{/if}
{/if}
</div>
<input type="hidden" name="type" bind:value={$formData.type} />
{/if}
<div class="text-center text-sm">
Don'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>
import { definePageMetaTags } from "svelte-meta-tags";
export const load = async ({ parent, data }) => {
await parent();
const { pageMetaTags } = definePageMetaTags({
title: "Log in",
});
return { ...data, pageMetaTags };
};
import { describe, it, expect } from "vitest";
import * as devalue from "devalue";
import type { Match } from "@velastack/pocketbase";
import type { RouteId } from "./$types";
describe("GET /login", () => {
it("should render the login page", async (context) => {
const response = await context.agent.get("/login" satisfies Match<RouteId>);
expect(response.status).toBe(200);
});
it("should redirect to the dashboard if the user is already authenticated", async (context) => {
await context.agent.authenticateUser();
const response = await context.agent.get("/login" satisfies Match<RouteId>);
expect(response.status).toBe(303);
expect(response.headers.location).toBe("/dashboard");
});
});
describe("POST /login", () => {
it("should login with password", async (context) => {
const response = await context.agent
.post("/login" satisfies Match<RouteId>)
.type("form")
.send({
type: "password",
email: context.user.email,
password: "password",
});
expect(response.body.status).toBe(303);
expect(response.body.location).toBe("/dashboard");
});
it("should error if the password is incorrect", async (context) => {
const response = await context.agent
.post("/login" satisfies Match<RouteId>)
.type("form")
.send({
type: "password",
email: context.user.email,
password: "incorrect",
});
const data = devalue.parse(response.body.data);
expect(response.body.status).toBe(400);
expect(data.form.message.text).toBe("Failed to authenticate.");
});
});
import { redirect } from "@sveltejs/kit";
export const POST = ({ cookies, locals }) => {
locals.pb.authStore.clear();
locals.pb.authStore.clearCookie(cookies);
throw redirect(303, "/");
};
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("/");
});
});
import { fail, message, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { otpSchema } from "$lib/schemas/otp";
import { redirect } from "@sveltejs/kit";
import { dev } from "$app/environment";
export const load = async ({ locals }) => {
if (locals.pb.authStore.isValid) {
redirect(303, "/dashboard");
}
return { form: await superValidate(zod4(otpSchema)) };
};
export const actions = {
default: async ({ locals, request, params, cookies, url }) => {
const form = await superValidate(request, zod4(otpSchema));
if (!form.valid) {
return fail(400, { form });
}
try {
await locals.pb
.collection("users")
.authWithOTP(params.token, form.data.otp);
} catch (error: any) {
return message(
form,
{ type: "error", text: error.response.message },
{ status: 400 },
);
}
const redirectUrl = url.searchParams.get("redirect") ?? "/dashboard";
const cookie = locals.pb.authStore.getCookie();
cookies.set("pb_auth", cookie, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: !dev,
maxAge: 60 * 60 * 24 * 30,
});
redirect(303, redirectUrl);
},
};
<script lang="ts">
import { untrack } from "svelte";
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { superForm } from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
import { otpSchema } from "$lib/schemas/otp";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
let { data } = $props();
const form = superForm(
untrack(() => data.form),
{
validators: zod4Client(otpSchema),
},
);
const { form: formData, message } = form;
</script>
<div class="flex flex-col flex-1 items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-sm flex-col gap-6">
<a href="/" class="flex items-center gap-2 self-center font-medium">
<div
class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"
>
<img src="/favicon.svg" alt="logo" class="size-4" />
</div>
{data.meta.appName}
</a>
<div class="flex flex-col gap-6">
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">Enter your OTP code</Card.Title>
<Card.Description>Enter the code sent to your email</Card.Description>
</Card.Header>
<Card.Content>
<form method="POST">
<div class="grid gap-6">
<div class="grid gap-2">
<Form.Field {form} name="otp" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>OTP code</Form.Label>
<Input
{...props}
type="text"
bind:value={$formData.otp}
required
/>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
{#if $message && $message.type === "error"}
<div class="text-destructive text-sm font-medium -mt-2">
{$message.text}
</div>
{/if}
<Button type="submit" class="w-full">Verify code</Button>
</div>
</div>
</form>
</Card.Content>
</Card.Root>
</div>
</div>
</div>
import { definePageMetaTags } from "svelte-meta-tags";
export const load = async ({ parent, data }) => {
await parent();
const { pageMetaTags } = definePageMetaTags({
title: "Verify code",
});
return { ...data, pageMetaTags };
};
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/pocketbase";
import type { RouteId } from "./$types";
describe("GET /otp/[token]", () => {
it("should render the otp page", async (context) => {
const response = await context.request.get(
"/otp/123456" satisfies Match<RouteId>,
);
expect(response.status).toBe(200);
});
});
describe("POST /otp/[token]", () => {
it("should throw an error if the otp is invalid", async (context) => {
const response = await context.agent
.post("/otp/123456" satisfies Match<RouteId>)
.type("form")
.send({ otp: "123456" });
expect(response.body.status).toBe(400);
});
});
import { fail, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { resetSchema } from "$lib/schemas/reset";
import { redirect } from "@sveltejs/kit";
import { setFlash } from "sveltekit-flash-message/server";
export const load = async ({ locals }) => {
if (locals.pb.authStore.isValid) {
redirect(303, "/dashboard");
}
return { form: await superValidate(zod4(resetSchema)) };
};
export const actions = {
default: async ({ locals, request, cookies }) => {
const form = await superValidate(request, zod4(resetSchema));
if (!form.valid) {
return fail(400, { form });
}
await locals.pb.collection("users").requestPasswordReset(form.data.email);
setFlash(
{
type: "toast",
message: "We sent a password reset link to your email.",
},
cookies,
);
return { form };
},
};
<script lang="ts">
import { untrack } from "svelte";
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { superForm } from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
import { resetSchema } from "$lib/schemas/reset";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
let { data } = $props();
const form = superForm(
untrack(() => data.form),
{
validators: zod4Client(resetSchema),
},
);
const { form: formData } = form;
</script>
<div class="flex flex-col flex-1 items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-sm flex-col gap-6">
<a href="/" class="flex items-center gap-2 self-center font-medium">
<div
class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"
>
<img src="/favicon.svg" alt="logo" class="size-4" />
</div>
{data.meta.appName}
</a>
<div class="flex flex-col gap-6">
<Card.Root>
<Card.Header class="text-center">
<Card.Title class="text-xl">Reset your password</Card.Title>
<Card.Description
>Enter your email to reset your password</Card.Description
>
</Card.Header>
<Card.Content>
<form method="POST">
<div class="grid gap-6">
<div class="grid gap-2">
<Form.Field {form} name="email" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Email</Form.Label>
<Input
{...props}
type="email"
bind:value={$formData.email}
required
/>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
<Button type="submit" class="w-full">Send reset link</Button>
</div>
<div class="text-center text-sm">
Remembered your password?
<a href="/login" class="underline underline-offset-4">Log in</a>
</div>
</div>
</form>
</Card.Content>
</Card.Root>
</div>
</div>
</div>
import { definePageMetaTags } from "svelte-meta-tags";
export const load = async ({ parent, data }) => {
await parent();
const { pageMetaTags } = definePageMetaTags({
title: "Reset password",
});
return { ...data, pageMetaTags };
};
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/pocketbase";
import type { RouteId } from "./$types";
describe("GET /reset", () => {
it("should render the reset page", async (context) => {
const response = await context.request.get(
"/reset" satisfies Match<RouteId>,
);
expect(response.status).toBe(200);
});
});
describe("POST /reset", () => {
it("should request a password reset", async (context) => {
const response = await context.agent
.post("/reset" satisfies Match<RouteId>)
.type("form")
.send({ email: context.user.email });
expect(response.body.status).toBe(200);
});
});
import { redirect } from "@sveltejs/kit";
import { fail, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { setFlash } from "sveltekit-flash-message/server";
import { setPocketbaseErrors } from "@velastack/pocketbase";
import { dev } from "$app/environment";
import { signupSchema } from "$lib/schemas/signup";
export const load = async ({ locals }) => {
const authMethods = await locals.admin.collection("users").listAuthMethods();
if (locals.pb.authStore.isValid) {
redirect(303, "/dashboard");
}
return { form: await superValidate(zod4(signupSchema)), authMethods };
};
export const actions = {
default: async ({ locals, request, cookies, url }) => {
const form = await superValidate(request, zod4(signupSchema));
if (!form.valid) {
return fail(400, { form });
}
let user;
try {
user = await locals.admin.collection("users").create({
email: form.data.email,
password: form.data.password,
passwordConfirm: form.data.passwordConfirm,
});
} catch (error) {
setPocketbaseErrors(form, error);
return fail(400, { form });
}
await locals.pb.collection("users").requestVerification(user.email);
await locals.pb
.collection("users")
.authWithPassword(form.data.email, form.data.password);
const redirectUrl = url.searchParams.get("redirect") ?? "/dashboard";
const cookie = locals.pb.authStore.getCookie();
cookies.set("pb_auth", cookie, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: !dev,
maxAge: 60 * 60 * 24 * 30,
});
setFlash(
{ type: "toast", message: "We sent a confirmation link to your email." },
cookies,
);
return redirect(303, redirectUrl);
},
};
<script lang="ts">
import { untrack } from "svelte";
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { superForm } from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
import { signupSchema } from "$lib/schemas/signup";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import PocketBase from "pocketbase-sveltekit";
import { goto } from "$app/navigation";
import { page } from "$app/state";
let { data } = $props();
let authMethods = $derived(data.authMethods);
const redirect = page.url.searchParams.get("redirect");
const hasOAuth2 = $derived(
authMethods.oauth2.enabled && authMethods.oauth2.providers.length > 0,
);
const hasAuthMethods = $derived(
authMethods.password.enabled || authMethods.otp.enabled || hasOAuth2,
);
const form = superForm(
untrack(() => data.form),
{
validators: zod4Client(signupSchema),
},
);
const handleOAuth2 = (provider: string) => {
const pb = new PocketBase("/");
pb.collection("users")
.authWithOAuth2({ provider, createData: {} })
.then(() => goto("/dashboard"));
};
const { form: formData } = form;
</script>
<div class="h-full flex flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-sm flex-col gap-6">
<a href="/" class="flex items-center gap-2 self-center font-medium">
<div
class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"
>
<img src="/favicon.svg" alt="logo" class="size-4" />
</div>
{data.meta.appName}
</a>
<div class="flex flex-col gap-6">
<Card.Root>
{#if hasAuthMethods}
<Card.Header class="text-center">
<Card.Title class="text-xl">Create an account</Card.Title>
{#if hasOAuth2}
<Card.Description>Choose a sign up method</Card.Description>
{:else}
<Card.Description>Use your email to sign up</Card.Description>
{/if}
</Card.Header>
{:else}
<Card.Header class="text-center">
<Card.Title class="text-xl">Signups are disabled</Card.Title>
<Card.Description
>Check back later for signup options</Card.Description
>
</Card.Header>
{/if}
<Card.Content>
<form method="POST">
<div class="grid gap-6">
{#if hasOAuth2}
<div class="flex flex-col gap-4">
{#each authMethods.oauth2.providers as provider}
<Button
variant="outline"
class="w-full"
onclick={() => handleOAuth2(provider.name)}
>
<img
src="/admin/_/images/oauth2/{provider.name}.svg"
class="size-5 bg-white p-0.5 rounded-sm"
alt=""
/>
Login with {provider.displayName}
</Button>
{/each}
</div>
{/if}
{#if authMethods.password.enabled}
{#if hasOAuth2}
<div
class="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t"
>
<span
class="bg-card text-muted-foreground relative z-10 px-2"
>
Or continue with
</span>
</div>
{/if}
<div class="grid gap-2">
<Form.Field {form} name="email" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Email</Form.Label>
<Input
{...props}
type="email"
bind:value={$formData.email}
required
/>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
<Form.Field {form} name="password" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Password</Form.Label>
<Input
{...props}
type="password"
bind:value={$formData.password}
required
autocomplete="new-password"
/>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
<Form.Field {form} name="passwordConfirm" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Confirm Password</Form.Label>
<Input
{...props}
type="password"
bind:value={$formData.passwordConfirm}
required
autocomplete="new-password"
/>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
<Button type="submit" class="w-full">Create account</Button>
</div>
{/if}
<div class="text-center text-sm">
Already have an account?
<a
href="/login{redirect
? `?redirect=${encodeURIComponent(redirect)}`
: ''}"
class="underline underline-offset-4"
>
Log in
</a>
</div>
</div>
</form>
</Card.Content>
</Card.Root>
<div
class="text-muted-foreground *:[a]:hover:text-primary *:[a]:underline *:[a]:underline-offset-4 text-balance text-center text-xs"
>
By clicking continue, you agree to our <a href="/terms"
>Terms of Service</a
>
and <a href="/privacy">Privacy Policy</a>.
</div>
</div>
</div>
</div>
import { definePageMetaTags } from "svelte-meta-tags";
export const load = async ({ parent, data }) => {
await parent();
const { pageMetaTags } = definePageMetaTags({
title: "Sign up",
});
return { ...data, pageMetaTags };
};
import { describe, it, expect } from "vitest";
import type { Match } from "@velastack/pocketbase";
import * as devalue from "devalue";
import type { RouteId } from "./$types";
describe("GET /signup", () => {
it("should render the signup page", async (context) => {
const response = await context.request.get(
"/signup" satisfies Match<RouteId>,
);
expect(response.status).toBe(200);
});
});
describe("POST /signup", () => {
it("should signup with password", async (context) => {
const response = await context.agent
.post("/signup" satisfies Match<RouteId>)
.type("form")
.send({
email: `test-${Math.random().toString(36).slice(2)}@example.com`,
password: "password",
passwordConfirm: "password",
});
expect(response.body.status).toBe(303);
expect(response.body.location).toBe("/dashboard");
});
it("should error if the password does not match", async (context) => {
const response = await context.agent
.post("/signup" satisfies Match<RouteId>)
.type("form")
.send({
email: `test-${Math.random().toString(36).slice(2)}@example.com`,
password: "password",
passwordConfirm: "password2",
});
expect(response.body.status).toBe(400);
const data = devalue.parse(response.body.data);
expect(data.form.errors.passwordConfirm).toBeDefined();
expect(data.form.errors.passwordConfirm[0]).toBe("Values don't match.");
});
it("should error if the email is already taken", async (context) => {
const response = await context.agent
.post("/signup" satisfies Match<RouteId>)
.type("form")
.send({
email: context.user.email,
password: "password",
passwordConfirm: "password",
});
expect(response.body.status).toBe(400);
const data = devalue.parse(response.body.data);
expect(data.form.errors.email).toBeDefined();
expect(data.form.errors.email[0]).toBe("Value must be unique.");
});
});
import {
POCKETBASE_URL,
POCKETBASE_SUPERUSER_EMAIL,
POCKETBASE_SUPERUSER_PASSWORD,
} from "$env/static/private";
import { handlePocketbase } from "@velastack/pocketbase";
export const handle = handlePocketbase({
pocketbaseUrl: POCKETBASE_URL,
superuserEmail: POCKETBASE_SUPERUSER_EMAIL,
superuserPassword: POCKETBASE_SUPERUSER_PASSWORD,
auth: { protectedRoutes: ["/(app)"] },
});
<script lang="ts">
import { toggleMode } from "mode-watcher";
import * as Navbar from "$lib/components/ui/navbar";
import { Button } from "$lib/components/ui/button";
import * as AuthMenu from "$lib/components/ui/auth-menu";
import * as Avatar from "$lib/components/ui/avatar";
let { children, data } = $props();
</script>
<div class="min-h-dvh grid grid-rows-[max-content_1fr_max-content] root-layout">
<Navbar.Root>
<Navbar.Brand href="/">
<Navbar.Logo src="/favicon.svg" alt="Logo" />
</Navbar.Brand>
<Navbar.MobileToggle />
<Navbar.List>
<Navbar.Item>
<Button
class="w-full justify-start md:justify-center md:w-auto"
href="/"
variant="ghost"
>
Home
</Button>
</Navbar.Item>
<Navbar.Item
class="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-4"
>
<AuthMenu.Root>
{#snippet user()}
<AuthMenu.Trigger>
<Avatar.Root>
{#if data.user?.avatar}
<Avatar.Image
src="/api/files/users/{data.user?.id}/{data.user?.avatar}"
alt={data.user?.email}
/>
{/if}
<Avatar.Fallback>
{data.user?.email?.charAt(0).toUpperCase()}
</Avatar.Fallback>
</Avatar.Root>
</AuthMenu.Trigger>
<AuthMenu.Content align="end">
<AuthMenu.Item href="/dashboard">Dashboard</AuthMenu.Item>
<AuthMenu.Group>
<AuthMenu.Item href="/settings">Settings</AuthMenu.Item>
<AuthMenu.Item>
<form action="/logout" method="post">
<button type="submit" class="contents">Logout</button>
</form>
</AuthMenu.Item>
</AuthMenu.Group>
</AuthMenu.Content>
{/snippet}
{#snippet guest()}
<Button
class="w-full justify-start md:justify-center md:w-auto"
variant="ghost"
href="/login">Login</Button
>
<Button
class="w-full justify-start md:justify-center md:w-auto"
variant="outline"
href="/signup">Sign up</Button
>
{/snippet}
</AuthMenu.Root>
</Navbar.Item>
</Navbar.List>
</Navbar.Root>
{@render children?.()}
<footer class="border-t border-border py-4">
<div
class="mx-auto flex max-w-7xl px-4 flex-col items-center justify-between gap-3 sm:flex-row"
>
<p class="text-muted-foreground text-sm">
© {new Date().getFullYear()}
{data.meta.appName}. All rights reserved.
</p>
<nav class="flex items-center gap-4 text-sm">
<Button onclick={toggleMode} variant="ghost" size="icon">
<Navbar.Mode />
</Button>
<a href="/privacy" class="underline-offset-4 hover:underline">Privacy</a
>
<a href="/terms" class="underline-offset-4 hover:underline">Terms</a>
</nav>
</div>
</footer>
</div>
<style lang="postcss">
@reference "tailwindcss";
.root-layout :global(section[data-role="content"]) {
@apply container mx-auto max-w-7xl px-4 py-8 w-full;
}
</style>
import { loadFlash } from "sveltekit-flash-message/server";
export const load = loadFlash(async ({ locals }) => {
return {
user: locals.pb.authStore.record,
};
});
oauth_accounts