| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558 |
- 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
- 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 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
- 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
- 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
- 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>
- ) : (
- <Shortcut
- id={SHORTCUT_IDS.LIST_PAGE_NEW_ITEM}
- label="Create new provider"
- onTrigger={() => setShowCreateSheet(true)}
- side="bottom"
- >
- <Button
- type="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
- 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)}
- />
- </>
- )
- }
|