index.tsx 17 KB

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