import type { OAuthClient } from '@supabase/supabase-js' import { useParams } from 'common' import { Edit, MoreVertical, Plus, RotateCw, Search, Trash, X } from 'lucide-react' import Link from 'next/link' import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs' import { useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { Button, Card, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, InputGroup, InputGroupAddon, InputGroupInput, Table, TableBody, TableCell, TableHead, TableHeader, TableHeadSort, TableRow, } from 'ui' import { Admonition } from 'ui-patterns/admonition' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { CreateOrUpdateOAuthAppSheet } from './CreateOrUpdateOAuthAppSheet' import { DeleteOAuthAppModal } from './DeleteOAuthAppModal' import { NewOAuthAppBanner } from './NewOAuthAppBanner' import { filterOAuthApps, OAUTH_APP_CLIENT_TYPE_OPTIONS, OAUTH_APP_REGISTRATION_TYPE_OPTIONS, } from './oauthApps.utils' import AlertError from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { FilterPopover } from '@/components/ui/FilterPopover' import { Shortcut } from '@/components/ui/Shortcut' import { useAuthConfigQuery } from '@/data/auth/auth-config-query' import { useProjectApiUrl } from '@/data/config/project-endpoint-query' import { useOAuthServerAppDeleteMutation } from '@/data/oauth-server-apps/oauth-server-app-delete-mutation' import { useOAuthServerAppRegenerateSecretMutation } from '@/data/oauth-server-apps/oauth-server-app-regenerate-secret-mutation' import { useOAuthServerAppsQuery } from '@/data/oauth-server-apps/oauth-server-apps-query' import { onSearchInputEscape } from '@/lib/keyboard' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' const OAUTH_APPS_SORT_VALUES = [ 'name:asc', 'name:desc', 'client_type:asc', 'client_type:desc', 'registration_type:asc', 'registration_type:desc', 'created_at:asc', 'created_at:desc', ] as const type OAuthAppsSort = (typeof OAUTH_APPS_SORT_VALUES)[number] type OAuthAppsSortColumn = OAuthAppsSort extends `${infer Column}:${string}` ? Column : unknown type OAuthAppsSortOrder = OAuthAppsSort extends `${string}:${infer Order}` ? Order : unknown export const OAuthAppsList = () => { const { ref: projectRef } = useParams() const { data: authConfig, isPending: isAuthConfigLoading, isSuccess: isSuccessAuthConfig, } = useAuthConfigQuery({ projectRef }) const isOAuthServerEnabled = !!authConfig?.OAUTH_SERVER_ENABLED const [newOAuthApp, setNewOAuthApp] = useState(undefined) const [showRegenerateDialog, setShowRegenerateDialog] = useState(false) const [selectedApp, setSelectedApp] = useState() const [filteredRegistrationTypes, setFilteredRegistrationTypes] = useState([]) const [filteredClientTypes, setFilteredClientTypes] = useState([]) const [filterString, setFilterString] = useState('') const searchInputRef = useRef(null) const { hostEndpoint: clientEndpoint } = useProjectApiUrl({ projectRef }) const { data, error, isPending: isLoading, isSuccess, isError, } = useOAuthServerAppsQuery({ projectRef }) const oAuthApps = useMemo(() => data?.clients || [], [data]) const { mutateAsync: regenerateSecret, isPending: isRegenerating } = useOAuthServerAppRegenerateSecretMutation({ onSuccess: (data) => { if (data) setNewOAuthApp(data) }, }) const [sort, setSort] = useQueryState( 'sort', parseAsStringLiteral(OAUTH_APPS_SORT_VALUES).withDefault('name:asc') ) const [showCreateSheet, setShowCreateSheet] = useQueryState( 'new', parseAsBoolean.withDefault(false) ) const [selectedAppToEdit, setSelectedAppToEdit] = useQueryState('edit', parseAsString) const appToEdit = oAuthApps?.find((app) => app.client_id === selectedAppToEdit) const [selectedAppToDelete, setSelectedAppToDelete] = useQueryState('delete', parseAsString) const appToDelete = oAuthApps?.find((app) => app.client_id === selectedAppToDelete) const { mutate: deleteOAuthApp, isPending: isDeletingApp, isSuccess: isSuccessDelete, } = useOAuthServerAppDeleteMutation({ onSuccess: () => { toast.success(`Successfully deleted OAuth app`) setSelectedAppToDelete(null) }, }) const filteredAndSortedOAuthApps = useMemo(() => { const filtered = filterOAuthApps({ apps: oAuthApps, searchString: filterString, registrationTypes: filteredRegistrationTypes, clientTypes: filteredClientTypes, }) const [sortCol, sortOrder] = sort.split(':') as [OAuthAppsSortColumn, OAuthAppsSortOrder] const orderMultiplier = sortOrder === 'asc' ? 1 : -1 return filtered.sort((a, b) => { if (sortCol === 'name') { return (a.client_name || '').localeCompare(b.client_name || '') * orderMultiplier } if (sortCol === 'client_type') { return a.client_type.localeCompare(b.client_type) * orderMultiplier } if (sortCol === 'registration_type') { return a.registration_type.localeCompare(b.registration_type) * orderMultiplier } if (sortCol === 'created_at') { return ( (new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) * orderMultiplier ) } return 0 }) }, [oAuthApps, filterString, filteredRegistrationTypes, filteredClientTypes, sort]) const hasActiveFilters = filterString.length > 0 || filteredRegistrationTypes.length > 0 || filteredClientTypes.length > 0 const handleResetFilters = () => { setFilterString('') setFilteredRegistrationTypes([]) setFilteredClientTypes([]) } useShortcut( SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH, () => { searchInputRef.current?.focus() searchInputRef.current?.select() }, { label: 'Search OAuth apps' } ) useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, handleResetFilters) const handleSortChange = (column: OAuthAppsSortColumn) => { const [currentCol, currentOrder] = sort.split(':') as [OAuthAppsSortColumn, OAuthAppsSortOrder] if (currentCol === column) { // Cycle through: asc -> desc -> no sort (default) if (currentOrder === 'asc') { setSort(`${column}:desc` as OAuthAppsSort) } else { // Reset to default sort (name:asc) setSort('name:asc') } } else { // New column, start with asc setSort(`${column}:asc` as OAuthAppsSort) } } const isCreateMode = showCreateSheet && isOAuthServerEnabled const isEditMode = !!appToEdit const isCreateOrUpdateSheetVisible = isCreateMode || isEditMode // Prevent opening the create sheet if OAuth Server is disabled useEffect(() => { if (isSuccessAuthConfig && !isOAuthServerEnabled && showCreateSheet) { setShowCreateSheet(false) } }, [isSuccessAuthConfig, isOAuthServerEnabled, showCreateSheet, setShowCreateSheet]) useEffect(() => { if (isSuccess && !!selectedAppToEdit && !appToEdit) { toast('App not found') setSelectedAppToEdit(null) } }, [appToEdit, isSuccess, selectedAppToEdit, setSelectedAppToEdit]) useEffect(() => { if (isSuccess && !!selectedAppToDelete && !appToDelete && !isSuccessDelete) { toast('App not found') setSelectedAppToDelete(null) } }, [appToDelete, isSuccess, isSuccessDelete, selectedAppToDelete, setSelectedAppToDelete]) if (isAuthConfigLoading || (isOAuthServerEnabled && isLoading)) { return } if (isError) { return } return ( <>
{newOAuthApp?.client_secret && ( setNewOAuthApp(undefined)} /> )} {!isOAuthServerEnabled && ( OAuth Server Settings } /> )}
setFilterString(e.target.value)} onKeyDown={onSearchInputEscape(filterString, setFilterString)} /> {hasActiveFilters && (
{isOAuthServerEnabled ? ( setShowCreateSheet(true)} side="bottom" > ) : ( } onClick={() => setShowCreateSheet(true)} className="grow" tooltip={{ content: { side: 'bottom', text: 'OAuth server must be enabled in settings', }, }} > New OAuth App )}
Name Client ID Client Type Registration Type Created
{filteredAndSortedOAuthApps.length === 0 && (

No OAuth apps found

)} {filteredAndSortedOAuthApps.length > 0 && filteredAndSortedOAuthApps.map((app) => ( {app.client_id} {app.client_type} {app.registration_type}
))}
{ const isCreating = !appToEdit setShowCreateSheet(false) setSelectedAppToEdit(null) setSelectedApp(undefined) // Only show banner for new apps or regenerated secrets, not for simple edits if (isCreating || app.client_secret) { setNewOAuthApp(app) } }} onCancel={() => { setShowCreateSheet(false) setSelectedAppToEdit(null) setSelectedApp(undefined) }} /> [0]) => { deleteOAuthApp(params) }} isLoading={isDeletingApp} /> setShowRegenerateDialog(false)} onConfirm={() => { regenerateSecret({ projectRef, clientEndpoint, clientId: selectedApp?.client_id, }) setShowRegenerateDialog(false) }} >

Are you sure you wish to regenerate the client secret for "{selectedApp?.client_name}"? You'll need to update it in all applications that use it. This action cannot be undone.

) }