BETTER-CONVEX

Admin

Role-based admin features with Better Auth and Convex.

In this guide, we'll explore role-based admin features with better-convex. You'll learn to configure the admin plugin, set up role-based access control, and use admin APIs for user management, banning, and impersonation.

Overview

Admin features built on Better Auth's admin plugin:

FeatureDescription
Role-based accessAdmin/user roles with middleware checking
User managementCreate, update, delete users
BanningBan/unban users with expiration
Session managementList, revoke user sessions
ImpersonationAdmin can impersonate users
Custom permissionsGranular access control beyond roles

Let's set up admin features step by step.

Prerequisites

Ensure you have Auth Server set up before adding admin features.

1. Server Configuration

Add the admin plugin to your auth options:

convex/functions/auth.ts
import { admin } from 'better-auth/plugins';
import type { GenericCtx } from '../lib/crpc';

const createAuthOptions = (ctx: GenericCtx) =>
  ({
    // ... existing config
    plugins: [
      admin({
        defaultRole: 'user',
        // adminUserIds: ['user_id_1'], // Always admin regardless of role
        // impersonationSessionDuration: 60 * 60, // 1 hour default
        // defaultBanReason: 'No reason',
        // bannedUserMessage: 'You have been banned',
      }),
      // ... other plugins
    ],
  }) satisfies BetterAuthOptions;

Admin Assignment via Environment

Configure admin emails in your environment:

convex/.env
ADMIN=admin@example.com,other@example.com

Assign admin role on user creation:

convex/functions/auth.ts
export const authClient = createClient<DataModel, typeof schema>({
  // ... config
  triggers: {
    user: {
      beforeCreate: async (_ctx, data) => {
        const env = getEnv();
        const adminEmails = env.ADMIN;

        const role =
          data.role !== 'admin' && adminEmails?.includes(data.email)
            ? 'admin'
            : data.role;

        return { ...data, role };
      },
    },
  },
});

2. Client Configuration

Add the admin client plugin:

src/lib/convex/auth-client.ts
import { adminClient } from 'better-auth/client/plugins';

export const authClient = createAuthClient({
  // ... existing config
  plugins: [
    adminClient(),
    // ... other plugins
  ],
});

3. Schema

Add admin fields to your user table:

convex/functions/schema.ts
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';

export default defineSchema({
  // ... other tables

  user: defineTable({
    // ... existing fields
    role: v.optional(v.string()), // 'admin' | 'user'
    banned: v.optional(v.boolean()),
    banReason: v.optional(v.string()),
    banExpires: v.optional(v.number()),
  }),

  session: defineTable({
    // ... existing fields
    impersonatedBy: v.optional(v.string()), // Admin user ID
  }),
});
convex/functions/schema.ts
import { v } from 'convex/values';
import { defineEnt, defineEntSchema } from 'convex-ents';

const schema = defineEntSchema({
  // ... other tables

  user: defineEnt({
    // ... existing fields
    role: v.optional(v.union(v.null(), v.string())), // 'admin' | 'user'
    banned: v.optional(v.union(v.null(), v.boolean())),
    banReason: v.optional(v.union(v.null(), v.string())),
    banExpires: v.optional(v.union(v.null(), v.number())),
  }),

  session: defineEnt({
    // ... existing fields
    impersonatedBy: v.optional(v.union(v.null(), v.string())), // Admin user ID
  }),
});

4. Access Control

Role Middleware

Add role middleware to your cRPC setup:

convex/lib/crpc.ts
type Meta = {
  auth?: 'optional' | 'required';
  role?: 'admin';
  // ...
};

/** Role middleware - checks admin role from meta after auth middleware */
const roleMiddleware = c.middleware<object>(({ ctx, meta, next }) => {
  const user = (ctx as { user?: { role?: string | null } }).user;
  if (meta.role === 'admin' && user?.role !== 'admin') {
    throw new CRPCError({
      code: 'FORBIDDEN',
      message: 'Admin access required',
    });
  }
  return next({ ctx });
});

export const authQuery = c.query
  .meta({ auth: 'required' })
  .use(devMiddleware)
  .use(authMiddleware)
  .use(roleMiddleware);

export const authMutation = c.mutation
  .meta({ auth: 'required' })
  .use(devMiddleware)
  .use(authMiddleware)
  .use(roleMiddleware)
  .use(rateLimitMiddleware);

Role Guard Helper

convex/lib/auth/role-guard.ts
import { CRPCError } from 'better-convex/server';

