# Roles Requests

> An access request queue where admins approve or deny permission requests, with an approval length setting.

A shadcn/ui block from the [@esuiss](https://suiss.app/ui?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/esuiss/roles-requests).

- Page, with a live preview: https://shadcn.exchange/r/esuiss/roles-requests
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/esuiss/roles-requests-light-0eb512b131d61f38.png
- Registry item: https://suiss.app/ui/r/new-york-v4/roles-requests.json
- Categories: blocks, roles
- Listed: 2026-10-20, source last changed 2026-10-20

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/esuiss/roles-requests.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `@esuiss/avatar`, `@esuiss/button`, `@esuiss/empty`, `@esuiss/select`, `@esuiss/sidebar`

## Files

### `components/blocks/roles-requests.tsx`

```tsx
"use client"

import * as React from "react"

import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Empty,
  EmptyContent,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
  useSidebar,
} from "@/components/ui/sidebar"

type NavItem = {
  value: string
  label: string
  icon: React.ReactNode
  badge?: React.ReactNode
  group?: string
}

function NavSidebar({
  items,
  value: valueProp,
  defaultValue,
  onValueChange,
  header,
  footer,
}: {
  items: NavItem[]
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  header?: React.ReactNode
  footer?: React.ReactNode
}) {
  const { isMobile, setOpenMobile } = useSidebar()
  const [uncontrolledValue, setUncontrolledValue] = React.useState(
    defaultValue ?? items[0]?.value
  )
  const value = valueProp ?? uncontrolledValue
  const groups = [...new Set(items.map((item) => item.group ?? ""))]

  return (
    <Sidebar collapsible="icon">
      {header && (
        <SidebarHeader className="group-data-[collapsible=icon]:hidden">
          {header}
        </SidebarHeader>
      )}
      <SidebarContent>
        {groups.map((group) => (
          <SidebarGroup key={group}>
            {group && <SidebarGroupLabel>{group}</SidebarGroupLabel>}
            <SidebarGroupContent>
              <SidebarMenu>
                {items
                  .filter((item) => (item.group ?? "") === group)
                  .map((item) => (
                    <SidebarMenuItem key={item.value}>
                      <SidebarMenuButton
                        isActive={item.value === value}
                        tooltip={item.label}
                        aria-current={item.value === value ? "page" : undefined}
                        onClick={() => {
                          if (valueProp === undefined) {
                            setUncontrolledValue(item.value)
                          }
                          onValueChange?.(item.value)
                          if (isMobile) setOpenMobile(false)
                        }}
                      >
                        {item.icon}
                        <span>{item.label}</span>
                      </SidebarMenuButton>
                      {item.badge !== undefined && (
                        <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>
                      )}
                    </SidebarMenuItem>
                  ))}
              </SidebarMenu>
            </SidebarGroupContent>
          </SidebarGroup>
        ))}
      </SidebarContent>
      {footer && (
        <SidebarFooter className="group-data-[collapsible=icon]:hidden">
          {footer}
        </SidebarFooter>
      )}
      <SidebarRail />
    </Sidebar>
  )
}

const nav: NavItem[] = [
  {
    value: "users",
    label: "Users",
    icon: (
      <IconPlaceholder
        lucide="UsersIcon"
        tabler="IconUsers"
        hugeicons="UserGroupIcon"
        phosphor="UsersIcon"
        remixicon="RiGroupLine"
      />
    ),
  },
  {
    value: "roles",
    label: "Roles",
    icon: (
      <IconPlaceholder
        lucide="ShieldIcon"
        tabler="IconShield"
        hugeicons="Shield01Icon"
        phosphor="ShieldIcon"
        remixicon="RiShieldLine"
      />
    ),
  },
  {
    value: "log",
    label: "Activity",
    icon: (
      <IconPlaceholder
        lucide="ChartColumnIcon"
        tabler="IconChartBar"
        hugeicons="ChartColumnIcon"
        phosphor="ChartBarIcon"
        remixicon="RiBarChartLine"
      />
    ),
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <IconPlaceholder
        lucide="SettingsIcon"
        tabler="IconSettings"
        hugeicons="Settings01Icon"
        phosphor="GearIcon"
        remixicon="RiSettingsLine"
      />
    ),
  },
]

type Decision = "approved" | "denied" | null

const initialRequests = [
  {
    name: "Morgan Lee",
    permission: "Change prices",
    reason: "I need to update the campaign prices.",
    time: "10 min ago",
    hue: 30,
  },
  {
    name: "Riley Chen",
    permission: "Export customers",
    reason: "I need the customer list for the monthly report.",
    time: "1 hr ago",
    hue: 150,
  },
  {
    name: "Jordan Park",
    permission: "Issue refunds",
    reason: "So I can refund support tickets myself.",
    time: "Yesterday",
    hue: 100,
  },
  {
    name: "Taylor Kim",
    permission: "Publish",
    reason: "I'd like to publish blog posts.",
    time: "2 days ago",
    hue: 200,
  },
]

export function RolesRequests() {
  const idPrefix = React.useId()
  const [requests, setRequests] = React.useState(() =>
    initialRequests.map((request) => ({
      ...request,
      decision: null as Decision,
    }))
  )
  const [duration, setDuration] = React.useState("Permanent")
  const open = requests.filter((request) => !request.decision).length

  const decide = (name: string, decision: Decision) => {
    setRequests((current) =>
      current.map((request) =>
        request.name === name ? { ...request, decision } : request
      )
    )
    window.setTimeout(
      () =>
        setRequests((current) =>
          current.filter((request) => request.name !== name)
        ),
      900
    )
  }

  return (
    <SidebarProvider>
      <NavSidebar
        items={nav}
        defaultValue="roles"
        header={
          <p className="flex items-center gap-2 px-2 pt-1 text-sm font-semibold">
            <span className="font-bold">suiss</span> Admin
          </p>
        }
      />
      <SidebarInset>
        <div className="flex flex-col gap-5 px-5 py-5.5 md:px-7">
          <div className="flex flex-wrap items-start gap-3">
            <SidebarTrigger className="-ms-2 mt-0.5" />
            <div className="flex flex-1 flex-col">
              <h1 className="text-3xl font-semibold tracking-tight">
                Access requests
              </h1>
              <p className="text-sm text-muted-foreground" aria-live="polite">
                {open} pending {open === 1 ? "request" : "requests"}
              </p>
            </div>
            <div className="flex items-center gap-2">
              <span
                id={`${idPrefix}-duration`}
                className="text-sm text-muted-foreground"
              >
                Approval length
              </span>
              <Select
                value={duration}
                onValueChange={(value) => setDuration(value as string)}
              >
                <SelectTrigger
                  size="sm"
                  aria-labelledby={`${idPrefix}-duration`}
                  className="h-7.5"
                >
                  <SelectValue />
                </SelectTrigger>
                <SelectContent align="end">
                  {["24 hours", "7 days", "Permanent"].map((item) => (
                    <SelectItem key={item} value={item}>
                      {item}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>
          </div>
          {requests.length > 0 ? (
            <ul className="overflow-hidden rounded-2xl bg-muted">
              {requests.map((request) => (
                <li
                  key={request.name}
                  data-closing={request.decision ? "" : undefined}
                  className="grid grid-rows-[1fr] transition-[grid-template-rows,opacity] delay-500 duration-400 ease-[cubic-bezier(0.32,0.72,0,1)] not-data-closing:delay-0 data-closing:grid-rows-[0fr] data-closing:opacity-0 motion-reduce:transition-none"
                >
                  <div className="flex min-h-0 items-start gap-3 overflow-hidden ps-4">
                    <Avatar className="mt-3 size-8.5">
                      <AvatarFallback
                        style={{
                          background: `linear-gradient(160deg, oklch(0.78 0.1 ${request.hue}), oklch(0.6 0.14 ${request.hue + 30}))`,
                        }}
                        className="text-white"
                      >
                        {request.name
                          .split(" ")
                          .map((part) => part[0])
                          .join("")}
                      </AvatarFallback>
                    </Avatar>
                    <div className="flex min-w-0 flex-1 flex-col gap-2.5 border-border py-3 pe-4 sm:flex-row sm:items-start sm:gap-3 [li+li_&]:border-t">
                      <div className="flex min-w-0 flex-1 flex-col gap-0.75">
                        <span className="flex items-center gap-2">
                          <span className="text-sm font-semibold">
                            {request.name}
                          </span>
                          <span className="text-xs text-muted-foreground">
                            {request.time}
                          </span>
                        </span>
                        <span className="text-sm">
                          Requests {request.permission}
                        </span>
                        <span className="text-sm text-muted-foreground">
                          {request.reason}
                        </span>
                      </div>
                      {request.decision ? (
                        <span
                          role="status"
                          data-decision={request.decision}
                          className="pt-0.5 text-sm font-semibold text-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_45%)] data-[decision=denied]:text-destructive dark:text-green-500 dark:data-[decision=denied]:text-destructive"
                        >
                          {request.decision === "approved"
                            ? "Approved"
                            : "Denied"}
                        </span>
                      ) : (
                        <div className="flex flex-wrap gap-2 sm:justify-end">
                          <Button
                            size="sm"
                            variant="secondary"
                            onClick={() => decide(request.name, "denied")}
                          >
                            Deny
                          </Button>
                          <Button
                            size="sm"
                            onClick={() => decide(request.name, "approved")}
                          >
                            Approve
                          </Button>
                        </div>
                      )}
                    </div>
                  </div>
                </li>
              ))}
            </ul>
          ) : (
            <Empty className="gap-3 p-10 transition-[opacity,translate] duration-800 starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none">
              <EmptyHeader>
                <EmptyMedia className="mb-0 text-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_35%)] dark:text-green-500">
                  <IconPlaceholder
                    lucide="CheckIcon"
                    tabler="IconCheck"
                    hugeicons="Tick02Icon"
                    phosphor="CheckIcon"
                    remixicon="RiCheckLine"
                    className="size-10"
                  />
                </EmptyMedia>
                <EmptyTitle className="text-xl">
                  No pending requests.
                </EmptyTitle>
              </EmptyHeader>
              <EmptyContent>
                <button
                  type="button"
                  onClick={() =>
                    setRequests(
                      initialRequests.map((request) => ({
                        ...request,
                        decision: null,
                      }))
                    )
                  }
                  className="rounded-xs text-sm text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
                >
                  Reset the sample ›
                </button>
              </EmptyContent>
            </Empty>
          )}
        </div>
      </SidebarInset>
    </SidebarProvider>
  )
}
```
