BasicAuthSettingsForm.tsx350 lines · main
| 1 | import { zodResolver } from '@hookform/resolvers/zod' |
| 2 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 3 | import { useParams } from 'common' |
| 4 | import { ExternalLink } from 'lucide-react' |
| 5 | import Link from 'next/link' |
| 6 | import { useEffect } from 'react' |
| 7 | import { useForm } from 'react-hook-form' |
| 8 | import { toast } from 'sonner' |
| 9 | import { |
| 10 | Alert, |
| 11 | AlertDescription, |
| 12 | AlertTitle, |
| 13 | Button, |
| 14 | Card, |
| 15 | CardContent, |
| 16 | CardFooter, |
| 17 | Form, |
| 18 | FormControl, |
| 19 | FormField, |
| 20 | Switch, |
| 21 | WarningIcon, |
| 22 | } from 'ui' |
| 23 | import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' |
| 24 | import { |
| 25 | PageSection, |
| 26 | PageSectionContent, |
| 27 | PageSectionMeta, |
| 28 | PageSectionSummary, |
| 29 | PageSectionTitle, |
| 30 | } from 'ui-patterns/PageSection' |
| 31 | import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' |
| 32 | import * as z from 'zod' |
| 33 | |
| 34 | import { NO_REQUIRED_CHARACTERS } from './Auth.constants' |
| 35 | import AlertError from '@/components/ui/AlertError' |
| 36 | import { InlineLink } from '@/components/ui/InlineLink' |
| 37 | import NoPermission from '@/components/ui/NoPermission' |
| 38 | import { useAuthConfigQuery } from '@/data/auth/auth-config-query' |
| 39 | import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' |
| 40 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 41 | import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' |
| 42 | import { DOCS_URL } from '@/lib/constants' |
| 43 | |
| 44 | const schema = z.object({ |
| 45 | DISABLE_SIGNUP: z.boolean(), |
| 46 | EXTERNAL_ANONYMOUS_USERS_ENABLED: z.boolean(), |
| 47 | SECURITY_MANUAL_LINKING_ENABLED: z.boolean(), |
| 48 | MAILER_AUTOCONFIRM: z.boolean(), |
| 49 | SITE_URL: z.string().min(1, 'Must have a Site URL'), |
| 50 | }) |
| 51 | |
| 52 | export const BasicAuthSettingsForm = () => { |
| 53 | const { ref: projectRef } = useParams() |
| 54 | const showManualLinking = useIsFeatureEnabled('authentication:show_manual_linking') |
| 55 | |
| 56 | const { |
| 57 | data: authConfig, |
| 58 | error: authConfigError, |
| 59 | isError, |
| 60 | isSuccess, |
| 61 | isPending: isLoading, |
| 62 | } = useAuthConfigQuery({ projectRef }) |
| 63 | const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation() |
| 64 | |
| 65 | const { can: canReadConfig, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( |
| 66 | PermissionAction.READ, |
| 67 | 'custom_config_gotrue' |
| 68 | ) |
| 69 | const { can: canUpdateConfig } = useAsyncCheckPermissions( |
| 70 | PermissionAction.UPDATE, |
| 71 | 'custom_config_gotrue' |
| 72 | ) |
| 73 | |
| 74 | const form = useForm({ |
| 75 | resolver: zodResolver(schema as any), |
| 76 | defaultValues: { |
| 77 | DISABLE_SIGNUP: true, |
| 78 | EXTERNAL_ANONYMOUS_USERS_ENABLED: false, |
| 79 | SECURITY_MANUAL_LINKING_ENABLED: false, |
| 80 | MAILER_AUTOCONFIRM: true, |
| 81 | SITE_URL: '', |
| 82 | }, |
| 83 | }) |
| 84 | const { isDirty } = form.formState |
| 85 | |
| 86 | useEffect(() => { |
| 87 | if (authConfig) { |
| 88 | form.reset({ |
| 89 | DISABLE_SIGNUP: !authConfig.DISABLE_SIGNUP, |
| 90 | EXTERNAL_ANONYMOUS_USERS_ENABLED: authConfig.EXTERNAL_ANONYMOUS_USERS_ENABLED, |
| 91 | SECURITY_MANUAL_LINKING_ENABLED: authConfig.SECURITY_MANUAL_LINKING_ENABLED, |
| 92 | // The backend uses false to represent that email confirmation is required |
| 93 | MAILER_AUTOCONFIRM: !authConfig.MAILER_AUTOCONFIRM, |
| 94 | SITE_URL: authConfig.SITE_URL, |
| 95 | }) |
| 96 | } |
| 97 | }, [authConfig]) |
| 98 | |
| 99 | const onSubmit = (values: any) => { |
| 100 | const payload = { ...values } |
| 101 | payload.DISABLE_SIGNUP = !values.DISABLE_SIGNUP |
| 102 | // The backend uses empty string to represent no required characters in the password |
| 103 | if (payload.PASSWORD_REQUIRED_CHARACTERS === NO_REQUIRED_CHARACTERS) { |
| 104 | payload.PASSWORD_REQUIRED_CHARACTERS = '' |
| 105 | } |
| 106 | |
| 107 | // The backend uses false to represent that email confirmation is required |
| 108 | payload.MAILER_AUTOCONFIRM = !values.MAILER_AUTOCONFIRM |
| 109 | |
| 110 | updateAuthConfig( |
| 111 | { projectRef: projectRef!, config: payload }, |
| 112 | { |
| 113 | onError: (error) => { |
| 114 | toast.error(`Failed to update settings: ${error?.message}`) |
| 115 | }, |
| 116 | onSuccess: () => { |
| 117 | toast.success('Successfully updated settings') |
| 118 | }, |
| 119 | } |
| 120 | ) |
| 121 | } |
| 122 | |
| 123 | return ( |
| 124 | <PageSection> |
| 125 | <PageSectionMeta> |
| 126 | <PageSectionSummary> |
| 127 | <PageSectionTitle>User Signups</PageSectionTitle> |
| 128 | </PageSectionSummary> |
| 129 | </PageSectionMeta> |
| 130 | <PageSectionContent> |
| 131 | {isError && ( |
| 132 | <AlertError |
| 133 | error={authConfigError} |
| 134 | subject="Failed to retrieve auth configuration for hooks" |
| 135 | /> |
| 136 | )} |
| 137 | |
| 138 | {isPermissionsLoaded && !canReadConfig && ( |
| 139 | <div className="mt-8"> |
| 140 | <NoPermission resourceText="view auth configuration settings" /> |
| 141 | </div> |
| 142 | )} |
| 143 | |
| 144 | {isLoading && ( |
| 145 | <Card> |
| 146 | <CardContent className="py-6"> |
| 147 | <ShimmeringLoader /> |
| 148 | </CardContent> |
| 149 | <CardContent className="py-6"> |
| 150 | <ShimmeringLoader /> |
| 151 | </CardContent> |
| 152 | <CardContent className="py-6"> |
| 153 | <ShimmeringLoader /> |
| 154 | </CardContent> |
| 155 | <CardContent className="py-7"> |
| 156 | <ShimmeringLoader /> |
| 157 | </CardContent> |
| 158 | <CardContent className="py-7"></CardContent> |
| 159 | </Card> |
| 160 | )} |
| 161 | |
| 162 | {isSuccess && ( |
| 163 | <Form {...form}> |
| 164 | <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> |
| 165 | <Card> |
| 166 | <CardContent> |
| 167 | <FormField |
| 168 | control={form.control} |
| 169 | name="DISABLE_SIGNUP" |
| 170 | render={({ field }) => ( |
| 171 | <FormItemLayout |
| 172 | layout="flex-row-reverse" |
| 173 | label="Allow new users to sign up" |
| 174 | description="If this is disabled, new users will not be able to sign up to your application" |
| 175 | > |
| 176 | <FormControl> |
| 177 | <Switch |
| 178 | checked={field.value} |
| 179 | onCheckedChange={field.onChange} |
| 180 | disabled={!canUpdateConfig} |
| 181 | /> |
| 182 | </FormControl> |
| 183 | </FormItemLayout> |
| 184 | )} |
| 185 | /> |
| 186 | </CardContent> |
| 187 | {showManualLinking && ( |
| 188 | <CardContent> |
| 189 | <FormField |
| 190 | control={form.control} |
| 191 | name="SECURITY_MANUAL_LINKING_ENABLED" |
| 192 | render={({ field }) => ( |
| 193 | <FormItemLayout |
| 194 | layout="flex-row-reverse" |
| 195 | label="Allow manual linking" |
| 196 | description={ |
| 197 | <> |
| 198 | Enable{' '} |
| 199 | <InlineLink |
| 200 | className="text-foreground-light hover:text-foreground" |
| 201 | href={`${DOCS_URL}/guides/auth/auth-identity-linking#manual-linking-beta`} |
| 202 | > |
| 203 | manual linking APIs |
| 204 | </InlineLink>{' '} |
| 205 | for your project |
| 206 | </> |
| 207 | } |
| 208 | > |
| 209 | <FormControl> |
| 210 | <Switch |
| 211 | checked={field.value} |
| 212 | onCheckedChange={field.onChange} |
| 213 | disabled={!canUpdateConfig} |
| 214 | /> |
| 215 | </FormControl> |
| 216 | </FormItemLayout> |
| 217 | )} |
| 218 | /> |
| 219 | </CardContent> |
| 220 | )} |
| 221 | <CardContent> |
| 222 | <FormField |
| 223 | control={form.control} |
| 224 | name="EXTERNAL_ANONYMOUS_USERS_ENABLED" |
| 225 | render={({ field }) => ( |
| 226 | <FormItemLayout |
| 227 | layout="flex-row-reverse" |
| 228 | label="Allow anonymous sign-ins" |
| 229 | description={ |
| 230 | <> |
| 231 | Enable{' '} |
| 232 | <InlineLink |
| 233 | className="text-foreground-light hover:text-foreground" |
| 234 | href={`${DOCS_URL}/guides/auth/auth-anonymous`} |
| 235 | > |
| 236 | anonymous sign-ins |
| 237 | </InlineLink>{' '} |
| 238 | for your project |
| 239 | </> |
| 240 | } |
| 241 | > |
| 242 | <FormControl> |
| 243 | <Switch |
| 244 | checked={field.value} |
| 245 | onCheckedChange={field.onChange} |
| 246 | disabled={!canUpdateConfig} |
| 247 | /> |
| 248 | </FormControl> |
| 249 | </FormItemLayout> |
| 250 | )} |
| 251 | /> |
| 252 | |
| 253 | {form.watch('EXTERNAL_ANONYMOUS_USERS_ENABLED') && ( |
| 254 | <Alert |
| 255 | className="flex w-full items-center justify-between mt-4" |
| 256 | variant="warning" |
| 257 | > |
| 258 | <WarningIcon /> |
| 259 | <div> |
| 260 | <AlertTitle> |
| 261 | Anonymous users will use the{' '} |
| 262 | <code className="text-code-inline">authenticated</code> role when signing |
| 263 | in |
| 264 | </AlertTitle> |
| 265 | <AlertDescription className="flex flex-col gap-y-3"> |
| 266 | <p> |
| 267 | As a result, anonymous users will be subjected to RLS policies that |
| 268 | apply to the <code className="text-code-inline">public</code> and{' '} |
| 269 | <code className="text-code-inline">authenticated</code> roles. We |
| 270 | strongly advise{' '} |
| 271 | <Link |
| 272 | href={`/project/${projectRef}/auth/policies`} |
| 273 | className="text-foreground underline" |
| 274 | > |
| 275 | reviewing your RLS policies |
| 276 | </Link>{' '} |
| 277 | to ensure that access to your data is restricted where required. |
| 278 | </p> |
| 279 | <Button asChild type="default" className="w-min" icon={<ExternalLink />}> |
| 280 | <Link href={`${DOCS_URL}/guides/auth/auth-anonymous#access-control`}> |
| 281 | View access control docs |
| 282 | </Link> |
| 283 | </Button> |
| 284 | </AlertDescription> |
| 285 | </div> |
| 286 | </Alert> |
| 287 | )} |
| 288 | |
| 289 | {!authConfig?.SECURITY_CAPTCHA_ENABLED && |
| 290 | form.watch('EXTERNAL_ANONYMOUS_USERS_ENABLED') && ( |
| 291 | <Alert className="mt-4"> |
| 292 | <WarningIcon /> |
| 293 | <AlertTitle> |
| 294 | We highly recommend{' '} |
| 295 | <InlineLink href={`/project/${projectRef}/auth/protection`}> |
| 296 | enabling captcha |
| 297 | </InlineLink>{' '} |
| 298 | for anonymous sign-ins |
| 299 | </AlertTitle> |
| 300 | <AlertDescription> |
| 301 | This will prevent potential abuse on sign-ins which may bloat your |
| 302 | database and incur costs for monthly active users (MAU) |
| 303 | </AlertDescription> |
| 304 | </Alert> |
| 305 | )} |
| 306 | </CardContent> |
| 307 | <CardContent> |
| 308 | <FormField |
| 309 | control={form.control} |
| 310 | name="MAILER_AUTOCONFIRM" |
| 311 | render={({ field }) => ( |
| 312 | <FormItemLayout |
| 313 | layout="flex-row-reverse" |
| 314 | label="Confirm email" |
| 315 | description="Users will need to confirm their email address before signing in for the first time" |
| 316 | > |
| 317 | <FormControl> |
| 318 | <Switch |
| 319 | checked={field.value} |
| 320 | onCheckedChange={field.onChange} |
| 321 | disabled={!canUpdateConfig} |
| 322 | /> |
| 323 | </FormControl> |
| 324 | </FormItemLayout> |
| 325 | )} |
| 326 | /> |
| 327 | </CardContent> |
| 328 | <CardFooter className="justify-end space-x-2"> |
| 329 | {isDirty && ( |
| 330 | <Button type="default" onClick={() => form.reset()}> |
| 331 | Cancel |
| 332 | </Button> |
| 333 | )} |
| 334 | <Button |
| 335 | type="primary" |
| 336 | htmlType="submit" |
| 337 | disabled={!canUpdateConfig || isUpdatingConfig || !isDirty} |
| 338 | loading={isUpdatingConfig} |
| 339 | > |
| 340 | Save changes |
| 341 | </Button> |
| 342 | </CardFooter> |
| 343 | </Card> |
| 344 | </form> |
| 345 | </Form> |
| 346 | )} |
| 347 | </PageSectionContent> |
| 348 | </PageSection> |
| 349 | ) |
| 350 | } |