export function roleGuard(
  role: 'admin',
  user: { role?: string | null } | null
) {
  if (!user) {
    throw new CRPCError({
      code: 'FORBIDDEN',
      message: 'Access denied',
    });
  }
  if (role === 'admin' && user.role !== 'admin') {
    throw new CRPCError({
      code: 'FORBIDDEN',
      message: 'Admin access required',
    });
  }
}

Custom Access Control

Define granular permissions beyond admin/user roles:

convex/shared/permissions.ts
import { createAccessControl } from 'better-auth/plugins/access';
import { defaultStatements, adminAc } from 'better-auth/plugins/admin/access';

const statement = {
  ...defaultStatements,
  project: ['create', 'read', 'update', 'delete'],
} as const;

export const ac = createAccessControl(statement);

export const admin = ac.newRole({
  ...adminAc.statements,
  project: ['create', 'read', 'update', 'delete'],
});

export const user = ac.newRole({
  project: ['create', 'read'],
});

Pass to plugins:

convex/functions/auth.ts
// Server
admin({ ac, roles: { admin, user } })

// Client (src/lib/convex/auth-client.ts)
adminClient({ ac, roles: { admin, user } })

Admin Functions

Function examples use ctx.table (Convex Ents). For ctx.db, replace edge traversal with manual joins. See Ents for migration patterns.

Check Admin Status

convex/functions/admin.ts
import { CRPCError } from 'better-convex/server';
import { zid } from 'convex-helpers/server/zod4';
import { z } from 'zod';
import { authQuery } from '../lib/crpc';

export const checkUserAdminStatus = authQuery
  .meta({ role: 'admin' })
  .input(z.object({ userId: zid('user') }))
  .output(z.object({
    role: z.string().nullish(),
  }))
  .query(async ({ ctx, input }) => {
    const user = await ctx.table('user').getX(input.userId);
    return {
      role: user.role,
    };
  });

Update User Role

convex/functions/admin.ts
export const updateUserRole = authMutation
  .meta({ role: 'admin' })
  .input(z.object({
    role: z.enum(['user', 'admin']),
    userId: zid('user'),
  }))
  .output(z.boolean())
  .mutation(async ({ ctx, input }) => {
    if (input.role === 'admin' && ctx.user.role !== 'admin') {
      throw new CRPCError({
        code: 'FORBIDDEN',
        message: 'Only admin can promote users to admin',
      });
    }

    const targetUser = await ctx.table('user').getX(input.userId);

    if (targetUser.role === 'admin' && ctx.user.role !== 'admin') {
      throw new CRPCError({
        code: 'FORBIDDEN',
        message: 'Cannot modify admin users',
      });
    }

    await targetUser.patch({ role: input.role.toLowerCase() });
    return true;
  });

Grant Admin by Email

convex/functions/admin.ts
export const grantAdminByEmail = authMutation
  .meta({ role: 'admin' })
  .input(z.object({
    email: z.string().email(),
    role: z.enum(['admin']),
  }))
  .output(z.object({
    success: z.boolean(),
    userId: zid('user').optional(),
  }))
  .mutation(async ({ ctx, input }) => {
    const user = await ctx.table('user').get('email', input.email);
    if (!user) return { success: false };

    await user.patch({ role: input.role.toLowerCase() });
    return { success: true, userId: user._id };
  });

List All Users (Paginated)

convex/functions/admin.ts
const UserListItemSchema = z.object({
  _id: zid('user'),
  _creationTime: z.number(),
  name: z.string().optional(),
  email: z.string(),
  image: z.string().nullish(),
  role: z.string(),
  isBanned: z.boolean().optional(),
  banReason: z.string().nullish(),
  banExpiresAt: z.number().nullish(),
});

export const getAllUsers = authQuery
  .input(z.object({
    role: z.enum(['all', 'user', 'admin']).optional(),
    search: z.string().optional(),
  }))
  .paginated({ limit: 20, item: UserListItemSchema.nullable() })
  .query(async ({ ctx, input }) => {
    const paginationOpts = { cursor: input.cursor, numItems: input.limit };
    const result = await ctx.table('user').paginate(paginationOpts);

    const enrichedPage = await Promise.all(
      result.page.map(async (user) => ({
        ...user.doc(),
        banExpiresAt: user?.banExpires,
        banReason: user?.banReason,
        email: user?.email || '',
        isBanned: user?.banned,
        role: user?.role || 'user',
      }))
    );

    return { ...result, page: enrichedPage };
  });

