OAuthAppsList.tsx512 lines · main
| 1 | import type { OAuthClient } from '@supabase/supabase-js' |
| 2 | import { useParams } from 'common' |
| 3 | import { Edit, MoreVertical, Plus, RotateCw, Search, Trash, X } from 'lucide-react' |
| 4 | import Link from 'next/link' |
| 5 | import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs' |
| 6 | import { useEffect, useMemo, useRef, useState } from 'react' |
| 7 | import { toast } from 'sonner' |
| 8 | import { |
| 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' |
| 27 | import { Admonition } from 'ui-patterns/admonition' |
| 28 | import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' |
| 29 | import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' |
| 30 | import { TimestampInfo } from 'ui-patterns/TimestampInfo' |
| 31 | |
| 32 | import { CreateOrUpdateOAuthAppSheet } from './CreateOrUpdateOAuthAppSheet' |
| 33 | import { DeleteOAuthAppModal } from './DeleteOAuthAppModal' |
| 34 | import { NewOAuthAppBanner } from './NewOAuthAppBanner' |
| 35 | import { |
| 36 | filterOAuthApps, |
| 37 | OAUTH_APP_CLIENT_TYPE_OPTIONS, |
| 38 | OAUTH_APP_REGISTRATION_TYPE_OPTIONS, |
| 39 | } from './oauthApps.utils' |
| 40 | import AlertError from '@/components/ui/AlertError' |
| 41 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 42 | import { FilterPopover } from '@/components/ui/FilterPopover' |
| 43 | import { Shortcut } from '@/components/ui/Shortcut' |
| 44 | import { useAuthConfigQuery } from '@/data/auth/auth-config-query' |
| 45 | import { useProjectApiUrl } from '@/data/config/project-endpoint-query' |
| 46 | import { useOAuthServerAppDeleteMutation } from '@/data/oauth-server-apps/oauth-server-app-delete-mutation' |
| 47 | import { useOAuthServerAppRegenerateSecretMutation } from '@/data/oauth-server-apps/oauth-server-app-regenerate-secret-mutation' |
| 48 | import { useOAuthServerAppsQuery } from '@/data/oauth-server-apps/oauth-server-apps-query' |
| 49 | import { onSearchInputEscape } from '@/lib/keyboard' |
| 50 | import { SHORTCUT_IDS } from '@/state/shortcuts/registry' |
| 51 | import { useShortcut } from '@/state/shortcuts/useShortcut' |
| 52 | |
| 53 | const 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 | |
| 64 | type OAuthAppsSort = (typeof OAUTH_APPS_SORT_VALUES)[number] |
| 65 | type OAuthAppsSortColumn = OAuthAppsSort extends `${infer Column}:${string}` ? Column : unknown |
| 66 | type OAuthAppsSortOrder = OAuthAppsSort extends `${string}:${infer Order}` ? Order : unknown |
| 67 | |
| 68 | export 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 | } |