Role gates in the UI

Hide actions the user can't do.

6 minmedium

Role-based UI: hide buttons the user can't use. Two layers — the UI hides actions for clean UX, the API rejects unauthorized requests for security. Both matter; neither replaces the other.

The roles you ship with

packages/shared/src/constants/roles.ts
export const ROLES = {
USER: 'user', // default — read-only mostly
STAFF: 'staff', // can edit
ADMIN: 'admin', // full access
OWNER: 'owner', // tenant owner — billing too
} as const
export type UserRole = typeof ROLES[keyof typeof ROLES]
export const ROLE_HIERARCHY: Record<UserRole, number> = {
user: 1, staff: 2, admin: 3, owner: 4,
}
export function hasRole(userRole: UserRole, required: UserRole): boolean {
return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[required]
}

Hierarchical: admin includes staff includes user. Owner gets billing + admin actions.

The Can component

apps/web/components/can.tsx
'use client'
import { useCurrentUser } from '@/hooks/use-current-user'
import { hasRole, type UserRole } from '@workspace/shared/constants/roles'
interface Props {
role: UserRole
children: React.ReactNode
}
export function Can({ role, children }: Props) {
const { data: user } = useCurrentUser()
if (!user) return null
return hasRole(user.role, role) ? <>{children}</> : null
}

Use it anywhere:

<Can role="admin">
<Button onClick={deleteCustomer}>Delete customer</Button>
</Can>
<Can role="staff">
<Button onClick={refundOrder}>Issue refund</Button>
</Can>

Gating entire pages

For pages a role shouldn't see at all (e.g., /admin/billing for non-owners), redirect in the server component:

apps/web/app/(app)/billing/page.tsx
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/auth'
export default async function BillingPage() {
const user = await getCurrentUser()
if (!user) redirect('/login')
if (!hasRole(user.role, 'owner')) redirect('/dashboard')
return <BillingDashboard />
}
UI gates are UX, not security. Anyone with the right URL + a browser can still hit your API. The API must reject the unauthorized request independently — that's the actual defence. UI gates just hide what they can't use anyway.

useCan hook for conditional logic

export function useCan(role: UserRole) {
const { data: user } = useCurrentUser()
if (!user) return false
return hasRole(user.role, role)
}
// In a component:
const canRefund = useCan('staff')
return (
<Button disabled={!canRefund || isProcessing}>
{canRefund ? 'Issue refund' : 'Refunds require staff role'}
</Button>
)

Same outcome as <Can> but lets you use the boolean in JS logic (disabled state, conditional styling, etc.).

Server-side role checks for actions

For server actions / route handlers, check the role too:

'use server'
export async function deleteCustomer(id: string) {
const user = await getCurrentUser()
if (!user || !hasRole(user.role, 'admin')) {
throw new Error('Forbidden')
}
return apiFetch(`/api/customers/${id}`, { method: 'DELETE' })
}

And the Go API has its own RequireRoles middleware (from the Go API course ch.3). Defence in depth: web hides, server action checks, API rejects.

Quick check

The "Delete Customer" button is wrapped in <Can role="admin">. A staff user opens DevTools and removes the button's parent element from the DOM. The button now appears. What happens when they click it?

Try it

Add role gating to your scaffolded project:

  1. In apps/admin/app/resources/customers/page.tsx, wrap the Delete action in <Can role="admin">.
  2. Promote one of your test users to staff (not admin). Log in as them. The Delete button should be hidden.
  3. Open DevTools, manually click the API endpoint that deletes (curl or fetch). Confirm the API rejects with 404 — that's Grit's RequireRoles middleware.

Paste the curl response in notes.md.

What's next

Last lesson — the invitation flow. Inviting a teammate by email, completing signup, role + tenant assigned atomically.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub