kitcn
Version:
kitcn - React Query integration and CLI tools for Convex
447 lines (362 loc) • 12.4 kB
Markdown
# Auth Admin Plugin
Role-based admin features: middleware, user management, banning, impersonation, custom permissions.
Prerequisites: `setup/auth.md`, `setup/server.md`.
See [Better Auth Admin Plugin](https://www.better-auth.com/docs/plugins/admin) for full API reference.
## Server Config
```ts
// convex/functions/auth.ts
import { admin } from 'better-auth/plugins';
import { defineAuth } from './generated/auth';
export default defineAuth((ctx) => ({
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',
}),
],
}));
```
### Admin Assignment via Environment
```bash
# convex/.env
ADMIN=admin@domain.test,ops@domain.test
```
```ts
// convex/functions/auth.ts
import { defineAuth } from './generated/auth';
export default defineAuth((ctx) => ({
triggers: {
user: {
create: {
before: async (data, triggerCtx) => {
const env = getEnv();
const adminEmails = env.ADMIN;
const role =
data.role !== 'admin' && adminEmails?.includes(data.email)
? 'admin'
: data.role;
return { data: { ...data, role } };
},
},
},
},
}));
```
## Client Config
```ts
// src/lib/convex/auth-client.ts
import { adminClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({
plugins: [
adminClient(),
// ... other plugins
],
});
```
## Schema
```ts
// convex/functions/schema.ts
import { boolean, convexTable, defineSchema, integer, text } from 'kitcn/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 during impersonation
});
export const tables = { user, session };
export default defineSchema(tables, { strict: false });
```
## Access Control
### Role Middleware
```ts
// convex/lib/crpc.ts
const c = initCRPC
.meta<{ auth?: 'optional' | 'required'; role?: 'admin' }>()
.create();
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(ratelimit.middleware());
```
### Role Guard Helper
```ts
// convex/lib/auth/role-guard.ts
import { CRPCError } from 'kitcn/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
```ts
// 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:
```ts
// Server
admin({ ac, roles: { admin, user } })
// Client (src/lib/convex/auth-client.ts)
adminClient({ ac, roles: { admin, user } })
```
## Admin Functions
### Check Admin Status
```ts
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
```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
```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)
```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 result = await ctx.orm.query.user.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
```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 recentUsers = toRows(await ctx.orm.query.user.findMany({
limit: 5, orderBy: { createdAt: 'desc' },
columns: { id: true, createdAt: true, image: true, name: true },
}));
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((u) => {
const t = u.createdAt.getTime();
return t >= startOfDay && t <= 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((u) => u.role === 'admin').length;
const totalUsers = await ctx.orm.query.user.count();
const totalAdmins = Math.round((adminCount / sampleUsers.length) * totalUsers);
return { recentUsers, totalAdmins, totalUsers, userGrowth };
});
```
## Client Usage
### Check Admin Status
```ts
const { data: session } = authClient.useSession();
const isAdmin = session?.user?.role === 'admin';
```
### Ban/Unban Users
```ts
await authClient.admin.banUser({
userId: 'user_123',
banReason: 'Violation of terms',
banExpiresIn: 60 * 60 * 24 * 7, // 7 days
});
await authClient.admin.unbanUser({ userId: 'user_123' });
```
### Session Management
```ts
const { data: sessions } = await authClient.admin.listUserSessions({ userId: 'user_123' });
await authClient.admin.revokeUserSession({ sessionToken: 'session_token' });
await authClient.admin.revokeUserSessions({ userId: 'user_123' }); // All sessions
```
### Impersonation
```ts
await authClient.admin.impersonateUser({ userId: 'user_123' });
await authClient.admin.stopImpersonating();
```
### User Management
```ts
// Create user
await authClient.admin.createUser({
email: 'user@domain.test', 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
```ts
// Server call
const { success } = await authClient.admin.hasPermission({
permissions: { project: ['create', 'update'] },
});
// 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.