/* Copyright (C) 2023-2026 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { zodResolver } from '@hookform/resolvers/zod' import { useEffect, useMemo, useRef } from 'react' import { useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import * as z from 'zod' import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' import { SettingsForm, SettingsSwitchContent, SettingsSwitchItem, } from '../components/settings-form-layout' import { SettingsPageFormActions } from '../components/settings-page-context' import { SettingsSection } from '../components/settings-section' import { useUpdateOption } from '../hooks/use-update-option' type AttachmentPreference = '' | 'platform' | 'cross-platform' type AttachmentSelectValue = 'none' | 'platform' | 'cross-platform' /** * Use a nested object so the dotted FormField `name` props line up with * react-hook-form's path semantics. Flat keys with dots cause the form state * to silently diverge from what zod validates on submit. */ const passkeySchema = z.object({ passkey: z.object({ enabled: z.boolean(), rp_display_name: z.string(), rp_id: z.string(), origins: z.string(), allow_insecure_origin: z.boolean(), user_verification: z.enum(['required', 'preferred', 'discouraged']), attachment_preference: z.enum(['none', 'platform', 'cross-platform']), }), }) type PasskeyFormInput = z.input type PasskeyFormValues = z.output type FlatPasskeyDefaults = { 'passkey.enabled': boolean 'passkey.rp_display_name': string 'passkey.rp_id': string 'passkey.origins': string 'passkey.allow_insecure_origin': boolean 'passkey.user_verification': 'required' | 'preferred' | 'discouraged' 'passkey.attachment_preference': AttachmentPreference } const toAttachmentSelectValue = ( value: AttachmentPreference ): AttachmentSelectValue => (value === '' ? 'none' : value) const fromAttachmentSelectValue = ( value: AttachmentSelectValue ): AttachmentPreference => (value === 'none' ? '' : value) const buildFormDefaults = ( defaults: FlatPasskeyDefaults ): PasskeyFormInput => ({ passkey: { enabled: defaults['passkey.enabled'], rp_display_name: defaults['passkey.rp_display_name'] ?? '', rp_id: defaults['passkey.rp_id'] ?? '', origins: (defaults['passkey.origins'] ?? '') .split(',') .map((origin) => origin.trim()) .filter(Boolean) .join('\n'), allow_insecure_origin: defaults['passkey.allow_insecure_origin'], user_verification: defaults['passkey.user_verification'], attachment_preference: toAttachmentSelectValue( defaults['passkey.attachment_preference'] ), }, }) const normalizeFormValues = ( values: PasskeyFormValues ): FlatPasskeyDefaults => ({ 'passkey.enabled': values.passkey.enabled, 'passkey.rp_display_name': values.passkey.rp_display_name, 'passkey.rp_id': values.passkey.rp_id, 'passkey.origins': values.passkey.origins .split('\n') .map((origin) => origin.trim()) .filter(Boolean) .join(','), 'passkey.allow_insecure_origin': values.passkey.allow_insecure_origin, 'passkey.user_verification': values.passkey.user_verification, 'passkey.attachment_preference': fromAttachmentSelectValue( values.passkey.attachment_preference ), }) interface PasskeySectionProps { defaultValues: FlatPasskeyDefaults } export function PasskeySection(props: PasskeySectionProps) { const { t } = useTranslation() const updateOption = useUpdateOption() const formDefaults = useMemo( () => buildFormDefaults(props.defaultValues), [props.defaultValues] ) const form = useForm({ resolver: zodResolver(passkeySchema), defaultValues: formDefaults, }) const baselineRef = useRef(props.defaultValues) const baselineSerializedRef = useRef( JSON.stringify(props.defaultValues) ) useEffect(() => { const serialized = JSON.stringify(props.defaultValues) if (serialized === baselineSerializedRef.current) return baselineRef.current = props.defaultValues baselineSerializedRef.current = serialized form.reset(buildFormDefaults(props.defaultValues)) }, [props.defaultValues, form]) const onSubmit = async (values: PasskeyFormValues) => { const normalized = normalizeFormValues(values) const changedKeys = ( Object.keys(normalized) as Array ).filter((key) => normalized[key] !== baselineRef.current[key]) if (changedKeys.length === 0) { toast.info(t('No changes to save')) return } for (const key of changedKeys) { await updateOption.mutateAsync({ key, value: normalized[key], }) } baselineRef.current = normalized baselineSerializedRef.current = JSON.stringify(normalized) form.reset(buildFormDefaults(normalized)) } return (
( {t('Enable Passkey')} {t( 'Allow users to register and sign in with Passkey (WebAuthn)' )} )} /> ( {t('Relying Party Display Name')} field.onChange(event.target.value)} name={field.name} onBlur={field.onBlur} ref={field.ref} /> {t( 'Human-readable name shown to users during Passkey prompts.' )} )} /> ( {t('Relying Party ID')} field.onChange(event.target.value)} name={field.name} onBlur={field.onBlur} ref={field.ref} /> {t( 'The effective domain for Passkey registration. Must match the current domain or be its parent domain.' )} )} /> ( {t('User Verification')} {t( 'Controls whether user verification (biometrics/PIN) is required during Passkey flows.' )} )} /> ( {t('Device Type Preference')} {t( 'Built-in: phone fingerprint/face, or Windows Hello; External: USB security key' )} )} /> ( {t('Allow Insecure Origins')} {t( 'Permit Passkey registration on non-HTTPS origins (only recommended for development)' )} )} /> ( {t('Allowed Origins')}