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:
| Feature | Description |
|---|---|
| Role-based access | Admin/user roles with middleware checking |
| User management | Create, update, delete users |
| Banning | Ban/unban users with expiration |
| Session management | List, revoke user sessions |
| Impersonation | Admin can impersonate users |
| Custom permissions | Granular 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:
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:
ADMIN=admin@example.com,other@example.comAssign admin role on user creation:
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:
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:
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
}),
});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:
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
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:
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:
// 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
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
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
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)
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
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
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
| Operation | Method | Admin Required |
|---|---|---|
| Create user | authClient.admin.createUser | Yes |
| List users | authClient.admin.listUsers | Yes |
| Set role | authClient.admin.setRole | Yes |
| Set password | authClient.admin.setUserPassword | Yes |
| Update user | authClient.admin.updateUser | Yes |
| Ban user | authClient.admin.banUser | Yes |
| Unban user | authClient.admin.unbanUser | Yes |
| List sessions | authClient.admin.listUserSessions | Yes |
| Revoke session | authClient.admin.revokeUserSession | Yes |
| Revoke all sessions | authClient.admin.revokeUserSessions | Yes |
| Impersonate | authClient.admin.impersonateUser | Yes |
| Stop impersonating | authClient.admin.stopImpersonating | Yes |
| Remove user | authClient.admin.removeUser | Yes |
| Check permission | authClient.admin.hasPermission | No |
| Check role permission | authClient.admin.checkRolePermission | No |
Use Convex functions for custom admin operations. Use Better Auth client API for standard operations like user management, banning, and session management.