mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 20:05:06 +03:00
## Problem
On tables with a sticky last column (Database Extensions, OAuth Apps,
Custom Auth Providers), hovering a row lets scrolled cell content show
through the sticky column.
Root cause: row hover uses `bg-surface-200`, which maps to `--muted`
(alpha). That works for normal cells (nothing behind them) but sticky
cells sit above horizontally scrolled content, so the tint is
see-through.
## Solution
In `ShadowScrollArea` when `stickyLastColumn` is set:
- Keep an opaque `bg-surface-100` base on sticky cells
- Composite the muted hover tint via `background-image` so hover matches
`TableRow` without becoming transparent
Also:
- Extensions: use `Table containerProps={{ stickyLastColumn: true }}`
instead of nesting a second `ShadowScrollArea` (same pattern as OAuth /
Custom Auth)
- OAuth Apps and Custom Auth Providers: drop redundant per-cell
`bg-surface-100` / `hover:bg-surface-200` so sticky hover comes only
from `ShadowScrollArea`
| Before | After |
| --- | --- |
| <img width="1042" height="98" alt="27097"
src="https://github.com/user-attachments/assets/204a7dc4-d5ac-40b7-87fb-fdb03982eaad"
/> | <img width="1084" height="101" alt="94456"
src="https://github.com/user-attachments/assets/c6db3607-6bbe-4488-a0a5-81f5a0436d66"
/> |
## Review instructions
1. Open Database Extensions. Narrow the viewport or scroll the table
horizontally so the Enabled column is sticky.
2. Hover a row while Links or description content sits under the Enabled
column. The sticky cell should stay opaque and match the row hover tint
(light and dark).
3. Optionally repeat on Auth → OAuth Apps and Auth → Custom Providers
(actions column).
Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>
553 lines
20 KiB
TypeScript
553 lines
20 KiB
TypeScript
import { useQueryClient } from '@tanstack/react-query'
|
|
import { useParams } from 'common'
|
|
import { Edit, MoreVertical, Plus, Power, PowerOff, Search, Trash, X } from 'lucide-react'
|
|
import { parseAsBoolean, parseAsStringLiteral, useQueryState } from 'nuqs'
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
HoverCard,
|
|
HoverCardContent,
|
|
HoverCardTrigger,
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupInput,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableHeadSort,
|
|
TableRow,
|
|
} from 'ui'
|
|
import { Admonition } from 'ui-patterns/Admonition'
|
|
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
|
|
|
import { CreateOrUpdateCustomProviderSheet } from './CreateOrUpdateCustomProviderSheet'
|
|
import {
|
|
CUSTOM_PROVIDER_ENABLED_OPTIONS,
|
|
CUSTOM_PROVIDER_TYPE_OPTIONS,
|
|
filterCustomProviders,
|
|
getNextPlanForCustomProviders,
|
|
} from './customProviders.utils'
|
|
import { DeleteCustomProviderModal } from './DeleteCustomProviderModal'
|
|
import { DisableCustomProviderModal } from './DisableCustomProviderModal'
|
|
import { AlertError } from '@/components/ui/AlertError'
|
|
import { FilterPopover } from '@/components/ui/FilterPopover'
|
|
import { Shortcut } from '@/components/ui/Shortcut'
|
|
import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton'
|
|
import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
|
|
import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
|
|
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
|
|
import { useOAuthCustomProviderUpdateMutation } from '@/data/oauth-custom-providers/oauth-custom-provider-update-mutation'
|
|
import { useOAuthCustomProvidersQuery } from '@/data/oauth-custom-providers/oauth-custom-providers-query'
|
|
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
|
import { onSearchInputEscape } from '@/lib/keyboard'
|
|
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
|
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
|
|
|
const CUSTOM_PROVIDERS_SORT_VALUES = [
|
|
'name:asc',
|
|
'name:desc',
|
|
'identifier:asc',
|
|
'identifier:desc',
|
|
'provider_type:asc',
|
|
'provider_type:desc',
|
|
'created_at:asc',
|
|
'created_at:desc',
|
|
] as const
|
|
|
|
type CustomProvidersSort = (typeof CUSTOM_PROVIDERS_SORT_VALUES)[number]
|
|
type CustomProvidersSortColumn = CustomProvidersSort extends `${infer Column}:${string}`
|
|
? Column
|
|
: unknown
|
|
type CustomProvidersSortOrder = CustomProvidersSort extends `${string}:${infer Order}`
|
|
? Order
|
|
: unknown
|
|
|
|
const NewProviderButton = ({
|
|
canCreateProvider,
|
|
setShowCreateSheet,
|
|
}: {
|
|
canCreateProvider: boolean
|
|
setShowCreateSheet: (show: boolean) => void
|
|
}) => {
|
|
return (
|
|
<Button
|
|
variant="primary"
|
|
disabled={!canCreateProvider}
|
|
icon={<Plus />}
|
|
onClick={() => setShowCreateSheet(true)}
|
|
className="grow"
|
|
>
|
|
New Provider
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
export const CustomAuthProvidersList = () => {
|
|
const { ref: projectRef } = useParams()
|
|
|
|
const { data: organization } = useSelectedOrganizationQuery()
|
|
const { data: authConfig, isPending: isAuthConfigLoading } = useAuthConfigQuery({ projectRef })
|
|
const { hostEndpoint: clientEndpoint } = useProjectApiUrl({ projectRef })
|
|
const nextPlan = getNextPlanForCustomProviders(organization?.plan?.id)
|
|
const isCustomProvidersEnabled = !!authConfig?.CUSTOM_OAUTH_ENABLED
|
|
const providerLimit = authConfig?.CUSTOM_OAUTH_MAX_PROVIDERS || 0
|
|
|
|
const queryClient = useQueryClient()
|
|
const { mutate: updateAuthConfig, isPending: isEnabling } = useAuthConfigUpdateMutation({
|
|
onSuccess: () => {
|
|
toast.success('Custom providers have been enabled')
|
|
// Invalidate and refetch custom providers query so it retries after enabling
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['projects', projectRef, 'oauth-custom-providers'],
|
|
})
|
|
},
|
|
})
|
|
|
|
const { mutate: updateCustomProvider } = useOAuthCustomProviderUpdateMutation({
|
|
onSuccess: () => {
|
|
toast.success('Custom provider enabled')
|
|
},
|
|
})
|
|
|
|
const handleEnableCustomProviders = () => {
|
|
if (projectRef) {
|
|
updateAuthConfig({ projectRef, config: { CUSTOM_OAUTH_ENABLED: true } })
|
|
}
|
|
}
|
|
|
|
const [selectedProviderToEdit, setSelectedProviderToEdit] = useState<string | null>(null)
|
|
const [selectedProviderToDelete, setSelectedProviderToDelete] = useState<string | null>(null)
|
|
const [selectedProviderToDisable, setSelectedProviderToDisable] = useState<string | null>(null)
|
|
const [filteredProviderTypes, setFilteredProviderTypes] = useState<string[]>([])
|
|
const [filteredEnabledStatuses, setFilteredEnabledStatuses] = useState<string[]>([])
|
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
const {
|
|
data: customProviders,
|
|
isLoading: isPending,
|
|
isError,
|
|
error,
|
|
} = useOAuthCustomProvidersQuery({ projectRef })
|
|
const providerCount = customProviders?.length ?? 0
|
|
const atProviderLimit = providerLimit !== Infinity && providerCount >= providerLimit
|
|
|
|
const [showCreateSheet, setShowCreateSheet] = useQueryState(
|
|
'new',
|
|
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
|
|
)
|
|
|
|
// Prevent opening the create sheet if custom providers are disabled or at plan limit
|
|
useEffect(() => {
|
|
if (showCreateSheet && (!isCustomProvidersEnabled || atProviderLimit)) {
|
|
setShowCreateSheet(false)
|
|
}
|
|
}, [showCreateSheet, atProviderLimit, isCustomProvidersEnabled, setShowCreateSheet])
|
|
|
|
const [filterString, setFilterString] = useState<string>('')
|
|
|
|
const [sort, setSort] = useQueryState(
|
|
'sort',
|
|
parseAsStringLiteral<CustomProvidersSort>(CUSTOM_PROVIDERS_SORT_VALUES).withDefault('name:asc')
|
|
)
|
|
|
|
const providerToEdit = useMemo(
|
|
() => customProviders?.find((p) => p.id === selectedProviderToEdit),
|
|
[customProviders, selectedProviderToEdit]
|
|
)
|
|
|
|
const providerToDelete = useMemo(
|
|
() => customProviders?.find((p) => p.id === selectedProviderToDelete),
|
|
[customProviders, selectedProviderToDelete]
|
|
)
|
|
|
|
const providerToDisable = useMemo(
|
|
() => customProviders?.find((p) => p.id === selectedProviderToDisable),
|
|
[customProviders, selectedProviderToDisable]
|
|
)
|
|
|
|
const filteredAndSortedCustomProviders = useMemo(() => {
|
|
const filtered = filterCustomProviders({
|
|
providers: customProviders ?? [],
|
|
searchString: filterString,
|
|
providerTypes: filteredProviderTypes,
|
|
enabledStatuses: filteredEnabledStatuses,
|
|
})
|
|
|
|
const [sortCol, sortOrder] = sort.split(':') as [
|
|
CustomProvidersSortColumn,
|
|
CustomProvidersSortOrder,
|
|
]
|
|
const orderMultiplier = sortOrder === 'asc' ? 1 : -1
|
|
|
|
return filtered.sort((a, b) => {
|
|
if (sortCol === 'name') {
|
|
return a.name.localeCompare(b.name) * orderMultiplier
|
|
}
|
|
if (sortCol === 'identifier') {
|
|
return a.identifier.localeCompare(b.identifier) * orderMultiplier
|
|
}
|
|
if (sortCol === 'provider_type') {
|
|
return a.provider_type.localeCompare(b.provider_type) * orderMultiplier
|
|
}
|
|
if (sortCol === 'created_at') {
|
|
return (
|
|
(new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) * orderMultiplier
|
|
)
|
|
}
|
|
return 0
|
|
})
|
|
}, [customProviders, filterString, filteredProviderTypes, filteredEnabledStatuses, sort])
|
|
|
|
const hasActiveFilters =
|
|
filterString.length > 0 ||
|
|
filteredProviderTypes.length > 0 ||
|
|
filteredEnabledStatuses.length > 0
|
|
|
|
const handleResetFilters = () => {
|
|
setFilterString('')
|
|
setFilteredProviderTypes([])
|
|
setFilteredEnabledStatuses([])
|
|
}
|
|
|
|
useShortcut(
|
|
SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
|
|
() => {
|
|
searchInputRef.current?.focus()
|
|
searchInputRef.current?.select()
|
|
},
|
|
{ label: 'Search custom providers' }
|
|
)
|
|
|
|
useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, handleResetFilters)
|
|
|
|
const handleSortChange = (column: CustomProvidersSortColumn) => {
|
|
const [currentCol, currentOrder] = sort.split(':') as [
|
|
CustomProvidersSortColumn,
|
|
CustomProvidersSortOrder,
|
|
]
|
|
if (currentCol === column) {
|
|
// Cycle through: asc -> desc -> no sort (default)
|
|
if (currentOrder === 'asc') {
|
|
setSort(`${column}:desc` as CustomProvidersSort)
|
|
} else {
|
|
// Reset to default sort (name:asc)
|
|
setSort('name:asc')
|
|
}
|
|
} else {
|
|
// New column, start with asc
|
|
setSort(`${column}:asc` as CustomProvidersSort)
|
|
}
|
|
}
|
|
|
|
const isCreateOrUpdateSheetVisible =
|
|
isCustomProvidersEnabled && (showCreateSheet || !!providerToEdit)
|
|
const canCreateProvider = isCustomProvidersEnabled && !atProviderLimit
|
|
|
|
if (isAuthConfigLoading || (isCustomProvidersEnabled && isPending)) {
|
|
return <GenericSkeletonLoader />
|
|
}
|
|
|
|
if (!isCustomProvidersEnabled) {
|
|
return (
|
|
<Admonition
|
|
type="default"
|
|
title="Custom providers are not enabled"
|
|
description="Enable custom OAuth/OIDC providers to configure your own identity providers for authentication."
|
|
>
|
|
<Button
|
|
variant="primary"
|
|
loading={isEnabling}
|
|
disabled={isEnabling}
|
|
onClick={handleEnableCustomProviders}
|
|
>
|
|
Enable Custom Providers
|
|
</Button>
|
|
</Admonition>
|
|
)
|
|
}
|
|
|
|
if (isError) {
|
|
if (error?.message?.includes('Custom providers are not enabled')) {
|
|
return (
|
|
<Admonition
|
|
type="default"
|
|
title="Custom providers are not enabled"
|
|
description="Enable custom OAuth/OIDC providers to configure your own identity providers for authentication."
|
|
>
|
|
<Button
|
|
variant="primary"
|
|
loading={isEnabling}
|
|
disabled={isEnabling}
|
|
onClick={handleEnableCustomProviders}
|
|
>
|
|
Enable Custom Providers
|
|
</Button>
|
|
</Admonition>
|
|
)
|
|
}
|
|
return <AlertError error={error} subject="Failed to retrieve Custom Auth Providers" />
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="flex flex-col gap-y-4">
|
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2 flex-wrap">
|
|
<div className="flex flex-col lg:flex-row lg:items-center gap-2">
|
|
<InputGroup className="w-full lg:w-52">
|
|
<InputGroupInput
|
|
ref={searchInputRef}
|
|
size="tiny"
|
|
placeholder="Search custom providers"
|
|
value={filterString}
|
|
onChange={(e) => setFilterString(e.target.value)}
|
|
onKeyDown={onSearchInputEscape(filterString, setFilterString)}
|
|
/>
|
|
<InputGroupAddon>
|
|
<Search />
|
|
</InputGroupAddon>
|
|
</InputGroup>
|
|
<FilterPopover
|
|
name="Provider Type"
|
|
options={CUSTOM_PROVIDER_TYPE_OPTIONS}
|
|
labelKey="name"
|
|
valueKey="value"
|
|
iconKey="icon"
|
|
activeOptions={filteredProviderTypes}
|
|
labelClass="text-xs text-foreground-light"
|
|
maxHeightClass="h-[200px]"
|
|
className="w-52"
|
|
onSaveFilters={setFilteredProviderTypes}
|
|
/>
|
|
<FilterPopover
|
|
name="Status"
|
|
options={CUSTOM_PROVIDER_ENABLED_OPTIONS}
|
|
labelKey="name"
|
|
valueKey="value"
|
|
iconKey="icon"
|
|
activeOptions={filteredEnabledStatuses}
|
|
labelClass="text-xs text-foreground-light"
|
|
maxHeightClass="h-[190px]"
|
|
className="w-52"
|
|
onSaveFilters={setFilteredEnabledStatuses}
|
|
/>
|
|
{hasActiveFilters && (
|
|
<Button size="tiny" className="px-1" icon={<X />} onClick={handleResetFilters} />
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-x-2">
|
|
{!isCustomProvidersEnabled || atProviderLimit ? (
|
|
<HoverCard openDelay={0}>
|
|
<HoverCardTrigger>
|
|
<NewProviderButton
|
|
canCreateProvider={canCreateProvider}
|
|
setShowCreateSheet={setShowCreateSheet}
|
|
/>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent
|
|
side="bottom"
|
|
align="end"
|
|
className="text-xs flex flex-col gap-y-2 bg-alternative items-start"
|
|
>
|
|
{!isCustomProvidersEnabled ? (
|
|
<div className="flex flex-col gap-y-2">
|
|
<p>Custom providers are not enabled for this project.</p>
|
|
<Button
|
|
variant="primary"
|
|
size="tiny"
|
|
loading={isEnabling}
|
|
disabled={isEnabling}
|
|
onClick={handleEnableCustomProviders}
|
|
>
|
|
Enable Custom Providers
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<p>You've reached the limit of {providerLimit} providers for your plan.</p>
|
|
<UpgradePlanButton
|
|
source={`customAuthProviders-${organization?.plan.id}`}
|
|
plan={nextPlan ?? 'Pro'}
|
|
/>
|
|
</>
|
|
)}
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
) : (
|
|
<Shortcut
|
|
id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
|
|
label="Create new provider"
|
|
onTrigger={() => setShowCreateSheet(true)}
|
|
side="bottom"
|
|
>
|
|
<Button
|
|
variant="primary"
|
|
disabled={!canCreateProvider}
|
|
icon={<Plus />}
|
|
onClick={() => setShowCreateSheet(true)}
|
|
className="grow"
|
|
>
|
|
New Provider
|
|
</Button>
|
|
</Shortcut>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="w-full overflow-hidden overflow-x-auto">
|
|
<Card className="@container">
|
|
<Table containerProps={{ stickyLastColumn: true }}>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>
|
|
<TableHeadSort column="name" currentSort={sort} onSortChange={handleSortChange}>
|
|
Name
|
|
</TableHeadSort>
|
|
</TableHead>
|
|
<TableHead>
|
|
<TableHeadSort
|
|
column="identifier"
|
|
currentSort={sort}
|
|
onSortChange={handleSortChange}
|
|
>
|
|
Identifier
|
|
</TableHeadSort>
|
|
</TableHead>
|
|
<TableHead>
|
|
<TableHeadSort
|
|
column="provider_type"
|
|
currentSort={sort}
|
|
onSortChange={handleSortChange}
|
|
>
|
|
Type
|
|
</TableHeadSort>
|
|
</TableHead>
|
|
<TableHead>Enabled</TableHead>
|
|
<TableHead className="w-8 px-0">
|
|
<div className="bg-200! px-4 w-full h-full flex items-center border-l @[944px]:border-l-0" />
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredAndSortedCustomProviders.length === 0 && (
|
|
<TableRow>
|
|
<TableCell colSpan={5}>
|
|
<p className="text-foreground-lighter">No custom providers found</p>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{filteredAndSortedCustomProviders.length > 0 &&
|
|
filteredAndSortedCustomProviders.map((provider) => (
|
|
<TableRow key={provider.id} className="w-full">
|
|
<TableCell className="flex" title={provider.name}>
|
|
<Button
|
|
variant="text"
|
|
className="text-link-table-cell text-sm p-0 hover:bg-transparent title [&>span]:w-full!"
|
|
onClick={() => setSelectedProviderToEdit(provider.id)}
|
|
title={provider.name}
|
|
>
|
|
{provider.name}
|
|
</Button>
|
|
</TableCell>
|
|
<TableCell title={provider.identifier}>
|
|
<Badge className="font-mono">{provider.identifier}</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-xs text-foreground-light max-w-28 uppercase">
|
|
{provider.provider_type}
|
|
</TableCell>
|
|
<TableCell className="text-xs text-foreground-light max-w-28">
|
|
<Badge variant={provider.enabled ? 'success' : 'default'}>
|
|
{provider.enabled ? 'Enabled' : 'Disabled'}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="max-w-20 relative px-6">
|
|
<div className="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center border-l @[944px]:border-l-0">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button className="px-1" icon={<MoreVertical />} />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent side="bottom" align="end" className="w-48">
|
|
<DropdownMenuItem
|
|
className="space-x-2"
|
|
onClick={() => {
|
|
setSelectedProviderToEdit(provider.id)
|
|
}}
|
|
>
|
|
<Edit size={12} />
|
|
<p>Update</p>
|
|
</DropdownMenuItem>
|
|
{provider.enabled ? (
|
|
<DropdownMenuItem
|
|
className="space-x-2"
|
|
onClick={() => setSelectedProviderToDisable(provider.id)}
|
|
>
|
|
<PowerOff size={12} />
|
|
<p>Disable</p>
|
|
</DropdownMenuItem>
|
|
) : (
|
|
<DropdownMenuItem
|
|
className="space-x-2"
|
|
onClick={() =>
|
|
updateCustomProvider({
|
|
identifier: provider.identifier,
|
|
projectRef,
|
|
clientEndpoint,
|
|
enabled: true,
|
|
})
|
|
}
|
|
>
|
|
<Power size={12} />
|
|
<p>Enable</p>
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem
|
|
className="space-x-2"
|
|
onClick={() => setSelectedProviderToDelete(provider.id)}
|
|
>
|
|
<Trash size={12} />
|
|
<p>Delete</p>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
|
|
<CreateOrUpdateCustomProviderSheet
|
|
visible={isCreateOrUpdateSheetVisible}
|
|
providerToEdit={providerToEdit}
|
|
onClose={() => {
|
|
setShowCreateSheet(false)
|
|
setSelectedProviderToEdit(null)
|
|
}}
|
|
/>
|
|
|
|
<DeleteCustomProviderModal
|
|
visible={!!providerToDelete}
|
|
selectedProvider={providerToDelete}
|
|
onClose={() => setSelectedProviderToDelete(null)}
|
|
/>
|
|
|
|
<DisableCustomProviderModal
|
|
visible={!!providerToDisable}
|
|
selectedProvider={providerToDisable}
|
|
onClose={() => setSelectedProviderToDisable(null)}
|
|
/>
|
|
</>
|
|
)
|
|
}
|