Skip to content
File

Blob: src/client/pages/admin/clients.tsx

typescript271 lines
1import { useQuery, useQueryClient } from "@tanstack/react-query";
2import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
3import { useState } from "react";
4 
5import { ClientForm, type ClientFormValues } from "@/client/components/admin/client-form";
6import { Button } from "@/client/components/ui/button";
7import { Card } from "@/client/components/ui/card";
8import { ConfirmButton } from "@/client/components/ui/confirm-button";
9import { PageError } from "@/client/components/ui/page-error";
10import { PageHeader } from "@/client/components/ui/page-header";
11import { ListRow } from "@/client/components/ui/row";
12import { SecretRevealCard } from "@/client/components/ui/secret-reveal-card";
13import { useRequireAdmin } from "@/client/hooks/use-auth-guard";
14import { useResourceForm } from "@/client/hooks/use-resource-form";
15import { apiJsonRequest, apiRequest, responseMessage } from "@/client/lib/http";
16import { queryKeys } from "@/client/lib/query";
17import { 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.
22interface 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 
32export 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};