fix: ensure ACL and Security Headers dropdown selections persist correctly in Proxy Host form
This commit is contained in:
@@ -101,9 +101,12 @@ interface ProxyHostFormProps {
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFormProps) {
|
||||
type ProxyHostFormState = Partial<ProxyHost> & { addUptime?: boolean; uptimeInterval?: number; uptimeMaxRetries?: number }
|
||||
const [formData, setFormData] = useState<ProxyHostFormState>({
|
||||
function buildInitialFormData(host?: ProxyHost): Partial<ProxyHost> & {
|
||||
addUptime?: boolean
|
||||
uptimeInterval?: number
|
||||
uptimeMaxRetries?: number
|
||||
} {
|
||||
return {
|
||||
name: host?.name || '',
|
||||
domain_names: host?.domain_names || '',
|
||||
forward_scheme: host?.forward_scheme || 'http',
|
||||
@@ -123,7 +126,42 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
access_list_id: host?.access_list_id,
|
||||
security_header_profile_id: host?.security_header_profile_id,
|
||||
dns_provider_id: host?.dns_provider_id || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeNullableID(value: unknown): number | null | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(trimmed, 10)
|
||||
return Number.isNaN(parsed) ? null : parsed
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFormProps) {
|
||||
type ProxyHostFormState = Partial<ProxyHost> & { addUptime?: boolean; uptimeInterval?: number; uptimeMaxRetries?: number }
|
||||
const [formData, setFormData] = useState<ProxyHostFormState>(buildInitialFormData(host))
|
||||
|
||||
useEffect(() => {
|
||||
setFormData(buildInitialFormData(host))
|
||||
}, [host?.uuid])
|
||||
|
||||
// Charon internal IP for config helpers (previously CPMP internal IP)
|
||||
const [charonInternalIP, setCharonInternalIP] = useState<string>('')
|
||||
@@ -420,6 +458,10 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
// strip temporary uptime-only flags from payload by destructuring
|
||||
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 res = await onSubmit(payloadWithoutUptime)
|
||||
|
||||
// if user asked to add uptime, request server to sync monitors
|
||||
@@ -824,7 +866,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
|
||||
{/* Access Control List */}
|
||||
<AccessListSelector
|
||||
value={formData.access_list_id || null}
|
||||
value={formData.access_list_id ?? null}
|
||||
onChange={id => setFormData(prev => ({ ...prev, access_list_id: id }))}
|
||||
/>
|
||||
|
||||
@@ -836,9 +878,9 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
</label>
|
||||
|
||||
<Select
|
||||
value={String(formData.security_header_profile_id || 0)}
|
||||
value={formData.security_header_profile_id == null ? 'none' : String(formData.security_header_profile_id)}
|
||||
onValueChange={e => {
|
||||
const value = e === "0" ? null : parseInt(e) || null
|
||||
const value = e === 'none' ? null : normalizeNullableID(e)
|
||||
setFormData(prev => ({ ...prev, security_header_profile_id: value }))
|
||||
}}
|
||||
>
|
||||
@@ -846,7 +888,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">None (No Security Headers)</SelectItem>
|
||||
<SelectItem value="none">None (No Security Headers)</SelectItem>
|
||||
{securityProfiles
|
||||
?.filter(p => p.is_preset)
|
||||
.sort((a, b) => a.security_score - b.security_score)
|
||||
|
||||
@@ -410,4 +410,130 @@ describe('ProxyHostForm Dropdown Change Bug Fix', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('persists null to value transitions for ACL and security headers in edit flow', async () => {
|
||||
const user = userEvent.setup()
|
||||
const Wrapper = createWrapper()
|
||||
|
||||
const existingHostWithNulls: ProxyHost = {
|
||||
uuid: 'host-uuid-null-fields',
|
||||
name: 'Existing Null Fields',
|
||||
domain_names: 'existing-null.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 8080,
|
||||
ssl_forced: true,
|
||||
http2_support: true,
|
||||
hsts_enabled: true,
|
||||
hsts_subdomains: true,
|
||||
block_exploits: true,
|
||||
websocket_support: false,
|
||||
enable_standard_headers: true,
|
||||
application: 'none',
|
||||
advanced_config: '',
|
||||
enabled: true,
|
||||
locations: [],
|
||||
certificate_id: null,
|
||||
access_list_id: null,
|
||||
security_header_profile_id: null,
|
||||
dns_provider_id: null,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
}
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<ProxyHostForm host={existingHostWithNulls} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
const aclTrigger = screen.getByRole('combobox', { name: /Access Control List/i })
|
||||
await user.click(aclTrigger)
|
||||
await user.click(await screen.findByRole('option', { name: /Office Network/i }))
|
||||
|
||||
const headersTrigger = screen.getByRole('combobox', { name: /Security Headers/i })
|
||||
await user.click(headersTrigger)
|
||||
await user.click(await screen.findByRole('option', { name: /Strict Security/i }))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Save/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
access_list_id: 1,
|
||||
security_header_profile_id: 2,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('resets ACL/security header form state when editing target host changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const Wrapper = createWrapper()
|
||||
|
||||
const firstHost: ProxyHost = {
|
||||
uuid: 'host-uuid-first',
|
||||
name: 'First Host',
|
||||
domain_names: 'first.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 8080,
|
||||
ssl_forced: true,
|
||||
http2_support: true,
|
||||
hsts_enabled: true,
|
||||
hsts_subdomains: true,
|
||||
block_exploits: true,
|
||||
websocket_support: false,
|
||||
enable_standard_headers: true,
|
||||
application: 'none',
|
||||
advanced_config: '',
|
||||
enabled: true,
|
||||
locations: [],
|
||||
certificate_id: null,
|
||||
access_list_id: 1,
|
||||
security_header_profile_id: 1,
|
||||
dns_provider_id: null,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
}
|
||||
|
||||
const secondHost: ProxyHost = {
|
||||
...firstHost,
|
||||
uuid: 'host-uuid-second',
|
||||
name: 'Second Host',
|
||||
domain_names: 'second.example.com',
|
||||
access_list_id: null,
|
||||
security_header_profile_id: null,
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<Wrapper>
|
||||
<ProxyHostForm host={firstHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
// Mutate first host state in the form before switching targets.
|
||||
await user.click(screen.getByRole('combobox', { name: /Access Control List/i }))
|
||||
await user.click(await screen.findByRole('option', { name: /VPN Users/i }))
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: /Security Headers/i }))
|
||||
await user.click(await screen.findByRole('option', { name: /Strict Security/i }))
|
||||
|
||||
rerender(
|
||||
<Wrapper>
|
||||
<ProxyHostForm host={secondHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Save/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
access_list_id: null,
|
||||
security_header_profile_id: null,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user