diff --git a/.vscode/settings.json b/.vscode/settings.json index 3fd86a94..b2505a05 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,6 +24,6 @@ "editor.defaultFormatter": "biomejs.biome" }, "[mdx]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "unifiedjs.vscode-mdx" } } diff --git a/docs/content/docs/plugins/organization.mdx b/docs/content/docs/plugins/organization.mdx index 33c3d51a..215aa3ec 100644 --- a/docs/content/docs/plugins/organization.mdx +++ b/docs/content/docs/plugins/organization.mdx @@ -40,6 +40,7 @@ export const auth = betterAuth({ See the [Schema](#schema) section to add the fields manually. + @@ -55,10 +56,10 @@ export const auth = betterAuth({ ] // [!code highlight] }) ``` + - ## Usage Once you've installed the plugin, you can start using the organization plugin to manage your organization's members and teams. The client plugin will provide you methods under the `organization` namespace. And the server `api` will provide you with the necessary endpoints to manage your organization and gives you easier way to call the functions on your own backend. @@ -68,37 +69,39 @@ Once you've installed the plugin, you can start using the organization plugin to ### Create an organization - ```ts - const metadata = { someKey: "someValue" }; - type createOrganization = { - /** - * The organization name. - */ - name: string = "My Organization" - /** - * The organization slug. - */ - slug: string = "my-org" - /** - * The organization logo. - */ - logo?: string = "https://example.com/logo.png" - /** - * The metadata of the organization. - */ - metadata?: Record - /** - * The user id of the organization creator. If not provided, the current user will be used. Should only be used by admins or when called by the server. - * @serverOnly - */ - userId?: string = "some_user_id" - /** - * Whether to keep the current active organization active after creating a new one. - */ - keepCurrentActiveOrganization?: boolean = false - } - ``` +```ts +const metadata = { someKey: "someValue" }; + +type createOrganization = { + /** + The organization name. + */ + name: string = "My Organization" + /** + * The organization slug. + */ + slug: string = "my-org" + /** + * The organization logo. + */ + logo?: string = "https://example.com/logo.png" + /** + * The metadata of the organization. + */ + metadata?: Record + /** + * The user id of the organization creator. If not provided, the current user will be used. Should only be used by admins or when called by the server. + * @serverOnly + */ + userId?: string = "some_user_id" + /** + * Whether to keep the current active organization active after creating a new one. + */ + keepCurrentActiveOrganization?: boolean = false +} +``` + #### Restrict who can create an organization @@ -106,20 +109,21 @@ Once you've installed the plugin, you can start using the organization plugin to By default, any user can create an organization. To restrict this, set the `allowUserToCreateOrganization` option to a function that returns a boolean, or directly to `true` or `false`. ```ts title="auth.ts" -import { betterAuth } from "better-auth" -import { organization } from "better-auth/plugins" +import { betterAuth } from "better-auth"; +import { organization } from "better-auth/plugins"; const auth = betterAuth({ - //... - plugins: [ - organization({ - allowUserToCreateOrganization: async (user) => { // [!code highlight] - const subscription = await getSubscription(user.id) // [!code highlight] - return subscription.plan === "pro" // [!code highlight] - } // [!code highlight] - }) - ] -}) + //... + plugins: [ + organization({ + allowUserToCreateOrganization: async (user) => { + // [!code highlight] + const subscription = await getSubscription(user.id); // [!code highlight] + return subscription.plan === "pro"; // [!code highlight] + }, // [!code highlight] + }), + ], +}); ``` #### Check if organization slug is taken @@ -137,58 +141,373 @@ type checkOrganizationSlug = { ``` +### Organization Hooks -#### Organization Creation Hooks +You can customize organization operations using hooks that run before and after various organization-related activities. Better Auth provides two ways to configure hooks: -You can customize the organization creation process using hooks that run before and after an organization is created. +1. **Legacy organizationCreation hooks** (deprecated, use `organizationHooks` instead) +2. **Modern organizationHooks** (recommended) - provides comprehensive control over all organization-related activities + +#### Organization Creation and Management Hooks + +Control organization lifecycle operations: ```ts title="auth.ts" -import { betterAuth } from "better-auth" -import { organization } from "better-auth/plugins" +import { betterAuth } from "better-auth"; +import { organization } from "better-auth/plugins"; export const auth = betterAuth({ - plugins: [ - organization({ - organizationCreation: { - disabled: false, // Set to true to disable organization creation - beforeCreate: async ({ organization, user }, request) => { - // Run custom logic before organization is created - // Optionally modify the organization data - return { - data: { - ...organization, - metadata: { - customField: "value" - } - } - } - }, - afterCreate: async ({ organization, member, user }, request) => { - // Run custom logic after organization is created - // e.g., create default resources, send notifications - await setupDefaultResources(organization.id) - } - } - }) - ] -}) + plugins: [ + organization({ + organizationHooks: { + // Organization creation hooks + beforeCreateOrganization: async ({ organization, user }) => { + // Run custom logic before organization is created + // Optionally modify the organization data + return { + data: { + ...organization, + metadata: { + customField: "value", + }, + }, + }; + }, + + afterCreateOrganization: async ({ organization, member, user }) => { + // Run custom logic after organization is created + // e.g., create default resources, send notifications + await setupDefaultResources(organization.id); + }, + + // Organization update hooks + beforeUpdateOrganization: async ({ organization, user, member }) => { + // Validate updates, apply business rules + return { + data: { + ...organization, + name: organization.name?.toLowerCase(), + }, + }; + }, + + afterUpdateOrganization: async ({ organization, user, member }) => { + // Sync changes to external systems + await syncOrganizationToExternalSystems(organization); + }, + }, + }), + ], +}); ``` -The `beforeCreate` hook runs before an organization is created. It receives: + + The legacy `organizationCreation` hooks are still supported but deprecated. + Use `organizationHooks.beforeCreateOrganization` and + `organizationHooks.afterCreateOrganization` instead for new projects. + -- `organization`: The organization data (without ID) -- `user`: The user creating the organization -- `request`: The HTTP request object (optional) +#### Member Hooks -Return an object with `data` property to modify the organization data that will be created. +Control member operations within organizations: -The `afterCreate` hook runs after an organization is successfully created. It receives: +```ts title="auth.ts" +import { betterAuth } from "better-auth"; +import { organization } from "better-auth/plugins"; -- `organization`: The created organization (with ID) -- `member`: The member record for the creator -- `user`: The user who created the organization -- `request`: The HTTP request object (optional) +export const auth = betterAuth({ + plugins: [ + organization({ + organizationHooks: { + // Before a member is added to an organization + beforeAddMember: async ({ member, user, organization }) => { + // Custom validation or modification + console.log(`Adding ${user.email} to ${organization.name}`); + // Optionally modify member data + return { + data: { + ...member, + role: "custom-role", // Override the role + }, + }; + }, + + // After a member is added + afterAddMember: async ({ member, user, organization }) => { + // Send welcome email, create default resources, etc. + await sendWelcomeEmail(user.email, organization.name); + }, + + // Before a member is removed + beforeRemoveMember: async ({ member, user, organization }) => { + // Cleanup user's resources, send notification, etc. + await cleanupUserResources(user.id, organization.id); + }, + + // After a member is removed + afterRemoveMember: async ({ member, user, organization }) => { + await logMemberRemoval(user.id, organization.id); + }, + + // Before updating a member's role + beforeUpdateMemberRole: async ({ + member, + newRole, + user, + organization, + }) => { + // Validate role change permissions + if (newRole === "owner" && !hasOwnerUpgradePermission(user)) { + throw new Error("Cannot upgrade to owner role"); + } + + // Optionally modify the role + return { + data: { + role: newRole, + }, + }; + }, + + // After updating a member's role + afterUpdateMemberRole: async ({ + member, + previousRole, + user, + organization, + }) => { + await logRoleChange(user.id, previousRole, member.role); + }, + }, + }), + ], +}); +``` + +#### Invitation Hooks + +Control invitation lifecycle: + +```ts title="auth.ts" +export const auth = betterAuth({ + plugins: [ + organization({ + organizationHooks: { + // Before creating an invitation + beforeCreateInvitation: async ({ + invitation, + inviter, + organization, + }) => { + // Custom validation or expiration logic + const customExpiration = new Date( + Date.now() + 1000 * 60 * 60 * 24 * 7 + ); // 7 days + + return { + data: { + ...invitation, + expiresAt: customExpiration, + }, + }; + }, + + // After creating an invitation + afterCreateInvitation: async ({ + invitation, + inviter, + organization, + }) => { + // Send custom invitation email, track metrics, etc. + await sendCustomInvitationEmail(invitation, organization); + }, + + // Before accepting an invitation + beforeAcceptInvitation: async ({ invitation, user, organization }) => { + // Additional validation before acceptance + await validateUserEligibility(user, organization); + }, + + // After accepting an invitation + afterAcceptInvitation: async ({ + invitation, + member, + user, + organization, + }) => { + // Setup user account, assign default resources + await setupNewMemberResources(user, organization); + }, + + // Before/after rejecting invitations + beforeRejectInvitation: async ({ invitation, user, organization }) => { + // Log rejection reason, send notification to inviter + }, + + afterRejectInvitation: async ({ invitation, user, organization }) => { + await notifyInviterOfRejection(invitation.inviterId, user.email); + }, + + // Before/after cancelling invitations + beforeCancelInvitation: async ({ + invitation, + cancelledBy, + organization, + }) => { + // Verify cancellation permissions + }, + + afterCancelInvitation: async ({ + invitation, + cancelledBy, + organization, + }) => { + await logInvitationCancellation(invitation.id, cancelledBy.id); + }, + }, + }), + ], +}); +``` + +#### Team Hooks + +Control team operations (when teams are enabled): + +```ts title="auth.ts" +export const auth = betterAuth({ + plugins: [ + organization({ + teams: { enabled: true }, + organizationHooks: { + // Before creating a team + beforeCreateTeam: async ({ team, user, organization }) => { + // Validate team name, apply naming conventions + return { + data: { + ...team, + name: team.name.toLowerCase().replace(/\s+/g, "-"), + }, + }; + }, + + // After creating a team + afterCreateTeam: async ({ team, user, organization }) => { + // Create default team resources, channels, etc. + await createDefaultTeamResources(team.id); + }, + + // Before updating a team + beforeUpdateTeam: async ({ team, updates, user, organization }) => { + // Validate updates, apply business rules + return { + data: { + ...updates, + name: updates.name?.toLowerCase(), + }, + }; + }, + + // After updating a team + afterUpdateTeam: async ({ team, user, organization }) => { + await syncTeamChangesToExternalSystems(team); + }, + + // Before deleting a team + beforeDeleteTeam: async ({ team, user, organization }) => { + // Backup team data, notify members + await backupTeamData(team.id); + }, + + // After deleting a team + afterDeleteTeam: async ({ team, user, organization }) => { + await cleanupTeamResources(team.id); + }, + + // Team member operations + beforeAddTeamMember: async ({ + teamMember, + team, + user, + organization, + }) => { + // Validate team membership limits, permissions + const memberCount = await getTeamMemberCount(team.id); + if (memberCount >= 10) { + throw new Error("Team is full"); + } + }, + + afterAddTeamMember: async ({ + teamMember, + team, + user, + organization, + }) => { + await grantTeamAccess(user.id, team.id); + }, + + beforeRemoveTeamMember: async ({ + teamMember, + team, + user, + organization, + }) => { + // Backup user's team-specific data + await backupTeamMemberData(user.id, team.id); + }, + + afterRemoveTeamMember: async ({ + teamMember, + team, + user, + organization, + }) => { + await revokeTeamAccess(user.id, team.id); + }, + }, + }), + ], +}); +``` + +#### Hook Error Handling + +All hooks support error handling. Throwing an error in a `before` hook will prevent the operation from proceeding: + +```ts title="auth.ts" +import { APIError } from "better-auth/api"; + +export const auth = betterAuth({ + plugins: [ + organization({ + organizationHooks: { + beforeAddMember: async ({ member, user, organization }) => { + // Check if user has pending violations + const violations = await checkUserViolations(user.id); + if (violations.length > 0) { + throw new APIError("BAD_REQUEST", { + message: + "User has pending violations and cannot join organizations", + }); + } + }, + + beforeCreateTeam: async ({ team, user, organization }) => { + // Validate team name uniqueness + const existingTeam = await findTeamByName(team.name, organization.id); + if (existingTeam) { + throw new APIError("BAD_REQUEST", { + message: "Team name already exists in this organization", + }); + } + }, + }, + }), + ], +}); +``` ### List User's Organizations @@ -200,13 +519,15 @@ To list the organizations that a user is a member of, you can use `useListOrgani import { authClient } from "@/lib/auth-client" function App(){ - const { data: organizations } = authClient.useListOrganizations() - return ( -
- {organizations.map(org =>

{org.name}

)} -
- ) -} +const { data: organizations } = authClient.useListOrganizations() +return ( + +
+ {organizations.map((org) => ( +

{org.name}

+ ))} +
+) } ``` @@ -220,6 +541,7 @@ function App(){

Organizations

s {#if $organizations.isPending} +

Loading...

{:else if $organizations.data === null}

No organizations found.

@@ -231,6 +553,7 @@ function App(){ {/if} ``` + @@ -263,10 +586,11 @@ export default { Or alternatively, you can call `organization.list` if you don't want to use a hook. -```ts -type listOrganizations = { -} -``` + ```ts + type listOrganizations = { + + } + ``` @@ -275,7 +599,9 @@ type listOrganizations = { Active organization is the workspace the user is currently working on. By default when the user is signed in the active organization is set to `null`. You can set the active organization to the user session. - It's not always you want to persist the active organization in the session. You can manage the active organization in the client side only. For example, multiple tabs can have different active organizations. + It's not always you want to persist the active organization in the session. + You can manage the active organization in the client side only. For example, + multiple tabs can have different active organizations. #### Set Active Organization @@ -283,8 +609,9 @@ Active organization is the workspace the user is currently working on. By defaul You can set the active organization by calling the `organization.setActive` function. It'll set the active organization for the user session. - In some applications, you may want the ability to un-set an active organization. - In this case, you can call this endpoint with `organizationId` set to `null`. + In some applications, you may want the ability to un-set an active + organization. In this case, you can call this endpoint with `organizationId` + set to `null`. @@ -302,27 +629,26 @@ type setActiveOrganization = { ``` - To set active organization when a session is created you can use [database hooks](/docs/concepts/database#database-hooks). ```ts title="auth.ts" export const auth = betterAuth({ databaseHooks: { - session: { - create: { - before: async(session)=>{ - const organization = await getActiveOrganization(session.userId) - return { - data: { - ...session, - activeOrganizationId: organization.id - } - } - } - } - } - } -}) + session: { + create: { + before: async (session) => { + const organization = await getActiveOrganization(session.userId); + return { + data: { + ...session, + activeOrganizationId: organization.id, + }, + }; + }, + }, + }, + }, +}); ``` #### Use Active Organization @@ -385,6 +711,7 @@ To retrieve the active organization for the user, you can call the `useActiveOrg ``` + ### Get Full Organization @@ -456,7 +783,6 @@ type updateOrganization = { ``` - ### Delete Organization To remove user owned organization, you can use `organization.delete` @@ -484,12 +810,12 @@ You can configure how organization deletion is handled through `organizationDele const auth = betterAuth({ plugins: [ organization({ - organizationDeletion: { - disabled: true, //to disable it altogether - beforeDelete: async (data, request) => { + disableOrganizationDeletion: true, //to disable it altogether + organizationHooks: { + beforeDeleteOrganization: async (data, request) => { // a callback to run before deleting org }, - afterDelete: async (data, request) => { + afterDeleteOrganization: async (data, request) => { // a callback to run after deleting org }, }, @@ -509,24 +835,24 @@ For member invitation to work we first need to provide `sendInvitationEmail` to You'll need to construct and send the invitation link to the user. The link should include the invitation ID, which will be used with the acceptInvitation function when the user clicks on it. ```ts title="auth.ts" -import { betterAuth } from "better-auth" -import { organization } from "better-auth/plugins" -import { sendOrganizationInvitation } from "./email" +import { betterAuth } from "better-auth"; +import { organization } from "better-auth/plugins"; +import { sendOrganizationInvitation } from "./email"; export const auth = betterAuth({ - plugins: [ - organization({ - async sendInvitationEmail(data) { - const inviteLink = `https://example.com/accept-invitation/${data.id}` - sendOrganizationInvitation({ - email: data.email, - invitedByUsername: data.inviter.user.name, - invitedByEmail: data.inviter.user.email, - teamName: data.organization.name, - inviteLink - }) - }, - }), - ], + plugins: [ + organization({ + async sendInvitationEmail(data) { + const inviteLink = `https://example.com/accept-invitation/${data.id}`; + sendOrganizationInvitation({ + email: data.email, + invitedByUsername: data.inviter.user.name, + invitedByEmail: data.inviter.user.email, + teamName: data.organization.name, + inviteLink, + }); + }, + }), + ], }); ``` @@ -562,9 +888,12 @@ type createInvitation = { - - If the user is already a member of the organization, the invitation will be canceled. - - If the user is already invited to the organization, unless `resend` is set to `true`, the invitation will not be sent again. - - If `cancelPendingInvitationsOnReInvite` is set to `true`, the invitation will be canceled if the user is already invited to the organization and a new invitation is sent. + - If the user is already a member of the organization, the invitation will be + canceled. - If the user is already invited to the organization, unless + `resend` is set to `true`, the invitation will not be sent again. - If + `cancelPendingInvitationsOnReInvite` is set to `true`, the invitation will be + canceled if the user is already invited to the organization and a new + invitation is sent. ### Accept Invitation @@ -589,19 +918,19 @@ type acceptInvitation = { If the `requireEmailVerificationOnInvitation` option is enabled in your organization configuration, users must verify their email address before they can accept invitations. This adds an extra security layer to ensure that only verified users can join your organization. ```ts title="auth.ts" -import { betterAuth } from "better-auth" -import { organization } from "better-auth/plugins" +import { betterAuth } from "better-auth"; +import { organization } from "better-auth/plugins"; export const auth = betterAuth({ - plugins: [ - organization({ - requireEmailVerificationOnInvitation: true, // [!code highlight] - async sendInvitationEmail(data) { - // ... your email sending logic - } - }) - ] -}) + plugins: [ + organization({ + requireEmailVerificationOnInvitation: true, // [!code highlight] + async sendInvitationEmail(data) { + // ... your email sending logic + }, + }), + ], +}); ``` ### Invitation Accepted Callback @@ -609,20 +938,20 @@ export const auth = betterAuth({ 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" +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 - }, - }), - ], + plugins: [ + organization({ + async sendInvitationEmail(data) { + // ... your invitation email logic + }, + async onInvitationAccepted(data) { + // This callback gets triggered when an invitation is accepted + }, + }), + ], }); ``` @@ -635,7 +964,6 @@ The callback receives the following data: - `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. @@ -669,7 +997,10 @@ type rejectInvitation = { -Like accepting invitations, rejecting invitations also requires email verification when the `requireEmailVerificationOnInvitation` option is enabled. Users with unverified emails will receive an error when attempting to reject invitations. + Like accepting invitations, rejecting invitations also requires email + verification when the `requireEmailVerificationOnInvitation` option is + enabled. Users with unverified emails will receive an error when attempting to + reject invitations. ### Get Invitation @@ -711,20 +1042,22 @@ type listInvitations = { To list all invitations for a given user you can use the `listUserInvitations` function provided by the client. ```ts title="auth-client.ts" -const invitations = await authClient.organization.listUserInvitations() +const invitations = await authClient.organization.listUserInvitations(); ``` On the server, you can pass the user ID as a query parameter. ```ts title="api.ts" const invitations = await auth.api.listUserInvitations({ - query: { - email: "user@example.com" - } -}) + query: { + email: "user@example.com", + }, +}); ``` + -The `email` query parameter is only available on the server to query for invitations for a specific user. + The `email` query parameter is only available on the server to query for + invitations for a specific user. ## Members @@ -738,7 +1071,7 @@ To list all members of an organization you can use the `listMembers` function. ```ts type listMembers = { /** - * An optional organization ID to list members for. If not provided, will default to the user's active organization. + * An optional organization ID to list members for. If not provided, will default to the user's active organization. */ organizationId?: string = "organization-id" /** @@ -769,7 +1102,6 @@ type listMembers = { * The value to filter by. */ filterValue?: string = "value" - /** } ``` @@ -778,7 +1110,6 @@ type listMembers = { To remove you can use `organization.removeMember` - ```ts type removeMember = { @@ -827,10 +1158,11 @@ To get the current member of the active organization you can use the `organizati requireSession resultVariable="member" > -```ts -type getActiveMember = { -} -``` + ```ts + type getActiveMember = { + + } + ``` ### Add Member @@ -884,7 +1216,6 @@ type leaveOrganization = { ``` - ## Access Control The organization plugin providers a very flexible access control system. You can control the access of the user based on the role they have in the organization. You can define your own set of permissions based on the role of the user. @@ -900,7 +1231,8 @@ By default, there are three roles in the organization: `member`: Users with the member role have limited control over the organization. They can create projects, invite users, and manage projects they have created. - A user can have multiple roles. Multiple roles are stored as string separated by comma (","). + A user can have multiple roles. Multiple roles are stored as string separated + by comma (","). ### Permissions @@ -1044,8 +1376,8 @@ The plugin provides an easy way to define your own set of permissions for each r }) ``` - + ### Access Control Usage @@ -1058,22 +1390,22 @@ import { auth } from "@/auth"; await auth.api.hasPermission({ headers: await headers(), - body: { - permissions: { - project: ["create"] // This must match the structure in your access control - } - } + body: { + permissions: { + project: ["create"], // This must match the structure in your access control + }, + }, }); // You can also check multiple resource permissions at the same time await auth.api.hasPermission({ headers: await headers(), - body: { - permissions: { - project: ["create"], // This must match the structure in your access control - sale: ["create"] - } - } + body: { + permissions: { + project: ["create"], // This must match the structure in your access control + sale: ["create"], + }, + }, }); ``` @@ -1081,18 +1413,19 @@ If you want to check the permission of the user on the client from the server yo ```ts title="auth-client.ts" const canCreateProject = await authClient.organization.hasPermission({ - permissions: { - project: ["create"] - } -}) + permissions: { + project: ["create"], + }, +}); // You can also check multiple resource permissions at the same time -const canCreateProjectAndCreateSale = await authClient.organization.hasPermission({ +const canCreateProjectAndCreateSale = + await authClient.organization.hasPermission({ permissions: { - project: ["create"], - sale: ["create"] - } -}) + project: ["create"], + sale: ["create"], + }, + }); ``` **Check Role Permission**: @@ -1101,20 +1434,21 @@ Once you have defined the roles and permissions to avoid checking the permission ```ts title="auth-client.ts" const canCreateProject = authClient.organization.checkRolePermission({ - permissions: { - organization: ["delete"], - }, - role: "admin", + permissions: { + organization: ["delete"], + }, + role: "admin", }); // You can also check multiple resource permissions at the same time -const canCreateProjectAndCreateSale = authClient.organization.checkRolePermission({ - permissions: { - organization: ["delete"], - member: ["delete"] - }, - role: "admin", -}); +const canCreateProjectAndCreateSale = + authClient.organization.checkRolePermission({ + permissions: { + organization: ["delete"], + member: ["delete"], + }, + role: "admin", + }); ``` ## Teams @@ -1126,40 +1460,41 @@ Teams allow you to group members within an organization. The teams feature provi To enable teams, pass the `teams` configuration option to both server and client plugins: ```ts title="auth.ts" -import { betterAuth } from "better-auth" -import { organization } from "better-auth/plugins" +import { betterAuth } from "better-auth"; +import { organization } from "better-auth/plugins"; export const auth = betterAuth({ - plugins: [ - organization({ - teams: { - enabled: true, - maximumTeams: 10, // Optional: limit teams per organization - allowRemovingAllTeams: false // Optional: prevent removing the last team - } - }) - ] -}) + plugins: [ + organization({ + teams: { + enabled: true, + maximumTeams: 10, // Optional: limit teams per organization + allowRemovingAllTeams: false, // Optional: prevent removing the last team + }, + }), + ], +}); ``` ```ts title="auth-client.ts" -import { createAuthClient } from "better-auth/client" -import { organizationClient } from "better-auth/client/plugins" +import { createAuthClient } from "better-auth/client"; +import { organizationClient } from "better-auth/client/plugins"; export const authClient = createAuthClient({ - plugins: [ - organizationClient({ - teams: { - enabled: true - } - }) - ] -}) + plugins: [ + organizationClient({ + teams: { + enabled: true, + }, + }), + ], +}); ``` ### Managing Teams #### Create Team + Create a new team within an organization: @@ -1178,6 +1513,7 @@ type createTeam = { #### List Teams + Get all teams in an organization: #### Update Team + Update a team's details: #### Remove Team + Delete a team from an organization: @@ -1253,6 +1591,7 @@ type removeTeam = { #### Set Active Team + Sets the given team as the current active team. If `teamId` is `null` the current active team is unset. @@ -1267,16 +1606,19 @@ type setActiveTeam = { #### List User Teams + List all teams that the current user is a part of. -```ts -type listUserTeams = { -} -``` + ```ts + type listUserTeams = { + + } + ``` #### List Team Members + List the members of the given team. @@ -1291,6 +1633,7 @@ type listTeamMembers = { #### Add Team Member + Add a member to a team. @@ -1309,6 +1652,7 @@ type addTeamMember = { #### Remove Team Member + Remove a member from a team. @@ -1335,6 +1679,7 @@ Teams follow the organization's permission system. To manage teams, users need t - `team:delete` - Remove teams By default: + - Organization owners and admins can manage teams - Regular members cannot create, update, or delete teams @@ -1343,6 +1688,7 @@ By default: The teams feature supports several configuration options: - `maximumTeams`: Limit the number of teams per organization + ```ts teams: { enabled: true, @@ -1377,10 +1723,10 @@ When inviting members to an organization, you can specify a team: ```ts await authClient.organization.inviteMember({ - email: "user@example.com", - role: "member", - teamId: "team-id" -}) + email: "user@example.com", + role: "member", + teamId: "team-id", +}); ``` The invited member will be added to the specified team upon accepting the invitation. @@ -1397,29 +1743,29 @@ Table Name: `team` name: "id", type: "string", description: "Unique identifier for each team", - isPrimaryKey: true + isPrimaryKey: true, }, { name: "name", type: "string", - description: "The name of the team" + description: "The name of the team", }, { name: "organizationId", type: "string", description: "The ID of the organization", - isForeignKey: true + isForeignKey: true, }, { name: "createdAt", type: "Date", - description: "Timestamp of when the team was created" + description: "Timestamp of when the team was created", }, { name: "updatedAt", type: "Date", isOptional: true, - description: "Timestamp of when the team was created" + description: "Timestamp of when the team was created", }, ]} /> @@ -1432,24 +1778,24 @@ Table Name: `teamMember` name: "id", type: "string", description: "Unique identifier for each team member", - isPrimaryKey: true + isPrimaryKey: true, }, { name: "teamId", type: "string", description: "Unique identifier for each team", - isForeignKey: true + isForeignKey: true, }, { name: "userId", type: "string", description: "The ID of the user", - isForeignKey: true + isForeignKey: true, }, { name: "createdAt", type: "Date", - description: "Timestamp of when the team member was created" + description: "Timestamp of when the team member was created", }, ]} /> @@ -1468,37 +1814,37 @@ Table Name: `organization` name: "id", type: "string", description: "Unique identifier for each organization", - isPrimaryKey: true + isPrimaryKey: true, }, { name: "name", type: "string", - description: "The name of the organization" + description: "The name of the organization", }, { name: "slug", type: "string", - description: "The slug of the organization" + description: "The slug of the organization", }, { name: "logo", type: "string", description: "The logo of the organization", - isOptional: true + isOptional: true, }, { name: "metadata", type: "string", description: "Additional metadata for the organization", - isOptional: true + isOptional: true, }, { name: "createdAt", type: "Date", - description: "Timestamp of when the organization was created" + description: "Timestamp of when the organization was created", }, ]} - /> +/> ### Member @@ -1510,32 +1856,32 @@ Table Name: `member` name: "id", type: "string", description: "Unique identifier for each member", - isPrimaryKey: true + isPrimaryKey: true, }, { name: "userId", type: "string", description: "The ID of the user", - isForeignKey: true + isForeignKey: true, }, { name: "organizationId", type: "string", description: "The ID of the organization", - isForeignKey: true + isForeignKey: true, }, { name: "role", type: "string", - description: "The role of the user in the organization" + description: "The role of the user in the organization", }, { name: "createdAt", type: "Date", - description: "Timestamp of when the member was added to the organization" + description: "Timestamp of when the member was added to the organization", }, ]} - /> +/> ### Invitation @@ -1547,42 +1893,42 @@ Table Name: `invitation` name: "id", type: "string", description: "Unique identifier for each invitation", - isPrimaryKey: true + isPrimaryKey: true, }, { name: "email", type: "string", - description: "The email address of the user" + description: "The email address of the user", }, { name: "inviterId", type: "string", description: "The ID of the inviter", - isForeignKey: true + isForeignKey: true, }, { name: "organizationId", type: "string", description: "The ID of the organization", - isForeignKey: true + isForeignKey: true, }, { name: "role", type: "string", - description: "The role of the user in the organization" + description: "The role of the user in the organization", }, { name: "status", type: "string", - description: "The status of the invitation" + description: "The status of the invitation", }, { name: "expiresAt", type: "Date", - description: "Timestamp of when the invitation expires" + description: "Timestamp of when the invitation expires", }, ]} - /> +/> If teams are enabled, you need to add the following fields to the invitation table: @@ -1592,10 +1938,10 @@ If teams are enabled, you need to add the following fields to the invitation tab name: "teamId", type: "string", description: "The ID of the team", - isOptional: true + isOptional: true, }, ]} - /> +/> ### Session @@ -1609,16 +1955,16 @@ You need to add two more fields to the session table to store the active organiz name: "activeOrganizationId", type: "string", description: "The ID of the active organization", - isOptional: true + isOptional: true, }, { name: "activeTeamId", type: "string", description: "The ID of the active team", - isOptional: true + isOptional: true, }, ]} - /> +/> ### Teams (optional) @@ -1630,32 +1976,32 @@ Table Name: `team` name: "id", type: "string", description: "Unique identifier for each team", - isPrimaryKey: true + isPrimaryKey: true, }, { name: "name", type: "string", - description: "The name of the team" + description: "The name of the team", }, { name: "organizationId", type: "string", description: "The ID of the organization", - isForeignKey: true + isForeignKey: true, }, { name: "createdAt", type: "Date", - description: "Timestamp of when the team was created" + description: "Timestamp of when the team was created", }, { name: "updatedAt", type: "Date", isOptional: true, - description: "Timestamp of when the team was created" + description: "Timestamp of when the team was created", }, ]} - /> +/> Table Name: `teamMember` @@ -1665,27 +2011,27 @@ Table Name: `teamMember` name: "id", type: "string", description: "Unique identifier for each team member", - isPrimaryKey: true + isPrimaryKey: true, }, { name: "teamId", type: "string", description: "Unique identifier for each team", - isForeignKey: true + isForeignKey: true, }, { name: "userId", type: "string", description: "The ID of the user", - isForeignKey: true + isForeignKey: true, }, { name: "createdAt", type: "Date", - description: "Timestamp of when the team member was created" + description: "Timestamp of when the team member was created", }, ]} - /> +/> Table Name: `invitation` @@ -1695,10 +2041,10 @@ Table Name: `invitation` name: "teamId", type: "string", description: "The ID of the team", - isOptional: true + isOptional: true, }, ]} - /> +/> ### Customizing the Schema @@ -1710,23 +2056,23 @@ const auth = betterAuth({ organization({ schema: { organization: { - modelName: "organizations", //map the organization table to organizations + modelName: "organizations", //map the organization table to organizations fields: { - name: "title" //map the name field to title + name: "title", //map the name field to title }, additionalFields: { // Add a new field to the organization table myCustomField: { type: "string", input: true, - required: false - } - } - } - } - }) - ] -}) + required: false, + }, + }, + }, + }, + }), + ], +}); ``` #### Additional Fields @@ -1742,59 +2088,68 @@ const auth = betterAuth({ schema: { organization: { additionalFields: { - myCustomField: { // [!code highlight] + myCustomField: { + // [!code highlight] type: "string", // [!code highlight] input: true, // [!code highlight] - required: false // [!code highlight] - } // [!code highlight] - } - } - } - }) - ] -}) + required: false, // [!code highlight] + }, // [!code highlight] + }, + }, + }, + }), + ], +}); ``` For inferring the additional fields, you can use the `inferOrgAdditionalFields` function. This function will infer the additional fields from the auth object type. ```ts title="auth-client.ts" import { createAuthClient } from "better-auth/client"; -import { inferOrgAdditionalFields, organizationClient } from "better-auth/client/plugins"; -import type { auth } from "@/auth" // import the auth object type only +import { + inferOrgAdditionalFields, + organizationClient, +} from "better-auth/client/plugins"; +import type { auth } from "@/auth"; // import the auth object type only const client = createAuthClient({ - plugins: [organizationClient({ - schema: inferOrgAdditionalFields() - })] -}) + plugins: [ + organizationClient({ + schema: inferOrgAdditionalFields(), + }), + ], +}); ``` if you can't import the auth object type, you can use the `inferOrgAdditionalFields` function without the generic. This function will infer the additional fields from the schema object. ```ts title="auth-client.ts" - const client = createAuthClient({ - plugins: [organizationClient({ - schema: inferOrgAdditionalFields({ - organization: { // [!code highlight] - additionalFields: { - newField: { // [!code highlight] - type: "string", // [!code highlight] - }, // [!code highlight] - }, + plugins: [ + organizationClient({ + schema: inferOrgAdditionalFields({ + organization: { + // [!code highlight] + additionalFields: { + newField: { + // [!code highlight] + type: "string", // [!code highlight] + }, // [!code highlight] }, - }) - })] -}) + }, + }), + }), + ], +}); //example usage await client.organization.create({ - name: "Test", - slug: "test", - newField: "123", //this should be allowed - //@ts-expect-error - this field is not available - unavalibleField: "123", //this should be not allowed -}) + name: "Test", + slug: "test", + newField: "123", //this should be allowed + //@ts-expect-error - this field is not available + unavalibleField: "123", //this should be not allowed +}); ``` ## Options diff --git a/packages/better-auth/src/index.ts b/packages/better-auth/src/index.ts index aa6a2c71..3339f35a 100644 --- a/packages/better-auth/src/index.ts +++ b/packages/better-auth/src/index.ts @@ -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"; diff --git a/packages/better-auth/src/plugins/organization/organization.test.ts b/packages/better-auth/src/plugins/organization/organization.test.ts index fbf63544..d2bf6ad3 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, 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"); + }); +}); 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 d4cdf11e..822fdb96 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-invites.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-invites.ts @@ -378,14 +378,37 @@ export const createInvitation = (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 = (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 = (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 = (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 = (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 = (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); }, ); diff --git a/packages/better-auth/src/plugins/organization/routes/crud-members.ts b/packages/better-auth/src/plugins/organization/routes/crud-members.ts index bc2dba7f..0644f4f6 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-members.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-members.ts @@ -147,13 +147,42 @@ export const addMember = (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 = (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 = (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 = (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 = (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); }, ); diff --git a/packages/better-auth/src/plugins/organization/routes/crud-org.ts b/packages/better-auth/src/plugins/organization/routes/crud-org.ts index ffb09a65..eaac6f68 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-org.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-org.ts @@ -22,7 +22,7 @@ import { } from "../../../db"; export const createOrganization = ( - options: O, + options?: O, ) => { const additionalFieldsSchema = toZodSchema({ fields: options?.schema?.organization?.additionalFields || {}, @@ -162,12 +162,6 @@ export const createOrganization = ( ...orgData } = ctx.body; - let hookResponse: - | { - data: Record; - } - | undefined = undefined; - if (options.organizationCreation?.beforeCreate) { const response = await options.organizationCreation.beforeCreate( { @@ -180,7 +174,24 @@ export const createOrganization = ( 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 = ( organization: { ...orgData, createdAt: new Date(), - ...(hookResponse?.data || {}), }, }); @@ -241,6 +251,14 @@ export const createOrganization = ( ); } + 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 = ( ); export const updateOrganization = ( - options: O, + options?: O, ) => { const additionalFieldsSchema = toZodSchema({ fields: options?.schema?.organization?.additionalFields || {}, @@ -416,10 +434,31 @@ export const updateOrganization = ( 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 = ( }, }, 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 = ( */ 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, }); diff --git a/packages/better-auth/src/plugins/organization/routes/crud-team.ts b/packages/better-auth/src/plugins/organization/routes/crud-team.ts index 1366db3d..c6b0d690 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-team.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-team.ts @@ -155,13 +155,52 @@ export const createTeam = (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 = (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 = (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 = (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 = (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." }); }, ); diff --git a/packages/better-auth/src/plugins/organization/types.ts b/packages/better-auth/src/plugins/organization/types.ts index d6630dd4..e9c76016 100644 --- a/packages/better-auth/src/plugins/organization/types.ts +++ b/packages/better-auth/src/plugins/organization/types.ts @@ -221,42 +221,6 @@ 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. */ @@ -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; }; + /** + * @deprecated Use `organizationHooks` instead + */ organizationCreation?: { disabled?: boolean; beforeCreate?: ( data: { organization: Omit & Record; - user: User; + user: User & Record; }, request?: Request, ) => Promise; member: Member & Record; - user: User; + user: User & Record; }, request?: Request, ) => Promise; }; + /** + * 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; + [key: string]: any; + }; + user: User & Record; + }) => Promise; + }>; + /** + * A callback that runs after the organization is created + */ + afterCreateOrganization?: (data: { + organization: Organization & Record; + member: Member & Record; + user: User & Record; + }) => Promise; + /** + * 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; + [key: string]: any; + }; + user: User & Record; + member: Member & Record; + }) => Promise; + [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) | null; + user: User & Record; + member: Member & Record; + }) => Promise; + /** + * A callback that runs before the organization is deleted + */ + beforeDeleteOrganization?: (data: { + organization: Organization & Record; + user: User & Record; + }) => Promise; + /** + * A callback that runs after the organization is deleted + */ + afterDeleteOrganization?: (data: { + organization: Organization & Record; + user: User & Record; + }) => Promise; + /** + * 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; + organization: Organization & Record; + }) => Promise; + }>; + + /** + * A callback that runs after a member is added to an organization + */ + afterAddMember?: (data: { + member: Member & Record; + user: User & Record; + organization: Organization & Record; + }) => Promise; + + /** + * A callback that runs before a member is removed from an organization + */ + beforeRemoveMember?: (data: { + member: Member & Record; + user: User & Record; + organization: Organization & Record; + }) => Promise; + + /** + * A callback that runs after a member is removed from an organization + */ + afterRemoveMember?: (data: { + member: Member & Record; + user: User & Record; + organization: Organization & Record; + }) => Promise; + + /** + * 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; + newRole: string; + user: User & Record; + organization: Organization & Record; + }) => Promise; + + /** + * A callback that runs after a member's role is updated + */ + afterUpdateMemberRole?: (data: { + member: Member & Record; + previousRole: string; + user: User & Record; + organization: Organization & Record; + }) => Promise; + + /** + * 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; + organization: Organization & Record; + }) => Promise; + }>; + + /** + * A callback that runs after an invitation is created + */ + afterCreateInvitation?: (data: { + invitation: Invitation & Record; + inviter: User & Record; + organization: Organization & Record; + }) => Promise; + + /** + * A callback that runs before an invitation is accepted + */ + beforeAcceptInvitation?: (data: { + invitation: Invitation & Record; + user: User & Record; + organization: Organization & Record; + }) => Promise; + + /** + * A callback that runs after an invitation is accepted + */ + afterAcceptInvitation?: (data: { + invitation: Invitation & Record; + member: Member & Record; + user: User & Record; + organization: Organization & Record; + }) => Promise; + + /** + * A callback that runs before an invitation is rejected + */ + beforeRejectInvitation?: (data: { + invitation: Invitation & Record; + user: User & Record; + organization: Organization & Record; + }) => Promise; + + /** + * A callback that runs after an invitation is rejected + */ + afterRejectInvitation?: (data: { + invitation: Invitation & Record; + user: User & Record; + organization: Organization & Record; + }) => Promise; + + /** + * A callback that runs before an invitation is cancelled + */ + beforeCancelInvitation?: (data: { + invitation: Invitation & Record; + cancelledBy: User & Record; + organization: Organization & Record; + }) => Promise; + + /** + * A callback that runs after an invitation is cancelled + */ + afterCancelInvitation?: (data: { + invitation: Invitation & Record; + cancelledBy: User & Record; + organization: Organization & Record; + }) => Promise; + + /** + * 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; + organization: Organization & Record; + }) => Promise; + }>; + + /** + * A callback that runs after a team is created + */ + afterCreateTeam?: (data: { + team: Team & Record; + user?: User & Record; + organization: Organization & Record; + }) => Promise; + + /** + * 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; + updates: { + name?: string; + [key: string]: any; + }; + user: User & Record; + organization: Organization & Record; + }) => Promise; + }>; + + /** + * A callback that runs after a team is updated + */ + afterUpdateTeam?: (data: { + team: (Team & Record) | null; + user: User & Record; + organization: Organization & Record; + }) => Promise; + + /** + * A callback that runs before a team is deleted + */ + beforeDeleteTeam?: (data: { + team: Team & Record; + user?: User & Record; + organization: Organization & Record; + }) => Promise; + + /** + * A callback that runs after a team is deleted + */ + afterDeleteTeam?: (data: { + team: Team & Record; + user?: User & Record; + organization: Organization & Record; + }) => Promise; + + /** + * 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; + user: User & Record; + organization: Organization & Record; + }) => Promise; + }>; + + /** + * A callback that runs after a member is added to a team + */ + afterAddTeamMember?: (data: { + teamMember: TeamMember & Record; + team: Team & Record; + user: User & Record; + organization: Organization & Record; + }) => Promise; + + /** + * A callback that runs before a member is removed from a team + */ + beforeRemoveTeamMember?: (data: { + teamMember: TeamMember & Record; + team: Team & Record; + user: User & Record; + organization: Organization & Record; + }) => Promise; + + /** + * A callback that runs after a member is removed from a team + */ + afterRemoveTeamMember?: (data: { + teamMember: TeamMember & Record; + team: Team & Record; + user: User & Record; + organization: Organization & Record; + }) => Promise; + }; /** * Automatically create an organization for the user on sign up. *