BETTER-CONVEX

Plugins

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';

const getAuthOptions = (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 { boolean, convexTable, defineSchema, integer, text } from 'better-convex/orm';

export const user = convexTable('user', {
  // ... existing fields
  role: text(), // 'admin' | 'user'
  banned: boolean(),
  banReason: text(),
  banExpires: integer(),
});

export const session = convexTable('session', {
  // ... existing fields
  impersonatedBy: text(), // Admin user ID
});

export const tables = { user, session };
export default defineSchema(tables, { strict: false });

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 the ORM (ctx.orm).

Check Admin Status

convex/functions/admin.ts
import { z } from 'zod';
import { authQuery } from '../lib/crpc';

export const checkUserAdminStatus = authQuery
  .meta({ role: 'admin' })
  .input(z.object({ userId: z.string() }))
  .output(
    z.object({
      isAdmin: z.boolean(),
      role: z.string().nullish(),
    })
  )
  .query(async ({ ctx, input }) => {
    const user = await ctx.orm.query.user.findFirstOrThrow({
      where: { id: input.userId },
    });

    return {
      isAdmin: user.role === 'admin',
      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: z.string(),
    })
  )
  .output(z.boolean())
  .mutation(async ({ ctx, input }) => {
    if (input.role === 'admin' && !ctx.user.isAdmin) {
      throw new CRPCError({
        code: 'FORBIDDEN',
        message: 'Only admin can promote users to admin',
      });
    }

    const targetUser = await ctx.orm.query.user.findFirstOrThrow({
      where: { id: input.userId },
    });

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

    await ctx.orm
      .update(userTable)
      .set({ role: input.role.toLowerCase() })
      .where(eq(userTable.id, targetUser.id));

    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: z.string().optional(),
    })
  )
  .mutation(async ({ ctx, input }) => {
    const user = await ctx.orm.query.user.findFirst({
      where: { email: input.email },
    });

    if (!user) {
      return { success: false };
    }

    await ctx.orm
      .update(userTable)
      .set({ role: input.role.toLowerCase() })
      .where(eq(userTable.id, user.id));

    return { success: true, userId: user.id };
  });

List All Users (Paginated)

convex/functions/admin.ts
const UserListItemSchema = z.object({
  id: z.string(),
  createdAt: z.date(),
  name: z.string().optional(),
  email: z.string(),
  image: z.string().nullish(),
  role: z.string(),
  isBanned: z.boolean().nullish(),
  banReason: z.string().nullish(),
  banExpiresAt: z.date().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 query = ctx.orm.query.user;
    const result = await query.findMany({
      cursor: input.cursor,
      limit: input.limit,
    });

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

        if (input.search) {
          const searchLower = input.search.toLowerCase();
          if (
            !(
              userData.name?.toLowerCase().includes(searchLower) ||
              userData.email.toLowerCase().includes(searchLower)
            )
          ) {
            return null;
          }
        }

        if (input.role && input.role !== 'all' && userData.role !== input.role) {
          return null;
        }

        return userData;
      })
      .filter((row): row is NonNullable<typeof row> => row !== null);

    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: z.string(),
          createdAt: z.date(),
          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 toRows = <TRow>(result: TRow[] | { page: TRow[] }): TRow[] =>
      Array.isArray(result) ? result : result.page;

    const recentUsersResult = await ctx.orm.query.user.findMany({
      limit: 5,
      orderBy: { createdAt: 'desc' },
      columns: { id: true, createdAt: true, image: true, name: true },
    });
    const recentUsers = toRows(recentUsersResult);

    const usersLast7Days = toRows(
      await ctx.orm.query.user.findMany({
        where: { createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) } },
        limit: 1000,
      })
    );

    const userGrowth: { count: number; date: string }[] = [];
    const now = Date.now();
    const oneDay = 24 * 60 * 60 * 1000;
    for (let i = 6; i >= 0; i--) {
      const date = new Date(now - i * oneDay);
      const startOfDay = new Date(date.setHours(0, 0, 0, 0)).getTime();
      const endOfDay = new Date(date.setHours(23, 59, 59, 999)).getTime();

      userGrowth.push({
        count: usersLast7Days.filter((user) => {
          const createdAt = user.createdAt.getTime();
          return createdAt >= startOfDay && createdAt <= endOfDay;
        }).length,
        date: new Date(startOfDay).toISOString().split('T')[0],
      });
    }

    const sampleUsers = toRows(await ctx.orm.query.user.findMany({ limit: 100 }));
    const adminCount = sampleUsers.filter((user) => user.role === 'admin').length;
    const totalUsers = await aggregateUsers.count(ctx, {
      bounds: {},
      namespace: 'global',
    });
    const totalAdmins = Math.round((adminCount / sampleUsers.length) * totalUsers);

    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