Files
Danny WhiteandJoshen Lim 503c01b5bc chore(studio): refactor oauth apps page and table (#43366)
## What kind of change does this PR introduce?

UI update.

## What is the current behavior?

The org’s /apps page is glaringly out of date compared to similar pages.
It uses old page and layout components.

## What is the new behavior?

- Refactored /apps to use latest
[layout](https://supabase.com/design-system/docs/ui-patterns/layout) and
[table](https://supabase.com/design-system/docs/components/table)
components

Unrelated minor changes snuck in:

- Global `pnpm format` to properly format recurring annoyances elsewhere
- Minor polish on org tiles (pictured)

| Before | After |
| --- | --- |
| <img width="1728" height="997" alt="OAuth Apps
Supabase-30B6CDAE-4954-40F8-886A-939797999AA6"
src="https://github.com/user-attachments/assets/163c2787-f6ab-4a0b-80aa-af81260bdf9f"
/> | <img width="1728" height="997" alt="OAuth Apps
Supabase-7DD241E0-262A-4817-91B2-D2A299F8EB93"
src="https://github.com/user-attachments/assets/29928305-9110-4256-8663-c3821e696587"
/> |
| <img width="1728" height="997" alt="OAuth Apps
Supabase-9870816A-8022-479D-8011-236A813249AB"
src="https://github.com/user-attachments/assets/32e41835-59bc-4d83-92eb-635b98c5f4a5"
/> | <img width="1728" height="997" alt="OAuth Apps
Supabase-21CFBC16-E850-44A6-BEFF-C4FBED8ACB43"
src="https://github.com/user-attachments/assets/6999e2a0-ea17-44cb-99f2-42f985354589"
/> |
| <img width="1152" height="664"
alt="Supabase-9DD87028-2D46-47D0-8546-240184E1711B"
src="https://github.com/user-attachments/assets/2c7d8b2a-280f-48d6-9f78-19c519cf4654"
/> | <img width="1152" height="664"
alt="Supabase-F1C92F66-C602-4DC9-B3B4-AE3FB673276E"
src="https://github.com/user-attachments/assets/0a24a98e-386f-4ada-b282-0a59b159bbab"
/> |

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-03-06 12:23:33 +11:00

421 lines
16 KiB
TypeScript

import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import CopyButton from 'components/ui/CopyButton'
import NoPermission from 'components/ui/NoPermission'
import { AuthorizedApp, useAuthorizedAppsQuery } from 'data/oauth/authorized-apps-query'
import { OAuthAppCreateResponse } from 'data/oauth/oauth-app-create-mutation'
import { OAuthApp, useOAuthAppsQuery } from 'data/oauth/oauth-apps-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { Check, X } from 'lucide-react'
import { useMemo, useState } from 'react'
import {
Button,
Card,
cn,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableHeadSort,
TableRow,
} from 'ui'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageSection,
PageSectionAside,
PageSectionContent,
PageSectionDescription,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { AuthorizedAppRow } from './AuthorizedAppRow'
import { DeleteAppModal } from './DeleteAppModal'
import { OAuthAppRow } from './OAuthAppRow'
import { PublishAppSidePanel } from './PublishAppSidePanel'
import { RevokeAppModal } from './RevokeAppModal'
// [Joshen] Note on nav UX
// Kang Ming mentioned that it might be better to split Published Apps and Authorized Apps into 2 separate tabs
// to prevent any confusion (case study: GitHub). Authorized apps could be in the "integrations" tab, but let's
// check in again after we wrap up Vercel integration
type SortOrder = 'asc' | 'desc'
type PublishedAppsSort = 'created:asc' | 'created:desc'
type PublishedAppsSortColumn = 'created'
type AuthorizedAppsSort = 'authorized:asc' | 'authorized:desc'
type AuthorizedAppsSortColumn = 'authorized'
const parseSort = <C extends string>(sort: string): [C, SortOrder] => {
return sort.split(':') as [C, SortOrder]
}
const toggleSort = <S extends string>(
currentSort: S,
column: string,
setSort: (sort: S) => void
) => {
const [currentColumn, currentOrder] = parseSort(currentSort)
if (currentColumn === column) {
setSort(`${column}:${currentOrder === 'asc' ? 'desc' : 'asc'}` as S)
} else {
setSort(`${column}:asc` as S)
}
}
export const OAuthApps = () => {
const { slug } = useParams()
const [showPublishModal, setShowPublishModal] = useState(false)
const [createdApp, setCreatedApp] = useState<OAuthAppCreateResponse>()
const [selectedAppToUpdate, setSelectedAppToUpdate] = useState<OAuthApp>()
const [selectedAppToDelete, setSelectedAppToDelete] = useState<OAuthApp>()
const [selectedAppToRevoke, setSelectedAppToRevoke] = useState<AuthorizedApp>()
const [publishedAppsSort, setPublishedAppsSort] = useState<PublishedAppsSort>('created:asc')
const [authorizedAppsSort, setAuthorizedAppsSort] = useState<AuthorizedAppsSort>('authorized:asc')
const { can: canReadOAuthApps, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
PermissionAction.READ,
'approved_oauth_apps'
)
const { can: canCreateOAuthApps } = useAsyncCheckPermissions(
PermissionAction.CREATE,
'approved_oauth_apps'
)
const {
data: publishedApps,
error: publishedAppsError,
isPending: isLoadingPublishedApps,
isSuccess: isSuccessPublishedApps,
isError: isErrorPublishedApps,
} = useOAuthAppsQuery({ slug }, { enabled: canReadOAuthApps })
const sortedPublishedApps = useMemo(() => {
const [sortColumn, sortOrder] = parseSort<PublishedAppsSortColumn>(publishedAppsSort)
const orderMultiplier = sortOrder === 'asc' ? 1 : -1
return [...(publishedApps ?? [])].sort((a, b) => {
if (sortColumn === 'created') {
return (
(new Date(a.created_at ?? '').getTime() - new Date(b.created_at ?? '').getTime()) *
orderMultiplier
)
}
return 0
})
}, [publishedApps, publishedAppsSort])
const {
data: authorizedApps,
isPending: isLoadingAuthorizedApps,
isSuccess: isSuccessAuthorizedApps,
isError: isErrorAuthorizedApps,
} = useAuthorizedAppsQuery({ slug })
const sortedAuthorizedApps = useMemo(() => {
const [sortColumn, sortOrder] = parseSort<AuthorizedAppsSortColumn>(authorizedAppsSort)
const orderMultiplier = sortOrder === 'asc' ? 1 : -1
return [...(authorizedApps ?? [])].sort((a, b) => {
if (sortColumn === 'authorized') {
return (
(new Date(a.authorized_at).getTime() - new Date(b.authorized_at).getTime()) *
orderMultiplier
)
}
return 0
})
}, [authorizedApps, authorizedAppsSort])
const hasPublishedApps = (publishedApps?.length ?? 0) > 0
const hasAuthorizedApps = (authorizedApps?.length ?? 0) > 0
const avatarHeadClass = 'w-[62px] min-w-[62px] max-w-[62px]'
const avatarHeadCollapsedClass = 'w-0 min-w-0 max-w-0 p-0'
const handlePublishedSortChange = (column: PublishedAppsSortColumn) => {
toggleSort(publishedAppsSort, column, setPublishedAppsSort)
}
const handleAuthorizedSortChange = (column: AuthorizedAppsSortColumn) => {
toggleSort(authorizedAppsSort, column, setAuthorizedAppsSort)
}
return (
<>
<PageContainer size="default" className="pb-16">
<PageSection id="published-apps" className="pt-12">
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Published apps</PageSectionTitle>
<PageSectionDescription>
Build integrations that extend Supabase's functionality
</PageSectionDescription>
</PageSectionSummary>
<PageSectionAside>
<ButtonTooltip
disabled={!canCreateOAuthApps}
type="primary"
onClick={() => setShowPublishModal(true)}
tooltip={{
content: {
side: 'bottom',
text: !canCreateOAuthApps
? 'You need additional permissions to create apps'
: undefined,
},
}}
>
Publish OAuth app
</ButtonTooltip>
</PageSectionAside>
</PageSectionMeta>
<PageSectionContent className="space-y-4">
{isLoadingPublishedApps || isLoadingPermissions ? (
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
) : !canReadOAuthApps ? (
<NoPermission resourceText="view OAuth apps" />
) : null}
{isErrorPublishedApps && (
<AlertError
error={publishedAppsError}
subject="Failed to retrieve published OAuth apps"
/>
)}
{createdApp !== undefined && (
<div
className={cn(
'flex items-center justify-between p-4 px-6 border first:rounded-t last:rounded-b',
'bg-background-alternative',
'rounded'
)}
>
<div className="absolute top-4 right-4">
<Button
type="text"
icon={<X size={18} />}
className="px-1"
onClick={() => setCreatedApp(undefined)}
/>
</div>
<div className="w-full space-y-4">
<div className="flex flex-col gap-0">
<div className="flex items-center gap-2">
<Check size={14} className="text-brand" strokeWidth={3} />
<p className="text-sm">You've created your new OAuth application.</p>
</div>
<p className="text-sm text-foreground-light">
Ensure that you store the client secret securely - you will not be able to see
it again.
</p>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<p className="text-sm text-foreground-light">Client ID</p>
<p className="font-mono text-sm">{createdApp.client_id}</p>
<CopyButton text={createdApp.client_id} type="default" iconOnly />
</div>
<div className="flex items-center gap-2">
<p className="text-sm text-foreground-light">Client Secret</p>
<p className="font-mono text-sm">{createdApp.client_secret}</p>
<CopyButton text={createdApp.client_secret} type="default" iconOnly />
</div>
</div>
</div>
</div>
)}
{isSuccessPublishedApps && (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead
className={cn(
hasPublishedApps ? avatarHeadClass : avatarHeadCollapsedClass,
!hasPublishedApps && 'text-foreground-muted'
)}
>
<span className="sr-only">Avatar</span>
</TableHead>
<TableHead className={cn(!hasPublishedApps && 'text-foreground-muted')}>
Name
</TableHead>
<TableHead className={cn(!hasPublishedApps && 'text-foreground-muted')}>
Client ID
</TableHead>
<TableHead className={cn(!hasPublishedApps && 'text-foreground-muted')}>
{hasPublishedApps ? (
<TableHeadSort
column="created"
currentSort={publishedAppsSort}
onSortChange={handlePublishedSortChange}
>
CREATED
</TableHeadSort>
) : (
'CREATED'
)}
</TableHead>
<TableHead
className={cn('text-right', !hasPublishedApps && 'text-foreground-muted')}
>
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{hasPublishedApps ? (
sortedPublishedApps?.map((app) => (
<OAuthAppRow
key={app.id}
app={app}
onSelectEdit={() => {
setShowPublishModal(true)
setSelectedAppToUpdate(app)
}}
onSelectDelete={() => setSelectedAppToDelete(app)}
/>
))
) : (
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={5}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-lighter">
You do not have any published applications yet
</p>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
)}
</PageSectionContent>
</PageSection>
<PageSection id="authorized-apps">
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Authorized apps</PageSectionTitle>
<PageSectionDescription>
Applications that have access to your organization's settings and projects
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent className="space-y-4">
{isLoadingAuthorizedApps || isLoadingPermissions ? (
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
) : !canReadOAuthApps ? (
<NoPermission resourceText="view authorized apps" />
) : null}
{isErrorAuthorizedApps && <AlertError subject="Failed to retrieve authorized apps" />}
{isSuccessAuthorizedApps && (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead
className={cn(
hasAuthorizedApps ? avatarHeadClass : avatarHeadCollapsedClass,
!hasAuthorizedApps && 'text-foreground-muted'
)}
>
<span className="sr-only">Avatar</span>
</TableHead>
<TableHead className={cn(!hasAuthorizedApps && 'text-foreground-muted')}>
Name
</TableHead>
<TableHead className={cn(!hasAuthorizedApps && 'text-foreground-muted')}>
Author
</TableHead>
<TableHead className={cn(!hasAuthorizedApps && 'text-foreground-muted')}>
App ID
</TableHead>
<TableHead className={cn(!hasAuthorizedApps && 'text-foreground-muted')}>
{hasAuthorizedApps ? (
<TableHeadSort
column="authorized"
currentSort={authorizedAppsSort}
onSortChange={handleAuthorizedSortChange}
>
AUTHORIZED
</TableHeadSort>
) : (
'AUTHORIZED'
)}
</TableHead>
<TableHead
className={cn('text-right', !hasAuthorizedApps && 'text-foreground-muted')}
>
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{hasAuthorizedApps ? (
sortedAuthorizedApps?.map((app) => (
<AuthorizedAppRow
key={app.id}
app={app}
onSelectRevoke={() => setSelectedAppToRevoke(app)}
/>
))
) : (
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={6}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-lighter">
You do not have any authorized applications yet
</p>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
)}
</PageSectionContent>
</PageSection>
</PageContainer>
<PublishAppSidePanel
visible={showPublishModal}
selectedApp={selectedAppToUpdate}
onClose={() => {
setSelectedAppToUpdate(undefined)
setShowPublishModal(false)
}}
onCreateSuccess={setCreatedApp}
/>
<DeleteAppModal
selectedApp={selectedAppToDelete}
onClose={() => setSelectedAppToDelete(undefined)}
/>
<RevokeAppModal
selectedApp={selectedAppToRevoke}
onClose={() => setSelectedAppToRevoke(undefined)}
/>
</>
)
}