shadimage
better-auth-ui
componentagent-authsource: ok · hit

Agent Auth

Capability approval and grant revocation UI for Better Auth Agent Auth.

npx shadcn add "https://better-auth-ui.com/r/{style}/agent-auth.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

alert-dialogbadgebuttoncardcheckboxskeletonspinner

Files4 · source cached 10/9/2026, 11:06:26 PM

examples/start-shadcn-example/src/components/auth/agent-auth/agent-approval.tsx
"use client"

import type {
  AgentApprovalRequest,
  AgentAuthClient,
  AgentCapabilityGrant
} from "@better-auth-ui/core/plugins/agent-auth"
import { useAuth, useAuthPlugin, useSession } from "@better-auth-ui/react"
import {
  useAgentApproval,
  useApproveAgent,
  useDenyAgent
} from "@better-auth-ui/react/plugins/agent-auth"
import {
  BotIcon,
  CheckIcon,
  CircleCheckIcon,
  CircleXIcon,
  FingerprintIcon
} from "lucide-react"
import { useEffect, useMemo, useState } from "react"

import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle
} from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { Skeleton } from "@/components/ui/skeleton"
import { Spinner } from "@/components/ui/spinner"
import { agentAuthPlugin } from "@/lib/auth/agent-auth-plugin"
import { cn } from "cn"

type ApprovalResult = "approved" | "denied"

const strengthVariant = (strength: AgentCapabilityGrant["approvalStrength"]) =>
  strength === "webauthn" ? "outline" : "secondary"

export type AgentApprovalProps = { className?: string }

