A just-in-time access-request row showing requester, resource, role, and justification, with approve/deny actions and a time-boxed access window.
npx shadcn@latest add @paragon/access-request"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>
);
}