diff --git a/apps/docs/content/docs/offer.mdx b/apps/docs/content/docs/offer.mdx
index 8221cf8..4536de4 100644
--- a/apps/docs/content/docs/offer.mdx
+++ b/apps/docs/content/docs/offer.mdx
@@ -7,12 +7,14 @@ To stay up-to-date with the current progress of Better-Auth-Kit, you can track o
## Plugins
-- [Waitlist](/docs/plugins/waitlist) - A plugin to create a waitlist for your users. (Coming Soon)
- [Reverify](/docs/plugins/reverify) - A plugin to reverify a user's identity.
-- [Legal Consent](/docs/plugins/legal-consent) - A plugin to collect legal consent from your users. (Coming Soon)
+- [Feedback](/docs/plugins/feedback) - A plugin to collect feedback from your users.
+- [Legal Consent](/docs/plugins/legal-consent) - A plugin to collect legal consent from your users.
+- [App Invite](/docs/plugins/app-invite) - A plugin to invite users to your application.
+- [Onboarding](/docs/plugins/onboarding) - A plugin to add onboarding to your authentication flow.
+- [Waitlist](/docs/plugins/waitlist) - A plugin to create a waitlist for your users. (Coming Soon)
- [Blockade](/docs/plugins/blockade) - A plugin to blacklist or whitelist users from accessing your application. (Coming Soon)
- [Shutdown](/docs/plugins/shutdown) - A plugin to stop signins or signups at any moment, such as for maintenance. (Coming Soon)
-- [Feedback](/docs/plugins/feedback) - A plugin to collect feedback from your users.
## Libraries
diff --git a/apps/docs/content/docs/plugins/onboarding.mdx b/apps/docs/content/docs/plugins/onboarding.mdx
new file mode 100644
index 0000000..a3ab087
--- /dev/null
+++ b/apps/docs/content/docs/plugins/onboarding.mdx
@@ -0,0 +1,403 @@
+---
+title: Onboarding
+description: Easily add onboarding to your authentication flow.
+---
+
+
+
+
+
+The Onboarding plugin allows you to create multi-step onboarding flows for new users. It automatically tracks completion status, enforces step requirements, and integrates seamlessly with your authentication flow.
+
+## Features
+
+- **Multi-step onboarding flows** with custom validation
+- **Automatic completion tracking** per user
+- **Required step enforcement** before marking onboarding complete
+- **One-time step protection** to prevent duplicate completions
+- **Built-in presets** for common onboarding scenarios
+- **Client-side integration** with automatic redirects
+
+## Installation
+
+
+
+ ### Install the plugin
+
+ ```package-install
+ @better-auth-kit/onboarding
+ ```
+
+
+
+
+ ### Add the plugin to your auth config
+
+ To use the Onboarding plugin, add it to your auth config.
+
+ ```ts title="auth.ts"
+ import { betterAuth } from "better-auth";
+ import { onboarding, createOnboardingStep } from "@better-auth-kit/onboarding";
+ import { z } from "zod";
+
+ export const auth = betterAuth({
+ plugins: [
+ onboarding({
+ steps: {
+ profile: createOnboardingStep({
+ input: z.object({
+ firstName: z.string().min(1),
+ lastName: z.string().min(1),
+ bio: z.string().optional(),
+ }),
+ async handler(ctx) {
+ // Update user profile
+ return { success: true };
+ },
+ required: true,
+ }),
+ preferences: createOnboardingStep({
+ input: z.object({
+ theme: z.enum(["light", "dark", "auto"]),
+ notifications: z.boolean(),
+ }),
+ async handler(ctx) {
+ // Save user preferences
+ return { success: true };
+ },
+ }),
+ },
+ completionStep: "preferences",
+ }),
+ ],
+ });
+ ```
+
+
+
+
+ ### Add the client plugin
+
+ Include the client plugin in your auth client instance.
+
+ ```ts title="auth-client.ts"
+ import { createAuthClient } from "better-auth/client";
+ import { onboardingClient } from "@better-auth-kit/onboarding/client";
+ import type { auth } from "./your/path"; // Import as type
+
+ const authClient = createAuthClient({
+ plugins: [
+ onboardingClient({
+ onOnboardingRedirect: () => {
+ window.location.href = "/onboarding";
+ },
+ }),
+ ],
+ });
+ ```
+
+
+
+
+ ### Run migrations
+
+ This plugin adds additional fields to the user table. [Click here to see the schema](#schema)
+
+
+
+ ```package-install
+ npx @better-auth/cli migrate
+ ```
+
+
+ ```package-install
+ npx @better-auth/cli generate
+ ```
+
+
+
+ Learn more about the migrate/generate commands [here](https://www.better-auth.com/docs/concepts/cli#generate).
+
+
+
+
+## Usage
+
+The Onboarding plugin provides several endpoints to manage the onboarding flow. Users can check if they need onboarding, complete steps, and verify their progress.
+
+### Check if User Needs Onboarding
+
+Use the `shouldOnboard` function to check if a user needs to complete onboarding.
+
+```ts title="client"
+// Check if user needs onboarding
+const { data: needsOnboarding } = await authClient.onboarding.shouldOnboard();
+
+if (needsOnboarding) {
+ // Redirect to onboarding flow
+ router.push("/onboarding");
+}
+```
+
+```ts title="server"
+// Check if user needs onboarding
+const needsOnboarding = await auth.api.shouldOnboard();
+
+if (needsOnboarding) {
+ // Redirect to onboarding flow
+ redirect("/onboarding");
+}
+```
+
+### Complete Onboarding Step
+
+Use the `onboardingStep` function to complete a specific onboarding step. The step name is derived from your step configuration.
+
+```ts title="client"
+// Complete profile step
+const { data, error } = await authClient.onboarding.step.profile({
+ firstName: "John",
+ lastName: "Doe",
+ bio: "Software developer",
+});
+
+if (error) {
+ console.error("Failed to complete profile step:", error);
+} else {
+ console.log("Completed steps:", data.completedSteps);
+}
+
+const success = data.data.success;
+```
+
+```ts title="server"
+// Complete profile step
+const data = await auth.api.onboardingStepProfile({
+ body: {
+ firstName: "John",
+ lastName: "Doe",
+ bio: "Software developer",
+ }
+});
+
+const success = data.success;
+```
+
+### Check Step Access
+
+Use the `canAccessOnboardingStep` function to check if a user can access a specific step. This is useful for preventing access to steps that shouldn't be available.
+
+```ts title="server"
+// Check if user can access preferences step
+const canAccess = await auth.api.canAccessOnboardingStepPreferences();
+```
+
+### Skip Completion Step
+
+For optional completion steps, users can skip them if they're not required. This is useful when the completion step is optional but you still want to mark onboarding as complete.
+
+```ts title="client"
+// Skip the completion step (only available for non-required completion steps)
+const { data, error } = await authClient.onboarding.skipStep.preferences();
+```
+
+```ts title="server"
+// Skip the completion step (only available for non-required completion steps)
+const data = await auth.api.skipOnboardingStepPreferences();
+```
+
+### Handle Onboarding Redirects
+
+The plugin automatically handles onboarding redirects when users sign up or get their session. Configure the redirect behavior in the client plugin.
+
+```ts title="auth-client.ts"
+onboardingClient({
+ onOnboardingRedirect: () => {
+ // Custom redirect logic
+ window.location.href = "/onboarding";
+ },
+})
+```
+
+## Defining Steps
+
+Steps are defined using the `createOnboardingStep` function. Each step requires a handler function and can include input validation and completion rules.
+
+```ts
+import { createOnboardingStep } from "@better-auth-kit/onboarding";
+
+const step = createOnboardingStep({
+ async handler(ctx) {
+ // process step
+ },
+ // other configuration
+});
+```
+
+### Options
+
+- **input**: `ZodSchema` - Zod schema for request body validation
+- **handler**: `(ctx: GenericEndpointContext) => R | Promise` - Function that processes the step
+- **once**: `boolean` - If `true`, step can only be completed once (default: `true`)
+- **required**: `boolean` - If `true`, step must be completed before onboarding is done
+- **requireHeader**: `boolean` - If `true`, headers are required in context
+- **requireRequest**: `boolean` - If `true`, request object is required
+- **cloneRequest**: `boolean` - Clone the request object from router
+
+### Example
+
+```ts
+import { createOnboardingStep } from "@better-auth-kit/onboarding";
+
+const profileStep = createOnboardingStep({
+ input: z.object({
+ firstName: z.string().min(1),
+ lastName: z.string().min(1),
+ }),
+ async handler(ctx) {
+ const { firstName, lastName } = ctx.body;
+
+ await ctx.context.internalAdapter.updateUser(ctx.context.session!.user.id, {
+ firstName,
+ name: lastName,
+ });
+
+ return { success: true };
+ },
+ required: true,
+});
+```
+
+## Presets
+
+### Setup New Password
+
+```ts
+import { setupNewPasswordStep } from "@better-auth-kit/onboarding/presets";
+
+onboarding({
+ steps: {
+ newPassword: setupNewPasswordStep({
+ required: true,
+ passwordSchema: {
+ minLength: 12,
+ maxLength: 128,
+ },
+ }),
+ },
+ completionStep: "newPassword",
+});
+```
+
+### Setup 2FA
+
+```ts
+import { setup2FAStep } from "@better-auth-kit/onboarding/presets";
+
+onboarding({
+ steps: {
+ twoFactor: setup2FAStep({
+ required: true,
+ }),
+ },
+ completionStep: "twoFactor",
+});
+```
+
+## Schema
+
+The plugin adds additional fields to the `user` table.
+
+
+
+Fields:
+
+- `shouldOnboard`: Whether the user needs to complete onboarding
+- `completedSteps`: JSON string array of completed step IDs
+
+## Options
+
+**steps**: `Record` - Object mapping step IDs to step configurations. Each step defines the input validation, handler function, and completion rules.
+
+**completionStep**: `keyof Steps` - The step ID that marks onboarding as complete. Once this step is completed, the user's `shouldOnboard` field is set to `false`.
+
+**autoEnableOnSignUp**: `boolean | ((ctx: GenericEndpointContext) => boolean | Promise)` - Whether to automatically enable onboarding for new users during sign up. (default: `true`).
+
+**secondaryStorage**: `boolean` - Whether to use secondary storage instead of the database. (default: `false`).
+
+**schema**: `InferOptionSchema` - Custom schema configuration for renaming fields or adding additional configuration.
+
+## Best Practices
+
+### 1. Progressive Disclosure
+
+Break down onboarding into logical, digestible steps:
+
+```ts
+const steps = {
+ welcome: createOnboardingStep({ /* welcome step */ }),
+ profile: createOnboardingStep({ /* basic profile */ }),
+ preferences: createOnboardingStep({ /* user preferences */ }),
+ verification: createOnboardingStep({ /* email/phone verification */ }),
+};
+```
+
+### 2. Required vs Optional Steps
+
+Use the `required` flag to distinguish between essential and optional steps:
+
+```ts
+const steps = {
+ terms: createOnboardingStep({ required: true }), // Must complete
+ profile: createOnboardingStep({ required: true }), // Must complete
+ preferences: createOnboardingStep({ required: false }), // Optional
+};
+```
+
+### 3. Input Validation
+
+Always validate user input with Zod schemas:
+
+```ts
+createOnboardingStep({
+ input: z.object({
+ email: z.string().email("Invalid email address"),
+ phone: z.string().regex(/^\+?[\d\s-()]+$/, "Invalid phone number"),
+ }).refine(data => data.email || data.phone, {
+ message: "Either email or phone is required",
+ path: ["email"],
+ }),
+ async handler(ctx) {
+ // ...
+ },
+});
+```
+
+### 4. Secondary Storage
+
+Use secondary storage instead of the main database.
+
+```ts
+onboarding({
+ secondaryStorage: true,
+ // ...
+});
+```
+
+## Shoutout
+
+This plugin was built by jslno! ❤️
\ No newline at end of file
diff --git a/apps/docs/src/components/sidebar-content.tsx b/apps/docs/src/components/sidebar-content.tsx
index 0f1586e..c4ac0f9 100644
--- a/apps/docs/src/components/sidebar-content.tsx
+++ b/apps/docs/src/components/sidebar-content.tsx
@@ -22,6 +22,7 @@ import {
Book,
User,
UserPlus,
+ DoorOpen
} from "lucide-react";
import type { Content } from "./sidebar";
@@ -73,6 +74,11 @@ export const contents: Content[] = [
title: "App Invite",
icon: () => ,
},
+ {
+ href: "/docs/plugins/onboarding",
+ title: "Onboarding",
+ icon: () => ,
+ },
{
href: "/docs/plugins/blockade",
title: "Blockade",
diff --git a/bun.lock b/bun.lock
index fb74438..50da399 100644
--- a/bun.lock
+++ b/bun.lock
@@ -232,7 +232,7 @@
},
"packages/plugins/app-invite": {
"name": "@better-auth-kit/app-invite",
- "version": "0.1.0",
+ "version": "0.1.2",
"dependencies": {
"zod": "^3.24.2",
},
@@ -247,7 +247,7 @@
},
"packages/plugins/feedback": {
"name": "@better-auth-kit/feedback",
- "version": "0.1.1",
+ "version": "0.1.3",
"dependencies": {
"zod": "^3.24.2",
},
@@ -290,6 +290,23 @@
"better-auth": "^1.2.7",
},
},
+ "packages/plugins/onboarding": {
+ "name": "@better-auth-kit/onboarding",
+ "version": "0.1.0",
+ "dependencies": {
+ "dotenv": "^16.5.0",
+ "file-type": "^20.5.0",
+ "zod": "^3.24.2",
+ },
+ "devDependencies": {
+ "@better-auth-kit/internal-build": "workspace:*",
+ "@better-auth-kit/tests": "workspace:*",
+ "vitest": "^3.0.8",
+ },
+ "peerDependencies": {
+ "better-auth": "^1.1.21",
+ },
+ },
"packages/plugins/profile-image": {
"name": "@better-auth-kit/profile-image",
"version": "0.1.1",
@@ -312,9 +329,11 @@
"packages/plugins/reverify": {
"name": "@better-auth-kit/reverify",
"version": "1.0.3",
+ "dependencies": {
+ "@better-auth-kit/internal-utils": "workspace:*",
+ },
"devDependencies": {
"@better-auth-kit/internal-build": "workspace:*",
- "@better-auth-kit/internal-utils": "workspace:*",
"@better-auth-kit/tests": "workspace:*",
"vitest": "^3.0.8",
},
@@ -427,6 +446,8 @@
"@better-auth-kit/legal-consent": ["@better-auth-kit/legal-consent@workspace:packages/plugins/legal-consent"],
+ "@better-auth-kit/onboarding": ["@better-auth-kit/onboarding@workspace:packages/plugins/onboarding"],
+
"@better-auth-kit/profile-image": ["@better-auth-kit/profile-image@workspace:packages/plugins/profile-image"],
"@better-auth-kit/reverify": ["@better-auth-kit/reverify@workspace:packages/plugins/reverify"],
@@ -2423,6 +2444,8 @@
"@better-auth-kit/fs-uploadthing/dotenv": ["dotenv@16.5.0", "", {}, "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg=="],
+ "@better-auth-kit/onboarding/dotenv": ["dotenv@16.5.0", "", {}, "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg=="],
+
"@better-auth-kit/profile-image/dotenv": ["dotenv@16.5.0", "", {}, "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg=="],
"@changesets/apply-release-plan/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
diff --git a/packages/plugins/onboarding/.gitignore b/packages/plugins/onboarding/.gitignore
new file mode 100644
index 0000000..5d2489a
--- /dev/null
+++ b/packages/plugins/onboarding/.gitignore
@@ -0,0 +1,5 @@
+.DS_Store
+.env
+.env.local
+node_modules
+dist
\ No newline at end of file
diff --git a/packages/plugins/onboarding/.npmignore b/packages/plugins/onboarding/.npmignore
new file mode 100644
index 0000000..19dff29
--- /dev/null
+++ b/packages/plugins/onboarding/.npmignore
@@ -0,0 +1,4 @@
+build-dev.ts
+build.ts
+.turbo
+src
\ No newline at end of file
diff --git a/packages/plugins/onboarding/LICENSE b/packages/plugins/onboarding/LICENSE
new file mode 100644
index 0000000..5a27647
--- /dev/null
+++ b/packages/plugins/onboarding/LICENSE
@@ -0,0 +1,7 @@
+Copyright 2025 - present, ping-maxwell
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
diff --git a/packages/plugins/onboarding/README.md b/packages/plugins/onboarding/README.md
new file mode 100644
index 0000000..5156664
--- /dev/null
+++ b/packages/plugins/onboarding/README.md
@@ -0,0 +1,11 @@
+# @better-auth-kit/onboarding
+
+Easily add user onboarding to your authentication flow.
+
+## Documentation
+
+Learn more about this plugin in the [better-auth-kit documentation](https://better-auth-kit.com/docs/plugins/onboarding).
+
+## License
+
+[MIT](LICENSE)
diff --git a/packages/plugins/onboarding/build-dev.ts b/packages/plugins/onboarding/build-dev.ts
new file mode 100644
index 0000000..2ab2d1d
--- /dev/null
+++ b/packages/plugins/onboarding/build-dev.ts
@@ -0,0 +1,4 @@
+import { buildDev } from "@better-auth-kit/internal-build";
+import { config } from "./build";
+
+buildDev(config);
diff --git a/packages/plugins/onboarding/build.ts b/packages/plugins/onboarding/build.ts
new file mode 100644
index 0000000..a68f870
--- /dev/null
+++ b/packages/plugins/onboarding/build.ts
@@ -0,0 +1,7 @@
+import { build, type Config } from "@better-auth-kit/internal-build";
+
+export const config: Config = {
+ enableDts: true,
+ entrypoints: ["./src/index.ts", "./src/client.ts", "./src/presets/index.ts"],
+};
+build(config);
diff --git a/packages/plugins/onboarding/package.json b/packages/plugins/onboarding/package.json
new file mode 100644
index 0000000..0e1fa8d
--- /dev/null
+++ b/packages/plugins/onboarding/package.json
@@ -0,0 +1,55 @@
+{
+ "name": "@better-auth-kit/onboarding",
+ "version": "0.1.0",
+ "description": "Easily add user onboarding to your authentication flow.",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "keywords": [
+ "better-auth",
+ "auth",
+ "plugin",
+ "onboarding",
+ "onboard",
+ "better-auth-kit",
+ "kit"
+ ],
+ "license": "MIT",
+ "author": "better-auth-kit",
+ "files": ["./dist/**/*"],
+ "scripts": {
+ "build": "bun build.ts",
+ "dev": "bun build-dev.ts",
+ "test": "vitest",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "dotenv": "^16.5.0",
+ "file-type": "^20.5.0",
+ "zod": "^3.24.2"
+ },
+ "peerDependencies": {
+ "better-auth": "^1.1.21"
+ },
+ "devDependencies": {
+ "@better-auth-kit/internal-build": "workspace:*",
+ "vitest": "^3.0.8",
+ "@better-auth-kit/tests": "workspace:*"
+ },
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "./client": {
+ "types": "./dist/client.d.ts",
+ "default": "./dist/client.js"
+ },
+ "./presets": {
+ "types": "./dist/presets/index.d.ts",
+ "default": "./dist/presets/index.js"
+ }
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/plugins/onboarding/src/adapter.ts b/packages/plugins/onboarding/src/adapter.ts
new file mode 100644
index 0000000..53fd502
--- /dev/null
+++ b/packages/plugins/onboarding/src/adapter.ts
@@ -0,0 +1,94 @@
+import type { GenericEndpointContext } from "better-auth";
+import type { OnboardingOptions } from "./types";
+
+export const getOnboardingAdapter = (
+ options: OnboardingOptions,
+ ctx: GenericEndpointContext,
+) => {
+ return {
+ getCompletedSteps: async (userId: string) => {
+ let completedSteps: string[];
+ if (options.secondaryStorage && ctx.context.secondaryStorage) {
+ completedSteps =
+ JSON.parse(
+ (await ctx.context.secondaryStorage.get(`onboarding:${userId}`)) ??
+ "{}",
+ ).completedSteps ?? [];
+ } else {
+ completedSteps = JSON.parse(
+ (
+ await ctx.context.adapter.findOne<{
+ completedSteps?: string;
+ }>({
+ model: "user",
+ where: [
+ {
+ field: "id",
+ value: userId,
+ },
+ ],
+ select: ["completedSteps"],
+ })
+ )?.completedSteps ?? "[]",
+ );
+ }
+
+ return new Set(completedSteps);
+ },
+ updateOnboardingState: async (
+ userId: string,
+ data: Partial<{
+ shouldOnboard: boolean | null;
+ completedSteps: string[] | null;
+ }>,
+ ) => {
+ if (options.secondaryStorage && ctx.context.secondaryStorage) {
+ const currentState = JSON.parse(
+ (await ctx.context.secondaryStorage.get(`onboarding:${userId}`)) ??
+ "{}",
+ );
+ const baseState = {
+ shouldOnboard: false,
+ completedSteps: [],
+ };
+ await ctx.context.secondaryStorage.set(`onboarding:${userId}`, {
+ ...baseState,
+ ...currentState,
+ ...data,
+ });
+ } else {
+ await ctx.context.internalAdapter.updateUser(userId, {
+ ...data,
+ completedSteps: Array.isArray(data.completedSteps)
+ ? JSON.stringify(data.completedSteps)
+ : data.completedSteps,
+ });
+ }
+ },
+ getShouldOnboard: async (userId: string) => {
+ if (options.secondaryStorage && ctx.context.secondaryStorage) {
+ return (
+ JSON.parse(
+ (await ctx.context.secondaryStorage.get(`onboarding:${userId}`)) ??
+ "{}",
+ ).shouldOnboard ?? false
+ );
+ }
+
+ return (
+ (
+ await ctx.context.adapter.findOne<{ shouldOnboard?: boolean }>({
+ model: "user",
+ where: [
+ {
+ field: "id",
+ value: userId,
+ },
+ ],
+ select: ["shouldOnboard"],
+ })
+ )?.shouldOnboard ?? false
+ );
+ },
+ };
+};
diff --git a/packages/plugins/onboarding/src/client.ts b/packages/plugins/onboarding/src/client.ts
new file mode 100644
index 0000000..ddf37a3
--- /dev/null
+++ b/packages/plugins/onboarding/src/client.ts
@@ -0,0 +1,79 @@
+import type { BetterAuthClientPlugin } from "better-auth";
+import type { onboarding, OnboardingStep } from ".";
+import { toPath } from "./utils";
+
+type InferSteps = T extends {
+ $Infer: {
+ OnboardingSteps: infer Steps extends Record<
+ string,
+ OnboardingStep
+ >;
+ };
+}
+ ? Steps
+ : T extends Record>
+ ? T
+ : never;
+
+export const onboardingClient = <
+ Steps extends {
+ $Infer: {
+ OnboardingSteps: Record>;
+ OnboardingCompletionStep: string;
+ };
+ },
+>(options?: {
+ /**
+ * a redirect function to call if a user needs
+ * to be onboarded
+ */
+ onOnboardingRedirect?: () => void | Promise;
+}) => {
+ return {
+ id: "onboarding",
+ $InferServerPlugin: {} as ReturnType<
+ typeof onboarding<
+ InferSteps,
+ Steps["$Infer"]["OnboardingCompletionStep"]
+ >
+ >,
+ atomListeners: [
+ {
+ matcher: (path) => path.startsWith("/onboarding/"),
+ signal: "$sessionSignal",
+ },
+ ],
+
+ fetchPlugins: [
+ {
+ id: "onboarding",
+ name: "onboarding",
+ hooks: {
+ async onSuccess(context) {
+ if (context.data?.onboardingRedirect) {
+ if (options?.onOnboardingRedirect) {
+ await options.onOnboardingRedirect();
+ }
+ }
+ },
+ async onRequest(context) {
+ const urlPath = toPath(context.url);
+ const basePathRaw = toPath(context.baseURL ?? "/api/auth");
+ const basePath = basePathRaw.endsWith("/")
+ ? basePathRaw.slice(0, -1)
+ : basePathRaw;
+ if (
+ urlPath.startsWith(`${basePath}/onboarding/step/`) ||
+ urlPath.startsWith(`${basePath}/onboarding/skip-step/`)
+ ) {
+ return {
+ ...context,
+ method: "POST",
+ };
+ }
+ },
+ },
+ },
+ ],
+ } satisfies BetterAuthClientPlugin;
+};
diff --git a/packages/plugins/onboarding/src/error-codes.ts b/packages/plugins/onboarding/src/error-codes.ts
new file mode 100644
index 0000000..fff5e7a
--- /dev/null
+++ b/packages/plugins/onboarding/src/error-codes.ts
@@ -0,0 +1,6 @@
+export const ONBOARDING_ERROR_CODES = {
+ ALREADY_ONBOARDED: "Already onboarded.",
+ STEP_ALREADY_COMPLETED: "Step already completed.",
+ COMPLETE_REQUIRED_STEPS_BEFORE_COMPLETING_ONBOARDING:
+ "Complete required steps before completing onboarding.",
+} as const;
diff --git a/packages/plugins/onboarding/src/index.ts b/packages/plugins/onboarding/src/index.ts
new file mode 100644
index 0000000..fcdca6f
--- /dev/null
+++ b/packages/plugins/onboarding/src/index.ts
@@ -0,0 +1,292 @@
+import type { BetterAuthPlugin, PrettifyDeep } from "better-auth";
+import { mergeSchema } from "better-auth/db";
+import { schema } from "./schema";
+import { ONBOARDING_ERROR_CODES } from "./error-codes";
+import {
+ createAuthEndpoint,
+ createAuthMiddleware,
+ APIError,
+ sessionMiddleware,
+ type AuthEndpoint,
+} from "better-auth/api";
+import type { OnboardingOptions, OnboardingStep } from "./types";
+import type {
+ CanAccessOnboardingStepReturnType,
+ InferSkipCompletionStep,
+ Merged,
+ OnboardingStepReturnType,
+ OnboardingStepsToEndpoints,
+ SkipOnboardingStepReturnType,
+} from "./internal-types";
+import { transformClientPath, transformPath } from "./utils";
+import { verifyOnboarding } from "./verify-onboarding";
+import { getOnboardingAdapter } from "./adapter";
+
+export const onboarding = <
+ Steps extends Record>,
+ CompletionStep extends keyof Steps,
+>(
+ options: OnboardingOptions,
+) => {
+ const opts = {
+ autoEnableOnSignUp: true,
+ ...options,
+ };
+
+ const steps = Object.entries(options.steps);
+
+ const requiredSteps = steps.filter(([_, step]) => step.required);
+ const endpoints = Object.fromEntries(
+ steps.flatMap(([id, step]) => {
+ const isCompletionStep = options.completionStep === id;
+ const key = transformPath(id);
+ const path = transformClientPath(id);
+
+ const endpoints: Record = {
+ [`onboardingStep${key}`]: createAuthEndpoint(
+ `/onboarding/step/${path}`,
+ {
+ method: "POST",
+ body: step.input,
+ use: [sessionMiddleware],
+ requireHeaders: step.requireHeaders,
+ requireRequest: step.requireRequest,
+ cloneRequest: step.cloneRequest,
+ },
+ async (ctx): Promise> => {
+ const adapter = getOnboardingAdapter(options, ctx);
+ const { session } = await verifyOnboarding(ctx, {
+ adapter,
+ options,
+ });
+
+ const completedSteps = await adapter.getCompletedSteps(
+ session.user.id,
+ );
+
+ if (step.once && completedSteps.has(id)) {
+ throw new APIError("FORBIDDEN", {
+ message: ONBOARDING_ERROR_CODES.STEP_ALREADY_COMPLETED,
+ });
+ }
+
+ if (
+ isCompletionStep &&
+ requiredSteps
+ .filter(([key]) => key !== id)
+ .some(([key]) => !completedSteps.has(key))
+ ) {
+ throw new APIError("FORBIDDEN", {
+ message:
+ ONBOARDING_ERROR_CODES.COMPLETE_REQUIRED_STEPS_BEFORE_COMPLETING_ONBOARDING,
+ });
+ }
+
+ const result = await step.handler(ctx);
+
+ const updatedSteps = [...completedSteps.add(id)];
+ const update: Record = {
+ completedSteps: updatedSteps,
+ };
+
+ if (isCompletionStep) {
+ update.shouldOnboard = false;
+ }
+
+ await adapter.updateOnboardingState(session.user.id, update);
+
+ return {
+ completedSteps: updatedSteps,
+ data: result,
+ };
+ },
+ ),
+ [`canAccessOnboardingStep${key}`]: createAuthEndpoint(
+ `/onboarding/can-access-step/${path}`,
+ {
+ method: "GET",
+ use: [sessionMiddleware],
+ metadata: {
+ SERVER_ONLY: true,
+ },
+ },
+ async (ctx): Promise => {
+ const adapter = getOnboardingAdapter(options, ctx);
+ const { session } = await verifyOnboarding(ctx, {
+ adapter,
+ options,
+ });
+
+ if (step.once) {
+ const completedSteps = await adapter.getCompletedSteps(
+ session.user.id,
+ );
+
+ if (completedSteps?.has(id)) {
+ throw new APIError("FORBIDDEN", {
+ message: ONBOARDING_ERROR_CODES.STEP_ALREADY_COMPLETED,
+ });
+ }
+ }
+
+ return true;
+ },
+ ),
+ };
+
+ if (isCompletionStep && step.required !== true) {
+ endpoints[`skipOnboardingStep${key}`] = createAuthEndpoint(
+ `/onboarding/skip-step/${path}`,
+ {
+ method: "POST",
+ use: [sessionMiddleware],
+ },
+ async (ctx): Promise => {
+ const adapter = getOnboardingAdapter(options, ctx);
+ const { session } = await verifyOnboarding(ctx, {
+ adapter,
+ options,
+ });
+
+ const completedSteps = await adapter.getCompletedSteps(
+ session.user.id,
+ );
+
+ if (completedSteps.has(id)) {
+ throw new APIError("FORBIDDEN", {
+ message: ONBOARDING_ERROR_CODES.STEP_ALREADY_COMPLETED,
+ });
+ }
+ if (
+ requiredSteps
+ .filter(([key]) => key !== id)
+ .some(([key]) => !completedSteps.has(key))
+ ) {
+ throw new APIError("FORBIDDEN", {
+ message:
+ ONBOARDING_ERROR_CODES.COMPLETE_REQUIRED_STEPS_BEFORE_COMPLETING_ONBOARDING,
+ });
+ }
+
+ await adapter.updateOnboardingState(session.user.id, {
+ shouldOnboard: false,
+ });
+
+ return {
+ completedSteps: [...completedSteps],
+ data: null,
+ };
+ },
+ );
+ }
+
+ return Object.entries(endpoints);
+ }),
+ ) as PrettifyDeep<
+ Merged> &
+ InferSkipCompletionStep
+ >;
+
+ return {
+ id: "onboarding",
+ endpoints: {
+ shouldOnboard: createAuthEndpoint(
+ "/onboarding/should-onboard",
+ {
+ method: "GET",
+ use: [sessionMiddleware],
+ },
+ async (ctx) => {
+ await verifyOnboarding(ctx, {
+ options,
+ });
+
+ return true;
+ },
+ ),
+ ...endpoints,
+ },
+ hooks: {
+ after: [
+ {
+ matcher(context) {
+ return context.path === "/get-session";
+ },
+ handler: createAuthMiddleware(async (ctx) => {
+ const data = ctx.context.session;
+
+ if (!data?.user.shouldOnboard) {
+ return null;
+ }
+
+ return ctx.json({
+ onboardingRedirect: true,
+ });
+ }),
+ },
+ {
+ matcher(context) {
+ return (
+ opts.autoEnableOnSignUp && context.path.startsWith("/sign-up")
+ );
+ },
+ handler: createAuthMiddleware(async (ctx) => {
+ const adapter = getOnboardingAdapter(options, ctx);
+ const data = ctx.context.newSession;
+ const enabled =
+ typeof opts.autoEnableOnSignUp === "function"
+ ? await opts.autoEnableOnSignUp(ctx)
+ : opts.autoEnableOnSignUp;
+
+ if (!data || !enabled) {
+ return;
+ }
+
+ await adapter.updateOnboardingState(data.user.id, {
+ shouldOnboard: true,
+ });
+
+ return ctx.json({
+ onboardingRedirect: true,
+ });
+ }),
+ },
+ ],
+ },
+ rateLimit: [
+ {
+ pathMatcher(path) {
+ return path.startsWith("/onboarding/");
+ },
+ window: 10,
+ max: 3,
+ },
+ ],
+ schema: !options.secondaryStorage
+ ? mergeSchema(schema, opts?.schema)
+ : undefined,
+ $ERROR_CODES: ONBOARDING_ERROR_CODES,
+ $Infer: {
+ OnboardingSteps: {} as Steps,
+ OnboardingCompletionStep: {} as CompletionStep,
+ },
+ } satisfies BetterAuthPlugin;
+};
+
+export const createOnboardingStep = <
+ Schema extends Record | undefined | null,
+ Result = unknown,
+ Required extends boolean = false,
+>(
+ def: Omit, "required"> &
+ (Required extends true ? { required: true } : { required?: Required }),
+) => {
+ return {
+ once: true,
+ required: (def.required ?? false) as Required,
+ ...def,
+ };
+};
+
+export * from "./types";
+export * from "./client";
diff --git a/packages/plugins/onboarding/src/internal-types.ts b/packages/plugins/onboarding/src/internal-types.ts
new file mode 100644
index 0000000..f8976ba
--- /dev/null
+++ b/packages/plugins/onboarding/src/internal-types.ts
@@ -0,0 +1,118 @@
+import type { createAuthEndpoint, sessionMiddleware } from "better-auth/api";
+import type { OnboardingStep } from "./types";
+import type { z, ZodSchema, ZodType } from "zod";
+import type { TransformClientPath, TransformPath } from "./utils";
+
+type InferStepInput> = K extends {
+ input?: infer I;
+}
+ ? I extends ZodSchema
+ ? Schema
+ : undefined
+ : undefined;
+
+type InferStepResult> =
+ K extends OnboardingStep ? R : never;
+
+export type OnboardingStepReturnType> =
+ {
+ completedSteps: string[];
+ data: InferStepResult;
+ };
+
+export type CanAccessOnboardingStepReturnType = boolean;
+
+export type EndpointPair<
+ Path extends string,
+ K extends OnboardingStep,
+ C extends string,
+> = {
+ onboardingStep: ReturnType<
+ typeof createAuthEndpoint<
+ `/onboarding/step/${TransformClientPath}`,
+ {
+ method: "POST";
+ body: ZodType>;
+ use: [typeof sessionMiddleware];
+ },
+ OnboardingStepReturnType
+ >
+ >;
+ canAccessOnboardingStep: ReturnType<
+ typeof createAuthEndpoint<
+ `/onboarding/can-access-step/${TransformClientPath}`,
+ {
+ method: "GET";
+ use: [typeof sessionMiddleware];
+ metadata: {
+ SERVER_ONLY: true;
+ };
+ },
+ CanAccessOnboardingStepReturnType
+ >
+ >;
+};
+
+type PrefixedEndpoints<
+ Path extends string,
+ S extends OnboardingStep,
+ C extends string,
+> = {
+ [K in keyof EndpointPair<
+ Path,
+ S,
+ C
+ > as `${Extract}${TransformPath}`]: EndpointPair<
+ Path,
+ S,
+ C
+ >[K];
+};
+
+export type OnboardingStepsToEndpoints<
+ S extends Record>,
+ CompletionStep extends keyof S,
+> = {
+ [K in keyof S & string]: PrefixedEndpoints<
+ K,
+ S[K],
+ Extract
+ >;
+};
+
+export type SkipOnboardingStepReturnType = {
+ completedSteps: string[];
+ data: null;
+};
+
+type SkipOnboardingStepEndpoint = ReturnType<
+ typeof createAuthEndpoint<
+ `/onboarding/skip-step/${TransformClientPath}`,
+ {
+ method: "POST";
+ use: [typeof sessionMiddleware];
+ },
+ SkipOnboardingStepReturnType
+ >
+>;
+export type InferSkipCompletionStep<
+ S extends Record>,
+ CompletionStep extends keyof S,
+> = {
+ [K in `skipOnboardingStep${TransformPath>}`]: S[CompletionStep]["required"] extends infer R extends
+ boolean
+ ? R extends true
+ ? never
+ : SkipOnboardingStepEndpoint>
+ : SkipOnboardingStepEndpoint>;
+};
+
+export type Merged = {
+ [K in keyof T]: T[K];
+}[keyof T];
+
+export type IsEqual = (() => G extends (A & G) | G ? 1 : 2) extends <
+ G,
+>() => G extends (B & G) | G ? 1 : 2
+ ? true
+ : false;
diff --git a/packages/plugins/onboarding/src/presets/index.ts b/packages/plugins/onboarding/src/presets/index.ts
new file mode 100644
index 0000000..8215ba8
--- /dev/null
+++ b/packages/plugins/onboarding/src/presets/index.ts
@@ -0,0 +1,2 @@
+export * from "./setup-new-password";
+export * from "./setup-2fa";
diff --git a/packages/plugins/onboarding/src/presets/setup-2fa.ts b/packages/plugins/onboarding/src/presets/setup-2fa.ts
new file mode 100644
index 0000000..d28adba
--- /dev/null
+++ b/packages/plugins/onboarding/src/presets/setup-2fa.ts
@@ -0,0 +1,39 @@
+import { APIError } from "better-auth";
+import { createOnboardingStep } from "..";
+import { z } from "zod";
+
+export type Setup2FAOptions = {
+ /**
+ * If true, this step must be completed before onboarding is considered done.
+ */
+ required?: boolean;
+};
+
+export const setup2FAStep = (options?: O) => {
+ return createOnboardingStep({
+ input: z.object({
+ password: z.string().nonempty(),
+ issuer: z.string().optional(),
+ }),
+ async handler(ctx) {
+ const plugin = ctx.context.options.plugins?.find(
+ (p) => p.id === "two-factor",
+ );
+
+ if (!plugin?.endpoints) {
+ throw new APIError("FAILED_DEPENDENCY", {
+ message: "2FA is not set up.",
+ });
+ }
+
+ const res = (await plugin.endpoints.enableTwoFactor(ctx)) as {
+ totpURI: string;
+ backupCodes: string[];
+ };
+
+ return res;
+ },
+ once: true,
+ required: options?.required,
+ });
+};
diff --git a/packages/plugins/onboarding/src/presets/setup-new-password.ts b/packages/plugins/onboarding/src/presets/setup-new-password.ts
new file mode 100644
index 0000000..b917eff
--- /dev/null
+++ b/packages/plugins/onboarding/src/presets/setup-new-password.ts
@@ -0,0 +1,60 @@
+import { z, ZodString } from "zod";
+import { createOnboardingStep } from "..";
+
+export type SetupNewPasswordStepOptions = {
+ passwordSchema?:
+ | ZodString
+ | {
+ /**
+ * @default 8
+ */
+ minLength?: number;
+ /**
+ * @default 128
+ */
+ maxLength?: number;
+ };
+ /**
+ * If true, this step must be completed before onboarding is considered done.
+ */
+ required?: boolean;
+};
+
+export const setupNewPasswordStep = (
+ options?: O,
+) => {
+ return createOnboardingStep({
+ input: z
+ .object({
+ newPassword:
+ options?.passwordSchema instanceof ZodString
+ ? options.passwordSchema
+ : z.string(),
+ confirmPassword: z.string(),
+ })
+ .superRefine(({ newPassword, confirmPassword }, ctx) => {
+ if (newPassword !== confirmPassword) {
+ ctx.addIssue({
+ path: ["confirmPassword"],
+ code: "custom",
+ message: "Passwords do not match.",
+ });
+ }
+ }),
+ async handler(ctx) {
+ const session = ctx.context.session!;
+
+ await ctx.context.internalAdapter.updatePassword(
+ session.user.id,
+ ctx.body.newPassword,
+ ctx,
+ );
+
+ return {
+ success: true,
+ };
+ },
+ once: true,
+ required: options?.required,
+ });
+};
diff --git a/packages/plugins/onboarding/src/schema.ts b/packages/plugins/onboarding/src/schema.ts
new file mode 100644
index 0000000..eb0f209
--- /dev/null
+++ b/packages/plugins/onboarding/src/schema.ts
@@ -0,0 +1,17 @@
+import type { AuthPluginSchema } from "better-auth";
+
+export const schema = {
+ user: {
+ fields: {
+ shouldOnboard: {
+ type: "boolean",
+ required: false,
+ },
+ completedSteps: {
+ type: "string",
+ required: false,
+ input: false,
+ },
+ },
+ },
+} satisfies AuthPluginSchema;
diff --git a/packages/plugins/onboarding/src/types.ts b/packages/plugins/onboarding/src/types.ts
new file mode 100644
index 0000000..853e2ef
--- /dev/null
+++ b/packages/plugins/onboarding/src/types.ts
@@ -0,0 +1,84 @@
+import type {
+ AuthContext,
+ GenericEndpointContext,
+ InferOptionSchema,
+} from "better-auth";
+import type { ZodSchema } from "zod";
+import type { schema } from "./schema";
+
+type ActionEndpointContext = (
+ ctx: Omit & {
+ body: Schema;
+ },
+) => Result | Promise;
+
+export type OnboardingOptions<
+ Steps extends Record>,
+ CompletionStep extends keyof Steps,
+> = {
+ /**
+ * Map of onboarding steps keyed by a unique step identifier.
+ */
+ steps: Steps;
+ /**
+ * The key of the step that, when completed, marks onboarding as finished.
+ */
+ completionStep: CompletionStep;
+ /**
+ * Whether to automatically enable onboarding for new users during sign up
+ * @default true
+ */
+ autoEnableOnSignUp?:
+ | boolean
+ | ((ctx: GenericEndpointContext) => boolean | Promise);
+ /**
+ * Whether to use secondary storage instead of database.
+ *
+ * @default false
+ */
+ secondaryStorage?: boolean;
+ /**
+ * Custom schema configuration for the onboarding plugin
+ */
+ schema?: InferOptionSchema;
+};
+
+export type OnboardingStep<
+ Schema extends Record | undefined | null,
+ Result,
+ Required extends boolean = false,
+> = {
+ /**
+ * Optional Zod schema used to validate the request body for this step.
+ * If omitted, the handler receives the raw body without validation.
+ */
+ input?: ZodSchema;
+ /**
+ * The function executed for this step. Receives the validated body (if an
+ * `input` schema is provided) and the endpoint context. Can be async and
+ * should return the step result.
+ */
+ handler: ActionEndpointContext;
+ /**
+ * If true, this step can be completed only once per user. Subsequent
+ * attempts should be treated as no-ops or rejected.
+ */
+ once?: boolean;
+ /**
+ * If true headers will be required to be passed in the context
+ */
+ requireHeaders?: boolean;
+ /**
+ * If true request object will be required
+ */
+ requireRequest?: boolean;
+ /**
+ * Clone the request object from the router
+ */
+ cloneRequest?: boolean;
+
+ /**
+ * If true, this step must be completed before onboarding is considered done.
+ */
+ required?: boolean;
+} & (Required extends true ? { required: true } : { required?: false });
diff --git a/packages/plugins/onboarding/src/utils.ts b/packages/plugins/onboarding/src/utils.ts
new file mode 100644
index 0000000..5c65fb5
--- /dev/null
+++ b/packages/plugins/onboarding/src/utils.ts
@@ -0,0 +1,70 @@
+import type { LiteralString } from "better-auth";
+
+export function transformPath(
+ path: T,
+): TransformPath {
+ const result = path
+ .split(/[-/]/g)
+ .map((segment, index) => {
+ if (segment.length === 0) return ""; // handle leading separators
+ return segment.charAt(0).toUpperCase() + segment.slice(1);
+ })
+ .join("");
+
+ return result as TransformPath;
+}
+
+type CapitalizeFirst = S extends `${infer First}${infer Rest}`
+ ? `${Uppercase}${Rest}`
+ : S;
+
+export type TransformPath =
+ S extends `${infer Head}/${infer Tail}`
+ ? TransformPath<`${Head}${CapitalizeFirst}`>
+ : S extends `${infer Head}-${infer Tail}`
+ ? TransformPath<`${Head}${CapitalizeFirst}`>
+ : CapitalizeFirst;
+
+export function transformClientPath(
+ path: T,
+): TransformClientPath {
+ const result = path
+ .replace(/[\/]+/g, "-")
+ .replace(/([A-Z])([A-Z])/g, "$1-$2")
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
+ .replace(/([A-Z])([A-Z][a-z])/g, "$1-$2")
+ .replace(/^-+|-+$/g, "")
+ .replace(/-+/g, "-")
+ .toLowerCase();
+
+ return result as TransformClientPath;
+}
+
+type KebabStart = S extends `-${infer R}`
+ ? KebabStart
+ : S extends `/${infer R}`
+ ? KebabStart
+ : S extends `${infer F}${infer R}`
+ ? F extends Lowercase
+ ? `${F}${KebabCont}`
+ : `${Lowercase}${KebabCont}`
+ : S;
+
+type KebabCont = S extends `${infer F}${infer R}`
+ ? F extends "-" | "/"
+ ? `-${KebabStart}`
+ : F extends Lowercase
+ ? `${F}${KebabCont}`
+ : `-${Lowercase}${KebabCont}`
+ : S;
+
+export type TransformClientPath = KebabStart;
+
+export const toPath = (u?: string | URL) => {
+ if (!u) return "";
+ try {
+ return new URL(u).pathname;
+ } catch {
+ return `${u}`;
+ }
+};
diff --git a/packages/plugins/onboarding/src/verify-onboarding.ts b/packages/plugins/onboarding/src/verify-onboarding.ts
new file mode 100644
index 0000000..44a66e6
--- /dev/null
+++ b/packages/plugins/onboarding/src/verify-onboarding.ts
@@ -0,0 +1,37 @@
+import { APIError, getSessionFromCtx } from "better-auth/api";
+import type { GenericEndpointContext } from "better-auth/types";
+import { ONBOARDING_ERROR_CODES } from "./error-codes";
+import type { OnboardingOptions } from "./types";
+import { getOnboardingAdapter } from "./adapter";
+
+export async function verifyOnboarding(
+ ctx: GenericEndpointContext,
+ context: {
+ adapter?: ReturnType;
+ options: OnboardingOptions;
+ },
+) {
+ const session = await getSessionFromCtx(ctx);
+
+ if (!session) {
+ throw new APIError("UNAUTHORIZED");
+ }
+
+ let shouldOnboard = session.user.shouldOnboard;
+ if (context.options.secondaryStorage && ctx.context.secondaryStorage) {
+ const adapter = context.adapter
+ ? context.adapter
+ : getOnboardingAdapter(context.options, ctx);
+ shouldOnboard = await adapter.getShouldOnboard(session.user.id);
+ }
+ if (!shouldOnboard) {
+ throw new APIError("FORBIDDEN", {
+ message: ONBOARDING_ERROR_CODES.ALREADY_ONBOARDED,
+ });
+ }
+
+ return {
+ session,
+ key: `${session.user.id}!${session.session.id}`,
+ };
+}
diff --git a/packages/plugins/onboarding/tests/auth.ts b/packages/plugins/onboarding/tests/auth.ts
new file mode 100644
index 0000000..9d0dcbc
--- /dev/null
+++ b/packages/plugins/onboarding/tests/auth.ts
@@ -0,0 +1,45 @@
+import {
+ onboarding,
+ type OnboardingOptions,
+ createOnboardingStep,
+} from "../src";
+import { betterAuth, type BetterAuthPlugin } from "better-auth";
+import database from "better-sqlite3";
+import { z } from "zod";
+
+const onboardingSchema = z
+ .object({
+ foo: z.string().optional(),
+ })
+ .nullish();
+
+export const getAuth = (
+ options?: Partial>,
+ authOptions?: {
+ plugins?: BetterAuthPlugin[];
+ },
+) => {
+ const auth = betterAuth({
+ database: database(":memory:"),
+ emailAndPassword: {
+ enabled: true,
+ },
+ plugins: [
+ onboarding({
+ steps: {
+ newPassword: createOnboardingStep({
+ input: onboardingSchema,
+ handler: async (ctx) => {
+ return true;
+ },
+ }),
+ },
+ completionStep: "newPassword",
+ ...options,
+ }),
+ ...(authOptions?.plugins ?? []),
+ ],
+ });
+
+ return auth;
+};
diff --git a/packages/plugins/onboarding/tests/onboarding.test.ts b/packages/plugins/onboarding/tests/onboarding.test.ts
new file mode 100644
index 0000000..ad5e955
--- /dev/null
+++ b/packages/plugins/onboarding/tests/onboarding.test.ts
@@ -0,0 +1,406 @@
+import { getTestInstance } from "@better-auth-kit/tests";
+import { describe, expect, it, vi, beforeAll, beforeEach } from "vitest";
+import { onboardingClient } from "../src/client";
+import { ONBOARDING_ERROR_CODES } from "../src/error-codes";
+import { getAuth } from "./auth";
+
+const mockOnboardingRedirect = vi.fn();
+describe("Onboarding", () => {
+ describe("(success)", async () => {
+ const auth = getAuth();
+ const { resetDatabase, client, signUpWithTestUser, testUser, db } =
+ await getTestInstance(auth, {
+ clientOptions: {
+ plugins: [
+ onboardingClient({
+ onOnboardingRedirect: mockOnboardingRedirect,
+ }),
+ ],
+ },
+ shouldRunMigrations: true,
+ });
+
+ let headers: Headers;
+ beforeAll(async () => {
+ await resetDatabase();
+ const result = await signUpWithTestUser();
+ headers = result.headers;
+ });
+
+ beforeEach(async () => {
+ await db.update({
+ model: "user",
+ where: [
+ {
+ field: "email",
+ value: testUser.email,
+ },
+ ],
+ update: {
+ shouldOnboard: true,
+ completedSteps: "[]",
+ },
+ });
+ });
+
+ it("should return true for shouldOnboard when user needs onboarding", async () => {
+ const { data, error } = await client.onboarding.shouldOnboard({
+ fetchOptions: {
+ headers,
+ },
+ });
+ if (error) throw error;
+ expect(data).toBe(true);
+ });
+
+ it("should trigger redirect via getSession hook", async () => {
+ mockOnboardingRedirect.mockClear();
+ await client.getSession({
+ fetchOptions: {
+ headers,
+ throw: true,
+ },
+ });
+ expect(mockOnboardingRedirect).toHaveBeenCalled();
+ });
+
+ it("should complete onboarding step successfully and return true", async () => {
+ const res = await (client.onboarding as any).step.newPassword({
+ foo: "bar",
+ fetchOptions: {
+ headers,
+ },
+ });
+ if (res.error) throw res.error;
+ expect(res.data.completedSteps).toEqual(["newPassword"]);
+ expect(res.data.data).toBe(true);
+ });
+
+ it("should not trigger redirect via getSession after completing onboarding", async () => {
+ mockOnboardingRedirect.mockClear();
+ await (client as any).onboarding.step.newPassword({
+ fetchOptions: {
+ headers,
+ },
+ });
+ await client.getSession({
+ fetchOptions: {
+ headers,
+ throw: true,
+ },
+ });
+ expect(mockOnboardingRedirect).not.toHaveBeenCalled();
+ });
+
+ it("should return forbidden on shouldOnboard when already onboarded", async () => {
+ await (client.onboarding as any).step.newPassword({
+ fetchOptions: {
+ headers,
+ },
+ });
+ const { error } = await client.onboarding.shouldOnboard({
+ fetchOptions: {
+ headers,
+ },
+ });
+ expect(error?.status).toBe(403);
+ expect(error?.message).toBe(ONBOARDING_ERROR_CODES.ALREADY_ONBOARDED);
+ });
+
+ it("should fail shouldOnboard without session", async () => {
+ const { error } = await client.onboarding.shouldOnboard();
+ expect(error?.status).toBe(401);
+ });
+
+ it("should fail onboarding without session", async () => {
+ const res = await (client.onboarding as any).step.newPassword({
+ fetchOptions: {
+ headers: new Headers(),
+ },
+ });
+ expect(res.error?.status).toBe(401);
+ });
+
+ it("should error when completing the same step twice if once is true", async () => {
+ await (client.onboarding as any).step.newPassword({
+ fetchOptions: {
+ headers,
+ },
+ });
+ const res = await (client.onboarding as any).step.newPassword({
+ fetchOptions: {
+ headers,
+ },
+ });
+ expect(res.error?.status).toBe(403);
+ });
+ });
+
+ describe("(auto enable on sign-up)", async () => {
+ const { resetDatabase, signUpWithTestUser } = await getTestInstance(
+ getAuth(),
+ {
+ clientOptions: {
+ plugins: [
+ onboardingClient({
+ onOnboardingRedirect: mockOnboardingRedirect,
+ }),
+ ],
+ },
+ shouldRunMigrations: true,
+ },
+ );
+
+ beforeEach(async () => {
+ await resetDatabase();
+ });
+
+ it("should trigger redirect during sign-up when autoEnableOnSignUp is true", async () => {
+ mockOnboardingRedirect.mockClear();
+ await signUpWithTestUser();
+ expect(mockOnboardingRedirect).toHaveBeenCalled();
+ });
+
+ it("should not trigger redirect during sign-up when autoEnableOnSignUp is false", async () => {
+ mockOnboardingRedirect.mockClear();
+ const { resetDatabase, signUpWithTestUser } = await getTestInstance(
+ getAuth({
+ autoEnableOnSignUp: false,
+ }),
+ {
+ clientOptions: {
+ plugins: [
+ onboardingClient({
+ onOnboardingRedirect: mockOnboardingRedirect,
+ }),
+ ],
+ },
+ shouldRunMigrations: true,
+ },
+ );
+ await signUpWithTestUser();
+ expect(mockOnboardingRedirect).not.toHaveBeenCalled();
+ });
+
+ it("should trigger redirect when autoEnableOnSignUp is a function returning true", async () => {
+ mockOnboardingRedirect.mockClear();
+ const { signUpWithTestUser } = await getTestInstance(
+ getAuth({
+ autoEnableOnSignUp: () => true,
+ }),
+ {
+ clientOptions: {
+ plugins: [
+ onboardingClient({
+ onOnboardingRedirect: mockOnboardingRedirect,
+ }),
+ ],
+ },
+ shouldRunMigrations: true,
+ },
+ );
+ await signUpWithTestUser();
+ expect(mockOnboardingRedirect).toHaveBeenCalled();
+ });
+
+ it("should not trigger redirect when autoEnableOnSignUp is an async function returning false", async () => {
+ mockOnboardingRedirect.mockClear();
+ const { signUpWithTestUser } = await getTestInstance(
+ getAuth({
+ autoEnableOnSignUp: async () => false,
+ }),
+ {
+ clientOptions: {
+ plugins: [
+ onboardingClient({
+ onOnboardingRedirect: mockOnboardingRedirect,
+ }),
+ ],
+ },
+ shouldRunMigrations: true,
+ },
+ );
+ await signUpWithTestUser();
+ expect(mockOnboardingRedirect).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("(required steps)", async () => {
+ const auth = getAuth({
+ steps: {
+ profile: {
+ handler: async () => true,
+ required: true,
+ },
+ newPassword: {
+ handler: async () => true,
+ },
+ },
+ completionStep: "newPassword" as any,
+ } as any);
+
+ const { resetDatabase, client, signUpWithTestUser, db, testUser } =
+ await getTestInstance(auth, {
+ clientOptions: {
+ plugins: [
+ onboardingClient({
+ onOnboardingRedirect: () => Promise.resolve(),
+ }),
+ ],
+ },
+ shouldRunMigrations: true,
+ });
+
+ let headers: Headers;
+ beforeAll(async () => {
+ await resetDatabase();
+ const result = await signUpWithTestUser();
+ headers = result.headers;
+ });
+
+ beforeEach(async () => {
+ await db.update({
+ model: "user",
+ where: [
+ {
+ field: "email",
+ value: testUser.email,
+ },
+ ],
+ update: {
+ shouldOnboard: true,
+ completedSteps: "[]",
+ },
+ });
+ });
+
+ it("should forbid completing completion step before required steps", async () => {
+ const res = await (client.onboarding as any).step.newPassword({
+ fetchOptions: { headers },
+ });
+ expect(res.error?.status).toBe(403);
+ expect(res.error?.message).toBe(
+ ONBOARDING_ERROR_CODES.COMPLETE_REQUIRED_STEPS_BEFORE_COMPLETING_ONBOARDING,
+ );
+ });
+
+ it("should allow completion after required steps are completed", async () => {
+ const r1 = await (client.onboarding as any).step.profile({
+ fetchOptions: { headers },
+ });
+ if (r1.error) throw r1.error;
+ const r2 = await (client.onboarding as any).step.newPassword({
+ fetchOptions: { headers },
+ });
+ if (r2.error) throw r2.error;
+ expect(r2.data.completedSteps).toEqual(["profile", "newPassword"]);
+ });
+ });
+
+ describe("(skip completion step)", async () => {
+ const auth = getAuth({
+ steps: {
+ profile: {
+ handler: async () => true,
+ required: true,
+ },
+ preferences: {
+ handler: async () => true,
+ },
+ },
+ completionStep: "preferences",
+ });
+
+ const { resetDatabase, client, signUpWithTestUser, db, testUser } =
+ await getTestInstance(auth, {
+ clientOptions: {
+ plugins: [
+ onboardingClient({
+ onOnboardingRedirect: () => Promise.resolve(),
+ }),
+ ],
+ },
+ shouldRunMigrations: true,
+ });
+
+ let headers: Headers;
+ beforeAll(async () => {
+ await resetDatabase();
+ const result = await signUpWithTestUser();
+ headers = result.headers;
+ });
+
+ beforeEach(async () => {
+ await db.update({
+ model: "user",
+ where: [
+ {
+ field: "email",
+ value: testUser.email,
+ },
+ ],
+ update: {
+ shouldOnboard: true,
+ completedSteps: "[]",
+ },
+ });
+ });
+
+ it("should allow skipping non-required completion step", async () => {
+ await client.onboarding.step.profile({
+ fetchOptions: { headers },
+ });
+
+ const res = await client.onboarding.skipStep.preferences({
+ fetchOptions: { headers },
+ });
+
+ if (res.error) throw res.error;
+ expect(res.data.completedSteps).toEqual(["profile"]);
+ expect(res.data.data).toBe(null);
+ });
+
+ it("should forbid skipping completion step before required steps are completed", async () => {
+ const res = await client.onboarding.skipStep.preferences({
+ fetchOptions: { headers },
+ });
+
+ expect(res.error?.status).toBe(403);
+ expect(res.error?.message).toBe(
+ ONBOARDING_ERROR_CODES.COMPLETE_REQUIRED_STEPS_BEFORE_COMPLETING_ONBOARDING,
+ );
+ });
+
+ it("should forbid skipping already completed step", async () => {
+ await client.onboarding.step.profile({
+ fetchOptions: { headers },
+ });
+
+ await client.onboarding.step.preferences({
+ fetchOptions: { headers },
+ });
+
+ const res = await client.onboarding.skipStep.preferences({
+ fetchOptions: { headers },
+ });
+
+ expect(res.error?.status).toBe(403);
+ });
+
+ it("should mark onboarding as complete when skipping non-required completion step", async () => {
+ await client.onboarding.step.profile({
+ fetchOptions: { headers },
+ });
+
+ await client.onboarding.skipStep.preferences({
+ fetchOptions: { headers },
+ });
+
+ const { data: needsOnboarding } = await client.onboarding.shouldOnboard({
+ fetchOptions: { headers },
+ });
+
+ expect(needsOnboarding).not.toBe(true);
+ });
+ });
+});
diff --git a/packages/plugins/onboarding/tests/presets/setup-2fa.test.ts b/packages/plugins/onboarding/tests/presets/setup-2fa.test.ts
new file mode 100644
index 0000000..5e8c251
--- /dev/null
+++ b/packages/plugins/onboarding/tests/presets/setup-2fa.test.ts
@@ -0,0 +1,90 @@
+import { beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { getAuth } from "../auth";
+import { setup2FAStep } from "../../src/presets/setup-2fa";
+import { getTestInstance } from "@better-auth-kit/tests";
+import { onboardingClient } from "../../src";
+import { ONBOARDING_ERROR_CODES } from "../../src/error-codes";
+import { twoFactor } from "better-auth/plugins";
+
+describe("setup-new-password preset", async () => {
+ const auth = getAuth(
+ {
+ steps: {
+ twoFactor: setup2FAStep({ required: false }),
+ complete: {
+ async handler(ctx) {
+ return true;
+ },
+ },
+ },
+ completionStep: "complete",
+ },
+ {
+ plugins: [twoFactor()],
+ },
+ );
+
+ const { resetDatabase, client, signUpWithTestUser, db, testUser } =
+ await getTestInstance(auth, {
+ clientOptions: {
+ plugins: [onboardingClient()],
+ },
+ shouldRunMigrations: true,
+ });
+
+ let headers: Headers;
+ beforeAll(async () => {
+ await resetDatabase();
+ const result = await signUpWithTestUser();
+ headers = result.headers;
+ });
+
+ beforeEach(async () => {
+ await db.update({
+ model: "user",
+ where: [
+ {
+ field: "email",
+ value: testUser.email,
+ },
+ ],
+ update: {
+ shouldOnboard: true,
+ completedSteps: "[]",
+ },
+ });
+ });
+
+ it("should validate required password field", async () => {
+ const res = await (client.onboarding as any).step.twoFactor({
+ password: "",
+ fetchOptions: { headers },
+ });
+ expect(res.error?.status).toBe(400);
+ });
+
+ it("should accept optional issuer field", async () => {
+ const res = await (client.onboarding as any).step.twoFactor({
+ password: testUser.password,
+ issuer: "TestApp",
+ fetchOptions: { headers },
+ });
+ expect(res.data?.completedSteps).toContain("twoFactor");
+ });
+
+ it("should enforce once constraint for 2FA setup", async () => {
+ await (client.onboarding as any).step.twoFactor({
+ password: testUser.password,
+ fetchOptions: { headers },
+ });
+
+ const res = await (client.onboarding as any).step.twoFactor({
+ password: testUser.password,
+ fetchOptions: { headers },
+ });
+ expect(res.error?.status).toBe(403);
+ expect(res.error?.message).toBe(
+ ONBOARDING_ERROR_CODES.STEP_ALREADY_COMPLETED,
+ );
+ });
+});
diff --git a/packages/plugins/onboarding/tests/presets/setup-new-password.test.ts b/packages/plugins/onboarding/tests/presets/setup-new-password.test.ts
new file mode 100644
index 0000000..abd908d
--- /dev/null
+++ b/packages/plugins/onboarding/tests/presets/setup-new-password.test.ts
@@ -0,0 +1,98 @@
+import { beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { getAuth } from "../auth";
+import { setupNewPasswordStep } from "../../src/presets/setup-new-password";
+import { getTestInstance } from "@better-auth-kit/tests";
+import { onboardingClient } from "../../src";
+
+describe("setup-new-password preset", async () => {
+ const auth = getAuth({
+ steps: {
+ newPassword: setupNewPasswordStep({ required: true }),
+ },
+ completionStep: "newPassword",
+ });
+
+ const { resetDatabase, client, signUpWithTestUser, db, testUser } =
+ await getTestInstance(auth, {
+ clientOptions: {
+ plugins: [
+ onboardingClient(),
+ ],
+ },
+ shouldRunMigrations: true,
+ });
+
+ let headers: Headers;
+ beforeAll(async () => {
+ await resetDatabase();
+ const result = await signUpWithTestUser();
+ headers = result.headers;
+ });
+
+ beforeEach(async () => {
+ await db.update({
+ model: "user",
+ where: [
+ {
+ field: "email",
+ value: testUser.email,
+ },
+ ],
+ update: {
+ shouldOnboard: true,
+ completedSteps: "[]",
+ },
+ });
+ });
+
+ it("should validate password and confirmPassword match", async () => {
+ const res = await (client.onboarding as any).step.newPassword({
+ newPassword: "newpassword123",
+ confirmPassword: "differentpassword",
+ fetchOptions: { headers },
+ });
+ expect(res.error?.status).toBe(400);
+ expect(res.error?.message).toContain("Invalid body parameters");
+ });
+
+ it("should successfully update password when passwords match", async () => {
+ const res = await (client.onboarding as any).step.newPassword({
+ newPassword: "newpassword123",
+ confirmPassword: "newpassword123",
+ fetchOptions: { headers },
+ });
+ if (res.error) throw res.error;
+ expect(res.data.data.success).toBe(true);
+ expect(res.data.completedSteps).toEqual(["newPassword"]);
+ });
+
+ it("should mark step as completed and finish onboarding", async () => {
+ const res = await (client.onboarding as any).step.newPassword({
+ newPassword: "newpassword123",
+ confirmPassword: "newpassword123",
+ fetchOptions: { headers },
+ });
+ if (res.error) throw res.error;
+
+ const { data: shouldOnboard } = await client.onboarding.shouldOnboard({
+ fetchOptions: { headers },
+ });
+ expect(shouldOnboard).not.toBe(true);
+ });
+
+ it("should enforce once constraint for password setup", async () => {
+ await (client.onboarding as any).step.newPassword({
+ newPassword: "newpassword123",
+ confirmPassword: "newpassword123",
+ fetchOptions: { headers },
+ });
+
+ const res = await (client.onboarding as any).step.newPassword({
+ newPassword: "anotherpassword123",
+ confirmPassword: "anotherpassword123",
+ fetchOptions: { headers },
+ });
+ expect(res.error?.status).toBe(403);
+ expect(res.error?.message).toBeDefined();
+ });
+});
diff --git a/packages/plugins/onboarding/tsconfig.json b/packages/plugins/onboarding/tsconfig.json
new file mode 100644
index 0000000..dfb4375
--- /dev/null
+++ b/packages/plugins/onboarding/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ // Enable latest features
+ "lib": ["ESNext", "DOM"],
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
+ "allowJs": true,
+
+ // Bundler mode
+ "moduleResolution": "bundler",
+ "verbatimModuleSyntax": true,
+ "declaration": true,
+
+ // Best practices
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+
+ // Some stricter flags (disabled by default)
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noPropertyAccessFromIndexSignature": false
+ },
+ "include": ["./src/*"],
+ "exclude": ["dist", "build.ts"]
+}