Security

Access Request

A just-in-time access-request row showing requester, resource, role, and justification, with approve/deny actions and a time-boxed access window.

Install

npx shadcn@latest add @paragon/access-request

access-request.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Clock, ShieldCheck, X } from "lucide-react";
import { cn } from "@/lib/utils";

export interface AccessRequestProps extends React.ComponentProps<"div"> {
  /** Requester name. */
  requester?: string;
  /** Requester email / handle. */
  handle?: string;
  /** Resource being requested. */
  resource?: string;
  /** Role or permission level requested. */
  role?: string;
  /** Justification supplied by the requester. */
  reason?: string;
  /** Requested access window, e.g. "4 hours". */
  duration?: string;
  /** Disables enter animation. */
  static?: boolean;
}

type Decision = "pending" | "approved" | "denied";

/**
 * A just-in-time access-request row with approve / deny actions and an access
 * expiry window. Deciding blur-crossfades the action pair to a resolved chip
 * via AnimatePresence, so the row height stays fixed. The expiry is shown as a
 * relative window (not a live clock) so render stays deterministic. Reduced
 * motion keeps the crossfade as a plain fade.
 */
export function AccessRequest({
  requester = "Priya Raman",
  handle = "priya@acme.com",
  resource = "prod-payments-db",
  role = "Read/Write",
  reason = "Investigating failed settlement batch #4471",
  duration = "4 hours",
  static: isStatic = false,
  className,
  ...props
}: AccessRequestProps) {
  const reduced = useReducedMotion();
  const [decision, setDecision] = React.useState<Decision>("pending");

  const initials = requester
    .split(" ")
    .map((n) => n[0])
    .slice(0, 2)
    .join("");

  return (
    <div
      data-slot="access-request"
      className={cn(
        "w-full max-w-md rounded-xl bg-card p-4 text-card-foreground shadow-border",
        className,
      )}
      style={
        !isStatic && !reduced
          ? ({
              animation: "access-enter var(--duration-base) var(--ease-out)",
            } as React.CSSProperties)
          : undefined
      }
      {...props}
    >
      <style href="paragon-access-request" precedence="paragon">{`
        @keyframes access-enter {
          from { opacity: 0; transform: translateY(8px); filter: blur(4px); }
          to { opacity: 1; transform: translateY(0); filter: blur(0); }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-slot="access-request"] { animation: none !important; }
        }
      `}</style>

      <div className="flex items-start gap-3">
        <span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-secondary text-xs font-semibold text-foreground uppercase">
          {initials}
        </span>
        <div className="min-w-0 flex-1">
          <p className="text-sm font-semibold">{requester}</p>
          <p className="truncate text-xs text-muted-foreground">{handle}</p>
        </div>
        <span className="flex shrink-0 items-center gap-1 rounded-md bg-secondary px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground tabular-nums">
          <Clock className="size-3" />
          {duration}
        </span>
      </div>

      <div className="mt-3 rounded-lg border bg-background px-3 py-2.5 text-[13px]">
        <div className="flex items-center gap-2">
          <ShieldCheck className="size-4 shrink-0 text-muted-foreground" />
          <span className="font-mono">{resource}</span>
          <span className="rounded bg-secondary px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground">
            {role}
          </span>
        </div>
        <p className="mt-2 text-xs text-muted-foreground">{reason}</p>
      </div>

      <div className="mt-3">
        <AnimatePresence mode="wait" initial={false}>
          {decision === "pending" ? (
            <motion.div
              key="actions"
              className="flex gap-2"
              initial={false}
              exit={reduced ? { opacity: 0 } : { opacity: 0, filter: "blur(4px)" }}
              transition={{ duration: 0.15 }}
            >
              <button
                type="button"
                onClick={() => setDecision("approved")}
                className="pressable inline-flex h-8 flex-1 items-center justify-center gap-1.5 rounded-lg bg-primary px-3 text-[13px] font-medium text-primary-foreground transition-[opacity] duration-(--duration-fast) hover:opacity-90 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
              >
                <Check className="size-4" />
                Approve for {duration}
              </button>
              <button
                type="button"
                onClick={() => setDecision("denied")}
                className="pressable inline-flex h-8 items-center justify-center gap-1.5 rounded-lg bg-card px-3 text-[13px] font-medium shadow-border transition-[box-shadow] duration-(--duration-fast) hover:shadow-border-hover outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
              >
                <X className="size-4" />
                Deny
              </button>
            </motion.div>
          ) : (
            <motion.div
              key="resolved"
              className={cn(
                "flex items-center gap-2 rounded-lg px-3 py-2 text-[13px] font-medium",
                decision === "approved"
                  ? "bg-success/10 text-success"
                  : "bg-secondary text-muted-foreground",
              )}
              initial={reduced ? { opacity: 0 } : { opacity: 0, filter: "blur(4px)" }}
              animate={{ opacity: 1, filter: "blur(0px)" }}
              transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
            >
              {decision === "approved" ? (
                <>
                  <Check className="size-4" />
                  Granted — access expires in {duration}
                </>
              ) : (
                <>
                  <X className="size-4" />
                  Request denied
                </>
              )}
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </div>
  );
}