Role gates in the UI
Hide actions the user can't do.
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
export const ROLES = {USER: 'user', // default — read-only mostlySTAFF: 'staff', // can editADMIN: 'admin', // full accessOWNER: 'owner', // tenant owner — billing too} as constexport 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
'use client'import { useCurrentUser } from '@/hooks/use-current-user'import { hasRole, type UserRole } from '@workspace/shared/constants/roles'interface Props {role: UserRolechildren: React.ReactNode}export function Can({ role, children }: Props) {const { data: user } = useCurrentUser()if (!user) return nullreturn 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:
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 />}
useCan hook for conditional logic
export function useCan(role: UserRole) {const { data: user } = useCurrentUser()if (!user) return falsereturn 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
<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:
- In
apps/admin/app/resources/customers/page.tsx, wrap the Delete action in<Can role="admin">. - Promote one of your test users to
staff(not admin). Log in as them. The Delete button should be hidden. - 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