Add WhatsApp sign-in to your authenticated app. Puts a phone number option next to email on the login and signup pages, sends a one-time code on WhatsApp, and scaffolds the /whatsapp/[token] code page. Adds phone and phoneVerified fields to users and makes email optional, so people can sign up with only a phone number. Codes go out through the pocketbase-whatsapp plugin that velabase ships, from VelaStack's number with a project key or from your own WhatsApp Business account. Requires auth to be enabled first.
/**
* WhatsApp one-time code auth, from the pocketbase-whatsapp plugin that
* velabase ships. The SDK has no methods for it, so these call the plugin
* routes directly.
*/
/**
* The `whatsapp` entry the plugin adds to `listAuthMethods()`.
*
* Vanilla PocketBase has no such entry, which reads as disabled.
*/
export type WhatsAppAuthMethod = {
enabled: boolean;
/**
* Whether unknown numbers can sign up. Plugin versions without the flag
* leave it out, which hides WhatsApp on the signup page.
*/
allowSignup?: boolean;
codeLength: number;
duration: number;
};
export function whatsappAuthMethod(authMethods: object): WhatsAppAuthMethod {
const whatsapp = (authMethods as { whatsapp?: WhatsAppAuthMethod }).whatsapp;
return whatsapp ?? { enabled: false, codeLength: 6, duration: 300 };
}
/**
* Sends a code to `phone` (international format, eg. +16165550123).
*
* Unknown numbers still get an `otpId` when signup is off, so the response
* never tells whether the number has an account.
*/
export async function requestWhatsAppOTP(
pb: App.Locals["pb"],
phone: string,
lang?: string,
): Promise<{ otpId: string }> {
return pb.send("/api/collections/users/request-whatsapp-otp", {
method: "POST",
body: { phone, ...(lang ? { lang } : {}) },
});
}
/**
* Checks the code and signs `pb` in. `isNew` is true when the code created
* the account (signup on), so the caller can run its new user setup.
*/
export async function authWithWhatsApp(
pb: App.Locals["pb"],
otpId: string,
code: string,
): Promise<{ isNew: boolean }> {
const auth = await pb.send("/api/collections/users/auth-with-whatsapp", {
method: "POST",
body: { otpId, password: code },
});
// Unlike the SDK auth methods, `send` leaves the auth store alone.
pb.authStore.save(auth.token, auth.record);
return { isNew: !!auth.meta?.isNew };
}
import { fail, message, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { otpSchema } from "#lib/schemas/otp.js";
import { authWithWhatsApp } from "#lib/server/whatsapp.js";
import { redirect } from "@sveltejs/kit";
import { dev } from "$app/env";
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 });
}
let isNew: boolean;
try {
// `isNew` is true when this code created the account.
({ isNew } = await authWithWhatsApp(
locals.pb,
params.token,
form.data.otp,
));
} catch (error: any) {
return message(
form,
{
type: "error",
text: error.response?.message ?? "Failed to verify the code.",
},
{ status: 400 },
);
}
// Follow only a same-site path (`/x`, not `//host` or `/\host`):
// `redirect()` throws on an external URL.
const next = url.searchParams.get("redirect");
const redirectUrl = next && /^\/(?![/\\])/.test(next) ? next : "/dashboard";
const cookie = locals.pb.authStore.getCookie();
cookies.set("pb_auth", cookie, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: !dev,
maxAge: 60 * 60 * 24 * 30,
});
// A new account has only the phone number: ask for a name first.
redirect(
303,
isNew
? `/welcome?redirect=${encodeURIComponent(redirectUrl)}`
: redirectUrl,
);
},
};
<script lang="ts">
import favicon from "#lib/assets/favicon.svg";
import { site } from "#lib/site.js";
import { untrack } from "svelte";
import { Button } from "#lib/components/ui/button/index.js";
import * as Card from "#lib/components/ui/card/index.js";
import { superForm } from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
import { otpSchema } from "#lib/schemas/otp.js";
import * as Form from "#lib/components/ui/form/index.js";
import { Input } from "#lib/components/ui/input/index.js";
let { data } = $props();
const form = superForm(
untrack(() => data.form),
{
validators: zod4Client(otpSchema),
},
);
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} 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">Check your WhatsApp</Card.Title>
<Card.Description
>Enter the code we sent to your WhatsApp</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>Code</Form.Label>
<Input
{...props}
type="text"
inputmode="numeric"
autocomplete="one-time-code"
bind:value={$formData.otp}
required
autofocus
/>
{/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/kit";
import type { RouteId } from "./$types";
describe("GET /whatsapp/[token]", () => {
it("should render the code page", async (context) => {
const response = await context.request.get(
"/whatsapp/123456" satisfies Match<RouteId>,
);
expect(response.status).toBe(200);
});
});
describe("POST /whatsapp/[token]", () => {
it("should throw an error if the code is invalid", async (context) => {
const response = await context.agent
.post("/whatsapp/123456" satisfies Match<RouteId>)
.type("form")
.send({ otp: "123456" });
expect(response.body.status).toBe(400);
});
});
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(),
});
const whatsappSchema = z.object({
type: z.literal("whatsapp"),
phone: z.string().min(1),
});
export const loginSchema = z.discriminatedUnion("type", [
otpSchema,
passwordSchema,
oAuth2Schema,
whatsappSchema,
]);
import { z } from "zod";
const passwordSchema = z.object({
type: z.literal("password"),
email: z.email(),
password: z.string(),
passwordConfirm: z.string(),
});
const otpSchema = z.object({
type: z.literal("otp"),
email: z.email(),
});
const whatsappSchema = z.object({
type: z.literal("whatsapp"),
phone: z.string().min(1),
});
export const signupSchema = z.discriminatedUnion("type", [
passwordSchema,
otpSchema,
whatsappSchema,
]);
import { fail, superValidate, message } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { setPocketbaseErrors } from "@velastack/pocketbase/form";
import { loginSchema } from "#lib/schemas/login.js";
import {
requestWhatsAppOTP,
whatsappAuthMethod,
} from "#lib/server/whatsapp.js";
import { redirect } from "@sveltejs/kit";
import { dev } from "$app/env";
export const load = async ({ locals }) => {
if (locals.pb.authStore.isValid) {
redirect(303, "/dashboard");
}
const authMethods = await locals.admin.collection("users").listAuthMethods();
const whatsapp = whatsappAuthMethod(authMethods);
const type: "password" | "otp" | "oauth2" | "whatsapp" = authMethods.otp
.enabled
? "otp"
: authMethods.password.enabled
? "password"
: whatsapp.enabled
? "whatsapp"
: "oauth2";
return {
form: await superValidate(
zod4(loginSchema.default({ type, email: "", password: "", phone: "" })),
),
authMethods,
whatsapp,
};
};
export const actions = {
default: async ({ locals, request, cookies, url }) => {
const form = await superValidate(request, zod4(loginSchema));
if (!form.valid) {
return fail(400, { form });
}
// Follow only a same-site path (`/x`, not `//host` or `/\host`):
// `redirect()` throws on an external URL.
const next = url.searchParams.get("redirect");
const redirectParam = next && /^\/(?![/\\])/.test(next) ? next : null;
const redirectQuery = redirectParam
? `?redirect=${encodeURIComponent(redirectParam)}`
: "";
if (form.data.type === "otp") {
const req = await locals.pb
.collection("users")
.requestOTP(form.data.email);
return redirect(303, `/otp/${req.otpId}${redirectQuery}`);
} else if (form.data.type === "whatsapp") {
let otpId: string;
try {
({ otpId } = await requestWhatsAppOTP(locals.pb, form.data.phone));
} catch (error: any) {
// An invalid number goes on the field; the rest (WhatsApp auth
// disabled, rate limited) on the form.
if (Object.keys(error.response?.data ?? {}).length) {
setPocketbaseErrors(form, error);
return fail(400, { form });
}
return message(
form,
{
type: "error",
text: error.response?.message ?? "Failed to send the code.",
},
{ status: 400 },
);
}
return redirect(303, `/whatsapp/${otpId}${redirectQuery}`);
} 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 favicon from "#lib/assets/favicon.svg";
import { site } from "#lib/site.js";
import { untrack } from "svelte";
import { Button } from "#lib/components/ui/button/index.js";
import * as Card from "#lib/components/ui/card/index.js";
import { superForm } from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
import { loginSchema } from "#lib/schemas/login.js";
import * as Form from "#lib/components/ui/form/index.js";
import { Input } from "#lib/components/ui/input/index.js";
import PocketBase from "pocketbase-sveltekit";
import { goto } from "$app/navigation";
import { page } from "$app/state";
let { data } = $props();
let authMethods = $derived(data.authMethods);
let whatsapp = $derived(data.whatsapp);
const redirect = page.url.searchParams.get("redirect");
const hasOAuth2 = $derived(
authMethods.oauth2.enabled && authMethods.oauth2.providers.length > 0,
);
const hasEmail = $derived(
authMethods.password.enabled || authMethods.otp.enabled,
);
const hasAuthMethods = $derived(hasEmail || whatsapp.enabled || hasOAuth2);
// Where "Continue with email" goes back to from WhatsApp.
const emailType = $derived(authMethods.otp.enabled ? "otp" : "password");
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} 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 || (hasEmail && whatsapp.enabled)}
<Card.Description>Choose a login method</Card.Description>
{:else if whatsapp.enabled}
<Card.Description
>Use your WhatsApp number to login</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 hasEmail || whatsapp.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">
{#if $formData.type === "whatsapp" && whatsapp.enabled}
<Form.Field {form} name="phone" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>WhatsApp number</Form.Label>
<Input
{...props}
type="tel"
bind:value={$formData.phone}
required
autocomplete="tel"
placeholder="+1 616 555 0123"
autofocus
/>
{/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"
>Send code on WhatsApp</Button
>
{#if hasEmail}
<Button
variant="outline"
class="w-full"
onclick={() =>
($formData.type = emailType as "whatsapp")}
>Continue with email</Button
>
{/if}
{:else if hasEmail && $formData.type !== "whatsapp"}
<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
autocomplete="username"
autofocus
/>
{/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="grid grid-cols-[1fr_auto] gap-2">
<Form.Label>Password</Form.Label>
<Input
{...props}
type="password"
bind:value={$formData.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>
{/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}
{#if whatsapp.enabled}
<Button
variant="outline"
class="w-full"
onclick={() =>
($formData.type = "whatsapp" as "password")}
>Continue with WhatsApp</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 { redirect } from "@sveltejs/kit";
import { fail, message, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { setFlash } from "sveltekit-flash-message/server";
import { setPocketbaseErrors } from "@velastack/pocketbase/form";
import { dev } from "$app/env";
import { signupSchema } from "#lib/schemas/signup.js";
import {
requestWhatsAppOTP,
whatsappAuthMethod,
} from "#lib/server/whatsapp.js";
export const load = async ({ locals }) => {
const authMethods = await locals.admin.collection("users").listAuthMethods();
if (locals.pb.authStore.isValid) {
redirect(303, "/dashboard");
}
const whatsapp = whatsappAuthMethod(authMethods);
const whatsappSignup = whatsapp.enabled && whatsapp.allowSignup === true;
const type: "password" | "otp" | "whatsapp" = authMethods.password.enabled
? "password"
: authMethods.otp.enabled
? "otp"
: whatsappSignup
? "whatsapp"
: "password";
return {
form: await superValidate(
zod4(
signupSchema.default({
type,
email: "",
password: "",
passwordConfirm: "",
phone: "",
}),
),
),
authMethods,
whatsappSignup,
};
};
export const actions = {
default: async ({ locals, request, cookies, url }) => {
const form = await superValidate(request, zod4(signupSchema));
if (!form.valid) {
return fail(400, { form });
}
// Follow only a same-site path (`/x`, not `//host` or `/\host`):
// `redirect()` throws on an external URL.
const next = url.searchParams.get("redirect");
const redirectParam = next && /^\/(?![/\\])/.test(next) ? next : null;
const redirectQuery = redirectParam
? `?redirect=${encodeURIComponent(redirectParam)}`
: "";
if (form.data.type === "whatsapp") {
// The account is created when the code is checked.
let otpId: string;
try {
({ otpId } = await requestWhatsAppOTP(locals.pb, form.data.phone));
} catch (error: any) {
// An invalid number goes on the field; the rest (WhatsApp auth
// disabled, rate limited) on the form.
if (Object.keys(error.response?.data ?? {}).length) {
setPocketbaseErrors(form, error);
return fail(400, { form });
}
return message(
form,
{
type: "error",
text: error.response?.message ?? "Failed to send the code.",
},
{ status: 400 },
);
}
return redirect(303, `/whatsapp/${otpId}${redirectQuery}`);
}
if (form.data.type === "otp") {
// PocketBase only sends codes to existing accounts, so create it first,
// with a password nobody knows (a reset sets a real one). Checking the
// code marks the email verified.
const password = crypto.randomUUID();
try {
await locals.admin.collection("users").create({
email: form.data.email,
password,
passwordConfirm: password,
});
} catch (error: any) {
// An existing account gets a code too, the same as logging in with one.
if (error.response?.data?.email?.code !== "validation_not_unique") {
setPocketbaseErrors(form, error);
return fail(400, { form });
}
}
let otpId: string;
try {
({ otpId } = await locals.pb
.collection("users")
.requestOTP(form.data.email));
} catch (error: any) {
return message(
form,
{
type: "error",
text: error.response?.message ?? "Failed to send the code.",
},
{ status: 400 },
);
}
// A new account has no name yet: ask for one once the code checks out.
const welcome = `/welcome${redirectQuery}`;
return redirect(
303,
`/otp/${otpId}?redirect=${encodeURIComponent(welcome)}`,
);
}
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 = 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,
});
setFlash(
{ type: "toast", message: "We sent a confirmation link to your email." },
cookies,
);
return redirect(303, redirectUrl);
},
};
<script lang="ts">
import favicon from "#lib/assets/favicon.svg";
import { site } from "#lib/site.js";
import { untrack } from "svelte";
import { Button } from "#lib/components/ui/button/index.js";
import * as Card from "#lib/components/ui/card/index.js";
import { superForm } from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
import { signupSchema } from "#lib/schemas/signup.js";
import * as Form from "#lib/components/ui/form/index.js";
import { Input } from "#lib/components/ui/input/index.js";
import PocketBase from "pocketbase-sveltekit";
import { goto } from "$app/navigation";
import { page } from "$app/state";
let { data } = $props();
let authMethods = $derived(data.authMethods);
let whatsappSignup = $derived(data.whatsappSignup);
const redirect = page.url.searchParams.get("redirect");
const hasOAuth2 = $derived(
authMethods.oauth2.enabled && authMethods.oauth2.providers.length > 0,
);
const hasEmail = $derived(
authMethods.password.enabled || authMethods.otp.enabled,
);
const hasAuthMethods = $derived(hasEmail || whatsappSignup || hasOAuth2);
// Where "Continue with email" goes back to from WhatsApp.
const emailType = $derived(authMethods.password.enabled ? "password" : "otp");
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, 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} 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 || (hasEmail && whatsappSignup)}
<Card.Description>Choose a sign up method</Card.Description>
{:else if whatsappSignup}
<Card.Description
>Use your WhatsApp number to sign up</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=""
/>
Sign up with {provider.displayName}
</Button>
{/each}
</div>
{/if}
{#if hasEmail || whatsappSignup}
{#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">
{#if $formData.type === "whatsapp" && whatsappSignup}
<Form.Field {form} name="phone" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>WhatsApp number</Form.Label>
<Input
{...props}
type="tel"
bind:value={$formData.phone}
required
autocomplete="tel"
placeholder="+1 616 555 0123"
autofocus
/>
{/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"
>Send code on WhatsApp</Button
>
{#if hasEmail}
<Button
variant="outline"
class="w-full"
onclick={() =>
($formData.type = emailType as "whatsapp")}
>Continue with email</Button
>
{/if}
{:else if hasEmail && $formData.type !== "whatsapp"}
<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
autocomplete="username"
autofocus
/>
{/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 })}
<Form.Label>Password</Form.Label>
<Input
{...props}
type="password"
bind:value={$formData.password}
required
autocomplete="new-password"
minlength={8}
/>
{/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"
minlength={8}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
<Button type="submit" class="w-full"
>Create account</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}
{#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"
>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}
{#if whatsappSignup}
<Button
variant="outline"
class="w-full"
onclick={() =>
($formData.type = "whatsapp" as "password")}
>Continue with WhatsApp</Button
>
{/if}
{/if}
</div>
<input type="hidden" name="type" bind:value={$formData.type} />
{/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>