feat(organization): organization life cycle hooks (#4049)

Co-authored-by: Maxwell <145994855+ping-maxwell@users.noreply.github.com>
This commit is contained in:
Bereket Engida
2025-08-29 22:31:04 -07:00
committed by GitHub
parent 51856c576c
commit bba0a42ee5
9 changed files with 1765 additions and 519 deletions

View File

@@ -24,6 +24,6 @@
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },
"[mdx]": { "[mdx]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "unifiedjs.vscode-mdx"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -15,3 +15,4 @@ export type * from "./oauth2/types";
export { createTelemetry } from "./telemetry"; export { createTelemetry } from "./telemetry";
export { getTelemetryAuthConfig } from "./telemetry/detectors/detect-auth-config"; export { getTelemetryAuthConfig } from "./telemetry/detectors/detect-auth-config";
export type { TelemetryEvent } from "./telemetry/types"; export type { TelemetryEvent } from "./telemetry/types";
export { APIError } from "./api";

View File

@@ -1,4 +1,4 @@
import { describe, expect, expectTypeOf, it, vi } from "vitest"; import { describe, expect, expectTypeOf, it } from "vitest";
import { getTestInstance } from "../../test-utils/test-instance"; import { getTestInstance } from "../../test-utils/test-instance";
import { organization } from "./organization"; import { organization } from "./organization";
import { createAuthClient } from "../../client"; import { createAuthClient } from "../../client";
@@ -15,7 +15,6 @@ import { ownerAc } from "./access";
import { nextCookies } from "../../integrations/next-js"; import { nextCookies } from "../../integrations/next-js";
describe("organization", async (it) => { describe("organization", async (it) => {
const onInvitationAccepted = vi.fn();
const { auth, signInWithTestUser, signInWithUser, cookieSetter } = const { auth, signInWithTestUser, signInWithUser, cookieSetter } =
await getTestInstance({ await getTestInstance({
user: { user: {
@@ -37,7 +36,6 @@ describe("organization", async (it) => {
}, },
}, },
invitationLimit: 3, invitationLimit: 3,
onInvitationAccepted,
}), }),
], ],
logger: { logger: {
@@ -295,94 +293,7 @@ describe("organization", async (it) => {
organizationId, 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 () => { it("should create invitation with multiple roles", async () => {
const invite = await client.organization.inviteMember({ const invite = await client.organization.inviteMember({
organizationId: organizationId, organizationId: organizationId,
@@ -1996,3 +1907,79 @@ describe("Additional Fields", async () => {
expect(row.teamRequiredField).toBe("hey4"); expect(row.teamRequiredField).toBe("hey4");
}); });
}); });
describe("organization hooks", async (it) => {
let hooksCalled: string[] = [];
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [
organization({
organizationHooks: {
beforeCreateOrganization: async (data) => {
hooksCalled.push("beforeCreateOrganization");
return {
data: {
...data.organization,
metadata: { hookCalled: true },
},
};
},
afterCreateOrganization: async (data) => {
hooksCalled.push("afterCreateOrganization");
},
beforeCreateInvitation: async (data) => {
hooksCalled.push("beforeCreateInvitation");
},
afterCreateInvitation: async (data) => {
hooksCalled.push("afterCreateInvitation");
},
beforeAddMember: async (data) => {
hooksCalled.push("beforeAddMember");
},
afterAddMember: async (data) => {
hooksCalled.push("afterAddMember");
},
},
async sendInvitationEmail() {},
}),
],
});
const client = createAuthClient({
plugins: [organizationClient()],
baseURL: "http://localhost:3000/api/auth",
fetchOptions: {
customFetchImpl: async (url, init) => {
return auth.handler(new Request(url, init));
},
},
});
const { headers } = await signInWithTestUser();
it("should call organization creation hooks", async () => {
hooksCalled = [];
const organization = await client.organization.create({
name: "Test Org with Hooks",
slug: "test-org-hooks",
fetchOptions: { headers },
});
expect(hooksCalled).toContain("beforeCreateOrganization");
expect(hooksCalled).toContain("afterCreateOrganization");
expect(organization.data?.metadata).toEqual({ hookCalled: true });
});
it("should call invitation hooks", async () => {
hooksCalled = [];
await client.organization.inviteMember({
email: "test@example.com",
role: "member",
fetchOptions: { headers },
});
expect(hooksCalled).toContain("beforeCreateInvitation");
expect(hooksCalled).toContain("afterCreateInvitation");
});
});

View File

@@ -378,14 +378,37 @@ export const createInvitation = <O extends OrganizationOptions>(option: O) => {
...additionalFields ...additionalFields
} = ctx.body; } = ctx.body;
let invitationData = {
role: roles,
email: ctx.body.email.toLowerCase(),
organizationId: organizationId,
teamIds,
...(additionalFields ? additionalFields : {}),
};
// Run beforeCreateInvitation hook
if (option?.organizationHooks?.beforeCreateInvitation) {
const response = await option?.organizationHooks.beforeCreateInvitation(
{
invitation: {
...invitationData,
inviterId: session.user.id,
teamId: teamIds.length > 0 ? teamIds[0] : undefined,
},
inviter: session.user,
organization,
},
);
if (response && typeof response === "object" && "data" in response) {
invitationData = {
...invitationData,
...response.data,
};
}
}
const invitation = await adapter.createInvitation({ const invitation = await adapter.createInvitation({
invitation: { invitation: invitationData,
role: roles,
email: ctx.body.email.toLowerCase(),
organizationId: organizationId,
teamIds,
...(additionalFields ? additionalFields : {}),
},
user: session.user, user: session.user,
}); });
@@ -405,6 +428,15 @@ export const createInvitation = <O extends OrganizationOptions>(option: O) => {
ctx.request, ctx.request,
); );
// Run afterCreateInvitation hook
if (option?.organizationHooks?.afterCreateInvitation) {
await option?.organizationHooks.afterCreateInvitation({
invitation: invitation as unknown as Invitation,
inviter: session.user,
organization,
});
}
return ctx.json(invitation); return ctx.json(invitation);
}, },
); );
@@ -492,6 +524,25 @@ export const acceptInvitation = <O extends OrganizationOptions>(options: O) =>
ORGANIZATION_ERROR_CODES.ORGANIZATION_MEMBERSHIP_LIMIT_REACHED, ORGANIZATION_ERROR_CODES.ORGANIZATION_MEMBERSHIP_LIMIT_REACHED,
}); });
} }
const organization = await adapter.findOrganizationById(
invitation.organizationId,
);
if (!organization) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND,
});
}
// Run beforeAcceptInvitation hook
if (options?.organizationHooks?.beforeAcceptInvitation) {
await options?.organizationHooks.beforeAcceptInvitation({
invitation: invitation as unknown as Invitation,
user: session.user,
organization,
});
}
const acceptedI = await adapter.updateInvitation({ const acceptedI = await adapter.updateInvitation({
invitationId: ctx.body.invitationId, invitationId: ctx.body.invitationId,
status: "accepted", status: "accepted",
@@ -573,35 +624,13 @@ export const acceptInvitation = <O extends OrganizationOptions>(options: O) =>
}, },
}); });
} }
if (ctx.context.orgOptions.onInvitationAccepted) { if (options?.organizationHooks?.afterAcceptInvitation) {
const organization = await adapter.findOrganizationById( await options?.organizationHooks.afterAcceptInvitation({
invitation.organizationId, invitation: acceptedI as unknown as Invitation,
); member,
user: session.user,
const inviterMember = await adapter.findMemberByOrgId({ organization,
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({ return ctx.json({
invitation: acceptedI, invitation: acceptedI,
@@ -679,11 +708,38 @@ export const rejectInvitation = <O extends OrganizationOptions>(options: O) =>
}); });
} }
const organization = await adapter.findOrganizationById(
invitation.organizationId,
);
if (!organization) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND,
});
}
// Run beforeRejectInvitation hook
if (options?.organizationHooks?.beforeRejectInvitation) {
await options?.organizationHooks.beforeRejectInvitation({
invitation: invitation as unknown as Invitation,
user: session.user,
organization,
});
}
const rejectedI = await adapter.updateInvitation({ const rejectedI = await adapter.updateInvitation({
invitationId: ctx.body.invitationId, invitationId: ctx.body.invitationId,
status: "rejected", status: "rejected",
}); });
// Run afterRejectInvitation hook
if (options?.organizationHooks?.afterRejectInvitation) {
await options?.organizationHooks.afterRejectInvitation({
invitation: rejectedI || (invitation as unknown as Invitation),
user: session.user,
organization,
});
}
return ctx.json({ return ctx.json({
invitation: rejectedI, invitation: rejectedI,
member: null, member: null,
@@ -756,10 +812,39 @@ export const cancelInvitation = <O extends OrganizationOptions>(options: O) =>
ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION, ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION,
}); });
} }
const organization = await adapter.findOrganizationById(
invitation.organizationId,
);
if (!organization) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND,
});
}
// Run beforeCancelInvitation hook
if (options?.organizationHooks?.beforeCancelInvitation) {
await options?.organizationHooks.beforeCancelInvitation({
invitation: invitation as unknown as Invitation,
cancelledBy: session.user,
organization,
});
}
const canceledI = await adapter.updateInvitation({ const canceledI = await adapter.updateInvitation({
invitationId: ctx.body.invitationId, invitationId: ctx.body.invitationId,
status: "canceled", status: "canceled",
}); });
// Run afterCancelInvitation hook
if (options?.organizationHooks?.afterCancelInvitation) {
await options?.organizationHooks.afterCancelInvitation({
invitation: (canceledI as unknown as Invitation) || invitation,
cancelledBy: session.user,
organization,
});
}
return ctx.json(canceledI); return ctx.json(canceledI);
}, },
); );

