Implement role-based access control (RBAC) in web applications using tRPC and React. Use when adding admin/manager-only features, restricting endpoints by user role, or implementing permission systems in full-stack TypeScript applications.
Implement secure role-based access control in full-stack TypeScript applications using tRPC procedures and React components.
protectedProcedure already implementedrole field in database schemauseAuth())Verify the user table includes a role field:
// drizzle/schema.ts or similar
export const user = sqliteTable('user', {
id: integer('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
role: text('role', { enum: ['admin', 'user'] }).notNull().default('user'),
// ... other fields
});
If the role field doesn't exist, add it and run migrations.
Add custom procedures for each role level in your tRPC router file:
// server/routers.ts or similar
import { protectedProcedure, router } from "./_core/trpc";
import { TRPCError } from "@trpc/server";
// Admin-only procedure
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role !== 'admin') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only administrators can access this resource'
});
}
return next({ ctx });
});
// Manager or admin procedure (if you have multiple admin-like roles)
const managerProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role !== 'admin' && ctx.user.role !== 'manager') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only managers and administrators can access this resource'
});
}
return next({ ctx });
});
Key points:
protectedProcedure (assumes user is already authenticated)FORBIDDEN error code (403) for permission denialsReplace protectedProcedure with role-specific procedures for restricted endpoints:
export const myRouter = router({
// Public endpoint - anyone can access
getPublicData: publicProcedure.query(async () => {
return await getPublicData();
}),
// Protected endpoint - any authenticated user
getUserData: protectedProcedure.query(async ({ ctx }) => {
return await getUserData(ctx.user.id);
}),
// Admin-only endpoint
getAllUsers: adminProcedure.query(async () => {
return await getAllUsers();
}),
// Manager or admin endpoint
getTeamStats: managerProcedure.query(async () => {
return await getTeamStats();
}),
});
Protect React components and UI elements based on user role:
// pages/AdminDashboard.tsx
import { useAuth } from "@/_core/hooks/useAuth";
import { AlertCircle } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
export function AdminDashboard() {
const { user } = useAuth();
// Check authentication
if (!user) {
return (
<div className="container mx-auto py-8">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Authentication Required</AlertTitle>
<AlertDescription>
Please log in to access this page.
</AlertDescription>
</Alert>
</div>
);
}
// Check authorization
if (user.role !== 'admin') {
return (
<div className="container mx-auto py-8">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Access Denied</AlertTitle>
<AlertDescription>
You do not have permission to access this page. This area is restricted to administrators only.
</AlertDescription>
</Alert>
</div>
);
}
// Render admin content
return (
<div className="container mx-auto py-8">
<h1>Admin Dashboard</h1>
{/* Admin-only content */}
</div>
);
}
Conditionally render navigation links based on user role:
// pages/Home.tsx or Layout.tsx
import { trpc } from "@/lib/trpc";
import { Link } from "wouter";
import { Button } from "@/components/ui/button";
export function Navigation() {
const { data: user } = trpc.auth.me.useQuery();
return (
<nav className="flex items-center gap-4">
<Link href="/dashboard">
<Button variant="ghost">Dashboard</Button>
</Link>
<Link href="/profile">
<Button variant="ghost">Profile</Button>
</Link>
{user?.role === 'admin' && (
<Link href="/admin">
<Button variant="ghost">Admin</Button>
</Link>
)}
{(user?.role === 'admin' || user?.role === 'manager') && (
<Link href="/manager">
<Button variant="ghost">Manager</Button>
</Link>
)}
</nav>
);
}
Key points:
user?.role) to handle loading statesFor applications with more than two roles (e.g., admin, manager, moderator, user):
const roleHierarchy = {
admin: 4,
manager: 3,
moderator: 2,
user: 1,
};
const requireRole = (minRole: keyof typeof roleHierarchy) => {
return protectedProcedure.use(({ ctx, next }) => {
const userRoleLevel = roleHierarchy[ctx.user.role];
const requiredLevel = roleHierarchy[minRole];
if (userRoleLevel < requiredLevel) {
throw new TRPCError({
code: 'FORBIDDEN',
message: `This resource requires ${minRole} role or higher`
});
}
return next({ ctx });
});
};
// Usage
const managerProcedure = requireRole('manager'); // allows manager, admin
const moderatorProcedure = requireRole('moderator'); // allows moderator, manager, admin
For fine-grained permissions (e.g., "user can only edit their own posts"):
const canEditPost = protectedProcedure.use(async ({ ctx, next, input }) => {
const post = await getPostById(input.postId);
if (post.authorId !== ctx.user.id && ctx.user.role !== 'admin') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You can only edit your own posts'
});
}
return next({ ctx: { ...ctx, post } });
});
Create a custom hook for consistent role checks across components:
// hooks/useRequireRole.ts
import { useAuth } from "@/_core/hooks/useAuth";
import { useEffect } from "react";
import { useLocation } from "wouter";
export function useRequireRole(requiredRole: 'admin' | 'manager') {
const { user } = useAuth();
const [, setLocation] = useLocation();
useEffect(() => {
if (!user) {
setLocation('/login');
} else if (user.role !== requiredRole && user.role !== 'admin') {
setLocation('/access-denied');
}
}, [user, requiredRole, setLocation]);
return { user, isAuthorized: user?.role === requiredRole || user?.role === 'admin' };
}
// Usage in component
export function AdminDashboard() {
const { user, isAuthorized } = useRequireRole('admin');
if (!isAuthorized) return null; // Will redirect
return <div>Admin content</div>;
}
FORBIDDEN (403) for permission issues, UNAUTHORIZED (401) for authentication issuesError: "Cannot read property 'role' of undefined"
protectedProcedure is properly configuredFrontend shows admin links but backend denies access
User role not updating after database change
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer