SavvySolve Docs

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:

RoleLevelDescription
customer1End users seeking tech support
solver2Tech support providers who claim and resolve tickets
admin3System administrators who manage solvers and view reports
owner4Platform owners with full access (super admin)

The hierarchy is defined in lib/auth/roles.ts:

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 >= admin

requireRole

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");  // passes

getAccessibleRoles

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:

server/trpc.ts
// 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:

server/routers/admin.ts
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:

lib/db/schema/enums.ts
export const userRoleEnum = pgEnum("user_role", [
  "customer",
  "solver",
  "admin",
  "owner",
]);
lib/db/schema/users.ts
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-owner

The script requires the user to already exist in the database (they must have signed in at least once via Clerk).

Role Assignment Flow

  1. New users default to customer role when created via Clerk webhook
  2. Solvers are promoted from customer by an admin during onboarding
  3. Admins are promoted from solver by an owner
  4. 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:

app/(admin)/admin/settings/page.tsx
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.name

Layout-Level Protection

Route groups use layout components to enforce role requirements:

Route GroupLayout LocationRequired Role
(authenticated)app/(authenticated)/layout.tsxsolver
(admin)app/(admin)/layout.tsxadmin
app/(authenticated)/layout.tsx
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
}

The sidebar automatically filters navigation items based on user role:

components/dashboard/Sidebar.tsx
const navItems = [
  { label: "Dashboard", href: "/dashboard" },
  { label: "Admin", href: "/admin", minRole: "admin" },
];

// Items with minRole are only shown if user meets requirement

Security Considerations

Defense in Depth

Role checks happen at multiple layers:

  1. Middleware - Clerk authentication at the edge
  2. Server components - roleGuard() for page-level protection
  3. tRPC procedures - API-level role enforcement
  4. Database constraints - Enum type prevents invalid role values
  5. 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.

hooks/use-view-as-role.ts
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:

components/dashboard/UserMenu.tsx
{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.

The sidebar respects the view-as role preference, showing only navigation items appropriate for the effective role:

components/dashboard/Sidebar.tsx
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.

On this page