🛡️ Utility Functions
Use canViewContent(), requireRole(), and getUserRole() for programmatic role checks with
detailed results.
The role-based visibility system provides a unified API for controlling content access based on user roles. Supports both Supabase user roles (app-level) and Clerk organization roles (org-level) through a single, consistent interface.
Version: 1.0 | Status: ✅ Production Ready
Looking for page-level protection? See the dedicated Page-Level Protection Guide for complete coverage of requireRole() with hierarchical privilege escalation.
Looking for declarative page protection? See the Auth Layout Role Guards Guide for protecting entire pages using Auth layout props instead of manual role checks.
The role guard system provides:
🛡️ Utility Functions
Use canViewContent(), requireRole(), and getUserRole() for programmatic role checks with
detailed results.
🎨 Wrapper Components
Use <RoleGuard> components in Astro and React to hide/show content based on user roles with
zero configuration.
⚡ Performance
Built-in caching with 1-minute TTL reduces database queries. Configure cache duration per check.
🔍 Debug Mode
Development-only debug mode shows role checking details to help troubleshoot access issues.
---import RoleGuard from '#components/astro/RoleGuard.astro'---
<RoleGuard allowedRoles={['admin', 'super_admin']}> <h1>Admin Dashboard</h1> <p>Only admins can see this content</p></RoleGuard>That’s it! The component handles everything:
The astro-basics project uses two independent role systems:
Storage: Supabase users table
Sync Source: Clerk webhook → Supabase
Roles: member, admin, super_admin
// Determines app-wide privilegestype UserRole = 'member' | 'admin' | 'super_admin'Use Cases:
Storage: Clerk session claims (Astro.locals.userRole)
Roles: org:admin, org:member
// Determines organization-specific privilegestype OrgRole = 'org:admin' | 'org:member'Use Cases:
The role guard system works with both role systems through a unified API:
type AnyRole = UserRole | OrgRole// Can be: 'member' | 'admin' | 'super_admin' | 'org:admin' | 'org:member'The project offers three complementary patterns for role-based access control. Choose based on your specific needs:
| Pattern | Best For | Implementation | When to Use |
|---|---|---|---|
| Auth Layout Props | Entire page protection | <Auth requiredRoles={[...]}/> | Admin pages, role-specific sections |
| requireRole() Helper | Page-level redirection | await requireRole(...) | API routes, forced redirects |
| RoleGuard Components | Section visibility | <RoleGuard allowedRoles={[...]}/> | Conditional content, mixed pages |
Declarative page protection using layout props
---import Auth from '#layouts/Auth.astro'const requiredRoles: AnyRole[] = ['admin', 'super_admin']---
<Auth pageTitle="Admin Dashboard" requiredRoles={requiredRoles}> <!-- Entire page content protected --></Auth>
{ /* The Auth layout denies in place (shows an "insufficient permissions" notice and withholds the page) — it cannot redirect. For a redirect, guard in the page's own frontmatter with redirectUnauthorized(Astro, requiredRoles). */}Advantages:
Imperative role checking with redirection
---import { requireRole } from '#utils/roles'
// Check role and redirect if unauthorizedawait requireRole(Astro.locals, ['admin'], Astro.redirect)---
<!-- Page content (only reached if authorized) -->Advantages:
Component-based conditional rendering
---import RoleGuard from '#components/astro/RoleGuard.astro'---
<h1>Public Content</h1>
<RoleGuard allowedRoles={['admin']}> <h2>Admin-Only Section</h2></RoleGuard>Advantages:
See: This guide (below)
canViewContent()Primary role-checking function - Returns boolean indicating if user can view content.
canViewContent( locals: App.Locals, allowedRoles: AnyRole[], options?: Partial<RoleGuardConfig>): Promise<boolean>---import { canViewContent } from '#utils/role-guard'
const canViewAdmin = await canViewContent(Astro.locals, ['admin', 'super_admin'])---
{canViewAdmin && <AdminPanel />}---const canViewAdmin = await canViewContent(Astro.locals, ['admin', 'super_admin'], { fetchFromSupabase: true, // Fetch role from DB if not in locals cacheTTL: 120000, // Cache for 2 minutes (default: 1 min)})---Return Value:
true - User has at least ONE of the allowed rolesfalse - User lacks all allowed roles OR not authenticatedcanViewContentDetailed()Enhanced version - Returns detailed authorization result with debugging info.
canViewContentDetailed( locals: App.Locals, allowedRoles: AnyRole[], options?: Partial<RoleGuardConfig>): Promise<RoleCheckResult>---import { canViewContentDetailed } from '#utils/role-guard'
const result = await canViewContentDetailed(Astro.locals, ['admin'])
if (!result.allowed) { console.log('Access denied:', result.reason) console.log('User role:', result.userRole)}---interface RoleCheckResult { allowed: boolean // Whether access is granted userRole: AnyRole | null // User's current role reason?: string // Reason for denial (if denied)}// Access granted{ allowed: true, userRole: 'admin' }
// Access denied - wrong role{ allowed: false, userRole: 'member', reason: 'User role "member" not in allowed roles: admin, super_admin'}
// Access denied - not authenticated{ allowed: false, userRole: null, reason: 'User not authenticated'}requireRole()Page-level protection - Throws 403 error if user lacks required role.
requireRole( locals: App.Locals, allowedRoles: AnyRole[], options?: Partial<RoleGuardConfig>): Promise<void>---import { requireRole } from '#utils/role-guard'
// Protect entire page - throws if unauthorizedawait requireRole(Astro.locals, ['super_admin'])---
<html> <body> <h1>Super Admin Dashboard</h1> <!-- All content protected - only renders if check passes --> </body></html>getUserRole()Extracts user role from Astro.locals with optional Supabase fallback.
getUserRole(locals: App.Locals, fetchFromSupabase?: boolean): Promise<AnyRole | null>---import { getUserRole } from '#utils/role-guard'
// Get org role from Clerk session (if available)const orgRole = await getUserRole(Astro.locals, false)
// Get user role from Supabase (with fallback)const userRole = await getUserRole(Astro.locals, true)
console.log('Org role:', orgRole) // e.g., 'org:admin' or nullconsole.log('User role:', userRole) // e.g., 'admin' or 'member'---Fetch Priority:
fetchFromSupabase === true)hasAnyRole() & hasAllRoles()Semantic aliases for role checking with explicit OR/AND logic.
hasAnyRole(locals: App.Locals, roles: AnyRole[]): Promise<boolean>hasAllRoles(locals: App.Locals, roles: AnyRole[]): Promise<boolean>---import { hasAnyRole } from '#utils/role-guard'
// User needs to be admin OR super_adminconst isStaff = await hasAnyRole(Astro.locals, ['admin', 'super_admin'])---
{isStaff && <StaffTools />}---import { hasAllRoles } from '#utils/role-guard'
// User must be admin (single role check)const isAdmin = await hasAllRoles(Astro.locals, ['admin'])---isValidRole()Type guard - Validates if string is a valid role.
const userInput = 'admin'
if (isValidRole(userInput)) { // TypeScript now knows userInput is AnyRole const label = ROLE_LABELS[userInput]}formatRoleForDisplay()Human-readable labels - Converts role identifiers to display names.
formatRoleForDisplay('super_admin') // Returns: "Super Admin"formatRoleForDisplay('org:admin') // Returns: "Organization Admin"formatRoleForDisplay('member') // Returns: "Member"Server-side component for Astro pages - performs authorization server-side (zero client JavaScript).
interface Props { allowedRoles: AnyRole[] // Required - roles that can view content fallback?: string // Optional - message when access denied debug?: boolean // Optional - show debug info (dev only) fetchFromSupabase?: boolean // Optional - query Supabase (default: true)}---import RoleGuard from '#components/astro/RoleGuard.astro'---
<RoleGuard allowedRoles={['admin', 'super_admin']}> <div class="admin-panel"> <h2>Admin Controls</h2> <button>Delete All Users</button> </div></RoleGuard><RoleGuard allowedRoles={['super_admin']} fallback="You need super admin permissions to view this content."> <DangerZone /></RoleGuard><RoleGuard allowedRoles={['admin']} debug={true}> <AdminPanel /></RoleGuard>
<!-- Renders in development:🔍 RoleGuard DebugUser Role: memberAllowed Roles: admin, super_adminAccess: ❌ Denied-->Client-side component for React - requires server-fetched role data as props.
interface RoleGuardProps { userRole: AnyRole | null // Required - pre-fetched server-side allowedRoles: AnyRole[] // Required - roles that can view content children: ReactNode // Required - content to protect fallback?: ReactNode // Optional - shown when access denied loading?: boolean // Optional - show loading state className?: string // Optional - custom CSS class 'data-testid'?: string // Optional - for testing}---import Dashboard from '#components/react/Dashboard'import { getUserRole } from '#utils/role-guard'
// Fetch role server-sideconst userRole = await getUserRole(Astro.locals, true)---
<Dashboard userRole={userRole} client:load />import { RoleGuard } from '#components/react/RoleGuard'import type { AnyRole } from '#utils/role-types'
interface Props { userRole: AnyRole | null}
export function Dashboard({ userRole }: Props) { return ( <div> <h1>Dashboard</h1>
{/* Admin-only content */} <RoleGuard userRole={userRole} allowedRoles={['admin', 'super_admin']} fallback={<p>Admin access required</p>} > <AdminSettings /> </RoleGuard>
{/* All authenticated users */} <RoleGuard userRole={userRole} allowedRoles={['member', 'admin', 'super_admin']}> <UserContent /> </RoleGuard> </div> )}<RoleGuard userRole={userRole} allowedRoles={['super_admin']} fallback={ <div className="access-denied"> <h2>Access Denied</h2> <p>Contact your administrator for super admin access.</p> <a href="/support">Request Access</a> </div> }> <SuperAdminPanel /></RoleGuard>Show different content based on role hierarchy.
---import { canViewContent } from '#utils/role-guard'
const canViewBasic = await canViewContent(Astro.locals, ['member', 'admin', 'super_admin'])const canViewPremium = await canViewContent(Astro.locals, ['admin', 'super_admin'])const canViewAdmin = await canViewContent(Astro.locals, ['super_admin'])---
{ canViewBasic && ( <section> <h2>Basic Content</h2> <p>All authenticated users can see this</p> </section> )}
{ canViewPremium && ( <section> <h2>Premium Content</h2> <p>Only admins and super admins can see this</p> </section> )}
{ canViewAdmin && ( <section> <h2>Super Admin Content</h2> <p>Highest privilege only</p> </section> )}Support both Supabase and Clerk roles in same page.
<RoleGuard allowedRoles={[ 'super_admin', // Supabase user role 'org:admin', // Clerk org role ]}> <AdvancedSettings /></RoleGuard>Combine page-level and component-level guards for granular control.
See Page-Level Protection Guide for more examples and security best practices.
---import { requireRole } from '#utils/role-guard'import RoleGuard from '#components/astro/RoleGuard.astro'
// Page-level: Only admins can access page at allawait requireRole(Astro.locals, ['admin', 'super_admin'])---
<html> <body> <h1>Admin Dashboard</h1>
<!-- Component-level: Only super_admins see danger zone --> <RoleGuard allowedRoles={['super_admin']}> <DangerZone /> </RoleGuard> </body></html>Show/hide navigation items based on role.
---import { canViewContent } from '#utils/role-guard'
const canViewAdmin = await canViewContent(Astro.locals, ['admin', 'super_admin'])const canViewOrg = await canViewContent(Astro.locals, ['org:admin'])---
<nav> <a href="/">Home</a> <a href="/dashboard">Dashboard</a>
{canViewOrg && <a href="/organization">Organization</a>} {canViewAdmin && <a href="/dashboard/users">Users</a>}</nav>Hide sensitive form fields based on role.
function UserForm({ userRole }: Props) { return ( <form> <input name="username" placeholder="Username" /> <input name="email" type="email" placeholder="Email" />
{/* Only admins can change roles */} <RoleGuard userRole={userRole} allowedRoles={['admin', 'super_admin']}> <select name="role"> <option value="member">Member</option> <option value="admin">Admin</option> </select> </RoleGuard> </form> )}Default cache TTL is 1 minute. Adjust based on your needs:
---// Short TTL (30 seconds) for frequently changing rolesconst canView = await canViewContent(Astro.locals, ['admin'], { cacheTTL: 30000 })
// Long TTL (5 minutes) for stable rolesconst canView = await canViewContent(Astro.locals, ['admin'], { cacheTTL: 300000 })---Clerk org roles are in Astro.locals (no DB query). Use them when possible:
---// ✅ Fast - uses Astro.locals (org role)const canManageOrg = await canViewContent(Astro.locals, ['org:admin'], { fetchFromSupabase: false,})
// ⚠️ Slower - queries Supabaseconst isAdmin = await canViewContent(Astro.locals, ['admin'], { fetchFromSupabase: true })---For protected routes, pre-fetch Supabase roles in middleware:
// src/middleware.ts (enhancement)if (isProtectedRoute(context.request)) { locals.supabaseRole = await fetchSupabaseRole(locals.userId)}Then role guards skip the DB query entirely.
When rendering lists, fetch role once and reuse:
---import { getUserRole } from '#utils/role-guard'
const userRole = await getUserRole(Astro.locals, true)
const features = [ { name: 'Basic Feature', allowedRoles: ['member', 'admin', 'super_admin'] }, { name: 'Admin Feature', allowedRoles: ['admin', 'super_admin'] }, { name: 'Super Feature', allowedRoles: ['super_admin'] },]---
{ features.map( feature => userRole && feature.allowedRoles.includes(userRole) && <FeatureCard name={feature.name} /> )}Symptoms: Content hidden even though user has correct role
Causes:
Solutions:
---// Enable debug mode to see what's happening---
<RoleGuard allowedRoles={['admin']} debug={true}> <Content /></RoleGuard>
<!-- Check output:User Role: member (expected: admin)Allowed Roles: adminAccess: Denied-->// Clear cache and try againimport { clearRoleCache } from '#utils/role-guard'clearRoleCache(userId)---// Force fresh Supabase queryconst result = await canViewContentDetailed(Astro.locals, ['admin'], { fetchFromSupabase: true, cacheTTL: 0,})
console.log('Role check result:', result)---Symptoms: PGRST116 error or null role returned
Cause: Clerk user not yet synced to Supabase database
Solution: User creation happens automatically via webhook. The UserInfo.astro component handles this with upsert logic (see: src/components/astro/UserInfo.astro:55-64).
Symptoms: RoleGuard in React shows nothing
Cause: userRole prop not passed from server
Solution:
---// WRONG - userRole not fetched;<Dashboard client:load />
// RIGHT - fetch server-side and pass as propimport { getUserRole } from '#utils/role-guard'const userRole = await getUserRole(Astro.locals, true)---
<Dashboard userRole={userRole} client:load />Symptoms: Page loads slowly with many role checks
Solutions:
// Monitor cache performanceimport { getRoleCacheStats } from '#utils/role-guard'const stats = getRoleCacheStats()console.log('Cache entries:', stats.entries, 'Size:', stats.size)---const supabase = getSupabaseServiceRole()const { data } = await supabase.from('users').select('role').eq('clerk_id', userId).single()
const isAdmin = data?.role === 'admin' || data?.role === 'super_admin'---
{isAdmin && <AdminPanel />}---import { canViewContent } from '#utils/role-guard'
const isAdmin = await canViewContent(Astro.locals, ['admin', 'super_admin'])---
{isAdmin && <AdminPanel />}Benefits:
canViewContentDetailed() for security auditsrequireRole) for route protection#utils/role-typesThe role-based visibility system provides:
🎯 Unified API
Single interface for both Supabase and Clerk roles
🔒 Server-Side Security
Zero client JavaScript, all checks happen server-side
⚡ Auto-Caching
Configurable cache TTL (default: 1 minute)
🛡️ Type Safety
Full TypeScript support with strict type checking
🧪 Well-Tested
54+ unit and component tests ensure reliability
👨💻 Developer-Friendly
Matches existing project patterns and conventions
Quick Reference:
canViewContent()requireRole()canViewContentDetailed()<RoleGuard><RoleGuard userRole={...}>For implementation details, see the role-based visibility system implementation plan.