| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401 |
- import { zodResolver } from '@hookform/resolvers/zod'
- import { PermissionAction } from '@supabase/shared-types/out/constants'
- import { useParams } from 'common'
- import { useForm } from 'react-hook-form'
- import { toast } from 'sonner'
- import {
- Button,
- Card,
- CardContent,
- CardFooter,
- Form,
- FormControl,
- FormField,
- Input,
- Switch,
- useWatch,
- } from 'ui'
- import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
- import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
- import * as z from 'zod'
- import { InlineLink } from '@/components/ui/InlineLink'
- import NoPermission from '@/components/ui/NoPermission'
- import type { components } from '@/data/api'
- import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
- import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
- import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { DOCS_URL } from '@/lib/constants'
- type GoTrueConfig = components['schemas']['GoTrueConfigResponse']
- function isLocalhost(hostname: string): boolean {
- return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'
- }
- function validateRpId(rpId: string): string | null {
- const trimmed = rpId.trim().toLowerCase()
- if (!trimmed) return null
- try {
- const url = new URL('https://' + trimmed)
- if (url.hostname !== trimmed) return null
- return trimmed
- } catch {
- return null
- }
- }
- function validateWebAuthnOrigins(
- value: string,
- rpId: string | null
- ): { valid: true } | { valid: false; message: string } {
- const origins = value
- .split(',')
- .map((o) => o.trim())
- .filter(Boolean)
- if (origins.length === 0) {
- return { valid: false, message: 'At least one origin is required' }
- }
- if (origins.length > 5) {
- return { valid: false, message: 'A maximum of 5 origins is allowed' }
- }
- for (const origin of origins) {
- let url: URL
- try {
- url = new URL(origin)
- } catch {
- return { valid: false, message: `"${origin}" is not a valid URL` }
- }
- if (url.protocol === 'http:') {
- if (!isLocalhost(url.hostname)) {
- return {
- valid: false,
- message: `"${origin}" must use HTTPS unless it is a localhost origin`,
- }
- }
- } else if (url.protocol !== 'https:') {
- return {
- valid: false,
- message: `"${origin}" must use HTTPS unless it is a localhost origin`,
- }
- }
- if (url.href !== url.origin + '/') {
- return {
- valid: false,
- message: `"${origin}" must be a plain origin without path, query, or fragment (e.g. "${url.origin}")`,
- }
- }
- if (rpId && !isOriginCompatibleWithRpId(url.hostname, rpId)) {
- return {
- valid: false,
- message: `"${origin}" is not compatible with Relying Party ID "${rpId}". The origin's hostname must match or be a subdomain of the RP ID.`,
- }
- }
- }
- return { valid: true }
- }
- function isOriginCompatibleWithRpId(originHostname: string, rpId: string): boolean {
- const host = originHostname.toLowerCase()
- const id = rpId.toLowerCase()
- if (isLocalhost(host) && isLocalhost(id)) return true
- if (host === id) return true
- if (host.endsWith('.' + id)) return true
- return false
- }
- const schema = z
- .object({
- PASSKEY_ENABLED: z.boolean(),
- WEBAUTHN_RP_ID: z.string().trim(),
- WEBAUTHN_RP_DISPLAY_NAME: z.string().trim(),
- WEBAUTHN_RP_ORIGINS: z.string().trim(),
- })
- .superRefine((data, ctx) => {
- if (!data.PASSKEY_ENABLED) return
- if (!data.WEBAUTHN_RP_DISPLAY_NAME) {
- ctx.addIssue({
- path: ['WEBAUTHN_RP_DISPLAY_NAME'],
- code: z.ZodIssueCode.custom,
- message: 'Relying Party Display Name is required when Passkey is enabled',
- })
- }
- let validatedRpId: string | null = null
- if (!data.WEBAUTHN_RP_ID) {
- ctx.addIssue({
- path: ['WEBAUTHN_RP_ID'],
- code: z.ZodIssueCode.custom,
- message: 'Relying Party ID is required when Passkey is enabled',
- })
- } else {
- validatedRpId = validateRpId(data.WEBAUTHN_RP_ID)
- if (validatedRpId === null) {
- ctx.addIssue({
- path: ['WEBAUTHN_RP_ID'],
- code: z.ZodIssueCode.custom,
- message:
- 'Relying Party ID must be a bare domain (e.g. "example.com"). Do not include a scheme, port, or path.',
- })
- }
- }
- const origins = data.WEBAUTHN_RP_ORIGINS
- if (!origins) {
- ctx.addIssue({
- path: ['WEBAUTHN_RP_ORIGINS'],
- code: z.ZodIssueCode.custom,
- message: 'Relying Party Origins is required when Passkey is enabled',
- })
- return
- }
- const result = validateWebAuthnOrigins(origins, validatedRpId)
- if (!result.valid) {
- ctx.addIssue({
- path: ['WEBAUTHN_RP_ORIGINS'],
- code: z.ZodIssueCode.custom,
- message: result.message,
- })
- }
- })
- type PasskeysSettings = z.infer<typeof schema>
- function getPasskeyDefault(
- key: keyof Pick<
- PasskeysSettings,
- 'WEBAUTHN_RP_ID' | 'WEBAUTHN_RP_ORIGINS' | 'WEBAUTHN_RP_DISPLAY_NAME'
- >,
- config: GoTrueConfig,
- project: { name: string } | undefined
- ): string {
- const siteUrl = config.SITE_URL
- switch (key) {
- case 'WEBAUTHN_RP_ID': {
- if (!siteUrl) return ''
- try {
- return new URL(siteUrl).hostname
- } catch {
- return ''
- }
- }
- case 'WEBAUTHN_RP_ORIGINS': {
- if (!siteUrl) return ''
- try {
- return new URL(siteUrl).origin
- } catch {
- return ''
- }
- }
- case 'WEBAUTHN_RP_DISPLAY_NAME': {
- return project?.name ?? ''
- }
- default:
- return ''
- }
- }
- function buildPasskeysFormValues(
- config: GoTrueConfig,
- project: { name: string } | undefined
- ): PasskeysSettings {
- const values: PasskeysSettings = {
- PASSKEY_ENABLED: config.PASSKEY_ENABLED ?? false,
- WEBAUTHN_RP_ID: config.WEBAUTHN_RP_ID || getPasskeyDefault('WEBAUTHN_RP_ID', config, project),
- WEBAUTHN_RP_DISPLAY_NAME:
- config.WEBAUTHN_RP_DISPLAY_NAME ||
- getPasskeyDefault('WEBAUTHN_RP_DISPLAY_NAME', config, project),
- WEBAUTHN_RP_ORIGINS:
- config.WEBAUTHN_RP_ORIGINS || getPasskeyDefault('WEBAUTHN_RP_ORIGINS', config, project),
- }
- return values
- }
- export const PasskeysSettingsForm = () => {
- const { ref: projectRef } = useParams()
- const { data: project } = useSelectedProjectQuery()
- const {
- data: authConfig,
- isPending: isAuthConfigLoading,
- isSuccess,
- } = useAuthConfigQuery({ projectRef })
- const { mutate: updateAuthConfig, isPending } = useAuthConfigUpdateMutation({
- onSuccess: () => {
- toast.success('Passkey settings updated successfully')
- },
- onError: (error) => {
- toast.error(`Failed to update passkey settings: ${error?.message}`)
- },
- })
- const {
- can: canReadConfig,
- isLoading: isLoadingPermissions,
- isSuccess: isPermissionsLoaded,
- } = useAsyncCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
- const { can: canUpdateConfig } = useAsyncCheckPermissions(
- PermissionAction.UPDATE,
- 'custom_config_gotrue'
- )
- const formValues =
- isSuccess && authConfig ? buildPasskeysFormValues(authConfig, project) : undefined
- const form = useForm<PasskeysSettings>({
- resolver: zodResolver(schema as any),
- defaultValues: formValues ?? {
- PASSKEY_ENABLED: false,
- WEBAUTHN_RP_ID: '',
- WEBAUTHN_RP_DISPLAY_NAME: '',
- WEBAUTHN_RP_ORIGINS: '',
- },
- values: formValues,
- })
- const onSubmit = (values: PasskeysSettings) => {
- if (!projectRef) return
- const payload: Record<string, string | boolean | null> = {
- PASSKEY_ENABLED: values.PASSKEY_ENABLED,
- WEBAUTHN_RP_ID: values.WEBAUTHN_RP_ID.trim() || null,
- WEBAUTHN_RP_DISPLAY_NAME: values.WEBAUTHN_RP_DISPLAY_NAME.trim() || null,
- WEBAUTHN_RP_ORIGINS: values.WEBAUTHN_RP_ORIGINS.trim() || null,
- }
- updateAuthConfig({ projectRef, config: payload })
- }
- const passKeysEnabled = useWatch({ control: form.control, name: 'PASSKEY_ENABLED' })
- if (isPermissionsLoaded && !canReadConfig) {
- return <NoPermission resourceText="view passkey settings" />
- }
- if (isAuthConfigLoading || isLoadingPermissions || !authConfig) {
- return <GenericSkeletonLoader />
- }
- return (
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)}>
- <Card>
- <CardContent>
- <FormField
- control={form.control}
- name="PASSKEY_ENABLED"
- render={({ field }) => (
- <FormItemLayout
- layout="flex-row-reverse"
- label="Enable Passkey authentication"
- description={
- <>
- Allow users to sign in using passkeys (WebAuthn) with biometrics, security
- keys, or platform authenticators.{' '}
- <InlineLink href={`${DOCS_URL}/guides/auth/passkeys`}>Learn more</InlineLink>
- </>
- }
- >
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- disabled={!canUpdateConfig}
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </CardContent>
- {passKeysEnabled && (
- <>
- <CardContent>
- <FormField
- control={form.control}
- name="WEBAUTHN_RP_DISPLAY_NAME"
- render={({ field }) => (
- <FormItemLayout
- layout="flex-row-reverse"
- label="Relying Party Display Name"
- description="A human-readable name for your application shown during passkey registration."
- >
- <FormControl>
- <Input {...field} placeholder="My project" />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </CardContent>
- <CardContent>
- <FormField
- control={form.control}
- name="WEBAUTHN_RP_ID"
- render={({ field }) => (
- <FormItemLayout
- layout="flex-row-reverse"
- label="Relying Party ID"
- description='The domain name for your application (e.g. "example.com"). This determines which passkeys can be used.'
- >
- <FormControl>
- <Input {...field} placeholder="example.com" />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </CardContent>
- <CardContent>
- <FormField
- control={form.control}
- name="WEBAUTHN_RP_ORIGINS"
- render={({ field }) => (
- <FormItemLayout
- layout="flex-row-reverse"
- label="Relying Party Origins"
- description='Comma-separated list of allowed origins (e.g. "https://example.com"). HTTPS is required except for localhost.'
- >
- <FormControl>
- <Input {...field} placeholder="https://example.com" />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </CardContent>
- </>
- )}
- <CardFooter className="justify-end space-x-2">
- <Button
- type="default"
- onClick={() => form.reset(buildPasskeysFormValues(authConfig, project))}
- disabled={isPending}
- >
- Cancel
- </Button>
- <Button
- type="primary"
- htmlType="submit"
- disabled={!canUpdateConfig || !form.formState.isDirty}
- loading={isPending}
- >
- Save changes
- </Button>
- </CardFooter>
- </Card>
- </form>
- </Form>
- )
- }
|