OAuthApps.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. import { PermissionAction } from '@supabase/shared-types/out/constants'
  2. import { useParams } from 'common'
  3. import { Check, X } from 'lucide-react'
  4. import { useMemo, useState } from 'react'
  5. import {
  6. Button,
  7. Card,
  8. cn,
  9. Table,
  10. TableBody,
  11. TableCell,
  12. TableHead,
  13. TableHeader,
  14. TableHeadSort,
  15. TableRow,
  16. } from 'ui'
  17. import { PageContainer } from 'ui-patterns/PageContainer'
  18. import {
  19. PageSection,
  20. PageSectionAside,
  21. PageSectionContent,
  22. PageSectionDescription,
  23. PageSectionMeta,
  24. PageSectionSummary,
  25. PageSectionTitle,
  26. } from 'ui-patterns/PageSection'
  27. import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
  28. import { AuthorizedAppRow } from './AuthorizedAppRow'
  29. import { DeleteAppModal } from './DeleteAppModal'
  30. import { OAuthAppRow } from './OAuthAppRow'
  31. import { PublishAppSidePanel } from './PublishAppSidePanel'
  32. import { RevokeAppModal } from './RevokeAppModal'
  33. import AlertError from '@/components/ui/AlertError'
  34. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  35. import CopyButton from '@/components/ui/CopyButton'
  36. import NoPermission from '@/components/ui/NoPermission'
  37. import { AuthorizedApp, useAuthorizedAppsQuery } from '@/data/oauth/authorized-apps-query'
  38. import { OAuthAppCreateResponse } from '@/data/oauth/oauth-app-create-mutation'
  39. import { OAuthApp, useOAuthAppsQuery } from '@/data/oauth/oauth-apps-query'
  40. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  41. // [Joshen] Note on nav UX
  42. // Kang Ming mentioned that it might be better to split Published Apps and Authorized Apps into 2 separate tabs
  43. // to prevent any confusion (case study: GitHub). Authorized apps could be in the "integrations" tab, but let's
  44. // check in again after we wrap up Vercel integration
  45. type SortOrder = 'asc' | 'desc'
  46. type PublishedAppsSort = 'created:asc' | 'created:desc'
  47. type PublishedAppsSortColumn = 'created'
  48. type AuthorizedAppsSort = 'authorized:asc' | 'authorized:desc'
  49. type AuthorizedAppsSortColumn = 'authorized'
  50. const parseSort = <C extends string>(sort: string): [C, SortOrder] => {
  51. return sort.split(':') as [C, SortOrder]
  52. }
  53. const toggleSort = <S extends string>(
  54. currentSort: S,
  55. column: string,
  56. setSort: (sort: S) => void
  57. ) => {
  58. const [currentColumn, currentOrder] = parseSort(currentSort)
  59. if (currentColumn === column) {
  60. setSort(`${column}:${currentOrder === 'asc' ? 'desc' : 'asc'}` as S)
  61. } else {
  62. setSort(`${column}:asc` as S)
  63. }
  64. }
  65. export const OAuthApps = () => {
  66. const { slug } = useParams()
  67. const [showPublishModal, setShowPublishModal] = useState(false)
  68. const [createdApp, setCreatedApp] = useState<OAuthAppCreateResponse>()
  69. const [selectedAppToUpdate, setSelectedAppToUpdate] = useState<OAuthApp>()
  70. const [selectedAppToDelete, setSelectedAppToDelete] = useState<OAuthApp>()
  71. const [selectedAppToRevoke, setSelectedAppToRevoke] = useState<AuthorizedApp>()
  72. const [publishedAppsSort, setPublishedAppsSort] = useState<PublishedAppsSort>('created:asc')
  73. const [authorizedAppsSort, setAuthorizedAppsSort] = useState<AuthorizedAppsSort>('authorized:asc')
  74. const { can: canReadOAuthApps, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
  75. PermissionAction.READ,
  76. 'approved_oauth_apps'
  77. )
  78. const { can: canCreateOAuthApps } = useAsyncCheckPermissions(
  79. PermissionAction.CREATE,
  80. 'approved_oauth_apps'
  81. )
  82. const {
  83. data: publishedApps,
  84. error: publishedAppsError,
  85. isPending: isLoadingPublishedApps,
  86. isSuccess: isSuccessPublishedApps,
  87. isError: isErrorPublishedApps,
  88. } = useOAuthAppsQuery({ slug }, { enabled: canReadOAuthApps })
  89. const sortedPublishedApps = useMemo(() => {
  90. const [sortColumn, sortOrder] = parseSort<PublishedAppsSortColumn>(publishedAppsSort)
  91. const orderMultiplier = sortOrder === 'asc' ? 1 : -1
  92. return [...(publishedApps ?? [])].sort((a, b) => {
  93. if (sortColumn === 'created') {
  94. return (
  95. (new Date(a.created_at ?? '').getTime() - new Date(b.created_at ?? '').getTime()) *
  96. orderMultiplier
  97. )
  98. }
  99. return 0
  100. })
  101. }, [publishedApps, publishedAppsSort])
  102. const {
  103. data: authorizedApps,
  104. isPending: isLoadingAuthorizedApps,
  105. isSuccess: isSuccessAuthorizedApps,
  106. isError: isErrorAuthorizedApps,
  107. } = useAuthorizedAppsQuery({ slug })
  108. const sortedAuthorizedApps = useMemo(() => {
  109. const [sortColumn, sortOrder] = parseSort<AuthorizedAppsSortColumn>(authorizedAppsSort)
  110. const orderMultiplier = sortOrder === 'asc' ? 1 : -1
  111. return [...(authorizedApps ?? [])].sort((a, b) => {
  112. if (sortColumn === 'authorized') {
  113. return (
  114. (new Date(a.authorized_at).getTime() - new Date(b.authorized_at).getTime()) *
  115. orderMultiplier
  116. )
  117. }
  118. return 0
  119. })
  120. }, [authorizedApps, authorizedAppsSort])
  121. const hasPublishedApps = (publishedApps?.length ?? 0) > 0
  122. const hasAuthorizedApps = (authorizedApps?.length ?? 0) > 0
  123. const avatarHeadClass = 'w-[62px] min-w-[62px] max-w-[62px]'
  124. const avatarHeadCollapsedClass = 'w-0 min-w-0 max-w-0 p-0'
  125. const handlePublishedSortChange = (column: PublishedAppsSortColumn) => {
  126. toggleSort(publishedAppsSort, column, setPublishedAppsSort)
  127. }
  128. const handleAuthorizedSortChange = (column: AuthorizedAppsSortColumn) => {
  129. toggleSort(authorizedAppsSort, column, setAuthorizedAppsSort)
  130. }
  131. return (
  132. <>
  133. <PageContainer size="default" className="pb-16">
  134. <PageSection id="published-apps" className="pt-12">
  135. <PageSectionMeta>
  136. <PageSectionSummary>
  137. <PageSectionTitle>Published apps</PageSectionTitle>
  138. <PageSectionDescription>
  139. Build integrations that extend Briven's functionality
  140. </PageSectionDescription>
  141. </PageSectionSummary>
  142. <PageSectionAside>
  143. <ButtonTooltip
  144. disabled={!canCreateOAuthApps}
  145. type="primary"
  146. onClick={() => setShowPublishModal(true)}
  147. tooltip={{
  148. content: {
  149. side: 'bottom',
  150. text: !canCreateOAuthApps
  151. ? 'You need additional permissions to create apps'
  152. : undefined,
  153. },
  154. }}
  155. >
  156. Publish OAuth app
  157. </ButtonTooltip>
  158. </PageSectionAside>
  159. </PageSectionMeta>
  160. <PageSectionContent className="space-y-4">
  161. {isLoadingPublishedApps || isLoadingPermissions ? (
  162. <div className="space-y-2">
  163. <ShimmeringLoader />
  164. <ShimmeringLoader className="w-3/4" />
  165. <ShimmeringLoader className="w-1/2" />
  166. </div>
  167. ) : !canReadOAuthApps ? (
  168. <NoPermission resourceText="view OAuth apps" />
  169. ) : null}
  170. {isErrorPublishedApps && (
  171. <AlertError
  172. error={publishedAppsError}
  173. subject="Failed to retrieve published OAuth apps"
  174. />
  175. )}
  176. {createdApp !== undefined && (
  177. <div
  178. className={cn(
  179. 'flex items-center justify-between p-4 px-6 border first:rounded-t last:rounded-b',
  180. 'bg-background-alternative',
  181. 'rounded-sm'
  182. )}
  183. >
  184. <div className="absolute top-4 right-4">
  185. <Button
  186. type="text"
  187. icon={<X size={18} />}
  188. className="px-1"
  189. onClick={() => setCreatedApp(undefined)}
  190. />
  191. </div>
  192. <div className="w-full space-y-4">
  193. <div className="flex flex-col gap-0">
  194. <div className="flex items-center gap-2">
  195. <Check size={14} className="text-brand" strokeWidth={3} />
  196. <p className="text-sm">You've created your new OAuth application.</p>
  197. </div>
  198. <p className="text-sm text-foreground-light">
  199. Ensure that you store the client secret securely - you will not be able to see
  200. it again.
  201. </p>
  202. </div>
  203. <div className="flex flex-col gap-1">
  204. <div className="flex items-center gap-2">
  205. <p className="text-sm text-foreground-light">Client ID</p>
  206. <p className="font-mono text-sm">{createdApp.client_id}</p>
  207. <CopyButton text={createdApp.client_id} type="default" iconOnly />
  208. </div>
  209. <div className="flex items-center gap-2">
  210. <p className="text-sm text-foreground-light">Client Secret</p>
  211. <p className="font-mono text-sm">{createdApp.client_secret}</p>
  212. <CopyButton text={createdApp.client_secret} type="default" iconOnly />
  213. </div>
  214. </div>
  215. </div>
  216. </div>
  217. )}
  218. {isSuccessPublishedApps && (
  219. <Card>
  220. <Table>
  221. <TableHeader>
  222. <TableRow>
  223. <TableHead
  224. className={cn(
  225. hasPublishedApps ? avatarHeadClass : avatarHeadCollapsedClass,
  226. !hasPublishedApps && 'text-foreground-muted'
  227. )}
  228. >
  229. <span className="sr-only">Avatar</span>
  230. </TableHead>
  231. <TableHead className={cn(!hasPublishedApps && 'text-foreground-muted')}>
  232. Name
  233. </TableHead>
  234. <TableHead className={cn(!hasPublishedApps && 'text-foreground-muted')}>
  235. Client ID
  236. </TableHead>
  237. <TableHead className={cn(!hasPublishedApps && 'text-foreground-muted')}>
  238. {hasPublishedApps ? (
  239. <TableHeadSort
  240. column="created"
  241. currentSort={publishedAppsSort}
  242. onSortChange={handlePublishedSortChange}
  243. >
  244. CREATED
  245. </TableHeadSort>
  246. ) : (
  247. 'CREATED'
  248. )}
  249. </TableHead>
  250. <TableHead
  251. className={cn('text-right', !hasPublishedApps && 'text-foreground-muted')}
  252. >
  253. <span className="sr-only">Actions</span>
  254. </TableHead>
  255. </TableRow>
  256. </TableHeader>
  257. <TableBody>
  258. {hasPublishedApps ? (
  259. sortedPublishedApps?.map((app) => (
  260. <OAuthAppRow
  261. key={app.id}
  262. app={app}
  263. onSelectEdit={() => {
  264. setShowPublishModal(true)
  265. setSelectedAppToUpdate(app)
  266. }}
  267. onSelectDelete={() => setSelectedAppToDelete(app)}
  268. />
  269. ))
  270. ) : (
  271. <TableRow className="[&>td]:hover:bg-inherit">
  272. <TableCell colSpan={5}>
  273. <p className="text-sm text-foreground">No results found</p>
  274. <p className="text-sm text-foreground-lighter">
  275. You do not have any published applications yet
  276. </p>
  277. </TableCell>
  278. </TableRow>
  279. )}
  280. </TableBody>
  281. </Table>
  282. </Card>
  283. )}
  284. </PageSectionContent>
  285. </PageSection>
  286. <PageSection id="authorized-apps">
  287. <PageSectionMeta>
  288. <PageSectionSummary>
  289. <PageSectionTitle>Authorized apps</PageSectionTitle>
  290. <PageSectionDescription>
  291. Applications that have access to your organization's settings and projects
  292. </PageSectionDescription>
  293. </PageSectionSummary>
  294. </PageSectionMeta>
  295. <PageSectionContent className="space-y-4">
  296. {isLoadingAuthorizedApps || isLoadingPermissions ? (
  297. <div className="space-y-2">
  298. <ShimmeringLoader />
  299. <ShimmeringLoader className="w-3/4" />
  300. <ShimmeringLoader className="w-1/2" />
  301. </div>
  302. ) : !canReadOAuthApps ? (
  303. <NoPermission resourceText="view authorized apps" />
  304. ) : null}
  305. {isErrorAuthorizedApps && <AlertError subject="Failed to retrieve authorized apps" />}
  306. {isSuccessAuthorizedApps && (
  307. <Card>
  308. <Table>
  309. <TableHeader>
  310. <TableRow>
  311. <TableHead
  312. className={cn(
  313. hasAuthorizedApps ? avatarHeadClass : avatarHeadCollapsedClass,
  314. !hasAuthorizedApps && 'text-foreground-muted'
  315. )}
  316. >
  317. <span className="sr-only">Avatar</span>
  318. </TableHead>
  319. <TableHead className={cn(!hasAuthorizedApps && 'text-foreground-muted')}>
  320. Name
  321. </TableHead>
  322. <TableHead className={cn(!hasAuthorizedApps && 'text-foreground-muted')}>
  323. Author
  324. </TableHead>
  325. <TableHead className={cn(!hasAuthorizedApps && 'text-foreground-muted')}>
  326. App ID
  327. </TableHead>
  328. <TableHead className={cn(!hasAuthorizedApps && 'text-foreground-muted')}>
  329. {hasAuthorizedApps ? (
  330. <TableHeadSort
  331. column="authorized"
  332. currentSort={authorizedAppsSort}
  333. onSortChange={handleAuthorizedSortChange}
  334. >
  335. AUTHORIZED
  336. </TableHeadSort>
  337. ) : (
  338. 'AUTHORIZED'
  339. )}
  340. </TableHead>
  341. <TableHead
  342. className={cn('text-right', !hasAuthorizedApps && 'text-foreground-muted')}
  343. >
  344. <span className="sr-only">Actions</span>
  345. </TableHead>
  346. </TableRow>
  347. </TableHeader>
  348. <TableBody>
  349. {hasAuthorizedApps ? (
  350. sortedAuthorizedApps?.map((app) => (
  351. <AuthorizedAppRow
  352. key={app.id}
  353. app={app}
  354. onSelectRevoke={() => setSelectedAppToRevoke(app)}
  355. />
  356. ))
  357. ) : (
  358. <TableRow className="[&>td]:hover:bg-inherit">
  359. <TableCell colSpan={6}>
  360. <p className="text-sm text-foreground">No results found</p>
  361. <p className="text-sm text-foreground-lighter">
  362. You do not have any authorized applications yet
  363. </p>
  364. </TableCell>
  365. </TableRow>
  366. )}
  367. </TableBody>
  368. </Table>
  369. </Card>
  370. )}
  371. </PageSectionContent>
  372. </PageSection>
  373. </PageContainer>
  374. <PublishAppSidePanel
  375. visible={showPublishModal}
  376. selectedApp={selectedAppToUpdate}
  377. onClose={() => {
  378. setSelectedAppToUpdate(undefined)
  379. setShowPublishModal(false)
  380. }}
  381. onCreateSuccess={setCreatedApp}
  382. />
  383. <DeleteAppModal
  384. selectedApp={selectedAppToDelete}
  385. onClose={() => setSelectedAppToDelete(undefined)}
  386. />
  387. <RevokeAppModal
  388. selectedApp={selectedAppToRevoke}
  389. onClose={() => setSelectedAppToRevoke(undefined)}
  390. />
  391. </>
  392. )
  393. }