An agent that registered itself was invisible, and withdrawing one meant deleting rows or rotating the signing key. It now sits next to Users, with the live-token count that tells an approved agent from one that only registered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
148 lines
5.0 KiB
TypeScript
148 lines
5.0 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
import { Bot } from "lucide-react"
|
|
import { useState } from "react"
|
|
|
|
import { OauthService, type RegisteredClient } from "@/client"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import useCustomToast from "@/hooks/useCustomToast"
|
|
import { handleError } from "@/utils"
|
|
|
|
const clientsKey = ["oauth", "clients"]
|
|
|
|
function when(value: string): string {
|
|
return new Date(value).toLocaleString()
|
|
}
|
|
|
|
/** The agents that registered themselves for the MCP endpoint, and the way to
|
|
* withdraw one — the alternative being to rotate the signing key, which cuts
|
|
* off every agent at once. */
|
|
export function OAuthClients() {
|
|
const { data } = useQuery({
|
|
queryKey: clientsKey,
|
|
queryFn: () => OauthService.readClients(),
|
|
})
|
|
const queryClient = useQueryClient()
|
|
const { showSuccessToast, showErrorToast } = useCustomToast()
|
|
const [pending, setPending] = useState<RegisteredClient | null>(null)
|
|
|
|
const revoke = useMutation({
|
|
mutationFn: (clientId: string) => OauthService.revokeClient({ clientId }),
|
|
onSuccess: (message) => {
|
|
showSuccessToast(message.message)
|
|
setPending(null)
|
|
},
|
|
onError: handleError.bind(showErrorToast),
|
|
onSettled: () => {
|
|
queryClient.invalidateQueries({ queryKey: clientsKey })
|
|
},
|
|
})
|
|
|
|
const clients = data?.data ?? []
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">Agents</h1>
|
|
<p className="text-muted-foreground">
|
|
Clients that registered themselves to reach the MCP endpoint. Each one
|
|
still needed a person to approve it before it could do anything.
|
|
</p>
|
|
</div>
|
|
|
|
{clients.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Nothing has registered yet. An agent adds itself here the first time
|
|
it asks for access.
|
|
</p>
|
|
) : (
|
|
<div className="grid gap-2">
|
|
{clients.map((client) => (
|
|
<div
|
|
key={client.id}
|
|
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card p-4 shadow-e1"
|
|
data-testid="oauth-client"
|
|
>
|
|
<div className="grid gap-1">
|
|
<span className="flex items-center gap-2 text-sm font-medium">
|
|
<Bot className="size-4 text-muted-foreground" />
|
|
{client.client_name}
|
|
</span>
|
|
<span className="font-mono text-xs text-muted-foreground">
|
|
{client.redirect_uris.join(" ")}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
Registered {when(client.created_at)}
|
|
{client.last_authorized_at
|
|
? ` · approved ${when(client.last_authorized_at)}`
|
|
: ""}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<Badge
|
|
variant={client.active_tokens > 0 ? "default" : "secondary"}
|
|
>
|
|
{client.active_tokens > 0
|
|
? `${client.active_tokens} live token${
|
|
client.active_tokens === 1 ? "" : "s"
|
|
}`
|
|
: "Never approved"}
|
|
</Badge>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-destructive hover:text-destructive"
|
|
data-testid="revoke-client"
|
|
onClick={() => setPending(client)}
|
|
>
|
|
Revoke
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<Dialog
|
|
open={pending !== null}
|
|
onOpenChange={(open) => !open && setPending(null)}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Revoke '{pending?.client_name}'?</DialogTitle>
|
|
<DialogDescription>
|
|
It loses its refresh tokens, so it can get nothing new and has to
|
|
be approved again from scratch. An access token already in its
|
|
hands keeps working until it expires. Every other agent is left
|
|
alone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setPending(null)}>
|
|
Keep it
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
disabled={revoke.isPending}
|
|
data-testid="confirm-revoke-client"
|
|
onClick={() => pending && revoke.mutate(pending.id)}
|
|
>
|
|
Revoke
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default OAuthClients
|