Files
Saxon FletcherandDanny White 692b14d47b fix(ui): opaque sticky table column hover (#47668)
## 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>
2026-10-09 17:12:47 +11:00

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)}
/>
</>
)
}