View File

@@ -147,13 +147,42 @@ export const addMember = <O extends OrganizationOptions>(option: O) => {
...additionalFields ...additionalFields
} = ctx.body; } = ctx.body;
const createdMember = await adapter.createMember({ const organization = await adapter.findOrganizationById(orgId);
if (!organization) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND,
});
}
let memberData = {
organizationId: orgId, organizationId: orgId,
userId: user.id, userId: user.id,
role: parseRoles(ctx.body.role as string | string[]), role: parseRoles(ctx.body.role as string | string[]),
createdAt: new Date(), createdAt: new Date(),
...(additionalFields ? additionalFields : {}), ...(additionalFields ? additionalFields : {}),
}); };
// Run beforeAddMember hook
if (option?.organizationHooks?.beforeAddMember) {
const response = await option?.organizationHooks.beforeAddMember({
member: {
userId: user.id,
organizationId: orgId,
role: parseRoles(ctx.body.role as string | string[]),
...additionalFields,
},
user,
organization,
});
if (response && typeof response === "object" && "data" in response) {
memberData = {
...memberData,
...response.data,
};
}
}
const createdMember = await adapter.createMember(memberData);
if (teamId) { if (teamId) {
await adapter.findOrCreateTeamMember({ await adapter.findOrCreateTeamMember({
@@ -162,6 +191,15 @@ export const addMember = <O extends OrganizationOptions>(option: O) => {
}); });
} }
// Run afterAddMember hook
if (option?.organizationHooks?.afterAddMember) {
await option?.organizationHooks.afterAddMember({
member: createdMember,
user,
organization,
});
}
return ctx.json(createdMember); return ctx.json(createdMember);
}, },
); );
@@ -308,6 +346,32 @@ export const removeMember = <O extends OrganizationOptions>(options: O) =>
message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND, message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND,
}); });
} }
const organization = await adapter.findOrganizationById(organizationId);
if (!organization) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND,
});
}
const userBeingRemoved = await ctx.context.internalAdapter.findUserById(
toBeRemovedMember.userId,
);
if (!userBeingRemoved) {
throw new APIError("BAD_REQUEST", {
message: "User not found",
});
}
// Run beforeRemoveMember hook
if (options?.organizationHooks?.beforeRemoveMember) {
await options?.organizationHooks.beforeRemoveMember({
member: toBeRemovedMember,
user: userBeingRemoved,
organization,
});
}
await adapter.deleteMember(toBeRemovedMember.id); await adapter.deleteMember(toBeRemovedMember.id);
if ( if (
session.user.id === toBeRemovedMember.userId && session.user.id === toBeRemovedMember.userId &&
@@ -316,6 +380,16 @@ export const removeMember = <O extends OrganizationOptions>(options: O) =>
) { ) {
await adapter.setActiveOrganization(session.session.token, null); await adapter.setActiveOrganization(session.session.token, null);
} }
// Run afterRemoveMember hook
if (options?.organizationHooks?.afterRemoveMember) {
await options?.organizationHooks.afterRemoveMember({
member: toBeRemovedMember,
user: userBeingRemoved,
organization,
});
}
return ctx.json({ return ctx.json({
member: toBeRemovedMember, member: toBeRemovedMember,
}); });
@@ -486,15 +560,82 @@ export const updateMemberRole = <O extends OrganizationOptions>(option: O) =>
ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER, ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER,
}); });
} }
const organization = await adapter.findOrganizationById(organizationId);
if (!organization) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND,
});
}
const userBeingUpdated = await ctx.context.internalAdapter.findUserById(
toBeUpdatedMember.userId,
);
if (!userBeingUpdated) {
throw new APIError("BAD_REQUEST", {
message: "User not found",
});
}
const previousRole = toBeUpdatedMember.role;
const newRole = parseRoles(ctx.body.role as string | string[]);
// Run beforeUpdateMemberRole hook
if (option?.organizationHooks?.beforeUpdateMemberRole) {
const response = await option?.organizationHooks.beforeUpdateMemberRole(
{
member: toBeUpdatedMember,
newRole,
user: userBeingUpdated,
organization,
},
);
if (response && typeof response === "object" && "data" in response) {
// Allow the hook to modify the role
const updatedMember = await adapter.updateMember(
ctx.body.memberId,
response.data.role || newRole,
);
if (!updatedMember) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND,
});
}
// Run afterUpdateMemberRole hook
if (option?.organizationHooks?.afterUpdateMemberRole) {
await option?.organizationHooks.afterUpdateMemberRole({
member: updatedMember,
previousRole,
user: userBeingUpdated,
organization,
});
}
return ctx.json(updatedMember);
}
}
const updatedMember = await adapter.updateMember( const updatedMember = await adapter.updateMember(
ctx.body.memberId, ctx.body.memberId,
parseRoles(ctx.body.role as string | string[]), newRole,
); );
if (!updatedMember) { if (!updatedMember) {
throw new APIError("BAD_REQUEST", { throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND, message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND,
}); });
} }
// Run afterUpdateMemberRole hook
if (option?.organizationHooks?.afterUpdateMemberRole) {
await option?.organizationHooks.afterUpdateMemberRole({
member: updatedMember,
previousRole,
user: userBeingUpdated,
organization,
});
}
return ctx.json(updatedMember); return ctx.json(updatedMember);
}, },
); );

