Role-based access control (RBAC) is critical for multi-user applications. Here is how to implement it properly.
Step 1: Define Permissions
// lib/permissions.ts
export const PERMISSIONS = {
// Posts
"posts:read": "View posts",
"posts:create": "Create posts",
"posts:update": "Edit posts",
"posts:delete": "Delete posts",
"posts:publish": "Publish posts",
// Users
"users:read": "View users",
"users:create": "Invite users",
"users:update": "Edit users",
"users:delete": "Remove users",
"users:manage-roles": "Manage user roles",
// Settings
"settings:read": "View settings",
"settings:update": "Modify settings",
// Billing
"billing:read": "View billing",
"billing:manage": "Manage billing",
// Analytics
"analytics:read": "View analytics",
"analytics:export": "Export analytics data",
} as const;
export type Permission = keyof typeof PERMISSIONS;
export interface Role {
id: string;
name: string;
description: string;
permissions: Permission[];
isSystem: boolean; // System roles cannot be deleted
}
// Default role definitions
export const DEFAULT_ROLES: Omit<Role, "id">[] = [
{
name: "Owner",
description: "Full access to everything",
permissions: Object.keys(PERMISSIONS) as Permission[],
isSystem: true,
},
{
name: "Admin",
description: "Manage content, users, and settings",
permissions: [
"posts:read", "posts:create", "posts:update", "posts:delete", "posts:publish",
"users:read", "users:create", "users:update",
"settings:read", "settings:update",
"analytics:read", "analytics:export",
],
isSystem: true,
},
{
name: "Editor",
description: "Create and edit content",
permissions: [
"posts:read", "posts:create", "posts:update", "posts:publish",
"analytics:read",
],
isSystem: true,
},
{
name: "Viewer",
description: "Read-only access",
permissions: ["posts:read", "analytics:read"],
isSystem: true,
},
];
Step 2: Permission Checker
// lib/auth/check-permission.ts
import type { Permission } from "@/lib/permissions";
interface UserWithRole {
id: string;
role: {
permissions: Permission[];
};
}
export function hasPermission(user: UserWithRole, permission: Permission): boolean {
return user.role.permissions.includes(permission);
}
export function hasAnyPermission(user: UserWithRole, permissions: Permission[]): boolean {
return permissions.some((p) => user.role.permissions.includes(p));
}
export function hasAllPermissions(user: UserWithRole, permissions: Permission[]): boolean {
return permissions.every((p) => user.role.permissions.includes(p));
}
// Resource-level permission check
export function canAccessResource(
user: UserWithRole & { id: string },
resource: { authorId: string },
permission: Permission
): boolean {
// Owner of resource can always access it
if (user.id === resource.authorId) return true;
return hasPermission(user, permission);
}
Step 3: Middleware Guard
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import type { Permission } from "@/lib/permissions";
// Route to permission mapping
const ROUTE_PERMISSIONS: Record<string, Permission[]> = {
"/dashboard/posts/new": ["posts:create"],
"/dashboard/posts/[id]/edit": ["posts:update"],
"/dashboard/users": ["users:read"],
"/dashboard/users/invite": ["users:create"],
"/dashboard/settings": ["settings:read"],
"/dashboard/billing": ["billing:read"],
"/dashboard/analytics": ["analytics:read"],
};
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check if route requires permissions
const matchedRoute = Object.entries(ROUTE_PERMISSIONS).find(([pattern]) => {
const regex = new RegExp(
"^" + pattern.replace(/\[.*?\]/g, "[^/]+") + "$"
);
return regex.test(pathname);
});
if (matchedRoute) {
const [, requiredPermissions] = matchedRoute;
// Get user session and check permissions
// In production, decode session token here
const userPermissions = request.cookies.get("user_permissions")?.value;
if (!userPermissions) {
return NextResponse.redirect(new URL("/login", request.url));
}
const permissions: Permission[] = JSON.parse(userPermissions);
const hasAccess = requiredPermissions.every((p) => permissions.includes(p));
if (!hasAccess) {
return NextResponse.redirect(new URL("/dashboard/unauthorized", request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: "/dashboard/:path*",
};
Step 4: Permission Gate Component
"use client";
import type { Permission } from "@/lib/permissions";
import { useUser } from "@/hooks/useUser";
interface PermissionGateProps {
permission: Permission | Permission[];
requireAll?: boolean;
fallback?: React.ReactNode;
children: React.ReactNode;
}
export function PermissionGate({
permission,
requireAll = false,
fallback = null,
children,
}: PermissionGateProps) {
const { user } = useUser();
if (!user) return fallback;
const permissions = Array.isArray(permission) ? permission : [permission];
const userPerms = user.role.permissions;
const hasAccess = requireAll
? permissions.every((p) => userPerms.includes(p))
: permissions.some((p) => userPerms.includes(p));
if (!hasAccess) return fallback;
return <>{children}</>;
}
Step 5: Role Management UI
"use client";
import { useState } from "react";
import { Check, Shield, ChevronDown, ChevronRight } from "lucide-react";
import { PERMISSIONS, type Permission, type Role } from "@/lib/permissions";
// Group permissions by category
function groupPermissions() {
const groups: Record<string, { key: Permission; label: string }[]> = {};
Object.entries(PERMISSIONS).forEach(([key, label]) => {
const [category] = key.split(":");
if (!groups[category]) groups[category] = [];
groups[category].push({ key: key as Permission, label });
});
return groups;
}
interface RoleEditorProps {
role: Role;
onSave: (role: Role) => void;
}
export function RoleEditor({ role, onSave }: RoleEditorProps) {
const [permissions, setPermissions] = useState<Permission[]>(role.permissions);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const groups = groupPermissions();
function togglePermission(perm: Permission) {
setPermissions((prev) =>
prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
);
}
function toggleGroup(group: string) {
const groupPerms = groups[group].map((p) => p.key);
const allSelected = groupPerms.every((p) => permissions.includes(p));
setPermissions((prev) =>
allSelected
? prev.filter((p) => !groupPerms.includes(p))
: [...new Set([...prev, ...groupPerms])]
);
}
function toggleExpand(group: string) {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(group)) next.delete(group);
else next.add(group);
return next;
});
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5" />
<h3 className="text-lg font-semibold">{role.name}</h3>
{role.isSystem && (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs dark:bg-gray-800">
System
</span>
)}
</div>
<div className="space-y-2">
{Object.entries(groups).map(([group, perms]) => {
const isExpanded = expandedGroups.has(group);
const selectedCount = perms.filter((p) => permissions.includes(p.key)).length;
const allSelected = selectedCount === perms.length;
return (
<div key={group} className="rounded-lg border dark:border-gray-700">
<button
onClick={() => toggleExpand(group)}
className="flex w-full items-center gap-3 px-4 py-3"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<span className="flex-1 text-left text-sm font-medium capitalize">
{group}
</span>
<span className="text-xs text-gray-400">
{selectedCount}/{perms.length}
</span>
<button
onClick={(e) => {
e.stopPropagation();
toggleGroup(group);
}}
className={`rounded px-2 py-0.5 text-xs ${
allSelected
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
: "bg-gray-100 text-gray-600 dark:bg-gray-800"
}`}
>
{allSelected ? "Deselect all" : "Select all"}
</button>
</button>
{isExpanded && (
<div className="border-t px-4 py-2 dark:border-gray-700">
{perms.map(({ key, label }) => (
<label
key={key}
className="flex cursor-pointer items-center gap-3 rounded-lg px-2 py-2 hover:bg-gray-50 dark:hover:bg-gray-800"
>
<div
className={`flex h-5 w-5 items-center justify-center rounded border ${
permissions.includes(key)
? "border-blue-600 bg-blue-600"
: "border-gray-300 dark:border-gray-600"
}`}
>
{permissions.includes(key) && (
<Check className="h-3 w-3 text-white" />
)}
</div>
<div>
<p className="text-sm">{label}</p>
<p className="text-xs text-gray-400">{key}</p>
</div>
</label>
))}
</div>
)}
</div>
);
})}
</div>
<button
onClick={() => onSave({ ...role, permissions })}
className="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Save Permissions
</button>
</div>
);
}
Step 6: Usage in Components
import { PermissionGate } from "@/components/PermissionGate";
export function PostActions({ postId }: { postId: string }) {
return (
<div className="flex gap-2">
<PermissionGate permission="posts:update">
<a href={`/dashboard/posts/${postId}/edit`} className="btn-secondary">
Edit
</a>
</PermissionGate>
<PermissionGate permission="posts:publish">
<button className="btn-primary">Publish</button>
</PermissionGate>
<PermissionGate permission="posts:delete">
<button className="btn-danger">Delete</button>
</PermissionGate>
</div>
);
}
Summary
- Define permissions as a typed constant for type safety
- Role definitions map to permission sets
- Server-side middleware enforces route access
- Client-side PermissionGate component hides unauthorized UI
Need Secure Applications?
We build applications with enterprise-grade access control and security. Contact us to discuss your project.