OAuthAppsList.tsx512 lines · main
1import type { OAuthClient } from '@supabase/supabase-js'
2import { useParams } from 'common'
3import { Edit, MoreVertical, Plus, RotateCw, Search, Trash, X } from 'lucide-react'
4import Link from 'next/link'
5import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs'
6import { useEffect, useMemo, useRef, useState } from 'react'
7import { toast } from 'sonner'
8import {
9 Button,
10 Card,
11 DropdownMenu,
12 DropdownMenuContent,
13 DropdownMenuItem,
14 DropdownMenuSeparator,
15 DropdownMenuTrigger,
16 InputGroup,
17 InputGroupAddon,
18 InputGroupInput,
19 Table,
20 TableBody,
21 TableCell,
22 TableHead,
23 TableHeader,
24 TableHeadSort,
25 TableRow,
26} from 'ui'
27import { Admonition } from 'ui-patterns/admonition'
28import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
29import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
30import { TimestampInfo } from 'ui-patterns/TimestampInfo'
31
32import { CreateOrUpdateOAuthAppSheet } from './CreateOrUpdateOAuthAppSheet'
33import { DeleteOAuthAppModal } from './DeleteOAuthAppModal'
34import { NewOAuthAppBanner } from './NewOAuthAppBanner'
35import {
36 filterOAuthApps,
37 OAUTH_APP_CLIENT_TYPE_OPTIONS,
38 OAUTH_APP_REGISTRATION_TYPE_OPTIONS,
39} from './oauthApps.utils'
40import AlertError from '@/components/ui/AlertError'
41import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
42import { FilterPopover } from '@/components/ui/FilterPopover'
43import { Shortcut } from '@/components/ui/Shortcut'
44import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
45import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
46import { useOAuthServerAppDeleteMutation } from '@/data/oauth-server-apps/oauth-server-app-delete-mutation'
47import { useOAuthServerAppRegenerateSecretMutation } from '@/data/oauth-server-apps/oauth-server-app-regenerate-secret-mutation'
48import { useOAuthServerAppsQuery } from '@/data/oauth-server-apps/oauth-server-apps-query'
49import { onSearchInputEscape } from '@/lib/keyboard'
50import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
51import { useShortcut } from '@/state/shortcuts/useShortcut'
52
53const OAUTH_APPS_SORT_VALUES = [
54 'name:asc',
55 'name:desc',
56 'client_type:asc',
57 'client_type:desc',
58 'registration_type:asc',
59 'registration_type:desc',
60 'created_at:asc',
61 'created_at:desc',
62] as const
63
64type OAuthAppsSort = (typeof OAUTH_APPS_SORT_VALUES)[number]
65type OAuthAppsSortColumn = OAuthAppsSort extends `${infer Column}:${string}` ? Column : unknown
66type OAuthAppsSortOrder = OAuthAppsSort extends `${string}:${infer Order}` ? Order : unknown
67
68export const OAuthAppsList = () => {
69 const { ref: projectRef } = useParams()
70 const {
71 data: authConfig,
72 isPending: isAuthConfigLoading,
73 isSuccess: isSuccessAuthConfig,
74 } = useAuthConfigQuery({ projectRef })
75 const isOAuthServerEnabled = !!authConfig?.OAUTH_SERVER_ENABLED
76
77 const [newOAuthApp, setNewOAuthApp] = useState<OAuthClient | undefined>(undefined)
78 const [showRegenerateDialog, setShowRegenerateDialog] = useState(false)
79 const [selectedApp, setSelectedApp] = useState<OAuthClient>()
80 const [filteredRegistrationTypes, setFilteredRegistrationTypes] = useState<string[]>([])
81 const [filteredClientTypes, setFilteredClientTypes] = useState<string[]>([])
82 const [filterString, setFilterString] = useState<string>('')
83 const searchInputRef = useRef<HTMLInputElement>(null)
84
85 const { hostEndpoint: clientEndpoint } = useProjectApiUrl({ projectRef })
86 const {
87 data,
88 error,
89 isPending: isLoading,
90 isSuccess,
91 isError,
92 } = useOAuthServerAppsQuery({ projectRef })
93 const oAuthApps = useMemo(() => data?.clients || [], [data])
94
95 const { mutateAsync: regenerateSecret, isPending: isRegenerating } =
96 useOAuthServerAppRegenerateSecretMutation({
97 onSuccess: (data) => {
98 if (data) setNewOAuthApp(data)
99 },
100 })
101
102 const [sort, setSort] = useQueryState(
103 'sort',
104 parseAsStringLiteral<OAuthAppsSort>(OAUTH_APPS_SORT_VALUES).withDefault('name:asc')
105 )
106
107 const [showCreateSheet, setShowCreateSheet] = useQueryState(
108 'new',
109 parseAsBoolean.withDefault(false)
110 )
111
112 const [selectedAppToEdit, setSelectedAppToEdit] = useQueryState('edit', parseAsString)
113 const appToEdit = oAuthApps?.find((app) => app.client_id === selectedAppToEdit)
114
115 const [selectedAppToDelete, setSelectedAppToDelete] = useQueryState('delete', parseAsString)
116 const appToDelete = oAuthApps?.find((app) => app.client_id === selectedAppToDelete)
117
118 const {
119 mutate: deleteOAuthApp,
120 isPending: isDeletingApp,
121 isSuccess: isSuccessDelete,
122 } = useOAuthServerAppDeleteMutation({
123 onSuccess: () => {
124 toast.success(`Successfully deleted OAuth app`)
125 setSelectedAppToDelete(null)
126 },
127 })
128
129 const filteredAndSortedOAuthApps = useMemo(() => {
130 const filtered = filterOAuthApps({
131 apps: oAuthApps,
132 searchString: filterString,
133 registrationTypes: filteredRegistrationTypes,
134 clientTypes: filteredClientTypes,
135 })
136
137 const [sortCol, sortOrder] = sort.split(':') as [OAuthAppsSortColumn, OAuthAppsSortOrder]
138 const orderMultiplier = sortOrder === 'asc' ? 1 : -1
139
140 return filtered.sort((a, b) => {
141 if (sortCol === 'name') {
142 return (a.client_name || '').localeCompare(b.client_name || '') * orderMultiplier
143 }
144 if (sortCol === 'client_type') {
145 return a.client_type.localeCompare(b.client_type) * orderMultiplier
146 }
147 if (sortCol === 'registration_type') {
148 return a.registration_type.localeCompare(b.registration_type) * orderMultiplier
149 }
150 if (sortCol === 'created_at') {
151 return (
152 (new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) * orderMultiplier
153 )
154 }
155 return 0
156 })
157 }, [oAuthApps, filterString, filteredRegistrationTypes, filteredClientTypes, sort])
158
159 const hasActiveFilters =
160 filterString.length > 0 ||
161 filteredRegistrationTypes.length > 0 ||
162 filteredClientTypes.length > 0
163
164 const handleResetFilters = () => {
165 setFilterString('')
166 setFilteredRegistrationTypes([])
167 setFilteredClientTypes([])
168 }
169
170 useShortcut(
171 SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
172 () => {
173 searchInputRef.current?.focus()
174 searchInputRef.current?.select()
175 },
176 { label: 'Search OAuth apps' }
177 )
178
179 useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, handleResetFilters)
180
181 const handleSortChange = (column: OAuthAppsSortColumn) => {
182 const [currentCol, currentOrder] = sort.split(':') as [OAuthAppsSortColumn, OAuthAppsSortOrder]
183 if (currentCol === column) {
184 // Cycle through: asc -> desc -> no sort (default)
185 if (currentOrder === 'asc') {
186 setSort(`${column}:desc` as OAuthAppsSort)
187 } else {
188 // Reset to default sort (name:asc)
189 setSort('name:asc')
190 }
191 } else {
192 // New column, start with asc
193 setSort(`${column}:asc` as OAuthAppsSort)
194 }
195 }
196
197 const isCreateMode = showCreateSheet && isOAuthServerEnabled
198 const isEditMode = !!appToEdit
199 const isCreateOrUpdateSheetVisible = isCreateMode || isEditMode
200
201 // Prevent opening the create sheet if OAuth Server is disabled
202 useEffect(() => {
203 if (isSuccessAuthConfig && !isOAuthServerEnabled && showCreateSheet) {
204 setShowCreateSheet(false)
205 }
206 }, [isSuccessAuthConfig, isOAuthServerEnabled, showCreateSheet, setShowCreateSheet])
207
208 useEffect(() => {
209 if (isSuccess && !!selectedAppToEdit && !appToEdit) {
210 toast('App not found')
211 setSelectedAppToEdit(null)
212 }
213 }, [appToEdit, isSuccess, selectedAppToEdit, setSelectedAppToEdit])
214
215 useEffect(() => {
216 if (isSuccess && !!selectedAppToDelete && !appToDelete && !isSuccessDelete) {
217 toast('App not found')
218 setSelectedAppToDelete(null)
219 }
220 }, [appToDelete, isSuccess, isSuccessDelete, selectedAppToDelete, setSelectedAppToDelete])
221
222 if (isAuthConfigLoading || (isOAuthServerEnabled && isLoading)) {
223 return <GenericSkeletonLoader />
224 }
225
226 if (isError) {
227 return <AlertError error={error} subject="Failed to retrieve OAuth Server apps" />
228 }
229
230 return (
231 <>
232 <div className="flex flex-col gap-y-4">
233 {newOAuthApp?.client_secret && (
234 <NewOAuthAppBanner oauthApp={newOAuthApp} onClose={() => setNewOAuthApp(undefined)} />
235 )}
236 {!isOAuthServerEnabled && (
237 <Admonition
238 type="default"
239 layout="horizontal"
240 className="mb-8"
241 title="OAuth Server is disabled"
242 description="Enable OAuth Server to make your project act as an identity provider for third-party applications."
243 actions={
244 <Button asChild type="default">
245 <Link href={`/project/${projectRef}/auth/oauth-server`}>OAuth Server Settings</Link>
246 </Button>
247 }
248 />
249 )}
250 <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2 flex-wrap">
251 <div className="flex flex-col lg:flex-row lg:items-center gap-2">
252 <InputGroup className="w-full lg:w-52">
253 <InputGroupInput
254 ref={searchInputRef}
255 size="tiny"
256 placeholder="Search OAuth apps"
257 value={filterString}
258 onChange={(e) => setFilterString(e.target.value)}
259 onKeyDown={onSearchInputEscape(filterString, setFilterString)}
260 />
261 <InputGroupAddon>
262 <Search />
263 </InputGroupAddon>
264 </InputGroup>
265 <FilterPopover
266 name="Registration Type"
267 options={OAUTH_APP_REGISTRATION_TYPE_OPTIONS}
268 labelKey="name"
269 valueKey="value"
270 iconKey="icon"
271 activeOptions={filteredRegistrationTypes}
272 labelClass="text-xs text-foreground-light"
273 maxHeightClass="h-[190px]"
274 className="w-52"
275 onSaveFilters={setFilteredRegistrationTypes}
276 />
277 <FilterPopover
278 name="Client Type"
279 options={OAUTH_APP_CLIENT_TYPE_OPTIONS}
280 labelKey="name"
281 valueKey="value"
282 iconKey="icon"
283 activeOptions={filteredClientTypes}
284 labelClass="text-xs text-foreground-light"
285 maxHeightClass="h-[190px]"
286 className="w-52"
287 onSaveFilters={setFilteredClientTypes}
288 />
289 {hasActiveFilters && (
290 <Button
291 type="default"
292 size="tiny"
293 className="px-1"
294 icon={<X />}
295 onClick={handleResetFilters}
296 />
297 )}
298 </div>
299 <div className="flex items-center gap-x-2">
300 {isOAuthServerEnabled ? (
301 <Shortcut
302 id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
303 label="Create new OAuth app"
304 onTrigger={() => setShowCreateSheet(true)}
305 side="bottom"
306 >
307 <Button
308 type="primary"
309 icon={<Plus />}
310 onClick={() => setShowCreateSheet(true)}
311 className="grow"
312 >
313 New OAuth App
314 </Button>
315 </Shortcut>
316 ) : (
317 <ButtonTooltip
318 disabled
319 icon={<Plus />}
320 onClick={() => setShowCreateSheet(true)}
321 className="grow"
322 tooltip={{
323 content: {
324 side: 'bottom',
325 text: 'OAuth server must be enabled in settings',
326 },
327 }}
328 >
329 New OAuth App
330 </ButtonTooltip>
331 )}
332 </div>
333 </div>
334
335 <div className="w-full overflow-hidden overflow-x-auto">
336 <Card className="@container">
337 <Table containerProps={{ stickyLastColumn: true }}>
338 <TableHeader>
339 <TableRow>
340 <TableHead className="w-48 max-w-48 flex">
341 <TableHeadSort column="name" currentSort={sort} onSortChange={handleSortChange}>
342 Name
343 </TableHeadSort>
344 </TableHead>
345 <TableHead>Client ID</TableHead>
346 <TableHead>
347 <TableHeadSort
348 column="client_type"
349 currentSort={sort}
350 onSortChange={handleSortChange}
351 >
352 Client Type
353 </TableHeadSort>
354 </TableHead>
355 <TableHead>
356 <TableHeadSort
357 column="registration_type"
358 currentSort={sort}
359 onSortChange={handleSortChange}
360 >
361 Registration Type
362 </TableHeadSort>
363 </TableHead>
364 <TableHead>
365 <TableHeadSort
366 column="created_at"
367 currentSort={sort}
368 onSortChange={handleSortChange}
369 >
370 Created
371 </TableHeadSort>
372 </TableHead>
373 <TableHead className="w-8 px-0">
374 <div className="bg-200! px-4 w-full h-full flex items-center border-l @[944px]:border-l-0" />
375 </TableHead>
376 </TableRow>
377 </TableHeader>
378 <TableBody>
379 {filteredAndSortedOAuthApps.length === 0 && (
380 <TableRow>
381 <TableCell colSpan={6}>
382 <p className="text-foreground-lighter">No OAuth apps found</p>
383 </TableCell>
384 </TableRow>
385 )}
386 {filteredAndSortedOAuthApps.length > 0 &&
387 filteredAndSortedOAuthApps.map((app) => (
388 <TableRow key={app.client_id} className="w-full">
389 <TableCell title={app.client_name}>
390 <Button
391 type="text"
392 className="text-link-table-cell text-sm p-0 hover:bg-transparent title [&>span]:w-full!"
393 onClick={() => setSelectedAppToEdit(app.client_id)}
394 title={app.client_name}
395 >
396 {app.client_name}
397 </Button>
398 </TableCell>
399 <TableCell title={app.client_id}>
400 <code className="text-code-inline">{app.client_id}</code>
401 </TableCell>
402 <TableCell className="max-w-28 capitalize">{app.client_type}</TableCell>
403 <TableCell className="max-w-28 capitalize">{app.registration_type}</TableCell>
404 <TableCell className="min-w-28 max-w-40 w-1/6">
405 <TimestampInfo
406 className="text-sm"
407 utcTimestamp={app.created_at}
408 labelFormat="D MMM, YYYY"
409 />
410 </TableCell>
411 <TableCell className="max-w-20 bg-surface-100 @[944px]:hover:bg-surface-200 px-6">
412 <div className="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center border-l @[944px]:border-l-0">
413 <DropdownMenu>
414 <DropdownMenuTrigger asChild>
415 <Button type="default" className="px-1" icon={<MoreVertical />} />
416 </DropdownMenuTrigger>
417 <DropdownMenuContent side="bottom" align="end" className="w-48">
418 <DropdownMenuItem
419 className="space-x-2"
420 onClick={() => {
421 setSelectedAppToEdit(app.client_id)
422 }}
423 >
424 <Edit size={12} />
425 <p>Edit OAuth app</p>
426 </DropdownMenuItem>
427 {app.client_type === 'confidential' && (
428 <DropdownMenuItem
429 className="space-x-2"
430 onClick={() => {
431 setSelectedApp(app)
432 setShowRegenerateDialog(true)
433 }}
434 >
435 <RotateCw size={12} />
436 <p>Regenerate client secret</p>
437 </DropdownMenuItem>
438 )}
439 <DropdownMenuSeparator />
440 <DropdownMenuItem
441 className="space-x-2"
442 onClick={() => setSelectedAppToDelete(app.client_id)}
443 >
444 <Trash size={12} />
445 <p>Delete OAuth app</p>
446 </DropdownMenuItem>
447 </DropdownMenuContent>
448 </DropdownMenu>
449 </div>
450 </TableCell>
451 </TableRow>
452 ))}
453 </TableBody>
454 </Table>
455 </Card>
456 </div>
457 </div>
458
459 <CreateOrUpdateOAuthAppSheet
460 visible={isCreateOrUpdateSheetVisible}
461 appToEdit={appToEdit}
462 onSuccess={(app) => {
463 const isCreating = !appToEdit
464 setShowCreateSheet(false)
465 setSelectedAppToEdit(null)
466 setSelectedApp(undefined)
467 // Only show banner for new apps or regenerated secrets, not for simple edits
468 if (isCreating || app.client_secret) {
469 setNewOAuthApp(app)
470 }
471 }}
472 onCancel={() => {
473 setShowCreateSheet(false)
474 setSelectedAppToEdit(null)
475 setSelectedApp(undefined)
476 }}
477 />
478
479 <DeleteOAuthAppModal
480 visible={!!appToDelete}
481 selectedApp={appToDelete}
482 setVisible={setSelectedAppToDelete}
483 onDelete={(params: Parameters<typeof deleteOAuthApp>[0]) => {
484 deleteOAuthApp(params)
485 }}
486 isLoading={isDeletingApp}
487 />
488
489 <ConfirmationModal
490 variant="warning"
491 visible={showRegenerateDialog}
492 loading={isRegenerating}
493 title="Confirm regenerating client secret"
494 confirmLabel="Confirm"
495 onCancel={() => setShowRegenerateDialog(false)}
496 onConfirm={() => {
497 regenerateSecret({
498 projectRef,
499 clientEndpoint,
500 clientId: selectedApp?.client_id,
501 })
502 setShowRegenerateDialog(false)
503 }}
504 >
505 <p className="text-sm text-foreground-light">
506 Are you sure you wish to regenerate the client secret for "{selectedApp?.client_name}"?
507 You'll need to update it in all applications that use it. This action cannot be undone.
508 </p>
509 </ConfirmationModal>
510 </>
511 )
512}