Skip to main content
Buildstory uses a three-tier role system to control access to admin features and moderation tools.

Role Hierarchy

Three roles are defined in the user_role enum (see lib/db/schema.ts:60-64):

User (Default)

Standard platform access:
  • Create and edit own projects
  • Register for events
  • Send team invites
  • Update profile settings
  • No access to /admin routes

Moderator

Content moderation capabilities:
  • All user permissions
  • Access to /admin/dashboard, /admin/users
  • Hide/unhide users
  • Ban users (excluding admins)
  • View audit log (read-only in some implementations)
Restrictions:
Moderators cannot ban users with admin role, change roles, or access admin-only pages like /admin/roles, /admin/audit, and /admin/mentors.

Admin

Full platform control:
  • All moderator permissions
  • Unban users
  • Delete user accounts
  • Assign roles (user, moderator, admin)
  • Review mentor applications
  • Access audit log
  • Manage Sanity Studio at /studio

Super Admin

A special admin designation granted via environment variable:
ADMIN_USER_IDS="user_xxxxx,user_yyyyy"
Characteristics:
  • Clerk user IDs listed in ADMIN_USER_IDS are always treated as admins
  • Bypasses database role column (source of truth for super-admins)
  • Cannot be banned, hidden, deleted, or have role changed
  • Identified by isSuperAdmin(clerkUserId) helper in lib/admin.ts:10-12
Super-admin status is immutable through the UI. Only removing a Clerk ID from ADMIN_USER_IDS env var will revoke super-admin access.

Role Assignment

Access Requirements

role
string
required
Requires admin role to access /admin/roles
The roles page is admin-only (enforced in app/admin/roles/page.tsx:11).

Role Management Interface

Navigate to /admin/roles to view and manage elevated users:
  • Displays all users with moderator or admin role
  • Search by display name or username
  • Shows super-admin badge for ADMIN_USER_IDS entries
  • Change role via dropdown or promote users by search

Set User Role

Server action in app/admin/roles/actions.ts:14-74:
setUserRole({
  profileId: string,
  role: "user" | "moderator" | "admin"
})
Workflow:
  1. Verify actor has admin permissions via isAdmin(userId)
  2. Fetch target user profile
  3. Check restrictions:
    • Cannot change super-admin roles
    • Cannot change your own role
    • No-op if same role
  4. Update profiles.role in database
  5. Log to adminAuditLog with oldRole and newRole metadata
  6. Revalidate /admin/roles and /admin/users paths
Audit Log Entry:
{
  "action": "set_role",
  "actorProfileId": "...",
  "targetProfileId": "...",
  "metadata": {
    "oldRole": "user",
    "newRole": "moderator"
  }
}

Search for Users

Server action searchProfilesByName(query: string) (lines 76-110):
  • Minimum 2 characters required
  • Case-insensitive search on displayName and username using ILIKE
  • Returns up to 10 results
  • Used for quick user lookup when assigning roles

Permission Helpers

Role checks are centralized in lib/admin.ts:

isSuperAdmin(clerkUserId)

Synchronous check against ADMIN_USER_IDS env var (lines 10-12):
const superAdminIds = (process.env.ADMIN_USER_IDS ?? "")
  .split(",")
  .map((id) => id.trim())
  .filter(Boolean);

export function isSuperAdmin(clerkUserId: string): boolean {
  return superAdminIds.includes(clerkUserId);
}

getRole(clerkUserId)

Async helper that returns the effective role (lines 14-25):
export async function getRole(
  clerkUserId: string
): Promise<"user" | "moderator" | "admin"> {
  if (isSuperAdmin(clerkUserId)) return "admin";

  const profile = await db.query.profiles.findFirst({
    where: eq(profiles.clerkId, clerkUserId),
    columns: { role: true },
  });

  return profile?.role ?? "user";
}
Falls back to "user" if profile doesn’t exist (e.g., during lazy profile creation).

isAdmin(clerkUserId)

Async check for admin permissions (lines 27-30):
export async function isAdmin(clerkUserId: string): Promise<boolean> {
  const role = await getRole(clerkUserId);
  return role === "admin";
}

isModerator(clerkUserId)

Async check for moderator or admin permissions (lines 32-35):
export async function isModerator(clerkUserId: string): Promise<boolean> {
  const role = await getRole(clerkUserId);
  return role === "admin" || role === "moderator";
}
isModerator returns true for admins since admins inherit all moderator capabilities.

canAccessAdmin(clerkUserId)

Alias for isModerator (lines 37-39):
export async function canAccessAdmin(clerkUserId: string): Promise<boolean> {
  return isModerator(clerkUserId);
}
Used in middleware and layouts to gate /admin/* routes.

Session Helper

lib/admin/get-admin-session.ts provides a React cache-wrapped session getter:
export const getAdminSession = cache(async () => {
  const { userId } = await auth();
  if (!userId) return null;

  const canAccess = await canAccessAdmin(userId);
  if (!canAccess) return null;

  const role = await getRole(userId);
  return { userId, role };
});
Returns null if unauthenticated or insufficient permissions. Used in admin pages and layouts to conditionally render UI based on role.

Middleware Protection

Route guards in app/proxy.ts:
// Admin area (moderators + admins)
if (req.nextUrl.pathname.startsWith("/admin")) {
  if (!userId || !(await canAccessAdmin(userId))) {
    return NextResponse.redirect(new URL("/", req.url));
  }
}

// Sanity Studio (admins only)
if (req.nextUrl.pathname.startsWith("/studio")) {
  if (!userId || !(await isAdmin(userId))) {
    return NextResponse.redirect(new URL("/", req.url));
  }
}
Provides defense-in-depth: even if a user bypasses client-side checks, middleware blocks unauthorized route access.

Admin Layout Navigation

The admin layout (app/admin/layout.tsx) conditionally renders nav links based on session role:
  • All moderators/admins: Dashboard, Users
  • Admins only: Roles, Mentors, Audit Log
This prevents moderators from seeing links to pages they can’t access.

Permissions Matrix

ActionUserModeratorAdminSuper Admin
Access /admin/dashboardNoYesYesYes
Access /admin/usersNoYesYesYes
Hide/unhide usersNoYesYesYes
Ban users (non-admin)NoYesYesYes
Ban adminsNoNoYesYes
Unban usersNoNoYesYes
Delete usersNoNoYesYes
Access /admin/rolesNoNoYesYes
Assign rolesNoNoYesYes
Access /admin/mentorsNoNoYesYes
Access /admin/auditNoNoYesYes
Access /studioNoNoYesYes
Be banned/deletedYesYesYesNo
Have role changedYesYesYesNo

Database Schema

Role is stored in the profiles table (from lib/db/schema.ts:92):
role: userRoleEnum("role").default("user").notNull()
Enum definition (lines 60-64):
export const userRoleEnum = pgEnum("user_role", [
  "user",
  "moderator",
  "admin",
]);
All role changes are logged to the adminAuditLog table with before/after values.

Best Practices

  1. Use moderator role for content moderation volunteers who don’t need full admin access
  2. Reserve admin role for trusted maintainers who manage roles and review applications
  3. Grant super-admin sparingly — only for platform owners who need immutable admin access
  4. Audit role changes regularly via /admin/audit to track permission escalations
  5. Never share super-admin Clerk IDs publicly or in client-side code (env var is server-only)

Build docs developers (and LLMs) love