/** Render the Agent Auth approval page configured by `deviceAuthorizationPage`. */
export function AgentApproval({ className }: AgentApprovalProps) {
  const { authClient, basePaths, navigate, viewPaths } =
    useAuth<AgentAuthClient>()
  const plugin = useAuthPlugin(agentAuthPlugin)
  const session = useSession(authClient)
  const request = useMemo<AgentApprovalRequest | undefined>(() => {
    if (typeof window === "undefined") return undefined
    const query = new URLSearchParams(window.location.search)
    const agentId = query.get("agent_id")
    if (!agentId) return undefined
    return {
      agentId,
      approvalId: query.get("approval_id") ?? undefined,
      userCode: query.get("code") ?? query.get("user_code") ?? undefined
    }
  }, [])
  const approval = useAgentApproval(authClient, plugin.adapter, request)
  const approve = useApproveAgent(authClient, plugin.adapter)
  const deny = useDenyAgent(authClient, plugin.adapter)
  const [selection, setSelection] = useState<Set<string> | null>(null)
  const [result, setResult] = useState<ApprovalResult>()

  useEffect(() => {
    if (session.isPending || session.data || typeof window === "undefined") {
      return
    }
    const returnPath = `${window.location.pathname}${window.location.search}`
    navigate({
      to: `${basePaths.auth}/${viewPaths.auth.signIn}?redirectTo=${encodeURIComponent(returnPath)}`
    })
  }, [
    basePaths.auth,
    navigate,
    session.data,
    session.isPending,
    viewPaths.auth.signIn
  ])

  const requested = approval.data?.requestedCapabilities ?? []
  const selected =
    selection ?? new Set(requested.map((grant) => grant.capability))
  const updateSelection = (capability: string, isSelected: boolean) => {
    const next = new Set(selected)
    if (isSelected) next.add(capability)
    else next.delete(capability)
    setSelection(next)
  }
  const decision = {
    ...request,
    agentId: request?.agentId ?? "",
    capabilities: [...selected]
  }
  const denyDecision = {
    ...request,
    agentId: request?.agentId ?? ""
  }

  if (!request) {
    return (
      <Card className={cn("w-full max-w-md", className)}>
        <CardContent className="text-sm text-destructive">
          {plugin.localization.invalidRequest}
        </CardContent>
      </Card>
    )
  }

  if (result) {
    const approved = result === "approved"
    return (
      <Card className={cn("w-full max-w-md", className)}>
        <CardContent className="flex flex-col items-center gap-4 py-10 text-center">
          {approved ? (
            <CircleCheckIcon className="size-10 text-emerald-600" />
          ) : (
            <CircleXIcon className="size-10 text-muted-foreground" />
          )}
          <div className="flex flex-col gap-1">
            <h1 className="font-semibold">
              {approved
                ? plugin.localization.approvedTitle
                : plugin.localization.deniedTitle}
            </h1>
            <p className="text-sm text-muted-foreground">
              {approved
                ? plugin.localization.approvedDescription
                : plugin.localization.deniedDescription}
            </p>
          </div>
        </CardContent>
      </Card>
    )
  }

  return (
    <Card className={cn("w-full max-w-md", className)}>
      <CardHeader className="flex-row items-start gap-3">
        <div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-muted">
          <BotIcon className="size-5" />
        </div>
        <div className="flex min-w-0 flex-col gap-1">
          <CardTitle>{plugin.localization.approvalTitle}</CardTitle>
          <CardDescription>
            {plugin.localization.approvalDescription}
          </CardDescription>
        </div>
      </CardHeader>
      <CardContent className="flex flex-col gap-5">
        {approval.isPending || session.isPending ? (
          <div className="flex flex-col gap-3">
            <Skeleton className="h-14 rounded-xl" />
            <Skeleton className="h-20 rounded-xl" />
            <Skeleton className="h-20 rounded-xl" />
          </div>
        ) : approval.isError ? (
          <p className="text-sm text-destructive">
            {plugin.localization.approvalError}
          </p>
        ) : approval.data ? (
          <>
            <div className="flex items-center justify-between gap-3 rounded-xl bg-muted p-3">
              <div className="flex min-w-0 flex-col">
                <span className="truncate text-sm font-semibold">
                  {approval.data.name}
                </span>
                <span className="truncate text-xs text-muted-foreground">
                  {approval.data.hostName ?? approval.data.hostId}
                </span>
              </div>
              <Badge variant="secondary">
                {approval.data.mode === "autonomous"
                  ? plugin.localization.autonomousAgent
                  : plugin.localization.delegatedAgent}
              </Badge>
            </div>
            <div className="flex flex-col gap-3">
              <h2 className="text-sm font-semibold">
                {plugin.localization.requestedCapabilities}
              </h2>
              {requested.length ? (
                requested.map((grant) => (
                  <label
                    key={grant.capability}
                    htmlFor={`agent-capability-${grant.capability}`}
                    className="flex cursor-pointer items-start gap-3 rounded-lg border p-3"
                  >
                    <Checkbox
                      id={`agent-capability-${grant.capability}`}
                      className="mt-0.5"
                      checked={selected.has(grant.capability)}
                      onCheckedChange={(checked) =>
                        updateSelection(grant.capability, checked === true)
                      }
                    >
                      <CheckIcon />
                    </Checkbox>
                    <span className="flex min-w-0 flex-1 flex-col gap-1">
                      <span className="break-words text-sm font-medium">
                        {grant.capability}
                      </span>
                      {grant.description && (
                        <span className="text-xs text-muted-foreground">
                          {grant.description}
                        </span>
                      )}
                      {grant.reason && (
                        <span className="text-xs text-muted-foreground">
                          {plugin.localization.requestReason}: {grant.reason}
                        </span>
                      )}
                      {grant.constraints && (
                        <span className="text-xs text-muted-foreground">
                          {plugin.localization.constraints}:{" "}
                          <code>{JSON.stringify(grant.constraints)}</code>
                        </span>
                      )}
                      <Badge
                        className="mt-1 w-fit"
                        variant={strengthVariant(grant.approvalStrength)}
                      >
                        {grant.approvalStrength === "webauthn" && (
                          <FingerprintIcon />
                        )}
                        {grant.approvalStrength === "webauthn"
                          ? plugin.localization.approvalWebauthn
                          : grant.approvalStrength === "session"
                            ? plugin.localization.approvalSession
                            : plugin.localization.approvalNone}
                      </Badge>
                    </span>
                  </label>
                ))
              ) : (
                <p className="text-sm text-muted-foreground">
                  {plugin.localization.noCapabilities}
                </p>
              )}
            </div>
          </>
        ) : null}
      </CardContent>
      <CardFooter className="gap-3">
        <Button
          className="flex-1"
          type="button"
          variant="outline"
          disabled={approve.isPending || deny.isPending || !approval.data}
          onClick={() =>
            deny.mutate(denyDecision, {
              onSuccess: () => setResult("denied")
            })
          }
        >
          {deny.isPending && <Spinner />}
          {plugin.localization.deny}
        </Button>
        <Button
          className="flex-1"
          type="button"
          disabled={
            approve.isPending ||
            !selected.size ||
            deny.isPending ||
            !approval.data
          }
          onClick={() =>
            approve.mutate(decision, {
              onSuccess: () => setResult("approved")
            })
          }
        >
          {approve.isPending && <Spinner />}
          {plugin.localization.allow}
        </Button>
      </CardFooter>
    </Card>
  )
}
examples/start-shadcn-example/src/components/auth/agent-auth/agent-authorizations.tsx
"use client"

