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