# 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.
