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';
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:
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 { 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:
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 the ORM (ctx.orm).
Check Admin Status
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
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
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)
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
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
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.