[PR #9082] [CLOSED] feat: add user enrollment flow for admin and organization plugins #16671

Closed
opened 2026-04-13 10:38:21 -05:00 by GiteaMirror · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/better-auth/better-auth/pull/9082
Author: @mcorbelli
Created: 4/9/2026
Status: Closed

Base: mainHead: feat/user-enrollment-flow


📝 Commits (10+)

  • 07e83e0 feat(admin): add enrollment options to AdminOptions and error codes
  • 3d23a80 feat(admin): add createUser enrollment flow and completeEnrollment endpoint
  • 8b8c871 feat(organization): allow unauthenticated getInvitation and add signupWithInvitation
  • d34b53f test: add tests for admin enrollment and org signupWithInvitation
  • cc6d475 fix: address security and correctness issues in enrollment and invitation flows
  • 7f89982 fix(organization): rollback new user on pre-commit failure in signupWithInvitation
  • efefa33 fix(organization): use nullish coalescing for membershipLimit default
  • 5d5c116 fix(organization): remove requireHeaders from getInvitation
  • e825ec8 fix(admin): guard against null return from updateUser in completeEnrollment
  • 6b47160 test(admin): add coverage for USER_ALREADY_HAS_PASSWORD in completeEnrollment

📊 Changes

11 files changed (+791 additions, -18 deletions)

View changed files

📝 packages/better-auth/src/plugins/admin/admin.test.ts (+168 -0)
📝 packages/better-auth/src/plugins/admin/admin.ts (+2 -0)
📝 packages/better-auth/src/plugins/admin/client.ts (+1 -0)
📝 packages/better-auth/src/plugins/admin/error-codes.ts (+3 -0)
📝 packages/better-auth/src/plugins/admin/routes.ts (+138 -0)
📝 packages/better-auth/src/plugins/admin/types.ts (+45 -0)
📝 packages/better-auth/src/plugins/organization/client.ts (+4 -1)
📝 packages/better-auth/src/plugins/organization/error-codes.ts (+2 -0)
📝 packages/better-auth/src/plugins/organization/organization.test.ts (+147 -0)
📝 packages/better-auth/src/plugins/organization/organization.ts (+3 -0)
📝 packages/better-auth/src/plugins/organization/routes/crud-invites.ts (+278 -17)

📄 Description

Admin Plugin — User Enrollment

Problem

When an admin creates a user on behalf of someone else (e.g., provisioning accounts for a company), there was no way to create the user without a password and have them set one themselves. The only option was to set a temporary password manually and share it out-of-band.

Solution

createUser now accepts being called without a password. When sendEnrollmentEmail is configured and no password is provided, the user is created with emailVerified: false and a one-time enrollment token is generated and stored in the verification table (user-enrollment:{token}).

The callback receives:

  • the user object
  • the enrollment URL
  • the raw token (for custom email rendering)

A new endpoint POST /admin/complete-enrollment lets the user set their password using the token from the email.

On success:

  • the account is linked
  • emailVerified is set to true
  • the token is deleted (one-time use)

New API

// Server config
betterAuth({
  plugins: [
    admin({
      sendEnrollmentEmail: async ({ user, url, token }, request) => {
        await sendEmail(user.email, "You've been invited", url);
      },
      enrollmentExpiresIn: 172800, // 48h (default)
    }),
  ],
});

// Create user without password → triggers enrollment email
await auth.api.createUser({
  body: { name: "Alice", email: "alice@example.com", role: "user" },
  headers: adminHeaders,
});

// User completes enrollment from the email link
await authClient.admin.completeEnrollment({
  token: "...",
  password: "my-new-password",
});

New Error Codes

  • ENROLLMENT_TOKEN_NOT_FOUND_OR_EXPIRED
  • USER_ALREADY_HAS_PASSWORD

Organization Plugin — Invitation Flow

Problem

The getInvitation endpoint required the user to be authenticated, which made it impossible to build a proper invitation landing page for new users (they don't have an account yet).

Additionally, there was no way for a new user to sign up and accept an invitation in one step.

Solution

getInvitation no longer requires authentication.

The invitationId is already a random secret (same security model as password reset tokens), so exposing:

  • organization name
  • role
  • inviter email

without a session is safe and necessary for the landing page to work.

A new endpoint POST /organization/signup-with-invitation allows a new user to create an account and accept the invitation in a single request.

  • Email is automatically marked as verified
  • A session token is returned on success

New Invitation Flow

/accept-invite?invitationId=xxx
  → GET /organization/get-invitation  (no auth required)
      → show: org name, role, inviter
  → if logged in     → POST /organization/accept-invitation (existing flow)
  → if no account    → POST /organization/signup-with-invitation (new)
  → if has account   → redirect to sign-in → then accept-invitation

New API

// No auth needed
const { data } = await authClient.organization.getInvitation({
  query: { id: invitationId },
});

// New user: sign up + accept in one step
const { data } = await authClient.organization.signupWithInvitation({
  invitationId,
  name: "Bob",
  password: "secure-password",
});
// returns: { token, user, member, invitation }

If the invited email already has an account, returns:

  • USER_ALREADY_EXISTS_PLEASE_SIGN_IN

Breaking Changes

None. All changes are purely additive.

  • createUser without password already worked before (no validation); enrollment email is opt-in via sendEnrollmentEmail.
  • getInvitation relaxing the auth requirement is backwards compatible (existing authenticated calls continue to work).

Tests

  • Admin: 6 new tests covering:

    • enrollment email trigger
    • URL/token generation
    • completeEnrollment happy path
    • expired/missing/used token guards
    • double-enrollment guard
  • Organization: 7 new tests covering:

    • unauthenticated getInvitation
    • signupWithInvitation happy path
    • existing-user guard
    • invalid/expired/already-accepted invitation guards

Summary by cubic

Adds passwordless admin enrollment and one-step org invite signup, removing temporary passwords and enabling public invite pages. Hardens both flows with strict token handling, verified-email rules, limits, and safe rollback.

  • New Features

    • Admin
      • createUser without password sends a one-time enrollment token when sendEnrollmentEmail is set; enrollmentExpiresIn controls expiry.
      • POST /admin/complete-enrollment sets the password, verifies email, and consumes the token.
      • Errors: ENROLLMENT_TOKEN_NOT_FOUND_OR_EXPIRED, USER_ALREADY_HAS_PASSWORD.
    • Organization
      • getInvitation is public; returns org name, role, and inviter email.
      • POST /organization/signup-with-invitation creates the user, verifies email, accepts the invite, returns a session. Error: USER_ALREADY_EXISTS_PLEASE_SIGN_IN.
  • Bug Fixes

    • Admin: force emailVerified: false for enrollment users; only consume tokens after a successful update; guard when a credential already exists.
    • Organization: signupWithInvitation enforces org/team limits (including per-team caps), honors before/after invitation hooks, rolls back the new user on pre-commit and function-based limit failures, uses membershipLimit ?? 100 to honor 0, removes header requirement from getInvitation, and updates client session state after signup.

Written for commit c2d2be142e. Summary will update on new commits.


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/better-auth/better-auth/pull/9082 **Author:** [@mcorbelli](https://github.com/mcorbelli) **Created:** 4/9/2026 **Status:** ❌ Closed **Base:** `main` ← **Head:** `feat/user-enrollment-flow` --- ### 📝 Commits (10+) - [`07e83e0`](https://github.com/better-auth/better-auth/commit/07e83e079097a611ff7618f26da5aeb35f80abbe) feat(admin): add enrollment options to AdminOptions and error codes - [`3d23a80`](https://github.com/better-auth/better-auth/commit/3d23a80690e16abaa4d7f04a4ca1acfc91ccfbe6) feat(admin): add createUser enrollment flow and completeEnrollment endpoint - [`8b8c871`](https://github.com/better-auth/better-auth/commit/8b8c871d260a1eeea3ca872995942ba1660fce47) feat(organization): allow unauthenticated getInvitation and add signupWithInvitation - [`d34b53f`](https://github.com/better-auth/better-auth/commit/d34b53fc414ed7dec918e9819b89c9c5063140d0) test: add tests for admin enrollment and org signupWithInvitation - [`cc6d475`](https://github.com/better-auth/better-auth/commit/cc6d4754df67285a4e93c32f2998bba30147c642) fix: address security and correctness issues in enrollment and invitation flows - [`7f89982`](https://github.com/better-auth/better-auth/commit/7f89982b3842142c834445f5d7126e58799d338d) fix(organization): rollback new user on pre-commit failure in signupWithInvitation - [`efefa33`](https://github.com/better-auth/better-auth/commit/efefa33022c7be944e34ae5b1e76b65f6391bffd) fix(organization): use nullish coalescing for membershipLimit default - [`5d5c116`](https://github.com/better-auth/better-auth/commit/5d5c1168ba70d8b4c4035747b521ed96e9c5aec9) fix(organization): remove requireHeaders from getInvitation - [`e825ec8`](https://github.com/better-auth/better-auth/commit/e825ec82f4f56ebf67f171e6e04af070d6e45ae6) fix(admin): guard against null return from updateUser in completeEnrollment - [`6b47160`](https://github.com/better-auth/better-auth/commit/6b47160cc261298bb2ba5f41088721a2197977a3) test(admin): add coverage for USER_ALREADY_HAS_PASSWORD in completeEnrollment ### 📊 Changes **11 files changed** (+791 additions, -18 deletions) <details> <summary>View changed files</summary> 📝 `packages/better-auth/src/plugins/admin/admin.test.ts` (+168 -0) 📝 `packages/better-auth/src/plugins/admin/admin.ts` (+2 -0) 📝 `packages/better-auth/src/plugins/admin/client.ts` (+1 -0) 📝 `packages/better-auth/src/plugins/admin/error-codes.ts` (+3 -0) 📝 `packages/better-auth/src/plugins/admin/routes.ts` (+138 -0) 📝 `packages/better-auth/src/plugins/admin/types.ts` (+45 -0) 📝 `packages/better-auth/src/plugins/organization/client.ts` (+4 -1) 📝 `packages/better-auth/src/plugins/organization/error-codes.ts` (+2 -0) 📝 `packages/better-auth/src/plugins/organization/organization.test.ts` (+147 -0) 📝 `packages/better-auth/src/plugins/organization/organization.ts` (+3 -0) 📝 `packages/better-auth/src/plugins/organization/routes/crud-invites.ts` (+278 -17) </details> ### 📄 Description ## Admin Plugin — User Enrollment ### Problem When an admin creates a user on behalf of someone else (e.g., provisioning accounts for a company), there was no way to create the user without a password and have them set one themselves. The only option was to set a temporary password manually and share it out-of-band. ### Solution `createUser` now accepts being called without a password. When `sendEnrollmentEmail` is configured and no password is provided, the user is created with `emailVerified: false` and a one-time enrollment token is generated and stored in the verification table (`user-enrollment:{token}`). The callback receives: - the user object - the enrollment URL - the raw token (for custom email rendering) A new endpoint `POST /admin/complete-enrollment` lets the user set their password using the token from the email. On success: - the account is linked - `emailVerified` is set to `true` - the token is deleted (one-time use) ### New API ```ts // Server config betterAuth({ plugins: [ admin({ sendEnrollmentEmail: async ({ user, url, token }, request) => { await sendEmail(user.email, "You've been invited", url); }, enrollmentExpiresIn: 172800, // 48h (default) }), ], }); // Create user without password → triggers enrollment email await auth.api.createUser({ body: { name: "Alice", email: "alice@example.com", role: "user" }, headers: adminHeaders, }); // User completes enrollment from the email link await authClient.admin.completeEnrollment({ token: "...", password: "my-new-password", }); ``` ### New Error Codes - `ENROLLMENT_TOKEN_NOT_FOUND_OR_EXPIRED` - `USER_ALREADY_HAS_PASSWORD` --- ## Organization Plugin — Invitation Flow ### Problem The `getInvitation` endpoint required the user to be authenticated, which made it impossible to build a proper invitation landing page for new users (they don't have an account yet). Additionally, there was no way for a new user to sign up and accept an invitation in one step. ### Solution `getInvitation` no longer requires authentication. The `invitationId` is already a random secret (same security model as password reset tokens), so exposing: - organization name - role - inviter email without a session is safe and necessary for the landing page to work. A new endpoint `POST /organization/signup-with-invitation` allows a new user to create an account and accept the invitation in a single request. - Email is automatically marked as verified - A session token is returned on success ### New Invitation Flow ``` /accept-invite?invitationId=xxx → GET /organization/get-invitation (no auth required) → show: org name, role, inviter → if logged in → POST /organization/accept-invitation (existing flow) → if no account → POST /organization/signup-with-invitation (new) → if has account → redirect to sign-in → then accept-invitation ``` ### New API ```ts // No auth needed const { data } = await authClient.organization.getInvitation({ query: { id: invitationId }, }); // New user: sign up + accept in one step const { data } = await authClient.organization.signupWithInvitation({ invitationId, name: "Bob", password: "secure-password", }); // returns: { token, user, member, invitation } ``` If the invited email already has an account, returns: - `USER_ALREADY_EXISTS_PLEASE_SIGN_IN` --- ## Breaking Changes None. All changes are purely additive. - `createUser` without password already worked before (no validation); enrollment email is opt-in via `sendEnrollmentEmail`. - `getInvitation` relaxing the auth requirement is backwards compatible (existing authenticated calls continue to work). --- ## Tests - **Admin:** 6 new tests covering: - enrollment email trigger - URL/token generation - completeEnrollment happy path - expired/missing/used token guards - double-enrollment guard - **Organization:** 7 new tests covering: - unauthenticated getInvitation - signupWithInvitation happy path - existing-user guard - invalid/expired/already-accepted invitation guards <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds passwordless admin enrollment and one-step org invite signup, removing temporary passwords and enabling public invite pages. Hardens both flows with strict token handling, verified-email rules, limits, and safe rollback. - **New Features** - Admin - `createUser` without `password` sends a one-time enrollment token when `sendEnrollmentEmail` is set; `enrollmentExpiresIn` controls expiry. - `POST /admin/complete-enrollment` sets the password, verifies email, and consumes the token. - Errors: `ENROLLMENT_TOKEN_NOT_FOUND_OR_EXPIRED`, `USER_ALREADY_HAS_PASSWORD`. - Organization - `getInvitation` is public; returns org name, role, and inviter email. - `POST /organization/signup-with-invitation` creates the user, verifies email, accepts the invite, returns a session. Error: `USER_ALREADY_EXISTS_PLEASE_SIGN_IN`. - **Bug Fixes** - Admin: force `emailVerified: false` for enrollment users; only consume tokens after a successful update; guard when a credential already exists. - Organization: `signupWithInvitation` enforces org/team limits (including per-team caps), honors before/after invitation hooks, rolls back the new user on pre-commit and function-based limit failures, uses `membershipLimit ?? 100` to honor `0`, removes header requirement from `getInvitation`, and updates client session state after signup. <sup>Written for commit c2d2be142e8adb09fabff83b62d129bfb5364867. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
GiteaMirror added the pull-request label 2026-04-13 10:38:21 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#16671