View File

@@ -22,7 +22,7 @@ import {
} from "../../../db"; } from "../../../db";
export const createOrganization = <O extends OrganizationOptions>( export const createOrganization = <O extends OrganizationOptions>(
options: O, options?: O,
) => { ) => {
const additionalFieldsSchema = toZodSchema({ const additionalFieldsSchema = toZodSchema({
fields: options?.schema?.organization?.additionalFields || {}, fields: options?.schema?.organization?.additionalFields || {},
@@ -162,12 +162,6 @@ export const createOrganization = <O extends OrganizationOptions>(
...orgData ...orgData
} = ctx.body; } = ctx.body;
let hookResponse:
| {
data: Record<string, any>;
}
| undefined = undefined;
if (options.organizationCreation?.beforeCreate) { if (options.organizationCreation?.beforeCreate) {
const response = await options.organizationCreation.beforeCreate( const response = await options.organizationCreation.beforeCreate(
{ {
@@ -180,7 +174,24 @@ export const createOrganization = <O extends OrganizationOptions>(
ctx.request, ctx.request,
); );
if (response && typeof response === "object" && "data" in response) { if (response && typeof response === "object" && "data" in response) {
hookResponse = response; ctx.body = {
...ctx.body,
...response.data,
};
}
}
if (options?.organizationHooks?.beforeCreateOrganization) {
const response =
await options?.organizationHooks.beforeCreateOrganization({
organization: orgData,
user,
});
if (response && typeof response === "object" && "data" in response) {
ctx.body = {
...ctx.body,
...response.data,
};
} }
} }
@@ -188,7 +199,6 @@ export const createOrganization = <O extends OrganizationOptions>(
organization: { organization: {
...orgData, ...orgData,
createdAt: new Date(), createdAt: new Date(),
...(hookResponse?.data || {}),
}, },
}); });
@@ -241,6 +251,14 @@ export const createOrganization = <O extends OrganizationOptions>(
); );
} }
if (options?.organizationHooks?.afterCreateOrganization) {
await options?.organizationHooks.afterCreateOrganization({
organization,
user,
member,
});
}
if (ctx.context.session && !ctx.body.keepCurrentActiveOrganization) { if (ctx.context.session && !ctx.body.keepCurrentActiveOrganization) {
await adapter.setActiveOrganization( await adapter.setActiveOrganization(
ctx.context.session.session.token, ctx.context.session.session.token,
@@ -297,7 +315,7 @@ export const checkOrganizationSlug = <O extends OrganizationOptions>(
); );
export const updateOrganization = <O extends OrganizationOptions>( export const updateOrganization = <O extends OrganizationOptions>(
options: O, options?: O,
) => { ) => {
const additionalFieldsSchema = toZodSchema({ const additionalFieldsSchema = toZodSchema({
fields: options?.schema?.organization?.additionalFields || {}, fields: options?.schema?.organization?.additionalFields || {},
@@ -416,10 +434,31 @@ export const updateOrganization = <O extends OrganizationOptions>(
ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION, ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION,
}); });
} }
if (options?.organizationHooks?.beforeUpdateOrganization) {
const response =
await options.organizationHooks.beforeUpdateOrganization({
organization: ctx.body.data,
user: session.user,
member,
});
if (response && typeof response === "object" && "data" in response) {
ctx.body.data = {
...ctx.body.data,
...response.data,
};
}
}
const updatedOrg = await adapter.updateOrganization( const updatedOrg = await adapter.updateOrganization(
organizationId, organizationId,
ctx.body.data, ctx.body.data,
); );
if (options?.organizationHooks?.afterUpdateOrganization) {
await options.organizationHooks.afterUpdateOrganization({
organization: updatedOrg,
user: session.user,
member,
});
}
return ctx.json(updatedOrg); return ctx.json(updatedOrg);
}, },
); );
@@ -459,6 +498,19 @@ export const deleteOrganization = <O extends OrganizationOptions>(
}, },
}, },
async (ctx) => { async (ctx) => {
const disableOrganizationDeletion =
ctx.context.orgOptions.organizationDeletion?.disabled ||
ctx.context.orgOptions.disableOrganizationDeletion;
if (disableOrganizationDeletion) {
if (ctx.context.orgOptions.organizationDeletion?.disabled) {
ctx.context.logger.info(
"`organizationDeletion.disabled` is deprecated. Use `disableOrganizationDeletion` instead",
);
}
throw new APIError("NOT_FOUND", {
message: "Organization deletion is disabled",
});
}
const session = await ctx.context.getSession(ctx); const session = await ctx.context.getSession(ctx);
if (!session) { if (!session) {
throw new APIError("UNAUTHORIZED", { status: 401 }); throw new APIError("UNAUTHORIZED", { status: 401 });
@@ -503,23 +555,20 @@ export const deleteOrganization = <O extends OrganizationOptions>(
*/ */
await adapter.setActiveOrganization(session.session.token, null); await adapter.setActiveOrganization(session.session.token, null);
} }
const option = ctx.context.orgOptions.organizationDeletion;
if (option?.disabled) {
throw new APIError("FORBIDDEN");
}
const org = await adapter.findOrganizationById(organizationId); const org = await adapter.findOrganizationById(organizationId);
if (!org) { if (!org) {
throw new APIError("BAD_REQUEST"); throw new APIError("BAD_REQUEST");
} }
if (option?.beforeDelete) { if (options?.organizationHooks?.beforeDeleteOrganization) {
await option.beforeDelete({ await options.organizationHooks.beforeDeleteOrganization({
organization: org, organization: org,
user: session.user, user: session.user,
}); });
} }
await adapter.deleteOrganization(organizationId); await adapter.deleteOrganization(organizationId);
if (option?.afterDelete) { if (options?.organizationHooks?.afterDeleteOrganization) {
await option.afterDelete({ await options.organizationHooks.afterDeleteOrganization({
organization: org, organization: org,
user: session.user, user: session.user,
}); });

View File

@@ -155,13 +155,52 @@ export const createTeam = <O extends OrganizationOptions>(options: O) => {
}); });
} }
const { name, organizationId: _, ...additionalFields } = ctx.body; const { name, organizationId: _, ...additionalFields } = ctx.body;
const createdTeam = await adapter.createTeam({
const organization = await adapter.findOrganizationById(organizationId);
if (!organization) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND,
});
}
let teamData = {
name, name,
organizationId, organizationId,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
...additionalFields, ...additionalFields,
}); };
// Run beforeCreateTeam hook
if (options?.organizationHooks?.beforeCreateTeam) {
const response = await options?.organizationHooks.beforeCreateTeam({
team: {
name,
organizationId,
...additionalFields,
},
user: session?.user,
organization,
});
if (response && typeof response === "object" && "data" in response) {
teamData = {
...teamData,
...response.data,
};
}
}
const createdTeam = await adapter.createTeam(teamData);
// Run afterCreateTeam hook
if (options?.organizationHooks?.afterCreateTeam) {
await options?.organizationHooks.afterCreateTeam({
team: createdTeam,
user: session?.user,
organization,
});
}
return ctx.json(createdTeam); return ctx.json(createdTeam);
}, },
); );
@@ -273,7 +312,33 @@ export const removeTeam = <O extends OrganizationOptions>(options: O) =>
} }
} }
const organization = await adapter.findOrganizationById(organizationId);
if (!organization) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND,
});
}
// Run beforeDeleteTeam hook
if (options?.organizationHooks?.beforeDeleteTeam) {
await options?.organizationHooks.beforeDeleteTeam({
team,
user: session?.user,
organization,
});
}
await adapter.deleteTeam(team.id); await adapter.deleteTeam(team.id);
// Run afterDeleteTeam hook
if (options?.organizationHooks?.afterDeleteTeam) {
await options?.organizationHooks.afterDeleteTeam({
team,
user: session?.user,
organization,
});
}
return ctx.json({ message: "Team removed successfully." }); return ctx.json({ message: "Team removed successfully." });
}, },
); );
@@ -414,10 +479,57 @@ export const updateTeam = <O extends OrganizationOptions>(options: O) => {
const { name, organizationId: __, ...additionalFields } = ctx.body.data; const { name, organizationId: __, ...additionalFields } = ctx.body.data;
const updatedTeam = await adapter.updateTeam(team.id, { const organization = await adapter.findOrganizationById(organizationId);
if (!organization) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND,
});
}
const updates = {
name, name,
...additionalFields, ...additionalFields,
}); };
// Run beforeUpdateTeam hook
if (options?.organizationHooks?.beforeUpdateTeam) {
const response = await options?.organizationHooks.beforeUpdateTeam({
team,
updates,
user: session.user,
organization,
});
if (response && typeof response === "object" && "data" in response) {
// Allow the hook to modify the updates
const modifiedUpdates = response.data;
const updatedTeam = await adapter.updateTeam(
team.id,
modifiedUpdates,
);
// Run afterUpdateTeam hook
if (options?.organizationHooks?.afterUpdateTeam) {
await options?.organizationHooks.afterUpdateTeam({
team: updatedTeam,
user: session.user,
organization,
});
}
return ctx.json(updatedTeam);
}
}
const updatedTeam = await adapter.updateTeam(team.id, updates);
// Run afterUpdateTeam hook
if (options?.organizationHooks?.afterUpdateTeam) {
await options?.organizationHooks.afterUpdateTeam({
team: updatedTeam,
user: session.user,
organization,
});
}
return ctx.json(updatedTeam); return ctx.json(updatedTeam);
}, },
@@ -862,11 +974,66 @@ export const addTeamMember = <O extends OrganizationOptions>(options: O) =>
}); });
} }
const team = await adapter.findTeamById({
teamId: ctx.body.teamId,
organizationId: session.session.activeOrganizationId,
});
if (!team) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND,
});
}
const organization = await adapter.findOrganizationById(
session.session.activeOrganizationId,
);
if (!organization) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND,
});
}
const userBeingAdded = await ctx.context.internalAdapter.findUserById(
ctx.body.userId,
);
if (!userBeingAdded) {
throw new APIError("BAD_REQUEST", {
message: "User not found",
});
}
// Run beforeAddTeamMember hook
if (options?.organizationHooks?.beforeAddTeamMember) {
const response = await options?.organizationHooks.beforeAddTeamMember({
teamMember: {
teamId: ctx.body.teamId,
userId: ctx.body.userId,
},
team,
user: userBeingAdded,
organization,
});
if (response && typeof response === "object" && "data" in response) {
// Allow the hook to modify the data
}
}
const teamMember = await adapter.findOrCreateTeamMember({ const teamMember = await adapter.findOrCreateTeamMember({
teamId: ctx.body.teamId, teamId: ctx.body.teamId,
userId: ctx.body.userId, userId: ctx.body.userId,
}); });
// Run afterAddTeamMember hook
if (options?.organizationHooks?.afterAddTeamMember) {
await options?.organizationHooks.afterAddTeamMember({
teamMember,
team,
user: userBeingAdded,
organization,
});
}
return ctx.json(teamMember); return ctx.json(teamMember);
}, },
); );
@@ -962,11 +1129,71 @@ export const removeTeamMember = <O extends OrganizationOptions>(options: O) =>
}); });
} }
const team = await adapter.findTeamById({
teamId: ctx.body.teamId,
organizationId: session.session.activeOrganizationId,
});
if (!team) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND,
});
}
const organization = await adapter.findOrganizationById(
session.session.activeOrganizationId,
);
if (!organization) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND,
});
}
const userBeingRemoved = await ctx.context.internalAdapter.findUserById(
ctx.body.userId,
);
if (!userBeingRemoved) {
throw new APIError("BAD_REQUEST", {
message: "User not found",
});
}
const teamMember = await adapter.findTeamMember({
teamId: ctx.body.teamId,
userId: ctx.body.userId,
});
if (!teamMember) {
throw new APIError("BAD_REQUEST", {
message: ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_TEAM,
});
}
// Run beforeRemoveTeamMember hook
if (options?.organizationHooks?.beforeRemoveTeamMember) {
await options?.organizationHooks.beforeRemoveTeamMember({
teamMember,
team,
user: userBeingRemoved,
organization,
});
}
await adapter.removeTeamMember({ await adapter.removeTeamMember({
teamId: ctx.body.teamId, teamId: ctx.body.teamId,
userId: ctx.body.userId, userId: ctx.body.userId,
}); });
// Run afterRemoveTeamMember hook
if (options?.organizationHooks?.afterRemoveTeamMember) {
await options?.organizationHooks.afterRemoveTeamMember({
teamMember,
team,
user: userBeingRemoved,
organization,
});
}
return ctx.json({ message: "Team member removed successfully." }); return ctx.json({ message: "Team member removed successfully." });
}, },
); );

