mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
13 files changed
+1395
-6
No files matched your search
+540
@@ -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>
|
||||
)
|
||||
}
|
||||
+408
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+68
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user