From a06813946b4bb104c66b371aa5a3c71521830cff Mon Sep 17 00:00:00 2001 From: Joel Solano Date: Thu, 21 Aug 2025 19:05:45 +0200 Subject: [PATCH 01/20] feat: onboarding plugin --- bun.lock | 29 ++- packages/plugins/onboarding/.gitignore | 5 + packages/plugins/onboarding/.npmignore | 4 + packages/plugins/onboarding/LICENSE | 7 + packages/plugins/onboarding/README.md | 11 ++ packages/plugins/onboarding/build-dev.ts | 4 + packages/plugins/onboarding/build.ts | 6 + packages/plugins/onboarding/package.json | 51 ++++++ packages/plugins/onboarding/src/client.ts | 42 +++++ .../plugins/onboarding/src/error-codes.ts | 4 + packages/plugins/onboarding/src/index.ts | 88 ++++++++++ .../onboarding/src/routes/complete-onboard.ts | 43 +++++ .../onboarding/src/routes/should-onboard.ts | 16 ++ packages/plugins/onboarding/src/schema.ts | 11 ++ packages/plugins/onboarding/src/types.ts | 26 +++ .../onboarding/src/verify-onboarding.ts | 36 ++++ packages/plugins/onboarding/tests/auth.ts | 39 +++++ .../onboarding/tests/onboarding.test.ts | 165 ++++++++++++++++++ packages/plugins/onboarding/tests/test.db | Bin 0 -> 45056 bytes packages/plugins/onboarding/tsconfig.json | 28 +++ 20 files changed, 612 insertions(+), 3 deletions(-) create mode 100644 packages/plugins/onboarding/.gitignore create mode 100644 packages/plugins/onboarding/.npmignore create mode 100644 packages/plugins/onboarding/LICENSE create mode 100644 packages/plugins/onboarding/README.md create mode 100644 packages/plugins/onboarding/build-dev.ts create mode 100644 packages/plugins/onboarding/build.ts create mode 100644 packages/plugins/onboarding/package.json create mode 100644 packages/plugins/onboarding/src/client.ts create mode 100644 packages/plugins/onboarding/src/error-codes.ts create mode 100644 packages/plugins/onboarding/src/index.ts create mode 100644 packages/plugins/onboarding/src/routes/complete-onboard.ts create mode 100644 packages/plugins/onboarding/src/routes/should-onboard.ts create mode 100644 packages/plugins/onboarding/src/schema.ts create mode 100644 packages/plugins/onboarding/src/types.ts create mode 100644 packages/plugins/onboarding/src/verify-onboarding.ts create mode 100644 packages/plugins/onboarding/tests/auth.ts create mode 100644 packages/plugins/onboarding/tests/onboarding.test.ts create mode 100644 packages/plugins/onboarding/tests/test.db create mode 100644 packages/plugins/onboarding/tsconfig.json 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..8425d0c --- /dev/null +++ b/packages/plugins/onboarding/build.ts @@ -0,0 +1,6 @@ +import { build, type Config } from "@better-auth-kit/internal-build"; + +export const config: Config = { + enableDts: true, +}; +build(config); diff --git a/packages/plugins/onboarding/package.json b/packages/plugins/onboarding/package.json new file mode 100644 index 0000000..324d5b4 --- /dev/null +++ b/packages/plugins/onboarding/package.json @@ -0,0 +1,51 @@ +{ + "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" + } + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/plugins/onboarding/src/client.ts b/packages/plugins/onboarding/src/client.ts new file mode 100644 index 0000000..b64061b --- /dev/null +++ b/packages/plugins/onboarding/src/client.ts @@ -0,0 +1,42 @@ +import type { BetterAuthClientPlugin } from "better-auth"; +import type { onboarding } from "."; +import type { ZodSchema } from "zod"; + +export const onboardingClient = >(options?: { + schema?: ZodSchema; + /** + * a redirect function to call if a user needs + * to be onboarded + */ + onOnboardingRedirect?: () => void | Promise; +}) => { + return { + id: "onboarding", + $InferServerPlugin: {} as ReturnType>, + atomListeners: [ + { + matcher: (path) => path.startsWith("/onboarding/"), + signal: "$sessionSignal", + }, + ], + pathMethods: { + "/onboarding/complete": "POST", + "/onboarding/should-onboard": "GET", + }, + fetchPlugins: [ + { + id: "onboarding", + name: "onboarding", + hooks: { + async onSuccess(context) { + if (context.data?.onboardingRedirect) { + if (options?.onOnboardingRedirect) { + await options.onOnboardingRedirect(); + } + } + }, + }, + }, + ], + } 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..ce285a1 --- /dev/null +++ b/packages/plugins/onboarding/src/error-codes.ts @@ -0,0 +1,4 @@ +export const ONBOARDING_ERROR_CODES = { + ALREADY_ONBOARDED: "Already onboarded", + FAILED_TO_COMPLETE_ONBOARDING: "Failed to complete 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..95fd724 --- /dev/null +++ b/packages/plugins/onboarding/src/index.ts @@ -0,0 +1,88 @@ +import type { OnboardingOptions } from "./types"; +import type { BetterAuthPlugin } from "better-auth"; +import { mergeSchema } from "better-auth/db"; +import { schema } from "./schema"; +import { ONBOARDING_ERROR_CODES } from "./error-codes"; +import { createAuthMiddleware } from "better-auth/api"; +import { shouldOnboard } from "./routes/should-onboard"; +import { completeOnboarding } from "./routes/complete-onboard"; + +export const onboarding = >( + options: OnboardingOptions, +) => { + const opts = { + autoEnableOnSignUp: true, + ...options, + }; + + return { + id: "onboarding", + endpoints: { + shouldOnboard, + completeOnboarding: completeOnboarding(opts), + }, + 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 data = ctx.context.newSession; + if (!data) { + return null; + } + await ctx.context.adapter.update({ + model: "user", + where: [ + { + field: "id", + value: data.user.id, + }, + ], + update: { + shouldOnboard: true, + }, + }); + + return ctx.json({ + onboardingRedirect: true, + }); + }), + }, + ], + }, + rateLimit: [ + { + pathMatcher(path) { + return path.startsWith("/onboarding/"); + }, + window: 10, + max: 3, + }, + ], + schema: mergeSchema(schema, opts?.schema), + $ERROR_CODES: ONBOARDING_ERROR_CODES, + } satisfies BetterAuthPlugin; +}; + +export * from "./types"; +export * from "./client"; diff --git a/packages/plugins/onboarding/src/routes/complete-onboard.ts b/packages/plugins/onboarding/src/routes/complete-onboard.ts new file mode 100644 index 0000000..6cb12b3 --- /dev/null +++ b/packages/plugins/onboarding/src/routes/complete-onboard.ts @@ -0,0 +1,43 @@ +import { + createAuthEndpoint, + sessionMiddleware, + APIError, +} from "better-auth/api"; +import { verifyOnboarding } from "../verify-onboarding"; +import type { OnboardingOptions } from "../types"; +import { ONBOARDING_ERROR_CODES } from "../error-codes"; + +export const completeOnboarding = >( + options: OnboardingOptions, +) => + createAuthEndpoint( + "/onboarding/complete", + { + method: "POST", + body: options.input, + use: [sessionMiddleware], + }, + async (ctx) => { + const { valid } = await verifyOnboarding(ctx); + + if (await options.onComplete(ctx)) { + await ctx.context.adapter.update({ + model: "user", + where: [ + { + field: "id", + value: ctx.context.session.user.id, + }, + ], + update: { + shouldOnboard: false, + }, + }); + return valid(ctx); + } + + throw new APIError("BAD_REQUEST", { + message: ONBOARDING_ERROR_CODES.FAILED_TO_COMPLETE_ONBOARDING, + }); + }, + ); diff --git a/packages/plugins/onboarding/src/routes/should-onboard.ts b/packages/plugins/onboarding/src/routes/should-onboard.ts new file mode 100644 index 0000000..ec869d1 --- /dev/null +++ b/packages/plugins/onboarding/src/routes/should-onboard.ts @@ -0,0 +1,16 @@ +import { sessionMiddleware } from "better-auth/api"; +import { createAuthEndpoint } from "better-auth/plugins"; +import { verifyOnboarding } from "../verify-onboarding"; + +export const shouldOnboard = createAuthEndpoint( + "/onboarding/should-onboard", + { + method: "GET", + use: [sessionMiddleware], + }, + async (ctx) => { + await verifyOnboarding(ctx); + + return true; + }, +); diff --git a/packages/plugins/onboarding/src/schema.ts b/packages/plugins/onboarding/src/schema.ts new file mode 100644 index 0000000..3a14e2a --- /dev/null +++ b/packages/plugins/onboarding/src/schema.ts @@ -0,0 +1,11 @@ +import type { AuthPluginSchema } from "better-auth"; + +export const schema = { + user: { + fields: { + shouldOnboard: { + type: "boolean", + }, + }, + }, +} satisfies AuthPluginSchema; diff --git a/packages/plugins/onboarding/src/types.ts b/packages/plugins/onboarding/src/types.ts new file mode 100644 index 0000000..e1ddba0 --- /dev/null +++ b/packages/plugins/onboarding/src/types.ts @@ -0,0 +1,26 @@ +import type { + AuthContext, + EndpointContext, + InferOptionSchema, +} from "better-auth"; +import type { ZodSchema } from "zod"; +import type { schema } from "./schema"; + +type ActionEndpointContext> = ( + ctx: EndpointContext< + string, + { + body: ZodSchema; + method: "POST"; + } + > & { + context: AuthContext; + }, +) => boolean | Promise; + +export type OnboardingOptions> = { + input: ZodSchema; + onComplete: ActionEndpointContext; + autoEnableOnSignUp?: boolean; + schema?: InferOptionSchema; +}; diff --git a/packages/plugins/onboarding/src/verify-onboarding.ts b/packages/plugins/onboarding/src/verify-onboarding.ts new file mode 100644 index 0000000..2cb7740 --- /dev/null +++ b/packages/plugins/onboarding/src/verify-onboarding.ts @@ -0,0 +1,36 @@ +import { APIError, getSessionFromCtx } from "better-auth/api"; +import type { GenericEndpointContext } from "better-auth/types"; +import { ONBOARDING_ERROR_CODES } from "./error-codes"; + +export async function verifyOnboarding(ctx: GenericEndpointContext) { + const session = await getSessionFromCtx(ctx); + + if (!session) { + throw new APIError("UNAUTHORIZED"); + } + + if (!session.user.shouldOnboard) { + throw new APIError("UNAUTHORIZED", { + message: ONBOARDING_ERROR_CODES.ALREADY_ONBOARDED, + }); + } + + return { + session, + key: `${session.user.id}!${session.session.id}`, + valid: async (ctx: GenericEndpointContext) => { + return ctx.json({ + user: { + id: session.user.id, + email: session.user.email, + emailVerified: session.user.emailVerified, + firstName: session.user.firstName, + name: session.user.name, + image: session.user.image, + createdAt: session.user.createdAt, + updatedAt: session.user.updatedAt, + }, + }); + }, + }; +} diff --git a/packages/plugins/onboarding/tests/auth.ts b/packages/plugins/onboarding/tests/auth.ts new file mode 100644 index 0000000..7eba26a --- /dev/null +++ b/packages/plugins/onboarding/tests/auth.ts @@ -0,0 +1,39 @@ +import { onboarding } from "../src"; +import { betterAuth, capitalizeFirstLetter } from "better-auth"; +import database from "better-sqlite3"; +import { z } from "zod"; + +const db = database("test.db"); +const onboardingSchema = z.object({ + foo: z.string().optional(), +}); + +export const auth = betterAuth({ + database: db, + emailAndPassword: { + enabled: true, + }, + plugins: [ + onboarding({ + input: onboardingSchema, + async onComplete(ctx) { + return true; + }, + }), + ], +}); + +export const authFail = betterAuth({ + database: db, + emailAndPassword: { + enabled: true, + }, + plugins: [ + onboarding({ + input: onboardingSchema, + async onComplete(ctx) { + return false; + }, + }), + ], +}); diff --git a/packages/plugins/onboarding/tests/onboarding.test.ts b/packages/plugins/onboarding/tests/onboarding.test.ts new file mode 100644 index 0000000..b32bfec --- /dev/null +++ b/packages/plugins/onboarding/tests/onboarding.test.ts @@ -0,0 +1,165 @@ +import { getTestInstance } from "@better-auth-kit/tests"; +import { describe, beforeEach, expect, it, vi, beforeAll } from "vitest"; +import { onboardingClient } from "../src/client"; +import { ONBOARDING_ERROR_CODES } from "../src/error-codes"; +import { auth, authFail } from "./auth"; + +const mockOnboardingRedirect = vi.fn(); +describe("Onboarding", () => { + describe("(success)", async () => { + const { resetDatabase, client, signUpWithTestUser, testUser, db } = + await getTestInstance(auth, { + clientOptions: { + plugins: [ + onboardingClient({ + onOnboardingRedirect: mockOnboardingRedirect, + }), + ], + }, + }); + + let headers: Headers; + beforeAll(async () => { + await resetDatabase(); + const result = await signUpWithTestUser(); + headers = result.headers; + await db.update({ + model: "user", + where: [ + { + field: "email", + value: testUser.email, + }, + ], + update: { + shouldOnboard: true, + }, + }); + }); + + 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 successfully and return sanitized user", async () => { + const res = await client.onboarding.complete({ + foo: "bar", + fetchOptions: { + headers, + }, + }); + if (res.error) throw res.error; + await expect( + db.findOne<{ shouldOnboard?: boolean }>({ + model: "user", + where: [ + { + field: "email", + value: testUser.email, + }, + ], + select: ["shouldOnboard"], + }), + ).resolves.toEqual({ + shouldOnboard: false, + }); + expect(res.data?.user.id).toBeDefined(); + expect(res.data?.user.email).toBeDefined(); + }); + + 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.complete({ + fetchOptions: { + headers: new Headers(), + }, + }); + expect(res.error?.status).toBe(401); + }); + + it("should error when already onboarded", async () => { + await db.update({ + model: "user", + where: [ + { + field: "email", + value: testUser.email, + }, + ], + update: { + shouldOnboard: false, + }, + }); + const res = await client.onboarding.complete({ + fetchOptions: { + headers, + }, + }); + expect(res.error?.message).toBe(ONBOARDING_ERROR_CODES.ALREADY_ONBOARDED); + }); + }); + + describe("(failure)", async () => { + const { resetDatabase, client, signUpWithTestUser, testUser, db } = + await getTestInstance(authFail, { + clientOptions: { + plugins: [ + onboardingClient({ + onOnboardingRedirect: mockOnboardingRedirect, + }), + ], + }, + }); + + let headers: Headers; + beforeAll(async () => { + await resetDatabase(); + const result = await signUpWithTestUser(); + headers = result.headers; + }); + + it("should reject onboarding when onComplete returns false", async () => { + await db.update({ + model: "user", + where: [ + { + field: "email", + value: testUser.email, + }, + ], + update: { + shouldOnboard: true, + }, + }); + const result = await client.onboarding.complete({ + fetchOptions: { + headers: headers, + }, + }); + + expect(result.error?.message).toBe( + ONBOARDING_ERROR_CODES.FAILED_TO_COMPLETE_ONBOARDING, + ); + }); + }); +}); diff --git a/packages/plugins/onboarding/tests/test.db b/packages/plugins/onboarding/tests/test.db new file mode 100644 index 0000000000000000000000000000000000000000..dfd8cc0506cef91cacb1d0483a275b969ed01394 GIT binary patch literal 45056 zcmeI*&r;h)90zbYB>Ww4E-5q3Au62=rBj%dtUneV&%{s%3>de>0Yaz4th_6uU`s}l z3FZcAI=OW^(`V=l^ac6`eSw^N&9%p_9EaEh*QAF|NWQ|dS3j-f-OsMaNLt$uzFTu6 zE<1iuqmi7FZc2%SbWfHgX>d-Gq%kqSo^FW5_16pHG0~Ckkrexw`{~Q^i9e*#k>}FL z^W>k&-^TM3zf9O;zm9)8`fTj`9vdPI0SG_<0uX=z1U`6yljFqD|Qhou%JZI+w(kfzW>n&g1Hx=L zb{iLSBKfl+Msj>CA1Cg`cXMUGrV>@3CFxmJDJkYWQRWpgmr9eJ zUhz>;uSD{NByN}>009U<00Izz00bZa0SG_<0uZ=@0-p~Q`kqC2b^QNQO1`{;6-1FC z009U<00Izz00bZa0SG_<0uZ=J;O4;eNY{9QSL6Q|rR3s8*%5;P1Rwwb2tWV=5P$## zAOHafK%gyfGA{=0X9}6jo$XD9Yg;M0vf&)88pYz-F#L4q z@O*7&Z)ra`+;Z!OJIfErdb9e-UE0bXZx+|w?S;zmgC4{8wVrWBHRp)93PfB*y_009U<00Izz00bZa zf%y2p-@t;_`2UIDC2_+90SG_<0uX=z1Rwwb2tWV=5O|*iPFE7iLgBN*t1_JyGnr;^ zoZr%%Z_-8UxDH%K`oDBj zl9I)D-#@m100bZa0SG_<0uX=z1Rwwb2z;ahCv%CR!m9W#z)Z Date: Thu, 21 Aug 2025 22:40:40 +0200 Subject: [PATCH 02/20] chore: update tests --- packages/plugins/onboarding/src/types.ts | 8 +- packages/plugins/onboarding/tests/auth.ts | 48 +++---- .../onboarding/tests/onboarding.test.ts | 121 ++++++++++++++++-- 3 files changed, 133 insertions(+), 44 deletions(-) diff --git a/packages/plugins/onboarding/src/types.ts b/packages/plugins/onboarding/src/types.ts index e1ddba0..c36f2f3 100644 --- a/packages/plugins/onboarding/src/types.ts +++ b/packages/plugins/onboarding/src/types.ts @@ -6,7 +6,9 @@ import type { import type { ZodSchema } from "zod"; import type { schema } from "./schema"; -type ActionEndpointContext> = ( +type ActionEndpointContext< + Schema extends Record = Record, +> = ( ctx: EndpointContext< string, { @@ -18,7 +20,9 @@ type ActionEndpointContext> = ( }, ) => boolean | Promise; -export type OnboardingOptions> = { +export type OnboardingOptions< + Schema extends Record = Record, +> = { input: ZodSchema; onComplete: ActionEndpointContext; autoEnableOnSignUp?: boolean; diff --git a/packages/plugins/onboarding/tests/auth.ts b/packages/plugins/onboarding/tests/auth.ts index 7eba26a..da03560 100644 --- a/packages/plugins/onboarding/tests/auth.ts +++ b/packages/plugins/onboarding/tests/auth.ts @@ -1,4 +1,4 @@ -import { onboarding } from "../src"; +import { onboarding, type OnboardingOptions } from "../src"; import { betterAuth, capitalizeFirstLetter } from "better-auth"; import database from "better-sqlite3"; import { z } from "zod"; @@ -8,32 +8,20 @@ const onboardingSchema = z.object({ foo: z.string().optional(), }); -export const auth = betterAuth({ - database: db, - emailAndPassword: { - enabled: true, - }, - plugins: [ - onboarding({ - input: onboardingSchema, - async onComplete(ctx) { - return true; - }, - }), - ], -}); - -export const authFail = betterAuth({ - database: db, - emailAndPassword: { - enabled: true, - }, - plugins: [ - onboarding({ - input: onboardingSchema, - async onComplete(ctx) { - return false; - }, - }), - ], -}); +export const getAuth = (options?: Partial) => { + return betterAuth({ + database: db, + emailAndPassword: { + enabled: true, + }, + plugins: [ + onboarding({ + input: onboardingSchema, + async onComplete(ctx) { + return true; + }, + ...options, + }), + ], + }); +}; \ No newline at end of file diff --git a/packages/plugins/onboarding/tests/onboarding.test.ts b/packages/plugins/onboarding/tests/onboarding.test.ts index b32bfec..2ed651f 100644 --- a/packages/plugins/onboarding/tests/onboarding.test.ts +++ b/packages/plugins/onboarding/tests/onboarding.test.ts @@ -1,14 +1,14 @@ import { getTestInstance } from "@better-auth-kit/tests"; -import { describe, beforeEach, expect, it, vi, beforeAll } from "vitest"; +import { describe, expect, it, vi, beforeAll, beforeEach } from "vitest"; import { onboardingClient } from "../src/client"; import { ONBOARDING_ERROR_CODES } from "../src/error-codes"; -import { auth, authFail } from "./auth"; +import { getAuth } from "./auth"; const mockOnboardingRedirect = vi.fn(); describe("Onboarding", () => { describe("(success)", async () => { const { resetDatabase, client, signUpWithTestUser, testUser, db } = - await getTestInstance(auth, { + await getTestInstance(getAuth(), { clientOptions: { plugins: [ onboardingClient({ @@ -23,6 +23,9 @@ describe("Onboarding", () => { await resetDatabase(); const result = await signUpWithTestUser(); headers = result.headers; + }); + + beforeEach(async () => { await db.update({ model: "user", where: [ @@ -84,6 +87,37 @@ describe("Onboarding", () => { expect(res.data?.user.email).toBeDefined(); }); + it("should not trigger redirect via getSession after completing onboarding", async () => { + mockOnboardingRedirect.mockClear(); + await client.onboarding.complete({ + fetchOptions: { + headers, + }, + }); + await client.getSession({ + fetchOptions: { + headers, + throw: true, + }, + }); + expect(mockOnboardingRedirect).not.toHaveBeenCalled(); + }); + + it("should return unauthorized on shouldOnboard when already onboarded", async () => { + await client.onboarding.complete({ + fetchOptions: { + headers, + }, + }); + const { error } = await client.onboarding.shouldOnboard({ + fetchOptions: { + headers, + }, + }); + expect(error?.status).toBe(401); + 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); @@ -121,15 +155,23 @@ describe("Onboarding", () => { describe("(failure)", async () => { const { resetDatabase, client, signUpWithTestUser, testUser, db } = - await getTestInstance(authFail, { - clientOptions: { - plugins: [ - onboardingClient({ - onOnboardingRedirect: mockOnboardingRedirect, - }), - ], + await getTestInstance( + getAuth({ + autoEnableOnSignUp: true, + async onComplete(ctx) { + return false; + }, + }), + { + clientOptions: { + plugins: [ + onboardingClient({ + onOnboardingRedirect: mockOnboardingRedirect, + }), + ], + }, }, - }); + ); let headers: Headers; beforeAll(async () => { @@ -153,7 +195,7 @@ describe("Onboarding", () => { }); const result = await client.onboarding.complete({ fetchOptions: { - headers: headers, + headers, }, }); @@ -161,5 +203,60 @@ describe("Onboarding", () => { ONBOARDING_ERROR_CODES.FAILED_TO_COMPLETE_ONBOARDING, ); }); + + it("should reject onboarding with invalid schema body", async () => { + const res = await client.onboarding.complete({ + foo: 123, + fetchOptions: { + headers, + }, + }); + expect(res.error?.status).toBe(400); + }); + }); + + describe("(auto enable on sign-up)", async () => { + const { resetDatabase, signUpWithTestUser } = await getTestInstance( + getAuth(), + { + clientOptions: { + plugins: [ + onboardingClient({ + onOnboardingRedirect: mockOnboardingRedirect, + }), + ], + }, + }, + ); + + 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, + }), + ], + }, + }, + ); + await signUpWithTestUser(); + expect(mockOnboardingRedirect).not.toHaveBeenCalled(); + }); }); }); From 4c67766c4a635840de87382c48c6df2269e3540e Mon Sep 17 00:00:00 2001 From: Joel Solano Date: Thu, 21 Aug 2025 23:02:12 +0200 Subject: [PATCH 03/20] chore: cleanup --- packages/plugins/onboarding/src/client.ts | 21 ++++++++++++++++++--- packages/plugins/onboarding/src/index.ts | 3 +++ packages/plugins/onboarding/src/types.ts | 15 +++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/plugins/onboarding/src/client.ts b/packages/plugins/onboarding/src/client.ts index b64061b..cd4655b 100644 --- a/packages/plugins/onboarding/src/client.ts +++ b/packages/plugins/onboarding/src/client.ts @@ -1,9 +1,22 @@ -import type { BetterAuthClientPlugin } from "better-auth"; +import type { BetterAuthClientPlugin, BetterAuthPlugin } from "better-auth"; import type { onboarding } from "."; import type { ZodSchema } from "zod"; +type InferSchema = T extends { + $Infer: { + OnboardingInput: infer Schema extends Record; + }; +} + ? Schema + : T extends Record + ? T + : never; + export const onboardingClient = >(options?: { - schema?: ZodSchema; + /** + * Zod schema for validating the onboarding input data + */ + input?: ZodSchema>; /** * a redirect function to call if a user needs * to be onboarded @@ -12,7 +25,9 @@ export const onboardingClient = >(options?: { }) => { return { id: "onboarding", - $InferServerPlugin: {} as ReturnType>, + $InferServerPlugin: {} as ReturnType< + typeof onboarding> + >, atomListeners: [ { matcher: (path) => path.startsWith("/onboarding/"), diff --git a/packages/plugins/onboarding/src/index.ts b/packages/plugins/onboarding/src/index.ts index 95fd724..2a8bdfd 100644 --- a/packages/plugins/onboarding/src/index.ts +++ b/packages/plugins/onboarding/src/index.ts @@ -81,6 +81,9 @@ export const onboarding = >( ], schema: mergeSchema(schema, opts?.schema), $ERROR_CODES: ONBOARDING_ERROR_CODES, + $Infer: { + OnboardingInput: {} as Schema, + }, } satisfies BetterAuthPlugin; }; diff --git a/packages/plugins/onboarding/src/types.ts b/packages/plugins/onboarding/src/types.ts index c36f2f3..2117a5c 100644 --- a/packages/plugins/onboarding/src/types.ts +++ b/packages/plugins/onboarding/src/types.ts @@ -23,8 +23,23 @@ type ActionEndpointContext< export type OnboardingOptions< Schema extends Record = Record, > = { + /** + * Zod schema for validating the onboarding input data + */ input: ZodSchema; + /** + * Function that gets executed when onboarding is completed + * @param ctx The endpoint context containing the request body and auth context + * @returns boolean indicating if the onboarding completion was successful + */ onComplete: ActionEndpointContext; + /** + * Whether to automatically enable onboarding for new users during sign up + * @default false + */ autoEnableOnSignUp?: boolean; + /** + * Custom schema configuration for the onboarding plugin + */ schema?: InferOptionSchema; }; From 2cc5e1fa10c787c9c1aa2dff7f308d5bb41c31e9 Mon Sep 17 00:00:00 2001 From: Joel Solano Date: Fri, 22 Aug 2025 13:37:36 +0200 Subject: [PATCH 04/20] refactor: support multiple steps --- packages/plugins/onboarding/src/client.ts | 48 ++++--- packages/plugins/onboarding/src/index.ts | 132 +++++++++++++++++- .../plugins/onboarding/src/internal-types.ts | 67 +++++++++ packages/plugins/onboarding/src/types.ts | 45 +++--- packages/plugins/onboarding/src/utils.ts | 60 ++++++++ .../onboarding/src/verify-onboarding.ts | 16 +-- packages/plugins/onboarding/tests/auth.ts | 33 +++-- .../onboarding/tests/onboarding.test.ts | 113 +++------------ packages/plugins/onboarding/tests/test.db | Bin 45056 -> 45056 bytes 9 files changed, 340 insertions(+), 174 deletions(-) create mode 100644 packages/plugins/onboarding/src/internal-types.ts create mode 100644 packages/plugins/onboarding/src/utils.ts diff --git a/packages/plugins/onboarding/src/client.ts b/packages/plugins/onboarding/src/client.ts index cd4655b..cf36f01 100644 --- a/packages/plugins/onboarding/src/client.ts +++ b/packages/plugins/onboarding/src/client.ts @@ -1,22 +1,25 @@ import type { BetterAuthClientPlugin, BetterAuthPlugin } from "better-auth"; -import type { onboarding } from "."; -import type { ZodSchema } from "zod"; +import type { onboarding, OnboardingStep } from "."; -type InferSchema = T extends { +type InferSteps = T extends { $Infer: { - OnboardingInput: infer Schema extends Record; + OnboardingSteps: infer Steps extends Record; }; } - ? Schema - : T extends Record + ? Steps + : T extends Record ? T : never; -export const onboardingClient = >(options?: { - /** - * Zod schema for validating the onboarding input data - */ - input?: ZodSchema>; +export const onboardingClient = < + Steps extends + | { + $Infer: { + OnboardingSteps: Record; + }; + } + | Record, +>(options?: { /** * a redirect function to call if a user needs * to be onboarded @@ -25,19 +28,14 @@ export const onboardingClient = >(options?: { }) => { return { id: "onboarding", - $InferServerPlugin: {} as ReturnType< - typeof onboarding> - >, + $InferServerPlugin: {} as ReturnType>>, atomListeners: [ { matcher: (path) => path.startsWith("/onboarding/"), signal: "$sessionSignal", }, ], - pathMethods: { - "/onboarding/complete": "POST", - "/onboarding/should-onboard": "GET", - }, + fetchPlugins: [ { id: "onboarding", @@ -50,6 +48,20 @@ export const onboardingClient = >(options?: { } } }, + async onRequest(context) { + if ( + !new URL(context.url).pathname.startsWith( + `${new URL(context.baseURL ?? "/api/auth").pathname}/onboarding/step`, + ) + ) { + return; + } + + return { + ...context, + method: "POST", + }; + }, }, }, ], diff --git a/packages/plugins/onboarding/src/index.ts b/packages/plugins/onboarding/src/index.ts index 2a8bdfd..fdbc8cb 100644 --- a/packages/plugins/onboarding/src/index.ts +++ b/packages/plugins/onboarding/src/index.ts @@ -1,25 +1,132 @@ -import type { OnboardingOptions } from "./types"; -import type { BetterAuthPlugin } from "better-auth"; +import { + betterAuth, + type BetterAuthPlugin, + type PrettifyDeep, +} from "better-auth"; import { mergeSchema } from "better-auth/db"; import { schema } from "./schema"; import { ONBOARDING_ERROR_CODES } from "./error-codes"; -import { createAuthMiddleware } from "better-auth/api"; +import { + createAuthEndpoint, + createAuthMiddleware, + APIError, +} from "better-auth/api"; import { shouldOnboard } from "./routes/should-onboard"; import { completeOnboarding } from "./routes/complete-onboard"; +import type { OnboardingOptions, OnboardingStep } from "./types"; +import type { + CanAccessOnboardingStepReturnType, + Merged, + OnboardingStepReturnType, + OnboardingStepsToEndpoints, +} from "./internal-types"; +import { transformClientPath, transformPath } from "./utils"; +import { verifyOnboarding } from "./verify-onboarding"; +import { z } from "zod"; -export const onboarding = >( - options: OnboardingOptions, +export const onboarding = >( + options: OnboardingOptions, ) => { const opts = { autoEnableOnSignUp: true, ...options, }; + const endpoints = Object.fromEntries( + Object.entries(options.steps).flatMap(([id, step]) => { + const isCompletionStep = options.completionStep === id; + const key = transformPath(id); + const path = transformClientPath(id); + + const entries = Object.entries({ + [`onboardingStep${key}`]: createAuthEndpoint( + `/onboarding/step/${path}`, + { + method: "POST", + body: step.input, + }, + async (ctx): Promise> => { + const { session } = await verifyOnboarding(ctx); + + const completedSteps = new Set( + ( + (await ctx.context.adapter.findOne<{ + completedSteps?: string[]; + }>({ + model: "user", + where: [ + { + field: "id", + value: session.user.id, + }, + ], + select: ["completedSteps"], + })) ?? {} + ).completedSteps, + ); + + if (step.once && completedSteps.has(id)) { + throw new APIError("FORBIDDEN", { + message: "Already completed this step", + }); + } + + const result = await step.handler(ctx); + + const update: Record = { + completedSteps: [...completedSteps.add(id)], + }; + + if (isCompletionStep) { + update.shouldOnboard = false; + } + + await ctx.context.adapter.update({ + model: "user", + where: [ + { + field: "id", + value: session.user.id, + }, + ], + update, + }); + + return { + completedSteps: update.completedSteps, + data: result, + }; + }, + ), + [`canAccessOnboardingStep${key}`]: createAuthEndpoint( + `/onboarding/can-access-step/${path}`, + { + method: "GET", + metadata: { + SERVER_ONLY: true, + }, + }, + async ( + ctx, + ): Promise> => { + if (step.once) { + } + + return true; + }, + ), + }); + + return entries; + }), + ) as PrettifyDeep>>; + return { id: "onboarding", endpoints: { shouldOnboard, completeOnboarding: completeOnboarding(opts), + ...endpoints, }, hooks: { after: [ @@ -82,10 +189,23 @@ export const onboarding = >( schema: mergeSchema(schema, opts?.schema), $ERROR_CODES: ONBOARDING_ERROR_CODES, $Infer: { - OnboardingInput: {} as Schema, + OnboardingSteps: {} as Steps, }, } satisfies BetterAuthPlugin; }; +export const createOnboardingStep = < + Schema extends Record | undefined | null, + Result = unknown, +>( + def: OnboardingStep, +) => { + return { + once: true, + input: z.record(z.any()).nullish(), + ...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..4cfcb0a --- /dev/null +++ b/packages/plugins/onboarding/src/internal-types.ts @@ -0,0 +1,67 @@ +import type { createAuthEndpoint } from "better-auth/api"; +import type { OnboardingStep } from "./types"; +import type { ZodSchema } from "zod"; +import type { TransformClientPath, TransformPath } from "./utils"; + +type InferStepInput = K extends { input?: infer I } + ? I extends ZodSchema + ? I + : never + : never; + +type InferStepResult = K extends OnboardingStep< + any, + infer R +> + ? R + : never; + +export type OnboardingStepReturnType = { + completedSteps: string[]; + data: InferStepResult; +}; + +export type CanAccessOnboardingStepReturnType = + boolean; + +export type EndpointPair = { + onboardingStep: ReturnType< + typeof createAuthEndpoint< + `/onboarding/step/${TransformClientPath}`, + { + method: "POST"; + body: InferStepInput; + }, + OnboardingStepReturnType + > + >; + canAccessOnboardingStep: ReturnType< + typeof createAuthEndpoint< + `/onboarding/can-access-step/${TransformClientPath}`, + { + method: "GET"; + metadata: { + SERVER_ONLY: true; + }; + }, + CanAccessOnboardingStepReturnType + > + >; +}; + +type PrefixedEndpoints = { + [K in keyof EndpointPair< + Path, + S + > as `${Extract}${TransformPath}`]: EndpointPair[K]; +}; + +export type OnboardingStepsToEndpoints< + S extends Record>, +> = { + [K in keyof S & string]: PrefixedEndpoints; +}; + +export type Merged = { + [K in keyof T]: T[K]; +}[keyof T]; diff --git a/packages/plugins/onboarding/src/types.ts b/packages/plugins/onboarding/src/types.ts index 2117a5c..926c05c 100644 --- a/packages/plugins/onboarding/src/types.ts +++ b/packages/plugins/onboarding/src/types.ts @@ -1,38 +1,18 @@ -import type { - AuthContext, - EndpointContext, - InferOptionSchema, -} from "better-auth"; +import type { GenericEndpointContext, InferOptionSchema } from "better-auth"; import type { ZodSchema } from "zod"; import type { schema } from "./schema"; -type ActionEndpointContext< - Schema extends Record = Record, -> = ( - ctx: EndpointContext< - string, - { - body: ZodSchema; - method: "POST"; - } - > & { - context: AuthContext; +type ActionEndpointContext = ( + ctx: Omit & { + body: Schema; }, -) => boolean | Promise; +) => Result | Promise; export type OnboardingOptions< - Schema extends Record = Record, + Steps extends Record = any, > = { - /** - * Zod schema for validating the onboarding input data - */ - input: ZodSchema; - /** - * Function that gets executed when onboarding is completed - * @param ctx The endpoint context containing the request body and auth context - * @returns boolean indicating if the onboarding completion was successful - */ - onComplete: ActionEndpointContext; + steps: Steps; + completionStep: keyof Steps; /** * Whether to automatically enable onboarding for new users during sign up * @default false @@ -43,3 +23,12 @@ export type OnboardingOptions< */ schema?: InferOptionSchema; }; + +export type OnboardingStep< + Schema extends Record | undefined | null = any, + Result = unknown, +> = { + input?: ZodSchema; + handler: ActionEndpointContext; + once?: boolean; +}; diff --git a/packages/plugins/onboarding/src/utils.ts b/packages/plugins/onboarding/src/utils.ts new file mode 100644 index 0000000..feadbe4 --- /dev/null +++ b/packages/plugins/onboarding/src/utils.ts @@ -0,0 +1,60 @@ +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 index === 0 + ? segment.charAt(0).toUpperCase() + segment.slice(1) + : 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-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}` | `/${infer F}` + ? 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; diff --git a/packages/plugins/onboarding/src/verify-onboarding.ts b/packages/plugins/onboarding/src/verify-onboarding.ts index 2cb7740..4b9572c 100644 --- a/packages/plugins/onboarding/src/verify-onboarding.ts +++ b/packages/plugins/onboarding/src/verify-onboarding.ts @@ -10,7 +10,7 @@ export async function verifyOnboarding(ctx: GenericEndpointContext) { } if (!session.user.shouldOnboard) { - throw new APIError("UNAUTHORIZED", { + throw new APIError("FORBIDDEN", { message: ONBOARDING_ERROR_CODES.ALREADY_ONBOARDED, }); } @@ -18,19 +18,5 @@ export async function verifyOnboarding(ctx: GenericEndpointContext) { return { session, key: `${session.user.id}!${session.session.id}`, - valid: async (ctx: GenericEndpointContext) => { - return ctx.json({ - user: { - id: session.user.id, - email: session.user.email, - emailVerified: session.user.emailVerified, - firstName: session.user.firstName, - name: session.user.name, - image: session.user.image, - createdAt: session.user.createdAt, - updatedAt: session.user.updatedAt, - }, - }); - }, }; } diff --git a/packages/plugins/onboarding/tests/auth.ts b/packages/plugins/onboarding/tests/auth.ts index da03560..6f23246 100644 --- a/packages/plugins/onboarding/tests/auth.ts +++ b/packages/plugins/onboarding/tests/auth.ts @@ -1,27 +1,40 @@ -import { onboarding, type OnboardingOptions } from "../src"; -import { betterAuth, capitalizeFirstLetter } from "better-auth"; +import { + onboarding, + type OnboardingOptions, + createOnboardingStep, +} from "../src"; +import { betterAuth } from "better-auth"; import database from "better-sqlite3"; import { z } from "zod"; const db = database("test.db"); -const onboardingSchema = z.object({ - foo: z.string().optional(), -}); +const onboardingSchema = z + .object({ + foo: z.string().optional(), + }) + .nullish(); export const getAuth = (options?: Partial) => { - return betterAuth({ + const auth = betterAuth({ database: db, emailAndPassword: { enabled: true, }, plugins: [ onboarding({ - input: onboardingSchema, - async onComplete(ctx) { - return true; + steps: { + newPassword: createOnboardingStep({ + input: onboardingSchema, + handler: async (ctx) => { + return true; + }, + }), }, + completionStep: "newPassword", ...options, }), ], }); -}; \ No newline at end of file + + return auth; +}; diff --git a/packages/plugins/onboarding/tests/onboarding.test.ts b/packages/plugins/onboarding/tests/onboarding.test.ts index 2ed651f..1061119 100644 --- a/packages/plugins/onboarding/tests/onboarding.test.ts +++ b/packages/plugins/onboarding/tests/onboarding.test.ts @@ -7,8 +7,9 @@ import { getAuth } from "./auth"; const mockOnboardingRedirect = vi.fn(); describe("Onboarding", () => { describe("(success)", async () => { + const auth = getAuth(); const { resetDatabase, client, signUpWithTestUser, testUser, db } = - await getTestInstance(getAuth(), { + await getTestInstance(auth, { clientOptions: { plugins: [ onboardingClient({ @@ -61,35 +62,21 @@ describe("Onboarding", () => { expect(mockOnboardingRedirect).toHaveBeenCalled(); }); - it("should complete onboarding successfully and return sanitized user", async () => { - const res = await client.onboarding.complete({ + 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; - await expect( - db.findOne<{ shouldOnboard?: boolean }>({ - model: "user", - where: [ - { - field: "email", - value: testUser.email, - }, - ], - select: ["shouldOnboard"], - }), - ).resolves.toEqual({ - shouldOnboard: false, - }); - expect(res.data?.user.id).toBeDefined(); - expect(res.data?.user.email).toBeDefined(); + 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.onboarding.complete({ + await (client as any).onboarding.step.newPassword({ fetchOptions: { headers, }, @@ -103,8 +90,8 @@ describe("Onboarding", () => { expect(mockOnboardingRedirect).not.toHaveBeenCalled(); }); - it("should return unauthorized on shouldOnboard when already onboarded", async () => { - await client.onboarding.complete({ + it("should return forbidden on shouldOnboard when already onboarded", async () => { + await (client.onboarding as any).step.newPassword({ fetchOptions: { headers, }, @@ -114,7 +101,7 @@ describe("Onboarding", () => { headers, }, }); - expect(error?.status).toBe(401); + expect(error?.status).toBe(403); expect(error?.message).toBe(ONBOARDING_ERROR_CODES.ALREADY_ONBOARDED); }); @@ -122,8 +109,9 @@ describe("Onboarding", () => { const { error } = await client.onboarding.shouldOnboard(); expect(error?.status).toBe(401); }); + it("should fail onboarding without session", async () => { - const res = await client.onboarding.complete({ + const res = await (client.onboarding as any).step.newPassword({ fetchOptions: { headers: new Headers(), }, @@ -131,87 +119,18 @@ describe("Onboarding", () => { expect(res.error?.status).toBe(401); }); - it("should error when already onboarded", async () => { - await db.update({ - model: "user", - where: [ - { - field: "email", - value: testUser.email, - }, - ], - update: { - shouldOnboard: false, - }, - }); - const res = await client.onboarding.complete({ + it("should error when completing the same step twice if once is true", async () => { + await (client.onboarding as any).step.newPassword({ fetchOptions: { headers, }, }); - expect(res.error?.message).toBe(ONBOARDING_ERROR_CODES.ALREADY_ONBOARDED); - }); - }); - - describe("(failure)", async () => { - const { resetDatabase, client, signUpWithTestUser, testUser, db } = - await getTestInstance( - getAuth({ - autoEnableOnSignUp: true, - async onComplete(ctx) { - return false; - }, - }), - { - clientOptions: { - plugins: [ - onboardingClient({ - onOnboardingRedirect: mockOnboardingRedirect, - }), - ], - }, - }, - ); - - let headers: Headers; - beforeAll(async () => { - await resetDatabase(); - const result = await signUpWithTestUser(); - headers = result.headers; - }); - - it("should reject onboarding when onComplete returns false", async () => { - await db.update({ - model: "user", - where: [ - { - field: "email", - value: testUser.email, - }, - ], - update: { - shouldOnboard: true, - }, - }); - const result = await client.onboarding.complete({ - fetchOptions: { - headers, - }, - }); - - expect(result.error?.message).toBe( - ONBOARDING_ERROR_CODES.FAILED_TO_COMPLETE_ONBOARDING, - ); - }); - - it("should reject onboarding with invalid schema body", async () => { - const res = await client.onboarding.complete({ - foo: 123, + const res = await (client.onboarding as any).step.newPassword({ fetchOptions: { headers, }, }); - expect(res.error?.status).toBe(400); + expect(res.error?.status).toBe(403); }); }); diff --git a/packages/plugins/onboarding/tests/test.db b/packages/plugins/onboarding/tests/test.db index dfd8cc0506cef91cacb1d0483a275b969ed01394..bed1df132706e94d35216576ea5bd22d9319f3a5 100644 GIT binary patch delta 753 zcmaiy%Wl(95I|AZi0l!Pjl{Oq+_?|m8?k9CLQ1IV!X=Sc+?RuqW0S_gw43x3TJ;b3 z1JWIP{v(L*K#)pMMHP}Y(k$kA=5ev^T&z2nk2?>iS5Njg`s+ItJbjSP`|(*l7#=r; zoH#o=sucYU3!?F$pR3wke+zksuDf9y_UbmMjdAty`=e$5KsUpq!!n-AiA@$s zSoVX z4SWOBoqOLS;&X^bC?yo(GBAseVdgjU&E=+cxv5<}tlevTe$?9LZ*M7jeBU3Y&{GHd z3Wu}aa4umRIeU`+-MZeWanau3WN?&{aSqhIy7s@)or zXm$GOq?gky^UL?{pbVB^B#u*Vk6wlIu1b>I{$_Ws7{6|)#*LVr0unY7bq z<(uSqGHMm&>;H2ZaaYHiMZ}n)ZooEJC3d{2$5q(te??V{>)DS7tL3~rOhtHM5-JCa zv+jphXK<8gA^ZJK(b_MnaPql#{_Nw#POBCxs!GC?JT;Do~lu6bPfNa~`Z@UU8L49uxd-t0R-gWK9G`#*3{jA4T7yNn^{{=6x;5`5U From 2f13bb89a0155a7e46e4a3dd2b93dd5ea7f9b9ba Mon Sep 17 00:00:00 2001 From: Joel Solano Date: Fri, 22 Aug 2025 13:41:41 +0200 Subject: [PATCH 05/20] chore: update schema --- packages/plugins/onboarding/src/schema.ts | 6 ++++++ packages/plugins/onboarding/tests/test.db | Bin 45056 -> 45056 bytes 2 files changed, 6 insertions(+) diff --git a/packages/plugins/onboarding/src/schema.ts b/packages/plugins/onboarding/src/schema.ts index 3a14e2a..985f8a1 100644 --- a/packages/plugins/onboarding/src/schema.ts +++ b/packages/plugins/onboarding/src/schema.ts @@ -5,6 +5,12 @@ export const schema = { fields: { shouldOnboard: { type: "boolean", + required: false, + }, + completedSteps: { + type: "string[]", + required: false, + input: false, }, }, }, diff --git a/packages/plugins/onboarding/tests/test.db b/packages/plugins/onboarding/tests/test.db index bed1df132706e94d35216576ea5bd22d9319f3a5..d5e72914ec06eed8339a7f7b9a8ed8b9b8e9e70a 100644 GIT binary patch delta 91 zcmZp8z|`=7X@az19s>gdHxR=B+e95>{yYY~yj{F}2N<|Giy1hJH|DW&ay9WYvWts~ nGPW>Ip2Yb{R7W8>Ker$!wInqqxFofpSRuqUB4l$5SItrYjJOv| delta 72 zcmZp8z|`=7X@az1E&~GtHxR=B>qH%6{#*vVHZ5Mh0}Nc89Socu8}nE>Cr%LMYOG>p U7Z(*}Y!07X%lT<@3s=oj03vJ=NB{r; From fee838b8a2f4f08c3048a36d0b97c7ab5c17a6ec Mon Sep 17 00:00:00 2001 From: Joel Solano Date: Fri, 22 Aug 2025 13:46:26 +0200 Subject: [PATCH 06/20] chore: cleanup --- packages/plugins/onboarding/src/client.ts | 2 +- .../plugins/onboarding/src/error-codes.ts | 2 +- packages/plugins/onboarding/src/index.ts | 13 +++--- .../plugins/onboarding/src/internal-types.ts | 4 +- .../onboarding/src/routes/complete-onboard.ts | 43 ------------------- 5 files changed, 10 insertions(+), 54 deletions(-) delete mode 100644 packages/plugins/onboarding/src/routes/complete-onboard.ts diff --git a/packages/plugins/onboarding/src/client.ts b/packages/plugins/onboarding/src/client.ts index cf36f01..678bd55 100644 --- a/packages/plugins/onboarding/src/client.ts +++ b/packages/plugins/onboarding/src/client.ts @@ -1,4 +1,4 @@ -import type { BetterAuthClientPlugin, BetterAuthPlugin } from "better-auth"; +import type { BetterAuthClientPlugin } from "better-auth"; import type { onboarding, OnboardingStep } from "."; type InferSteps = T extends { diff --git a/packages/plugins/onboarding/src/error-codes.ts b/packages/plugins/onboarding/src/error-codes.ts index ce285a1..505405a 100644 --- a/packages/plugins/onboarding/src/error-codes.ts +++ b/packages/plugins/onboarding/src/error-codes.ts @@ -1,4 +1,4 @@ export const ONBOARDING_ERROR_CODES = { ALREADY_ONBOARDED: "Already onboarded", - FAILED_TO_COMPLETE_ONBOARDING: "Failed to complete onboarding." + STEP_ALREADY_COMPLETED: "Step already completed.", } as const; diff --git a/packages/plugins/onboarding/src/index.ts b/packages/plugins/onboarding/src/index.ts index fdbc8cb..5ccfdc8 100644 --- a/packages/plugins/onboarding/src/index.ts +++ b/packages/plugins/onboarding/src/index.ts @@ -1,8 +1,4 @@ -import { - betterAuth, - type BetterAuthPlugin, - type PrettifyDeep, -} from "better-auth"; +import type { BetterAuthPlugin, PrettifyDeep } from "better-auth"; import { mergeSchema } from "better-auth/db"; import { schema } from "./schema"; import { ONBOARDING_ERROR_CODES } from "./error-codes"; @@ -10,9 +6,9 @@ import { createAuthEndpoint, createAuthMiddleware, APIError, + sessionMiddleware, } from "better-auth/api"; import { shouldOnboard } from "./routes/should-onboard"; -import { completeOnboarding } from "./routes/complete-onboard"; import type { OnboardingOptions, OnboardingStep } from "./types"; import type { CanAccessOnboardingStepReturnType, @@ -44,6 +40,7 @@ export const onboarding = >( { method: "POST", body: step.input, + use: [sessionMiddleware], }, async (ctx): Promise> => { const { session } = await verifyOnboarding(ctx); @@ -67,7 +64,7 @@ export const onboarding = >( if (step.once && completedSteps.has(id)) { throw new APIError("FORBIDDEN", { - message: "Already completed this step", + message: ONBOARDING_ERROR_CODES.STEP_ALREADY_COMPLETED, }); } @@ -102,6 +99,7 @@ export const onboarding = >( `/onboarding/can-access-step/${path}`, { method: "GET", + use: [sessionMiddleware], metadata: { SERVER_ONLY: true, }, @@ -125,7 +123,6 @@ export const onboarding = >( id: "onboarding", endpoints: { shouldOnboard, - completeOnboarding: completeOnboarding(opts), ...endpoints, }, hooks: { diff --git a/packages/plugins/onboarding/src/internal-types.ts b/packages/plugins/onboarding/src/internal-types.ts index 4cfcb0a..7537632 100644 --- a/packages/plugins/onboarding/src/internal-types.ts +++ b/packages/plugins/onboarding/src/internal-types.ts @@ -1,4 +1,4 @@ -import type { createAuthEndpoint } from "better-auth/api"; +import type { createAuthEndpoint, sessionMiddleware } from "better-auth/api"; import type { OnboardingStep } from "./types"; import type { ZodSchema } from "zod"; import type { TransformClientPath, TransformPath } from "./utils"; @@ -31,6 +31,7 @@ export type EndpointPair = { { method: "POST"; body: InferStepInput; + use: [typeof sessionMiddleware], }, OnboardingStepReturnType > @@ -40,6 +41,7 @@ export type EndpointPair = { `/onboarding/can-access-step/${TransformClientPath}`, { method: "GET"; + use: [typeof sessionMiddleware], metadata: { SERVER_ONLY: true; }; diff --git a/packages/plugins/onboarding/src/routes/complete-onboard.ts b/packages/plugins/onboarding/src/routes/complete-onboard.ts deleted file mode 100644 index 6cb12b3..0000000 --- a/packages/plugins/onboarding/src/routes/complete-onboard.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - createAuthEndpoint, - sessionMiddleware, - APIError, -} from "better-auth/api"; -import { verifyOnboarding } from "../verify-onboarding"; -import type { OnboardingOptions } from "../types"; -import { ONBOARDING_ERROR_CODES } from "../error-codes"; - -export const completeOnboarding = >( - options: OnboardingOptions, -) => - createAuthEndpoint( - "/onboarding/complete", - { - method: "POST", - body: options.input, - use: [sessionMiddleware], - }, - async (ctx) => { - const { valid } = await verifyOnboarding(ctx); - - if (await options.onComplete(ctx)) { - await ctx.context.adapter.update({ - model: "user", - where: [ - { - field: "id", - value: ctx.context.session.user.id, - }, - ], - update: { - shouldOnboard: false, - }, - }); - return valid(ctx); - } - - throw new APIError("BAD_REQUEST", { - message: ONBOARDING_ERROR_CODES.FAILED_TO_COMPLETE_ONBOARDING, - }); - }, - ); From e15a277b09970498e547c2ea1e0653b35349c150 Mon Sep 17 00:00:00 2001 From: Joel Solano Date: Fri, 22 Aug 2025 14:24:56 +0200 Subject: [PATCH 07/20] feat: add can access onboarding step endpoint --- packages/plugins/onboarding/src/index.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/plugins/onboarding/src/index.ts b/packages/plugins/onboarding/src/index.ts index 5ccfdc8..be832a4 100644 --- a/packages/plugins/onboarding/src/index.ts +++ b/packages/plugins/onboarding/src/index.ts @@ -107,7 +107,28 @@ export const onboarding = >( async ( ctx, ): Promise> => { + const { session } = await verifyOnboarding(ctx); + if (step.once) { + const { completedSteps } = + (await ctx.context.adapter.findOne<{ + completedSteps?: string[]; + }>({ + model: "user", + where: [ + { + field: "id", + value: session.user.id, + }, + ], + select: ["completedSteps"], + })) ?? {}; + + if (completedSteps?.includes(id)) { + throw new APIError("FORBIDDEN", { + message: ONBOARDING_ERROR_CODES.STEP_ALREADY_COMPLETED, + }); + } } return true; From da6aa5c1cc0278783352f1fe5f19b0a8c71225b3 Mon Sep 17 00:00:00 2001 From: Joel Solano Date: Fri, 22 Aug 2025 15:03:32 +0200 Subject: [PATCH 08/20] fix: completedSteps db type --- packages/plugins/onboarding/src/index.ts | 37 ++++++++++--------- packages/plugins/onboarding/src/schema.ts | 2 +- .../onboarding/tests/onboarding.test.ts | 1 + 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/plugins/onboarding/src/index.ts b/packages/plugins/onboarding/src/index.ts index be832a4..a478e3c 100644 --- a/packages/plugins/onboarding/src/index.ts +++ b/packages/plugins/onboarding/src/index.ts @@ -45,21 +45,23 @@ export const onboarding = >( async (ctx): Promise> => { const { session } = await verifyOnboarding(ctx); - const completedSteps = new Set( - ( - (await ctx.context.adapter.findOne<{ - completedSteps?: string[]; - }>({ - model: "user", - where: [ - { - field: "id", - value: session.user.id, - }, - ], - select: ["completedSteps"], - })) ?? {} - ).completedSteps, + const completedSteps = new Set( + JSON.parse( + ( + await ctx.context.adapter.findOne<{ + completedSteps?: string; + }>({ + model: "user", + where: [ + { + field: "id", + value: session.user.id, + }, + ], + select: ["completedSteps"], + }) + )?.completedSteps ?? "[]", + ), ); if (step.once && completedSteps.has(id)) { @@ -70,8 +72,9 @@ export const onboarding = >( const result = await step.handler(ctx); + const updatedSteps = [...completedSteps.add(id)]; const update: Record = { - completedSteps: [...completedSteps.add(id)], + completedSteps: JSON.stringify(updatedSteps), }; if (isCompletionStep) { @@ -90,7 +93,7 @@ export const onboarding = >( }); return { - completedSteps: update.completedSteps, + completedSteps: updatedSteps, data: result, }; }, diff --git a/packages/plugins/onboarding/src/schema.ts b/packages/plugins/onboarding/src/schema.ts index 985f8a1..eb0f209 100644 --- a/packages/plugins/onboarding/src/schema.ts +++ b/packages/plugins/onboarding/src/schema.ts @@ -8,7 +8,7 @@ export const schema = { required: false, }, completedSteps: { - type: "string[]", + type: "string", required: false, input: false, }, diff --git a/packages/plugins/onboarding/tests/onboarding.test.ts b/packages/plugins/onboarding/tests/onboarding.test.ts index 1061119..fbc42d2 100644 --- a/packages/plugins/onboarding/tests/onboarding.test.ts +++ b/packages/plugins/onboarding/tests/onboarding.test.ts @@ -37,6 +37,7 @@ describe("Onboarding", () => { ], update: { shouldOnboard: true, + completedSteps: "[]", }, }); }); From d4d247244051d0f65974ca88fa655ace554fe8aa Mon Sep 17 00:00:00 2001 From: Joel Solano Date: Fri, 22 Aug 2025 15:04:41 +0200 Subject: [PATCH 09/20] chore: cleanup --- .../plugins/onboarding/src/error-codes.ts | 2 +- .../plugins/onboarding/src/internal-types.ts | 8 ++++---- packages/plugins/onboarding/src/types.ts | 8 +++++++- packages/plugins/onboarding/src/utils.ts | 20 +++++++++---------- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/plugins/onboarding/src/error-codes.ts b/packages/plugins/onboarding/src/error-codes.ts index 505405a..df54220 100644 --- a/packages/plugins/onboarding/src/error-codes.ts +++ b/packages/plugins/onboarding/src/error-codes.ts @@ -1,4 +1,4 @@ export const ONBOARDING_ERROR_CODES = { - ALREADY_ONBOARDED: "Already onboarded", + ALREADY_ONBOARDED: "Already onboarded.", STEP_ALREADY_COMPLETED: "Step already completed.", } as const; diff --git a/packages/plugins/onboarding/src/internal-types.ts b/packages/plugins/onboarding/src/internal-types.ts index 7537632..6210522 100644 --- a/packages/plugins/onboarding/src/internal-types.ts +++ b/packages/plugins/onboarding/src/internal-types.ts @@ -6,8 +6,8 @@ import type { TransformClientPath, TransformPath } from "./utils"; type InferStepInput = K extends { input?: infer I } ? I extends ZodSchema ? I - : never - : never; + : undefined + : undefined; type InferStepResult = K extends OnboardingStep< any, @@ -31,7 +31,7 @@ export type EndpointPair = { { method: "POST"; body: InferStepInput; - use: [typeof sessionMiddleware], + use: [typeof sessionMiddleware]; }, OnboardingStepReturnType > @@ -41,7 +41,7 @@ export type EndpointPair = { `/onboarding/can-access-step/${TransformClientPath}`, { method: "GET"; - use: [typeof sessionMiddleware], + use: [typeof sessionMiddleware]; metadata: { SERVER_ONLY: true; }; diff --git a/packages/plugins/onboarding/src/types.ts b/packages/plugins/onboarding/src/types.ts index 926c05c..77ac9fb 100644 --- a/packages/plugins/onboarding/src/types.ts +++ b/packages/plugins/onboarding/src/types.ts @@ -11,11 +11,17 @@ type ActionEndpointContext = ( export type OnboardingOptions< Steps extends Record = any, > = { + /** + * 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: keyof Steps; /** * Whether to automatically enable onboarding for new users during sign up - * @default false + * @default true */ autoEnableOnSignUp?: boolean; /** diff --git a/packages/plugins/onboarding/src/utils.ts b/packages/plugins/onboarding/src/utils.ts index feadbe4..063b1a1 100644 --- a/packages/plugins/onboarding/src/utils.ts +++ b/packages/plugins/onboarding/src/utils.ts @@ -7,9 +7,7 @@ export function transformPath( .split(/[-/]/g) .map((segment, index) => { if (segment.length === 0) return ""; // handle leading separators - return index === 0 - ? segment.charAt(0).toUpperCase() + segment.slice(1) - : segment.charAt(0).toUpperCase() + segment.slice(1); + return segment.charAt(0).toUpperCase() + segment.slice(1); }) .join(""); @@ -41,13 +39,15 @@ export function transformClientPath( return result as TransformClientPath; } -type KebabStart = S extends `-${infer R}` | `/${infer F}` - ? KebabStart - : S extends `${infer F}${infer R}` - ? F extends Lowercase - ? `${F}${KebabCont}` - : `${Lowercase}${KebabCont}` - : S; +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 "-" | "/" From 67aa7623a9e8015e25c9e67f3b8af0de89786521 Mon Sep 17 00:00:00 2001 From: Joel Solano Date: Fri, 22 Aug 2025 15:06:57 +0200 Subject: [PATCH 10/20] chore: improve client onRequest callback --- packages/plugins/onboarding/src/client.ts | 13 +++++++------ packages/plugins/onboarding/src/utils.ts | 9 +++++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/plugins/onboarding/src/client.ts b/packages/plugins/onboarding/src/client.ts index 678bd55..8bb7c82 100644 --- a/packages/plugins/onboarding/src/client.ts +++ b/packages/plugins/onboarding/src/client.ts @@ -1,5 +1,6 @@ import type { BetterAuthClientPlugin } from "better-auth"; import type { onboarding, OnboardingStep } from "."; +import { toPath } from "./utils"; type InferSteps = T extends { $Infer: { @@ -49,14 +50,14 @@ export const onboardingClient = < } }, async onRequest(context) { - if ( - !new URL(context.url).pathname.startsWith( - `${new URL(context.baseURL ?? "/api/auth").pathname}/onboarding/step`, - ) - ) { + 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/`)) { return; } - return { ...context, method: "POST", diff --git a/packages/plugins/onboarding/src/utils.ts b/packages/plugins/onboarding/src/utils.ts index 063b1a1..4575ec7 100644 --- a/packages/plugins/onboarding/src/utils.ts +++ b/packages/plugins/onboarding/src/utils.ts @@ -58,3 +58,12 @@ type KebabCont = S extends `${infer F}${infer R}` : S; export type TransformClientPath = KebabStart; + +export const toPath = (u?: string | URL) => { + if (!u) return ""; + try { + return new URL(u).pathname; + } catch { + return `${u}`; + } +}; \ No newline at end of file From 053120724c0421710544db176da1f642238d8d8f Mon Sep 17 00:00:00 2001 From: Joel Solano Date: Fri, 22 Aug 2025 15:22:43 +0200 Subject: [PATCH 11/20] feat: required steps --- .../plugins/onboarding/src/error-codes.ts | 2 + packages/plugins/onboarding/src/index.ts | 17 ++++- packages/plugins/onboarding/src/types.ts | 17 +++++ .../onboarding/tests/onboarding.test.ts | 71 +++++++++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) diff --git a/packages/plugins/onboarding/src/error-codes.ts b/packages/plugins/onboarding/src/error-codes.ts index df54220..fff5e7a 100644 --- a/packages/plugins/onboarding/src/error-codes.ts +++ b/packages/plugins/onboarding/src/error-codes.ts @@ -1,4 +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 index a478e3c..f65ea5b 100644 --- a/packages/plugins/onboarding/src/index.ts +++ b/packages/plugins/onboarding/src/index.ts @@ -28,8 +28,11 @@ export const onboarding = >( ...options, }; + const steps = Object.entries(options.steps); + + const requiredSteps = steps.filter(([_, step]) => step.required); const endpoints = Object.fromEntries( - Object.entries(options.steps).flatMap(([id, step]) => { + steps.flatMap(([id, step]) => { const isCompletionStep = options.completionStep === id; const key = transformPath(id); const path = transformClientPath(id); @@ -70,6 +73,18 @@ export const onboarding = >( }); } + 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)]; diff --git a/packages/plugins/onboarding/src/types.ts b/packages/plugins/onboarding/src/types.ts index 77ac9fb..a38906b 100644 --- a/packages/plugins/onboarding/src/types.ts +++ b/packages/plugins/onboarding/src/types.ts @@ -34,7 +34,24 @@ export type OnboardingStep< Schema extends Record | undefined | null = any, Result = unknown, > = { + /** + * 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, this step must be completed before onboarding is considered done. + */ + required?: boolean; }; diff --git a/packages/plugins/onboarding/tests/onboarding.test.ts b/packages/plugins/onboarding/tests/onboarding.test.ts index fbc42d2..6f06aa8 100644 --- a/packages/plugins/onboarding/tests/onboarding.test.ts +++ b/packages/plugins/onboarding/tests/onboarding.test.ts @@ -180,3 +180,74 @@ describe("Onboarding", () => { }); }); }); + +describe("Onboarding (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(), + }), + ], + }, + }); + + 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"]); + }); +}); From 1348d5ec112745f0407234b09abc960ceeabd7dc Mon Sep 17 00:00:00 2001 From: Joel Solano Date: Fri, 22 Aug 2025 17:00:35 +0200 Subject: [PATCH 12/20] feat: dynamic `autoEnableOnSignUp` --- packages/plugins/onboarding/src/index.ts | 10 ++++- packages/plugins/onboarding/src/types.ts | 4 +- .../onboarding/tests/onboarding.test.ts | 40 +++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/plugins/onboarding/src/index.ts b/packages/plugins/onboarding/src/index.ts index f65ea5b..4754b70 100644 --- a/packages/plugins/onboarding/src/index.ts +++ b/packages/plugins/onboarding/src/index.ts @@ -190,9 +190,15 @@ export const onboarding = >( }, handler: createAuthMiddleware(async (ctx) => { const data = ctx.context.newSession; - if (!data) { - return null; + const enabled = + typeof opts.autoEnableOnSignUp === "function" + ? await opts.autoEnableOnSignUp(ctx) + : opts.autoEnableOnSignUp; + + if (!data || !enabled) { + return; } + await ctx.context.adapter.update({ model: "user", where: [ diff --git a/packages/plugins/onboarding/src/types.ts b/packages/plugins/onboarding/src/types.ts index a38906b..c34c0ea 100644 --- a/packages/plugins/onboarding/src/types.ts +++ b/packages/plugins/onboarding/src/types.ts @@ -23,7 +23,9 @@ export type OnboardingOptions< * Whether to automatically enable onboarding for new users during sign up * @default true */ - autoEnableOnSignUp?: boolean; + autoEnableOnSignUp?: + | boolean + | ((ctx: GenericEndpointContext) => boolean | Promise); /** * Custom schema configuration for the onboarding plugin */ diff --git a/packages/plugins/onboarding/tests/onboarding.test.ts b/packages/plugins/onboarding/tests/onboarding.test.ts index 6f06aa8..2c478a3 100644 --- a/packages/plugins/onboarding/tests/onboarding.test.ts +++ b/packages/plugins/onboarding/tests/onboarding.test.ts @@ -178,6 +178,46 @@ describe("Onboarding", () => { 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, + }), + ], + }, + }, + ); + 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, + }), + ], + }, + }, + ); + await signUpWithTestUser(); + expect(mockOnboardingRedirect).not.toHaveBeenCalled(); + }); }); }); From 28471745ffc0679056581b74398027ad90e5dc48 Mon Sep 17 00:00:00 2001 From: Joel Solano Date: Fri, 22 Aug 2025 17:29:23 +0200 Subject: [PATCH 13/20] chore: cleanup --- packages/plugins/onboarding/src/utils.ts | 3 +- .../onboarding/tests/onboarding.test.ts | 118 +++++++++--------- 2 files changed, 61 insertions(+), 60 deletions(-) diff --git a/packages/plugins/onboarding/src/utils.ts b/packages/plugins/onboarding/src/utils.ts index 4575ec7..5c65fb5 100644 --- a/packages/plugins/onboarding/src/utils.ts +++ b/packages/plugins/onboarding/src/utils.ts @@ -30,6 +30,7 @@ export function transformClientPath( ): 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, "") @@ -66,4 +67,4 @@ export const toPath = (u?: string | URL) => { } catch { return `${u}`; } -}; \ No newline at end of file +}; diff --git a/packages/plugins/onboarding/tests/onboarding.test.ts b/packages/plugins/onboarding/tests/onboarding.test.ts index 2c478a3..878a4c7 100644 --- a/packages/plugins/onboarding/tests/onboarding.test.ts +++ b/packages/plugins/onboarding/tests/onboarding.test.ts @@ -219,75 +219,75 @@ describe("Onboarding", () => { expect(mockOnboardingRedirect).not.toHaveBeenCalled(); }); }); -}); -describe("Onboarding (required steps)", async () => { - const auth = getAuth({ - steps: { - profile: { - handler: async () => true, - required: true, - }, - newPassword: { - handler: async () => true, + describe("(required steps)", async () => { + const auth = getAuth({ + steps: { + profile: { + handler: async () => true, + required: true, + }, + newPassword: { + handler: async () => true, + }, }, - }, - completionStep: "newPassword" as any, - } as any); + completionStep: "newPassword" as any, + } as any); - const { resetDatabase, client, signUpWithTestUser, db, testUser } = - await getTestInstance(auth, { - clientOptions: { - plugins: [ - onboardingClient({ - onOnboardingRedirect: () => Promise.resolve(), - }), - ], - }, - }); + const { resetDatabase, client, signUpWithTestUser, db, testUser } = + await getTestInstance(auth, { + clientOptions: { + plugins: [ + onboardingClient({ + onOnboardingRedirect: () => Promise.resolve(), + }), + ], + }, + }); - let headers: Headers; - beforeAll(async () => { - await resetDatabase(); - const result = await signUpWithTestUser(); - headers = result.headers; - }); + 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, + beforeEach(async () => { + await db.update({ + model: "user", + where: [ + { + field: "email", + value: testUser.email, + }, + ], + update: { + shouldOnboard: true, + completedSteps: "[]", }, - ], - 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 }, + 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, + ); }); - 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 }, + 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"]); }); - if (r2.error) throw r2.error; - expect(r2.data.completedSteps).toEqual(["profile", "newPassword"]); }); }); From 18688b0f4b4aa606dcdeeda88ababa337192bc30 Mon Sep 17 00:00:00 2001 From: Joel Solano Date: Fri, 22 Aug 2025 17:54:02 +0200 Subject: [PATCH 14/20] feat: setup new password preset --- packages/plugins/onboarding/build.ts | 1 + packages/plugins/onboarding/package.json | 4 ++ .../plugins/onboarding/src/presets/index.ts | 1 + .../src/presets/setup-new-password.ts | 60 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 packages/plugins/onboarding/src/presets/index.ts create mode 100644 packages/plugins/onboarding/src/presets/setup-new-password.ts diff --git a/packages/plugins/onboarding/build.ts b/packages/plugins/onboarding/build.ts index 8425d0c..a68f870 100644 --- a/packages/plugins/onboarding/build.ts +++ b/packages/plugins/onboarding/build.ts @@ -2,5 +2,6 @@ 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 index 324d5b4..0e1fa8d 100644 --- a/packages/plugins/onboarding/package.json +++ b/packages/plugins/onboarding/package.json @@ -43,6 +43,10 @@ "./client": { "types": "./dist/client.d.ts", "default": "./dist/client.js" + }, + "./presets": { + "types": "./dist/presets/index.d.ts", + "default": "./dist/presets/index.js" } }, "publishConfig": { diff --git a/packages/plugins/onboarding/src/presets/index.ts b/packages/plugins/onboarding/src/presets/index.ts new file mode 100644 index 0000000..46adb71 --- /dev/null +++ b/packages/plugins/onboarding/src/presets/index.ts @@ -0,0 +1 @@ +export * from "./setup-new-password"; 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, + }); +}; From b607a8aa7240414a215baf31f4dd1b6cba857ac4 Mon Sep 17 00:00:00 2001 From: Joel Solano Date: Fri, 22 Aug 2025 20:45:41 +0200 Subject: [PATCH 15/20] feat: setup 2fa preset --- packages/plugins/onboarding/src/index.ts | 5 +- .../onboarding/src/presets/setup-2fa.ts | 39 ++++ packages/plugins/onboarding/src/types.ts | 18 +- packages/plugins/onboarding/tests/auth.ts | 13 +- .../onboarding/tests/onboarding.test.ts | 6 + .../tests/presets/setup-2fa.test.ts | 179 ++++++++++++++++++ .../tests/presets/setup-new-password.test.ts | 98 ++++++++++ packages/plugins/onboarding/tests/test.db | Bin 45056 -> 0 bytes 8 files changed, 352 insertions(+), 6 deletions(-) create mode 100644 packages/plugins/onboarding/src/presets/setup-2fa.ts create mode 100644 packages/plugins/onboarding/tests/presets/setup-2fa.test.ts create mode 100644 packages/plugins/onboarding/tests/presets/setup-new-password.test.ts delete mode 100644 packages/plugins/onboarding/tests/test.db diff --git a/packages/plugins/onboarding/src/index.ts b/packages/plugins/onboarding/src/index.ts index 4754b70..c76e440 100644 --- a/packages/plugins/onboarding/src/index.ts +++ b/packages/plugins/onboarding/src/index.ts @@ -1,4 +1,4 @@ -import type { BetterAuthPlugin, PrettifyDeep } from "better-auth"; +import type { AuthContext, BetterAuthPlugin, PrettifyDeep } from "better-auth"; import { mergeSchema } from "better-auth/db"; import { schema } from "./schema"; import { ONBOARDING_ERROR_CODES } from "./error-codes"; @@ -44,6 +44,9 @@ export const onboarding = >( method: "POST", body: step.input, use: [sessionMiddleware], + requireHeaders: step.requireHeaders, + requireRequest: step.requireRequest, + cloneRequest: step.cloneRequest, }, async (ctx): Promise> => { const { session } = await verifyOnboarding(ctx); 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..572f7c1 --- /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("INTERNAL_SERVER_ERROR", { + 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/types.ts b/packages/plugins/onboarding/src/types.ts index c34c0ea..38b404f 100644 --- a/packages/plugins/onboarding/src/types.ts +++ b/packages/plugins/onboarding/src/types.ts @@ -1,4 +1,8 @@ -import type { GenericEndpointContext, InferOptionSchema } from "better-auth"; +import type { + AuthContext, + GenericEndpointContext, + InferOptionSchema, +} from "better-auth"; import type { ZodSchema } from "zod"; import type { schema } from "./schema"; @@ -56,4 +60,16 @@ export type OnboardingStep< * If true, this step must be completed before onboarding is considered done. */ required?: 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; }; diff --git a/packages/plugins/onboarding/tests/auth.ts b/packages/plugins/onboarding/tests/auth.ts index 6f23246..3595d09 100644 --- a/packages/plugins/onboarding/tests/auth.ts +++ b/packages/plugins/onboarding/tests/auth.ts @@ -3,20 +3,24 @@ import { type OnboardingOptions, createOnboardingStep, } from "../src"; -import { betterAuth } from "better-auth"; +import { betterAuth, type BetterAuthPlugin } from "better-auth"; import database from "better-sqlite3"; import { z } from "zod"; -const db = database("test.db"); const onboardingSchema = z .object({ foo: z.string().optional(), }) .nullish(); -export const getAuth = (options?: Partial) => { +export const getAuth = ( + options?: Partial, + authOptions?: { + plugins?: BetterAuthPlugin[]; + }, +) => { const auth = betterAuth({ - database: db, + database: database(":memory:"), emailAndPassword: { enabled: true, }, @@ -33,6 +37,7 @@ export const getAuth = (options?: Partial) => { completionStep: "newPassword", ...options, }), + ...(authOptions?.plugins ?? []), ], }); diff --git a/packages/plugins/onboarding/tests/onboarding.test.ts b/packages/plugins/onboarding/tests/onboarding.test.ts index 878a4c7..eb3be45 100644 --- a/packages/plugins/onboarding/tests/onboarding.test.ts +++ b/packages/plugins/onboarding/tests/onboarding.test.ts @@ -17,6 +17,7 @@ describe("Onboarding", () => { }), ], }, + shouldRunMigrations: true, }); let headers: Headers; @@ -146,6 +147,7 @@ describe("Onboarding", () => { }), ], }, + shouldRunMigrations: true, }, ); @@ -173,6 +175,7 @@ describe("Onboarding", () => { }), ], }, + shouldRunMigrations: true, }, ); await signUpWithTestUser(); @@ -193,6 +196,7 @@ describe("Onboarding", () => { }), ], }, + shouldRunMigrations: true, }, ); await signUpWithTestUser(); @@ -213,6 +217,7 @@ describe("Onboarding", () => { }), ], }, + shouldRunMigrations: true, }, ); await signUpWithTestUser(); @@ -243,6 +248,7 @@ describe("Onboarding", () => { }), ], }, + shouldRunMigrations: true, }); let headers: Headers; 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..5881880 --- /dev/null +++ b/packages/plugins/onboarding/tests/presets/setup-2fa.test.ts @@ -0,0 +1,179 @@ +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-2fa preset", async () => { +// const auth = getAuth( +// { +// steps: { +// twoFactor: setup2FAStep({ required: false }), +// }, +// completionStep: "twoFactor", +// }, +// { +// 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: "testpassword", +// issuer: "TestApp", +// fetchOptions: { headers }, +// }); +// console.log(res); +// expect(res.error?.status).toBe(404); +// }); + +// it("should enforce once constraint for 2FA setup", async () => { +// // First attempt (will fail due to missing 2FA plugin, but step is recorded) +// await (client.onboarding as any).step.twoFactor({ +// password: "testpassword", +// fetchOptions: { headers }, +// }); + +// // Try to complete again +// const res = await (client.onboarding as any).step.twoFactor({ +// password: "testpassword", +// fetchOptions: { headers }, +// }); +// expect(res.error?.status).toBe(403); +// expect(res.error?.message).toBe( +// ONBOARDING_ERROR_CODES.STEP_ALREADY_COMPLETED, +// ); +// }); + +// it("should handle missing 2FA plugin gracefully", async () => { +// const res = await (client.onboarding as any).step.twoFactor({ +// password: "testpassword", +// fetchOptions: { headers }, +// }); +// expect(res.error?.status).toBe(404); +// }); +//}); + +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).includes("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/tests/test.db b/packages/plugins/onboarding/tests/test.db deleted file mode 100644 index d5e72914ec06eed8339a7f7b9a8ed8b9b8e9e70a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45056 zcmeI*Pfy!s90zc_v_N2xUOJ@pP>-o%NCnxB9VeM0HH|`(rh$&Sz?ddv`*{-FI(G0M z0=<>B=V{Vj!`{GdJMA6p4fMQguRHE>NFkxglI}2-_G{wg@z0O#=lMKO1h$`$$KO7* zLn_ogziEU*io4G7Ja<o#S(x80C7sDE|@mV+Ik(cLXuvV3ZUloY~6fv`kx#;6rX z2CWMAy~rUOuIU**5i09?>3(VRj$nDsmP6Slk3-rD1iiGQFTZ%4m|D1fn?JETB8Q!l zSw?7kZf|;~U#NH0I8rvDZWvdYx%P>&8Xg)>G>Gx2W&1Q3RPhTYY#$dWmbID5h3ajd zvt2@uf&+*BoxNv7q1Rd7>n;7B)VnnMB9tgRo1?SL?1(`C z0uX=z1Rwwb2tWV=5P$##AkYos*5`ZMj>ji+sE79*>-)y`jsKW z`juTt)>cLKBaL_eALIYgn;`%J2tWV=5P$##AOHafKmY=lRUjV!Kk9-6KL3ANTNmYq z00bZa0SG_<0uX=z1Rwwb2%HMU=l`Py7Ien{&;Q1;2POzW00Izz00bZa0SG_<0uX?} zha_H3*)4S7PV8 zK@DeAPSq4mD@$ZcO_>@|EUPXOlcuPeQnIF~WO7-rFAOHafKmY;|fB*y_009U<;1d;ivC2=? zs_cIO=5;euIxKmGHF>|P9ZC95QKi&Z%jM(m)I+0bdamgiKC#`#3ej5|Q-^{{@HzgNte<&QOqgQs-mUO@AI>` wnp3q=@%a9K=luWmC)#4DHUuC50SG_<0uX=z1Rwwb2teSH3&i*TM_q>a2gw{O`Tzg` From 91511d02a0e005f2acd3c9fa84612fbb532c94db Mon Sep 17 00:00:00 2001 From: Joel Solano Date: Fri, 22 Aug 2025 20:48:14 +0200 Subject: [PATCH 16/20] chore: export setup 2fa preset --- packages/plugins/onboarding/src/presets/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/plugins/onboarding/src/presets/index.ts b/packages/plugins/onboarding/src/presets/index.ts index 46adb71..8215ba8 100644 --- a/packages/plugins/onboarding/src/presets/index.ts +++ b/packages/plugins/onboarding/src/presets/index.ts @@ -1 +1,2 @@ export * from "./setup-new-password"; +export * from "./setup-2fa"; From 0ec37d6e3e124c79d108602c6b7c85c9c042d138 Mon Sep 17 00:00:00 2001 From: Joel Solano Date: Sun, 24 Aug 2025 13:10:10 +0200 Subject: [PATCH 17/20] feat: allow skipping non-required `completeStep` --- packages/plugins/onboarding/src/client.ts | 41 ++++--- packages/plugins/onboarding/src/index.ts | 101 ++++++++++++++--- .../plugins/onboarding/src/internal-types.ts | 97 ++++++++++++---- packages/plugins/onboarding/src/types.ts | 37 +++--- packages/plugins/onboarding/tests/auth.ts | 2 +- .../onboarding/tests/onboarding.test.ts | 107 ++++++++++++++++++ 6 files changed, 314 insertions(+), 71 deletions(-) diff --git a/packages/plugins/onboarding/src/client.ts b/packages/plugins/onboarding/src/client.ts index 8bb7c82..ddf37a3 100644 --- a/packages/plugins/onboarding/src/client.ts +++ b/packages/plugins/onboarding/src/client.ts @@ -4,22 +4,24 @@ import { toPath } from "./utils"; type InferSteps = T extends { $Infer: { - OnboardingSteps: infer Steps extends Record; + OnboardingSteps: infer Steps extends Record< + string, + OnboardingStep + >; }; } ? Steps - : T extends Record + : T extends Record> ? T : never; export const onboardingClient = < - Steps extends - | { - $Infer: { - OnboardingSteps: Record; - }; - } - | Record, + Steps extends { + $Infer: { + OnboardingSteps: Record>; + OnboardingCompletionStep: string; + }; + }, >(options?: { /** * a redirect function to call if a user needs @@ -29,7 +31,12 @@ export const onboardingClient = < }) => { return { id: "onboarding", - $InferServerPlugin: {} as ReturnType>>, + $InferServerPlugin: {} as ReturnType< + typeof onboarding< + InferSteps, + Steps["$Infer"]["OnboardingCompletionStep"] + > + >, atomListeners: [ { matcher: (path) => path.startsWith("/onboarding/"), @@ -55,13 +62,15 @@ export const onboardingClient = < const basePath = basePathRaw.endsWith("/") ? basePathRaw.slice(0, -1) : basePathRaw; - if (!urlPath.startsWith(`${basePath}/onboarding/step/`)) { - return; + if ( + urlPath.startsWith(`${basePath}/onboarding/step/`) || + urlPath.startsWith(`${basePath}/onboarding/skip-step/`) + ) { + return { + ...context, + method: "POST", + }; } - return { - ...context, - method: "POST", - }; }, }, }, diff --git a/packages/plugins/onboarding/src/index.ts b/packages/plugins/onboarding/src/index.ts index c76e440..887038c 100644 --- a/packages/plugins/onboarding/src/index.ts +++ b/packages/plugins/onboarding/src/index.ts @@ -1,4 +1,4 @@ -import type { AuthContext, BetterAuthPlugin, PrettifyDeep } from "better-auth"; +import type { BetterAuthPlugin, PrettifyDeep } from "better-auth"; import { mergeSchema } from "better-auth/db"; import { schema } from "./schema"; import { ONBOARDING_ERROR_CODES } from "./error-codes"; @@ -7,21 +7,26 @@ import { createAuthMiddleware, APIError, sessionMiddleware, + type AuthEndpoint, } from "better-auth/api"; import { shouldOnboard } from "./routes/should-onboard"; 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 { z } from "zod"; -export const onboarding = >( - options: OnboardingOptions, +export const onboarding = < + Steps extends Record>, + CompletionStep extends keyof Steps, +>( + options: OnboardingOptions, ) => { const opts = { autoEnableOnSignUp: true, @@ -37,7 +42,7 @@ export const onboarding = >( const key = transformPath(id); const path = transformClientPath(id); - const entries = Object.entries({ + const endpoints: Record = { [`onboardingStep${key}`]: createAuthEndpoint( `/onboarding/step/${path}`, { @@ -125,9 +130,7 @@ export const onboarding = >( SERVER_ONLY: true, }, }, - async ( - ctx, - ): Promise> => { + async (ctx): Promise => { const { session } = await verifyOnboarding(ctx); if (step.once) { @@ -155,11 +158,80 @@ export const onboarding = >( return true; }, ), - }); + }; + + if (isCompletionStep && step.required !== true) { + endpoints[`skipOnboardingStep${key}`] = createAuthEndpoint( + `/onboarding/skip-step/${path}`, + { + method: "POST", + use: [sessionMiddleware], + }, + async (ctx): Promise => { + const { session } = await verifyOnboarding(ctx); + + const completedSteps = new Set( + JSON.parse( + ( + await ctx.context.adapter.findOne<{ + completedSteps?: string; + }>({ + model: "user", + where: [ + { + field: "id", + value: session.user.id, + }, + ], + select: ["completedSteps"], + }) + )?.completedSteps ?? "[]", + ), + ); + + 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 ctx.context.adapter.update({ + model: "user", + where: [ + { + field: "id", + value: session.user.id, + }, + ], + update: { + shouldOnboard: false, + }, + }); + + return { + completedSteps: [...completedSteps], + data: null, + }; + }, + ); + } - return entries; + return Object.entries(endpoints); }), - ) as PrettifyDeep>>; + ) as PrettifyDeep< + Merged> & + InferSkipCompletionStep + >; return { id: "onboarding", @@ -235,6 +307,7 @@ export const onboarding = >( $ERROR_CODES: ONBOARDING_ERROR_CODES, $Infer: { OnboardingSteps: {} as Steps, + OnboardingCompletionStep: {} as CompletionStep, }, } satisfies BetterAuthPlugin; }; @@ -242,12 +315,14 @@ export const onboarding = >( export const createOnboardingStep = < Schema extends Record | undefined | null, Result = unknown, + Required extends boolean = false, >( - def: OnboardingStep, + def: Omit, "required"> & + (Required extends true ? { required: true } : { required?: Required }), ) => { return { once: true, - input: z.record(z.any()).nullish(), + required: (def.required ?? false) as Required, ...def, }; }; diff --git a/packages/plugins/onboarding/src/internal-types.ts b/packages/plugins/onboarding/src/internal-types.ts index 6210522..f8976ba 100644 --- a/packages/plugins/onboarding/src/internal-types.ts +++ b/packages/plugins/onboarding/src/internal-types.ts @@ -1,36 +1,38 @@ import type { createAuthEndpoint, sessionMiddleware } from "better-auth/api"; import type { OnboardingStep } from "./types"; -import type { ZodSchema } from "zod"; +import type { z, ZodSchema, ZodType } from "zod"; import type { TransformClientPath, TransformPath } from "./utils"; -type InferStepInput = K extends { input?: infer I } - ? I extends ZodSchema - ? I +type InferStepInput> = K extends { + input?: infer I; +} + ? I extends ZodSchema + ? Schema : undefined : undefined; -type InferStepResult = K extends OnboardingStep< - any, - infer R -> - ? R - : never; +type InferStepResult> = + K extends OnboardingStep ? R : never; -export type OnboardingStepReturnType = { - completedSteps: string[]; - data: InferStepResult; -}; +export type OnboardingStepReturnType> = + { + completedSteps: string[]; + data: InferStepResult; + }; -export type CanAccessOnboardingStepReturnType = - boolean; +export type CanAccessOnboardingStepReturnType = boolean; -export type EndpointPair = { +export type EndpointPair< + Path extends string, + K extends OnboardingStep, + C extends string, +> = { onboardingStep: ReturnType< typeof createAuthEndpoint< `/onboarding/step/${TransformClientPath}`, { method: "POST"; - body: InferStepInput; + body: ZodType>; use: [typeof sessionMiddleware]; }, OnboardingStepReturnType @@ -46,24 +48,71 @@ export type EndpointPair = { SERVER_ONLY: true; }; }, - CanAccessOnboardingStepReturnType + CanAccessOnboardingStepReturnType > >; }; -type PrefixedEndpoints = { +type PrefixedEndpoints< + Path extends string, + S extends OnboardingStep, + C extends string, +> = { [K in keyof EndpointPair< Path, - S - > as `${Extract}${TransformPath}`]: EndpointPair[K]; + S, + C + > as `${Extract}${TransformPath}`]: EndpointPair< + Path, + S, + C + >[K]; }; export type OnboardingStepsToEndpoints< - S extends Record>, + S extends Record>, + CompletionStep extends keyof S, > = { - [K in keyof S & string]: PrefixedEndpoints; + [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/types.ts b/packages/plugins/onboarding/src/types.ts index 38b404f..12cbf63 100644 --- a/packages/plugins/onboarding/src/types.ts +++ b/packages/plugins/onboarding/src/types.ts @@ -13,7 +13,8 @@ type ActionEndpointContext = ( ) => Result | Promise; export type OnboardingOptions< - Steps extends Record = any, + Steps extends Record>, + CompletionStep extends keyof Steps, > = { /** * Map of onboarding steps keyed by a unique step identifier. @@ -22,7 +23,7 @@ export type OnboardingOptions< /** * The key of the step that, when completed, marks onboarding as finished. */ - completionStep: keyof Steps; + completionStep: CompletionStep; /** * Whether to automatically enable onboarding for new users during sign up * @default true @@ -37,8 +38,9 @@ export type OnboardingOptions< }; export type OnboardingStep< - Schema extends Record | undefined | null = any, - Result = unknown, + Schema extends Record | undefined | null, + Result, + Required extends boolean = false, > = { /** * Optional Zod schema used to validate the request body for this step. @@ -56,20 +58,21 @@ export type OnboardingStep< * 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; - /** - * 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; -}; +} & (Required extends true ? { required: true } : { required?: false }); diff --git a/packages/plugins/onboarding/tests/auth.ts b/packages/plugins/onboarding/tests/auth.ts index 3595d09..9d0dcbc 100644 --- a/packages/plugins/onboarding/tests/auth.ts +++ b/packages/plugins/onboarding/tests/auth.ts @@ -14,7 +14,7 @@ const onboardingSchema = z .nullish(); export const getAuth = ( - options?: Partial, + options?: Partial>, authOptions?: { plugins?: BetterAuthPlugin[]; }, diff --git a/packages/plugins/onboarding/tests/onboarding.test.ts b/packages/plugins/onboarding/tests/onboarding.test.ts index eb3be45..ad5e955 100644 --- a/packages/plugins/onboarding/tests/onboarding.test.ts +++ b/packages/plugins/onboarding/tests/onboarding.test.ts @@ -296,4 +296,111 @@ describe("Onboarding", () => { 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); + }); + }); }); From 03f8b50094c9130e2e0efc639ab2a879be05b87d Mon Sep 17 00:00:00 2001 From: Joel Solano Date: Sun, 24 Aug 2025 14:51:24 +0200 Subject: [PATCH 18/20] feat: secondary storage support --- packages/plugins/onboarding/src/adapter.ts | 94 ++++++++++++ packages/plugins/onboarding/src/index.ts | 135 +++++++----------- .../onboarding/src/routes/should-onboard.ts | 16 --- packages/plugins/onboarding/src/types.ts | 6 + .../onboarding/src/verify-onboarding.ts | 19 ++- 5 files changed, 165 insertions(+), 105 deletions(-) create mode 100644 packages/plugins/onboarding/src/adapter.ts delete mode 100644 packages/plugins/onboarding/src/routes/should-onboard.ts 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/index.ts b/packages/plugins/onboarding/src/index.ts index 887038c..fcdca6f 100644 --- a/packages/plugins/onboarding/src/index.ts +++ b/packages/plugins/onboarding/src/index.ts @@ -9,7 +9,6 @@ import { sessionMiddleware, type AuthEndpoint, } from "better-auth/api"; -import { shouldOnboard } from "./routes/should-onboard"; import type { OnboardingOptions, OnboardingStep } from "./types"; import type { CanAccessOnboardingStepReturnType, @@ -21,6 +20,7 @@ import type { } from "./internal-types"; import { transformClientPath, transformPath } from "./utils"; import { verifyOnboarding } from "./verify-onboarding"; +import { getOnboardingAdapter } from "./adapter"; export const onboarding = < Steps extends Record>, @@ -54,25 +54,14 @@ export const onboarding = < cloneRequest: step.cloneRequest, }, async (ctx): Promise> => { - const { session } = await verifyOnboarding(ctx); + const adapter = getOnboardingAdapter(options, ctx); + const { session } = await verifyOnboarding(ctx, { + adapter, + options, + }); - const completedSteps = new Set( - JSON.parse( - ( - await ctx.context.adapter.findOne<{ - completedSteps?: string; - }>({ - model: "user", - where: [ - { - field: "id", - value: session.user.id, - }, - ], - select: ["completedSteps"], - }) - )?.completedSteps ?? "[]", - ), + const completedSteps = await adapter.getCompletedSteps( + session.user.id, ); if (step.once && completedSteps.has(id)) { @@ -97,23 +86,14 @@ export const onboarding = < const updatedSteps = [...completedSteps.add(id)]; const update: Record = { - completedSteps: JSON.stringify(updatedSteps), + completedSteps: updatedSteps, }; if (isCompletionStep) { update.shouldOnboard = false; } - await ctx.context.adapter.update({ - model: "user", - where: [ - { - field: "id", - value: session.user.id, - }, - ], - update, - }); + await adapter.updateOnboardingState(session.user.id, update); return { completedSteps: updatedSteps, @@ -131,24 +111,18 @@ export const onboarding = < }, }, async (ctx): Promise => { - const { session } = await verifyOnboarding(ctx); + const adapter = getOnboardingAdapter(options, ctx); + const { session } = await verifyOnboarding(ctx, { + adapter, + options, + }); if (step.once) { - const { completedSteps } = - (await ctx.context.adapter.findOne<{ - completedSteps?: string[]; - }>({ - model: "user", - where: [ - { - field: "id", - value: session.user.id, - }, - ], - select: ["completedSteps"], - })) ?? {}; + const completedSteps = await adapter.getCompletedSteps( + session.user.id, + ); - if (completedSteps?.includes(id)) { + if (completedSteps?.has(id)) { throw new APIError("FORBIDDEN", { message: ONBOARDING_ERROR_CODES.STEP_ALREADY_COMPLETED, }); @@ -168,25 +142,14 @@ export const onboarding = < use: [sessionMiddleware], }, async (ctx): Promise => { - const { session } = await verifyOnboarding(ctx); + const adapter = getOnboardingAdapter(options, ctx); + const { session } = await verifyOnboarding(ctx, { + adapter, + options, + }); - const completedSteps = new Set( - JSON.parse( - ( - await ctx.context.adapter.findOne<{ - completedSteps?: string; - }>({ - model: "user", - where: [ - { - field: "id", - value: session.user.id, - }, - ], - select: ["completedSteps"], - }) - )?.completedSteps ?? "[]", - ), + const completedSteps = await adapter.getCompletedSteps( + session.user.id, ); if (completedSteps.has(id)) { @@ -205,17 +168,8 @@ export const onboarding = < }); } - await ctx.context.adapter.update({ - model: "user", - where: [ - { - field: "id", - value: session.user.id, - }, - ], - update: { - shouldOnboard: false, - }, + await adapter.updateOnboardingState(session.user.id, { + shouldOnboard: false, }); return { @@ -236,7 +190,20 @@ export const onboarding = < return { id: "onboarding", endpoints: { - shouldOnboard, + shouldOnboard: createAuthEndpoint( + "/onboarding/should-onboard", + { + method: "GET", + use: [sessionMiddleware], + }, + async (ctx) => { + await verifyOnboarding(ctx, { + options, + }); + + return true; + }, + ), ...endpoints, }, hooks: { @@ -264,6 +231,7 @@ export const onboarding = < ); }, handler: createAuthMiddleware(async (ctx) => { + const adapter = getOnboardingAdapter(options, ctx); const data = ctx.context.newSession; const enabled = typeof opts.autoEnableOnSignUp === "function" @@ -274,17 +242,8 @@ export const onboarding = < return; } - await ctx.context.adapter.update({ - model: "user", - where: [ - { - field: "id", - value: data.user.id, - }, - ], - update: { - shouldOnboard: true, - }, + await adapter.updateOnboardingState(data.user.id, { + shouldOnboard: true, }); return ctx.json({ @@ -303,7 +262,9 @@ export const onboarding = < max: 3, }, ], - schema: mergeSchema(schema, opts?.schema), + schema: !options.secondaryStorage + ? mergeSchema(schema, opts?.schema) + : undefined, $ERROR_CODES: ONBOARDING_ERROR_CODES, $Infer: { OnboardingSteps: {} as Steps, diff --git a/packages/plugins/onboarding/src/routes/should-onboard.ts b/packages/plugins/onboarding/src/routes/should-onboard.ts deleted file mode 100644 index ec869d1..0000000 --- a/packages/plugins/onboarding/src/routes/should-onboard.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { sessionMiddleware } from "better-auth/api"; -import { createAuthEndpoint } from "better-auth/plugins"; -import { verifyOnboarding } from "../verify-onboarding"; - -export const shouldOnboard = createAuthEndpoint( - "/onboarding/should-onboard", - { - method: "GET", - use: [sessionMiddleware], - }, - async (ctx) => { - await verifyOnboarding(ctx); - - return true; - }, -); diff --git a/packages/plugins/onboarding/src/types.ts b/packages/plugins/onboarding/src/types.ts index 12cbf63..853e2ef 100644 --- a/packages/plugins/onboarding/src/types.ts +++ b/packages/plugins/onboarding/src/types.ts @@ -31,6 +31,12 @@ export type OnboardingOptions< 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 */ diff --git a/packages/plugins/onboarding/src/verify-onboarding.ts b/packages/plugins/onboarding/src/verify-onboarding.ts index 4b9572c..44a66e6 100644 --- a/packages/plugins/onboarding/src/verify-onboarding.ts +++ b/packages/plugins/onboarding/src/verify-onboarding.ts @@ -1,15 +1,30 @@ 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) { +export async function verifyOnboarding( + ctx: GenericEndpointContext, + context: { + adapter?: ReturnType; + options: OnboardingOptions; + }, +) { const session = await getSessionFromCtx(ctx); if (!session) { throw new APIError("UNAUTHORIZED"); } - if (!session.user.shouldOnboard) { + 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, }); From 3085064b30658fa87be3d404190ee0c4f4904074 Mon Sep 17 00:00:00 2001 From: Joel Solano Date: Sun, 24 Aug 2025 14:55:25 +0200 Subject: [PATCH 19/20] chore: cleanup --- .../onboarding/src/presets/setup-2fa.ts | 2 +- .../tests/presets/setup-2fa.test.ts | 91 +------------------ 2 files changed, 2 insertions(+), 91 deletions(-) diff --git a/packages/plugins/onboarding/src/presets/setup-2fa.ts b/packages/plugins/onboarding/src/presets/setup-2fa.ts index 572f7c1..d28adba 100644 --- a/packages/plugins/onboarding/src/presets/setup-2fa.ts +++ b/packages/plugins/onboarding/src/presets/setup-2fa.ts @@ -21,7 +21,7 @@ export const setup2FAStep = (options?: O) => { ); if (!plugin?.endpoints) { - throw new APIError("INTERNAL_SERVER_ERROR", { + throw new APIError("FAILED_DEPENDENCY", { message: "2FA is not set up.", }); } diff --git a/packages/plugins/onboarding/tests/presets/setup-2fa.test.ts b/packages/plugins/onboarding/tests/presets/setup-2fa.test.ts index 5881880..5e8c251 100644 --- a/packages/plugins/onboarding/tests/presets/setup-2fa.test.ts +++ b/packages/plugins/onboarding/tests/presets/setup-2fa.test.ts @@ -6,95 +6,6 @@ import { onboardingClient } from "../../src"; import { ONBOARDING_ERROR_CODES } from "../../src/error-codes"; import { twoFactor } from "better-auth/plugins"; -// describe("setup-2fa preset", async () => { -// const auth = getAuth( -// { -// steps: { -// twoFactor: setup2FAStep({ required: false }), -// }, -// completionStep: "twoFactor", -// }, -// { -// 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: "testpassword", -// issuer: "TestApp", -// fetchOptions: { headers }, -// }); -// console.log(res); -// expect(res.error?.status).toBe(404); -// }); - -// it("should enforce once constraint for 2FA setup", async () => { -// // First attempt (will fail due to missing 2FA plugin, but step is recorded) -// await (client.onboarding as any).step.twoFactor({ -// password: "testpassword", -// fetchOptions: { headers }, -// }); - -// // Try to complete again -// const res = await (client.onboarding as any).step.twoFactor({ -// password: "testpassword", -// fetchOptions: { headers }, -// }); -// expect(res.error?.status).toBe(403); -// expect(res.error?.message).toBe( -// ONBOARDING_ERROR_CODES.STEP_ALREADY_COMPLETED, -// ); -// }); - -// it("should handle missing 2FA plugin gracefully", async () => { -// const res = await (client.onboarding as any).step.twoFactor({ -// password: "testpassword", -// fetchOptions: { headers }, -// }); -// expect(res.error?.status).toBe(404); -// }); -//}); - describe("setup-new-password preset", async () => { const auth = getAuth( { @@ -158,7 +69,7 @@ describe("setup-new-password preset", async () => { issuer: "TestApp", fetchOptions: { headers }, }); - expect(res.data?.completedSteps).includes("twoFactor"); + expect(res.data?.completedSteps).toContain("twoFactor"); }); it("should enforce once constraint for 2FA setup", async () => { From e9201ab72a6b40ad70956437a734ebdb6ef8cae9 Mon Sep 17 00:00:00 2001 From: Joel Solano Date: Sun, 24 Aug 2025 19:08:33 +0200 Subject: [PATCH 20/20] docs: add onboarding plugin --- apps/docs/content/docs/offer.mdx | 8 +- apps/docs/content/docs/plugins/onboarding.mdx | 403 ++++++++++++++++++ apps/docs/src/components/sidebar-content.tsx | 6 + 3 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 apps/docs/content/docs/plugins/onboarding.mdx 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",