Skip to content

Permission required

Not an error — the page works, the viewer just cannot see it.

Statesstarteremptypermissionaccessrole

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

This exact file renders the preview above.

import { Lock } from 'lucide-react'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { EmptyState } from '@/components/ui/empty-state'
import { Alert } from '@/components/ui/alert'

/**
 * Permission-required state
 *
 * Not an error — the page works, the viewer just cannot see it. Naming the
 * missing permission and who can grant it turns a dead end into a message the
 * user can forward, which is the only useful outcome of this screen.
 */
export default function PermissionRequiredEmptyState() {
  return (
    <section className="bg-canvas py-section">
      <Container size="narrow">
        <EmptyState
          icon={<Lock className="size-5" />}
          title="You do not have access to billing"
          description="Billing is visible to workspace Admins and Owners. Your role is Member."
          action={<ButtonLink href="/forms/detailed-contact">Request access</ButtonLink>}
          secondaryAction={
            <ButtonLink href="/starters/admin/preview" variant="ghost">
              Back to overview
            </ButtonLink>
          }
        />

        <Alert tone="info" title="Who can grant this" className="mx-auto mt-6 max-w-md">
          Priya Raman and Tomas Lindqvist are Admins on this workspace. Requesting access sends them
          a notification — in this demo, nothing is sent.
        </Alert>
      </Container>
    </section>
  )
}

components/blocks/sections/empty/permission-required.tsx

import { Lock } from 'lucide-react'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { EmptyState } from '@/components/ui/empty-state'
import { Alert } from '@/components/ui/alert'

/**
 * Permission-required state
 *
 * Not an error — the page works, the viewer just cannot see it. Naming the
 * missing permission and who can grant it turns a dead end into a message the
 * user can forward, which is the only useful outcome of this screen.
 */
export default function PermissionRequiredEmptyState() {
  return (
    <section className="bg-canvas py-section">
      <Container size="narrow">
        <EmptyState
          icon={<Lock className="size-5" />}
          title="You do not have access to billing"
          description="Billing is visible to workspace Admins and Owners. Your role is Member."
          action={<ButtonLink href="/forms/detailed-contact">Request access</ButtonLink>}
          secondaryAction={
            <ButtonLink href="/starters/admin/preview" variant="ghost">
              Back to overview
            </ButtonLink>
          }
        />

        <Alert tone="info" title="Who can grant this" className="mx-auto mt-6 max-w-md">
          Priya Raman and Tomas Lindqvist are Admins on this workspace. Requesting access sends them
          a notification — in this demo, nothing is sent.
        </Alert>
      </Container>
    </section>
  )
}

components/ui/empty-state.tsx

import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'

/**
 * EmptyState
 *
 * An empty state is a piece of product writing more than a piece of UI, so the
 * component enforces the three parts that make one useful: what is missing,
 * why it might be missing, and the single most likely next action.
 */
export interface EmptyStateProps {
  icon?: ReactNode
  title: string
  description?: string
  action?: ReactNode
  secondaryAction?: ReactNode
  /** `panel` draws a dashed enclosure; `bare` sits inside an existing panel. */
  appearance?: 'panel' | 'bare'
  size?: 'sm' | 'md' | 'lg'
  className?: string
}

export function EmptyState({
  icon,
  title,
  description,
  action,
  secondaryAction,
  appearance = 'panel',
  size = 'md',
  className,
}: EmptyStateProps) {
  const padding = { sm: 'py-8', md: 'py-12', lg: 'py-20' }[size]

  return (
    <div
      className={cn(
        'flex flex-col items-center px-6 text-center',
        padding,
        appearance === 'panel' &&
          'rounded-lg border border-dashed border-line-strong bg-surface-sunken/60',
        className,
      )}
    >
      {icon ? (
        <div className="mb-4 flex size-11 items-center justify-center rounded-full border border-line bg-surface text-ink-subtle">
          {icon}
        </div>
      ) : null}
      <p className="text-md font-semibold text-ink-strong text-balance">{title}</p>
      {description ? (
        <p className="mt-1.5 max-w-sm text-sm text-ink-muted text-pretty">{description}</p>
      ) : null}
      {(action || secondaryAction) && (
        <div className="mt-5 flex flex-wrap items-center justify-center gap-2">
          {action}
          {secondaryAction}
        </div>
      )}
    </div>
  )
}

components/ui/alert.tsx

import type { ReactNode } from 'react'
import { Info, CheckCircle2, AlertTriangle, OctagonAlert } from 'lucide-react'
import { cn } from '@/lib/cn'

/**
 * Alert
 *
 * A block-level message attached to a region of the page. The icon is chosen
 * from the tone, and `role` switches to `alert` for danger so screen readers
 * interrupt — informational tones use the polite `status` role instead.
 */
export type AlertTone = 'info' | 'success' | 'warning' | 'danger' | 'neutral'

export interface AlertProps {
  tone?: AlertTone
  title?: ReactNode
  children?: ReactNode
  /** Rendered under the body — usually one or two buttons. */
  actions?: ReactNode
  /** Slot for a dismiss control supplied by the caller. */
  trailing?: ReactNode
  className?: string
  /** Force the live-region politeness rather than deriving it from tone. */
  live?: 'off' | 'polite' | 'assertive'
}

const tones = {
  info: { icon: Info, surface: 'bg-info-soft border-info-line', accent: 'text-info' },
  success: {
    icon: CheckCircle2,
    surface: 'bg-success-soft border-success-line',
    accent: 'text-success',
  },
  warning: {
    icon: AlertTriangle,
    surface: 'bg-warning-soft border-warning-line',
    accent: 'text-warning',
  },
  danger: {
    icon: OctagonAlert,
    surface: 'bg-danger-soft border-danger-line',
    accent: 'text-danger',
  },
  neutral: { icon: Info, surface: 'bg-surface-sunken border-line', accent: 'text-ink-muted' },
} as const

export function Alert({
  tone = 'info',
  title,
  children,
  actions,
  trailing,
  className,
  live,
}: AlertProps) {
  const entry = tones[tone]
  const Icon = entry.icon
  const politeness = live ?? (tone === 'danger' ? 'assertive' : 'polite')

  return (
    <div
      role={tone === 'danger' ? 'alert' : 'status'}
      aria-live={politeness === 'off' ? undefined : politeness}
      className={cn('flex gap-3 rounded-md border p-3 text-sm', entry.surface, className)}
    >
      <Icon className={cn('mt-0.5 size-4 shrink-0', entry.accent)} aria-hidden="true" />
      <div className="min-w-0 flex-1">
        {title ? <p className="font-semibold text-ink-strong">{title}</p> : null}
        {children ? <div className={cn('text-ink-muted', title && 'mt-1')}>{children}</div> : null}
        {actions ? <div className="mt-3 flex flex-wrap gap-2">{actions}</div> : null}
      </div>
      {trailing ? <div className="shrink-0">{trailing}</div> : null}
    </div>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Naming the missing permission and who can grant it turns a dead end into a message the user can forward, which is the only useful outcome of this screen.

  • State the viewer's current role; 'access denied' without it is unactionable.
  • Name the people who can grant access if you can.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Named permission
  • Current role
  • Who-can-grant note

Accessibility

Not an error role
This is a status, not an alert — nothing has failed.
Actionable copy
The request action leads to a real flow.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.