mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 08:56:46 -04:00
fe93df7d6b
## Screenshots ### Auth: Create or edit custom Auth provider See the callback URL input at the bottom. Before: <img width="1179" height="1309" alt="image" src="https://github.com/user-attachments/assets/b15d38fd-6e32-489e-8ef6-bff519d38123" /> After: <img width="1176" height="1318" alt="image" src="https://github.com/user-attachments/assets/dedc72cc-7756-4995-af9a-5f7a4554f76f" /> ### Custom Auth provider list search input Before: <img width="1135" height="236" alt="image" src="https://github.com/user-attachments/assets/ced8538a-91ca-428b-8d90-544962c1eb5b" /> After: <img width="1147" height="227" alt="image" src="https://github.com/user-attachments/assets/695a5c87-f371-4d90-91a8-761266526345" /> ### Auth hooks Before: <img width="1150" height="301" alt="image" src="https://github.com/user-attachments/assets/20341d7b-6a2f-491a-b23f-74d92398192f" /> After: <img width="1143" height="305" alt="image" src="https://github.com/user-attachments/assets/95d73950-eb55-459d-9cb9-3077bcd10985" /> ### OAuth App list search input Before: <img width="1147" height="371" alt="image" src="https://github.com/user-attachments/assets/be935f9d-1b32-4488-bf37-6153f7d39262" /> After: <img width="1146" height="365" alt="image" src="https://github.com/user-attachments/assets/628b77c8-074b-455a-94ea-b8e20b4da2db" /> ### New policy sheet template search input Before: <img width="536" height="268" alt="image" src="https://github.com/user-attachments/assets/d5ea6ee9-02fa-48fc-a727-cb56e5f57f8f" /> After: <img width="534" height="260" alt="image" src="https://github.com/user-attachments/assets/34ac4c40-5613-47f1-b724-0780499afa26" /> ### Storage new policy dialog Before: <img width="1180" height="660" alt="image" src="https://github.com/user-attachments/assets/afb4b1d3-f42b-4379-9197-c47a97340eaa" /> After: <img width="1175" height="646" alt="image" src="https://github.com/user-attachments/assets/427e2f0f-553b-4ea8-a8ae-f1835c1c791b" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Updated search input design across authentication interfaces for improved consistency. * Standardized input control layout in auth configuration forms. * **Bug Fixes** * Corrected webhook configuration field behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
529 lines
19 KiB
TypeScript
529 lines
19 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, 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 { 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'
|
|
|
|
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
|
|
type="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 {
|
|
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([])
|
|
}
|
|
|
|
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
|
|
type="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
|
|
type="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
|
|
size="tiny"
|
|
placeholder="Search custom providers"
|
|
value={filterString}
|
|
onChange={(e) => setFilterString(e.target.value)}
|
|
/>
|
|
<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
|
|
type="default"
|
|
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
|
|
type="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>
|
|
) : (
|
|
<NewProviderButton
|
|
canCreateProvider={canCreateProvider}
|
|
setShowCreateSheet={setShowCreateSheet}
|
|
/>
|
|
)}
|
|
</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
|
|
type="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 bg-surface-100 @[944px]:hover:bg-surface-200 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 type="default" 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)}
|
|
/>
|
|
</>
|
|
)
|
|
}
|