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:
@@ -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
|
||||||
@@ -5,8 +5,10 @@ import { Suspense } from "react"
|
|||||||
import { type UserPublic, UsersService } from "@/client"
|
import { type UserPublic, UsersService } from "@/client"
|
||||||
import AddUser from "@/components/Admin/AddUser"
|
import AddUser from "@/components/Admin/AddUser"
|
||||||
import { columns, type UserTableData } from "@/components/Admin/columns"
|
import { columns, type UserTableData } from "@/components/Admin/columns"
|
||||||
|
import OAuthClients from "@/components/Admin/OAuthClients"
|
||||||
import { DataTable } from "@/components/Common/DataTable"
|
import { DataTable } from "@/components/Common/DataTable"
|
||||||
import PendingUsers from "@/components/Pending/PendingUsers"
|
import PendingUsers from "@/components/Pending/PendingUsers"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import useAuth from "@/hooks/useAuth"
|
import useAuth from "@/hooks/useAuth"
|
||||||
|
|
||||||
function getUsersQueryOptions() {
|
function getUsersQueryOptions() {
|
||||||
@@ -56,18 +58,29 @@ function UsersTable() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Admin() {
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<Tabs defaultValue="users" className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<TabsList>
|
||||||
<div>
|
<TabsTrigger value="users">Users</TabsTrigger>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Users</h1>
|
<TabsTrigger value="agents">Agents</TabsTrigger>
|
||||||
<p className="text-muted-foreground">
|
</TabsList>
|
||||||
Manage user accounts and permissions
|
<TabsContent value="users" className="flex flex-col gap-6">
|
||||||
</p>
|
<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>
|
</div>
|
||||||
<AddUser />
|
<UsersTable />
|
||||||
</div>
|
</TabsContent>
|
||||||
<UsersTable />
|
<TabsContent value="agents">
|
||||||
</div>
|
<OAuthClients />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user