diff --git a/frontend/src/components/AccessListSelector.tsx b/frontend/src/components/AccessListSelector.tsx index 960b3157..05bed061 100644 --- a/frontend/src/components/AccessListSelector.tsx +++ b/frontend/src/components/AccessListSelector.tsx @@ -9,27 +9,83 @@ import { } from './ui/Select'; interface AccessListSelectorProps { - value: number | null; - onChange: (id: number | null) => void; + value: number | string | null; + onChange: (id: number | string | null) => void; +} + +function resolveAccessListToken(value: number | string | null | undefined): string { + if (value === null || value === undefined) { + return 'none'; + } + + if (typeof value === 'number') { + return `id:${value}`; + } + + const trimmed = value.trim(); + if (trimmed === '') { + return 'none'; + } + + if (trimmed.startsWith('id:') || trimmed.startsWith('uuid:')) { + return trimmed; + } + + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isNaN(parsed)) { + return `id:${parsed}`; + } + + return `uuid:${trimmed}`; +} + +function getOptionToken(acl: { id?: number; uuid?: string }): string | null { + if (typeof acl.id === 'number' && Number.isFinite(acl.id)) { + return `id:${acl.id}`; + } + + if (acl.uuid) { + return `uuid:${acl.uuid}`; + } + + return null; } export default function AccessListSelector({ value, onChange }: AccessListSelectorProps) { const { data: accessLists } = useAccessLists(); - const selectedACL = accessLists?.find((acl) => acl.id === value); + const selectedToken = resolveAccessListToken(value); + const selectedACL = accessLists?.find((acl) => getOptionToken(acl) === selectedToken); - // Convert between component's string-based value and the prop's number|null - const selectValue = value === null || value === undefined ? 'none' : String(value); + // Keep select value stable for both numeric-ID and UUID-only payload shapes. + const selectValue = selectedToken; const handleValueChange = (newValue: string) => { if (newValue === 'none') { onChange(null); - } else { - const numericId = parseInt(newValue, 10); - if (!isNaN(numericId)) { + return; + } + + if (newValue.startsWith('id:')) { + const numericId = Number.parseInt(newValue.slice(3), 10); + if (!Number.isNaN(numericId)) { onChange(numericId); } + return; } + + if (newValue.startsWith('uuid:')) { + onChange(newValue.slice(5)); + return; + } + + const numericId = Number.parseInt(newValue, 10); + if (!Number.isNaN(numericId)) { + onChange(numericId); + return; + } + + onChange(newValue); }; return ( @@ -49,11 +105,18 @@ export default function AccessListSelector({ value, onChange }: AccessListSelect No Access Control (Public) {accessLists ?.filter((acl) => acl.enabled) - .map((acl) => ( - - {acl.name} ({acl.type.replace('_', ' ')}) - - ))} + .map((acl) => { + const optionToken = getOptionToken(acl); + if (!optionToken) { + return null; + } + + return ( + + {acl.name} ({acl.type.replace('_', ' ')}) + + ); + })} diff --git a/frontend/src/components/ProxyHostForm.tsx b/frontend/src/components/ProxyHostForm.tsx index 756c6351..85afdd47 100644 --- a/frontend/src/components/ProxyHostForm.tsx +++ b/frontend/src/components/ProxyHostForm.tsx @@ -149,14 +149,76 @@ function normalizeNullableID(value: unknown): number | null | undefined { } const parsed = Number.parseInt(trimmed, 10) + return Number.isNaN(parsed) ? undefined : parsed + } + + return undefined +} + +function resolveSelectToken(value: number | string | null | undefined): string { + if (value === null || value === undefined) { + return 'none' + } + + if (typeof value === 'number') { + return `id:${value}` + } + + const trimmed = value.trim() + if (trimmed === '') { + return 'none' + } + + if (trimmed.startsWith('id:') || trimmed.startsWith('uuid:')) { + return trimmed + } + + const parsed = Number.parseInt(trimmed, 10) + if (!Number.isNaN(parsed)) { + return `id:${parsed}` + } + + return `uuid:${trimmed}` +} + +function resolveTokenToFormValue(value: string): number | string | null { + if (value === 'none') { + return null + } + + if (value.startsWith('id:')) { + const parsed = Number.parseInt(value.slice(3), 10) return Number.isNaN(parsed) ? null : parsed } + if (value.startsWith('uuid:')) { + return value.slice(5) + } + + const parsed = Number.parseInt(value, 10) + return Number.isNaN(parsed) ? value : parsed +} + +function getEntityToken(entity: { id?: number; uuid?: string }): string | null { + if (typeof entity.id === 'number' && Number.isFinite(entity.id)) { + return `id:${entity.id}` + } + + if (entity.uuid) { + return `uuid:${entity.uuid}` + } + return null } export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFormProps) { - type ProxyHostFormState = Partial & { addUptime?: boolean; uptimeInterval?: number; uptimeMaxRetries?: number } + type ProxyHostFormState = Omit, 'access_list_id' | 'security_header_profile_id'> & { + access_list_id?: number | string | null + security_header_profile_id?: number | string | null + addUptime?: boolean + uptimeInterval?: number + uptimeMaxRetries?: number + } const [formData, setFormData] = useState(buildInitialFormData(host)) useEffect(() => { @@ -459,10 +521,13 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor const { addUptime: _addUptime, uptimeInterval: _uptimeInterval, uptimeMaxRetries: _uptimeMaxRetries, ...payloadWithoutUptime } = payload as ProxyHostFormState void _addUptime; void _uptimeInterval; void _uptimeMaxRetries; - payloadWithoutUptime.access_list_id = normalizeNullableID(payloadWithoutUptime.access_list_id) - payloadWithoutUptime.security_header_profile_id = normalizeNullableID(payloadWithoutUptime.security_header_profile_id) + const submitPayload: Partial = { + ...payloadWithoutUptime, + access_list_id: normalizeNullableID(payloadWithoutUptime.access_list_id), + security_header_profile_id: normalizeNullableID(payloadWithoutUptime.security_header_profile_id), + } - const res = await onSubmit(payloadWithoutUptime) + const res = await onSubmit(submitPayload) // if user asked to add uptime, request server to sync monitors if (addUptime) { @@ -550,15 +615,15 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor // Try to apply the preset logic (auto-populate or prompt) tryApplyPreset(detectedPreset) - setFormData({ - ...formData, + setFormData(prev => ({ + ...prev, forward_host: host, forward_port: port, forward_scheme: 'http', domain_names: newDomainNames, application: detectedPreset, - websocket_support: needsWebsockets || formData.websocket_support, - }) + websocket_support: needsWebsockets || prev.websocket_support, + })) } } @@ -878,10 +943,12 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor {formData.security_header_profile_id && (() => { - const selected = securityProfiles?.find(p => p.id === formData.security_header_profile_id) + const selectedToken = resolveSelectToken(formData.security_header_profile_id) + const selected = securityProfiles?.find(p => getEntityToken(p) === selectedToken) if (!selected) return null return ( @@ -931,7 +1013,8 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor {/* Mobile App Compatibility Warning for Strict/Paranoid profiles */} {formData.security_header_profile_id && (() => { - const selected = securityProfiles?.find(p => p.id === formData.security_header_profile_id) + const selectedToken = resolveSelectToken(formData.security_header_profile_id) + const selected = securityProfiles?.find(p => getEntityToken(p) === selectedToken) if (!selected) return null const isRestrictive = selected.preset_type === 'strict' || selected.preset_type === 'paranoid' diff --git a/frontend/src/components/__tests__/ProxyHostForm-dropdown-changes.test.tsx b/frontend/src/components/__tests__/ProxyHostForm-dropdown-changes.test.tsx index 16f2713d..403f9379 100644 --- a/frontend/src/components/__tests__/ProxyHostForm-dropdown-changes.test.tsx +++ b/frontend/src/components/__tests__/ProxyHostForm-dropdown-changes.test.tsx @@ -6,6 +6,8 @@ import ProxyHostForm from '../ProxyHostForm' import type { ProxyHost } from '../../api/proxyHosts' import type { AccessList } from '../../api/accessLists' import type { SecurityHeaderProfile } from '../../api/securityHeaders' +import { useAccessLists } from '../../hooks/useAccessLists' +import { useSecurityHeaderProfiles } from '../../hooks/useSecurityHeaders' // Mock all required hooks vi.mock('../../hooks/useRemoteServers', () => ({ @@ -179,6 +181,18 @@ describe('ProxyHostForm Dropdown Change Bug Fix', () => { beforeEach(() => { mockOnSubmit = vi.fn<(data: Partial) => Promise>() mockOnCancel = vi.fn<() => void>() + + vi.mocked(useAccessLists).mockReturnValue({ + data: mockAccessLists, + isLoading: false, + error: null, + } as unknown as ReturnType) + + vi.mocked(useSecurityHeaderProfiles).mockReturnValue({ + data: mockSecurityProfiles, + isLoading: false, + error: null, + } as unknown as ReturnType) }) it('allows changing ACL selection after initial selection', async () => { @@ -536,4 +550,68 @@ describe('ProxyHostForm Dropdown Change Bug Fix', () => { ) }) }) + + it('persists ACL and security header selections with UUID-only option payloads', async () => { + const user = userEvent.setup() + const Wrapper = createWrapper() + + const uuidOnlyAccessLists = [ + { + ...mockAccessLists[0], + id: undefined, + uuid: 'acl-uuid-only', + name: 'UUID Office Network', + }, + ] + + const uuidOnlySecurityProfiles = [ + { + ...mockSecurityProfiles[0], + id: undefined, + uuid: 'profile-uuid-only', + name: 'UUID Basic Security', + }, + ] + + vi.mocked(useAccessLists).mockReturnValue({ + data: uuidOnlyAccessLists as unknown as AccessList[], + isLoading: false, + error: null, + } as unknown as ReturnType) + + vi.mocked(useSecurityHeaderProfiles).mockReturnValue({ + data: uuidOnlySecurityProfiles as unknown as SecurityHeaderProfile[], + isLoading: false, + error: null, + } as unknown as ReturnType) + + render( + + + + ) + + await user.type(screen.getByLabelText(/^Name/), 'UUID Test Service') + await user.type(screen.getByLabelText(/Domain Names/), 'test.com') + await user.type(screen.getByLabelText(/^Host$/), 'localhost') + await user.clear(screen.getByLabelText(/^Port$/)) + await user.type(screen.getByLabelText(/^Port$/), '8080') + + const aclTrigger = screen.getByRole('combobox', { name: /Access Control List/i }) + await user.click(aclTrigger) + await user.click(await screen.findByRole('option', { name: /UUID Office Network/i })) + + const headersTrigger = screen.getByRole('combobox', { name: /Security Headers/i }) + await user.click(headersTrigger) + await user.click(await screen.findByRole('option', { name: /UUID Basic Security/i })) + + expect(screen.getByRole('combobox', { name: /Access Control List/i })).toHaveTextContent('UUID Office Network') + expect(screen.getByRole('combobox', { name: /Security Headers/i })).toHaveTextContent('UUID Basic Security') + + await user.click(screen.getByRole('button', { name: /Save/i })) + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalled() + }) + }) })