--- name: role-based-access-control description: 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. --- # Role-Based Access Control (RBAC) for tRPC + React Apps Implement secure role-based access control in full-stack TypeScript applications using tRPC procedures and React components. ## When to Use This Skill - Adding admin-only or manager-only features to existing applications - Restricting specific tRPC endpoints to authorized users - Implementing multi-tier permission systems (admin, manager, user) - Hiding UI elements based on user roles - Building dashboards or tools that require different access levels ## Prerequisites - tRPC backend with `protectedProcedure` already implemented - User authentication system in place (OAuth, JWT, or similar) - User table with `role` field in database schema - React frontend with auth context or hook (e.g., `useAuth()`) ## Implementation Steps ### Step 1: Ensure Database Schema Has Role Field Verify the `user` table includes a `role` field: ```typescript // 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. ### Step 2: Create Role-Specific Procedures Add custom procedures for each role level in your tRPC router file: ```typescript // 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:** - Build on top of `protectedProcedure` (assumes user is already authenticated) - Use `FORBIDDEN` error code (403) for permission denials - Provide clear error messages for debugging ### Step 3: Apply Role Procedures to Endpoints Replace `protectedProcedure` with role-specific procedures for restricted endpoints: ```typescript 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(); }), }); ``` ### Step 4: Add Frontend Role Checks Protect React components and UI elements based on user role: ```typescript // 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 (
Authentication Required Please log in to access this page.
); } // Check authorization if (user.role !== 'admin') { return (
Access Denied You do not have permission to access this page. This area is restricted to administrators only.
); } // Render admin content return (

Admin Dashboard

{/* Admin-only content */}
); } ``` ### Step 5: Hide Navigation Links for Unauthorized Users Conditionally render navigation links based on user role: ```typescript // 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 ( ); } ``` **Key points:** - Use optional chaining (`user?.role`) to handle loading states - Hide links entirely rather than showing disabled buttons - Keep role checks consistent between frontend and backend ## Testing Checklist - [ ] Verify admin users can access admin-only endpoints - [ ] Verify regular users receive 403 FORBIDDEN errors when accessing admin endpoints - [ ] Verify navigation links are hidden for unauthorized users - [ ] Verify protected pages show access denied messages for non-admin users - [ ] Test with unauthenticated users (should see login prompts) - [ ] Test role promotion (change user role in database, verify new permissions) ## Common Patterns ### Multiple Role Levels For applications with more than two roles (e.g., admin, manager, moderator, user): ```typescript 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 ``` ### Resource-Level Permissions For fine-grained permissions (e.g., "user can only edit their own posts"): ```typescript 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 } }); }); ``` ### Reusable Role Check Hook Create a custom hook for consistent role checks across components: ```typescript // 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
Admin content
; } ``` ## Security Best Practices 1. **Always validate on the backend** - Frontend checks are for UX only; backend procedures enforce security 2. **Use specific error codes** - `FORBIDDEN` (403) for permission issues, `UNAUTHORIZED` (401) for authentication issues 3. **Fail closed** - Default to denying access unless explicitly granted 4. **Log permission denials** - Track unauthorized access attempts for security monitoring 5. **Avoid role checks in database queries** - Use procedures to centralize authorization logic 6. **Test with different roles** - Verify each role level has correct access ## Troubleshooting **Error: "Cannot read property 'role' of undefined"** - User object is not available in context - Check that `protectedProcedure` is properly configured - Verify authentication middleware is running before role checks **Frontend shows admin links but backend denies access** - Role check logic differs between frontend and backend - Ensure consistent role field names and values - Check for typos in role strings ('admin' vs 'Admin') **User role not updating after database change** - Session/JWT token still contains old role - Implement token refresh or require re-login after role changes - Consider adding role version field to detect stale tokens