Logo VelaStack

Enable WhatsApp sign-in

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.

$ vela enable whatsapp
src/lib/server/whatsapp.ts
/**
 * 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 };
}
src/routes/(public)/(auth)/whatsapp/[token]/+page.server.ts
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,
    );
  },
};
src/routes/(public)/(auth)/whatsapp/[token]/+page.svelte
<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>
src/routes/(public)/(auth)/whatsapp/[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)/whatsapp/[token]/server.test.ts
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);
  });
});