diff --git a/frontend/src/components/Admin/OAuthClients.tsx b/frontend/src/components/Admin/OAuthClients.tsx new file mode 100644 index 0000000..0ba2335 --- /dev/null +++ b/frontend/src/components/Admin/OAuthClients.tsx @@ -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(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 ( +
+
+

Agents

+

+ Clients that registered themselves to reach the MCP endpoint. Each one + still needed a person to approve it before it could do anything. +

+
+ + {clients.length === 0 ? ( +

+ Nothing has registered yet. An agent adds itself here the first time + it asks for access. +

+ ) : ( +
+ {clients.map((client) => ( +
+
+ + + {client.client_name} + + + {client.redirect_uris.join(" ")} + + + Registered {when(client.created_at)} + {client.last_authorized_at + ? ` · approved ${when(client.last_authorized_at)}` + : ""} + +
+
+ 0 ? "default" : "secondary"} + > + {client.active_tokens > 0 + ? `${client.active_tokens} live token${ + client.active_tokens === 1 ? "" : "s" + }` + : "Never approved"} + + +
+
+ ))} +
+ )} + + !open && setPending(null)} + > + + + Revoke '{pending?.client_name}'? + + 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. + + + + + + + + +
+ ) +} + +export default OAuthClients diff --git a/frontend/src/routes/_layout/admin.tsx b/frontend/src/routes/_layout/admin.tsx index 1c64be9..69c34b5 100644 --- a/frontend/src/routes/_layout/admin.tsx +++ b/frontend/src/routes/_layout/admin.tsx @@ -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 ( -
-
-
-

Users

-

- Manage user accounts and permissions -

+ + + Users + Agents + + +
+
+

Users

+

+ Manage user accounts and permissions +

+
+
- -
- -
+ + + + + + ) }