diff --git a/web/default/src/components/status-badge.tsx b/web/default/src/components/status-badge.tsx index 1a12d1d2..6e3b1da7 100644 --- a/web/default/src/components/status-badge.tsx +++ b/web/default/src/components/status-badge.tsx @@ -79,7 +79,6 @@ const sizeMap = { lg: 'h-6 gap-1.5 px-2 text-xs leading-none', } as const - export interface StatusBadgeProps extends Omit< React.HTMLAttributes, 'children' diff --git a/web/default/src/features/channels/components/channels-columns.tsx b/web/default/src/features/channels/components/channels-columns.tsx index b39d398e..1676e6d6 100644 --- a/web/default/src/features/channels/components/channels-columns.tsx +++ b/web/default/src/features/channels/components/channels-columns.tsx @@ -638,14 +638,12 @@ export function useChannelsColumns(): ColumnDef[] { + } > - - {multiKeyTooltip} - + {multiKeyTooltip} )} @@ -654,7 +652,7 @@ export function useChannelsColumns(): ColumnDef[] { size='sm' copyable={false} showDot={false} - className='pl-1 gap-1' + className='gap-1 pl-1' > {icon} {typeName} diff --git a/web/default/src/features/system-settings/auth/oauth-section.tsx b/web/default/src/features/system-settings/auth/oauth-section.tsx index 31af1e15..741d3123 100644 --- a/web/default/src/features/system-settings/auth/oauth-section.tsx +++ b/web/default/src/features/system-settings/auth/oauth-section.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import * as z from 'zod' import axios from 'axios' import { useForm } from 'react-hook-form' @@ -46,129 +46,197 @@ import { SettingsPageFormActions } from '../components/settings-page-context' import { SettingsSection } from '../components/settings-section' import { useUpdateOption } from '../hooks/use-update-option' +/** + * react-hook-form 7 treats dotted `name` strings as nested paths. To keep + * form state, schema validation, and dirty tracking aligned, the + * `discord.*` and `oidc.*` fields are modeled as nested objects here and + * flattened back to dotted server keys only when persisting. + */ const oauthSchema = z.object({ GitHubOAuthEnabled: z.boolean(), - GitHubClientId: z.string().optional(), - GitHubClientSecret: z.string().optional(), - 'discord.enabled': z.boolean(), - 'discord.client_id': z.string().optional(), - 'discord.client_secret': z.string().optional(), - 'oidc.enabled': z.boolean(), - 'oidc.client_id': z.string().optional(), - 'oidc.client_secret': z.string().optional(), - 'oidc.well_known': z.string().optional(), - 'oidc.authorization_endpoint': z.string().optional(), - 'oidc.token_endpoint': z.string().optional(), - 'oidc.user_info_endpoint': z.string().optional(), + GitHubClientId: z.string(), + GitHubClientSecret: z.string(), + discord: z.object({ + enabled: z.boolean(), + client_id: z.string(), + client_secret: z.string(), + }), + oidc: z.object({ + enabled: z.boolean(), + client_id: z.string(), + client_secret: z.string(), + well_known: z.string(), + authorization_endpoint: z.string(), + token_endpoint: z.string(), + user_info_endpoint: z.string(), + }), TelegramOAuthEnabled: z.boolean(), - TelegramBotToken: z.string().optional(), - TelegramBotName: z.string().optional(), + TelegramBotToken: z.string(), + TelegramBotName: z.string(), LinuxDOOAuthEnabled: z.boolean(), - LinuxDOClientId: z.string().optional(), - LinuxDOClientSecret: z.string().optional(), - LinuxDOMinimumTrustLevel: z.string().optional(), + LinuxDOClientId: z.string(), + LinuxDOClientSecret: z.string(), + LinuxDOMinimumTrustLevel: z.string(), WeChatAuthEnabled: z.boolean(), - WeChatServerAddress: z.string().optional(), - WeChatServerToken: z.string().optional(), - WeChatAccountQRCodeImageURL: z.string().optional(), + WeChatServerAddress: z.string(), + WeChatServerToken: z.string(), + WeChatAccountQRCodeImageURL: z.string(), }) +type OAuthFormValues = z.infer + +type FlatOAuthDefaults = { + GitHubOAuthEnabled: boolean + GitHubClientId: string + GitHubClientSecret: string + 'discord.enabled': boolean + 'discord.client_id': string + 'discord.client_secret': string + 'oidc.enabled': boolean + 'oidc.client_id': string + 'oidc.client_secret': string + 'oidc.well_known': string + 'oidc.authorization_endpoint': string + 'oidc.token_endpoint': string + 'oidc.user_info_endpoint': string + TelegramOAuthEnabled: boolean + TelegramBotToken: string + TelegramBotName: string + LinuxDOOAuthEnabled: boolean + LinuxDOClientId: string + LinuxDOClientSecret: string + LinuxDOMinimumTrustLevel: string + WeChatAuthEnabled: boolean + WeChatServerAddress: string + WeChatServerToken: string + WeChatAccountQRCodeImageURL: string +} + const oauthTabContentClassName = 'grid min-w-0 gap-x-5 gap-y-6 lg:grid-cols-2 [&>[data-slot=form-item]]:min-w-0 lg:[&>[data-slot=form-item]:has([data-slot=switch])]:col-span-2' -type OAuthFormValues = z.infer +const buildFormDefaults = (defaults: FlatOAuthDefaults): OAuthFormValues => ({ + GitHubOAuthEnabled: defaults.GitHubOAuthEnabled, + GitHubClientId: defaults.GitHubClientId ?? '', + GitHubClientSecret: defaults.GitHubClientSecret ?? '', + discord: { + enabled: defaults['discord.enabled'], + client_id: defaults['discord.client_id'] ?? '', + client_secret: defaults['discord.client_secret'] ?? '', + }, + oidc: { + enabled: defaults['oidc.enabled'], + client_id: defaults['oidc.client_id'] ?? '', + client_secret: defaults['oidc.client_secret'] ?? '', + well_known: defaults['oidc.well_known'] ?? '', + authorization_endpoint: defaults['oidc.authorization_endpoint'] ?? '', + token_endpoint: defaults['oidc.token_endpoint'] ?? '', + user_info_endpoint: defaults['oidc.user_info_endpoint'] ?? '', + }, + TelegramOAuthEnabled: defaults.TelegramOAuthEnabled, + TelegramBotToken: defaults.TelegramBotToken ?? '', + TelegramBotName: defaults.TelegramBotName ?? '', + LinuxDOOAuthEnabled: defaults.LinuxDOOAuthEnabled, + LinuxDOClientId: defaults.LinuxDOClientId ?? '', + LinuxDOClientSecret: defaults.LinuxDOClientSecret ?? '', + LinuxDOMinimumTrustLevel: defaults.LinuxDOMinimumTrustLevel ?? '', + WeChatAuthEnabled: defaults.WeChatAuthEnabled, + WeChatServerAddress: defaults.WeChatServerAddress ?? '', + WeChatServerToken: defaults.WeChatServerToken ?? '', + WeChatAccountQRCodeImageURL: defaults.WeChatAccountQRCodeImageURL ?? '', +}) + +const normalizeFormValues = (values: OAuthFormValues): FlatOAuthDefaults => ({ + GitHubOAuthEnabled: values.GitHubOAuthEnabled, + GitHubClientId: values.GitHubClientId, + GitHubClientSecret: values.GitHubClientSecret, + 'discord.enabled': values.discord.enabled, + 'discord.client_id': values.discord.client_id, + 'discord.client_secret': values.discord.client_secret, + 'oidc.enabled': values.oidc.enabled, + 'oidc.client_id': values.oidc.client_id, + 'oidc.client_secret': values.oidc.client_secret, + 'oidc.well_known': values.oidc.well_known, + 'oidc.authorization_endpoint': values.oidc.authorization_endpoint, + 'oidc.token_endpoint': values.oidc.token_endpoint, + 'oidc.user_info_endpoint': values.oidc.user_info_endpoint, + TelegramOAuthEnabled: values.TelegramOAuthEnabled, + TelegramBotToken: values.TelegramBotToken, + TelegramBotName: values.TelegramBotName, + LinuxDOOAuthEnabled: values.LinuxDOOAuthEnabled, + LinuxDOClientId: values.LinuxDOClientId, + LinuxDOClientSecret: values.LinuxDOClientSecret, + LinuxDOMinimumTrustLevel: values.LinuxDOMinimumTrustLevel, + WeChatAuthEnabled: values.WeChatAuthEnabled, + WeChatServerAddress: values.WeChatServerAddress, + WeChatServerToken: values.WeChatServerToken, + WeChatAccountQRCodeImageURL: values.WeChatAccountQRCodeImageURL, +}) type OAuthSectionProps = { - defaultValues: OAuthFormValues + defaultValues: FlatOAuthDefaults } -export function OAuthSection({ defaultValues }: OAuthSectionProps) { +export function OAuthSection(props: OAuthSectionProps) { const { t } = useTranslation() const updateOption = useUpdateOption() const [activeTab, setActiveTab] = useState('github') - // Normalize empty strings for optional fields (only at mount) - const normalizedDefaults: OAuthFormValues = { - ...defaultValues, - GitHubClientId: defaultValues.GitHubClientId ?? '', - GitHubClientSecret: defaultValues.GitHubClientSecret ?? '', - 'discord.client_id': defaultValues['discord.client_id'] ?? '', - 'discord.client_secret': defaultValues['discord.client_secret'] ?? '', - 'oidc.client_id': defaultValues['oidc.client_id'] ?? '', - 'oidc.client_secret': defaultValues['oidc.client_secret'] ?? '', - 'oidc.well_known': defaultValues['oidc.well_known'] ?? '', - 'oidc.authorization_endpoint': - defaultValues['oidc.authorization_endpoint'] ?? '', - 'oidc.token_endpoint': defaultValues['oidc.token_endpoint'] ?? '', - 'oidc.user_info_endpoint': defaultValues['oidc.user_info_endpoint'] ?? '', - TelegramBotToken: defaultValues.TelegramBotToken ?? '', - TelegramBotName: defaultValues.TelegramBotName ?? '', - LinuxDOClientId: defaultValues.LinuxDOClientId ?? '', - LinuxDOClientSecret: defaultValues.LinuxDOClientSecret ?? '', - LinuxDOMinimumTrustLevel: defaultValues.LinuxDOMinimumTrustLevel ?? '', - WeChatServerAddress: defaultValues.WeChatServerAddress ?? '', - WeChatServerToken: defaultValues.WeChatServerToken ?? '', - WeChatAccountQRCodeImageURL: - defaultValues.WeChatAccountQRCodeImageURL ?? '', - } + const formDefaults = useMemo( + () => buildFormDefaults(props.defaultValues), + [props.defaultValues] + ) const form = useForm({ resolver: zodResolver(oauthSchema), - defaultValues: normalizedDefaults, + defaultValues: formDefaults, }) - const onSubmit = async () => { - // Get raw form values directly - // React Hook Form treats "oidc.xxx" as nested paths, so we need to flatten - const rawData = form.getValues() as Record + const baselineRef = useRef(props.defaultValues) + const baselineSerializedRef = useRef( + JSON.stringify(props.defaultValues) + ) - // Flatten nested oidc object back to dot notation keys - const flattenedData: Record = {} + 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]) - Object.entries(rawData).forEach(([key, value]) => { + const onSubmit = async (values: OAuthFormValues) => { + let finalValues = values + + if (values.oidc.well_known && values.oidc.well_known.trim() !== '') { + const wellKnown = values.oidc.well_known.trim() if ( - (key === 'oidc' || key === 'discord') && - typeof value === 'object' && - value !== null - ) { - // React Hook Form auto-nested these fields, flatten them back - Object.entries(value as Record).forEach( - ([nestedKey, nestedValue]) => { - flattenedData[`${key}.${nestedKey}`] = nestedValue - } - ) - } else { - flattenedData[key] = value - } - }) - - const finalData = flattenedData as OAuthFormValues - - if (finalData['oidc.well_known'] && finalData['oidc.well_known'] !== '') { - if ( - !finalData['oidc.well_known'].startsWith('http://') && - !finalData['oidc.well_known'].startsWith('https://') + !wellKnown.startsWith('http://') && + !wellKnown.startsWith('https://') ) { toast.error(t('Well-Known URL must start with http:// or https://')) return } try { - const res = await axios.create().get(finalData['oidc.well_known']) + const res = await axios.create().get(wellKnown) const authEndpoint = res.data['authorization_endpoint'] || '' const tokenEndpoint = res.data['token_endpoint'] || '' const userInfoEndpoint = res.data['userinfo_endpoint'] || '' - finalData['oidc.authorization_endpoint'] = authEndpoint - finalData['oidc.token_endpoint'] = tokenEndpoint - finalData['oidc.user_info_endpoint'] = userInfoEndpoint + finalValues = { + ...values, + oidc: { + ...values.oidc, + authorization_endpoint: authEndpoint, + token_endpoint: tokenEndpoint, + user_info_endpoint: userInfoEndpoint, + }, + } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - form.setValue('oidc.authorization_endpoint' as any, authEndpoint) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - form.setValue('oidc.token_endpoint' as any, tokenEndpoint) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - form.setValue('oidc.user_info_endpoint' as any, userInfoEndpoint) + form.setValue('oidc.authorization_endpoint', authEndpoint) + form.setValue('oidc.token_endpoint', tokenEndpoint) + form.setValue('oidc.user_info_endpoint', userInfoEndpoint) toast.success(t('OIDC configuration fetched successfully')) } catch (err) { @@ -183,73 +251,30 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { } } - // Find changed fields by comparing to initial values - const updates = Object.entries(finalData).filter( - ([key, value]) => - value !== normalizedDefaults[key as keyof OAuthFormValues] - ) + const normalized = normalizeFormValues(finalValues) + const changedKeys = ( + Object.keys(normalized) as Array + ).filter((key) => normalized[key] !== baselineRef.current[key]) - if (updates.length === 0) { + if (changedKeys.length === 0) { toast.info(t('No changes to save')) return } - // Save all changed fields - for (const [key, value] of updates) { - await updateOption.mutateAsync({ key, value: value ?? '' }) + for (const key of changedKeys) { + await updateOption.mutateAsync({ + key, + value: normalized[key], + }) } - // Reset form dirty state after successful save - form.reset(finalData) + baselineRef.current = normalized + baselineSerializedRef.current = JSON.stringify(normalized) + form.reset(buildFormDefaults(normalized)) } const handleReset = () => { - // React Hook Form auto-nests 'oidc.xxx' fields into { oidc: { xxx: value } } - // So we need to pass the same structure when resetting - const currentValues = form.getValues() as Record - - // Create reset values matching RHF's internal structure - const resetValues = { ...currentValues } - - // Update nested oidc fields - if (resetValues.oidc && typeof resetValues.oidc === 'object') { - Object.keys(resetValues.oidc as Record).forEach( - (key) => { - const flatKey = `oidc.${key}` as keyof typeof normalizedDefaults - if (flatKey in normalizedDefaults) { - ;(resetValues.oidc as Record)[key] = - normalizedDefaults[flatKey] - } - } - ) - } - - // Update nested discord fields - if (resetValues.discord && typeof resetValues.discord === 'object') { - Object.keys(resetValues.discord as Record).forEach( - (key) => { - const flatKey = `discord.${key}` as keyof typeof normalizedDefaults - if (flatKey in normalizedDefaults) { - ;(resetValues.discord as Record)[key] = - normalizedDefaults[flatKey] - } - } - ) - } - - // Update top-level fields - Object.keys(resetValues).forEach((key) => { - if (key !== 'oidc' && key in normalizedDefaults) { - resetValues[key] = - normalizedDefaults[key as keyof typeof normalizedDefaults] - } - }) - - form.reset(resetValues, { - keepDirty: false, - keepDirtyValues: false, - keepErrors: false, - }) + form.reset(buildFormDefaults(baselineRef.current)) toast.success(t('Form reset to saved values')) } @@ -310,7 +335,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { + field.onChange(event.target.value) + } + name={field.name} + onBlur={field.onBlur} + ref={field.ref} /> @@ -329,7 +360,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { type='password' placeholder={t('Your GitHub OAuth Client Secret')} autoComplete='new-password' - {...field} + value={field.value ?? ''} + onChange={(event) => + field.onChange(event.target.value) + } + name={field.name} + onBlur={field.onBlur} + ref={field.ref} /> @@ -362,8 +399,7 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { ( {t('Client ID')} @@ -371,7 +407,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { + field.onChange(event.target.value) + } + name={field.name} + onBlur={field.onBlur} + ref={field.ref} /> @@ -390,7 +432,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { type='password' placeholder={t('Your Discord OAuth Client Secret')} autoComplete='new-password' - {...field} + value={field.value ?? ''} + onChange={(event) => + field.onChange(event.target.value) + } + name={field.name} + onBlur={field.onBlur} + ref={field.ref} /> @@ -423,8 +471,7 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { ( {t('Client ID')} @@ -432,7 +479,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { + field.onChange(event.target.value) + } + name={field.name} + onBlur={field.onBlur} + ref={field.ref} /> @@ -451,7 +504,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { type='password' placeholder={t('OIDC Client Secret')} autoComplete='new-password' - {...field} + value={field.value ?? ''} + onChange={(event) => + field.onChange(event.target.value) + } + name={field.name} + onBlur={field.onBlur} + ref={field.ref} /> @@ -471,7 +530,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { 'https://provider.com/.well-known/openid-configuration' )} autoComplete='off' - {...field} + value={field.value ?? ''} + onChange={(event) => + field.onChange(event.target.value) + } + name={field.name} + onBlur={field.onBlur} + ref={field.ref} /> @@ -494,7 +559,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { + field.onChange(event.target.value) + } + name={field.name} + onBlur={field.onBlur} + ref={field.ref} /> @@ -512,7 +583,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { + field.onChange(event.target.value) + } + name={field.name} + onBlur={field.onBlur} + ref={field.ref} /> @@ -532,7 +609,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { + field.onChange(event.target.value) + } + name={field.name} + onBlur={field.onBlur} + ref={field.ref} /> @@ -577,7 +660,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { type='password' placeholder={t('Your Telegram Bot Token')} autoComplete='new-password' - {...field} + value={field.value ?? ''} + onChange={(event) => + field.onChange(event.target.value) + } + name={field.name} + onBlur={field.onBlur} + ref={field.ref} /> @@ -595,7 +684,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { + field.onChange(event.target.value) + } + name={field.name} + onBlur={field.onBlur} + ref={field.ref} /> @@ -636,7 +731,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { + field.onChange(event.target.value) + } + name={field.name} + onBlur={field.onBlur} + ref={field.ref} /> @@ -655,7 +756,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { type='password' placeholder={t('LinuxDO Client Secret')} autoComplete='new-password' - {...field} + value={field.value ?? ''} + onChange={(event) => + field.onChange(event.target.value) + } + name={field.name} + onBlur={field.onBlur} + ref={field.ref} /> @@ -670,7 +777,17 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { {t('Minimum Trust Level')} - + + field.onChange(event.target.value) + } + name={field.name} + onBlur={field.onBlur} + ref={field.ref} + /> {t('Minimum LinuxDO trust level required')} @@ -713,7 +830,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { + field.onChange(event.target.value) + } + name={field.name} + onBlur={field.onBlur} + ref={field.ref} /> @@ -732,7 +855,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { type='password' placeholder={t('Server Token')} autoComplete='new-password' - {...field} + value={field.value ?? ''} + onChange={(event) => + field.onChange(event.target.value) + } + name={field.name} + onBlur={field.onBlur} + ref={field.ref} /> @@ -750,7 +879,13 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { + field.onChange(event.target.value) + } + name={field.name} + onBlur={field.onBlur} + ref={field.ref} /> diff --git a/web/default/src/features/system-settings/auth/passkey-section.tsx b/web/default/src/features/system-settings/auth/passkey-section.tsx index 873302c8..ec889ee9 100644 --- a/web/default/src/features/system-settings/auth/passkey-section.tsx +++ b/web/default/src/features/system-settings/auth/passkey-section.tsx @@ -16,11 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useMemo } from 'react' +import { useEffect, useMemo, useRef } from 'react' import * as z from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' import { Form, FormControl, @@ -48,116 +49,139 @@ import { } from '../components/settings-form-layout' import { SettingsPageFormActions } from '../components/settings-page-context' import { SettingsSection } from '../components/settings-section' -import { useResetForm } from '../hooks/use-reset-form' 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.enabled': z.boolean(), - 'passkey.rp_display_name': z.string(), - 'passkey.rp_id': z.string(), - 'passkey.origins': z.string(), - 'passkey.allow_insecure_origin': z.boolean(), - 'passkey.user_verification': z.enum(['required', 'preferred', 'discouraged']), - 'passkey.attachment_preference': z.enum([ - 'none', - 'platform', - 'cross-platform', - ]), + 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 PasskeyFormValues = z.infer +type PasskeyFormInput = z.input +type PasskeyFormValues = z.output -interface PasskeySectionProps { - defaultValues: PasskeyFormValues +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 } -export function PasskeySection({ defaultValues }: PasskeySectionProps) { +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( - () => ({ - ...defaultValues, - 'passkey.origins': (defaultValues['passkey.origins'] as string) - .split(',') - .map((origin: string) => origin.trim()) - .filter(Boolean) - .join('\n'), - 'passkey.attachment_preference': - (defaultValues['passkey.attachment_preference'] as string) === '' - ? 'none' - : (defaultValues['passkey.attachment_preference'] as - | 'platform' - | 'cross-platform'), - }), - [defaultValues] + const formDefaults = useMemo( + () => buildFormDefaults(props.defaultValues), + [props.defaultValues] ) - const form = useForm({ + const form = useForm({ resolver: zodResolver(passkeySchema), defaultValues: formDefaults, }) - useResetForm(form, formDefaults) + const baselineRef = useRef(props.defaultValues) + const baselineSerializedRef = useRef( + JSON.stringify(props.defaultValues) + ) - const onSubmit = async () => { - const rawData = form.getValues() as Record - const flattenedEntries: Array< - [keyof PasskeyFormValues, PasskeyFormValues[keyof PasskeyFormValues]] - > = [] + 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]) - Object.entries(rawData).forEach(([key, value]) => { - if (key === 'passkey' && value && typeof value === 'object') { - Object.entries(value as Record).forEach( - ([nestedKey, nestedValue]) => { - flattenedEntries.push([ - `passkey.${nestedKey}` as keyof PasskeyFormValues, - nestedValue as PasskeyFormValues[keyof PasskeyFormValues], - ]) - } - ) - } else { - flattenedEntries.push([ - key as keyof PasskeyFormValues, - value as PasskeyFormValues[keyof PasskeyFormValues], - ]) - } - }) + const onSubmit = async (values: PasskeyFormValues) => { + const normalized = normalizeFormValues(values) + const changedKeys = ( + Object.keys(normalized) as Array + ).filter((key) => normalized[key] !== baselineRef.current[key]) - const data = Object.fromEntries(flattenedEntries) as PasskeyFormValues - const updates: Array<{ key: string; value: string | boolean }> = [] - - Object.entries(data).forEach(([key, value]) => { - if (key === 'passkey.origins') { - const processed = (value as string) - .split('\n') - .map((origin: string) => origin.trim()) - .filter(Boolean) - .join(',') - const currentDefault = defaultValues['passkey.origins'] as string - if (processed !== currentDefault) { - updates.push({ key, value: processed }) - } - } else if (key === 'passkey.attachment_preference') { - const attachmentPreference = - value as PasskeyFormValues['passkey.attachment_preference'] - const incoming = - attachmentPreference === 'none' ? '' : attachmentPreference - const currentDefault = - defaultValues['passkey.attachment_preference'] === 'none' - ? '' - : defaultValues['passkey.attachment_preference'] - if (incoming !== currentDefault) { - updates.push({ key, value: incoming }) - } - } else if (value !== defaultValues[key as keyof PasskeyFormValues]) { - updates.push({ key, value }) - } - }) - - for (const update of updates) { - await updateOption.mutateAsync(update) + 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 ( @@ -200,8 +224,11 @@ export function PasskeySection({ defaultValues }: PasskeySectionProps) { field.onChange(event.target.value)} + name={field.name} + onBlur={field.onBlur} + ref={field.ref} /> @@ -223,8 +250,11 @@ export function PasskeySection({ defaultValues }: PasskeySectionProps) { field.onChange(event.target.value)} + name={field.name} + onBlur={field.onBlur} + ref={field.ref} /> @@ -356,8 +386,11 @@ export function PasskeySection({ defaultValues }: PasskeySectionProps) {