mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat: passkeys config page (#44718)
## Summary by CodeRabbit * **New Features** * Added Passkeys configuration page to manage WebAuthn relying-party settings and enable/disable passkey auth. * Added a Beta "Passkeys" item to the Auth settings menu. * Enabled saving passkey-related authentication parameters. * **Tests** * Added test coverage to ensure the Passkeys menu appears or is omitted based on feature flags. * **Chores** * Removed an unused import to tidy the code. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: fadymak <dev@fadymak.com> Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
This commit is contained in:
7 files changed
+534
-1
No files matched your search
@@ -11,7 +11,6 @@ import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import { useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
Switch,
|
||||
useWatch_Shadcn_,
|
||||
} 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 form = useForm<PasskeysSettings>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
PASSKEY_ENABLED: false,
|
||||
WEBAUTHN_RP_ID: '',
|
||||
WEBAUTHN_RP_DISPLAY_NAME: '',
|
||||
WEBAUTHN_RP_ORIGINS: '',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess && authConfig) {
|
||||
form.reset(buildPasskeysFormValues(authConfig, project))
|
||||
}
|
||||
// form.reset is stable; authConfig/project drive when to sync from server
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSuccess, authConfig, project])
|
||||
|
||||
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_Shadcn_({ control: form.control, name: 'PASSKEY_ENABLED' })
|
||||
|
||||
if (isPermissionsLoaded && !canReadConfig) {
|
||||
return <NoPermission resourceText="view passkey settings" />
|
||||
}
|
||||
|
||||
if (isAuthConfigLoading || isLoadingPermissions || !authConfig) {
|
||||
return <GenericSkeletonLoader />
|
||||
}
|
||||
|
||||
return (
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
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_Shadcn_>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!canUpdateConfig}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
{passKeysEnabled && (
|
||||
<>
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
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_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="My project" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
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_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="example.com" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
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_Shadcn_>
|
||||
<Input_Shadcn_ {...field} placeholder="https://example.com" />
|
||||
</FormControl_Shadcn_>
|
||||
</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_Shadcn_>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const allFeaturesEnabled: GenerateAuthMenuOptions = {
|
||||
multiFactor: true,
|
||||
attackProtection: true,
|
||||
performance: true,
|
||||
passkeys: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -27,6 +28,7 @@ const allFeaturesDisabled: GenerateAuthMenuOptions = {
|
||||
multiFactor: false,
|
||||
attackProtection: false,
|
||||
performance: false,
|
||||
passkeys: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -53,6 +55,7 @@ describe('generateAuthMenu', () => {
|
||||
expect(names).toContain('Email')
|
||||
expect(names).toContain('Sign In / Providers')
|
||||
expect(names).toContain('OAuth Server')
|
||||
expect(names).toContain('Passkeys')
|
||||
expect(names).toContain('Sessions')
|
||||
expect(names).toContain('Rate Limits')
|
||||
expect(names).toContain('Multi-Factor')
|
||||
@@ -71,6 +74,7 @@ describe('generateAuthMenu', () => {
|
||||
expect(names).toContain('OAuth Apps')
|
||||
expect(names).toContain('Policies')
|
||||
expect(names).toContain('OAuth Server')
|
||||
expect(names).toContain('Passkeys')
|
||||
expect(names).toContain('Sessions')
|
||||
expect(names).toContain('URL Configuration')
|
||||
expect(names).toContain('Auth Hooks')
|
||||
@@ -141,4 +145,12 @@ describe('generateAuthMenu', () => {
|
||||
expect(users?.url).toBe('/project/my-project/auth/users')
|
||||
expect(oauthApps?.url).toBe('/project/my-project/auth/oauth-apps')
|
||||
})
|
||||
|
||||
it('hides Passkeys when passkeys feature is false', () => {
|
||||
const menu = generateAuthMenu({
|
||||
...allFeaturesEnabled,
|
||||
features: { ...allFeaturesEnabled.features, passkeys: false },
|
||||
})
|
||||
expect(flatItemNames(menu)).not.toContain('Passkeys')
|
||||
})
|
||||
})
|
||||
@@ -15,11 +15,13 @@ export interface GenerateAuthMenuOptions {
|
||||
multiFactor: boolean
|
||||
attackProtection: boolean
|
||||
performance: boolean
|
||||
passkeys?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function generateAuthMenu(options: GenerateAuthMenuOptions): ProductMenuGroup[] {
|
||||
const { ref, isPlatform, showOverview, features } = options
|
||||
const passkeysInMenu = Boolean(features.passkeys)
|
||||
const baseUrl = `/project/${ref}/auth`
|
||||
|
||||
return [
|
||||
@@ -84,6 +86,16 @@ export function generateAuthMenu(options: GenerateAuthMenuOptions): ProductMenuG
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(passkeysInMenu
|
||||
? [
|
||||
{
|
||||
name: 'Passkeys',
|
||||
key: 'passkeys',
|
||||
url: `${baseUrl}/passkeys`,
|
||||
label: 'Beta',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'OAuth Server',
|
||||
key: 'oauth-server',
|
||||
@@ -165,6 +177,7 @@ export function generateAuthMenu(options: GenerateAuthMenuOptions): ProductMenuG
|
||||
export const useGenerateAuthMenu = (): ProductMenuGroup[] => {
|
||||
const { ref } = useParams()
|
||||
const showOverview = useFlag('authOverviewPage')
|
||||
const enablePasskeyAuth = useFlag('enablePasskeyAuth')
|
||||
|
||||
const {
|
||||
authenticationSignInProviders,
|
||||
@@ -193,6 +206,7 @@ export const useGenerateAuthMenu = (): ProductMenuGroup[] => {
|
||||
multiFactor: authenticationMultiFactor,
|
||||
attackProtection: authenticationAttackProtection,
|
||||
performance: authenticationPerformance,
|
||||
passkeys: enablePasskeyAuth,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { IS_PLATFORM, useFeatureFlags, useFlag } from 'common'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
PageHeader,
|
||||
PageHeaderDescription,
|
||||
PageHeaderMeta,
|
||||
PageHeaderSummary,
|
||||
PageHeaderTitle,
|
||||
} from 'ui-patterns/PageHeader'
|
||||
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { PasskeysSettingsForm } from '@/components/interfaces/Auth/Passkeys/PasskeysSettingsForm'
|
||||
import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout'
|
||||
import DefaultLayout from '@/components/layouts/DefaultLayout'
|
||||
import type { NextPageWithLayout } from '@/types'
|
||||
|
||||
const PasskeysPage: NextPageWithLayout = () => {
|
||||
const { hasLoaded: flagsLoaded } = useFeatureFlags()
|
||||
const isPasskeyAuthEnabled = useFlag('enablePasskeyAuth')
|
||||
|
||||
const isResolvingPasskeyFlag = IS_PLATFORM && !flagsLoaded
|
||||
|
||||
if (isResolvingPasskeyFlag) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader size="default">
|
||||
<PageHeaderMeta>
|
||||
<HeaderSummary />
|
||||
</PageHeaderMeta>
|
||||
</PageHeader>
|
||||
<PageContainer size="default">
|
||||
<PageSection>
|
||||
<PageSectionContent>
|
||||
<GenericSkeletonLoader />
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isPasskeyAuthEnabled) {
|
||||
return (
|
||||
<PageContainer size="default">
|
||||
<PageSection>
|
||||
<PageSectionContent>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Passkey authentication is not available for this project.
|
||||
</p>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
</PageContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader size="default">
|
||||
<PageHeaderMeta>
|
||||
<HeaderSummary />
|
||||
</PageHeaderMeta>
|
||||
</PageHeader>
|
||||
<PageContainer size="default">
|
||||
<PageSection>
|
||||
<PageSectionContent>
|
||||
<PasskeysSettingsForm />
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const HeaderSummary = () => {
|
||||
return (
|
||||
<PageHeaderSummary>
|
||||
<PageHeaderTitle>Passkeys</PageHeaderTitle>
|
||||
<PageHeaderDescription>
|
||||
Configure WebAuthn passkeys so users can sign in with biometrics, security keys, or platform
|
||||
authenticators
|
||||
</PageHeaderDescription>
|
||||
</PageHeaderSummary>
|
||||
)
|
||||
}
|
||||
|
||||
PasskeysPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<AuthLayout title="Passkeys">{page}</AuthLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default PasskeysPage
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="gray" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user-round-key-icon lucide-user-round-key"><path d="M19 11v6"/><path d="M19 13h2"/><path d="M2 21a8 8 0 0 1 12.868-6.349"/><circle cx="10" cy="8" r="5"/><circle cx="19" cy="19" r="2"/></svg>
|
||||
|
After Width: | Height: | Size: 386 B |
+8
@@ -7001,6 +7001,10 @@ export interface components {
|
||||
MFA_WEB_AUTHN_ENROLL_ENABLED: boolean
|
||||
MFA_WEB_AUTHN_VERIFY_ENABLED: boolean
|
||||
NIMBUS_OAUTH_CLIENT_ID: string | null
|
||||
PASSKEY_ENABLED: boolean
|
||||
WEBAUTHN_RP_DISPLAY_NAME: string
|
||||
WEBAUTHN_RP_ID: string
|
||||
WEBAUTHN_RP_ORIGINS: string
|
||||
NIMBUS_OAUTH_CLIENT_SECRET: string | null
|
||||
OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: boolean
|
||||
OAUTH_SERVER_AUTHORIZATION_PATH: string | null
|
||||
@@ -10330,6 +10334,10 @@ export interface components {
|
||||
MFA_WEB_AUTHN_ENROLL_ENABLED?: boolean | null
|
||||
MFA_WEB_AUTHN_VERIFY_ENABLED?: boolean | null
|
||||
NIMBUS_OAUTH_CLIENT_ID?: string | null
|
||||
PASSKEY_ENABLED?: boolean | null
|
||||
WEBAUTHN_RP_DISPLAY_NAME?: string | null
|
||||
WEBAUTHN_RP_ID?: string | null
|
||||
WEBAUTHN_RP_ORIGINS?: string | null
|
||||
NIMBUS_OAUTH_CLIENT_SECRET?: string | null
|
||||
OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION?: boolean | null
|
||||
OAUTH_SERVER_AUTHORIZATION_PATH?: string | null
|
||||
|
||||
Reference in new issue
Block a user