Dashboard Stats

convex/functions/admin.ts
export const getDashboardStats = authQuery
  .meta({ role: 'admin' })
  .output(z.object({
    recentUsers: z.array(z.object({
      _id: zid('user'),
      _creationTime: z.number(),
      image: z.string().nullish(),
      name: z.string().optional(),
    })),
    totalAdmins: z.number(),
    totalUsers: z.number(),
    userGrowth: z.array(z.object({
      count: z.number(),
      date: z.string(),
    })),
  }))
  .query(async ({ ctx }) => {
    const recentUsers = await ctx.table('user').order('desc').take(5);

    // Use aggregate for O(log n) user count
    const totalUsers = await aggregateUsers.count(ctx, {
      bounds: {},
      namespace: 'global',
    });

    // Calculate 7-day growth
    const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
    const usersLast7Days = await ctx
      .table('user')
      .filter((q) => q.gte(q.field('_creationTime'), sevenDaysAgo))
      .take(1000);

    // ... growth calculation logic

    return { recentUsers, totalAdmins, totalUsers, userGrowth };
  });

Client Usage

React Hooks

src/components/admin-check.tsx
import { authClient } from '@/lib/convex/auth-client';

function AdminPanel() {
  const { data: session } = authClient.useSession();
  const isAdmin = session?.user?.role === 'admin';

  if (!isAdmin) {
    return <div>Access denied</div>;
  }

  return (
    <div>
      {/* Admin controls */}
    </div>
  );
}

Ban/Unban Users

// Ban user
await authClient.admin.banUser({
  userId: 'user_123',
  banReason: 'Violation of terms',
  banExpiresIn: 60 * 60 * 24 * 7, // 7 days
});

// Unban user
await authClient.admin.unbanUser({
  userId: 'user_123',
});

Session Management

// List user sessions
const { data: sessions } = await authClient.admin.listUserSessions({
  userId: 'user_123',
});

// Revoke specific session
await authClient.admin.revokeUserSession({
  sessionToken: 'session_token',
});

// Revoke all sessions
await authClient.admin.revokeUserSessions({
  userId: 'user_123',
});

Impersonation

// Start impersonating user
await authClient.admin.impersonateUser({
  userId: 'user_123',
});

// Stop impersonating
await authClient.admin.stopImpersonating();

User Management

// Create user
await authClient.admin.createUser({
  email: 'user@example.com',
  password: 'password',
  name: 'John Doe',
  role: 'user',
});

// List users (with filtering/sorting/pagination)
const { users, total } = await authClient.admin.listUsers({
  searchValue: 'john',
  searchField: 'name',
  limit: 20,
  offset: 0,
  sortBy: 'createdAt',
  sortDirection: 'desc',
});

// Set role
await authClient.admin.setRole({ userId, role: 'admin' });

// Set password
await authClient.admin.setUserPassword({ userId, newPassword });

// Update user
await authClient.admin.updateUser({ userId, data: { name: 'New Name' } });

// Delete user
await authClient.admin.removeUser({ userId });

Permission Checking

// Check if current user has permission (server call)
const { success } = await authClient.admin.hasPermission({
  permissions: { project: ['create', 'update'] },
});

// Check role permission (client-side, no server call)
const canDelete = authClient.admin.checkRolePermission({
  role: 'admin',
  permissions: { project: ['delete'] },
});

API Reference

OperationMethodAdmin Required
Create userauthClient.admin.createUserYes
List usersauthClient.admin.listUsersYes
Set roleauthClient.admin.setRoleYes
Set passwordauthClient.admin.setUserPasswordYes
Update userauthClient.admin.updateUserYes
Ban userauthClient.admin.banUserYes
Unban userauthClient.admin.unbanUserYes
List sessionsauthClient.admin.listUserSessionsYes
Revoke sessionauthClient.admin.revokeUserSessionYes
Revoke all sessionsauthClient.admin.revokeUserSessionsYes
ImpersonateauthClient.admin.impersonateUserYes
Stop impersonatingauthClient.admin.stopImpersonatingYes
Remove userauthClient.admin.removeUserYes
Check permissionauthClient.admin.hasPermissionNo
Check role permissionauthClient.admin.checkRolePermissionNo

Use Convex functions for custom admin operations. Use Better Auth client API for standard operations like user management, banning, and session management.

Next Steps

On this page