User Role System
Role-based access control with hierarchical permissions for customers, solvers, admins, and owners
User Role System
SavvySolve implements a hierarchical role-based access control (RBAC) system. Each user has a single role that determines their access level throughout the platform. Higher roles inherit all permissions from lower roles.
Role Hierarchy
The system defines four roles, ordered from lowest to highest privilege:
| Role | Level | Description |
|---|---|---|
customer | 1 | End users seeking tech support |
solver | 2 | Tech support providers who claim and resolve tickets |
admin | 3 | System administrators who manage solvers and view reports |
owner | 4 | Platform owners with full access (super admin) |
The hierarchy is defined in lib/auth/roles.ts:
export const ROLE_HIERARCHY: Record<UserRole, number> = {
customer: 1,
solver: 2,
admin: 3,
owner: 4,
};Role Utilities
The lib/auth/roles.ts module exports helper functions for access control:
hasRole
Check if a user's role meets or exceeds a required role:
import { hasRole } from "@/lib/auth/roles";
hasRole("admin", "solver"); // true - admin >= solver
hasRole("solver", "admin"); // false - solver < admin
hasRole("owner", "admin"); // true - owner >= adminrequireRole
Assert that a user has sufficient privileges, throwing a tRPC error if not:
import { requireRole } from "@/lib/auth/roles";
requireRole("solver", "admin");
// Throws TRPCError: "This action requires admin role or higher"
requireRole("admin", "admin"); // passes
requireRole("owner", "admin"); // passesgetAccessibleRoles
Get all roles a user can access (their role and below):
import { getAccessibleRoles } from "@/lib/auth/roles";
getAccessibleRoles("admin");
// ["customer", "solver", "admin"]
getAccessibleRoles("owner");
// ["customer", "solver", "admin", "owner"]tRPC Integration
Role-based procedures are exported from server/trpc.ts for use in routers:
// Public - no authentication
export const publicProcedure = t.procedure;
// Authenticated - any signed-in user
export const protectedProcedure = t.procedure.use(enforceAuth);
// Role-based - authenticated + minimum role
export const solverProcedure = t.procedure
.use(enforceAuthWithRole)
.use(enforceRole("solver"));
export const adminProcedure = t.procedure
.use(enforceAuthWithRole)
.use(enforceRole("admin"));
export const ownerProcedure = t.procedure
.use(enforceAuthWithRole)
.use(enforceRole("owner"));Using Role Procedures
In your routers, choose the appropriate procedure based on required access:
import { adminProcedure, ownerProcedure, router } from "../trpc";
export const adminRouter = router({
// Any admin or owner can view reports
getReports: adminProcedure.query(async ({ ctx }) => {
// ctx.auth includes: userId, role, dbUserId
console.log(`Admin ${ctx.auth.role} viewing reports`);
return fetchReports();
}),
// Only owners can modify system settings
updateSettings: ownerProcedure
.input(settingsSchema)
.mutation(async ({ input }) => {
return updateSystemSettings(input);
}),
});Context Shape
Role-based procedures provide an enriched context:
interface AuthStateWithRole {
userId: string; // Clerk user ID
sessionId: string; // Clerk session ID
role: UserRole; // User's role from database
dbUserId: string; // Database user ID (UUID)
}Database Schema
Roles are stored as a PostgreSQL enum on the users table:
export const userRoleEnum = pgEnum("user_role", [
"customer",
"solver",
"admin",
"owner",
]);export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
clerkId: varchar("clerk_id", { length: 255 }).notNull().unique(),
role: userRoleEnum("role").notNull().default("customer"),
// ... other fields
});Bootstrapping the First Owner
New installations need an initial owner to access admin features. Use the seed script:
# Promote by email
OWNER_EMAIL=admin@example.com bun run db:seed-owner
# Or by Clerk ID
OWNER_CLERK_ID=user_2abc123 bun run db:seed-ownerThe script requires the user to already exist in the database (they must have signed in at least once via Clerk).
Role Assignment Flow
- New users default to
customerrole when created via Clerk webhook - Solvers are promoted from customer by an admin during onboarding
- Admins are promoted from solver by an owner
- Owners are promoted via the seed script or by another owner
Route Protection
Routes are protected at multiple levels using a defense-in-depth approach.
Server Component Guards
The roleGuard function in lib/auth/guard.ts provides page-level protection in server components:
import { roleGuard } from "@/lib/auth";
export default async function SettingsPage() {
// Redirects to /admin if user is not an owner
await roleGuard("owner", "/admin");
return <div>Owner-only settings</div>;
}The guard returns user information if access is granted:
const user = await roleGuard("admin");
// user.userId, user.role, user.email, user.nameLayout-Level Protection
Route groups use layout components to enforce role requirements:
| Route Group | Layout Location | Required Role |
|---|---|---|
(authenticated) | app/(authenticated)/layout.tsx | solver |
(admin) | app/(admin)/layout.tsx | admin |
export default async function AuthenticatedLayout({ children }) {
const user = await roleGuard("solver", "/");
return (
<div>
<Sidebar userRole={user.role} />
{children}
</div>
);
}Helper Functions
Additional utilities for conditional rendering:
import { getCurrentUser, canAccess } from "@/lib/auth";
// Get current user without enforcing access
const user = await getCurrentUser();
if (user) {
console.log(user.role);
}
// Check access without redirecting
if (await canAccess("admin")) {
// Show admin features
}Sidebar Navigation
The sidebar automatically filters navigation items based on user role:
const navItems = [
{ label: "Dashboard", href: "/dashboard" },
{ label: "Admin", href: "/admin", minRole: "admin" },
];
// Items with minRole are only shown if user meets requirementSecurity Considerations
Defense in Depth
Role checks happen at multiple layers:
- Middleware - Clerk authentication at the edge
- Server components -
roleGuard()for page-level protection - tRPC procedures - API-level role enforcement
- Database constraints - Enum type prevents invalid role values
- UI guards - Hide features the user can't access (UX, not security)
Role Escalation Prevention
- Users cannot modify their own role
- Only owners can promote to admin
- Owner promotion requires database access (seed script)
Audit Trail
Consider logging role changes for compliance:
// Future: Add to role change operations
await db.insert(auditLogs).values({
action: "role_change",
targetUserId: user.id,
previousRole: user.role,
newRole: "admin",
performedBy: ctx.auth.dbUserId,
});View-As Mode
Admins and owners can temporarily view the platform as a different role without changing their actual database role. This is useful for testing and support purposes.
How It Works
The view-as preference is stored in the browser's localStorage under the key savvysolve_view_as. When set, the UI renders as if the user has that role, while their actual permissions remain unchanged.
const VIEW_AS_KEY = "savvysolve_view_as";
export function useViewAsRole(actualRole: UserRole) {
const [viewAsRole, setViewAsRole] = useState<UserRole | null>(null);
useEffect(() => {
const stored = localStorage.getItem(VIEW_AS_KEY);
if (stored && isValidRole(stored)) {
setViewAsRole(stored as UserRole);
}
}, []);
return {
effectiveRole: viewAsRole ?? actualRole,
isViewingAs: viewAsRole !== null && viewAsRole !== actualRole,
setViewAsRole,
clearViewAs,
};
}User Menu Integration
The role switcher appears in the user dropdown menu for admins and owners:
{canSwitchRoles && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<UserCog className="mr-2 h-4 w-4" />
View as Role
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={currentRole} onValueChange={handleRoleSwitch}>
<DropdownMenuRadioItem value="customer">Customer</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="solver">Solver</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="admin">Admin</DropdownMenuRadioItem>
{isOwner && (
<DropdownMenuRadioItem value="owner">Owner</DropdownMenuRadioItem>
)}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}When viewing as another role, the user menu displays an eye icon indicator next to the role name.
Sidebar Behavior
The sidebar respects the view-as role preference, showing only navigation items appropriate for the effective role:
useEffect(() => {
const viewAs = localStorage.getItem(VIEW_AS_KEY);
if (viewAs && isValidRole(viewAs)) {
setEffectiveRole(viewAs);
} else {
setEffectiveRole(userRole);
}
}, [userRole]);
const userLevel = roleHierarchy[effectiveRole] ?? 0;Security Note
View-as mode only affects the UI presentation. Backend tRPC procedures still enforce the user's actual database role. An admin viewing as a "customer" can still access admin API endpoints—the view-as mode is purely for visual testing.
Related Documentation
- Admin Dashboard - Platform administration interface
- tRPC API Layer - How role procedures integrate with the API
- Clerk Integration - User creation and sync
- Database Architecture - Users table schema