Developer Tools
Built-in admin interfaces, debugging tools, and development utilities to manage your SaaS application.
Admin Dashboard
Developer Role Access
Only users with the developer role can access admin features:
// Middleware protection for admin routes
export default function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
if (pathname.startsWith('/admin')) {
// Check if user has developer role
// Redirect if not authorized
}
}
export const config = {
matcher: ['/admin/:path*'],
};Admin Layout
// app/admin/layout.tsx
import { AdminNavigation } from "@/components/admin/navigation";
import { AdminHeader } from "@/components/admin/header";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen">
<AdminNavigation />
<div className="flex-1 flex flex-col">
<AdminHeader />
<main className="flex-1 overflow-auto p-6">
{children}
</main>
</div>
</div>
);
}User Management
User Browser
View and manage all users in the system:
// convex/admin/users.ts
export const getAllUsers = query({
args: {
limit: v.optional(v.number()),
cursor: v.optional(v.string()),
},
handler: async (ctx, args) => {
// Verify admin access
const currentUser = await getCurrentUser(ctx);
if (currentUser?.role !== 'developer') {
throw new Error('Unauthorized');
}
const users = await ctx.db
.query('users')
.order('desc')
.paginate({
numItems: args.limit || 50,
cursor: args.cursor,
});
return users;
},
});
export const updateUserRole = mutation({
args: {
userId: v.id('users'),
role: v.union(
v.literal('user'),
v.literal('admin'),
v.literal('developer')
),
},
handler: async (ctx, args) => {
// Verify admin access
const currentUser = await getCurrentUser(ctx);
if (currentUser?.role !== 'developer') {
throw new Error('Unauthorized');
}
await ctx.db.patch(args.userId, {
role: args.role,
updatedAt: Date.now(),
});
},
});User Management Interface
// components/admin/user-management.tsx
import { useQuery, useMutation } from "convex/react";
import { api } from "convex/_generated/api";
import { DataTable } from "@/components/ui/data-table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { MoreHorizontal } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function UserManagement() {
const users = useQuery(api.admin.users.getAllUsers);
const updateUserRole = useMutation(api.admin.users.updateUserRole);
const columns = [
{
accessorKey: "email",
header: "Email",
},
{
accessorKey: "name",
header: "Name",
},
{
accessorKey: "role",
header: "Role",
cell: ({ row }) => {
const role = row.getValue("role");
return (
<Badge variant={role === "developer" ? "default" : "secondary"}>
{role}
</Badge>
);
},
},
{
accessorKey: "createdAt",
header: "Created",
cell: ({ row }) => {
return new Date(row.getValue("createdAt")).toLocaleDateString();
},
},
{
id: "actions",
cell: ({ row }) => {
const user = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => updateUserRole({ userId: user._id, role: "admin" })}
>
Make Admin
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => updateUserRole({ userId: user._id, role: "user" })}
>
Make User
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
return (
<div className="space-y-4">
<h2 className="text-2xl font-bold">User Management</h2>
<DataTable columns={columns} data={users?.page || []} />
</div>
);
}Workspace Administration
Workspace Browser
View all workspaces with detailed information:
// convex/admin/workspaces.ts
export const getAllWorkspaces = query({
args: {},
handler: async ctx => {
// Verify admin access
const currentUser = await getCurrentUser(ctx);
if (currentUser?.role !== 'developer') {
throw new Error('Unauthorized');
}
const workspaces = await ctx.db.query('workspaces').collect();
// Get additional data for each workspace
return Promise.all(
workspaces.map(async workspace => {
const memberCount = await ctx.db
.query('members')
.withIndex('by_workspace', q => q.eq('workspaceId', workspace._id))
.collect()
.then(members => members.length);
const subscription = await ctx.db
.query('subscriptions')
.withIndex('by_workspace', q => q.eq('workspaceId', workspace._id))
.first();
return {
...workspace,
memberCount,
planId: subscription?.planId || 'free',
subscriptionStatus: subscription?.status || 'none',
};
})
);
},
});Workspace Analytics
// components/admin/workspace-analytics.tsx
import { useQuery } from "convex/react";
import { api } from "convex/_generated/api";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export function WorkspaceAnalytics() {
const workspaces = useQuery(api.admin.workspaces.getAllWorkspaces);
const stats = workspaces?.reduce(
(acc, workspace) => ({
total: acc.total + 1,
totalMembers: acc.totalMembers + workspace.memberCount,
activeSubscriptions: acc.activeSubscriptions + (workspace.subscriptionStatus === "active" ? 1 : 0),
freeWorkspaces: acc.freeWorkspaces + (workspace.planId === "free" ? 1 : 0),
}),
{ total: 0, totalMembers: 0, activeSubscriptions: 0, freeWorkspaces: 0 }
) || { total: 0, totalMembers: 0, activeSubscriptions: 0, freeWorkspaces: 0 };
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Workspaces</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Members</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalMembers}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Subscriptions</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.activeSubscriptions}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Free Workspaces</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.freeWorkspaces}</div>
</CardContent>
</Card>
</div>
);
}System Monitoring
Health Checks
Monitor system health and performance:
// convex/admin/system.ts
export const getSystemHealth = query({
args: {},
handler: async ctx => {
// Verify admin access
const currentUser = await getCurrentUser(ctx);
if (currentUser?.role !== 'developer') {
throw new Error('Unauthorized');
}
// Check database health
const userCount = await ctx.db
.query('users')
.collect()
.then(users => users.length);
const workspaceCount = await ctx.db
.query('workspaces')
.collect()
.then(w => w.length);
// Check recent activity
const recentActivities = await ctx.db
.query('activities')
.order('desc')
.take(10);
// Check failed operations (you'd implement error tracking)
const errors = await ctx.db
.query('errors')
.filter(q => q.gt(q.field('createdAt'), Date.now() - 24 * 60 * 60 * 1000))
.collect();
return {
database: {
status: 'healthy',
userCount,
workspaceCount,
},
activity: {
recentCount: recentActivities.length,
lastActivity: recentActivities[0]?.createdAt || null,
},
errors: {
count24h: errors.length,
lastError: errors[0]?.createdAt || null,
},
};
},
});Error Tracking
// convex/admin/errors.ts
export const logError = mutation({
args: {
message: v.string(),
stack: v.optional(v.string()),
userId: v.optional(v.id('users')),
workspaceId: v.optional(v.id('workspaces')),
metadata: v.optional(v.any()),
},
handler: async (ctx, args) => {
await ctx.db.insert('errors', {
...args,
resolved: false,
createdAt: Date.now(),
});
},
});
export const getRecentErrors = query({
args: {
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
// Verify admin access
const currentUser = await getCurrentUser(ctx);
if (currentUser?.role !== 'developer') {
throw new Error('Unauthorized');
}
return await ctx.db
.query('errors')
.order('desc')
.take(args.limit || 50);
},
});Feature Flags
Feature Flag Management
Control feature rollout with admin interface:
// convex/admin/features.ts
export const getFeatureFlags = query({
args: {},
handler: async ctx => {
return await ctx.db.query('feature_flags').collect();
},
});
export const updateFeatureFlag = mutation({
args: {
flagId: v.id('feature_flags'),
enabled: v.boolean(),
rolloutPercentage: v.optional(v.number()),
},
handler: async (ctx, args) => {
// Verify admin access
const currentUser = await getCurrentUser(ctx);
if (currentUser?.role !== 'developer') {
throw new Error('Unauthorized');
}
await ctx.db.patch(args.flagId, {
enabled: args.enabled,
rolloutPercentage: args.rolloutPercentage,
updatedAt: Date.now(),
});
},
});Feature Flag Component
// components/admin/feature-flags.tsx
import { useQuery, useMutation } from "convex/react";
import { api } from "convex/_generated/api";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export function FeatureFlags() {
const flags = useQuery(api.admin.features.getFeatureFlags);
const updateFlag = useMutation(api.admin.features.updateFeatureFlag);
return (
<div className="space-y-4">
<h2 className="text-2xl font-bold">Feature Flags</h2>
<div className="grid gap-4">
{flags?.map((flag) => (
<Card key={flag._id}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>{flag.name}</CardTitle>
<CardDescription>{flag.description}</CardDescription>
</div>
<Switch
checked={flag.enabled}
onCheckedChange={(enabled) =>
updateFlag({ flagId: flag._id, enabled })
}
/>
</div>
</CardHeader>
{flag.rolloutPercentage !== undefined && (
<CardContent>
<div className="text-sm text-muted-foreground">
Rollout: {flag.rolloutPercentage}%
</div>
</CardContent>
)}
</Card>
))}
</div>
</div>
);
}Debugging Tools
Query Inspector
Debug Convex queries and mutations:
// components/admin/query-inspector.tsx
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export function QueryInspector() {
const [query, setQuery] = useState("");
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(false);
const executeQuery = async () => {
setLoading(true);
try {
// This would execute arbitrary queries in development only
const response = await fetch("/api/admin/execute-query", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query }),
});
const data = await response.json();
setResult(data);
} catch (error) {
setResult({ error: error.message });
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Query Inspector</CardTitle>
<CardDescription>
Execute Convex queries for debugging (development only)
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
placeholder="Enter Convex query..."
value={query}
onChange={(e) => setQuery(e.target.value)}
rows={4}
/>
<Button onClick={executeQuery} disabled={loading}>
{loading ? "Executing..." : "Execute Query"}
</Button>
{result && (
<div className="bg-gray-100 p-4 rounded-md">
<pre className="text-sm overflow-auto">
{JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</CardContent>
</Card>
);
}Performance Monitor
Monitor query performance and identify bottlenecks:
// convex/admin/performance.ts
export const getPerformanceMetrics = query({
args: {},
handler: async ctx => {
// This would collect performance metrics
// In a real implementation, you'd track query times, etc.
return {
averageQueryTime: 45, // ms
slowQueries: [
{ name: 'getAllUsers', avgTime: 120 },
{ name: 'getWorkspaceMembers', avgTime: 89 },
],
totalQueries24h: 15420,
errorRate: 0.02, // 2%
};
},
});Development Utilities
Database Seeding
Seed development data:
// convex/admin/seed.ts
export const seedDevelopmentData = mutation({
args: {},
handler: async ctx => {
// Verify admin access and development environment
const currentUser = await getCurrentUser(ctx);
if (currentUser?.role !== 'developer') {
throw new Error('Unauthorized');
}
if (process.env.NODE_ENV === 'production') {
throw new Error('Cannot seed in production');
}
// Create test workspaces
const workspace1 = await ctx.db.insert('workspaces', {
name: 'Test Workspace 1',
description: 'Development test workspace',
slug: 'test-workspace-1',
ownerId: currentUser._id,
createdAt: Date.now(),
updatedAt: Date.now(),
});
// Create test members
await ctx.db.insert('members', {
workspaceId: workspace1,
userId: currentUser._id,
role: 'admin',
joinedAt: Date.now(),
});
return { message: 'Development data seeded successfully' };
},
});Export/Import Tools
Export and import data for backups or migrations:
// convex/admin/export.ts
export const exportWorkspaceData = query({
args: { workspaceId: v.id('workspaces') },
handler: async (ctx, args) => {
// Verify admin access
const currentUser = await getCurrentUser(ctx);
if (currentUser?.role !== 'developer') {
throw new Error('Unauthorized');
}
const workspace = await ctx.db.get(args.workspaceId);
if (!workspace) throw new Error('Workspace not found');
const members = await ctx.db
.query('members')
.withIndex('by_workspace', q => q.eq('workspaceId', args.workspaceId))
.collect();
const activities = await ctx.db
.query('activities')
.withIndex('by_workspace', q => q.eq('workspaceId', args.workspaceId))
.collect();
return {
workspace,
members,
activities,
exportedAt: Date.now(),
};
},
});Security Tools
Audit Logs
Track sensitive operations:
// convex/admin/audit.ts
export const logAuditEvent = mutation({
args: {
action: v.string(),
targetType: v.string(),
targetId: v.string(),
details: v.optional(v.any()),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return;
const user = await getCurrentUser(ctx, identity);
if (!user) return;
await ctx.db.insert('audit_logs', {
...args,
userId: user._id,
userEmail: user.email,
ipAddress: ctx.request?.headers?.['x-forwarded-for'] || 'unknown',
userAgent: ctx.request?.headers?.['user-agent'] || 'unknown',
createdAt: Date.now(),
});
},
});Next: Learn about Deployment and production setup.
Last updated on