feat: add onInvitationAccepted callback for org invitations (#4120)

This commit is contained in:
KinfeMichael Tariku
2025-08-27 11:06:22 -07:00
committed by GitHub
parent baa986903f
commit cac8b0301b
4 changed files with 187 additions and 3 deletions
@@ -604,6 +604,38 @@ export const auth = betterAuth({
})
```
### Invitation Accepted Callback
You can configure Better Auth to execute a callback function when an invitation is accepted. This is useful for logging events, updating analytics, sending notifications, or any other custom logic you need to run when someone joins your organization.
```ts title="auth.ts"
import { betterAuth } from "better-auth"
import { organization } from "better-auth/plugins"
export const auth = betterAuth({
plugins: [
organization({
async sendInvitationEmail(data) {
// ... your invitation email logic
},
async onInvitationAccepted(data) {
// This callback gets triggered when an invitation is accepted
},
}),
],
});
```
The callback receives the following data:
- `id`: The invitation ID
- `role`: The role assigned to the user
- `organization`: The organization the user joined
- `invitation`: The invitation object
- `inviter`: The member who sent the invitation (including user details)
- `acceptedUser`: The user who accepted the invitation
### Cancel Invitation
If a user has sent out an invitation, you can use this method to cancel it.
@@ -1,4 +1,4 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import { describe, expect, expectTypeOf, it, vi } from "vitest";
import { getTestInstance } from "../../test-utils/test-instance";
import { organization } from "./organization";
import { createAuthClient } from "../../client";
@@ -15,6 +15,7 @@ import { ownerAc } from "./access";
import { nextCookies } from "../../integrations/next-js";
describe("organization", async (it) => {
const onInvitationAccepted = vi.fn();
const { auth, signInWithTestUser, signInWithUser, cookieSetter } =
await getTestInstance({
user: {
@@ -36,6 +37,7 @@ describe("organization", async (it) => {
},
},
invitationLimit: 3,
onInvitationAccepted,
}),
],
logger: {
@@ -293,7 +295,94 @@ describe("organization", async (it) => {
organizationId,
);
});
it("should call onInvitationAccepted callback when invitation is accepted", async () => {
onInvitationAccepted.mockClear();
const testOrg = await client.organization.create({
name: "Test Org for Callback",
slug: `test-org-callback-${Math.random().toString(36).substring(7)}`,
metadata: {
test: "test",
},
fetchOptions: {
headers,
},
});
if (!testOrg.data) {
throw new Error("Failed to create test organization");
}
const uniqueId = Math.random().toString(36).substring(7);
const newUser = {
email: `test-accept-${uniqueId}@example.com`,
password: "password123",
name: "Test Accept User",
};
await client.signUp.email({
email: newUser.email,
password: newUser.password,
name: newUser.name,
});
const { headers: newUserHeaders } = await signInWithUser(
newUser.email,
newUser.password,
);
const invite = await client.organization.inviteMember({
organizationId: testOrg.data.id,
email: newUser.email,
role: "member",
fetchOptions: {
headers,
},
});
if (!invite.data) {
console.error("Invitation creation failed:", invite);
throw new Error("Invitation not created");
}
expect(invite.data.role).toBe("member");
const accept = await client.organization.acceptInvitation({
invitationId: invite.data.id,
fetchOptions: {
headers: newUserHeaders,
},
});
expect(accept.data?.invitation.status).toBe("accepted");
expect(onInvitationAccepted).toHaveBeenCalledTimes(1);
expect(onInvitationAccepted).toHaveBeenCalledWith(
expect.objectContaining({
id: invite.data.id,
role: "member",
organization: expect.objectContaining({
id: testOrg.data.id,
name: "Test Org for Callback",
}),
invitation: expect.objectContaining({
id: invite.data.id,
status: expect.any(String),
}),
inviter: expect.objectContaining({
user: expect.objectContaining({
email: expect.any(String),
name: expect.any(String),
}),
}),
acceptedUser: expect.objectContaining({
id: expect.any(String),
email: newUser.email,
name: newUser.name,
}),
}),
expect.any(Object),
);
});
it("should create invitation with multiple roles", async () => {
const invite = await client.organization.inviteMember({
organizationId: organizationId,
@@ -497,7 +497,6 @@ export const acceptInvitation = <O extends OrganizationOptions>(options: O) =>
invitationId: ctx.body.invitationId,
status: "accepted",
});
if (!acceptedI) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.FAILED_TO_RETRIEVE_INVITATION,
@@ -568,7 +567,6 @@ export const acceptInvitation = <O extends OrganizationOptions>(options: O) =>
session.session.token,
invitation.organizationId,
);
if (!acceptedI) {
return ctx.json(null, {
status: 400,
@@ -577,6 +575,36 @@ export const acceptInvitation = <O extends OrganizationOptions>(options: O) =>
},
});
}
if (ctx.context.orgOptions.onInvitationAccepted) {
const organization = await adapter.findOrganizationById(
invitation.organizationId,
);
const inviterMember = await adapter.findMemberByOrgId({
userId: invitation.inviterId,
organizationId: invitation.organizationId,
});
const inviterUser = await ctx.context.internalAdapter.findUserById(
invitation.inviterId,
);
if (organization && inviterMember && inviterUser) {
await ctx.context.orgOptions.onInvitationAccepted(
{
id: invitation.id,
role: invitation.role as string,
organization: organization,
invitation: invitation as unknown as Invitation,
inviter: {
...inviterMember,
user: inviterUser,
},
acceptedUser: session.user,
},
ctx.request,
);
}
}
return ctx.json({
invitation: acceptedI,
@@ -222,6 +222,41 @@ export interface OrganizationOptions {
request?: Request,
) => Promise<void>;
onInvitationAccepted?: (
data: {
/**
* the invitation id
*/
id: string;
/**
* the role of the user
*/
role: string;
/**
* the organization the user joined
*/
organization: Organization;
/**
* the invitation object
*/
invitation: Invitation;
/**
* the member who sent the invitation
*/
inviter: Member & {
user: User;
};
/**
* the user who accepted the invitation
*/
acceptedUser: User;
},
/**
* The request object
*/
request?: Request,
) => Promise<void>;
/**
* The schema for the organization plugin.
*/