Skip to Content
Iris Saas Kit documentation is under construction.
Developer ToolsOverview

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