Skip to contentVibraUI
Shared pagesinstalls at /no-access

No access

A 403 inside the shell: what is restricted and why, who can grant it, and a request-access form that validates through a server action and confirms in place.

Open the live page

Who is signed in matters more here than anywhere else: the page only makes sense for someone whose role does not reach the section they opened, so the reader is the first active viewer in db.members rather than the workspace owner, and the levels on offer are the roles db.roles defines minus the one they already have. Who can grant access is the owner and the admins db.members actually holds. requestAccess is a server action returning Result: an empty reason comes back pinned to the reason field and is shown beside it with the control marked invalid, and anything not pinned to a field becomes a form-level alert. Nothing navigates — the way back is a link the reader chooses, and the confirmation replaces the form in place with its heading taking focus, so a reader who is not watching the screen is told the request went. The links out go through next/link so they navigate client-side. Composes AppShell, PageHeader, EmptyState, FormRow, NativeSelect, Textarea, AsyncButton and Avatar.

Preview

Install

npx shadcn@latest add @vibra/states-no-access

Needs the @vibra registry in your components.json — set it up once.

Source

app/no-access/page.tsx
import Link from "next/link"
import { LockIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { buttonVariants } from "@/components/ui/button"
import { EmptyState } from "@/components/ui/empty-state"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { RequestAccess } from "./components/request-access"
import {
  accessLevels,
  approvers,
  currentUser,
  readerRole,
  RESOURCE,
  shellNotifications,
} from "./data"
import { NAV, ROUTE } from "./nav"

/**
 * The 403, inside the shell so the reader keeps everything they *can* open.
 * Nothing redirects: the way back is a link they choose, and the way forward
 * is a request that resolves in place.
 */
export default function NoAccessPage() {
  const role = readerRole()

  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTE}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="You don't have access"
        description={`${RESOURCE.name} is restricted, and your role does not reach it.`}
        meta={
          <>
            Signed in as {role} · <span className="font-mono">{RESOURCE.path}</span>
          </>
        }
      />

      <EmptyState
        variant="dashed"
        icon={<LockIcon />}
        title={`${RESOURCE.name} is restricted`}
        description={`Your role is ${role}, which does not include this section. Ask for the access you need below, or go back to what you can already open.`}
        action={
          <Link href="/saas" className={cn(buttonVariants())}>
            Back to overview
          </Link>
        }
        secondaryAction={
          <Link href="/support" className={cn(buttonVariants({ variant: "outline" }))}>
            Contact support
          </Link>
        }
      />

      <div className="grid gap-4 md:grid-cols-2">
        <RequestAccess levels={accessLevels()} />

        <section aria-label="Who can grant it" className="flex flex-col gap-3 panel p-4">
          <h2 className="text-sm font-medium">Who can grant it</h2>
          <ul className="flex flex-col gap-3">
            {approvers().map((approver) => (
              <li key={approver.id} className="flex items-center gap-3">
                <Avatar className="size-7 shrink-0">
                  {approver.avatarUrl ? <AvatarImage src={approver.avatarUrl} alt="" /> : null}
                  <AvatarFallback className="text-avatar">{approver.initials}</AvatarFallback>
                </Avatar>
                <span className="flex min-w-0 flex-col">
                  <span className="truncate text-sm">{approver.name}</span>
                  <span className="truncate text-xs text-muted-foreground">
                    {approver.email}
                  </span>
                </span>
                <span className="ml-auto shrink-0 text-xs text-muted-foreground">
                  {approver.role}
                </span>
              </li>
            ))}
          </ul>
        </section>
      </div>
    </AppShell>
  )
}
app/no-access/nav.ts
import { type NavConfig } from "@/lib/nav-config"

/** The route this page is installed at. AppShell matches the nav against it. */
export const ROUTE = "/no-access"

/**
 * This product's navigation, as plain data. Nothing here points at this page:
 * a state route is somewhere you land, never somewhere you navigate to, so no
 * item lights up and the trail is the brand alone — which is the point. The
 * nav is here so the reader has a way back out.
 */
