File
Blob: src/client/pages/admin/clients.tsx
| 1 | import { useQuery, useQueryClient } from "@tanstack/react-query"; |
| 2 | import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; |
| 3 | import { useState } from "react"; |
| 4 | |
| 5 | import { ClientForm, type ClientFormValues } from "@/client/components/admin/client-form"; |
| 6 | import { Button } from "@/client/components/ui/button"; |
| 7 | import { Card } from "@/client/components/ui/card"; |
| 8 | import { ConfirmButton } from "@/client/components/ui/confirm-button"; |
| 9 | import { PageError } from "@/client/components/ui/page-error"; |
| 10 | import { PageHeader } from "@/client/components/ui/page-header"; |
| 11 | import { ListRow } from "@/client/components/ui/row"; |
| 12 | import { SecretRevealCard } from "@/client/components/ui/secret-reveal-card"; |
| 13 | import { useRequireAdmin } from "@/client/hooks/use-auth-guard"; |
| 14 | import { useResourceForm } from "@/client/hooks/use-resource-form"; |
| 15 | import { apiJsonRequest, apiRequest, responseMessage } from "@/client/lib/http"; |
| 16 | import { queryKeys } from "@/client/lib/query"; |
| 17 | import { hasAdminRole } from "@/shared/role"; |
| 18 | |
| 19 | // Mirrors Better Auth's OAuth-2.0 dynamic-client-registration response |
| 20 | // shape (snake_case, RFC 7591 fields). The list endpoint streams these |
| 21 | // directly without translation, so we read them in the same form here. |
| 22 | interface OAuthClient { |
| 23 | client_id: string; |
| 24 | client_name?: string | null; |
| 25 | client_uri?: string | null; |
| 26 | redirect_uris?: string[] | null; |
| 27 | skip_consent?: boolean; |
| 28 | client_id_issued_at?: number; |
| 29 | disabled?: boolean; |
| 30 | } |
| 31 | |
| 32 | export const AdminClientsPage = () => { |
| 33 | const session = useRequireAdmin("/admin/clients"); |
| 34 | const queryClient = useQueryClient(); |
| 35 | const [secretReveal, setSecretReveal] = useState<{ clientId: string; clientSecret: string } | null>(null); |
| 36 | // Single per-action pending slot. Values are namespaced strings so the |
| 37 | // JSX can light up only the in-flight button: `submit`, `rotate:<id>`, |
| 38 | // `delete:<id>`. Combined with `secretReveal` below, this is the lock |
| 39 | // that prevents a second create/rotate from overwriting an undisclosed |
| 40 | // plaintext secret (OPERATOR.md: "shown exactly once"). |
| 41 | const [pending, setPending] = useState<string | null>(null); |
| 42 | const [error, setError] = useState<string | null>(null); |
| 43 | const form = useResourceForm({ canCancel: () => pending === null && secretReveal === null }); |
| 44 | const isAdmin = hasAdminRole(session.data?.user.role); |
| 45 | const userId = session.data?.user.id ?? null; |
| 46 | const isLocked = pending !== null || secretReveal !== null || form.formActive; |
| 47 | const clientsQueryKey = queryKeys.adminClients(userId); |
| 48 | |
| 49 | const clientsQuery = useQuery({ |
| 50 | queryKey: clientsQueryKey, |
| 51 | queryFn: async () => { |
| 52 | const res = await apiRequest("/api/admin/clients"); |
| 53 | if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| 54 | return (await res.json()) as OAuthClient[]; |
| 55 | }, |
| 56 | enabled: isAdmin, |
| 57 | }); |
| 58 | const clients = clientsQuery.data ?? []; |
| 59 | |
| 60 | const handleSubmit = async (values: ClientFormValues) => { |
| 61 | if (pending !== null || secretReveal !== null) return; |
| 62 | if (!form.creating && form.editingId === null) return; |
| 63 | form.setFormError(null); |
| 64 | setPending("submit"); |
| 65 | try { |
| 66 | if (form.creating) { |
| 67 | const res = await apiJsonRequest( |
| 68 | "/api/admin/clients", |
| 69 | { |
| 70 | name: values.name, |
| 71 | redirectUris: values.redirectUris, |
| 72 | skipConsent: values.skipConsent, |
| 73 | ...(values.uri ? { uri: values.uri } : {}), |
| 74 | }, |
| 75 | { method: "POST" }, |
| 76 | ); |
| 77 | if (!res.ok) { |
| 78 | form.setFormError(await responseMessage(res, "Could not create client.")); |
| 79 | return; |
| 80 | } |
| 81 | const body = (await res.json()) as { client_id: string; client_secret: string }; |
| 82 | setSecretReveal({ clientId: body.client_id, clientSecret: body.client_secret }); |
| 83 | form.close(); |
| 84 | await queryClient.invalidateQueries({ queryKey: clientsQueryKey, exact: true }); |
| 85 | } else if (form.editingId !== null) { |
| 86 | const res = await apiJsonRequest( |
| 87 | `/api/admin/clients/${encodeURIComponent(form.editingId)}`, |
| 88 | { name: values.name, skipConsent: values.skipConsent, uri: values.uri }, |
| 89 | { method: "PATCH" }, |
| 90 | ); |
| 91 | if (!res.ok) { |
| 92 | form.setFormError(await responseMessage(res, "Could not update client.")); |
| 93 | return; |
| 94 | } |
| 95 | form.close(); |
| 96 | await queryClient.invalidateQueries({ queryKey: clientsQueryKey, exact: true }); |
| 97 | } |
| 98 | } finally { |
| 99 | setPending(null); |
| 100 | } |
| 101 | }; |
| 102 | |
| 103 | const handleRotate = async (clientId: string) => { |
| 104 | if (isLocked) return; |
| 105 | setError(null); |
| 106 | setPending(`rotate:${clientId}`); |
| 107 | try { |
| 108 | const res = await apiRequest(`/api/admin/clients/${encodeURIComponent(clientId)}/rotate`, { |
| 109 | method: "POST", |
| 110 | }); |
| 111 | if (!res.ok) { |
| 112 | setError(await responseMessage(res, "Could not rotate client secret.")); |
| 113 | return; |
| 114 | } |
| 115 | const body = (await res.json()) as { client_secret: string }; |
| 116 | setSecretReveal({ clientId, clientSecret: body.client_secret }); |
| 117 | } finally { |
| 118 | setPending(null); |
| 119 | } |
| 120 | }; |
| 121 | |
| 122 | const handleDelete = async (clientId: string) => { |
| 123 | if (isLocked) return; |
| 124 | setError(null); |
| 125 | setPending(`delete:${clientId}`); |
| 126 | try { |
| 127 | const res = await apiRequest(`/api/admin/clients/${encodeURIComponent(clientId)}`, { |
| 128 | method: "DELETE", |
| 129 | }); |
| 130 | if (!res.ok) { |
| 131 | setError(await responseMessage(res, "Could not delete client.")); |
| 132 | return; |
| 133 | } |
| 134 | await queryClient.invalidateQueries({ queryKey: clientsQueryKey, exact: true }); |
| 135 | } finally { |
| 136 | setPending(null); |
| 137 | } |
| 138 | }; |
| 139 | |
| 140 | return ( |
| 141 | <div className="mx-auto max-w-3xl"> |
| 142 | <PageHeader |
| 143 | title="OAuth clients" |
| 144 | actions={ |
| 145 | <Button size="sm" onClick={form.startCreate} disabled={isLocked}> |
| 146 | <Plus className="h-4 w-4" aria-hidden="true" /> |
| 147 | New client |
| 148 | </Button> |
| 149 | } |
| 150 | /> |
| 151 | <PageError message={error} onDismiss={() => setError(null)} /> |
| 152 | {secretReveal ? ( |
| 153 | <div className="mb-6"> |
| 154 | <SecretRevealCard |
| 155 | eyebrow="Client secret — copy now, this is the only time it's shown" |
| 156 | lines={[ |
| 157 | { label: "client_id", value: secretReveal.clientId }, |
| 158 | { label: "client_secret", value: secretReveal.clientSecret }, |
| 159 | ]} |
| 160 | copyLabel="Copy secret" |
| 161 | onDismiss={() => setSecretReveal(null)} |
| 162 | /> |
| 163 | </div> |
| 164 | ) : null} |
| 165 | |
| 166 | {form.creating ? ( |
| 167 | <Card className="mb-4"> |
| 168 | <ClientForm |
| 169 | mode="create" |
| 170 | pending={pending === "submit"} |
| 171 | error={form.formError} |
| 172 | onDismissError={() => form.setFormError(null)} |
| 173 | onSubmit={handleSubmit} |
| 174 | onCancel={form.cancel} |
| 175 | /> |
| 176 | </Card> |
| 177 | ) : null} |
| 178 | |
| 179 | <Card> |
| 180 | {clients.length === 0 ? ( |
| 181 | <p className="text-sm text-zinc-500">No clients yet.</p> |
| 182 | ) : ( |
| 183 | <ul className="flex flex-col gap-2"> |
| 184 | {clients.map((client) => { |
| 185 | const redirectCount = client.redirect_uris?.length ?? 0; |
| 186 | const isEditingThis = form.editingId === client.client_id; |
| 187 | return ( |
| 188 | <ListRow key={client.client_id}> |
| 189 | <div className="flex flex-col gap-3 text-sm sm:flex-row sm:items-center sm:justify-between"> |
| 190 | <div className="min-w-0"> |
| 191 | <div className="flex flex-wrap items-center gap-2"> |
| 192 | <p className="truncate font-medium text-zinc-100">{client.client_name ?? client.client_id}</p> |
| 193 | {client.skip_consent ? ( |
| 194 | <span className="inline-flex items-center rounded-md bg-accent-500/10 px-1.5 py-0.5 text-[0.7rem] font-medium uppercase tracking-wider text-accent-400"> |
| 195 | first-party |
| 196 | </span> |
| 197 | ) : null} |
| 198 | </div> |
| 199 | <p className="mt-0.5 truncate text-xs text-zinc-500"> |
| 200 | <code className="font-mono">{client.client_id}</code> |
| 201 | {redirectCount > 0 ? ( |
| 202 | <span> |
| 203 | {" "} |
| 204 | · {redirectCount} redirect URI{redirectCount === 1 ? "" : "s"} |
| 205 | </span> |
| 206 | ) : null} |
| 207 | </p> |
| 208 | </div> |
| 209 | <div className="flex flex-shrink-0 flex-wrap gap-1"> |
| 210 | <Button |
| 211 | variant="ghost" |
| 212 | size="sm" |
| 213 | onClick={() => form.startEdit(client.client_id)} |
| 214 | disabled={isLocked} |
| 215 | > |
| 216 | <Pencil className="h-3.5 w-3.5" aria-hidden="true" /> |
| 217 | Edit |
| 218 | </Button> |
| 219 | <ConfirmButton |
| 220 | variant="ghost" |
| 221 | size="sm" |
| 222 | aria-label="Rotate client secret" |
| 223 | title="Rotate client secret" |
| 224 | onConfirm={() => handleRotate(client.client_id)} |
| 225 | loading={pending === `rotate:${client.client_id}`} |
| 226 | disabled={isLocked && pending !== `rotate:${client.client_id}`} |
| 227 | > |
| 228 | <RefreshCw className="h-3.5 w-3.5" aria-hidden="true" /> |
| 229 | </ConfirmButton> |
| 230 | <ConfirmButton |
| 231 | variant="ghost" |
| 232 | size="sm" |
| 233 | aria-label="Delete client" |
| 234 | title="Delete client" |
| 235 | onConfirm={() => handleDelete(client.client_id)} |
| 236 | loading={pending === `delete:${client.client_id}`} |
| 237 | disabled={isLocked && pending !== `delete:${client.client_id}`} |
| 238 | > |
| 239 | <Trash2 className="h-3.5 w-3.5" aria-hidden="true" /> |
| 240 | </ConfirmButton> |
| 241 | </div> |
| 242 | </div> |
| 243 | {isEditingThis ? ( |
| 244 | <div className="mt-4 border-t border-zinc-800/60 pt-4"> |
| 245 | <ClientForm |
| 246 | key={client.client_id} |
| 247 | mode="edit" |
| 248 | initial={{ |
| 249 | name: client.client_name ?? "", |
| 250 | redirectUris: client.redirect_uris ?? [], |
| 251 | uri: client.client_uri ?? "", |
| 252 | skipConsent: client.skip_consent === true, |
| 253 | }} |
| 254 | pending={pending === "submit"} |
| 255 | error={form.formError} |
| 256 | onDismissError={() => form.setFormError(null)} |
| 257 | onSubmit={handleSubmit} |
| 258 | onCancel={form.cancel} |
| 259 | /> |
| 260 | </div> |
| 261 | ) : null} |
| 262 | </ListRow> |
| 263 | ); |
| 264 | })} |
| 265 | </ul> |
| 266 | )} |
| 267 | </Card> |
| 268 | </div> |
| 269 | ); |
| 270 | }; |