Roles
User roles and permissions
Overview
This template includes a role-based access control system using Better-Auth. Roles control access to routes, procedures, and UI elements.
Role Definitions
User roles are defined in src/db/enums.ts:
export enum UserRole {
ADMIN = "admin",
USER = "user",
}Server-Side Role Checks
Layout Guards
Protect entire route groups at the layout level:
// app/[locale]/(admin)/layout.tsx
import { auth } from "@/lib/auth/server";
import { UserRole } from "@/db/enums";
export default async function AdminLayout({ children }) {
// Require admin role
await auth.requireRole(UserRole.ADMIN, {
preserveCallback: true
});
return <AdminLayout>{children}</AdminLayout>;
}Single Role
const session = await auth.requireRole(UserRole.ADMIN);Multiple Roles
const session = await auth.requireRole([UserRole.ADMIN, UserRole.MODERATOR]);With Options
await auth.requireRole(UserRole.ADMIN, {
preserveCallback: true, // Save redirect URL
requireOnboardingCompleted: true, // Require onboarding
});Role-Based Procedures
Single Role
Require a specific role for a tRPC procedure:
import { roleProcedure } from "@/trpc/procedures/trpc";
import { UserRole } from "@/db/enums";
export const adminRouter = createTRPCRouter({
listUsers: roleProcedure(UserRole.ADMIN).query(async ({ ctx }) => {
return ctx.db.users.list();
}),
});Multiple Roles
Allow multiple roles:
export const moderationRouter = createTRPCRouter({
moderateContent: roleProcedure([UserRole.ADMIN, UserRole.MODERATOR])
.mutation(async ({ ctx, input }) => {
// Available to both admins and moderators
return await ctx.db.content.moderate(input);
}),
});Role Access in Procedures
Access user role in any auth procedure:
export const profileRouter = createTRPCRouter({
getProfile: authProcedure.query(async ({ ctx }) => {
const isAdmin = ctx.user.role === UserRole.ADMIN;
if (isAdmin) {
// Return additional data for admins
}
return { user: ctx.user, isAdmin };
}),
});Client-Side Role Checks
Client-side role checks are for UI purposes only. Always validate roles on the server.
In React Components
"use client";
import { useAuth } from "@/lib/auth/client";
import { UserRole } from "@/db/enums";
export function AdminPanel() {
const { user } = useAuth();
if (user?.role !== UserRole.ADMIN) {
return <AccessDenied />;
}
return <AdminDashboard />;
}Conditional Rendering
export function Navigation() {
const { user } = useAuth();
return (
<nav>
<Link href={PageHome()}>Home</Link>
<Link href={PageDashboard()}>Dashboard</Link>
{user?.role === UserRole.ADMIN && (
<Link href={PageAdmin()}>Admin</Link>
)}
</nav>
);
}Role Helper
"use client";
import { useAuth } from "@/lib/auth/client";
import { UserRole } from "@/db/enums";
export function useHasRole(role: UserRole | UserRole[]) {
const { user } = useAuth();
if (!user) return false;
if (Array.isArray(role)) {
return role.includes(user.role);
}
return user.role === role;
}
// Usage
export function AdminButton() {
const isAdmin = useHasRole(UserRole.ADMIN);
if (!isAdmin) return null;
return <Button>Admin Action</Button>;
}Protected Routes
Middleware Protection
Routes are automatically protected based on PagesRequiringAuth:
// src/routes/routing.ts
export const routingConfig = {
PagesRequiringAuth: () => [
PageDashboard,
PageSettingsProfile,
PageAdmin, // Only admins can access
],
// ...
};Role-Specific Protection
For role-specific protection, use layout guards:
// app/[locale]/(admin)/layout.tsx
export default async function Layout({ children }) {
await auth.requireRole(UserRole.ADMIN);
return <>{children}</>;
}Adding New Roles
Update Enum
Add the new role to src/db/enums.ts:
export enum UserRole {
ADMIN = "admin",
MODERATOR = "moderator",
USER = "user",
}Update Database
Push schema changes:
pnpm db:pushUpdate Type Definitions
TypeScript will automatically pick up the new role from the enum.
Use in Code
await auth.requireRole(UserRole.MODERATOR);Role Hierarchy
You can implement role hierarchy by checking multiple roles:
// Helper function
function hasRoleOrHigher(userRole: UserRole, requiredRole: UserRole): boolean {
const hierarchy = [UserRole.USER, UserRole.MODERATOR, UserRole.ADMIN];
const userIndex = hierarchy.indexOf(userRole);
const requiredIndex = hierarchy.indexOf(requiredRole);
return userIndex >= requiredIndex;
}
// Usage in procedure
export const contentRouter = createTRPCRouter({
delete: authProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
// Allow MODERATOR and ADMIN
if (!hasRoleOrHigher(ctx.user.role, UserRole.MODERATOR)) {
throw new TRPCError({ code: "FORBIDDEN" });
}
return await ctx.db.content.delete(input);
}),
});Best Practices
1. Always Validate on Server
// ❌ Bad - Client-only check
if (user?.role === "admin") {
// Can be bypassed
await deleteUser(id);
}
// ✅ Good - Server-side validation
export const adminRouter = createTRPCRouter({
deleteUser: roleProcedure(UserRole.ADMIN).mutation(/* ... */),
});2. Use Layout Guards
Protect entire sections:
// ✅ Good - Layout protection
export default async function AdminLayout({ children }) {
await auth.requireRole(UserRole.ADMIN);
return <>{children}</>;
}3. Consistent Error Messages
if (ctx.user.role !== UserRole.ADMIN) {
return {
success: false,
message: ctx.t("errors.insufficientPermissions"),
};
}4. Log Role Changes
export const userRouter = createTRPCRouter({
updateRole: roleProcedure(UserRole.ADMIN)
.input(z.object({
userId: z.string(),
newRole: z.nativeEnum(UserRole),
}))
.mutation(async ({ ctx, input }) => {
// Log the role change
console.log(`Admin ${ctx.user.id} changed role of ${input.userId} to ${input.newRole}`);
return await ctx.db.users.update({
where: { id: input.userId },
data: { role: input.newRole },
});
}),
});Common Patterns
Admin-Only Components
import { RoleGuard } from "@/components/auth/role-guard";
import { UserRole } from "@/db/enums";
<RoleGuard role={UserRole.ADMIN}>
<AdminSettings />
</RoleGuard>Multi-Level Access
export const dashboardRouter = createTRPCRouter({
getData: authProcedure.query(async ({ ctx }) => {
const baseData = await getBaseData();
if (ctx.user.role === UserRole.ADMIN) {
return {
...baseData,
adminData: await getAdminData(),
};
}
return baseData;
}),
});