User Roles
System-wide role-based access control
Overview
User roles provide system-wide authorization that determines what actions users can perform across the entire application. Unlike organization permissions (which are scoped to specific organizations), roles apply globally.
UserRole Enum
Roles are defined in src/db/enums.ts:
export enum UserRole {
ADMIN = "admin",
USER = "user"
}Role Definitions
| Role | Description | Access Level |
|---|---|---|
ADMIN | System administrators | Full access to all features and admin panel |
USER | Standard users | Access to own resources and organizations |
You can extend this enum to add custom roles like MODERATOR, PREMIUM, or SUPPORT.
Server-Side Role Checks
Layout Guards
Use requireRole() in Server Components to protect entire routes:
// app/[locale]/admin/layout.tsx
import { requireRole } from "@/lib/auth/layout-guards";
import { UserRole } from "@/db/enums";
export default async function AdminLayout({
children
}: {
children: React.ReactNode
}) {
// Redirects if not authenticated or not an admin
const auth = await requireRole(UserRole.ADMIN);
return (
<div>
<h1>Welcome, Admin {auth.user.email}</h1>
{children}
</div>
);
}Multiple Roles
Allow access to users with any of the specified roles:
// Allow both admins and premium users
const auth = await requireRole([UserRole.ADMIN, UserRole.PREMIUM]);With Onboarding Check
Ensure user has completed onboarding:
const auth = await requireRole(UserRole.USER, {
requireOnboardingCompleted: true,
preserveCallback: true // Redirect back after onboarding
});Redirect Behavior
requireRole() automatically redirects based on the situation:
| Scenario | Redirect |
|---|---|
| Not authenticated | Login page with callback |
| Wrong role | Default page for user's actual role |
| Onboarding incomplete | Onboarding page with callback |
// Implementation from src/lib/auth/layout-guards.ts
export async function requireRole(
allowedRoles: UserRole | UserRole[],
options?: {
preserveCallback?: boolean;
requireOnboardingCompleted?: boolean;
}
): Promise<NonNullable<AuthRetrieval>>tRPC Role-Based Procedures
Role Procedure
Create tRPC procedures that require specific roles:
import { createTRPCRouter, roleProcedure } from "@/trpc/procedures/trpc";
import { UserRole } from "@/db/enums";
import { z } from "zod";
export const adminRouter = createTRPCRouter({
// Only admins can list all users
listAllUsers: roleProcedure(UserRole.ADMIN)
.query(async ({ ctx }) => {
// ctx.user is guaranteed to exist and be an admin
return await ctx.db.users.listDocuments({});
}),
// Allow multiple roles
moderateContent: roleProcedure([UserRole.ADMIN, UserRole.MODERATOR])
.input(z.object({ contentId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Either admin or moderator can execute this
return await ctx.db.content.moderate(input.contentId);
}),
});Auth Procedure
For procedures that require any authenticated user (regardless of role):
import { authProcedure } from "@/trpc/procedures/trpc";
// Any authenticated user can access
getUserProfile: authProcedure
.query(async ({ ctx }) => {
// ctx.user and ctx.session are guaranteed to exist
return await ctx.db.users.getDocument({ id: ctx.user.id });
})Public Procedure
For procedures that don't require authentication:
import { publicProcedure } from "@/trpc/procedures/trpc";
// Anyone can access
listPublicPosts: publicProcedure
.query(async ({ ctx }) => {
// ctx.user might be null
return await ctx.db.posts.listPublicPosts();
})Client-Side Role Checks
Using useAuth Hook
"use client";
import { useAuth } from "@/lib/auth/client";
import { UserRole } from "@/db/enums";
export function UserDashboard() {
const { user, hasRole, isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <LoginPrompt />;
}
return (
<div>
<h1>Dashboard</h1>
{/* Show admin panel only to admins */}
{hasRole(UserRole.ADMIN) && (
<AdminPanel />
)}
{/* Show different content based on role */}
{user.role === UserRole.ADMIN ? (
<AdminStats />
) : (
<UserStats />
)}
</div>
);
}Conditional Rendering
import { useAuth } from "@/lib/auth/client";
import { UserRole } from "@/db/enums";
export function Navigation() {
const { hasRole } = useAuth();
return (
<nav>
<Link href="/dashboard">Dashboard</Link>
{/* Admin-only link */}
{hasRole(UserRole.ADMIN) && (
<Link href="/admin">Admin Panel</Link>
)}
</nav>
);
}Client-Side Checks Are For UX Only - Always enforce authorization server-side. Client checks only hide UI elements but don't prevent API access.
Implementation Details
Role Procedure Implementation
From src/trpc/procedures/trpc.ts:
export const roleProcedure = (role: UserRole | UserRole[]) =>
authProcedure.use(async ({ ctx, next, meta, input }) => {
const allowedRoles = Array.isArray(role) ? role : [role];
if (!ctx.user?.role || !allowedRoles.includes(ctx.user.role as UserRole)) {
throw new TRPCError({
code: "FORBIDDEN",
message: ctx.t("statusCodes.403")
});
}
return next({ ctx, input });
});Better-Auth Admin Plugin
Roles are integrated with Better-Auth's admin plugin:
// src/lib/auth/server.ts
import { admin } from "better-auth/plugins";
adminPlugin({
adminRoles: [UserRole.ADMIN],
defaultRole: UserRole.USER,
impersonationSessionDuration: 60 * 60 * 24, // 1 day
})Best Practices
1. Use Enums, Not Strings
await requireRole(UserRole.ADMIN);await requireRole("admin"); // Type-unsafe2. Protect Routes at Layout Level
// app/[locale]/admin/layout.tsx
// Protects all pages under /admin
await requireRole(UserRole.ADMIN);3. Use Specific Procedures
Choose the right procedure for your use case:
// ✅ Specific role required
deleteUser: roleProcedure(UserRole.ADMIN)
// ✅ Any authenticated user
updateProfile: authProcedure
// ✅ Public access
getPublicStats: publicProcedure4. Check Roles Early
export default async function Page() {
// Check at the top of the component
const auth = await requireRole(UserRole.ADMIN);
// Now safely use auth.user
const data = await fetchAdminData(auth.user.id);
return <AdminView data={data} />;
}5. Provide Clear Error Messages
if (!hasRole(UserRole.ADMIN)) {
return (
<Alert variant="destructive">
<AlertTitle>Access Denied</AlertTitle>
<AlertDescription>
You need administrator privileges to access this page.
</AlertDescription>
</Alert>
);
}Adding Custom Roles
1. Update UserRole Enum
// src/db/enums.ts
export enum UserRole {
ADMIN = "admin",
MODERATOR = "moderator", // New role
PREMIUM = "premium", // New role
USER = "user"
}2. Update Better-Auth Config
// src/lib/auth/server.ts
adminPlugin({
adminRoles: [UserRole.ADMIN, UserRole.MODERATOR],
defaultRole: UserRole.USER,
})3. Update Database Schema
Run migration to add the new role to the database:
npm run db:push4. Use in Your Application
// Moderators can manage content
manageContent: roleProcedure([UserRole.ADMIN, UserRole.MODERATOR])
.mutation(async ({ ctx, input }) => {
// Implementation
})Examples
Admin-Only Page
// app/[locale]/admin/users/page.tsx
import { requireRole } from "@/lib/auth/layout-guards";
import { UserRole } from "@/db/enums";
export default async function AdminUsersPage() {
await requireRole(UserRole.ADMIN);
return <UserManagement />;
}Role-Based tRPC Router
// src/trpc/procedures/routers/admin.ts
import { createTRPCRouter, roleProcedure } from "@/trpc/procedures/trpc";
import { UserRole } from "@/db/enums";
import { z } from "zod";
export const adminRouter = createTRPCRouter({
listUsers: roleProcedure(UserRole.ADMIN)
.input(z.object({
page: z.number().default(1),
limit: z.number().default(50),
}))
.query(async ({ ctx, input }) => {
return await ctx.db.users.listDocuments({
limit: input.limit,
offset: (input.page - 1) * input.limit,
});
}),
deleteUser: roleProcedure(UserRole.ADMIN)
.input(z.object({ userId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db.users.deleteDocument({ id: input.userId });
return {
success: true,
message: ctx.t("admin.userDeleted"),
};
}),
});Conditional UI Component
"use client";
import { useAuth } from "@/lib/auth/client";
import { UserRole } from "@/db/enums";
import { Button } from "@/components/ui/button";
export function ContentActions({ contentId }: { contentId: string }) {
const { user, hasRole } = useAuth();
return (
<div className="flex gap-2">
{/* All users can edit their own content */}
<Button>Edit</Button>
{/* Only admins can delete */}
{hasRole(UserRole.ADMIN) && (
<Button variant="destructive">Delete</Button>
)}
</div>
);
}See Also
- Authentication - User login and session management
- Permissions - Organization-level permissions
- tRPC Procedures - API endpoint authorization