Just-Use-Convex Docs
Authentication

Roles & Permissions

Role-based access control for organizations

Roles & Permissions

Better Auth provides built-in role-based access control (RBAC) for organizations.

Built-in Roles

RoleDescription
ownerFull control, can delete organization
adminCan manage members and settings
memberStandard access

Checking Roles

In Convex Functions

export const deleteOrganization = zMutation({
  args: z.object({}),
  handler: async (ctx) => {
    // Only owners can delete
    if (ctx.identity.organizationRole !== "owner") {
      throw new Error("Only owners can delete organizations");
    }

    // Proceed with deletion...
  },
});

Role-Based Authorization Helper

// shared/auth_shared.ts
export function requireRole(
  identity: Identity,
  allowedRoles: string[]
): void {
  if (!allowedRoles.includes(identity.organizationRole)) {
    throw new Error(
      `This action requires one of these roles: ${allowedRoles.join(", ")}`
    );
  }
}

// Usage
export const updateSettings = zMutation({
  args: z.object({ /* ... */ }),
  handler: async (ctx, args) => {
    requireRole(ctx.identity, ["owner", "admin"]);
    // Only owners and admins reach here
  },
});

Changing Member Roles

async function updateMemberRole(
  memberId: string,
  role: "admin" | "member"
) {
  await authClient.organization.updateMemberRole({
    memberId,
    role,
  });
}

Permission Patterns

Action-Based Permissions

const permissions = {
  "todo:create": ["owner", "admin", "member"],
  "todo:delete": ["owner", "admin"],
  "settings:update": ["owner", "admin"],
  "member:invite": ["owner", "admin"],
  "member:remove": ["owner"],
  "org:delete": ["owner"],
};

function canPerform(role: string, action: string): boolean {
  return permissions[action]?.includes(role) ?? false;
}

// Usage in mutation
export const deleteTodo = zMutation({
  args: z.object({ id: z.string() }),
  handler: async (ctx, args) => {
    if (!canPerform(ctx.identity.organizationRole, "todo:delete")) {
      throw new Error("Permission denied");
    }
    // ...
  },
});

Resource-Based Permissions

export const updateTodo = zMutation({
  args: z.object({ id: z.string(), title: z.string() }),
  handler: async (ctx, args) => {
    const todo = await ctx.table("todos").get(args.id);

    // Check org membership
    if (todo.organizationId !== ctx.identity.activeOrganizationId) {
      throw new Error("Not found");
    }

    // Owner/Admin can edit any todo
    const isAdmin = ["owner", "admin"].includes(ctx.identity.organizationRole);

    // Members can only edit their own todos
    const isOwner = todo.userId === ctx.identity.userId;

    if (!isAdmin && !isOwner) {
      throw new Error("Permission denied");
    }

    return await ctx.table("todos").patch(args.id, { title: args.title });
  },
});

Frontend Role Checks

import { useSession } from "@/lib/auth";

function AdminPanel() {
  const { data: session } = useSession();
  const role = session?.session.organizationRole;

  if (role !== "owner" && role !== "admin") {
    return null; // Hide from non-admins
  }

  return (
    <div>
      <h2>Admin Panel</h2>
      {/* Admin-only content */}
    </div>
  );
}

Role-Based Button Visibility

function TodoActions({ todo }) {
  const { data: session } = useSession();
  const role = session?.session.organizationRole;
  const isOwner = todo.userId === session?.user.id;
  const canDelete = role === "owner" || role === "admin" || isOwner;

  return (
    <div>
      <button onClick={() => editTodo(todo.id)}>Edit</button>
      {canDelete && (
        <button onClick={() => deleteTodo(todo.id)}>Delete</button>
      )}
    </div>
  );
}

Custom Roles

For more complex RBAC, you can extend with custom roles:

// Store custom permissions in org settings
organizationPermissions: defineEnt({
  organizationId: v.string(),
  userId: v.string(),
  permissions: v.array(v.string()),
}).index("organizationId_userId", ["organizationId", "userId"])

// Check custom permissions
export const checkPermission = zQuery({
  args: z.object({ permission: z.string() }),
  handler: async (ctx, args) => {
    // Built-in admin roles have all permissions
    if (["owner", "admin"].includes(ctx.identity.organizationRole)) {
      return true;
    }

    const customPerms = await ctx
      .table("organizationPermissions", "organizationId_userId", (q) =>
        q.eq("organizationId", ctx.identity.activeOrganizationId)
          .eq("userId", ctx.identity.userId)
      )
      .unique();

    return customPerms?.permissions.includes(args.permission) ?? false;
  },
});

Next, learn about Aggregates.