diff --git a/docs/content/docs/plugins/organization.mdx b/docs/content/docs/plugins/organization.mdx index 996f59b66a..33c3d51ad2 100644 --- a/docs/content/docs/plugins/organization.mdx +++ b/docs/content/docs/plugins/organization.mdx @@ -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. diff --git a/packages/better-auth/src/plugins/organization/organization.test.ts b/packages/better-auth/src/plugins/organization/organization.test.ts index 6e53653765..ed364d1926 100644 --- a/packages/better-auth/src/plugins/organization/organization.test.ts +++ b/packages/better-auth/src/plugins/organization/organization.test.ts @@ -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, diff --git a/packages/better-auth/src/plugins/organization/routes/crud-invites.ts b/packages/better-auth/src/plugins/organization/routes/crud-invites.ts index 16545dd9d0..f12b71283b 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-invites.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-invites.ts @@ -497,7 +497,6 @@ export const acceptInvitation = (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 = (options: O) => session.session.token, invitation.organizationId, ); - if (!acceptedI) { return ctx.json(null, { status: 400, @@ -577,6 +575,36 @@ export const acceptInvitation = (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, diff --git a/packages/better-auth/src/plugins/organization/types.ts b/packages/better-auth/src/plugins/organization/types.ts index 4b70f4081e..d6630dd463 100644 --- a/packages/better-auth/src/plugins/organization/types.ts +++ b/packages/better-auth/src/plugins/organization/types.ts @@ -222,6 +222,41 @@ export interface OrganizationOptions { request?: Request, ) => Promise; + 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; + /** * The schema for the organization plugin. */