ProviderForm.tsx316 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { PermissionAction } from '@supabase/shared-types/out/constants'
3import { useParams } from 'common'
4import { Check } from 'lucide-react'
5import { useTheme } from 'next-themes'
6import { useQueryState } from 'nuqs'
7import { useEffect, useId, useMemo, useState } from 'react'
8import { useForm } from 'react-hook-form'
9import ReactMarkdown from 'react-markdown'
10import { toast } from 'sonner'
11import {
12 Button,
13 Form,
14 Sheet,
15 SheetContent,
16 SheetFooter,
17 SheetHeader,
18 SheetSection,
19 SheetTitle,
20} from 'ui'
21import { Admonition } from 'ui-patterns'
22import { Input } from 'ui-patterns/DataInputs/Input'
23import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
24
25import { NO_REQUIRED_CHARACTERS } from '../Auth.constants'
26import { AuthAlert } from './AuthAlert'
27import type { Provider } from './AuthProvidersForm.types'
28import FormField from './FormField'
29import { Markdown } from '@/components/interfaces/Markdown'
30import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
31import { DocsButton } from '@/components/ui/DocsButton'
32import { ResourceItem } from '@/components/ui/Resource/ResourceItem'
33import type { components } from '@/data/api'
34import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
35import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
36import { useHasEntitlementAccess } from '@/hooks/misc/useCheckEntitlements'
37import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
38import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
39import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
40import { BASE_PATH } from '@/lib/constants'
41
42interface ProviderFormProps {
43 config: components['schemas']['GoTrueConfigResponse']
44 provider: Provider
45 isActive: boolean
46}
47
48const doubleNegativeKeys = ['SMS_AUTOCONFIRM']
49
50export const ProviderForm = ({ config, provider, isActive }: ProviderFormProps) => {
51 const { resolvedTheme } = useTheme()
52 const { ref: projectRef } = useParams()
53 const { data: organization } = useSelectedOrganizationQuery()
54 const [urlProvider, setUrlProvider] = useQueryState('provider', { defaultValue: '' })
55
56 const [open, setOpen] = useState(false)
57 const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation()
58
59 const { data: endpoint } = useProjectApiUrl({ projectRef })
60
61 const { can: canUpdateConfig } = useAsyncCheckPermissions(
62 PermissionAction.UPDATE,
63 'custom_config_gotrue'
64 )
65
66 const shouldDisableField = (field: string): boolean => {
67 const shouldDisableSmsFields =
68 config.HOOK_SEND_SMS_ENABLED &&
69 field.startsWith('SMS_') &&
70 ![
71 'SMS_AUTOCONFIRM',
72 'SMS_OTP_EXP',
73 'SMS_OTP_LENGTH',
74 'SMS_OTP_LENGTH',
75 'SMS_TEMPLATE',
76 'SMS_TEST_OTP',
77 'SMS_TEST_OTP_VALID_UNTIL',
78 ].includes(field)
79 return (
80 ['EXTERNAL_SLACK_CLIENT_ID', 'EXTERNAL_SLACK_SECRET'].includes(field) ||
81 shouldDisableSmsFields
82 )
83 }
84
85 const hasEntitlementAccess = useHasEntitlementAccess()
86
87 const getValuesForProvider = useStaticEffectEvent(
88 (config: components['schemas']['GoTrueConfigResponse']) => {
89 const values: { [x: string]: string | boolean } = {}
90 Object.keys(provider.properties).forEach((key) => {
91 // This ensures the default value is visibly selected
92 if (key === 'PASSWORD_REQUIRED_CHARACTERS' && config.PASSWORD_REQUIRED_CHARACTERS === '') {
93 values[key] = NO_REQUIRED_CHARACTERS
94 return
95 }
96
97 const isDoubleNegative = doubleNegativeKeys.includes(key)
98 if (provider.title === 'SAML 2.0') {
99 const configValue = (config as any)[key]
100 values[key] = configValue || (provider.properties[key].type === 'boolean' ? false : '')
101 } else {
102 if (isDoubleNegative) {
103 values[key] = !(config as any)[key]
104 } else {
105 const configValue = (config as any)[key]
106 values[key] = configValue
107 ? configValue
108 : provider.properties[key].type === 'boolean'
109 ? false
110 : ''
111 }
112 }
113 })
114 return values
115 }
116 )
117
118 const INITIAL_VALUES = useMemo(() => {
119 // This check will always be true but let us avoid adding an eslint disable comment on unused memo dependencies
120 // which could hide real issues in the future.
121 // Adding the provider in the memo dependencies ensures the INITIAL_VALUES is properly applied
122 if (!provider) return
123 return getValuesForProvider(config)
124 }, [config, getValuesForProvider, provider])
125
126 const onSubmit = (values: any) => {
127 const payload = { ...values }
128 Object.keys(values).map((x: string) => {
129 if (doubleNegativeKeys.includes(x)) payload[x] = !values[x]
130 if (payload[x] === '') payload[x] = null
131 })
132
133 // The backend uses empty string to represent no required characters in the password
134 if (payload.PASSWORD_REQUIRED_CHARACTERS === NO_REQUIRED_CHARACTERS) {
135 payload.PASSWORD_REQUIRED_CHARACTERS = ''
136 }
137
138 updateAuthConfig(
139 { projectRef: projectRef!, config: payload },
140 {
141 onSuccess: (newValues) => {
142 setOpen(false)
143 setUrlProvider(null)
144 form.reset(getValuesForProvider(newValues))
145 toast.success('Successfully updated settings')
146 },
147 }
148 )
149 }
150
151 // Handle clicking on a provider in the list
152 const handleProviderClick = () => setUrlProvider(provider.title)
153
154 const handleOpenChange = (isOpen: boolean) => {
155 // Remove provider query param from URL when closed
156 if (!isOpen) setUrlProvider(null)
157 }
158
159 // Open or close the form based on the query parameter
160 useEffect(() => {
161 const isProviderInQuery = urlProvider.toLowerCase() === provider.title.toLowerCase()
162 setOpen(isProviderInQuery)
163 }, [urlProvider, provider.title])
164
165 const form = useForm({
166 defaultValues: INITIAL_VALUES,
167 resolver: zodResolver(provider.validationSchema as any),
168 shouldUnregister: false,
169 })
170
171 useEffect(() => {
172 if (open) {
173 form.reset(INITIAL_VALUES)
174 }
175 }, [open, form, INITIAL_VALUES])
176 const formId = useId()
177
178 return (
179 <>
180 <ResourceItem
181 onClick={handleProviderClick}
182 media={
183 <img
184 src={`${BASE_PATH}/img/icons/${provider.misc.iconKey}${provider.misc.hasLightIcon && !resolvedTheme?.includes('dark') ? '-light' : ''}.svg`}
185 width={18}
186 height={18}
187 alt={`${provider.title} auth icon`}
188 />
189 }
190 meta={
191 isActive ? (
192 <div className="flex items-center gap-1 rounded-full border border-brand-400 bg-brand-200 py-1 px-1 text-xs text-brand">
193 <span className="rounded-full bg-brand p-0.5 text-xs text-brand-200">
194 <Check strokeWidth={2} size={12} />
195 </span>
196 <span className="px-1">Enabled</span>
197 </div>
198 ) : (
199 <div className="rounded-md border border-strong bg-surface-100 py-1 px-3 text-xs text-foreground-lighter">
200 Disabled
201 </div>
202 )
203 }
204 >
205 {provider.title}
206 </ResourceItem>
207
208 <Sheet open={open} onOpenChange={handleOpenChange}>
209 <SheetContent className="flex flex-col gap-0" size="lg">
210 <SheetHeader className="shrink-0 flex items-center gap-4">
211 <img
212 src={`${BASE_PATH}/img/icons/${provider.misc.iconKey}${provider.misc.hasLightIcon && !resolvedTheme?.includes('dark') ? '-light' : ''}.svg`}
213 width={18}
214 height={18}
215 alt={`${provider.title} auth icon`}
216 />
217 <SheetTitle>{provider.title}</SheetTitle>
218 </SheetHeader>
219 <Form {...form}>
220 <form
221 id={formId}
222 name={formId}
223 className="overflow-y-auto grow px-0"
224 onSubmit={form.handleSubmit(onSubmit)}
225 >
226 <AuthAlert
227 title={provider.title}
228 isHookSendSMSEnabled={config.HOOK_SEND_SMS_ENABLED}
229 />
230
231 {Object.keys(provider.properties).map((x: string) => {
232 const { entitlementKey } = provider.properties[x]
233 const hasAccess = entitlementKey == null || hasEntitlementAccess(entitlementKey)
234
235 return (
236 <FormField
237 key={x}
238 projectRef={projectRef}
239 organizationSlug={organization?.slug}
240 name={x}
241 properties={provider.properties[x]}
242 control={form.control}
243 readOnly={shouldDisableField(x) || !canUpdateConfig}
244 hasAccess={hasAccess}
245 />
246 )
247 })}
248
249 {provider?.misc?.alert && (
250 <SheetSection>
251 <Admonition
252 type="warning"
253 title={provider.misc.alert.title}
254 description={<ReactMarkdown>{provider.misc.alert.description}</ReactMarkdown>}
255 />
256 </SheetSection>
257 )}
258
259 {provider.misc.requiresRedirect && (
260 <SheetSection>
261 <FormItemLayout
262 layout="horizontal"
263 label="Callback URL (for OAuth)"
264 description={
265 <Markdown
266 content={provider.misc.helper}
267 className="text-foreground-lighter"
268 />
269 }
270 >
271 <Input copy readOnly value={endpoint ? `${endpoint}/auth/v1/callback` : ''} />
272 </FormItemLayout>
273 </SheetSection>
274 )}
275 </form>
276 </Form>
277 <SheetFooter className="shrink-0">
278 <div className="flex items-center justify-between w-full">
279 <DocsButton href={provider.link} />
280 <div className="flex items-center gap-x-3">
281 <Button
282 type="default"
283 htmlType="reset"
284 onClick={() => {
285 setOpen(false)
286 setUrlProvider(null)
287 form.reset()
288 }}
289 disabled={isUpdatingConfig}
290 >
291 Cancel
292 </Button>
293 <ButtonTooltip
294 form={formId}
295 htmlType="submit"
296 loading={isUpdatingConfig}
297 disabled={isUpdatingConfig || !canUpdateConfig || !form.formState.isDirty}
298 tooltip={{
299 content: {
300 side: 'bottom',
301 text: !canUpdateConfig
302 ? 'You need additional permissions to update provider settings'
303 : undefined,
304 },
305 }}
306 >
307 Save
308 </ButtonTooltip>
309 </div>
310 </div>
311 </SheetFooter>
312 </SheetContent>
313 </Sheet>
314 </>
315 )
316}