View File

@@ -221,42 +221,6 @@ export interface OrganizationOptions {
*/ */
request?: Request, request?: Request,
) => Promise<void>; ) => 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. * The schema for the organization plugin.
*/ */
@@ -310,18 +274,29 @@ export interface OrganizationOptions {
}; };
}; };
}; };
/**
* Disable organization deletion
*
* @default false
*/
disableOrganizationDeletion?: boolean;
/** /**
* Configure how organization deletion is handled * Configure how organization deletion is handled
*
* @deprecated Use `organizationHooks` instead
*/ */
organizationDeletion?: { organizationDeletion?: {
/** /**
* disable deleting organization * disable deleting organization
*
* @deprecated Use `disableOrganizationDeletion` instead
*/ */
disabled?: boolean; disabled?: boolean;
/** /**
* A callback that runs before the organization is * A callback that runs before the organization is
* deleted * deleted
* *
* @deprecated Use `organizationHooks` instead
* @param data - organization and user object * @param data - organization and user object
* @param request - the request object * @param request - the request object
* @returns * @returns
@@ -337,6 +312,7 @@ export interface OrganizationOptions {
* A callback that runs after the organization is * A callback that runs after the organization is
* deleted * deleted
* *
* @deprecated Use `organizationHooks` instead
* @param data - organization and user object * @param data - organization and user object
* @param request - the request object * @param request - the request object
* @returns * @returns
@@ -349,12 +325,15 @@ export interface OrganizationOptions {
request?: Request, request?: Request,
) => Promise<void>; ) => Promise<void>;
}; };
/**
* @deprecated Use `organizationHooks` instead
*/
organizationCreation?: { organizationCreation?: {
disabled?: boolean; disabled?: boolean;
beforeCreate?: ( beforeCreate?: (
data: { data: {
organization: Omit<Organization, "id"> & Record<string, any>; organization: Omit<Organization, "id"> & Record<string, any>;
user: User; user: User & Record<string, any>;
}, },
request?: Request, request?: Request,
) => Promise<void | { ) => Promise<void | {
@@ -364,11 +343,433 @@ export interface OrganizationOptions {
data: { data: {
organization: Organization & Record<string, any>; organization: Organization & Record<string, any>;
member: Member & Record<string, any>; member: Member & Record<string, any>;
user: User; user: User & Record<string, any>;
}, },
request?: Request, request?: Request,
) => Promise<void>; ) => Promise<void>;
}; };
/**
* Hooks for organization
*/
organizationHooks?: {
/**
* A callback that runs before the organization is created
*
* You can return a `data` object to override the default data.
*
* @example
* ```ts
* beforeCreateOrganization: async (data) => {
* return {
* data: {
* ...data.organization,
* },
* };
* }
* ```
*
* You can also throw `new APIError` to stop the organization creation.
*
* @example
* ```ts
* beforeCreateOrganization: async (data) => {
* throw new APIError("BAD_REQUEST", {
* message: "Organization creation is disabled",
* });
* }
*/
beforeCreateOrganization?: (data: {
organization: {
name?: string;
slug?: string;
logo?: string;
metadata?: Record<string, any>;
[key: string]: any;
};
user: User & Record<string, any>;
}) => Promise<void | {
data: Record<string, any>;
}>;
/**
* A callback that runs after the organization is created
*/
afterCreateOrganization?: (data: {
organization: Organization & Record<string, any>;
member: Member & Record<string, any>;
user: User & Record<string, any>;
}) => Promise<void>;
/**
* A callback that runs before the organization is updated
*
* You can return a `data` object to override the default data.
*
* @example
* ```ts
* beforeUpdateOrganization: async (data) => {
* return { data: { ...data.organization } };
* }
*/
beforeUpdateOrganization?: (data: {
organization: {
name?: string;
slug?: string;
logo?: string;
metadata?: Record<string, any>;
[key: string]: any;
};
user: User & Record<string, any>;
member: Member & Record<string, any>;
}) => Promise<void | {
data: {
name?: string;
slug?: string;
logo?: string;
metadata?: Record<string, any>;
[key: string]: any;
};
}>;
/**
* A callback that runs after the organization is updated
*
* @example
* ```ts
* afterUpdateOrganization: async (data) => {
* console.log(data.organization);
* }
* ```
*/
afterUpdateOrganization?: (data: {
/**
* Updated organization object
*
* This could be `null` if an adapter doesn't return updated organization.
*/
organization: (Organization & Record<string, any>) | null;
user: User & Record<string, any>;
member: Member & Record<string, any>;
}) => Promise<void>;
/**
* A callback that runs before the organization is deleted
*/
beforeDeleteOrganization?: (data: {
organization: Organization & Record<string, any>;
user: User & Record<string, any>;
}) => Promise<void>;
/**
* A callback that runs after the organization is deleted
*/
afterDeleteOrganization?: (data: {
organization: Organization & Record<string, any>;
user: User & Record<string, any>;
}) => Promise<void>;
/**
* Member hooks
*/
/**
* A callback that runs before a member is added to an organization
*
* You can return a `data` object to override the default data.
*
* @example
* ```ts
* beforeAddMember: async (data) => {
* return {
* data: {
* ...data.member,
* role: "custom-role"
* }
* };
* }
* ```
*/
beforeAddMember?: (data: {
member: {
userId: string;
organizationId: string;
role: string;
[key: string]: any;
};
user: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void | {
data: Record<string, any>;
}>;
/**
* A callback that runs after a member is added to an organization
*/
afterAddMember?: (data: {
member: Member & Record<string, any>;
user: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void>;
/**
* A callback that runs before a member is removed from an organization
*/
beforeRemoveMember?: (data: {
member: Member & Record<string, any>;
user: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void>;
/**
* A callback that runs after a member is removed from an organization
*/
afterRemoveMember?: (data: {
member: Member & Record<string, any>;
user: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void>;
/**
* A callback that runs before a member's role is updated
*
* You can return a `data` object to override the default data.
*/
beforeUpdateMemberRole?: (data: {
member: Member & Record<string, any>;
newRole: string;
user: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void | {
data: {
role: string;
[key: string]: any;
};
}>;
/**
* A callback that runs after a member's role is updated
*/
afterUpdateMemberRole?: (data: {
member: Member & Record<string, any>;
previousRole: string;
user: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void>;
/**
* Invitation hooks
*/
/**
* A callback that runs before an invitation is created
*
* You can return a `data` object to override the default data.
*
* @example
* ```ts
* beforeCreateInvitation: async (data) => {
* return {
* data: {
* ...data.invitation,
* expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7) // 7 days
* }
* };
* }
* ```
*/
beforeCreateInvitation?: (data: {
invitation: {
email: string;
role: string;
organizationId: string;
inviterId: string;
teamId?: string;
[key: string]: any;
};
inviter: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void | {
data: Record<string, any>;
}>;
/**
* A callback that runs after an invitation is created
*/
afterCreateInvitation?: (data: {
invitation: Invitation & Record<string, any>;
inviter: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void>;
/**
* A callback that runs before an invitation is accepted
*/
beforeAcceptInvitation?: (data: {
invitation: Invitation & Record<string, any>;
user: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void>;
/**
* A callback that runs after an invitation is accepted
*/
afterAcceptInvitation?: (data: {
invitation: Invitation & Record<string, any>;
member: Member & Record<string, any>;
user: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void>;
/**
* A callback that runs before an invitation is rejected
*/
beforeRejectInvitation?: (data: {
invitation: Invitation & Record<string, any>;
user: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void>;
/**
* A callback that runs after an invitation is rejected
*/
afterRejectInvitation?: (data: {
invitation: Invitation & Record<string, any>;
user: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void>;
/**
* A callback that runs before an invitation is cancelled
*/
beforeCancelInvitation?: (data: {
invitation: Invitation & Record<string, any>;
cancelledBy: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void>;
/**
* A callback that runs after an invitation is cancelled
*/
afterCancelInvitation?: (data: {
invitation: Invitation & Record<string, any>;
cancelledBy: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void>;
/**
* Team hooks (when teams are enabled)
*/
/**
* A callback that runs before a team is created
*
* You can return a `data` object to override the default data.
*/
beforeCreateTeam?: (data: {
team: {
name: string;
organizationId: string;
[key: string]: any;
};
user?: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void | {
data: Record<string, any>;
}>;
/**
* A callback that runs after a team is created
*/
afterCreateTeam?: (data: {
team: Team & Record<string, any>;
user?: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void>;
/**
* A callback that runs before a team is updated
*
* You can return a `data` object to override the default data.
*/
beforeUpdateTeam?: (data: {
team: Team & Record<string, any>;
updates: {
name?: string;
[key: string]: any;
};
user: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void | {
data: Record<string, any>;
}>;
/**
* A callback that runs after a team is updated
*/
afterUpdateTeam?: (data: {
team: (Team & Record<string, any>) | null;
user: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void>;
/**
* A callback that runs before a team is deleted
*/
beforeDeleteTeam?: (data: {
team: Team & Record<string, any>;
user?: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void>;
/**
* A callback that runs after a team is deleted
*/
afterDeleteTeam?: (data: {
team: Team & Record<string, any>;
user?: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void>;
/**
* A callback that runs before a member is added to a team
*/
beforeAddTeamMember?: (data: {
teamMember: {
teamId: string;
userId: string;
[key: string]: any;
};
team: Team & Record<string, any>;
user: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void | {
data: Record<string, any>;
}>;
/**
* A callback that runs after a member is added to a team
*/
afterAddTeamMember?: (data: {
teamMember: TeamMember & Record<string, any>;
team: Team & Record<string, any>;
user: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void>;
/**
* A callback that runs before a member is removed from a team
*/
beforeRemoveTeamMember?: (data: {
teamMember: TeamMember & Record<string, any>;
team: Team & Record<string, any>;
user: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void>;
/**
* A callback that runs after a member is removed from a team
*/
afterRemoveTeamMember?: (data: {
teamMember: TeamMember & Record<string, any>;
team: Team & Record<string, any>;
user: User & Record<string, any>;
organization: Organization & Record<string, any>;
}) => Promise<void>;
};
/** /**
* Automatically create an organization for the user on sign up. * Automatically create an organization for the user on sign up.
* *