export const NAV: NavConfig = {
  brand: { name: "Northwind", initial: "N", href: "/saas", caption: "Production" },
  groups: [
    {
      label: "Workspace",
      items: [
        { title: "Overview", href: "/saas", icon: "layout-dashboard" },
        { title: "Inbox", href: "/support/inbox", icon: "inbox" },
        { title: "Calendar", href: "/projects/calendar", icon: "calendar" },
        { title: "Customers", href: "/ecommerce/customers", icon: "users" },
      ],
    },
    {
      label: "Platform",
      items: [
        { title: "AI usage", href: "/ai", icon: "sparkles" },
        { title: "Reports", href: "/reports", icon: "file-text" },
      ],
    },
  ],
  // Pinned under the groups, the way the secondary links were.
  footer: [
    { title: "Settings", href: "/settings", icon: "settings" },
    { title: "Support", href: "/support", icon: "life-buoy" },
  ],
}
app/no-access/data.ts
/**
 * What this page reads. Who is signed in matters here more than anywhere else:
 * the page only makes sense for someone whose role does not reach the section
 * they opened, so the reader is the first active viewer in `db.members` rather
 * than the workspace owner. Who can grant access is the owner and the admins
 * `db.members` actually holds, and the levels on offer are the roles
 * `db.roles` defines.
 */
import { getInitials } from "@/lib/format"
import { db, type Member } from "@/lib/sample-data"

/**
 * What was being opened. A real 403 is handed this by the route that refused —
 * a page cannot ask which door it is standing outside — so it is stated here
 * the way the preview needs it.
 */
export const RESOURCE = { name: "Billing", path: "/settings/billing" }

// Read per call, never held at module scope: a role changed or a member
// deactivated since the server started is what the next render shows.
const active = () => db.members.all().filter((member) => member.status === "active")

// Whoever is reading a 403 is by definition not an admin, so the page signs in
// the least-privileged person the workspace has.
function readerRow(): Member {
  const members = active()
  return members.find((member) => member.role === "viewer") ?? members[0]
}

/** The role the reader has, which is the reason they are here. */
export function readerRole(): string {
  const { role } = readerRow()
  return role.charAt(0).toUpperCase() + role.slice(1)
}

export type Approver = { id: string; name: string; email: string; role: string; initials: string; avatarUrl?: string }

/** Everyone who could say yes: the owner first, then the admins. */
export function approvers(): Approver[] {
  return active()
    .filter((member) => member.role === "owner" || member.role === "admin")
    .sort((a, b) => (a.role === "owner" ? -1 : b.role === "owner" ? 1 : 0))
    .slice(0, 4)
    .map((member) => ({
      id: member.id,
      name: member.name,
      email: member.email,
      role: member.role === "owner" ? "Owner" : "Admin",
      initials: getInitials(member.name),
      avatarUrl: member.avatarUrl,
    }))
}

export type AccessLevel = { id: string; name: string; description: string }

/** How much a role can do: every verb it holds, across every resource. */
function reach(permissions: Record<string, string[]>): number {
  return Object.values(permissions).reduce((total, verbs) => total + verbs.length, 0)
}

/**
 * The levels this workspace defines, minus the one the reader already has,
 * least powerful first — so the request opens on the smallest ask rather than
 * on "Owner".
 */
export function accessLevels(): AccessLevel[] {
  const reader = readerRow()
  return db.roles
    .all()
    .filter((role) => role.name.toLowerCase() !== reader.role)
    .sort((a, b) => reach(a.permissions) - reach(b.permissions))
    .map(({ id, name, description }) => ({ id, name, description }))
}

export function currentUser() {
  const reader = readerRow()
  return { name: reader.name, email: reader.email, initials: getInitials(reader.name), avatarUrl: reader.avatarUrl }
}

export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
app/no-access/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { asString, db, fields, REFERENCE_DATE, type Result } from "@/lib/sample-data"

/**
 * What this page changes. Server actions so the page can stay a server
 * component, and `Result` so the form reads one success-or-error shape: a
 * message the action pins to a field is shown beside that field, and anything
 * else becomes a form-level alert.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}

export type AccessRequest = {
  /** A `Role` id — the level being asked for. */
  levelId: string
  reason: string
}

export type AccessReceipt = {
  level: string
  /** How many people the request went to. */
  sentTo: number
  at: Date
}

