index.tsx471 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import type { OAuthScope } from '@supabase/shared-types/out/constants'
3import { useParams } from 'common'
4import { Edit, Upload } from 'lucide-react'
5import { ChangeEvent, useEffect, useRef, useState } from 'react'
6import { SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form'
7import { toast } from 'sonner'
8import {
9 Badge,
10 Button,
11 cn,
12 DropdownMenu,
13 DropdownMenuContent,
14 DropdownMenuItem,
15 DropdownMenuTrigger,
16 Form,
17 FormControl,
18 FormField,
19 Input,
20 InputGroup,
21 InputGroupAddon,
22 InputGroupButton,
23 InputGroupInput,
24 Modal,
25 SidePanel,
26} from 'ui'
27import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
28import * as z from 'zod'
29
30import { AuthorizeRequesterDetails } from '../AuthorizeRequesterDetails'
31import { OAuthSecrets } from '../OAuthSecrets/OAuthSecrets'
32import { ScopesPanel } from './Scopes'
33import { DocsButton } from '@/components/ui/DocsButton'
34import {
35 OAuthAppCreateResponse,
36 useOAuthAppCreateMutation,
37} from '@/data/oauth/oauth-app-create-mutation'
38import { useOAuthAppUpdateMutation } from '@/data/oauth/oauth-app-update-mutation'
39import type { OAuthApp } from '@/data/oauth/oauth-apps-query'
40import { DOCS_URL } from '@/lib/constants'
41import { isValidHttpUrl, uuidv4 } from '@/lib/helpers'
42import { uploadAttachment } from '@/lib/upload'
43
44export interface PublishAppSidePanelProps {
45 visible: boolean
46 selectedApp?: OAuthApp
47 onClose: () => void
48 onCreateSuccess: (app: OAuthAppCreateResponse) => void
49}
50
51const formSchema = z.object({
52 name: z.string().min(1, 'Please provide a name for your application'),
53 website: z
54 .string()
55 .min(1, 'Please provide a URL for your site')
56 .url('Please provide a URL for your site')
57 .refine((value) => isValidHttpUrl(value), 'Please provide a valid URL for your site'),
58 redirect_uris: z
59 .array(
60 z.object({
61 id: z.string(),
62 value: z.string().min(1, 'Please provide a URL').url('Please provide a URL'),
63 }),
64 { required_error: 'Please provide at least one callback URL' }
65 )
66 .min(1, 'Please provide at least one callback URL'),
67})
68
69const getFormDefaultValues = (selectedApp: OAuthApp | undefined) => {
70 if (selectedApp) {
71 return {
72 name: selectedApp.name,
73 website: selectedApp.website,
74 redirect_uris:
75 selectedApp.redirect_uris?.map((url) => {
76 return { id: uuidv4(), value: url }
77 }) ?? [],
78 }
79 }
80
81 return { name: '', website: '', redirect_uris: [{ id: uuidv4(), value: '' }] }
82}
83
84type FormSchema = z.infer<typeof formSchema>
85
86export const PublishAppSidePanel = ({
87 visible,
88 selectedApp,
89 onClose,
90 onCreateSuccess,
91}: PublishAppSidePanelProps) => {
92 const { slug } = useParams()
93 const uploadButtonRef = useRef<HTMLInputElement | null>(null)
94
95 const { mutateAsync: createOAuthApp } = useOAuthAppCreateMutation({
96 onSuccess: (res, variables) => {
97 toast.success(`Successfully created OAuth app "${variables.name}"!`)
98 onClose()
99 onCreateSuccess(res)
100 },
101 onError: (error) => {
102 toast.error(`Failed to create OAuth application: ${error.message}`)
103 },
104 })
105 const { mutateAsync: updateOAuthApp } = useOAuthAppUpdateMutation({
106 onSuccess: (_, variables) => {
107 toast.success(`Successfully updated OAuth app "${variables.name}"!`)
108 onClose()
109 },
110 onError: (error) => {
111 toast.error(`Failed to update OAuth application: ${error.message}`)
112 },
113 })
114
115 const [showPreview, setShowPreview] = useState(false)
116 const [iconFile, setIconFile] = useState<File>()
117 const [iconUrl, setIconUrl] = useState<string>()
118 const [scopes, setScopes] = useState<OAuthScope[]>([])
119
120 useEffect(() => {
121 if (visible) {
122 setIconFile(undefined)
123
124 if (selectedApp !== undefined) {
125 setScopes((selectedApp?.scopes ?? []) as OAuthScope[])
126 setIconUrl(selectedApp.icon === null ? undefined : selectedApp.icon)
127 } else {
128 setScopes([])
129 setIconUrl(undefined)
130 }
131 }
132 }, [visible, selectedApp])
133
134 const onFileUpload = async (event: ChangeEvent<HTMLInputElement>) => {
135 event.persist()
136 const [file] = event.target.files || (event as any).dataTransfer.items
137 setIconFile(file)
138 setIconUrl(URL.createObjectURL(file))
139 event.target.value = ''
140 }
141
142 const onSubmit: SubmitHandler<FormSchema> = async (values) => {
143 if (!slug) return console.error('Slug is required')
144
145 const { name, website, redirect_uris } = values
146 const uploadedIconUrl =
147 iconFile !== undefined
148 ? await uploadAttachment('oauth-app-icons', `${slug}/${uuidv4()}.png`, iconFile)
149 : iconUrl
150
151 if (iconFile !== undefined && uploadedIconUrl === undefined) {
152 toast.error('Failed to upload OAuth application icon')
153 return
154 }
155
156 try {
157 if (selectedApp === undefined) {
158 // Create application
159 await createOAuthApp({
160 slug,
161 name,
162 website,
163 redirect_uris: redirect_uris.map((uris) => uris.value),
164 scopes,
165 icon: uploadedIconUrl,
166 })
167 } else {
168 // Update application
169 await updateOAuthApp({
170 id: selectedApp.id,
171 slug,
172 name,
173 website,
174 redirect_uris: redirect_uris.map((uris) => uris.value),
175 scopes,
176 icon: uploadedIconUrl,
177 })
178 }
179 } catch {
180 // Error side effects are handled in the mutation hook options
181 }
182 }
183
184 const form = useForm<FormSchema>({
185 defaultValues: getFormDefaultValues(selectedApp),
186 resolver: zodResolver(formSchema as any),
187 })
188 const { reset } = form
189 const { errors, isSubmitting } = form.formState
190
191 useEffect(() => {
192 if (visible) {
193 const defaultValues = getFormDefaultValues(selectedApp)
194 reset(defaultValues)
195 }
196 }, [visible, selectedApp, reset])
197
198 const name = useWatch({ name: 'name', control: form.control })
199 const website = useWatch({ name: 'website', control: form.control })
200
201 const {
202 fields: callbackUrlsFields,
203 append: appendCallbackUrl,
204 remove: removeCallbackUrl,
205 } = useFieldArray({
206 name: 'redirect_uris',
207 control: form.control,
208 })
209
210 return (
211 <SidePanel
212 hideFooter
213 size="large"
214 visible={visible}
215 header={
216 selectedApp !== undefined ? 'Update OAuth application' : 'Publish a new OAuth application'
217 }
218 onCancel={() => onClose()}
219 >
220 <Form {...form}>
221 <form onSubmit={form.handleSubmit(onSubmit)}>
222 <div className="h-full flex flex-col">
223 <div className="grow">
224 <SidePanel.Content>
225 <div className="py-4 flex items-start justify-between gap-10">
226 <div className="space-y-4 w-full">
227 <FormField
228 control={form.control}
229 name="name"
230 render={({ field }) => (
231 <FormItemLayout
232 layout="vertical"
233 label="Application name"
234 description={selectedApp?.id && `ID: ${selectedApp.id}`}
235 >
236 <FormControl className="col-span-6">
237 <Input {...field} />
238 </FormControl>
239 </FormItemLayout>
240 )}
241 />
242 <FormField
243 control={form.control}
244 name="website"
245 render={({ field }) => (
246 <FormItemLayout layout="vertical" label="Website URL">
247 <FormControl className="col-span-6">
248 <Input {...field} placeholder="https://my-website.com" />
249 </FormControl>
250 </FormItemLayout>
251 )}
252 />
253 </div>
254 <div>
255 {iconUrl !== undefined ? (
256 <div
257 className={cn(
258 'shadow-sm transition group relative',
259 'bg-center bg-cover bg-no-repeat',
260 'mt-4 mr-4 space-y-2 rounded-full h-[120px] w-[120px] flex flex-col items-center justify-center'
261 )}
262 style={{
263 backgroundImage: iconUrl ? `url("${iconUrl}")` : 'none',
264 }}
265 >
266 <div className="absolute bottom-1 right-1">
267 <DropdownMenu>
268 <DropdownMenuTrigger asChild>
269 <Button type="default" className="px-1">
270 <Edit />
271 </Button>
272 </DropdownMenuTrigger>
273 <DropdownMenuContent align="end" side="bottom">
274 <DropdownMenuItem
275 key="upload"
276 onClick={() => {
277 if (uploadButtonRef.current)
278 (uploadButtonRef.current as any).click()
279 }}
280 >
281 <p>Upload image</p>
282 </DropdownMenuItem>
283 <DropdownMenuItem
284 key="remove"
285 onClick={() => {
286 setIconFile(undefined)
287 setIconUrl(undefined)
288 }}
289 >
290 <p>Remove image</p>
291 </DropdownMenuItem>
292 </DropdownMenuContent>
293 </DropdownMenu>
294 </div>
295 </div>
296 ) : (
297 <div
298 className={cn(
299 'border border-strong transition opacity-75 hover:opacity-100',
300 'mt-4 mr-4 space-y-2 rounded-full h-[120px] w-[120px] flex flex-col items-center justify-center cursor-pointer'
301 )}
302 onClick={() => {
303 if (uploadButtonRef.current) (uploadButtonRef.current as any).click()
304 }}
305 >
306 <Upload size={18} strokeWidth={1.5} className="text-foreground" />
307 <p className="text-xs text-foreground-light">Upload logo</p>
308 </div>
309 )}
310 <input
311 multiple
312 type="file"
313 ref={uploadButtonRef}
314 className="hidden"
315 accept="image/png, image/jpeg"
316 onChange={onFileUpload}
317 />
318 </div>
319 </div>
320 </SidePanel.Content>
321
322 <SidePanel.Separator />
323
324 <SidePanel.Content className="py-4">
325 <div className="mb-2 flex items-center justify-between">
326 <div>
327 <p className="text-foreground text-sm">Authorization callback URLs</p>
328 <p className="text-sm text-foreground-light">
329 All URLs must use HTTPS, except for localhost
330 </p>
331 </div>
332 <Button
333 type="default"
334 onClick={() => appendCallbackUrl({ id: uuidv4(), value: '' })}
335 >
336 Add URL
337 </Button>
338 </div>
339 <div className="space-y-2 pb-2">
340 {callbackUrlsFields.map((url, index) => (
341 <FormField
342 key={url.id}
343 control={form.control}
344 name={`redirect_uris.${index}.value`}
345 render={({ field }) => (
346 <FormItemLayout
347 layout="vertical"
348 label={<span className="sr-only">Callback URL</span>}
349 >
350 <FormControl>
351 <InputGroup>
352 <InputGroupInput
353 {...field}
354 placeholder="e.g https://my-website.com"
355 />
356 {callbackUrlsFields.length > 1 ? (
357 <InputGroupAddon align="inline-end">
358 <InputGroupButton
359 type="default"
360 onClick={() => removeCallbackUrl(index)}
361 >
362 Remove
363 </InputGroupButton>
364 </InputGroupAddon>
365 ) : null}
366 </InputGroup>
367 </FormControl>
368 </FormItemLayout>
369 )}
370 />
371 ))}
372 {errors.redirect_uris?.root != null ? (
373 <p className="text-red-900 text-sm">{errors.redirect_uris?.root.message}</p>
374 ) : null}
375 </div>
376 </SidePanel.Content>
377
378 {selectedApp !== undefined && (
379 <>
380 <SidePanel.Separator />
381 <SidePanel.Content className="py-4">
382 <OAuthSecrets selectedApp={selectedApp} />
383 </SidePanel.Content>
384 </>
385 )}
386
387 <SidePanel.Separator />
388 <div className="p-6 ">
389 <div className="flex items-start justify-between space-x-4 pb-4">
390 <div className="flex flex-col">
391 <span className="text-sm text-foreground">Application permissions</span>
392 <span className="text-sm text-foreground-light">
393 The application permissions are organized in scopes and will be presented to
394 the user when adding an app to their organization and all of its projects.
395 </span>
396 </div>
397 <DocsButton href={`${DOCS_URL}/guides/platform/oauth-apps/oauth-scopes`} />
398 </div>
399
400 <ScopesPanel scopes={scopes} setScopes={setScopes} />
401 </div>
402 </div>
403
404 <SidePanel.Separator />
405
406 <SidePanel.Content>
407 <div className="pt-2 pb-3 flex items-center justify-between">
408 <Button
409 type="default"
410 onClick={() => setShowPreview(true)}
411 disabled={name.length === 0 || website.length === 0}
412 >
413 Preview consent for users
414 </Button>
415 <div className="flex items-center space-x-2">
416 <Button type="default" disabled={isSubmitting} onClick={() => onClose()}>
417 Cancel
418 </Button>
419 <Button htmlType="submit" loading={isSubmitting} disabled={isSubmitting}>
420 Confirm
421 </Button>
422 </div>
423 </div>
424 </SidePanel.Content>
425 </div>
426
427 <Modal
428 hideFooter
429 showCloseButton={false}
430 className="max-w-[600px]!"
431 visible={showPreview}
432 onCancel={() => setShowPreview(false)}
433 >
434 <Modal.Content>
435 <div className="flex items-center gap-x-2 justify-between">
436 <p className="truncate">Authorize API access for {name}</p>
437 <Badge variant="success">Preview</Badge>
438 </div>
439 </Modal.Content>
440 <Modal.Separator />
441 <Modal.Content>
442 <AuthorizeRequesterDetails
443 icon={iconUrl || null}
444 name={name}
445 domain={website}
446 scopes={scopes}
447 />
448 <div className="pt-4 space-y-2">
449 <p className="prose text-sm">Select an organization to grant API access to</p>
450 <div className="border border-control text-foreground-light rounded-sm px-4 py-2 text-sm bg-surface-200">
451 Organizations that you have access to will be listed here
452 </div>
453 </div>
454 </Modal.Content>
455 <Modal.Separator />
456 <Modal.Content>
457 <div className="flex items-center justify-between">
458 <p className="prose text-xs">
459 This is what your users will see when authorizing with your app
460 </p>
461 <Button type="default" onClick={() => setShowPreview(false)}>
462 Close
463 </Button>
464 </div>
465 </Modal.Content>
466 </Modal>
467 </form>
468 </Form>
469 </SidePanel>
470 )
471}