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()
+ })
+ })
})