| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316 |
- import { zodResolver } from '@hookform/resolvers/zod'
- import { PermissionAction } from '@supabase/shared-types/out/constants'
- import { useParams } from 'common'
- import { Check } from 'lucide-react'
- import { useTheme } from 'next-themes'
- import { useQueryState } from 'nuqs'
- import { useEffect, useId, useMemo, useState } from 'react'
- import { useForm } from 'react-hook-form'
- import ReactMarkdown from 'react-markdown'
- import { toast } from 'sonner'
- import {
- Button,
- Form,
- Sheet,
- SheetContent,
- SheetFooter,
- SheetHeader,
- SheetSection,
- SheetTitle,
- } from 'ui'
- import { Admonition } from 'ui-patterns'
- import { Input } from 'ui-patterns/DataInputs/Input'
- import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
- import { NO_REQUIRED_CHARACTERS } from '../Auth.constants'
- import { AuthAlert } from './AuthAlert'
- import type { Provider } from './AuthProvidersForm.types'
- import FormField from './FormField'
- import { Markdown } from '@/components/interfaces/Markdown'
- import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
- import { DocsButton } from '@/components/ui/DocsButton'
- import { ResourceItem } from '@/components/ui/Resource/ResourceItem'
- import type { components } from '@/data/api'
- import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
- import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
- import { useHasEntitlementAccess } from '@/hooks/misc/useCheckEntitlements'
- import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
- import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
- import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
- import { BASE_PATH } from '@/lib/constants'
- interface ProviderFormProps {
- config: components['schemas']['GoTrueConfigResponse']
- provider: Provider
- isActive: boolean
- }
- const doubleNegativeKeys = ['SMS_AUTOCONFIRM']
- export const ProviderForm = ({ config, provider, isActive }: ProviderFormProps) => {
- const { resolvedTheme } = useTheme()
- const { ref: projectRef } = useParams()
- const { data: organization } = useSelectedOrganizationQuery()
- const [urlProvider, setUrlProvider] = useQueryState('provider', { defaultValue: '' })
- const [open, setOpen] = useState(false)
- const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation()
- const { data: endpoint } = useProjectApiUrl({ projectRef })
- const { can: canUpdateConfig } = useAsyncCheckPermissions(
- PermissionAction.UPDATE,
- 'custom_config_gotrue'
- )
- const shouldDisableField = (field: string): boolean => {
- const shouldDisableSmsFields =
- config.HOOK_SEND_SMS_ENABLED &&
- field.startsWith('SMS_') &&
- ![
- 'SMS_AUTOCONFIRM',
- 'SMS_OTP_EXP',
- 'SMS_OTP_LENGTH',
- 'SMS_OTP_LENGTH',
- 'SMS_TEMPLATE',
- 'SMS_TEST_OTP',
- 'SMS_TEST_OTP_VALID_UNTIL',
- ].includes(field)
- return (
- ['EXTERNAL_SLACK_CLIENT_ID', 'EXTERNAL_SLACK_SECRET'].includes(field) ||
- shouldDisableSmsFields
- )
- }
- const hasEntitlementAccess = useHasEntitlementAccess()
- const getValuesForProvider = useStaticEffectEvent(
- (config: components['schemas']['GoTrueConfigResponse']) => {
- const values: { [x: string]: string | boolean } = {}
- Object.keys(provider.properties).forEach((key) => {
- // This ensures the default value is visibly selected
- if (key === 'PASSWORD_REQUIRED_CHARACTERS' && config.PASSWORD_REQUIRED_CHARACTERS === '') {
- values[key] = NO_REQUIRED_CHARACTERS
- return
- }
- const isDoubleNegative = doubleNegativeKeys.includes(key)
- if (provider.title === 'SAML 2.0') {
- const configValue = (config as any)[key]
- values[key] = configValue || (provider.properties[key].type === 'boolean' ? false : '')
- } else {
- if (isDoubleNegative) {
- values[key] = !(config as any)[key]
- } else {
- const configValue = (config as any)[key]
- values[key] = configValue
- ? configValue
- : provider.properties[key].type === 'boolean'
- ? false
- : ''
- }
- }
- })
- return values
- }
- )
- const INITIAL_VALUES = useMemo(() => {
- // This check will always be true but let us avoid adding an eslint disable comment on unused memo dependencies
- // which could hide real issues in the future.
- // Adding the provider in the memo dependencies ensures the INITIAL_VALUES is properly applied
- if (!provider) return
- return getValuesForProvider(config)
- }, [config, getValuesForProvider, provider])
- const onSubmit = (values: any) => {
- const payload = { ...values }
- Object.keys(values).map((x: string) => {
- if (doubleNegativeKeys.includes(x)) payload[x] = !values[x]
- if (payload[x] === '') payload[x] = null
- })
- // The backend uses empty string to represent no required characters in the password
- if (payload.PASSWORD_REQUIRED_CHARACTERS === NO_REQUIRED_CHARACTERS) {
- payload.PASSWORD_REQUIRED_CHARACTERS = ''
- }
- updateAuthConfig(
- { projectRef: projectRef!, config: payload },
- {
- onSuccess: (newValues) => {
- setOpen(false)
- setUrlProvider(null)
- form.reset(getValuesForProvider(newValues))
- toast.success('Successfully updated settings')
- },
- }
- )
- }
- // Handle clicking on a provider in the list
- const handleProviderClick = () => setUrlProvider(provider.title)
- const handleOpenChange = (isOpen: boolean) => {
- // Remove provider query param from URL when closed
- if (!isOpen) setUrlProvider(null)
- }
- // Open or close the form based on the query parameter
- useEffect(() => {
- const isProviderInQuery = urlProvider.toLowerCase() === provider.title.toLowerCase()
- setOpen(isProviderInQuery)
- }, [urlProvider, provider.title])
- const form = useForm({
- defaultValues: INITIAL_VALUES,
- resolver: zodResolver(provider.validationSchema as any),
- shouldUnregister: false,
- })
- useEffect(() => {
- if (open) {
- form.reset(INITIAL_VALUES)
- }
- }, [open, form, INITIAL_VALUES])
- const formId = useId()
- return (
- <>
- <ResourceItem
- onClick={handleProviderClick}
- media={
- <img
- src={`${BASE_PATH}/img/icons/${provider.misc.iconKey}${provider.misc.hasLightIcon && !resolvedTheme?.includes('dark') ? '-light' : ''}.svg`}
- width={18}
- height={18}
- alt={`${provider.title} auth icon`}
- />
- }
- meta={
- isActive ? (
- <div className="flex items-center gap-1 rounded-full border border-brand-400 bg-brand-200 py-1 px-1 text-xs text-brand">
- <span className="rounded-full bg-brand p-0.5 text-xs text-brand-200">
- <Check strokeWidth={2} size={12} />
- </span>
- <span className="px-1">Enabled</span>
- </div>
- ) : (
- <div className="rounded-md border border-strong bg-surface-100 py-1 px-3 text-xs text-foreground-lighter">
- Disabled
- </div>
- )
- }
- >
- {provider.title}
- </ResourceItem>
- <Sheet open={open} onOpenChange={handleOpenChange}>
- <SheetContent className="flex flex-col gap-0" size="lg">
- <SheetHeader className="shrink-0 flex items-center gap-4">
- <img
- src={`${BASE_PATH}/img/icons/${provider.misc.iconKey}${provider.misc.hasLightIcon && !resolvedTheme?.includes('dark') ? '-light' : ''}.svg`}
- width={18}
- height={18}
- alt={`${provider.title} auth icon`}
- />
- <SheetTitle>{provider.title}</SheetTitle>
- </SheetHeader>
- <Form {...form}>
- <form
- id={formId}
- name={formId}
- className="overflow-y-auto grow px-0"
- onSubmit={form.handleSubmit(onSubmit)}
- >
- <AuthAlert
- title={provider.title}
- isHookSendSMSEnabled={config.HOOK_SEND_SMS_ENABLED}
- />
- {Object.keys(provider.properties).map((x: string) => {
- const { entitlementKey } = provider.properties[x]
- const hasAccess = entitlementKey == null || hasEntitlementAccess(entitlementKey)
- return (
- <FormField
- key={x}
- projectRef={projectRef}
- organizationSlug={organization?.slug}
- name={x}
- properties={provider.properties[x]}
- control={form.control}
- readOnly={shouldDisableField(x) || !canUpdateConfig}
- hasAccess={hasAccess}
- />
- )
- })}
- {provider?.misc?.alert && (
- <SheetSection>
- <Admonition
- type="warning"
- title={provider.misc.alert.title}
- description={<ReactMarkdown>{provider.misc.alert.description}</ReactMarkdown>}
- />
- </SheetSection>
- )}
- {provider.misc.requiresRedirect && (
- <SheetSection>
- <FormItemLayout
- layout="horizontal"
- label="Callback URL (for OAuth)"
- description={
- <Markdown
- content={provider.misc.helper}
- className="text-foreground-lighter"
- />
- }
- >
- <Input copy readOnly value={endpoint ? `${endpoint}/auth/v1/callback` : ''} />
- </FormItemLayout>
- </SheetSection>
- )}
- </form>
- </Form>
- <SheetFooter className="shrink-0">
- <div className="flex items-center justify-between w-full">
- <DocsButton href={provider.link} />
- <div className="flex items-center gap-x-3">
- <Button
- type="default"
- htmlType="reset"
- onClick={() => {
- setOpen(false)
- setUrlProvider(null)
- form.reset()
- }}
- disabled={isUpdatingConfig}
- >
- Cancel
- </Button>
- <ButtonTooltip
- form={formId}
- htmlType="submit"
- loading={isUpdatingConfig}
- disabled={isUpdatingConfig || !canUpdateConfig || !form.formState.isDirty}
- tooltip={{
- content: {
- side: 'bottom',
- text: !canUpdateConfig
- ? 'You need additional permissions to update provider settings'
- : undefined,
- },
- }}
- >
- Save
- </ButtonTooltip>
- </div>
- </div>
- </SheetFooter>
- </SheetContent>
- </Sheet>
- </>
- )
- }
|