MfaAuthSettingsForm.tsx683 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { PermissionAction } from '@supabase/shared-types/out/constants'
3import { useParams } from 'common'
4import { useEffect, useState } from 'react'
5import { SubmitHandler, useForm } from 'react-hook-form'
6import { toast } from 'sonner'
7import {
8 Alert,
9 AlertTitle,
10 Button,
11 Card,
12 CardContent,
13 CardFooter,
14 Form,
15 FormControl,
16 FormField,
17 FormInputGroupInput,
18 Input,
19 InputGroup,
20 InputGroupAddon,
21 InputGroupText,
22 Select,
23 SelectContent,
24 SelectItem,
25 SelectTrigger,
26 SelectValue,
27 Switch,
28 WarningIcon,
29} from 'ui'
30import { GenericSkeletonLoader } from 'ui-patterns'
31import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
32import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
33import {
34 PageSection,
35 PageSectionContent,
36 PageSectionMeta,
37 PageSectionSummary,
38 PageSectionTitle,
39} from 'ui-patterns/PageSection'
40import * as z from 'zod'
41
42import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer'
43import AlertError from '@/components/ui/AlertError'
44import NoPermission from '@/components/ui/NoPermission'
45import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
46import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
47import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
48import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
49import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
50import { IS_PLATFORM } from '@/lib/constants'
51
52function determineMFAStatus(verifyEnabled: boolean, enrollEnabled: boolean) {
53 return verifyEnabled ? (enrollEnabled ? 'Enabled' : 'Verify Enabled') : 'Disabled'
54}
55
56const MFAFactorSelectionOptions = [
57 {
58 label: 'Enabled',
59 value: 'Enabled',
60 },
61 {
62 label: 'Verify Enabled',
63 value: 'Verify Enabled',
64 },
65 {
66 label: 'Disabled',
67 value: 'Disabled',
68 },
69]
70
71const MfaStatusToState = (status: (typeof MFAFactorSelectionOptions)[number]['value']) => {
72 return status === 'Enabled'
73 ? { verifyEnabled: true, enrollEnabled: true }
74 : status === 'Verify Enabled'
75 ? { verifyEnabled: true, enrollEnabled: false }
76 : { verifyEnabled: false, enrollEnabled: false }
77}
78
79const totpSchema = z.object({
80 MFA_TOTP: z.string().min(1, 'Required'),
81 MFA_MAX_ENROLLED_FACTORS: z.preprocess(
82 (val) => (val === '' || val == null ? undefined : val),
83 z.coerce
84 .number({ required_error: 'Required', invalid_type_error: 'Required' })
85 .min(0, 'Must be a value 0 or larger')
86 .max(30, 'Must be a value no greater than 30')
87 ),
88})
89
90type TotpFormValues = z.infer<typeof totpSchema>
91
92const phoneSchema = z.object({
93 MFA_PHONE: z.string().min(1, 'Required'),
94 MFA_PHONE_OTP_LENGTH: z.preprocess(
95 (val) => (val === '' || val == null ? undefined : val),
96 z.coerce
97 .number({ required_error: 'Required', invalid_type_error: 'Required' })
98 .min(6, 'Must be a value 6 or larger')
99 .max(30, 'must be a value no greater than 30')
100 ),
101 MFA_PHONE_TEMPLATE: z.string().min(1, 'Required'),
102})
103
104type PhoneFormValues = z.infer<typeof phoneSchema>
105
106const securitySchema = z.object({
107 MFA_ALLOW_LOW_AAL: z.boolean({ required_error: 'Required' }),
108})
109type SecurityFormValues = z.infer<typeof securitySchema>
110
111export const MfaAuthSettingsForm = () => {
112 const { ref: projectRef } = useParams()
113 const {
114 data: authConfig,
115 error: authConfigError,
116 isError,
117 isPending: isLoading,
118 } = useAuthConfigQuery({ projectRef })
119 const { mutate: updateAuthConfig } = useAuthConfigUpdateMutation()
120
121 // Separate loading states for each form
122 const [isUpdatingTotpForm, setIsUpdatingTotpForm] = useState(false)
123 const [isUpdatingPhoneForm, setIsUpdatingPhoneForm] = useState(false)
124 const [isUpdatingSecurityForm, setIsUpdatingSecurityForm] = useState(false)
125
126 const [isConfirmationModalVisible, setIsConfirmationModalVisible] = useState(false)
127
128 const { can: canReadConfig } = useAsyncCheckPermissions(
129 PermissionAction.READ,
130 'custom_config_gotrue'
131 )
132 const { can: canUpdateConfig } = useAsyncCheckPermissions(
133 PermissionAction.UPDATE,
134 'custom_config_gotrue'
135 )
136
137 const { hasAccess: hasAccessToMFAEntitlement, isLoading: isLoadingEntitlement } =
138 useCheckEntitlements('auth.mfa_phone')
139 const hasAccessToMFA = !IS_PLATFORM || hasAccessToMFAEntitlement
140 const promptProPlanUpgrade = IS_PLATFORM && !hasAccessToMFAEntitlement
141
142 const {
143 hasAccess: hasAccessToEnhanceSecurityEntitlement,
144 isLoading: isLoadingEntitlementEnhanceSecurity,
145 } = useCheckEntitlements('auth.mfa_enhanced_security')
146 const hasAccessToEnhanceSecurity = !IS_PLATFORM || hasAccessToEnhanceSecurityEntitlement
147 const promptEnhancedSecurityUpgrade = IS_PLATFORM && !hasAccessToEnhanceSecurityEntitlement
148
149 // For now, we support Twilio and Vonage. Twilio Verify is not supported and the remaining providers are community maintained.
150 const sendSMSHookIsEnabled =
151 authConfig?.HOOK_SEND_SMS_URI !== null && authConfig?.HOOK_SEND_SMS_ENABLED === true
152 const hasValidMFAPhoneProvider = authConfig?.EXTERNAL_PHONE_ENABLED === true
153 const hasValidMFAProvider = hasValidMFAPhoneProvider || sendSMSHookIsEnabled
154
155 const totpForm = useForm<TotpFormValues>({
156 resolver: zodResolver(totpSchema as any),
157 defaultValues: {
158 MFA_TOTP: 'Enabled',
159 MFA_MAX_ENROLLED_FACTORS: 10,
160 },
161 })
162 const { reset: resetTotpForm } = totpForm
163
164 const phoneForm = useForm<PhoneFormValues>({
165 resolver: zodResolver(phoneSchema as any),
166 defaultValues: {
167 MFA_PHONE: 'Disabled',
168 MFA_PHONE_OTP_LENGTH: 6,
169 MFA_PHONE_TEMPLATE: 'Your code is {{ .Code }}',
170 },
171 })
172 const { reset: resetPhoneForm } = phoneForm
173
174 const securityForm = useForm<SecurityFormValues>({
175 resolver: zodResolver(securitySchema as any),
176 defaultValues: {
177 MFA_ALLOW_LOW_AAL: false,
178 },
179 })
180 const { reset: resetSecurityForm } = securityForm
181
182 useEffect(() => {
183 if (authConfig) {
184 if (!isUpdatingTotpForm) {
185 resetTotpForm({
186 MFA_TOTP:
187 determineMFAStatus(
188 authConfig?.MFA_TOTP_VERIFY_ENABLED ?? true,
189 authConfig?.MFA_TOTP_ENROLL_ENABLED ?? true
190 ) || 'Enabled',
191 MFA_MAX_ENROLLED_FACTORS: authConfig?.MFA_MAX_ENROLLED_FACTORS ?? 10,
192 })
193 }
194
195 if (!isUpdatingPhoneForm) {
196 resetPhoneForm({
197 MFA_PHONE:
198 determineMFAStatus(
199 authConfig?.MFA_PHONE_VERIFY_ENABLED || false,
200 authConfig?.MFA_PHONE_ENROLL_ENABLED || false
201 ) || 'Disabled',
202 MFA_PHONE_OTP_LENGTH: authConfig?.MFA_PHONE_OTP_LENGTH || 6,
203 MFA_PHONE_TEMPLATE: authConfig?.MFA_PHONE_TEMPLATE || 'Your code is {{ .Code }}',
204 })
205 }
206
207 if (!isUpdatingSecurityForm) {
208 resetSecurityForm({
209 MFA_ALLOW_LOW_AAL: authConfig?.MFA_ALLOW_LOW_AAL ?? true,
210 })
211 }
212 }
213 }, [
214 authConfig,
215 isUpdatingTotpForm,
216 isUpdatingPhoneForm,
217 isUpdatingSecurityForm,
218 resetTotpForm,
219 resetPhoneForm,
220 resetSecurityForm,
221 ])
222
223 const onSubmitTotpForm: SubmitHandler<TotpFormValues> = (values) => {
224 const { verifyEnabled: MFA_TOTP_VERIFY_ENABLED, enrollEnabled: MFA_TOTP_ENROLL_ENABLED } =
225 MfaStatusToState(values.MFA_TOTP)
226
227 const payload = {
228 MFA_MAX_ENROLLED_FACTORS: values.MFA_MAX_ENROLLED_FACTORS,
229 MFA_TOTP_ENROLL_ENABLED,
230 MFA_TOTP_VERIFY_ENABLED,
231 }
232
233 setIsUpdatingTotpForm(true)
234
235 updateAuthConfig(
236 { projectRef: projectRef!, config: payload },
237 {
238 onError: (error) => {
239 toast.error(`Failed to update TOTP settings: ${error?.message}`)
240 setIsUpdatingTotpForm(false)
241 },
242 onSuccess: () => {
243 toast.success('Successfully updated TOTP settings')
244 setIsUpdatingTotpForm(false)
245 },
246 }
247 )
248 }
249
250 const onSubmitSecurityForm: SubmitHandler<SecurityFormValues> = (values) => {
251 setIsUpdatingSecurityForm(true)
252
253 updateAuthConfig(
254 { projectRef: projectRef!, config: values },
255 {
256 onError: (error) => {
257 toast.error(`Failed to update enhanced MFA security settings: ${error?.message}`)
258 setIsUpdatingSecurityForm(false)
259 },
260 onSuccess: () => {
261 toast.success('Successfully updated enhanced MFA security settings')
262 setIsUpdatingSecurityForm(false)
263 },
264 }
265 )
266 }
267
268 const onSubmitPhoneForm: SubmitHandler<PhoneFormValues> = (values) => {
269 let payload: Record<string, string | number | boolean> = {
270 MFA_PHONE_OTP_LENGTH: values.MFA_PHONE_OTP_LENGTH,
271 MFA_PHONE_TEMPLATE: values.MFA_PHONE_TEMPLATE,
272 }
273
274 if (hasAccessToMFA) {
275 const { verifyEnabled: MFA_PHONE_VERIFY_ENABLED, enrollEnabled: MFA_PHONE_ENROLL_ENABLED } =
276 MfaStatusToState(values.MFA_PHONE)
277 payload = {
278 MFA_PHONE_OTP_LENGTH: values.MFA_PHONE_OTP_LENGTH,
279 MFA_PHONE_TEMPLATE: values.MFA_PHONE_TEMPLATE,
280 MFA_PHONE_ENROLL_ENABLED,
281 MFA_PHONE_VERIFY_ENABLED,
282 }
283 }
284
285 setIsUpdatingPhoneForm(true)
286
287 updateAuthConfig(
288 { projectRef: projectRef!, config: payload },
289 {
290 onError: (error) => {
291 toast.error(`Failed to update phone MFA settings: ${error?.message}`)
292 setIsUpdatingPhoneForm(false)
293 },
294 onSuccess: () => {
295 toast.success('Successfully updated phone MFA settings')
296 setIsUpdatingPhoneForm(false)
297 },
298 }
299 )
300 }
301
302 if (isError) {
303 return (
304 <PageSection>
305 <PageSectionContent>
306 <AlertError error={authConfigError} subject="Failed to retrieve auth configuration" />
307 </PageSectionContent>
308 </PageSection>
309 )
310 }
311
312 if (!canReadConfig) {
313 return (
314 <PageSection>
315 <PageSectionContent>
316 <NoPermission resourceText="view auth configuration settings" />
317 </PageSectionContent>
318 </PageSection>
319 )
320 }
321
322 if (isLoading || isLoadingEntitlement || isLoadingEntitlementEnhanceSecurity) {
323 return (
324 <PageSection>
325 <PageSectionContent>
326 <GenericSkeletonLoader />
327 </PageSectionContent>
328 </PageSection>
329 )
330 }
331
332 const phoneMFAIsEnabled =
333 phoneForm.watch('MFA_PHONE') === 'Enabled' || phoneForm.watch('MFA_PHONE') === 'Verify Enabled'
334 const hasUpgradedPhoneMFA =
335 authConfig && !authConfig.MFA_PHONE_VERIFY_ENABLED && phoneMFAIsEnabled
336
337 const maybeConfirmPhoneMFAOrSubmit = () => {
338 if (hasUpgradedPhoneMFA) {
339 setIsConfirmationModalVisible(true)
340 } else {
341 phoneForm.handleSubmit(onSubmitPhoneForm)()
342 }
343 }
344
345 return (
346 <>
347 <PageSection>
348 <PageSectionMeta>
349 <PageSectionSummary>
350 <PageSectionTitle>Multi-Factor Authentication (MFA)</PageSectionTitle>
351 </PageSectionSummary>
352 </PageSectionMeta>
353 <PageSectionContent>
354 <Form {...totpForm}>
355 <form onSubmit={totpForm.handleSubmit(onSubmitTotpForm)} className="space-y-4">
356 <Card>
357 <CardContent>
358 <FormField
359 control={totpForm.control}
360 name="MFA_TOTP"
361 render={({ field }) => (
362 <FormItemLayout
363 layout="flex-row-reverse"
364 label="TOTP (App Authenticator)"
365 description="Control use of TOTP (App Authenticator) factors"
366 >
367 <FormControl>
368 <Select
369 value={field.value}
370 onValueChange={field.onChange}
371 disabled={!canUpdateConfig}
372 >
373 <SelectTrigger>
374 <SelectValue placeholder="Select status" />
375 </SelectTrigger>
376 <SelectContent>
377 {MFAFactorSelectionOptions.map((option) => (
378 <SelectItem key={option.value} value={option.value}>
379 {option.label}
380 </SelectItem>
381 ))}
382 </SelectContent>
383 </Select>
384 </FormControl>
385 </FormItemLayout>
386 )}
387 />
388 </CardContent>
389
390 <CardContent>
391 <FormField
392 control={totpForm.control}
393 name="MFA_MAX_ENROLLED_FACTORS"
394 render={({ field }) => (
395 <FormItemLayout
396 layout="flex-row-reverse"
397 label="Maximum number of per-user MFA factors"
398 description="How many MFA factors can be enrolled at once per user."
399 >
400 <FormControl>
401 <InputGroup>
402 <FormInputGroupInput
403 type="number"
404 min={0}
405 max={30}
406 {...field}
407 disabled={!canUpdateConfig}
408 data-1p-ignore // 1Password
409 data-lpignore="true" // LastPass
410 data-form-type="other" // Dashlane
411 data-bwignore // Bitwarden
412 />
413 <InputGroupAddon align="inline-end">
414 <InputGroupText>factors</InputGroupText>
415 </InputGroupAddon>
416 </InputGroup>
417 </FormControl>
418 </FormItemLayout>
419 )}
420 />
421 </CardContent>
422
423 <CardFooter className="justify-end space-x-2">
424 {totpForm.formState.isDirty && (
425 <Button type="default" onClick={() => totpForm.reset()}>
426 Cancel
427 </Button>
428 )}
429 <Button
430 type="primary"
431 htmlType="submit"
432 disabled={!canUpdateConfig || isUpdatingTotpForm || !totpForm.formState.isDirty}
433 loading={isUpdatingTotpForm}
434 >
435 Save changes
436 </Button>
437 </CardFooter>
438 </Card>
439 </form>
440 </Form>
441 </PageSectionContent>
442 </PageSection>
443
444 <PageSection>
445 <PageSectionMeta>
446 <PageSectionSummary>
447 <PageSectionTitle>SMS MFA</PageSectionTitle>
448 </PageSectionSummary>
449 </PageSectionMeta>
450 <PageSectionContent>
451 <Form {...phoneForm}>
452 <form
453 onSubmit={(e) => {
454 e.preventDefault()
455 maybeConfirmPhoneMFAOrSubmit()
456 }}
457 >
458 <Card>
459 <CardContent>
460 <FormField
461 control={phoneForm.control}
462 name="MFA_PHONE"
463 render={({ field }) => (
464 <FormItemLayout
465 layout="flex-row-reverse"
466 label="Phone"
467 description="Control use of phone factors"
468 >
469 <FormControl>
470 <Select
471 value={field.value}
472 onValueChange={field.onChange}
473 disabled={!canUpdateConfig || !hasAccessToMFA}
474 >
475 <SelectTrigger>
476 <SelectValue placeholder="Select status" />
477 </SelectTrigger>
478 <SelectContent>
479 {MFAFactorSelectionOptions.map((option) => (
480 <SelectItem key={option.value} value={option.value}>
481 {option.label}
482 </SelectItem>
483 ))}
484 </SelectContent>
485 </Select>
486 </FormControl>
487 </FormItemLayout>
488 )}
489 />
490
491 {!hasValidMFAProvider && phoneMFAIsEnabled && (
492 <Alert variant="warning" className="mt-3">
493 <WarningIcon />
494 <AlertTitle>
495 To use MFA with Phone you should set up a Phone provider or Send SMS Hook.
496 </AlertTitle>
497 </Alert>
498 )}
499 </CardContent>
500
501 <CardContent>
502 <FormField
503 control={phoneForm.control}
504 name="MFA_PHONE_OTP_LENGTH"
505 render={({ field }) => (
506 <FormItemLayout
507 layout="flex-row-reverse"
508 label="Phone OTP Length"
509 description="Number of digits in OTP"
510 >
511 <FormControl>
512 <InputGroup>
513 <FormInputGroupInput
514 type="number"
515 min={6}
516 max={30}
517 {...field}
518 disabled={!canUpdateConfig || !hasAccessToMFA}
519 data-1p-ignore // 1Password
520 data-lpignore="true" // LastPass
521 data-form-type="other" // Dashlane
522 data-bwignore // Bitwarden
523 />
524 <InputGroupAddon align="inline-end">
525 <InputGroupText>digits</InputGroupText>
526 </InputGroupAddon>
527 </InputGroup>
528 </FormControl>
529 </FormItemLayout>
530 )}
531 />
532 </CardContent>
533
534 <CardContent>
535 <FormField
536 control={phoneForm.control}
537 name="MFA_PHONE_TEMPLATE"
538 render={({ field }) => (
539 <FormItemLayout
540 layout="flex-row-reverse"
541 label="Phone verification message"
542 description="To format the OTP code use `{{ .Code }}`"
543 >
544 <FormControl>
545 <Input
546 type="text"
547 {...field}
548 disabled={!canUpdateConfig || !hasAccessToMFA}
549 data-1p-ignore // 1Password
550 data-lpignore="true" // LastPass
551 data-form-type="other" // Dashlane
552 data-bwignore // Bitwarden
553 />
554 </FormControl>
555 </FormItemLayout>
556 )}
557 />
558 </CardContent>
559
560 {promptProPlanUpgrade && (
561 <UpgradeToPro
562 fullWidth
563 source="authSmsMfa"
564 featureProposition="configure settings for SMS MFA"
565 primaryText="SMS MFA is only available on the Pro Plan and above"
566 secondaryText="Upgrade to the Pro plan to configure settings for SMS MFA."
567 />
568 )}
569
570 <CardFooter className="justify-end space-x-2">
571 {phoneForm.formState.isDirty && (
572 <Button type="default" onClick={() => phoneForm.reset()}>
573 Cancel
574 </Button>
575 )}
576 <Button
577 type={promptProPlanUpgrade ? 'default' : 'primary'}
578 htmlType="submit"
579 disabled={
580 !canUpdateConfig ||
581 isUpdatingPhoneForm ||
582 !phoneForm.formState.isDirty ||
583 !hasAccessToMFA
584 }
585 loading={isUpdatingPhoneForm}
586 >
587 Save changes
588 </Button>
589 </CardFooter>
590 </Card>
591 </form>
592 </Form>
593 </PageSectionContent>
594 </PageSection>
595
596 <ConfirmationModal
597 visible={isConfirmationModalVisible}
598 title="Confirm SMS MFA"
599 confirmLabel="Confirm and save"
600 onCancel={() => setIsConfirmationModalVisible(false)}
601 onConfirm={() => {
602 setIsConfirmationModalVisible(false)
603 phoneForm.handleSubmit(onSubmitPhoneForm)()
604 }}
605 variant="warning"
606 >
607 Enabling SMS MFA will result in an additional charge of <span translate="no">$75</span> per
608 month for the first project in the organization and an additional{' '}
609 <span translate="no">$10</span> per month for additional projects.
610 <p className="mt-2">
611 Billing will start immediately upon enabling this add-on, regardless of whether your
612 customers are using SMS MFA.
613 </p>
614 <TaxDisclaimer className="mt-2" />
615 </ConfirmationModal>
616
617 <PageSection>
618 <PageSectionMeta>
619 <PageSectionSummary>
620 <PageSectionTitle>Enhanced MFA Security</PageSectionTitle>
621 </PageSectionSummary>
622 </PageSectionMeta>
623 <PageSectionContent>
624 <Form {...securityForm}>
625 <form onSubmit={securityForm.handleSubmit(onSubmitSecurityForm)}>
626 <Card>
627 <CardContent>
628 <FormField
629 control={securityForm.control}
630 name="MFA_ALLOW_LOW_AAL"
631 render={({ field }) => (
632 <FormItemLayout
633 layout="flex-row-reverse"
634 label="Limit duration of AAL1 sessions"
635 description="A user's session will be terminated unless they verify one of their factors within 15 minutes of initial sign in. Recommendation: ON"
636 >
637 <FormControl>
638 <Switch
639 checked={!field.value}
640 onCheckedChange={(value) => field.onChange(!value)}
641 disabled={!canUpdateConfig || !hasAccessToEnhanceSecurity}
642 />
643 </FormControl>
644 </FormItemLayout>
645 )}
646 />
647 </CardContent>
648
649 {promptEnhancedSecurityUpgrade && (
650 <UpgradeToPro
651 fullWidth
652 source="authEnhancedSecurity"
653 featureProposition="configure settings for Enhanced MFA Security"
654 primaryText="Enhanced MFA Security is not available on your plan"
655 secondaryText="Upgrade your plan to configure settings for Enhanced MFA Security"
656 buttonText="Upgrade"
657 />
658 )}
659 <CardFooter className="justify-end space-x-2">
660 {securityForm.formState.isDirty && (
661 <Button type="default" onClick={() => securityForm.reset()}>
662 Cancel
663 </Button>
664 )}
665 <Button
666 type="primary"
667 htmlType="submit"
668 disabled={
669 !canUpdateConfig || isUpdatingSecurityForm || !securityForm.formState.isDirty
670 }
671 loading={isUpdatingSecurityForm}
672 >
673 Save changes
674 </Button>
675 </CardFooter>
676 </Card>
677 </form>
678 </Form>
679 </PageSectionContent>
680 </PageSection>
681 </>
682 )
683}