UNPKG

kitcn

Version:

kitcn - React Query integration and CLI tools for Convex

1,151 lines (998 loc) 30.9 kB
# Auth Organizations Plugin Multi-tenant organization features via Better Auth plugin. Organizations, members, invitations, teams, RBAC, lifecycle hooks. Prerequisites: `setup/auth.md`, `setup/server.md`. See [Better Auth Organization Plugin](https://www.better-auth.com/docs/plugins/organization) for full API reference. ## Server Config ```ts // convex/functions/auth.ts import { organization } from "better-auth/plugins"; import { requireSchedulerCtx } from "kitcn/server"; import { defineAuth } from "./generated/auth"; export default defineAuth((ctx) => ({ plugins: [ convex({ authConfig, jwks: process.env.JWKS }), admin(), organization({ ac, roles, allowUserToCreateOrganization: true, organizationLimit: 5, membershipLimit: 100, creatorRole: "owner", invitationExpiresIn: 48 * 60 * 60, // 48 hours teams: { enabled: true, maximumTeams: 10 }, sendInvitationEmail: async (data) => { const schedulerCtx = requireSchedulerCtx(ctx); const inviterName = data.inviter.user.name || "Team Admin"; const organizationName = data.organization.name; const roleSuffix = data.role ? ` as ${data.role}` : ""; const acceptUrl = `${process.env.SITE_URL!}/w/${data.organization.slug}?invite=${data.id}`; await schedulerCtx.scheduler.runAfter( 0, internal.plugins.email.sendTemplatedEmail, { to: data.email, subject: `${inviterName} invited you to join ${organizationName}`, title: `Invitation to join ${organizationName}`, body: `${inviterName} (${data.inviter.user.email}) invited you to join ${organizationName}${roleSuffix}.`, ctaLabel: "Accept invitation", ctaUrl: acceptUrl, } ); }, }), ], })); ``` `sendInvitationEmail` can run from mutation-driven auth flows. Use `requireSchedulerCtx(ctx)` when you need scheduling. Do not narrow to `ActionCtx` unless the callback truly runs only inside an action. ## Client Config ```ts // src/lib/convex/auth-client.ts import { organizationClient } from "better-auth/client/plugins"; import { ac, roles } from "@convex/auth-shared"; export const authClient = createAuthClient({ plugins: [ inferAdditionalFields<Auth>(), convexClient(), organizationClient({ ac, roles, teams: { enabled: true } }), ], }); ``` ## Schema ```ts // convex/functions/schema.ts import { convexTable, defineSchema, id, index, integer, json, text, timestamp, } from "kitcn/orm"; export const organization = convexTable( "organization", { name: text().notNull(), slug: text().notNull(), logo: text(), createdAt: timestamp().notNull().defaultNow(), metadata: json<Record<string, unknown>>(), }, (t) => [index("slug").on(t.slug), index("name").on(t.name)] ); export const member = convexTable( "member", { organizationId: id("organization").notNull(), userId: id("user").notNull(), role: text().notNull(), createdAt: timestamp().notNull().defaultNow(), }, (t) => [ index("userId").on(t.userId), index("organizationId_userId").on(t.organizationId, t.userId), index("organizationId_role").on(t.organizationId, t.role), ] ); export const invitation = convexTable( "invitation", { organizationId: id("organization").notNull(), inviterId: id("user").notNull(), email: text().notNull(), role: text(), status: text().notNull(), expiresAt: integer().notNull(), createdAt: timestamp().notNull().defaultNow(), }, (t) => [ index("email").on(t.email), index("status").on(t.status), index("email_organizationId_status").on( t.email, t.organizationId, t.status ), index("organizationId_status").on(t.organizationId, t.status), ] ); // Add to existing session table export const session = convexTable("session", { // ... existing session fields activeOrganizationId: id("organization"), activeTeamId: id("team"), }); ``` ### Teams (Optional) ```ts export const team = convexTable( "team", { name: text().notNull(), organizationId: id("organization").notNull(), createdAt: timestamp().notNull().defaultNow(), updatedAt: integer(), }, (t) => [index("organizationId").on(t.organizationId)] ); export const teamMember = convexTable( "teamMember", { teamId: id("team").notNull(), userId: id("user").notNull(), createdAt: timestamp(), }, (t) => [index("teamId").on(t.teamId), index("userId").on(t.userId)] ); ``` ### Additional Fields Extend organization tables with custom fields in plugin config: ```ts organization({ schema: { organization: { fields: { description: v.optional(v.string()), website: v.optional(v.string()) } }, member: { fields: { title: v.optional(v.string()), department: v.optional(v.string()) } }, invitation: { fields: { message: v.optional(v.string()) } }, }, }), ``` Then add matching columns in your schema. ## Access Control ### Basic Setup ```ts // convex/shared/auth-shared.ts import { createAccessControl } from "better-auth/plugins/access"; import { defaultStatements, memberAc, ownerAc, } from "better-auth/plugins/organization/access"; const statement = { ...defaultStatements } as const; export const ac = createAccessControl(statement); const member = ac.newRole({ ...memberAc.statements }); const owner = ac.newRole({ ...ownerAc.statements }); export const roles = { member, owner }; ``` ### Custom Permissions ```ts const statement = { ...defaultStatements, project: ["create", "read", "update", "delete"], billing: ["read", "update"], analytics: ["read"], } as const; export const ac = createAccessControl(statement); const viewer = ac.newRole({ project: ["read"], analytics: ["read"] }); const editor = ac.newRole({ ...memberAc.statements, project: ["create", "read", "update"], analytics: ["read"], }); const admin = ac.newRole({ ...ownerAc.statements, project: ["create", "read", "update", "delete"], billing: ["read", "update"], analytics: ["read"], }); export const roles = { viewer, editor, admin }; ``` ### Check Role Permission ```ts const canEdit = ac.checkRolePermission({ role: "editor", permission: { project: ["update"] }, }); ``` ### Dynamic Access Control ```ts organization({ ac: { ...ac, resolveRole: async ({ role, organizationId }) => { if (roles[role]) return roles[role]; const customRole = await ctx.orm.query.customRole.findFirst({ where: { name: role, organizationId } }); if (customRole) return ac.newRole(customRole.permissions); return null; }, }, }), ``` ### Permission Helper ```ts // convex/lib/auth/auth-helpers.ts import { CRPCError } from "kitcn/server"; import type { AuthCtx } from "../crpc"; export const hasPermission = async ( ctx: AuthCtx, body: { permissions: Record<string, string[]> }, shouldThrow = true ) => { const result = await ctx.auth.api.hasPermission({ body, headers: ctx.auth.headers, }); if (shouldThrow && !result.success) { throw new CRPCError({ code: "FORBIDDEN", message: "Insufficient permissions", }); } return result.success; }; ``` ## Organization Functions **Pattern:** Better Auth API for multi-table ops (create, delete, invitations). `ctx.orm` for simple reads/updates. Example-parity helper module: - `convex/lib/organization-helpers.ts` for shared organization listing and personal-organization bootstrap logic. ### Check Slug ```ts export const checkSlug = authQuery .input(z.object({ slug: z.string() })) .output(z.object({ available: z.boolean() })) .query(async ({ ctx, input }) => { const existing = await ctx.orm.query.organization.findFirst({ where: { slug: input.slug }, }); return { available: !existing }; }); ``` ### List Organizations ```ts export const listOrganizations = authQuery .output( z.object({ canCreateOrganization: z.boolean(), organizations: z.array( z.object({ id: z.string(), createdAt: z.date(), isPersonal: z.boolean(), logo: z.string().nullish(), name: z.string(), plan: z.string(), slug: z.string(), }) ), }) ) .query(async ({ ctx }) => { const orgs = await listUserOrganizations(ctx, ctx.userId); if (!orgs?.length) return { canCreateOrganization: true, organizations: [] }; const activeOrgId = ctx.user.activeOrganization?.id; const organizations = orgs .filter((org) => org.id !== activeOrgId) .map((org) => ({ id: org.id, createdAt: org.createdAt, isPersonal: org.id === ctx.user.personalOrganizationId, logo: org.logo || null, name: org.name, plan: DEFAULT_PLAN, slug: org.slug, })); return { canCreateOrganization: true, organizations }; }); ``` ### Create Organization ```ts export const createOrganization = authMutation .meta({ ratelimit: "organization/create" }) .input(z.object({ name: z.string().min(1).max(100) })) .output(z.object({ id: z.string(), slug: z.string() })) .mutation(async ({ ctx, input }) => { let slug = input.name; let attempt = 0; while (attempt < 10) { const existing = await ctx.orm.query.organization.findFirst({ where: { slug }, }); if (!existing) break; slug = `${slug}-${Math.random().toString(36).slice(2, 10)}`; attempt++; } if (attempt >= 10) throw new CRPCError({ code: "BAD_REQUEST", message: "Could not generate unique slug", }); const org = await ctx.auth.api.createOrganization({ body: { name: input.name, slug }, headers: ctx.auth.headers, }); if (!org) throw new CRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create organization", }); await setActiveOrganizationHandler(ctx, { organizationId: org.id }); return { id: org.id, slug: org.slug }; }); ``` ### Update Organization ```ts export const updateOrganization = authMutation .meta({ ratelimit: "organization/update" }) .input( z.object({ organizationId: z.string(), logo: z.string().url().optional(), name: z.string().min(1).max(100).optional(), slug: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { await hasPermission(ctx, { organizationId: input.organizationId, permissions: { organization: ["update"] }, }); let slug = input.slug; if (input.slug) { if (input.organizationId === ctx.user.personalOrganizationId) { slug = undefined; } else { slugSchema.parse(input.slug); const existing = await ctx.orm.query.organization.findFirst({ where: { slug: input.slug }, }); if (existing && existing.id !== input.organizationId) { throw new CRPCError({ code: "BAD_REQUEST", message: "This slug is already taken", }); } } } const data: { logo?: string; name?: string; slug?: string } = {}; if (input.logo !== undefined) data.logo = input.logo; if (input.name !== undefined) data.name = input.name; if (slug !== undefined) data.slug = slug; await ctx.orm .update(organization) .set(data) .where(eq(organization.id, input.organizationId)); return null; }); ``` Use an `authAction` instead of an `authMutation` for any Better Auth endpoint that can run external plugin work such as Stripe, Polar, or email delivery. Convex mutations cannot call those SDKs. ### Delete Organization ```ts export const deleteOrganization = authMutation .input(z.object({ organizationId: z.string() })) .mutation(async ({ ctx, input }) => { await hasPermission(ctx, { organizationId: input.organizationId, permissions: { organization: ["delete"] }, }); if (input.organizationId === ctx.user.personalOrganizationId) { throw new CRPCError({ code: "FORBIDDEN", message: "Personal organizations can be deleted only by deleting your account.", }); } if (input.organizationId === ctx.user.activeOrganization?.id) { await setActiveOrganizationHandler(ctx, { organizationId: ctx.user.personalOrganizationId!, }); } await ctx.auth.api.deleteOrganization({ body: { organizationId: input.organizationId }, headers: ctx.auth.headers, }); return null; }); ``` ### Set Active Organization ```ts export const setActiveOrganization = authMutation .meta({ ratelimit: "organization/setActive" }) .input(z.object({ organizationId: z.string() })) .mutation(async ({ ctx, input }) => setActiveOrganizationHandler(ctx, input)); ``` ## Invitation Functions ### Send Invitation ```ts export const inviteMember = authMutation .meta({ ratelimit: "organization/invite" }) .input( z.object({ email: z.string().email(), organizationId: z.string(), role: z.enum(["owner", "member"]), }) ) .mutation(async ({ ctx, input }) => { await hasPermission(ctx, { organizationId: input.organizationId, permissions: { invitation: ["create"] }, }); // Check member limit const members = await ctx.orm.query.member.findMany({ where: { organizationId: input.organizationId }, limit: DEFAULT_LIST_LIMIT, }); const pending = await ctx.orm.query.invitation.findMany({ where: { organizationId: input.organizationId, status: "pending" }, limit: DEFAULT_LIST_LIMIT, }); if (members.length + pending.length >= MEMBER_LIMIT) { throw new CRPCError({ code: "FORBIDDEN", message: `Organization member limit reached. Maximum ${MEMBER_LIMIT} members allowed.`, }); } // Cancel existing pending invites for same email const existing = await ctx.orm.query.invitation.findMany({ where: { email: input.email, organizationId: input.organizationId, status: "pending", }, limit: DEFAULT_LIST_LIMIT, }); for (const inv of existing) { await ctx.orm .update(invitation) .set({ status: "canceled" }) .where(eq(invitation.id, inv.id)); } await ctx.auth.api.createInvitation({ body: { email: input.email, organizationId: input.organizationId, role: input.role, }, headers: ctx.auth.headers, }); return null; }); ``` ### Accept / Reject / Cancel ```ts export const acceptInvitation = authMutation .input(z.object({ invitationId: z.string() })) .mutation(async ({ ctx, input }) => { const inv = await ctx.orm.query.invitation .findFirstOrThrow({ where: { id: input.invitationId, email: ctx.user.email }, }) .catch(() => { throw new CRPCError({ code: "FORBIDDEN", message: "Invitation not found for your email", }); }); if (inv.status !== "pending") throw new CRPCError({ code: "BAD_REQUEST", message: "Invitation already processed", }); await ctx.auth.api.acceptInvitation({ body: { invitationId: input.invitationId }, headers: ctx.auth.headers, }); return null; }); export const rejectInvitation = authMutation .meta({ ratelimit: "organization/rejectInvite" }) .input(z.object({ invitationId: z.string() })) .mutation(async ({ ctx, input }) => { const inv = await ctx.orm.query.invitation .findFirstOrThrow({ where: { id: input.invitationId, email: ctx.user.email }, }) .catch(() => { throw new CRPCError({ code: "FORBIDDEN", message: "Invitation not found for your email", }); }); if (inv.status !== "pending") throw new CRPCError({ code: "BAD_REQUEST", message: "Invitation already processed", }); await ctx.auth.api.rejectInvitation({ body: { invitationId: input.invitationId }, headers: ctx.auth.headers, }); return null; }); export const cancelInvitation = authMutation .meta({ ratelimit: "organization/cancelInvite" }) .input(z.object({ invitationId: z.string() })) .mutation(async ({ ctx, input }) => { const inv = await ctx.orm.query.invitation.findFirstOrThrow({ where: { id: input.invitationId }, }); await hasPermission(ctx, { organizationId: inv.organizationId, permissions: { invitation: ["cancel"] }, }); try { await ctx.auth.api.cancelInvitation({ body: { invitationId: input.invitationId }, headers: ctx.auth.headers, }); } catch (error) { if (error instanceof Error && error.message?.includes("not found")) { throw new CRPCError({ code: "NOT_FOUND", message: "Invitation not found or already processed", }); } throw new CRPCError({ code: "BAD_REQUEST", message: `Failed: ${error instanceof Error ? error.message : "Unknown"}`, }); } return null; }); ``` ### List User Invitations ```ts export const listUserInvitations = authQuery .output( z.array( z.object({ id: z.string(), expiresAt: z.date(), inviterName: z.string().nullable(), organizationName: z.string(), organizationSlug: z.string(), role: z.string(), }) ) ) .query(async ({ ctx }) => { const invitations = await ctx.orm.query.invitation.findMany({ where: { email: ctx.user.email, status: "pending" }, limit: DEFAULT_LIST_LIMIT, columns: { id: true, expiresAt: true, organizationId: true, inviterId: true, role: true, }, with: { organization: { columns: { name: true, slug: true } }, inviter: { columns: { name: true } }, }, }); return invitations.map((inv) => { const org = inv.organization; if (!org) throw new CRPCError({ code: "NOT_FOUND", message: "Organization not found", }); return { id: inv.id, expiresAt: inv.expiresAt, inviterName: inv.inviter?.name ?? null, organizationName: org.name, organizationSlug: org.slug, role: inv.role || "member", }; }); }); ``` ### List Pending Invitations ```ts export const listPendingInvitations = authQuery .input(z.object({ slug: z.string() })) .output( z.array( z.object({ id: z.string(), createdAt: z.date(), email: z.string(), expiresAt: z.date(), organizationId: z.string(), role: z.string(), status: z.string(), }) ) ) .query(async ({ ctx, input }) => { const org = await ctx.orm.query.organization.findFirst({ where: { slug: input.slug }, }); if (!org) return []; const canManage = await hasPermission( ctx, { organizationId: org.id, permissions: { invitation: ["create"] } }, false ); if (!canManage) return []; const invitations = await ctx.orm.query.invitation.findMany({ where: { organizationId: org.id, status: "pending" }, limit: DEFAULT_LIST_LIMIT, columns: { id: true, createdAt: true, email: true, expiresAt: true, organizationId: true, role: true, status: true, }, }); return invitations.map((inv) => ({ id: inv.id, createdAt: inv.createdAt, email: inv.email, expiresAt: inv.expiresAt, organizationId: inv.organizationId, role: inv.role || "member", status: inv.status, })); }); ``` ## Member Functions ### Get Active Member ```ts export const getActiveMember = authQuery .output( z .object({ id: z.string(), createdAt: z.date(), role: z.string() }) .nullable() ) .query(async ({ ctx }) => { if (!ctx.user.activeOrganization) return null; const m = await ctx.orm.query.member.findFirst({ where: { organizationId: ctx.user.activeOrganization.id, userId: ctx.userId, }, }); if (!m) return null; return { id: m.id, createdAt: m.createdAt, role: m.role }; }); ``` ### Add Member Directly ```ts export const addMember = authMutation .meta({ ratelimit: "organization/addMember" }) .input(z.object({ role: z.enum(["owner", "member"]), userId: z.string() })) .mutation(async ({ ctx, input }) => { await hasPermission(ctx, { permissions: { member: ["create"] } }); await ctx.auth.api.addMember({ body: { userId: input.userId, organizationId: ctx.user.activeOrganization!.id, role: input.role, }, headers: ctx.auth.headers, }); return null; }); ``` ### List Members ```ts export const listMembers = authQuery .input(z.object({ slug: z.string() })) .output( z.object({ currentUserRole: z.string().optional(), isPersonal: z.boolean(), members: z.array( z.object({ id: z.string(), createdAt: z.date(), organizationId: z.string(), role: z.string(), user: z.object({ id: z.string(), email: z.string(), image: z.string().nullish(), name: z.string().nullable(), }), userId: z.string(), }) ), }) ) .query(async ({ ctx, input }) => { const org = await ctx.orm.query.organization.findFirst({ where: { slug: input.slug }, }); if (!org) return { isPersonal: false, members: [] }; const currentMember = await ctx.orm.query.member.findFirst({ where: { organizationId: org.id, userId: ctx.userId }, }); if (!currentMember) return { isPersonal: org.id === ctx.user.personalOrganizationId, members: [], }; const members = await ctx.orm.query.member.findMany({ where: { organizationId: org.id }, limit: DEFAULT_LIST_LIMIT, with: { user: true }, }); if (!members?.length) return { isPersonal: org.id === ctx.user.personalOrganizationId, members: [], }; const enriched = members .map((m) => { if (!m.user) return null; return { id: m.id, createdAt: m.createdAt, organizationId: org.id, role: m.role, user: { id: m.user.id, email: m.user.email, image: m.user.image, name: m.user.name, }, userId: m.userId, }; }) .filter((row): row is NonNullable<typeof row> => row !== null); return { currentUserRole: currentMember.role, isPersonal: org.id === ctx.user.personalOrganizationId, members: enriched, }; }); ``` ### Update Member Role ```ts export const updateMemberRole = authMutation .meta({ ratelimit: "organization/updateRole" }) .input(z.object({ memberId: z.string(), role: z.enum(["owner", "member"]) })) .mutation(async ({ ctx, input }) => { const m = await ctx.orm.query.member.findFirstOrThrow({ where: { id: input.memberId }, }); await hasPermission(ctx, { organizationId: m.organizationId, permissions: { member: ["update"] }, }); await ctx.auth.api.updateMemberRole({ body: { memberId: input.memberId, organizationId: m.organizationId, role: input.role, }, headers: ctx.auth.headers, }); return null; }); ``` ### Remove Member ```ts export const removeMember = authMutation .meta({ ratelimit: "organization/removeMember" }) .input(z.object({ memberId: z.string() })) .mutation(async ({ ctx, input }) => { const m = await ctx.orm.query.member.findFirstOrThrow({ where: { id: input.memberId }, }); await hasPermission(ctx, { organizationId: m.organizationId, permissions: { member: ["delete"] }, }); await ctx.auth.api.removeMember({ body: { memberIdOrEmail: input.memberId, organizationId: m.organizationId, }, headers: ctx.auth.headers, }); return null; }); ``` ### Leave Organization ```ts export const leaveOrganization = authMutation .meta({ ratelimit: "organization/leave" }) .input(z.object({ organizationId: z.string() })) .mutation(async ({ ctx, input }) => { if (input.organizationId === ctx.user.personalOrganizationId) { throw new CRPCError({ code: "BAD_REQUEST", message: "Cannot leave personal organization", }); } const currentMember = await ctx.orm.query.member .findFirstOrThrow({ where: { organizationId: input.organizationId, userId: ctx.userId }, }) .catch(() => { throw new CRPCError({ code: "FORBIDDEN", message: "Not a member" }); }); if (currentMember.role === "owner") { const owners = await ctx.orm.query.member.findMany({ where: { organizationId: input.organizationId, role: "owner" }, limit: 2, }); if (owners.length <= 1) { throw new CRPCError({ code: "FORBIDDEN", message: "Cannot leave as the only owner. Transfer ownership first.", }); } } await ctx.auth.api.leaveOrganization({ body: { organizationId: input.organizationId }, headers: ctx.auth.headers, }); if (input.organizationId === ctx.user.activeOrganization?.id) { await setActiveOrganizationHandler(ctx, { organizationId: ctx.user.personalOrganizationId!, }); } return null; }); ``` ## Teams Use Better Auth team APIs directly: ```ts // List teams const teams = await ctx.auth.api.listTeams({ query: { organizationId: ctx.user.activeOrganization!.id }, headers: ctx.auth.headers, }); // Add/remove member await ctx.auth.api.addTeamMember({ body: { teamId, userId }, headers: ctx.auth.headers, }); await ctx.auth.api.removeTeamMember({ body: { teamId, userId }, headers: ctx.auth.headers, }); // List team members const members = await ctx.auth.api.listTeamMembers({ body: { teamId }, headers: ctx.auth.headers, }); ``` ## Hooks ### Organization Hooks ```ts organization({ organizationCreation: { beforeCreate: async ({ organization, user }) => { return { data: organization }; }, afterCreate: async ({ organization, member, user }) => { /* setup defaults */ }, }, organizationDeletion: { beforeDelete: async (data) => { /* cleanup */ }, afterDelete: async (data) => { /* post-cleanup */ }, }, }), ``` ### Member Hooks ```ts organization({ membershipManagement: { beforeAddMember: async ({ organization, member, user }) => { return { data: member }; }, afterAddMember: async ({ organization, member, user }) => { /* notifications */ }, beforeRemoveMember: async ({ organization, member, user }) => { /* cleanup */ }, afterRemoveMember: async ({ organization, member, user }) => { /* post-removal */ }, beforeUpdateRole: async ({ organization, member, role }) => { return { data: { role } }; }, afterUpdateRole: async ({ organization, member, role }) => { /* notifications */ }, }, }), ``` ### Invitation Hooks ```ts organization({ invitationManagement: { beforeCreateInvitation: async ({ invitation, organization, inviter }) => { return { data: invitation }; }, afterCreateInvitation: async ({ invitation, organization, inviter }) => { /* notify */ }, beforeAcceptInvitation: async ({ invitation, user }) => { return { data: invitation }; }, afterAcceptInvitation: async ({ invitation, member, user }) => { /* welcome */ }, }, }), ``` ### Team Hooks ```ts organization({ teamManagement: { beforeCreateTeam: async ({ team, organization }) => { return { data: team }; }, afterCreateTeam: async ({ team, organization }) => {}, beforeAddTeamMember: async ({ team, user }) => { return { data: { team, user } }; }, afterAddTeamMember: async ({ team, user }) => {}, }, }), ``` ## Client Usage ### React Hooks ```tsx const { data: activeOrg } = authClient.useActiveOrganization(); const { data: orgs } = authClient.useListOrganizations(); authClient.organization.setActive({ organizationId: orgId }); authClient.organization.create({ name: "New Org", slug: "new-org" }); ``` ### Get Full Organization ```ts const { data: fullOrg } = await authClient.organization.getFullOrganization({ query: { organizationId: orgId }, }); // Returns: organization + members + invitations ``` ### Invitation Operations (Client) ```ts const { data: invitations } = await authClient.organization.listInvitations(); await authClient.organization.acceptInvitation({ invitationId }); await authClient.organization.rejectInvitation({ invitationId }); await authClient.organization.cancelInvitation({ invitationId }); ``` ### Member Operations (Client) ```ts const { data: member } = await authClient.organization.getActiveMember(); await authClient.organization.leave(); await authClient.organization.removeMember({ memberIdOrEmail }); await authClient.organization.updateMemberRole({ memberId, role: "admin" }); ``` ### Permission Check (Client) ```ts const { data } = await authClient.organization.hasPermission({ permissions: { organization: ["delete"] }, }); if (data?.success) { /* show delete button */ } ``` ### Team Operations (Client) ```ts const { data: teams } = await authClient.organization.listTeams(); await authClient.organization.createTeam({ name: "Engineering" }); await authClient.organization.setActiveTeam({ teamId }); ``` ## API Reference | Operation | Method | Multi-table | | ------------------ | --------------- | ----------- | | Create org | Better Auth API | Yes | | Update org | Better Auth API | No | | Delete org | Better Auth API | Yes | | List orgs | ORM | No | | Check slug | ORM | No | | Invite member | Better Auth API | Yes | | Accept invite | Better Auth API | Yes | | Reject invite | Better Auth API | Yes | | Cancel invite | Better Auth API | Yes | | List user invites | ORM | No | | Add member | Better Auth API | Yes | | Update role | Better Auth API | Yes | | Remove member | Better Auth API | Yes | | Leave org | Better Auth API | Yes | | Create team | Better Auth API | Yes | | Add team member | Better Auth API | Yes | | Remove team member | Better Auth API | Yes | Use Better Auth API for multi-table operations. Use `ctx.orm` for simple single-table reads/updates.