import type {
  AgentAuthClient,
  AgentAuthorization,
  AgentCapabilityGrant
} from "@better-auth-ui/core/plugins/agent-auth"
import { useAuth, useAuthPlugin } from "@better-auth-ui/react"
import {
  useAgentAuthorizations,
  useRevokeAgentCapability
} from "@better-auth-ui/react/plugins/agent-auth"
import { BotIcon, ShieldAlertIcon, XIcon } from "lucide-react"
import { useState } from "react"

import {
  AlertDialog,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogMedia,
  AlertDialogTitle
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Spinner } from "@/components/ui/spinner"
import { agentAuthPlugin } from "@/lib/auth/agent-auth-plugin"
import { cn } from "cn"

type RevokeTarget = {
  agent: AgentAuthorization
  grant: AgentCapabilityGrant
}

export type AgentAuthorizationsProps = { className?: string }

/** List user-owned agents and revoke individual active capability grants. */
export function AgentAuthorizations({ className }: AgentAuthorizationsProps) {
  const { authClient, localization } = useAuth<AgentAuthClient>()
  const plugin = useAuthPlugin(agentAuthPlugin)
  const agents = useAgentAuthorizations(authClient, plugin.adapter)
  const revoke = useRevokeAgentCapability(authClient, plugin.adapter)
  const [target, setTarget] = useState<RevokeTarget>()

  return (
    <div className={cn("flex flex-col gap-3", className)}>
      <div className="flex flex-col gap-1">
        <h2 className="text-sm font-semibold">{plugin.localization.agents}</h2>
        <p className="text-xs text-muted-foreground">
          {plugin.localization.agentsDescription}
        </p>
      </div>
      <Card className="p-0">
        <CardContent className="flex flex-col gap-5 p-4">
          {agents.isPending ? (
            <>
              <Skeleton className="h-24 rounded-xl" />
              <Skeleton className="h-24 rounded-xl" />
            </>
          ) : agents.data?.length ? (
            agents.data.map((agent) => (
              <div key={agent.id} className="flex flex-col gap-3">
                <div className="flex items-center gap-3">
                  <div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
                    <BotIcon className="size-4" />
                  </div>
                  <div className="flex min-w-0 flex-1 flex-col">
                    <span className="truncate text-sm font-semibold">
                      {agent.name}
                    </span>
                    <span className="truncate text-xs text-muted-foreground">
                      {agent.hostName ?? agent.hostId}
                    </span>
                  </div>
                  <Badge variant="secondary">
                    {agent.mode === "autonomous"
                      ? plugin.localization.autonomousAgent
                      : plugin.localization.delegatedAgent}
                  </Badge>
                </div>
                <div className="flex flex-col gap-2 pl-13">
                  {agent.grants.map((grant) => (
                    <div
                      key={grant.capability}
                      className="flex items-center gap-2"
                    >
                      <div className="flex min-w-0 flex-1 flex-col">
                        <span className="truncate text-sm">
                          {grant.capability}
                        </span>
                        {grant.description && (
                          <span className="truncate text-xs text-muted-foreground">
                            {grant.description}
                          </span>
                        )}
                      </div>
                      <Badge variant="secondary">
                        {plugin.localization[grant.status]}
                      </Badge>
                      {grant.status === "active" && (
                        <Button
                          type="button"
                          size="icon-sm"
                          variant="ghost"
                          aria-label={`${plugin.localization.revoke} ${grant.capability}`}
                          onClick={() => setTarget({ agent, grant })}
                        >
                          <XIcon />
                        </Button>
                      )}
                    </div>
                  ))}
                </div>
              </div>
            ))
          ) : (
            <div className="flex items-center gap-3 text-muted-foreground">
              <BotIcon className="size-5" />
              <p className="text-sm">{plugin.localization.noAgents}</p>
            </div>
          )}
        </CardContent>
      </Card>

      <AlertDialog
        open={Boolean(target)}
        onOpenChange={(open) => {
          if (!open) setTarget(undefined)
        }}
      >
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogMedia>
              <ShieldAlertIcon />
            </AlertDialogMedia>
            <AlertDialogTitle>
              {plugin.localization.revokeTitle}
            </AlertDialogTitle>
            <AlertDialogDescription>
              {plugin.localization.revokeDescription}
            </AlertDialogDescription>
          </AlertDialogHeader>
          <code className="rounded-lg bg-muted p-3 text-xs">
            {target?.grant.capability}
          </code>
          <AlertDialogFooter>
            <AlertDialogCancel disabled={revoke.isPending}>
              {localization.settings.cancel}
            </AlertDialogCancel>
            <Button
              type="button"
              variant="destructive"
              disabled={revoke.isPending}
              onClick={() => {
                if (!target) return
                revoke.mutate(
                  {
                    agentId: target.agent.id,
                    capability: target.grant.capability
                  },
                  { onSuccess: () => setTarget(undefined) }
                )
              }}
            >
              {revoke.isPending && <Spinner />}
              {plugin.localization.confirmRevoke}
            </Button>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </div>
  )
}
examples/start-shadcn-example/src/lib/auth/agent-auth-plugin.ts
import { createAuthPlugin } from "@better-auth-ui/core"
import {
  type AgentAuthPluginOptions,
  agentAuthPlugin as coreAgentAuthPlugin
} from "@better-auth-ui/core/plugins/agent-auth"

