🛡️ 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.
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'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="/admin">Admin</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.