feat: implement bulk ACL application feature for proxy hosts
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { bulkUpdateACL } from '../proxyHosts';
|
||||
import type { BulkUpdateACLResponse } from '../proxyHosts';
|
||||
|
||||
// Mock the client module
|
||||
const mockPut = vi.fn();
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('proxyHosts bulk operations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('bulkUpdateACL', () => {
|
||||
it('should apply ACL to multiple hosts', async () => {
|
||||
const mockResponse: BulkUpdateACLResponse = {
|
||||
updated: 3,
|
||||
errors: [],
|
||||
};
|
||||
mockPut.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const hostUUIDs = ['uuid-1', 'uuid-2', 'uuid-3'];
|
||||
const accessListID = 42;
|
||||
const result = await bulkUpdateACL(hostUUIDs, accessListID);
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/proxy-hosts/bulk-update-acl', {
|
||||
host_uuids: hostUUIDs,
|
||||
access_list_id: accessListID,
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should remove ACL from hosts when accessListID is null', async () => {
|
||||
const mockResponse: BulkUpdateACLResponse = {
|
||||
updated: 2,
|
||||
errors: [],
|
||||
};
|
||||
mockPut.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const hostUUIDs = ['uuid-1', 'uuid-2'];
|
||||
const result = await bulkUpdateACL(hostUUIDs, null);
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/proxy-hosts/bulk-update-acl', {
|
||||
host_uuids: hostUUIDs,
|
||||
access_list_id: null,
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle partial failures', async () => {
|
||||
const mockResponse: BulkUpdateACLResponse = {
|
||||
updated: 1,
|
||||
errors: [
|
||||
{ uuid: 'invalid-uuid', error: 'proxy host not found' },
|
||||
],
|
||||
};
|
||||
mockPut.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const hostUUIDs = ['valid-uuid', 'invalid-uuid'];
|
||||
const accessListID = 10;
|
||||
const result = await bulkUpdateACL(hostUUIDs, accessListID);
|
||||
|
||||
expect(result.updated).toBe(1);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].uuid).toBe('invalid-uuid');
|
||||
});
|
||||
|
||||
it('should handle empty host list', async () => {
|
||||
const mockResponse: BulkUpdateACLResponse = {
|
||||
updated: 0,
|
||||
errors: [],
|
||||
};
|
||||
mockPut.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await bulkUpdateACL([], 5);
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/proxy-hosts/bulk-update-acl', {
|
||||
host_uuids: [],
|
||||
access_list_id: 5,
|
||||
});
|
||||
expect(result.updated).toBe(0);
|
||||
});
|
||||
|
||||
it('should propagate API errors', async () => {
|
||||
const error = new Error('Network error');
|
||||
mockPut.mockRejectedValue(error);
|
||||
|
||||
await expect(bulkUpdateACL(['uuid-1'], 1)).rejects.toThrow('Network error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -70,3 +70,24 @@ export const deleteProxyHost = async (uuid: string): Promise<void> => {
|
||||
export const testProxyHostConnection = async (host: string, port: number): Promise<void> => {
|
||||
await client.post('/proxy-hosts/test', { forward_host: host, forward_port: port });
|
||||
};
|
||||
|
||||
export interface BulkUpdateACLRequest {
|
||||
host_uuids: string[];
|
||||
access_list_id: number | null;
|
||||
}
|
||||
|
||||
export interface BulkUpdateACLResponse {
|
||||
updated: number;
|
||||
errors: { uuid: string; error: string }[];
|
||||
}
|
||||
|
||||
export const bulkUpdateACL = async (
|
||||
hostUUIDs: string[],
|
||||
accessListID: number | null
|
||||
): Promise<BulkUpdateACLResponse> => {
|
||||
const { data } = await client.put<BulkUpdateACLResponse>('/proxy-hosts/bulk-update-acl', {
|
||||
host_uuids: hostUUIDs,
|
||||
access_list_id: accessListID,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useProxyHosts } from '../useProxyHosts';
|
||||
import * as proxyHostsApi from '../../api/proxyHosts';
|
||||
|
||||
// Mock the API module
|
||||
vi.mock('../../api/proxyHosts');
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('useProxyHosts bulk operations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('bulkUpdateACL', () => {
|
||||
it('should apply ACL to multiple hosts', async () => {
|
||||
const mockResponse = {
|
||||
updated: 2,
|
||||
errors: [],
|
||||
};
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([]);
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
const hostUUIDs = ['uuid-1', 'uuid-2'];
|
||||
const accessListID = 5;
|
||||
|
||||
const response = await result.current.bulkUpdateACL(hostUUIDs, accessListID);
|
||||
|
||||
expect(proxyHostsApi.bulkUpdateACL).toHaveBeenCalledWith(hostUUIDs, accessListID);
|
||||
expect(response.updated).toBe(2);
|
||||
expect(response.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should remove ACL from hosts', async () => {
|
||||
const mockResponse = {
|
||||
updated: 1,
|
||||
errors: [],
|
||||
};
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([]);
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
const response = await result.current.bulkUpdateACL(['uuid-1'], null);
|
||||
|
||||
expect(proxyHostsApi.bulkUpdateACL).toHaveBeenCalledWith(['uuid-1'], null);
|
||||
expect(response.updated).toBe(1);
|
||||
});
|
||||
|
||||
it('should invalidate queries after successful bulk update', async () => {
|
||||
const mockHosts = [
|
||||
{
|
||||
uuid: 'uuid-1',
|
||||
name: 'Host 1',
|
||||
domain_names: 'host1.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: 'localhost',
|
||||
forward_port: 8001,
|
||||
ssl_forced: false,
|
||||
http2_support: false,
|
||||
hsts_enabled: false,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: true,
|
||||
websocket_support: false,
|
||||
application: 'none' as const,
|
||||
locations: [],
|
||||
enabled: true,
|
||||
access_list_id: null,
|
||||
certificate_id: null,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(proxyHostsApi.getProxyHosts)
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce(mockHosts);
|
||||
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockResolvedValue({
|
||||
updated: 1,
|
||||
errors: [],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.hosts).toEqual([]);
|
||||
|
||||
await result.current.bulkUpdateACL(['uuid-1'], 10);
|
||||
|
||||
// Query should be invalidated and refetch
|
||||
await waitFor(() => expect(result.current.hosts).toEqual(mockHosts));
|
||||
});
|
||||
|
||||
it('should handle bulk update errors', async () => {
|
||||
const error = new Error('Bulk update failed');
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([]);
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockRejectedValue(error);
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
await expect(result.current.bulkUpdateACL(['uuid-1'], 5)).rejects.toThrow(
|
||||
'Bulk update failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('should track bulk updating state', async () => {
|
||||
vi.mocked(proxyHostsApi.getProxyHosts).mockResolvedValue([]);
|
||||
vi.mocked(proxyHostsApi.bulkUpdateACL).mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve({ updated: 1, errors: [] }), 100))
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.isBulkUpdating).toBe(false);
|
||||
|
||||
const promise = result.current.bulkUpdateACL(['uuid-1'], 1);
|
||||
|
||||
await waitFor(() => expect(result.current.isBulkUpdating).toBe(true));
|
||||
|
||||
await promise;
|
||||
|
||||
await waitFor(() => expect(result.current.isBulkUpdating).toBe(false));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createProxyHost,
|
||||
updateProxyHost,
|
||||
deleteProxyHost,
|
||||
bulkUpdateACL,
|
||||
ProxyHost
|
||||
} from '../api/proxyHosts';
|
||||
|
||||
@@ -39,6 +40,14 @@ export function useProxyHosts() {
|
||||
},
|
||||
});
|
||||
|
||||
const bulkUpdateACLMutation = useMutation({
|
||||
mutationFn: ({ hostUUIDs, accessListID }: { hostUUIDs: string[]; accessListID: number | null }) =>
|
||||
bulkUpdateACL(hostUUIDs, accessListID),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
hosts: query.data || [],
|
||||
loading: query.isLoading,
|
||||
@@ -47,9 +56,12 @@ export function useProxyHosts() {
|
||||
createHost: createMutation.mutateAsync,
|
||||
updateHost: (uuid: string, data: Partial<ProxyHost>) => updateMutation.mutateAsync({ uuid, data }),
|
||||
deleteHost: deleteMutation.mutateAsync,
|
||||
bulkUpdateACL: (hostUUIDs: string[], accessListID: number | null) =>
|
||||
bulkUpdateACLMutation.mutateAsync({ hostUUIDs, accessListID }),
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isBulkUpdating: bulkUpdateACLMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Loader2, ExternalLink, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { Loader2, ExternalLink, AlertTriangle, ChevronUp, ChevronDown, CheckSquare, Square } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useProxyHosts } from '../hooks/useProxyHosts'
|
||||
import { useCertificates } from '../hooks/useCertificates'
|
||||
import { useAccessLists } from '../hooks/useAccessLists'
|
||||
import { getSettings } from '../api/settings'
|
||||
import type { ProxyHost } from '../api/proxyHosts'
|
||||
import type { AccessList } from '../api/accessLists'
|
||||
import ProxyHostForm from '../components/ProxyHostForm'
|
||||
import { Switch } from '../components/ui/Switch'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
type SortColumn = 'name' | 'domain' | 'forward'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
export default function ProxyHosts() {
|
||||
const { hosts, loading, isFetching, error, createHost, updateHost, deleteHost } = useProxyHosts()
|
||||
const { hosts, loading, isFetching, error, createHost, updateHost, deleteHost, bulkUpdateACL, isBulkUpdating } = useProxyHosts()
|
||||
const { certificates } = useCertificates()
|
||||
const { data: accessLists } = useAccessLists()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingHost, setEditingHost] = useState<ProxyHost | undefined>()
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('name')
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
|
||||
const [selectedHosts, setSelectedHosts] = useState<Set<string>>(new Set())
|
||||
const [showBulkACLModal, setShowBulkACLModal] = useState(false)
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
@@ -123,6 +129,45 @@ export default function ProxyHosts() {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleHostSelection = (uuid: string) => {
|
||||
setSelectedHosts(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(uuid)) {
|
||||
next.delete(uuid)
|
||||
} else {
|
||||
next.add(uuid)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedHosts.size === hosts.length) {
|
||||
setSelectedHosts(new Set())
|
||||
} else {
|
||||
setSelectedHosts(new Set(hosts.map(h => h.uuid)))
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkApplyACL = async (accessListID: number | null) => {
|
||||
const hostUUIDs = Array.from(selectedHosts)
|
||||
try {
|
||||
const result = await bulkUpdateACL(hostUUIDs, accessListID)
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
toast.error(`Updated ${result.updated} host(s), ${result.errors.length} failed`)
|
||||
} else {
|
||||
const action = accessListID ? 'applied to' : 'removed from'
|
||||
toast.success(`Access list ${action} ${result.updated} host(s)`)
|
||||
}
|
||||
|
||||
setSelectedHosts(new Set())
|
||||
setShowBulkACLModal(false)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to update hosts')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
@@ -130,12 +175,26 @@ export default function ProxyHosts() {
|
||||
<h1 className="text-3xl font-bold text-white">Proxy Hosts</h1>
|
||||
{isFetching && !loading && <Loader2 className="animate-spin text-blue-400" size={24} />}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="px-4 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Add Proxy Host
|
||||
</button>
|
||||
<div className="flex gap-3">
|
||||
{selectedHosts.size > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-400 text-sm">{selectedHosts.size} selected</span>
|
||||
<button
|
||||
onClick={() => setShowBulkACLModal(true)}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
|
||||
disabled={isBulkUpdating}
|
||||
>
|
||||
{isBulkUpdating ? 'Updating...' : 'Bulk Actions'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className="px-4 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Add Proxy Host
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -156,6 +215,19 @@ export default function ProxyHosts() {
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-900 border-b border-gray-800">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
<button
|
||||
onClick={toggleSelectAll}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
title={selectedHosts.size === hosts.length ? 'Deselect all' : 'Select all'}
|
||||
>
|
||||
{selectedHosts.size === hosts.length ? (
|
||||
<CheckSquare size={18} />
|
||||
) : (
|
||||
<Square size={18} />
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
onClick={() => handleSort('name')}
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider cursor-pointer hover:text-gray-200 transition-colors"
|
||||
@@ -197,6 +269,18 @@ export default function ProxyHosts() {
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{sortedHosts.map((host) => (
|
||||
<tr key={host.uuid} className="hover:bg-gray-900/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => toggleHostSelection(host.uuid)}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
{selectedHosts.has(host.uuid) ? (
|
||||
<CheckSquare size={18} className="text-blue-400" />
|
||||
) : (
|
||||
<Square size={18} />
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-white">
|
||||
{host.name || <span className="text-gray-500 italic">Unnamed</span>}
|
||||
@@ -325,6 +409,71 @@ export default function ProxyHosts() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bulk ACL Modal */}
|
||||
{showBulkACLModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={() => setShowBulkACLModal(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-dark-card border border-gray-800 rounded-lg p-6 max-w-md w-full mx-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-xl font-bold text-white mb-4">Apply Access List</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Applying to {selectedHosts.size} selected host(s)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Access List
|
||||
</label>
|
||||
<select
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value === 'remove') {
|
||||
if (confirm(`Remove access list from ${selectedHosts.size} host(s)?`)) {
|
||||
handleBulkApplyACL(null)
|
||||
}
|
||||
} else if (value !== '') {
|
||||
handleBulkApplyACL(parseInt(value, 10))
|
||||
}
|
||||
}}
|
||||
defaultValue=""
|
||||
disabled={isBulkUpdating}
|
||||
>
|
||||
<option value="">Select an access list...</option>
|
||||
<option value="remove" className="text-red-400">
|
||||
🚫 Remove Access List
|
||||
</option>
|
||||
<optgroup label="Available Access Lists">
|
||||
{accessLists
|
||||
?.filter((acl: AccessList) => acl.enabled)
|
||||
.map((acl: AccessList) => (
|
||||
<option key={acl.id} value={acl.id}>
|
||||
{acl.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowBulkACLModal(false)}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
|
||||
disabled={isBulkUpdating}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user