index.tsx471 lines · main
| 1 | import { zodResolver } from '@hookform/resolvers/zod' |
| 2 | import type { OAuthScope } from '@supabase/shared-types/out/constants' |
| 3 | import { useParams } from 'common' |
| 4 | import { Edit, Upload } from 'lucide-react' |
| 5 | import { ChangeEvent, useEffect, useRef, useState } from 'react' |
| 6 | import { SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form' |
| 7 | import { toast } from 'sonner' |
| 8 | import { |
| 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' |
| 27 | import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' |
| 28 | import * as z from 'zod' |
| 29 | |
| 30 | import { AuthorizeRequesterDetails } from '../AuthorizeRequesterDetails' |
| 31 | import { OAuthSecrets } from '../OAuthSecrets/OAuthSecrets' |
| 32 | import { ScopesPanel } from './Scopes' |
| 33 | import { DocsButton } from '@/components/ui/DocsButton' |
| 34 | import { |
| 35 | OAuthAppCreateResponse, |
| 36 | useOAuthAppCreateMutation, |
| 37 | } from '@/data/oauth/oauth-app-create-mutation' |
| 38 | import { useOAuthAppUpdateMutation } from '@/data/oauth/oauth-app-update-mutation' |
| 39 | import type { OAuthApp } from '@/data/oauth/oauth-apps-query' |
| 40 | import { DOCS_URL } from '@/lib/constants' |
| 41 | import { isValidHttpUrl, uuidv4 } from '@/lib/helpers' |
| 42 | import { uploadAttachment } from '@/lib/upload' |
| 43 | |
| 44 | export interface PublishAppSidePanelProps { |
| 45 | visible: boolean |
| 46 | selectedApp?: OAuthApp |
| 47 | onClose: () => void |
| 48 | onCreateSuccess: (app: OAuthAppCreateResponse) => void |
| 49 | } |
| 50 | |
| 51 | const 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 | |
| 69 | const 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 | |
| 84 | type FormSchema = z.infer<typeof formSchema> |
| 85 | |
| 86 | export 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 | } |