/** Asks the workspace's owner and admins for a level of access. */
export async function requestAccess(sent: AccessRequest): Promise<Result<AccessReceipt>> {
  const input = fields(sent)
  const reason = asString(input.reason).trim()
  if (!reason) {
    return {
      ok: false,
      error: {
        code: "required",
        message: "Say what you need access for — an admin has to be able to judge it.",
        field: "reason",
      },
    }
  }

  const level = db.roles.all().find((role) => role.id === input.levelId)
  if (!level) {
    return {
      ok: false,
      error: { code: "not_found", message: "That access level no longer exists.", field: "level" },
    }
  }

  const sentTo = db.members
    .all()
    .filter(
      (member) =>
        member.status === "active" && (member.role === "owner" || member.role === "admin")
    ).length

  return { ok: true, data: { level: level.name, sentTo, at: REFERENCE_DATE } }
}
app/no-access/components/request-access.tsx
"use client"

import * as React from "react"
import { CheckIcon, SendIcon } from "lucide-react"

import { formatDate } from "@/lib/format"
import { AsyncButton } from "@/components/ui/async-button"
import { FormRow } from "@/components/ui/form-section"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Textarea } from "@/components/ui/textarea"

import { requestAccess, type AccessReceipt } from "../actions"
import { type AccessLevel } from "../data"

/**
 * The form that asks for access, and the receipt that replaces it. Nothing
 * navigates: the confirmation happens in place, and its heading takes focus so
 * a reader who is not watching the screen is told the request went.
 */
export function RequestAccess({ levels }: { levels: AccessLevel[] }) {
  const [levelId, setLevelId] = React.useState(levels[0]?.id ?? "")
  const [reason, setReason] = React.useState("")
  const [fieldError, setFieldError] = React.useState<Record<string, string>>({})
  const [formError, setFormError] = React.useState<string | undefined>(undefined)
  const [receipt, setReceipt] = React.useState<AccessReceipt | null>(null)
  const [sending, setSending] = React.useState(false)
  const heading = React.useRef<HTMLHeadingElement>(null)

  React.useEffect(() => {
    if (receipt) heading.current?.focus()
  }, [receipt])

  if (receipt) {
    return (
      <section
        aria-label="Access requested"
        className="flex flex-col items-start gap-3 panel p-4"
      >
        <span className="flex size-8 items-center justify-center rounded-full bg-success-muted text-success">
          <CheckIcon aria-hidden="true" className="size-4" />
        </span>
        <h2 ref={heading} tabIndex={-1} className="text-sm font-medium outline-none">
          Request sent
        </h2>
        <p className="text-sm text-muted-foreground">
          Your request for {receipt.level} access went to {receipt.sentTo} people on{" "}
          {formatDate(receipt.at, "medium", { timeZone: "UTC" })}. You will get a notification either way.
        </p>
      </section>
    )
  }

  async function submit(event: React.FormEvent) {
    event.preventDefault()
    setSending(true)
    const result = await requestAccess({ levelId, reason })
    setSending(false)

    if (!result.ok) {
      // A message the action pinned to a field belongs beside that field;
      // anything else is the form's to announce.
      if (result.error.field) {
        setFieldError({ [result.error.field]: result.error.message })
        setFormError(undefined)
      } else {
        setFieldError({})
        setFormError(result.error.message)
      }
      return
    }

    setFieldError({})
    setFormError(undefined)
    setReceipt(result.data)
  }

  return (
    <form
      aria-label="Request access"
      onSubmit={submit}
      className="flex flex-col gap-4 panel p-4"
    >
      <h2 className="text-sm font-medium">Ask for access</h2>

      <FormRow label="Access level" htmlFor="access-level" error={fieldError.level}>
        <NativeSelect
          id="access-level"
          className="w-full"
          value={levelId}
          onChange={(event) => setLevelId(event.target.value)}
        >
          {levels.map((level) => (
            <NativeSelectOption key={level.id} value={level.id}>
              {level.name}
            </NativeSelectOption>
          ))}
        </NativeSelect>
      </FormRow>

      <FormRow
        label="Why do you need it?"
        htmlFor="access-reason"
        description="An admin sees this, so say what you are trying to do."
        required
        error={fieldError.reason}
      >
        <Textarea
          id="access-reason"
          rows={3}
          value={reason}
          placeholder="I need to check this month's invoices before the finance review."
          onChange={(event) => setReason(event.target.value)}
          className="resize-none"
        />
      </FormRow>

      {formError ? (
        <p role="alert" className="text-sm text-danger">
          {formError}
        </p>
      ) : null}

      <AsyncButton type="submit" size="sm" loading={sending} loadingText="Sending" className="self-start">
        <SendIcon data-icon="inline-start" aria-hidden="true" />
        Request access
      </AsyncButton>
    </form>
  )
}