OAuthApps.tsx420 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import { useParams } from 'common'
3import { Check, X } from 'lucide-react'
4import { useMemo, useState } from 'react'
5import {
6 Button,
7 Card,
8 cn,
9 Table,
10 TableBody,
11 TableCell,
12 TableHead,
13 TableHeader,
14 TableHeadSort,
15 TableRow,
16} from 'ui'
17import { PageContainer } from 'ui-patterns/PageContainer'
18import {
19 PageSection,
20 PageSectionAside,
21 PageSectionContent,
22 PageSectionDescription,
23 PageSectionMeta,
24 PageSectionSummary,
25 PageSectionTitle,
26} from 'ui-patterns/PageSection'
27import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
28
29import { AuthorizedAppRow } from './AuthorizedAppRow'
30import { DeleteAppModal } from './DeleteAppModal'
31import { OAuthAppRow } from './OAuthAppRow'
32import { PublishAppSidePanel } from './PublishAppSidePanel'
33import { RevokeAppModal } from './RevokeAppModal'
34import AlertError from '@/components/ui/AlertError'
35import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
36import CopyButton from '@/components/ui/CopyButton'
37import NoPermission from '@/components/ui/NoPermission'
38import { AuthorizedApp, useAuthorizedAppsQuery } from '@/data/oauth/authorized-apps-query'
39import { OAuthAppCreateResponse } from '@/data/oauth/oauth-app-create-mutation'
40import { OAuthApp, useOAuthAppsQuery } from '@/data/oauth/oauth-apps-query'
41import { 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
48type SortOrder = 'asc' | 'desc'
49type PublishedAppsSort = 'created:asc' | 'created:desc'
50type PublishedAppsSortColumn = 'created'
51type AuthorizedAppsSort = 'authorized:asc' | 'authorized:desc'
52type AuthorizedAppsSortColumn = 'authorized'
53
54const parseSort = <C extends string>(sort: string): [C, SortOrder] => {
55 return sort.split(':') as [C, SortOrder]
56}
57
58const 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
71export 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}