[GH-ISSUE #9100] feat(admin,organization): user enrollment flow and unauthenticated invitation signup #28597

Open
opened 2026-04-17 20:02:16 -05:00 by GiteaMirror · 1 comment
Owner

Originally created by @mcorbelli on GitHub (Apr 10, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/9100

Is this suited for github?

  • Yes, this is suited for github

We now support two onboarding flows that are very useful in real apps, but they deserve to be clearly described as part of one cohesive story:

  1. Admin-created users
  2. Organization invitation onboarding

The tricky part is that organization invitations can target two different kinds of recipients:

  • someone who already has an account
  • someone who does not have an account yet

Those two cases should both work well, but they do not follow the same path.

Right now the implementation supports both, but the intended flow is not obvious unless you read the code carefully.

That makes it easy for developers integrating invitation landing pages to ask questions like:

  • “If the invited user is not logged in, what should I show?”
  • “If they already have an account, should they sign up again?”
  • “When do I use signupWithInvitation vs acceptInvitation?”
  • “What happens after login?”

This issue is about documenting and clarifying that behavior around the new implementation.

1. Admin enrollment

An admin can create a user without a password, send them an enrollment email, and let them finish setup later.

This is useful for:

  • B2B / enterprise onboarding
  • invite-only products
  • internal provisioning flows
  • cases where the admin creates the account first, but the user chooses their own password

2. Organization invitation onboarding

An organization invitation can now support both:

  • new users → sign up and join in one step
  • existing users → sign in first, then accept the invitation

That distinction is important and should be very explicit in the docs / issue description.

Describe the solution you'd like

Part 1 — Admin enrollment flow

Support a clear “create account now, let the user activate it later” flow in the admin plugin.

Example configuration

plugins: [
  admin({
    sendEnrollmentEmail: async ({ user, url, token }, request) => {
      await sendEmail({
        to: user.email,
        subject: "Complete your account setup",
        text: `Set your password: ${url}`,
      });
    },
  }),
]

Example usage

Create a user without a password:

await authClient.admin.createUser(
  {
    email: "alice@example.com",
    name: "Alice",
    role: "user",
  },
  {
    headers: adminHeaders,
  }
);

Then the user completes setup from the link:

await authClient.admin.completeEnrollment({
  token,
  password: "super-secure-password",
});

Expected behavior

When createUser is called without a password and enrollment email sending is configured:

  1. the user is created with emailVerified: false
  2. an enrollment token is stored
  3. the enrollment email is sent
  4. the user later redeems the token and sets their password
  5. the user is marked as verified

Important guards

  • the token must exist and still be valid
  • if the user already completed enrollment, it should not silently create another credential
  • if a previous attempt partially succeeded, retrying should still be recoverable

Part 2 — Organization invitation flow

This is the part that needs the clearest explanation.

When someone receives an organization invitation, there are two valid cases:

Case A — new user

They can:

  1. open the invitation link
  2. view the public invitation preview
  3. sign up and accept the invitation in one step
await authClient.organization.getInvitationPreview({
  query: { id: invitationId },
});

await authClient.organization.signupWithInvitation({
  invitationId,
  name: "Alice",
  password: "super-secure-password",
});

Expected result

  • create the user
  • create the credential account
  • accept the invitation
  • create the membership
  • create the session
  • set the active organization
  • set the active team if applicable

Case B — existing user (not logged in)

Correct flow:

  1. open invitation link
  2. view preview
  3. sign in
  4. accept invitation
await authClient.organization.getInvitationPreview({
  query: { id: invitationId },
});

// user signs in

await authClient.organization.acceptInvitation({
  invitationId,
});

Rule

  • signupWithInvitation → only for new users
  • acceptInvitation → only for existing users

If misused:

  • USER_ALREADY_EXISTS_PLEASE_SIGN_IN

Security constraint

  • logged-in email must match invitation email

Public invitation preview

await authClient.organization.getInvitationPreview({
  query: { id: invitationId },
});

Returns safe data:

  • invitation id
  • role
  • status
  • expiration
  • organization name
  • organization slug
  • inviter display name

Flow summary

New user

  • preview
  • signupWithInvitation
  • join

Existing user

  • preview
  • sign in
  • acceptInvitation

Consistency guarantees

Flows are transaction-backed to avoid partial states:

  • user created but invitation not accepted
  • invitation accepted but membership missing
  • session without correct org state

Alternatives considered

State machine approach → rejected due to complexity.

Chosen approach:

  • rely on transaction support in adapters

Additional context

  • getInvitationPreview → public
  • getInvitation → authenticated
  • signupWithInvitation → new users only
  • acceptInvitation → existing users
  • admin vs organization flows are distinct
Originally created by @mcorbelli on GitHub (Apr 10, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/9100 ### Is this suited for github? - [x] Yes, this is suited for github ### Is your feature request related to a problem? Please describe. We now support two onboarding flows that are very useful in real apps, but they deserve to be clearly described as part of one cohesive story: 1. **Admin-created users** 2. **Organization invitation onboarding** The tricky part is that organization invitations can target **two different kinds of recipients**: - someone who **already has an account** - someone who **does not have an account yet** Those two cases should both work well, but they do **not** follow the same path. Right now the implementation supports both, but the intended flow is not obvious unless you read the code carefully. That makes it easy for developers integrating invitation landing pages to ask questions like: - “If the invited user is not logged in, what should I show?” - “If they already have an account, should they sign up again?” - “When do I use `signupWithInvitation` vs `acceptInvitation`?” - “What happens after login?” This issue is about documenting and clarifying that behavior around the new implementation. ### Two related onboarding flows #### 1. Admin enrollment An admin can create a user **without a password**, send them an enrollment email, and let them finish setup later. This is useful for: - B2B / enterprise onboarding - invite-only products - internal provisioning flows - cases where the admin creates the account first, but the user chooses their own password #### 2. Organization invitation onboarding An organization invitation can now support both: - **new users** → sign up and join in one step - **existing users** → sign in first, then accept the invitation That distinction is important and should be very explicit in the docs / issue description. ### Describe the solution you'd like ## Part 1 — Admin enrollment flow Support a clear “create account now, let the user activate it later” flow in the admin plugin. ### Example configuration ```ts plugins: [ admin({ sendEnrollmentEmail: async ({ user, url, token }, request) => { await sendEmail({ to: user.email, subject: "Complete your account setup", text: `Set your password: ${url}`, }); }, }), ] ``` ### Example usage Create a user without a password: ```ts await authClient.admin.createUser( { email: "alice@example.com", name: "Alice", role: "user", }, { headers: adminHeaders, } ); ``` Then the user completes setup from the link: ```ts await authClient.admin.completeEnrollment({ token, password: "super-secure-password", }); ``` ### Expected behavior When createUser is called without a password and enrollment email sending is configured: 1. the user is created with emailVerified: false 2. an enrollment token is stored 3. the enrollment email is sent 4. the user later redeems the token and sets their password 5. the user is marked as verified ### Important guards - the token must exist and still be valid - if the user already completed enrollment, it should not silently create another credential - if a previous attempt partially succeeded, retrying should still be recoverable --- ## Part 2 — Organization invitation flow This is the part that needs the clearest explanation. When someone receives an organization invitation, there are two valid cases: ### Case A — new user They can: 1. open the invitation link 2. view the public invitation preview 3. sign up and accept the invitation in one step ```ts await authClient.organization.getInvitationPreview({ query: { id: invitationId }, }); await authClient.organization.signupWithInvitation({ invitationId, name: "Alice", password: "super-secure-password", }); ``` ### Expected result - create the user - create the credential account - accept the invitation - create the membership - create the session - set the active organization - set the active team if applicable --- ### Case B — existing user (not logged in) Correct flow: 1. open invitation link 2. view preview 3. sign in 4. accept invitation ```ts await authClient.organization.getInvitationPreview({ query: { id: invitationId }, }); // user signs in await authClient.organization.acceptInvitation({ invitationId, }); ``` ### Rule - `signupWithInvitation` → only for new users - `acceptInvitation` → only for existing users If misused: - USER_ALREADY_EXISTS_PLEASE_SIGN_IN ### Security constraint - logged-in email must match invitation email --- ## Public invitation preview ```ts await authClient.organization.getInvitationPreview({ query: { id: invitationId }, }); ``` Returns safe data: - invitation id - role - status - expiration - organization name - organization slug - inviter display name --- ## Flow summary ### New user - preview - signupWithInvitation - join ### Existing user - preview - sign in - acceptInvitation --- ## Consistency guarantees Flows are transaction-backed to avoid partial states: - user created but invitation not accepted - invitation accepted but membership missing - session without correct org state --- ## Alternatives considered State machine approach → rejected due to complexity. Chosen approach: - rely on transaction support in adapters --- ## Additional context - getInvitationPreview → public - getInvitation → authenticated - signupWithInvitation → new users only - acceptInvitation → existing users - admin vs organization flows are distinct
GiteaMirror added the credentialsorganization labels 2026-04-17 20:02:16 -05:00
Author
Owner

@mcorbelli commented on GitHub (Apr 12, 2026):

I’ve reworked the implementation to remove the breaking changes that were previously called out, so the flow should now be consistent with the existing behavior.

At this point, I believe the onboarding flow is in a valid state:

  • admin enrollment works without changing existing behavior
  • organization invitations now clearly split new-user signup from existing-user acceptance
  • the public preview flow no longer weakens the authenticated invitation flow

@bytaesu could you take a look and let me know if this updated approach seems acceptable

<!-- gh-comment-id:4231672872 --> @mcorbelli commented on GitHub (Apr 12, 2026): I’ve reworked the implementation to remove the breaking changes that were previously called out, so the flow should now be consistent with the existing behavior. At this point, I believe the onboarding flow is in a valid state: - admin enrollment works without changing existing behavior - organization invitations now clearly split new-user signup from existing-user acceptance - the public preview flow no longer weakens the authenticated invitation flow @bytaesu could you take a look and let me know if this updated approach seems acceptable
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#28597