feat: custom oauth providers (#43094)

Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
Co-authored-by: Ali Waseem <waseema393@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-03-16 17:13:08 +01:00
1 parent e217f747bd
commit b69a145403
13 files changed
+1395 -6

No files matched your search

@@ -0,0 +1,540 @@
import { zodResolver } from '@hookform/resolvers/zod'
import type { CustomOAuthProvider } from '@supabase/auth-js'
import { useParams } from 'common'
import { X } from 'lucide-react'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
cn,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input,
Input_Shadcn_,
PrePostTab,
RadioGroupStacked,
RadioGroupStackedItem,
Separator,
Sheet,
SheetClose,
SheetContent,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
Switch,
useWatch_Shadcn_,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import * as z from 'zod'
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { FormSectionLabel } from '@/components/ui/Forms/FormSection'
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
import { useOAuthCustomProviderCreateMutation } from '@/data/oauth-custom-providers/oauth-custom-provider-create-mutation'
import {
useOAuthCustomProviderUpdateMutation,
type OAuthCustomProviderUpdateVariables,
} from '@/data/oauth-custom-providers/oauth-custom-provider-update-mutation'
import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
interface CreateOrUpdateCustomProviderSheetProps {
visible: boolean
providerToEdit?: CustomOAuthProvider
onClose: () => void
}
const SharedFormSchema = z.object({
identifier: z
.string()
.min(1, 'Please provide an identifier')
.regex(
/^[a-zA-Z0-9_-]+$/,
'Identifier can only contain letters, numbers, hyphens, and underscores'
),
name: z
.string()
.min(1, 'Please provide a name for your custom provider')
.max(100, 'Name must be less than 100 characters'),
provider_type: z.enum(['oidc', 'oauth2']).default('oidc'),
client_id: z.string().min(1, 'Please provide a client ID').trim(),
client_secret: z.string().min(1, 'Please provide a client secret').trim(),
email_optional: z.boolean().default(false),
issuer: z.string().url('Please provide a valid URL').trim(),
// comma-separated scopes in the form, will be transformed to array when sending
scopes: z.string().default(''),
})
const OidcSchema = SharedFormSchema.extend({
provider_type: z.literal('oidc'),
discovery_url: z.union([z.string().url('Please provide a valid URL'), z.literal('')]).default(''),
})
const OAuth2Schema = SharedFormSchema.extend({
provider_type: z.literal('oauth2'),
authorization_url: z
.union([z.string().url('Please provide a valid URL'), z.literal('')])
.default(''),
token_url: z.union([z.string().url('Please provide a valid URL'), z.literal('')]).default(''),
userinfo_url: z.union([z.string().url('Please provide a valid URL'), z.literal('')]).default(''),
jwks_uri: z.union([z.string().url('Please provide a valid URL'), z.literal('')]).default(''),
})
const FormSchema = z.discriminatedUnion('provider_type', [OidcSchema, OAuth2Schema])
const FORM_ID = 'create-or-update-custom-provider-form'
const initialValues = {
name: '',
identifier: '',
provider_type: 'oidc' as const,
issuer: '',
authorization_url: '',
token_url: '',
userinfo_url: '',
jwks_uri: '',
discovery_url: '',
scopes: '',
client_id: '',
client_secret: '',
email_optional: false,
}
/** Mock autodiscovery endpoint: simulates success or error (random for demo) */
export const CreateOrUpdateCustomProviderSheet = ({
visible,
providerToEdit,
onClose,
}: CreateOrUpdateCustomProviderSheetProps) => {
const isEditMode = !!providerToEdit
const { ref: projectRef } = useParams()
const { data: endpointData } = useProjectApiUrl({ projectRef })
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: initialValues,
})
useEffect(() => {
if (visible) {
if (providerToEdit) {
if (providerToEdit.provider_type === 'oidc') {
form.reset({
name: providerToEdit.name,
identifier: providerToEdit.identifier.replace('custom:', ''),
provider_type: providerToEdit.provider_type,
client_id: providerToEdit.client_id,
client_secret: 'placeholder',
email_optional: providerToEdit.email_optional,
issuer: providerToEdit.issuer,
discovery_url: providerToEdit.discovery_url,
scopes: (providerToEdit.scopes || []).join(', '),
})
} else {
form.reset({
name: providerToEdit.name,
identifier: providerToEdit.identifier.replace('custom:', ''),
provider_type: providerToEdit.provider_type,
client_id: providerToEdit.client_id,
client_secret: 'placeholder',
email_optional: providerToEdit.email_optional,
issuer: providerToEdit.issuer,
authorization_url: providerToEdit.authorization_url,
token_url: providerToEdit.token_url,
userinfo_url: providerToEdit.userinfo_url,
jwks_uri: providerToEdit.jwks_uri,
scopes: (providerToEdit.scopes || []).join(', '),
})
}
} else {
form.reset(initialValues)
}
}
}, [visible, providerToEdit, form])
const { mutate: createCustomProvider, isPending: isCreating } =
useOAuthCustomProviderCreateMutation({
onSuccess: () => {
toast.success('Custom provider created successfully')
onClose()
},
})
const { mutate: updateCustomProvider, isPending: isUpdating } =
useOAuthCustomProviderUpdateMutation({
onSuccess: () => {
toast.success('Custom provider updated successfully')
onClose()
},
})
const onSubmit = async (values: z.infer<typeof FormSchema>) => {
const identifierValue = (values.identifier || '').replace(/^custom:/i, '').trim()
const identifier = identifierValue ? `custom:${identifierValue}` : ''
let payload: Partial<OAuthCustomProviderUpdateVariables> = {}
if (values.provider_type === 'oidc') {
payload = {
skip_nonce_check: false,
discovery_url:
values.discovery_url ||
`${values.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`,
}
} else {
const issuer = values.issuer
payload = {
authorization_url:
values.authorization_url || `${issuer.replace(/\/$/, '')}/oauth/authorize`,
token_url: values.token_url || `${issuer.replace(/\/$/, '')}/oauth/token`,
userinfo_url: values.userinfo_url || `${issuer.replace(/\/$/, '')}/oauth/userinfo`,
jwks_uri: values.jwks_uri || `${issuer.replace(/\/$/, '')}/.well-known/jwks.json`,
}
}
if (isEditMode) {
// only include the client secret if it was changed, otherwise keep existing secret
if (values.client_secret !== 'placeholder') {
payload.client_secret = values.client_secret
}
updateCustomProvider({
identifier,
projectRef,
clientEndpoint: endpointData,
name: values.name,
client_id: values.client_id,
scopes: values.scopes.split(',').map((s) => s.trim()),
issuer: values.issuer,
pkce_enabled: true,
enabled: true,
email_optional: values.email_optional,
...payload,
})
} else {
createCustomProvider({
identifier,
projectRef,
clientEndpoint: endpointData,
provider_type: values.provider_type,
name: values.name,
client_id: values.client_id,
client_secret: values.client_secret,
scopes: values.scopes.split(',').map((s) => s.trim()),
issuer: values.issuer,
pkce_enabled: true,
enabled: true,
email_optional: values.email_optional,
...payload,
})
}
}
const isManualConfiguration =
useWatch_Shadcn_({ control: form.control, name: 'provider_type' }) === 'oauth2'
const {
confirmOnClose,
handleOpenChange,
modalProps: closeConfirmationModalProps,
} = useConfirmOnClose({
checkIsDirty: () => form.formState.isDirty,
onClose: () => {
form.reset(initialValues)
onClose()
},
})
const issuerUrlValue = useWatch_Shadcn_({ control: form.control, name: 'issuer' })
return (
<Sheet open={visible} onOpenChange={handleOpenChange}>
<SheetContent
size="lg"
showClose={false}
className="flex flex-col gap-0"
tabIndex={undefined}
>
<SheetHeader>
<div className="flex flex-row gap-3 items-center">
<SheetClose
className={cn(
'text-muted hover:text ring-offset-background transition-opacity hover:opacity-100',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'disabled:pointer-events-none data-[state=open]:bg-secondary',
'transition'
)}
>
<X className="h-3 w-3" />
<span className="sr-only">Close</span>
</SheetClose>
<SheetTitle className="truncate">
{isEditMode ? 'Update Custom Auth Provider' : 'Create Custom Auth Provider'}
</SheetTitle>
</div>
</SheetHeader>
<Form_Shadcn_ {...form}>
<form
className="flex-grow overflow-auto"
onSubmit={form.handleSubmit(onSubmit)}
id={FORM_ID}
>
<SheetSection className="flex-grow px-5 space-y-4">
<FormField_Shadcn_
control={form.control}
name="identifier"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Provider Identifier"
description="Lowercase letters, numbers, and hyphens only. Used in SDK: signInWithOAuth({ provider: 'custom:my-company' })"
>
<FormControl_Shadcn_>
<PrePostTab preTab="custom:" className="w-full">
<Input_Shadcn_
{...field}
placeholder="my-company"
disabled={isEditMode}
onChange={(e) => {
const raw = e.target.value
const userValue = raw.replace(/^custom:/i, '').trimStart()
field.onChange(userValue)
}}
/>
</PrePostTab>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout layout="horizontal" label="Display Name">
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="Provider name" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="provider_type"
render={({ field }) => (
<FormItemLayout layout="horizontal" label="Configuration Method">
<RadioGroupStacked value={field.value} onValueChange={field.onChange}>
<RadioGroupStackedItem
className="[&>div]:px-3"
value="oidc"
label="Auto-discovery (Recommended)"
description="Automatically fetch OAuth endpoints"
/>
<RadioGroupStackedItem
className="[&>div]:px-3"
value="oauth2"
label="Manual configuration"
description="Enter endpoints myself"
/>
</RadioGroupStacked>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator />
<SheetSection className="flex-grow px-5 space-y-4">
<FormSectionLabel>OAuth Endpoints</FormSectionLabel>
<FormField_Shadcn_
control={form.control}
name="issuer"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Issuer URL"
description="Base URL of your OAuth provider. Discovery runs when you save."
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="https://auth.company.com" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
{isManualConfiguration ? (
<SheetSection className="flex-grow px-5 pt-0 space-y-4" key="manual-config">
<FormField_Shadcn_
control={form.control}
name="authorization_url"
render={({ field }) => (
<FormItemLayout layout="horizontal" label="Authorization URL">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
placeholder="https://auth.company.com/oauth/authorize"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="token_url"
render={({ field }) => (
<FormItemLayout layout="horizontal" label="Token URL">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
placeholder="https://auth.company.com/oauth/token"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="userinfo_url"
render={({ field }) => (
<FormItemLayout layout="horizontal" label="Userinfo URL">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
placeholder="https://auth.company.com/oauth/userinfo"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="jwks_uri"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="JWKS URI"
description="Required for ID token verification"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
placeholder="https://auth.company.com/.well-known/jwks.json"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
) : (
<SheetSection className="flex-grow px-5 pt-0 space-y-4" key="discovery-config">
<FormField_Shadcn_
control={form.control}
name="discovery_url"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Discovery URL"
description="Leave empty to use standard path: {issuer}/.well-known/openid-configuration. Only needed if your provider uses a non-standard discovery path. Discovery runs when you save."
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
placeholder={
issuerUrlValue
? `${issuerUrlValue}/.well-known/openid-configuration`
: 'https://github.company.com/.well-known/openid-configuration'
}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
)}
<Separator />
<SheetSection className="flex-grow px-5 space-y-4">
<FormField_Shadcn_
control={form.control}
name="client_id"
render={({ field }) => (
<FormItemLayout layout="horizontal" label="Client ID">
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="Client ID" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="client_secret"
render={({ field }) => (
<FormItemLayout layout="horizontal" label="Client Secret">
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} type="password" placeholder="Client secret" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator />
<SheetSection className="flex-grow px-5 space-y-4">
<FormField_Shadcn_
control={form.control}
name="scopes"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Scopes"
description="Comma-separated list. Common: openid, email, profile"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="openid, email, profile" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="email_optional"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Allow users without email"
description="Allows the user to successfully authenticate when the provider does not return an email address."
>
<FormControl_Shadcn_>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator />
<SheetSection className="flex-grow px-5 space-y-4">
<FormItemLayout
layout="horizontal"
label="Callback URL"
description="Configure this in your OAuth provider's settings."
>
<Input
copy
readOnly
disabled
value={`${endpointData}/auth/v1/callback`}
placeholder={`${endpointData}/auth/v1/callback`}
/>
</FormItemLayout>
</SheetSection>
</form>
</Form_Shadcn_>
<SheetFooter>
<Button type="default" onClick={confirmOnClose}>
Cancel
</Button>
<Button htmlType="submit" form={FORM_ID} loading={isCreating || isUpdating}>
{isEditMode ? 'Update provider' : 'Create and enable provider'}
</Button>
</SheetFooter>
</SheetContent>
<DiscardChangesConfirmationDialog {...closeConfirmationModalProps} />
</Sheet>
)
}
@@ -0,0 +1,408 @@
import { useParams } from 'common'
import { Edit, MoreVertical, Plus, Search, Trash, X } from 'lucide-react'
import { parseAsBoolean, parseAsStringLiteral, useQueryState } from 'nuqs'
import { useEffect, useMemo, useState } from 'react'
import {
Badge,
Button,
Card,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
HoverCard,
HoverCardContent,
HoverCardTrigger,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableHeadSort,
TableRow,
} from 'ui'
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 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 { 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="flex-grow"
>
New Provider
</Button>
)
}
export const CustomAuthProvidersList = () => {
const { ref: projectRef } = useParams()
const { data: organization } = useSelectedOrganizationQuery()
const { data: authConfig, isPending: isAuthConfigLoading } = useAuthConfigQuery({ projectRef })
const nextPlan = getNextPlanForCustomProviders(organization?.plan?.id)
const isCustomProvidersEnabled = !!authConfig?.CUSTOM_OAUTH_ENABLED
const providerLimit = authConfig?.CUSTOM_OAUTH_MAX_PROVIDERS || 0
const [selectedProviderToEdit, setSelectedProviderToEdit] = useState<string | null>(null)
const [selectedProviderToDelete, setSelectedProviderToDelete] = 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 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 (isError) {
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">
<Input
placeholder="Search custom providers"
size="tiny"
icon={<Search />}
value={filterString}
className="w-full lg:w-52"
onChange={(e) => setFilterString(e.target.value)}
/>
<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 ? (
'Custom providers are not enabled for this project. Please contact support to enable this feature.'
) : (
<>
<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>
<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)}
/>
</>
)
}
@@ -0,0 +1,68 @@
import type { CustomOAuthProvider } from '@supabase/auth-js'
import { useParams } from 'common'
import { toast } from 'sonner'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
import { useOAuthCustomProviderDeleteMutation } from '@/data/oauth-custom-providers/oauth-custom-provider-delete-mutation'
interface DeleteCustomProviderModalProps {
visible: boolean
selectedProvider?: CustomOAuthProvider
onClose: () => void
}
export const DeleteCustomProviderModal = ({
visible,
selectedProvider,
onClose,
}: DeleteCustomProviderModalProps) => {
const { ref: projectRef } = useParams()
const { hostEndpoint: clientEndpoint } = useProjectApiUrl({ projectRef })
const { mutate, isPending } = useOAuthCustomProviderDeleteMutation({
onSuccess: () => {
toast.success('Custom provider deleted successfully')
onClose()
},
})
const onConfirmDelete = () => {
mutate({
identifier: selectedProvider?.identifier,
projectRef,
clientEndpoint,
})
}
return (
<ConfirmationModal
variant="destructive"
size="medium"
loading={isPending}
visible={visible}
title={
<>
Confirm to delete custom provider{' '}
<code className="text-sm">{selectedProvider?.name}</code>
</>
}
confirmLabel="Confirm delete"
confirmLabelLoading="Deleting..."
onCancel={() => onClose()}
onConfirm={() => onConfirmDelete()}
alert={{
title: 'This action cannot be undone',
description:
'You will need to re-create the custom provider if you want to revert the deletion.',
}}
>
<p className="text-sm">Before deleting this custom provider, consider:</p>
<ul className="space-y-2 mt-2 text-sm text-foreground-light">
<li className="list-disc ml-6">
Any users authenticating with this provider will lose access
</li>
<li className="list-disc ml-6">This provider is no longer in use by any applications</li>
</ul>
</ConfirmationModal>
)
}
@@ -0,0 +1,44 @@
import type { CustomOAuthProvider } from '@supabase/auth-js'
/** Next plan to upgrade to for more custom providers: Free → Pro, Pro → Team */
export function getNextPlanForCustomProviders(planId: string | undefined): 'Pro' | 'Team' | null {
if (planId === 'free') return 'Pro'
if (planId === 'pro') return 'Team'
return null
}
export const CUSTOM_PROVIDER_TYPE_OPTIONS = [
{ name: 'OIDC', value: 'oidc', icon: null },
{ name: 'OAuth2', value: 'oauth2', icon: null },
]
export const CUSTOM_PROVIDER_ENABLED_OPTIONS = [
{ name: 'Enabled', value: 'true', icon: null },
{ name: 'Disabled', value: 'false', icon: null },
]
export function filterCustomProviders({
providers,
searchString,
providerTypes,
enabledStatuses,
}: {
providers: CustomOAuthProvider[]
searchString: string
providerTypes: string[]
enabledStatuses: string[]
}) {
return providers.filter((provider) => {
const matchesSearch =
searchString === '' ||
provider.name.toLowerCase().includes(searchString.toLowerCase()) ||
provider.identifier.toLowerCase().includes(searchString.toLowerCase())
const matchesType = providerTypes.length === 0 || providerTypes.includes(provider.provider_type)
const matchesEnabled =
enabledStatuses.length === 0 || enabledStatuses.includes(provider.enabled ? 'true' : 'false')
return matchesSearch && matchesType && matchesEnabled
})
}
@@ -0,0 +1,32 @@
import { Badge } from 'ui'
import {
PageSection,
PageSectionContent,
PageSectionDescription,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import { CustomAuthProvidersList } from './CustomAuthProvidersList'
export const CustomAuthProviders = () => {
return (
<PageSection id="custom-providers">
<PageSectionMeta>
<PageSectionSummary>
<div className="flex items-center gap-x-2">
<PageSectionTitle>Custom Providers</PageSectionTitle>
<Badge variant="success">New</Badge>
</div>
<PageSectionDescription>
Configure OAuth/OIDC providers for this project using your own issuer or endpoints.
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<CustomAuthProvidersList />
</PageSectionContent>
</PageSection>
)
}
@@ -0,0 +1,5 @@
export const oAuthCustomProvidersKeys = {
// temporaryApiKey has to be added to reset the query when it changes
list: (projectRef: string | undefined, clientEndpoint: string | undefined) =>
['projects', projectRef, 'oauth-custom-providers', clientEndpoint] as const,
}
@@ -0,0 +1,68 @@
import type { CreateCustomProviderParams } from '@supabase/auth-js'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { oAuthCustomProvidersKeys } from './keys'
import { handleError } from '@/data/fetchers'
import { createProjectSupabaseClient } from '@/lib/project-supabase-client'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
export type OAuthCustomProviderCreateVariables = CreateCustomProviderParams & {
projectRef: string | undefined
clientEndpoint: string | undefined
}
export async function createOAuthCustomProvider({
projectRef,
clientEndpoint,
...params
}: OAuthCustomProviderCreateVariables) {
if (!projectRef) throw new Error('Project reference is required')
if (!clientEndpoint) throw new Error('Client endpoint is required')
const supabaseClient = await createProjectSupabaseClient(projectRef, clientEndpoint)
const { data, error } = await supabaseClient.auth.admin.customProviders.createProvider(params)
if (error) handleError(error)
return data!
}
type OAuthCustomProviderCreateData = Awaited<ReturnType<typeof createOAuthCustomProvider>>
export const useOAuthCustomProviderCreateMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<
OAuthCustomProviderCreateData,
ResponseError,
OAuthCustomProviderCreateVariables
>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<
OAuthCustomProviderCreateData,
ResponseError,
OAuthCustomProviderCreateVariables
>({
mutationFn: (vars) => createOAuthCustomProvider(vars),
onSuccess: async (data, variables, context) => {
const { projectRef, clientEndpoint } = variables
await queryClient.invalidateQueries({
queryKey: oAuthCustomProvidersKeys.list(projectRef, clientEndpoint),
})
await onSuccess?.(data, variables, context)
},
onError: async (data, variables, context) => {
if (onError === undefined) {
toast.error(`Failed to create custom OAuth provider: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -0,0 +1,71 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useAuthConfigQuery } from '../auth/auth-config-query'
import { useProjectApiUrl } from '../config/project-endpoint-query'
import { oAuthCustomProvidersKeys } from './keys'
import { handleError } from '@/data/fetchers'
import { createProjectSupabaseClient } from '@/lib/project-supabase-client'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
export type OAuthCustomProviderDeleteVariables = {
identifier: string | undefined
projectRef: string | undefined
clientEndpoint: string | undefined
}
export async function deleteOAuthCustomProvider({
projectRef,
clientEndpoint,
identifier,
}: OAuthCustomProviderDeleteVariables) {
if (!projectRef) throw new Error('Project reference is required')
if (!clientEndpoint) throw new Error('Client endpoint is required')
if (!identifier) throw new Error('Provider identifier is required')
const supabaseClient = await createProjectSupabaseClient(projectRef, clientEndpoint)
const { error } = await supabaseClient.auth.admin.customProviders.deleteProvider(identifier)
if (error) handleError(error)
return null
}
type OAuthCustomProviderDeleteData = Awaited<ReturnType<typeof deleteOAuthCustomProvider>>
export const useOAuthCustomProviderDeleteMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<
OAuthCustomProviderDeleteData,
ResponseError,
OAuthCustomProviderDeleteVariables
>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<
OAuthCustomProviderDeleteData,
ResponseError,
OAuthCustomProviderDeleteVariables
>({
mutationFn: (vars) => deleteOAuthCustomProvider(vars),
onSuccess: async (data, variables, context) => {
const { projectRef, clientEndpoint } = variables
await queryClient.invalidateQueries({
queryKey: oAuthCustomProvidersKeys.list(projectRef, clientEndpoint),
})
await onSuccess?.(data, variables, context)
},
onError: async (data, variables, context) => {
if (onError === undefined) {
toast.error(`Failed to delete custom OAuth provider: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -0,0 +1,74 @@
import type { UpdateCustomProviderParams } from '@supabase/auth-js'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { oAuthCustomProvidersKeys } from './keys'
import { handleError } from '@/data/fetchers'
import { createProjectSupabaseClient } from '@/lib/project-supabase-client'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
export type OAuthCustomProviderUpdateVariables = UpdateCustomProviderParams & {
identifier: string | undefined
projectRef: string | undefined
clientEndpoint: string | undefined
}
export async function updateOAuthCustomProvider({
identifier,
projectRef,
clientEndpoint,
...params
}: OAuthCustomProviderUpdateVariables) {
if (!projectRef) throw new Error('Project reference is required')
if (!clientEndpoint) throw new Error('Client endpoint is required')
if (!identifier) throw new Error('Provider identifier is required')
const supabaseClient = await createProjectSupabaseClient(projectRef, clientEndpoint)
const { data, error } = await supabaseClient.auth.admin.customProviders.updateProvider(
identifier,
params
)
if (error) handleError(error)
return data!
}
type OAuthCustomProviderUpdateData = Awaited<ReturnType<typeof updateOAuthCustomProvider>>
export const useOAuthCustomProviderUpdateMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<
OAuthCustomProviderUpdateData,
ResponseError,
OAuthCustomProviderUpdateVariables
>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<
OAuthCustomProviderUpdateData,
ResponseError,
OAuthCustomProviderUpdateVariables
>({
mutationFn: (vars) => updateOAuthCustomProvider(vars),
onSuccess: async (data, variables, context) => {
const { projectRef, clientEndpoint } = variables
await queryClient.invalidateQueries({
queryKey: oAuthCustomProvidersKeys.list(projectRef, clientEndpoint),
})
await onSuccess?.(data, variables, context)
},
onError: async (data, variables, context) => {
if (onError === undefined) {
toast.error(`Failed to update custom OAuth provider: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -0,0 +1,68 @@
import { AuthError, type CustomOAuthProvider } from '@supabase/auth-js'
import { useQuery } from '@tanstack/react-query'
import { useAuthConfigQuery } from '../auth/auth-config-query'
import { oAuthCustomProvidersKeys } from './keys'
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
import { handleError } from '@/data/fetchers'
import { createProjectSupabaseClient } from '@/lib/project-supabase-client'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
export type OAuthCustomProvidersVariables = {
projectRef: string | undefined
page?: number
}
export async function getOAuthCustomProviders({
projectRef,
clientEndpoint,
page = 1,
}: OAuthCustomProvidersVariables & { clientEndpoint: string | undefined }) {
if (!projectRef) throw new Error('Project reference is required')
if (!clientEndpoint) throw new Error('Client endpoint is required')
const supabaseClient = await createProjectSupabaseClient(projectRef, clientEndpoint)
const { data, error } = await supabaseClient.auth.admin.customProviders.listProviders()
if (error) {
let newError = error
console.log(newError)
if (
newError.message.startsWith(
'AuthUnknownError: JSON.parse: unexpected non-whitespace character'
)
) {
newError = new AuthError('Custom providers are not enabled for this project')
}
handleError(newError)
}
return data.providers as CustomOAuthProvider[]
}
export type OAuthCustomProvidersData = Awaited<ReturnType<typeof getOAuthCustomProviders>>
export type OAuthCustomProvidersError = ResponseError
export const useOAuthCustomProvidersQuery = <TData = OAuthCustomProvidersData>(
{ projectRef }: OAuthCustomProvidersVariables,
{
enabled = true,
...options
}: UseCustomQueryOptions<OAuthCustomProvidersData, OAuthCustomProvidersError, TData> = {}
) => {
const { data: clientEndpoint } = useProjectApiUrl({ projectRef })
const { data: authConfig, isSuccess: isSuccessConfig } = useAuthConfigQuery({ projectRef })
const isOAuthCustomProvidersEnabled = !!authConfig?.CUSTOM_OAUTH_ENABLED
return useQuery<OAuthCustomProvidersData, OAuthCustomProvidersError, TData>({
queryKey: oAuthCustomProvidersKeys.list(projectRef, clientEndpoint),
queryFn: () => getOAuthCustomProviders({ projectRef, clientEndpoint }),
enabled:
enabled &&
typeof projectRef !== 'undefined' &&
!!clientEndpoint &&
isSuccessConfig &&
isOAuthCustomProvidersEnabled,
...options,
})
}
@@ -1,18 +1,24 @@
import { AuthProvidersForm } from 'components/interfaces/Auth/AuthProvidersForm'
import { BasicAuthSettingsForm } from 'components/interfaces/Auth/BasicAuthSettingsForm'
import { AuthProvidersLayout } from 'components/layouts/AuthLayout/AuthProvidersLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import type { NextPageWithLayout } from 'types'
import { useFlag } from 'common'
import { PageContainer } from 'ui-patterns/PageContainer'
import { AuthProvidersForm } from '@/components/interfaces/Auth/AuthProvidersForm'
import { BasicAuthSettingsForm } from '@/components/interfaces/Auth/BasicAuthSettingsForm'
import { CustomAuthProviders } from '@/components/interfaces/Auth/CustomAuthProviders'
import { AuthProvidersLayout } from '@/components/layouts/AuthLayout/AuthProvidersLayout'
import DefaultLayout from '@/components/layouts/DefaultLayout'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import type { NextPageWithLayout } from '@/types'
const ProvidersPage: NextPageWithLayout = () => {
const showProviders = useIsFeatureEnabled('authentication:show_providers')
const showCustomProviders = useIsFeatureEnabled('authentication:show_custom_providers')
const isOauthProvidersEnabled = useFlag('CustomOauthProviders')
return (
<PageContainer size="default">
<BasicAuthSettingsForm />
{showProviders && <AuthProvidersForm />}
{showCustomProviders && isOauthProvidersEnabled && <CustomAuthProviders />}
</PageContainer>
)
}
@@ -20,6 +20,7 @@
"authentication:show_manual_linking": true,
"authentication:show_provider_filter": true,
"authentication:show_providers": true,
"authentication:show_custom_providers": true,
"authentication:show_send_invitation": true,
"authentication:show_sort_by_email": true,
"authentication:show_sort_by_phone": true,
@@ -61,6 +61,10 @@
"type": "boolean",
"description": "Show the providers section in the authentication Sign In / Providers page"
},
"authentication:show_custom_providers": {
"type": "boolean",
"description": "Show the custom oAuth providers section in the authentication Sign In / Providers page"
},
"authentication:show_manual_linking": {
"type": "boolean",
"description": "Show the manual linking toggle in the authentication Sign In / Providers page under User Signups"