Permission card for connecting an agent to an app: scopes with risk levels, optional trims, and an animated connect handshake.
Set your access key from API keys as NEXTUI_TOKEN in .env.local and add the @nextui namespace below to components.json once. Then every item installs by name. Each install counts as a copy on a free account.
$pnpm dlx shadcn@latest add @nextui/oauth-consent
1{2"registries": {3"@nextui": {4"url": "https://nextui.dev/r/{name}.json",5"headers": { "Authorization": "Bearer ${NEXTUI_TOKEN}" }6}7}8}
1"use client";23import { OAuthConsent } from "@/components/nextui/oauth-consent";45export function ConnectDrive() {6return (7<OAuthConsent8agentName="Acme Assistant"9appName="Northwind Drive"10account="kim@northwind.test"11onAllow={(scopes) => startOAuth(scopes)}12/>13);14}
| Prop | Type | Default | Description |
|---|---|---|---|
| agentName | string | "Acme Assistant" | App asking for access |
| appName | string | "Northwind Drive" | App being connected |
| account | string | "kim@northwind.test" | Signed-in account |
| scopes | OAuthScope[] | – | Scopes with id, label, description, risk (low, medium, high), required and icon |
| defaultDenied | string[] | – | Optional scopes that start unchecked |
| status | "pending" | "connecting" | "connected" | "denied" | – | Controlled status |
| onAllow | (scopeIds: string[]) => void | – | Called with the granted scopes |
| onDeny | () => void | – | Called when access is denied |
| onRevoke | () => void | – | Called from Revoke on the connected state |
| autoPlay | boolean | false | Loop the scripted demo; it stops on interaction and pauses offscreen |
| className | string | – | Extra classes on the wrapper |
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { Check, Eye, HardDrive, Lock, PencilLine, ShieldCheck, Sparkles, Trash2, Clock, type LucideIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type OAuthScope = {
id: string;
label: string;
description: string;
risk: "low" | "medium" | "high";
/** Required scopes can't be unchecked. */
required?: boolean;
icon?: LucideIcon;
};
export type OAuthConsentProps = {
agentName?: string;
appName?: string;
account?: string;
scopes?: OAuthScope[];
/** Scopes that start unchecked. */
defaultDenied?: string[];
/** Controlled status. */
status?: "pending" | "connecting" | "connected" | "denied";
onAllow?: (scopeIds: string[]) => void;
onDeny?: () => void;
onRevoke?: () => void;
/** Loop a scripted review → trim scopes → allow → connected demo. */
autoPlay?: boolean;
className?: string;
};
const DEFAULT_SCOPES: OAuthScope[] = [
{ id: "read", label: "View files and folders", description: "Search and read documents you can open", risk: "low", required: true, icon: Eye },
{ id: "write", label: "Create and edit documents", description: "Draft files and update ones it created", risk: "medium", icon: PencilLine },
{ id: "delete", label: "Permanently delete files", description: "Cannot be undone from the trash", risk: "high", icon: Trash2 },
{ id: "offline", label: "Work while you're away", description: "Keeps access for scheduled tasks", risk: "low", icon: Clock },
];
const RISK = {
low: "bg-muted text-muted-foreground",
medium: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
high: "bg-rose-500/10 text-rose-700 dark:text-rose-300",
} as const;
const EASE = [0.22, 1, 0.36, 1] as const;
const TICK = 100;
const LOOP = 9600;
function useDemoClock(enabled: boolean, loop: number) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el || !enabled) return;
const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));
io.observe(el);
return () => io.disconnect();
}, [enabled]);
useEffect(() => {
if (!enabled || !visible) return;
const id = setInterval(() => setElapsed((e) => e + TICK), TICK);
return () => clearInterval(id);
}, [enabled, visible]);
return { ref, t: elapsed % loop };
}
type Status = "pending" | "connecting" | "connected" | "denied";
type View = { status: Status; denied: string[]; press: "allow" | "delete" | null };
function demoView(t: number): View {
return {
status: t >= 4900 ? "connected" : t >= 3500 ? "connecting" : "pending",
denied: t >= 1900 ? ["delete"] : [],
press: t >= 1700 && t < 1900 ? "delete" : t >= 3300 && t < 3500 ? "allow" : null,
};
}
/** Permission card for connecting an agent to an app: scopes with risk levels, optional trims, and a connect → connected handshake. */
export function OAuthConsent({
agentName = "Acme Assistant",
appName = "Northwind Drive",
account = "kim@northwind.test",
scopes = DEFAULT_SCOPES,
defaultDenied = [],
status: statusProp,
onAllow,
onDeny,
onRevoke,
autoPlay = false,
className,
}: OAuthConsentProps) {
const reduce = useReducedMotion() ?? false;
const uid = useId();
const [touched, setTouched] = useState(false);
const demo = autoPlay && !reduce && !touched && !statusProp && scopes === DEFAULT_SCOPES;
const { ref, t } = useDemoClock(demo, LOOP);
const [view, setView] = useState<View>({ status: "pending", denied: defaultDenied, press: null });
// Sync the scripted frame during render (no effect → no extra commit per tick).
const [syncedT, setSyncedT] = useState(-1);
if (demo && t !== syncedT) {
setSyncedT(t);
setView(demoView(t));
}
const status = statusProp ?? view.status;
const { denied, press } = view;
const granted = scopes.filter((s) => s.required || !denied.includes(s.id));
const busy = status === "connecting";
const linked = status === "connected";
useEffect(() => {
if (demo || statusProp || view.status !== "connecting") return;
const id = setTimeout(() => setView((v) => ({ ...v, status: "connected" })), reduce ? 0 : 1400);
return () => clearTimeout(id);
}, [demo, statusProp, view.status, reduce]);
const patch = (fn: (v: View) => View) => {
setTouched(true);
setView(fn);
};
return (
<div ref={ref} className={cn("w-full max-w-md text-sm", className)}>
<div className="overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-18px_rgb(0_0_0/0.25)]">
{/* Handshake */}
<div className="relative flex items-center justify-center gap-0 bg-gradient-to-b from-muted/60 to-transparent px-4 pt-5 pb-3" aria-hidden>
<span className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_30%,rgb(14_165_233/0.12),transparent_60%)]" />
<AppTile icon={Sparkles} className="bg-foreground text-background" pulse={busy && !reduce} />
<span className="relative mx-1 flex h-10 w-28 items-center">
<span className="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 border-t border-dashed border-foreground/25" />
<motion.span
className="absolute top-1/2 left-0 h-0.5 -translate-y-1/2 rounded-full bg-gradient-to-r from-sky-500 via-indigo-500 to-emerald-500"
initial={false}
animate={{ width: linked ? "100%" : busy ? "100%" : "0%", opacity: linked || busy ? 1 : 0 }}
transition={{ duration: busy ? 1.2 : 0.4, ease: EASE }}
/>
{busy && !reduce && <span className="absolute top-1/2 size-1.5 -translate-y-1/2 animate-[oc-dot_1s_linear_infinite] rounded-full bg-sky-500 shadow-[0_0_8px_2px_rgb(14_165_233/0.6)]" />}
<motion.span
className={cn(
"absolute top-1/2 left-1/2 grid size-8 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border bg-background shadow-md",
linked && "border-emerald-500/40",
)}
animate={{ scale: linked ? [1, 1.2, 1] : busy ? 1.08 : 1 }}
transition={{ duration: 0.45, ease: EASE }}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={linked ? "ok" : "lock"} initial={reduce ? false : { scale: 0, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={reduce ? undefined : { scale: 0, opacity: 0 }} transition={{ type: "spring", stiffness: 600, damping: 24 }}>
{linked ? <Check className="size-4 text-emerald-600 dark:text-emerald-400" strokeWidth={3} /> : <Lock className="size-3.5" />}
</motion.span>
</AnimatePresence>
</motion.span>
</span>
<AppTile icon={HardDrive} className="bg-gradient-to-br from-sky-500 to-indigo-600 text-white" pulse={false} />
</div>
<div className="px-4 pb-2 text-center">
<p className="text-sm font-semibold">
{linked ? (
<>
{appName} is connected
</>
) : (
<>
{agentName} wants access to {appName}
</>
)}
</p>
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">
as <span className="font-mono">{account}</span>
</p>
</div>
<ul className="mx-3 mb-3 divide-y overflow-hidden rounded-xl border bg-background" aria-label="Requested permissions">
{scopes.map((s, i) => {
const on = s.required || !denied.includes(s.id);
const Icon = s.icon ?? ShieldCheck;
const hide = linked && !on;
return (
<motion.li
key={s.id}
initial={reduce ? false : { opacity: 0, x: -8 }}
animate={{ opacity: hide ? 0.35 : 1, x: 0 }}
transition={{ delay: i * 0.06, duration: 0.35, ease: EASE }}
className={cn("relative flex items-center gap-3 px-3 py-2", s.risk === "high" && on && !linked && "bg-rose-500/[0.04]")}
>
<span className={cn("grid size-7 shrink-0 place-items-center rounded-lg", RISK[s.risk])}>
<Icon className="size-3.5" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className={cn("block truncate text-xs font-medium", !on && "text-muted-foreground line-through decoration-foreground/30")}>{s.label}</span>
<span className="block truncate text-[10.5px] text-muted-foreground">{s.description}</span>
</span>
{s.risk !== "low" && <span className={cn("shrink-0 rounded-full px-1.5 py-px text-[9px] font-medium capitalize", RISK[s.risk])}>{s.risk} risk</span>}
{linked ? (
<span className={cn("grid size-[18px] shrink-0 place-items-center rounded-full", on ? "bg-emerald-500 text-white" : "bg-muted")}>{on && <Check className="size-2.5" strokeWidth={3.5} aria-label="Granted" />}</span>
) : (
<button
type="button"
role="checkbox"
aria-checked={on}
aria-label={s.label}
disabled={s.required || busy}
title={s.required ? "Required" : undefined}
onClick={() => patch((v) => ({ ...v, denied: on ? [...v.denied, s.id] : v.denied.filter((x) => x !== s.id) }))}
className={cn(
"grid size-[18px] shrink-0 cursor-pointer place-items-center rounded-[5px] border outline-none transition-[background-color,border-color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default",
on ? "border-foreground bg-foreground text-background" : "bg-background",
s.required && "opacity-50",
press === "delete" && s.id === "delete" && "scale-90",
)}
>
<AnimatePresence initial={false}>
{on && (
<motion.svg viewBox="0 0 12 12" className="size-2.5" initial={reduce ? false : { opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<motion.path d="M2.5 6.2 5 8.5 9.5 3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.25 }} />
</motion.svg>
)}
</AnimatePresence>
</button>
)}
</motion.li>
);
})}
</ul>
<div className="flex h-12 items-center gap-2 border-t px-3">
<AnimatePresence mode="popLayout" initial={false}>
{linked ? (
<motion.div key="on" initial={reduce ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={reduce ? undefined : { opacity: 0, y: -8 }} transition={{ duration: 0.3, ease: EASE }} className="flex min-w-0 flex-1 items-center gap-2" role="status">
<ShieldCheck className="size-4 shrink-0 text-emerald-600 dark:text-emerald-400" aria-hidden />
<span className="min-w-0 flex-1 truncate text-xs">
{granted.length} of {scopes.length} scopes · <span className="text-muted-foreground">revoke anytime</span>
</span>
<button
type="button"
onClick={() => {
onRevoke?.();
patch((v) => ({ ...v, status: "pending" }));
}}
className="h-8 shrink-0 cursor-pointer rounded-lg border px-3 text-xs font-medium text-rose-600 outline-none hover:bg-rose-500/5 focus-visible:ring-2 focus-visible:ring-ring dark:text-rose-400"
>
Revoke
</button>
</motion.div>
) : status === "denied" ? (
<motion.p key="no" initial={reduce ? false : { opacity: 0 }} animate={{ opacity: 1 }} className="flex-1 text-center text-xs text-muted-foreground" role="status">
Access denied · nothing was shared
</motion.p>
) : (
<motion.div key="ask" initial={reduce ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={reduce ? undefined : { opacity: 0, y: -8 }} transition={{ duration: 0.3, ease: EASE }} className="flex min-w-0 flex-1 items-center gap-2">
<span id={`${uid}-n`} className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground">
{granted.length} of {scopes.length} permissions
</span>
<button
type="button"
disabled={busy}
onClick={() => {
onDeny?.();
patch((v) => ({ ...v, status: "denied" }));
}}
className="h-8 shrink-0 cursor-pointer rounded-lg px-3 text-xs font-medium text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
>
Deny
</button>
<motion.button
type="button"
disabled={busy}
aria-describedby={`${uid}-n`}
onClick={() => {
onAllow?.(granted.map((s) => s.id));
patch((v) => ({ ...v, status: "connecting" }));
}}
animate={{ scale: press === "allow" ? 0.94 : 1 }}
transition={{ type: "spring", stiffness: 500, damping: 26 }}
className="relative inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 overflow-hidden rounded-lg bg-foreground px-3.5 text-xs font-medium text-background outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-wait"
>
{busy && !reduce && <span className="absolute inset-0 animate-[oc-shine_1.1s_ease-in-out_infinite] bg-gradient-to-r from-transparent via-background/25 to-transparent" aria-hidden />}
<span className="relative">{busy ? "Connecting…" : "Allow"}</span>
</motion.button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<style>{`@keyframes oc-dot{0%{left:0;opacity:0}15%{opacity:1}85%{opacity:1}100%{left:100%;opacity:0}}@keyframes oc-shine{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}@keyframes oc-pulse{0%{transform:scale(1);opacity:.5}100%{transform:scale(1.5);opacity:0}}`}</style>
</div>
);
}
function AppTile({ icon: Icon, className, pulse }: { icon: LucideIcon; className: string; pulse: boolean }) {
return (
<span className="relative grid size-12 shrink-0 place-items-center">
{pulse && <span className="absolute inset-0 animate-[oc-pulse_1.2s_ease-out_infinite] rounded-2xl bg-sky-500/40" />}
<span className={cn("relative grid size-12 place-items-center rounded-2xl shadow-lg ring-1 ring-black/5", className)}>
<Icon className="size-5" />
</span>
</span>
);
}
export default function OAuthConsentDemo() {
return (
<div className="flex h-[400px] w-full items-start justify-center pt-2">
<OAuthConsent autoPlay />
</div>
);
}