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"
},
"[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 { getTelemetryAuthConfig } from "./telemetry/detectors/detect-auth-config";
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 { organization } from "./organization";
import { createAuthClient } from "../../client";
@@ -15,7 +15,6 @@ 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: {
@@ -37,7 +36,6 @@ describe("organization", async (it) => {
},
},
invitationLimit: 3,
onInvitationAccepted,
}),
],
logger: {
@@ -295,94 +293,7 @@ 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,
@@ -1996,3 +1907,79 @@ describe("Additional Fields", async () => {
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
} = 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({
invitation: {
role: roles,
email: ctx.body.email.toLowerCase(),
organizationId: organizationId,
teamIds,
...(additionalFields ? additionalFields : {}),
},
invitation: invitationData,
user: session.user,
});
@@ -405,6 +428,15 @@ export const createInvitation = <O extends OrganizationOptions>(option: O) => {
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);
},
);
@@ -492,6 +524,25 @@ export const acceptInvitation = <O extends OrganizationOptions>(options: O) =>
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({
invitationId: ctx.body.invitationId,
status: "accepted",
@@ -573,35 +624,13 @@ 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,
if (options?.organizationHooks?.afterAcceptInvitation) {
await options?.organizationHooks.afterAcceptInvitation({
invitation: acceptedI as unknown as Invitation,
member,
user: session.user,
organization,
});
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,
@@ -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({
invitationId: ctx.body.invitationId,
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({
invitation: rejectedI,
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,
});
}
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({
invitationId: ctx.body.invitationId,
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);
},
);

View File

@@ -147,13 +147,42 @@ export const addMember = <O extends OrganizationOptions>(option: O) => {
...additionalFields
} = 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,
userId: user.id,
role: parseRoles(ctx.body.role as string | string[]),
createdAt: new Date(),
...(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) {
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);
},
);
@@ -308,6 +346,32 @@ export const removeMember = <O extends OrganizationOptions>(options: O) =>
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);
if (
session.user.id === toBeRemovedMember.userId &&
@@ -316,6 +380,16 @@ export const removeMember = <O extends OrganizationOptions>(options: O) =>
) {
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({
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,
});
}
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(
ctx.body.memberId,
parseRoles(ctx.body.role as string | string[]),
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);
},
);

View File

@@ -22,7 +22,7 @@ import {
} from "../../../db";
export const createOrganization = <O extends OrganizationOptions>(
options: O,
options?: O,
) => {
const additionalFieldsSchema = toZodSchema({
fields: options?.schema?.organization?.additionalFields || {},
@@ -162,12 +162,6 @@ export const createOrganization = <O extends OrganizationOptions>(
...orgData
} = ctx.body;
let hookResponse:
| {
data: Record<string, any>;
}
| undefined = undefined;
if (options.organizationCreation?.beforeCreate) {
const response = await options.organizationCreation.beforeCreate(
{
@@ -180,7 +174,24 @@ export const createOrganization = <O extends OrganizationOptions>(
ctx.request,
);
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: {
...orgData,
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) {
await adapter.setActiveOrganization(
ctx.context.session.session.token,
@@ -297,7 +315,7 @@ export const checkOrganizationSlug = <O extends OrganizationOptions>(
);
export const updateOrganization = <O extends OrganizationOptions>(
options: O,
options?: O,
) => {
const additionalFieldsSchema = toZodSchema({
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,
});
}
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(
organizationId,
ctx.body.data,
);
if (options?.organizationHooks?.afterUpdateOrganization) {
await options.organizationHooks.afterUpdateOrganization({
organization: updatedOrg,
user: session.user,
member,
});
}
return ctx.json(updatedOrg);
},
);
@@ -459,6 +498,19 @@ export const deleteOrganization = <O extends OrganizationOptions>(
},
},
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);
if (!session) {
throw new APIError("UNAUTHORIZED", { status: 401 });
@@ -503,23 +555,20 @@ export const deleteOrganization = <O extends OrganizationOptions>(
*/
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);
if (!org) {
throw new APIError("BAD_REQUEST");
}
if (option?.beforeDelete) {
await option.beforeDelete({
if (options?.organizationHooks?.beforeDeleteOrganization) {
await options.organizationHooks.beforeDeleteOrganization({
organization: org,
user: session.user,
});
}
await adapter.deleteOrganization(organizationId);
if (option?.afterDelete) {
await option.afterDelete({
if (options?.organizationHooks?.afterDeleteOrganization) {
await options.organizationHooks.afterDeleteOrganization({
organization: org,
user: session.user,
});

View File

@@ -155,13 +155,52 @@ export const createTeam = <O extends OrganizationOptions>(options: O) => {
});
}
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,
organizationId,
createdAt: new Date(),
updatedAt: new Date(),
...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);
},
);
@@ -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);
// Run afterDeleteTeam hook
if (options?.organizationHooks?.afterDeleteTeam) {
await options?.organizationHooks.afterDeleteTeam({
team,
user: session?.user,
organization,
});
}
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 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,
...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);
},
@@ -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({
teamId: ctx.body.teamId,
userId: ctx.body.userId,
});
// Run afterAddTeamMember hook
if (options?.organizationHooks?.afterAddTeamMember) {
await options?.organizationHooks.afterAddTeamMember({
teamMember,
team,
user: userBeingAdded,
organization,
});
}
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({
teamId: ctx.body.teamId,
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." });
},
);

View File

@@ -221,42 +221,6 @@ 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.
*/
@@ -310,18 +274,29 @@ export interface OrganizationOptions {
};
};
};
/**
* Disable organization deletion
*
* @default false
*/
disableOrganizationDeletion?: boolean;
/**
* Configure how organization deletion is handled
*
* @deprecated Use `organizationHooks` instead
*/
organizationDeletion?: {
/**
* disable deleting organization
*
* @deprecated Use `disableOrganizationDeletion` instead
*/
disabled?: boolean;
/**
* A callback that runs before the organization is
* deleted
*
* @deprecated Use `organizationHooks` instead
* @param data - organization and user object
* @param request - the request object
* @returns
@@ -337,6 +312,7 @@ export interface OrganizationOptions {
* A callback that runs after the organization is
* deleted
*
* @deprecated Use `organizationHooks` instead
* @param data - organization and user object
* @param request - the request object
* @returns
@@ -349,12 +325,15 @@ export interface OrganizationOptions {
request?: Request,
) => Promise<void>;
};
/**
* @deprecated Use `organizationHooks` instead
*/
organizationCreation?: {
disabled?: boolean;
beforeCreate?: (
data: {
organization: Omit<Organization, "id"> & Record<string, any>;
user: User;
user: User & Record<string, any>;
},
request?: Request,
) => Promise<void | {
@@ -364,11 +343,433 @@ export interface OrganizationOptions {
data: {
organization: Organization & Record<string, any>;
member: Member & Record<string, any>;
user: User;
user: User & Record<string, any>;
},
request?: Request,
) => 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.
*