forked from
slices.network/slices
Highly ambitious ATProtocol AppView service and sdks
1import { useEffect, useState } from "react";
2import { graphql, useMutation } from "react-relay";
3import { Dialog } from "./Dialog.tsx";
4import { Button } from "./Button.tsx";
5import { Input } from "./Input.tsx";
6import { Textarea } from "./Textarea.tsx";
7import { FormControl } from "./FormControl.tsx";
8import { CopyableField } from "./CopyableField.tsx";
9import type { OAuthClientModalCreateMutation } from "../__generated__/OAuthClientModalCreateMutation.graphql.ts";
10import type { OAuthClientModalUpdateMutation } from "../__generated__/OAuthClientModalUpdateMutation.graphql.ts";
11
12interface OAuthClient {
13 clientId: string;
14 clientSecret?: string | null;
15 clientName: string;
16 redirectUris: ReadonlyArray<string>;
17 scope?: string | null;
18 clientUri?: string | null;
19 logoUri?: string | null;
20 tosUri?: string | null;
21 policyUri?: string | null;
22}
23
24interface OAuthClientModalProps {
25 sliceUri: string;
26 client?: OAuthClient | null;
27 onClose: () => void;
28 onSuccess: (clientId: string, clientSecret: string | null) => void;
29}
30
31export function OAuthClientModal({
32 sliceUri,
33 client,
34 onClose,
35 onSuccess,
36}: OAuthClientModalProps) {
37 const isEditMode = !!client;
38
39 const [clientName, setClientName] = useState("");
40 const [redirectUris, setRedirectUris] = useState("");
41 const [scope, setScope] = useState("");
42 const [clientUri, setClientUri] = useState("");
43 const [logoUri, setLogoUri] = useState("");
44 const [tosUri, setTosUri] = useState("");
45 const [policyUri, setPolicyUri] = useState("");
46 const [error, setError] = useState<string | null>(null);
47
48 // Initialize form with client data in edit mode
49 useEffect(() => {
50 if (client) {
51 setClientName(client.clientName);
52 setRedirectUris(client.redirectUris.join("\n"));
53 setScope(client.scope || "");
54 setClientUri(client.clientUri || "");
55 setLogoUri(client.logoUri || "");
56 setTosUri(client.tosUri || "");
57 setPolicyUri(client.policyUri || "");
58 }
59 }, [client]);
60
61 const [createOAuthClient, isCreating] = useMutation<
62 OAuthClientModalCreateMutation
63 >(graphql`
64 mutation OAuthClientModalCreateMutation(
65 $sliceUri: String!
66 $clientName: String!
67 $redirectUris: [String!]!
68 $scope: String!
69 $clientUri: String
70 $logoUri: String
71 $tosUri: String
72 $policyUri: String
73 ) {
74 createOAuthClient(
75 sliceUri: $sliceUri
76 clientName: $clientName
77 redirectUris: $redirectUris
78 scope: $scope
79 clientUri: $clientUri
80 logoUri: $logoUri
81 tosUri: $tosUri
82 policyUri: $policyUri
83 ) {
84 clientId
85 clientSecret
86 }
87 }
88 `);
89
90 const [updateOAuthClient, isUpdating] = useMutation<
91 OAuthClientModalUpdateMutation
92 >(graphql`
93 mutation OAuthClientModalUpdateMutation(
94 $clientId: String!
95 $clientName: String
96 $redirectUris: [String!]
97 $scope: String
98 $clientUri: String
99 $logoUri: String
100 $tosUri: String
101 $policyUri: String
102 ) {
103 updateOAuthClient(
104 clientId: $clientId
105 clientName: $clientName
106 redirectUris: $redirectUris
107 scope: $scope
108 clientUri: $clientUri
109 logoUri: $logoUri
110 tosUri: $tosUri
111 policyUri: $policyUri
112 ) {
113 clientId
114 }
115 }
116 `);
117
118 const isSubmitting = isCreating || isUpdating;
119
120 const handleSubmit = (e: React.FormEvent) => {
121 e.preventDefault();
122 setError(null);
123
124 // Parse redirect URIs
125 const uris = redirectUris
126 .split("\n")
127 .map((uri) => uri.trim())
128 .filter((uri) => uri.length > 0);
129
130 if (uris.length === 0) {
131 setError("At least one redirect URI is required");
132 return;
133 }
134
135 // Validate redirect URIs
136 for (const uri of uris) {
137 if (!uri.startsWith("http://") && !uri.startsWith("https://")) {
138 setError(`Invalid redirect URI: ${uri}. Must use HTTP or HTTPS.`);
139 return;
140 }
141 }
142
143 if (isEditMode && client) {
144 // Update existing client
145 updateOAuthClient({
146 variables: {
147 clientId: client.clientId,
148 clientName: clientName.trim() || null,
149 redirectUris: uris,
150 scope: scope.trim() || null,
151 clientUri: clientUri.trim() || null,
152 logoUri: logoUri.trim() || null,
153 tosUri: tosUri.trim() || null,
154 policyUri: policyUri.trim() || null,
155 },
156 onCompleted: (response) => {
157 onSuccess(response.updateOAuthClient.clientId, null);
158 },
159 onError: (err) => {
160 console.error("Failed to update OAuth client:", err);
161 setError(
162 err.message || "Failed to update OAuth client. Please try again.",
163 );
164 },
165 });
166 } else {
167 // Create new client
168 createOAuthClient({
169 variables: {
170 sliceUri,
171 clientName,
172 redirectUris: uris,
173 scope: scope.trim(),
174 clientUri: clientUri.trim() || null,
175 logoUri: logoUri.trim() || null,
176 tosUri: tosUri.trim() || null,
177 policyUri: policyUri.trim() || null,
178 },
179 onCompleted: (response) => {
180 onSuccess(
181 response.createOAuthClient.clientId,
182 response.createOAuthClient.clientSecret ?? null,
183 );
184 },
185 onError: (err) => {
186 console.error("Failed to create OAuth client:", err);
187 setError(
188 err.message || "Failed to create OAuth client. Please try again.",
189 );
190 },
191 });
192 }
193 };
194
195 return (
196 <Dialog
197 open
198 onClose={onClose}
199 title={isEditMode ? "Edit OAuth Client" : "Register OAuth Client"}
200 maxWidth="2xl"
201 className="max-h-[90vh] overflow-y-auto"
202 >
203 {isEditMode && client && (
204 <div className="mb-4 pb-4 border-b border-zinc-800 space-y-4">
205 <CopyableField value={client.clientId} label="Client ID" />
206 {client.clientSecret && (
207 <CopyableField value={client.clientSecret} label="Client Secret" />
208 )}
209 </div>
210 )}
211
212 <form onSubmit={handleSubmit} className="space-y-4">
213 <FormControl label="Client Name" htmlFor="clientName">
214 <Input
215 id="clientName"
216 value={clientName}
217 onChange={(e) => setClientName(e.target.value)}
218 placeholder="My Application"
219 required
220 disabled={isSubmitting}
221 />
222 </FormControl>
223
224 <FormControl
225 label="Redirect URIs"
226 htmlFor="redirectUris"
227 >
228 <Textarea
229 id="redirectUris"
230 value={redirectUris}
231 onChange={(e) => setRedirectUris(e.target.value)}
232 placeholder="https://example.com/callback https://localhost:3000/callback"
233 required
234 disabled={isSubmitting}
235 rows={3}
236 className="bg-zinc-800 border-zinc-700 focus:border-blue-500 font-mono"
237 />
238 </FormControl>
239
240 <FormControl
241 label="Scope"
242 htmlFor="scope"
243 >
244 <Input
245 id="scope"
246 value={scope}
247 onChange={(e) => setScope(e.target.value)}
248 placeholder="atproto"
249 required
250 disabled={isSubmitting}
251 />
252 </FormControl>
253
254 {error && (
255 <div className="bg-red-500/10 border border-red-500/20 text-red-400 px-3 py-2 rounded text-sm">
256 {error}
257 </div>
258 )}
259
260 <div className="flex justify-end gap-3 pt-4">
261 <Button
262 type="button"
263 onClick={onClose}
264 variant="default"
265 disabled={isSubmitting}
266 >
267 Cancel
268 </Button>
269 <Button
270 type="button"
271 variant="primary"
272 onClick={(e) => {
273 e.preventDefault();
274 e.stopPropagation();
275 handleSubmit(e);
276 }}
277 disabled={isSubmitting}
278 >
279 {isEditMode
280 ? (isUpdating ? "Updating..." : "Update Client")
281 : (isCreating ? "Creating..." : "Register Client")}
282 </Button>
283 </div>
284 </form>
285 </Dialog>
286 );
287}