import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { useState } from "react"; import { ClientForm, type ClientFormValues } from "@/client/components/admin/client-form"; import { Button } from "@/client/components/ui/button"; import { Card } from "@/client/components/ui/card"; import { ConfirmButton } from "@/client/components/ui/confirm-button"; import { PageError } from "@/client/components/ui/page-error"; import { PageHeader } from "@/client/components/ui/page-header"; import { ListRow } from "@/client/components/ui/row"; import { SecretRevealCard } from "@/client/components/ui/secret-reveal-card"; import { useRequireAdmin } from "@/client/hooks/use-auth-guard"; import { useResourceForm } from "@/client/hooks/use-resource-form"; import { apiJsonRequest, apiRequest, responseMessage } from "@/client/lib/http"; import { queryKeys } from "@/client/lib/query"; import { hasAdminRole } from "@/shared/role"; // Mirrors Better Auth's OAuth-2.0 dynamic-client-registration response // shape (snake_case, RFC 7591 fields). The list endpoint streams these // directly without translation, so we read them in the same form here. interface OAuthClient { client_id: string; client_name?: string | null; client_uri?: string | null; redirect_uris?: string[] | null; skip_consent?: boolean; client_id_issued_at?: number; disabled?: boolean; } export const AdminClientsPage = () => { const session = useRequireAdmin("/admin/clients"); const queryClient = useQueryClient(); const [secretReveal, setSecretReveal] = useState<{ clientId: string; clientSecret: string } | null>(null); // Single per-action pending slot. Values are namespaced strings so the // JSX can light up only the in-flight button: `submit`, `rotate:`, // `delete:`. Combined with `secretReveal` below, this is the lock // that prevents a second create/rotate from overwriting an undisclosed // plaintext secret (OPERATOR.md: "shown exactly once"). const [pending, setPending] = useState(null); const [error, setError] = useState(null); const form = useResourceForm({ canCancel: () => pending === null && secretReveal === null }); const isAdmin = hasAdminRole(session.data?.user.role); const userId = session.data?.user.id ?? null; const isLocked = pending !== null || secretReveal !== null || form.formActive; const clientsQueryKey = queryKeys.adminClients(userId); const clientsQuery = useQuery({ queryKey: clientsQueryKey, queryFn: async () => { const res = await apiRequest("/api/admin/clients"); if (!res.ok) throw new Error(`HTTP ${res.status}`); return (await res.json()) as OAuthClient[]; }, enabled: isAdmin, }); const clients = clientsQuery.data ?? []; const handleSubmit = async (values: ClientFormValues) => { if (pending !== null || secretReveal !== null) return; if (!form.creating && form.editingId === null) return; form.setFormError(null); setPending("submit"); try { if (form.creating) { const res = await apiJsonRequest( "/api/admin/clients", { name: values.name, redirectUris: values.redirectUris, skipConsent: values.skipConsent, ...(values.uri ? { uri: values.uri } : {}), }, { method: "POST" }, ); if (!res.ok) { form.setFormError(await responseMessage(res, "Could not create client.")); return; } const body = (await res.json()) as { client_id: string; client_secret: string }; setSecretReveal({ clientId: body.client_id, clientSecret: body.client_secret }); form.close(); await queryClient.invalidateQueries({ queryKey: clientsQueryKey, exact: true }); } else if (form.editingId !== null) { const res = await apiJsonRequest( `/api/admin/clients/${encodeURIComponent(form.editingId)}`, { name: values.name, skipConsent: values.skipConsent, uri: values.uri }, { method: "PATCH" }, ); if (!res.ok) { form.setFormError(await responseMessage(res, "Could not update client.")); return; } form.close(); await queryClient.invalidateQueries({ queryKey: clientsQueryKey, exact: true }); } } finally { setPending(null); } }; const handleRotate = async (clientId: string) => { if (isLocked) return; setError(null); setPending(`rotate:${clientId}`); try { const res = await apiRequest(`/api/admin/clients/${encodeURIComponent(clientId)}/rotate`, { method: "POST", }); if (!res.ok) { setError(await responseMessage(res, "Could not rotate client secret.")); return; } const body = (await res.json()) as { client_secret: string }; setSecretReveal({ clientId, clientSecret: body.client_secret }); } finally { setPending(null); } }; const handleDelete = async (clientId: string) => { if (isLocked) return; setError(null); setPending(`delete:${clientId}`); try { const res = await apiRequest(`/api/admin/clients/${encodeURIComponent(clientId)}`, { method: "DELETE", }); if (!res.ok) { setError(await responseMessage(res, "Could not delete client.")); return; } await queryClient.invalidateQueries({ queryKey: clientsQueryKey, exact: true }); } finally { setPending(null); } }; return (
); };