Show and revoke registered agents under Admin

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
This commit is contained in:
2026-08-16 16:36:48 +02:00
co-authored by Claude Fable 5
parent 45392d31d9
commit 2303c1f92d
2 changed files with 171 additions and 11 deletions
@@ -0,0 +1,147 @@
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
+24 -11
View File
@@ -5,8 +5,10 @@ import { Suspense } from "react"
import { type UserPublic, UsersService } from "@/client"
import AddUser from "@/components/Admin/AddUser"
import { columns, type UserTableData } from "@/components/Admin/columns"
import OAuthClients from "@/components/Admin/OAuthClients"
import { DataTable } from "@/components/Common/DataTable"
import PendingUsers from "@/components/Pending/PendingUsers"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import useAuth from "@/hooks/useAuth"
function getUsersQueryOptions() {
@@ -56,18 +58,29 @@ function UsersTable() {
}
function Admin() {
// Agents get in through the same door as people, so they are administered
// next to them rather than in a screen of their own.
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Users</h1>
<p className="text-muted-foreground">
Manage user accounts and permissions
</p>
<Tabs defaultValue="users" className="flex flex-col gap-6">
<TabsList>
<TabsTrigger value="users">Users</TabsTrigger>
<TabsTrigger value="agents">Agents</TabsTrigger>
</TabsList>
<TabsContent value="users" className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Users</h1>
<p className="text-muted-foreground">
Manage user accounts and permissions
</p>
</div>
<AddUser />
</div>
<AddUser />
</div>
<UsersTable />
</div>
<UsersTable />
</TabsContent>
<TabsContent value="agents">
<OAuthClients />
</TabsContent>
</Tabs>
)
}