mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 19:05:06 +03:00
Follow-up to #48344: collapses the two resolution paths for the Admonition module into one. `src/admonition.tsx` was a back-compat shim re-exporting `src/Admonition/`. Two ways to resolve one module is exactly what produced the macOS self-import bug fixed in #48344, and the local typecheck errors that #48374 worked around. This removes the shim and standardizes on the PascalCase subpath, matching every other export in the package. **Changed:** - Codemodded all 246 `ui-patterns/admonition` imports to `ui-patterns/Admonition` (240 `.tsx`, 5 `.mdx`, 1 `.ts` across studio, docs, www, design-system, and lite-studio) - Pointed the 5 internal `'../admonition'` imports back at the `'../Admonition'` directory **Removed:** - `packages/ui-patterns/src/admonition.tsx`, and its `./admonition` entry in the exports map (regenerated with `pnpm gen:exports`) ## To test - `grep -r "ui-patterns/admonition" --include='*.ts*'` → no hits - `pnpm test:case-hazards` → passes - `pnpm typecheck` → all 15 tasks green - `pnpm --filter studio run lint:ratchet` → passes - `pnpm --filter ui-patterns vitest run src/Admonition` → 11 tests pass <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Standardized Admonition component imports across the application and documentation. * Improved compatibility with case-sensitive environments by using the canonical component path. * Removed the legacy Admonition import entry point. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
559 lines
20 KiB
TypeScript
559 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
|
|
variant="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
|
|
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 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 variant="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)}
|
|
/>
|
|
</>
|
|
)
|
|
}
|