import { AgentApproval } from "@/components/auth/agent-auth/agent-approval"
import { AgentAuthorizations } from "@/components/auth/agent-auth/agent-authorizations"

export const agentAuthPlugin = createAuthPlugin(
  coreAgentAuthPlugin.id,
  (options: AgentAuthPluginOptions) => {
    const core = coreAgentAuthPlugin(options)
    return {
      ...core,
      views: { auth: { agentApproval: AgentApproval } },
      securityCards: core.grants ? [AgentAuthorizations] : []
    }
  }
)
examples/start-shadcn-example/src/lib/auth/auth-plugin.ts
import type {
  AuthPluginComponents,
  AuthPlugin as AuthPluginPrimitive
} from "@better-auth-ui/react"

declare module "@better-auth-ui/core" {
  /** Widens `useAuth().plugins` to the shadcn-typed `AuthPlugin`. */
  interface AuthPluginRegister {
    shadcn: AuthPlugin
  }
}

/** Props the shadcn `<Auth>` router spreads onto plugin-contributed auth views. */
export type AuthViewProps = {
  className?: string
  socialLayout?: "auto" | "horizontal" | "vertical" | "grid"
  socialPosition?: "top" | "bottom"
}

/** Props the shadcn `<Settings>` router spreads onto plugin-contributed settings views. */
export type SettingsViewProps = {
  className?: string
}

/** Shadcn plugin type. Plugin authors import this from `@/lib/auth/auth-plugin`. */
export type AuthPlugin = AuthPluginPrimitive<
  AuthPluginComponents,
  AuthViewProps,
  SettingsViewProps
>

More from better-auth-ui

Browse all components in this library →