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.
Overview
Section titled “Overview”The role guard system provides:
- Unified API for Supabase and Clerk roles
- Server-side security with zero client JavaScript
- Automatic caching with configurable TTL (default: 1 minute)
- Type safety with full TypeScript support
- Flexible usage - utilities and components
- Well-tested - 54+ unit and component tests
🛡️ 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.
Quick Start
Section titled “Quick Start”30-Second Example
Section titled “30-Second Example”---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:
- ✅ Fetches user role from Astro.locals or Supabase
- ✅ Checks if user has any allowed role
- ✅ Hides content if unauthorized
- ✅ Caches role queries (1-minute TTL)
Core Concepts
Section titled “Core Concepts”Two Role Systems
Section titled “Two Role Systems”The astro-basics project uses two independent role systems:
1. Supabase User Roles (App-Level)
Section titled “1. Supabase User Roles (App-Level)”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:
- Global admin features
- User management
- System configuration
- Cross-organization permissions
2. Clerk Organization Roles (Org-Level)
Section titled “2. Clerk Organization Roles (Org-Level)”Storage: Clerk session claims (Astro.locals.userRole)
Roles: org:admin, org:member
// Determines organization-specific privilegestype OrgRole = 'org:admin' | 'org:member'Use Cases:
- Organization settings
- Team management
- Org-specific features
- Multi-tenant access control
Unified API
Section titled “Unified API”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'Utility Functions
Section titled “Utility Functions”canViewContent()
Section titled “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 authenticated
canViewContentDetailed()
Section titled “canViewContentDetailed()”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()
Section titled “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()
Section titled “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:
- Astro.locals.userRole (Clerk org role from middleware)
- Supabase query (if
fetchFromSupabase === true) - null (if not authenticated)
hasAnyRole() & hasAllRoles()
Section titled “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'])---Helper Functions
Section titled “Helper Functions”isValidRole()
Section titled “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()
Section titled “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"Wrapper Components
Section titled “Wrapper Components”Astro RoleGuard
Section titled “Astro RoleGuard”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-->React RoleGuard
Section titled “React RoleGuard”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>Common Patterns
Section titled “Common Patterns”1. Tiered Content Access
Section titled “1. Tiered Content Access”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> )}2. Mixed Role Types
Section titled “2. Mixed Role Types”Support both Supabase and Clerk roles in same page.
<RoleGuard allowedRoles={[ 'super_admin', // Supabase user role 'org:admin', // Clerk org role ]}> <AdvancedSettings /></RoleGuard>3. Nested Protection
Section titled “3. Nested Protection”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>4. Conditional Navigation
Section titled “4. Conditional Navigation”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>5. Form Field Protection
Section titled “5. Form Field Protection”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> )}Performance Tips
Section titled “Performance Tips”1. Cache Configuration
Section titled “1. Cache Configuration”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 })---2. Minimize Supabase Queries
Section titled “2. Minimize Supabase Queries”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 })---3. Pre-fetch in Middleware
Section titled “3. Pre-fetch in Middleware”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.
4. Batch Role Checks
Section titled “4. Batch Role Checks”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} /> )}Troubleshooting
Section titled “Troubleshooting”Issue: Role Not Detected
Section titled “Issue: Role Not Detected”Symptoms: Content hidden even though user has correct role
Causes:
- Supabase role not synced from Clerk
- Typo in role name
- Cache showing stale data
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)---Issue: “User not found in Supabase”
Section titled “Issue: “User not found in Supabase””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).
Issue: React Component Not Rendering
Section titled “Issue: React Component Not Rendering”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 />Issue: Performance Degradation
Section titled “Issue: Performance Degradation”Symptoms: Page loads slowly with many role checks
Solutions:
- Reduce cache TTL (if roles change frequently)
- Increase cache TTL (if roles are stable)
- Pre-fetch roles in middleware for protected routes
- Use Clerk org roles instead of Supabase roles when possible
// Monitor cache performanceimport { getRoleCacheStats } from '#utils/role-guard'const stats = getRoleCacheStats()console.log('Cache entries:', stats.entries, 'Size:', stats.size)Migration Guide
Section titled “Migration Guide”Replacing Manual Role Checks
Section titled “Replacing Manual Role Checks”---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:
- ✅ 80% less code
- ✅ Type-safe
- ✅ Automatic caching
- ✅ Better error handling
- ✅ Consistent with project patterns
Best Practices
Section titled “Best Practices”Security
Section titled “Security”- Never trust client-side - Always fetch roles server-side
- Defense in depth - Combine role guards with RLS policies and middleware
- Audit logging - Use
canViewContentDetailed()for security audits - Principle of least privilege - Grant minimum necessary permissions
Code Organization
Section titled “Code Organization”- Component-level guards for granular UI control
- Page-level guards (
requireRole) for route protection - Middleware guards for API endpoint protection
- Consistent role naming - Use constants from
#utils/role-types
Testing
Section titled “Testing”- Mock Astro.locals in tests
- Test both authorized and unauthorized cases
- Verify fallback content renders
- Check accessibility attributes (role, aria-*)
Summary
Section titled “Summary”The 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:
- Simple checks:
canViewContent() - Page protection:
requireRole() - Debugging:
canViewContentDetailed() - Astro components:
<RoleGuard> - React components:
<RoleGuard userRole={...}>
For implementation details, see the role-based visibility system implementation plan.