feat: implement access list management with CRUD operations and IP testing

- Added API integration for access lists including listing, creating, updating, deleting, and testing IPs against access lists.
- Created AccessListForm component for creating and editing access lists with validation.
- Developed AccessListSelector component for selecting access lists with detailed display of selected ACL.
- Implemented hooks for managing access lists and handling API interactions.
- Added tests for AccessListSelector and useAccessLists hooks to ensure functionality.
- Enhanced AccessLists page with UI for managing access lists, including create, edit, delete, and test IP features.
This commit is contained in:
Wikid82
2025-11-27 08:55:29 +00:00
parent 486c9b40c1
commit 429de10f0f
30 changed files with 4138 additions and 35 deletions
+2
View File
@@ -21,6 +21,7 @@ const Tasks = lazy(() => import('./pages/Tasks'))
const Logs = lazy(() => import('./pages/Logs'))
const Domains = lazy(() => import('./pages/Domains'))
const Security = lazy(() => import('./pages/Security'))
const AccessLists = lazy(() => import('./pages/AccessLists'))
const Uptime = lazy(() => import('./pages/Uptime'))
const Notifications = lazy(() => import('./pages/Notifications'))
const Login = lazy(() => import('./pages/Login'))
@@ -49,6 +50,7 @@ export default function App() {
<Route path="domains" element={<Domains />} />
<Route path="certificates" element={<Certificates />} />
<Route path="security" element={<Security />} />
<Route path="access-lists" element={<AccessLists />} />
<Route path="uptime" element={<Uptime />} />
<Route path="notifications" element={<Notifications />} />
<Route path="import" element={<ImportCaddy />} />
@@ -0,0 +1,179 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { accessListsApi } from '../accessLists';
import client from '../client';
import type { AccessList } from '../accessLists';
// Mock the client module
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}));
describe('accessListsApi', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('list', () => {
it('should fetch all access lists', async () => {
const mockLists: AccessList[] = [
{
id: 1,
uuid: 'test-uuid',
name: 'Test ACL',
description: 'Test description',
type: 'whitelist',
ip_rules: '[{"cidr":"192.168.1.0/24"}]',
country_codes: '',
local_network_only: false,
enabled: true,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
];
vi.mocked(client.get).mockResolvedValueOnce({ data: mockLists });
const result = await accessListsApi.list();
expect(client.get).toHaveBeenCalledWith<[string]>('/access-lists');
expect(result).toEqual(mockLists);
});
});
describe('get', () => {
it('should fetch access list by ID', async () => {
const mockList: AccessList = {
id: 1,
uuid: 'test-uuid',
name: 'Test ACL',
description: 'Test description',
type: 'whitelist',
ip_rules: '[{"cidr":"192.168.1.0/24"}]',
country_codes: '',
local_network_only: false,
enabled: true,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
vi.mocked(client.get).mockResolvedValueOnce({ data: mockList });
const result = await accessListsApi.get(1);
expect(client.get).toHaveBeenCalledWith<[string]>('/access-lists/1');
expect(result).toEqual(mockList);
});
});
describe('create', () => {
it('should create a new access list', async () => {
const newList = {
name: 'New ACL',
description: 'New description',
type: 'whitelist' as const,
ip_rules: '[{"cidr":"10.0.0.0/8"}]',
enabled: true,
};
const mockResponse: AccessList = {
id: 1,
uuid: 'new-uuid',
...newList,
country_codes: '',
local_network_only: false,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
vi.mocked(client.post).mockResolvedValueOnce({ data: mockResponse });
const result = await accessListsApi.create(newList);
expect(client.post).toHaveBeenCalledWith<[string, typeof newList]>('/access-lists', newList);
expect(result).toEqual(mockResponse);
});
});
describe('update', () => {
it('should update an access list', async () => {
const updates = {
name: 'Updated ACL',
enabled: false,
};
const mockResponse: AccessList = {
id: 1,
uuid: 'test-uuid',
name: 'Updated ACL',
description: 'Test description',
type: 'whitelist',
ip_rules: '[{"cidr":"192.168.1.0/24"}]',
country_codes: '',
local_network_only: false,
enabled: false,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
vi.mocked(client.put).mockResolvedValueOnce({ data: mockResponse });
const result = await accessListsApi.update(1, updates);
expect(client.put).toHaveBeenCalledWith<[string, typeof updates]>('/access-lists/1', updates);
expect(result).toEqual(mockResponse);
});
});
describe('delete', () => {
it('should delete an access list', async () => {
vi.mocked(client.delete).mockResolvedValueOnce({ data: undefined });
await accessListsApi.delete(1);
expect(client.delete).toHaveBeenCalledWith<[string]>('/access-lists/1');
});
});
describe('testIP', () => {
it('should test an IP against an access list', async () => {
const mockResponse = {
allowed: true,
reason: 'IP matches whitelist rule',
};
vi.mocked(client.post).mockResolvedValueOnce({ data: mockResponse });
const result = await accessListsApi.testIP(1, '192.168.1.100');
expect(client.post).toHaveBeenCalledWith<[string, { ip_address: string }]>('/access-lists/1/test', {
ip_address: '192.168.1.100',
});
expect(result).toEqual(mockResponse);
});
});
describe('getTemplates', () => {
it('should fetch access list templates', async () => {
const mockTemplates = [
{
name: 'Private Networks',
description: 'RFC1918 private networks',
type: 'whitelist' as const,
ip_rules: '[{"cidr":"10.0.0.0/8"},{"cidr":"172.16.0.0/12"},{"cidr":"192.168.0.0/16"}]',
},
];
vi.mocked(client.get).mockResolvedValueOnce({ data: mockTemplates });
const result = await accessListsApi.getTemplates();
expect(client.get).toHaveBeenCalledWith<[string]>('/access-lists/templates');
expect(result).toEqual(mockTemplates);
});
});
});
+106
View File
@@ -0,0 +1,106 @@
import client from './client';
export interface AccessListRule {
cidr: string;
description: string;
}
export interface AccessList {
id: number;
uuid: string;
name: string;
description: string;
type: 'whitelist' | 'blacklist' | 'geo_whitelist' | 'geo_blacklist';
ip_rules: string; // JSON string of AccessListRule[]
country_codes: string; // Comma-separated
local_network_only: boolean;
enabled: boolean;
created_at: string;
updated_at: string;
}
export interface CreateAccessListRequest {
name: string;
description?: string;
type: 'whitelist' | 'blacklist' | 'geo_whitelist' | 'geo_blacklist';
ip_rules?: string;
country_codes?: string;
local_network_only?: boolean;
enabled?: boolean;
}
export interface TestIPRequest {
ip_address: string;
}
export interface TestIPResponse {
allowed: boolean;
reason: string;
}
export interface AccessListTemplate {
name: string;
description: string;
type: string;
local_network_only?: boolean;
country_codes?: string;
}
export const accessListsApi = {
/**
* Fetch all access lists
*/
async list(): Promise<AccessList[]> {
const response = await client.get<AccessList[]>('/access-lists');
return response.data;
},
/**
* Get a single access list by ID
*/
async get(id: number): Promise<AccessList> {
const response = await client.get<AccessList>(`/access-lists/${id}`);
return response.data;
},
/**
* Create a new access list
*/
async create(data: CreateAccessListRequest): Promise<AccessList> {
const response = await client.post<AccessList>('/access-lists', data);
return response.data;
},
/**
* Update an existing access list
*/
async update(id: number, data: Partial<CreateAccessListRequest>): Promise<AccessList> {
const response = await client.put<AccessList>(`/access-lists/${id}`, data);
return response.data;
},
/**
* Delete an access list
*/
async delete(id: number): Promise<void> {
await client.delete(`/access-lists/${id}`);
},
/**
* Test if an IP address would be allowed/blocked
*/
async testIP(id: number, ipAddress: string): Promise<TestIPResponse> {
const response = await client.post<TestIPResponse>(`/access-lists/${id}/test`, {
ip_address: ipAddress,
});
return response.data;
},
/**
* Get predefined ACL templates
*/
async getTemplates(): Promise<AccessListTemplate[]> {
const response = await client.get<AccessListTemplate[]>('/access-lists/templates');
return response.data;
},
};
+1
View File
@@ -38,6 +38,7 @@ export interface ProxyHost {
enabled: boolean;
certificate_id?: number | null;
certificate?: Certificate | null;
access_list_id?: number | null;
created_at: string;
updated_at: string;
}
+340
View File
@@ -0,0 +1,340 @@
import { useState } from 'react';
import { Button } from './ui/Button';
import { Input } from './ui/Input';
import { Switch } from './ui/Switch';
import { X, Plus, ExternalLink } from 'lucide-react';
import type { AccessList, AccessListRule } from '../api/accessLists';
interface AccessListFormProps {
initialData?: AccessList;
onSubmit: (data: AccessListFormData) => void;
onCancel: () => void;
isLoading?: boolean;
}
export interface AccessListFormData {
name: string;
description: string;
type: 'whitelist' | 'blacklist' | 'geo_whitelist' | 'geo_blacklist';
ip_rules: string;
country_codes: string;
local_network_only: boolean;
enabled: boolean;
}
const COUNTRIES = [
{ code: 'US', name: 'United States' },
{ code: 'CA', name: 'Canada' },
{ code: 'GB', name: 'United Kingdom' },
{ code: 'DE', name: 'Germany' },
{ code: 'FR', name: 'France' },
{ code: 'IT', name: 'Italy' },
{ code: 'ES', name: 'Spain' },
{ code: 'NL', name: 'Netherlands' },
{ code: 'BE', name: 'Belgium' },
{ code: 'SE', name: 'Sweden' },
{ code: 'NO', name: 'Norway' },
{ code: 'DK', name: 'Denmark' },
{ code: 'FI', name: 'Finland' },
{ code: 'PL', name: 'Poland' },
{ code: 'CZ', name: 'Czech Republic' },
{ code: 'AT', name: 'Austria' },
{ code: 'CH', name: 'Switzerland' },
{ code: 'AU', name: 'Australia' },
{ code: 'NZ', name: 'New Zealand' },
{ code: 'JP', name: 'Japan' },
{ code: 'CN', name: 'China' },
{ code: 'IN', name: 'India' },
{ code: 'BR', name: 'Brazil' },
{ code: 'MX', name: 'Mexico' },
{ code: 'AR', name: 'Argentina' },
{ code: 'RU', name: 'Russia' },
{ code: 'UA', name: 'Ukraine' },
{ code: 'TR', name: 'Turkey' },
{ code: 'IL', name: 'Israel' },
{ code: 'SA', name: 'Saudi Arabia' },
{ code: 'AE', name: 'United Arab Emirates' },
{ code: 'EG', name: 'Egypt' },
{ code: 'ZA', name: 'South Africa' },
{ code: 'KR', name: 'South Korea' },
{ code: 'SG', name: 'Singapore' },
{ code: 'MY', name: 'Malaysia' },
{ code: 'TH', name: 'Thailand' },
{ code: 'ID', name: 'Indonesia' },
{ code: 'PH', name: 'Philippines' },
{ code: 'VN', name: 'Vietnam' },
];
export function AccessListForm({ initialData, onSubmit, onCancel, isLoading }: AccessListFormProps) {
const [formData, setFormData] = useState<AccessListFormData>({
name: initialData?.name || '',
description: initialData?.description || '',
type: initialData?.type || 'whitelist',
ip_rules: initialData?.ip_rules || '',
country_codes: initialData?.country_codes || '',
local_network_only: initialData?.local_network_only || false,
enabled: initialData?.enabled ?? true,
});
const [ipRules, setIPRules] = useState<AccessListRule[]>(() => {
if (initialData?.ip_rules) {
try {
return JSON.parse(initialData.ip_rules);
} catch {
return [];
}
}
return [];
});
const [selectedCountries, setSelectedCountries] = useState<string[]>(() => {
if (initialData?.country_codes) {
return initialData.country_codes.split(',').map((c) => c.trim());
}
return [];
});
const [newIP, setNewIP] = useState('');
const [newIPDescription, setNewIPDescription] = useState('');
const isGeoType = formData.type.startsWith('geo_');
const isIPType = !isGeoType;
const handleAddIP = () => {
if (!newIP.trim()) return;
const newRule: AccessListRule = {
cidr: newIP.trim(),
description: newIPDescription.trim(),
};
const updatedRules = [...ipRules, newRule];
setIPRules(updatedRules);
setNewIP('');
setNewIPDescription('');
};
const handleRemoveIP = (index: number) => {
setIPRules(ipRules.filter((_, i) => i !== index));
};
const handleAddCountry = (countryCode: string) => {
if (!selectedCountries.includes(countryCode)) {
setSelectedCountries([...selectedCountries, countryCode]);
}
};
const handleRemoveCountry = (countryCode: string) => {
setSelectedCountries(selectedCountries.filter((c) => c !== countryCode));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data: AccessListFormData = {
...formData,
ip_rules: isIPType && !formData.local_network_only ? JSON.stringify(ipRules) : '',
country_codes: isGeoType ? selectedCountries.join(',') : '',
};
onSubmit(data);
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Info */}
<div className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
Name *
</label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="My Access List"
required
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-300 mb-2">
Description
</label>
<textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Optional description"
rows={2}
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"
/>
</div>
<div>
<label htmlFor="type" className="block text-sm font-medium text-gray-300 mb-2">
Type *
<a
href="https://wikid82.github.io/cpmp/docs/security.html#acl-best-practices-by-service-type"
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-blue-400 hover:text-blue-300 text-xs"
>
<ExternalLink className="inline h-3 w-3" /> Best Practices
</a>
</label>
<select
id="type"
value={formData.type}
onChange={(e) =>
setFormData({ ...formData, type: e.target.value as 'whitelist' | 'blacklist' | 'geo_whitelist' | 'geo_blacklist', local_network_only: false })
}
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"
>
<option value="whitelist">🛡 IP Whitelist (Allow Only)</option>
<option value="blacklist">🛡 IP Blacklist (Block Only)</option>
<option value="geo_whitelist">🌍 Geo Whitelist (Allow Countries)</option>
<option value="geo_blacklist">🌍 Geo Blacklist (Block Countries)</option>
</select>
</div>
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-300">Enabled</label>
<p className="text-xs text-gray-500">Apply this access list to hosts</p>
</div>
<Switch
checked={formData.enabled}
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
/>
</div>
</div>
{/* IP-based Rules */}
{isIPType && (
<div className="bg-gray-800 border border-gray-700 rounded-lg p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-300">Local Network Only (RFC1918)</label>
<p className="text-xs text-gray-500">
Allow only private network IPs (10.x.x.x, 192.168.x.x, 172.16-31.x.x)
</p>
</div>
<Switch
checked={formData.local_network_only}
onCheckedChange={(checked) =>
setFormData({ ...formData, local_network_only: checked })
}
/>
</div>
{!formData.local_network_only && (
<>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-300">IP Addresses / CIDR Ranges</label>
<div className="flex gap-2">
<Input
value={newIP}
onChange={(e) => setNewIP(e.target.value)}
placeholder="192.168.1.0/24 or 10.0.0.1"
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddIP())}
/>
<Input
value={newIPDescription}
onChange={(e) => setNewIPDescription(e.target.value)}
placeholder="Description (optional)"
className="flex-1"
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddIP())}
/>
<Button type="button" onClick={handleAddIP} size="sm">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{ipRules.length > 0 && (
<div className="space-y-2">
{ipRules.map((rule, index) => (
<div
key={index}
className="flex items-center justify-between p-3 rounded-lg border border-gray-600 bg-gray-700"
>
<div>
<p className="font-mono text-sm text-white">{rule.cidr}</p>
{rule.description && (
<p className="text-xs text-gray-400">{rule.description}</p>
)}
</div>
<button
type="button"
onClick={() => handleRemoveIP(index)}
className="text-gray-400 hover:text-red-400"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</>
)}
</div>
)}
{/* Geo-blocking Rules */}
{isGeoType && (
<div className="bg-gray-800 border border-gray-700 rounded-lg p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Select Countries</label>
<select
onChange={(e) => {
if (e.target.value) {
handleAddCountry(e.target.value);
e.target.value = '';
}
}}
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"
>
<option value="">Add a country...</option>
{COUNTRIES.filter((c) => !selectedCountries.includes(c.code)).map((country) => (
<option key={country.code} value={country.code}>
{country.name} ({country.code})
</option>
))}
</select>
</div>
{selectedCountries.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedCountries.map((code) => {
const country = COUNTRIES.find((c) => c.code === code);
return (
<span
key={code}
className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm bg-gray-700 text-gray-200 border border-gray-600"
>
{country?.name || code}
<X
className="h-3 w-3 cursor-pointer hover:text-red-400"
onClick={() => handleRemoveCountry(code)}
/>
</span>
);
})}
</div>
)}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onCancel} disabled={isLoading}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={isLoading}>
{isLoading ? 'Saving...' : initialData ? 'Update' : 'Create'}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,77 @@
import { useAccessLists } from '../hooks/useAccessLists';
import { ExternalLink } from 'lucide-react';
interface AccessListSelectorProps {
value: number | null;
onChange: (id: number | null) => void;
}
export default function AccessListSelector({ value, onChange }: AccessListSelectorProps) {
const { data: accessLists } = useAccessLists();
const selectedACL = accessLists?.find((acl) => acl.id === value);
return (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Access Control List
<span className="text-gray-500 font-normal ml-2">(Optional)</span>
</label>
<select
value={value || 0}
onChange={(e) => onChange(parseInt(e.target.value) || null)}
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"
>
<option value={0}>No Access Control (Public)</option>
{accessLists
?.filter((acl) => acl.enabled)
.map((acl) => (
<option key={acl.id} value={acl.id}>
{acl.name} ({acl.type.replace('_', ' ')})
</option>
))}
</select>
{selectedACL && (
<div className="mt-2 p-3 bg-gray-800 border border-gray-700 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-gray-200">{selectedACL.name}</span>
<span className="px-2 py-0.5 text-xs bg-gray-700 border border-gray-600 rounded">
{selectedACL.type.replace('_', ' ')}
</span>
</div>
{selectedACL.description && (
<p className="text-xs text-gray-400 mb-2">{selectedACL.description}</p>
)}
{selectedACL.local_network_only && (
<div className="text-xs text-blue-400">
🏠 Local Network Only (RFC1918)
</div>
)}
{selectedACL.type.startsWith('geo_') && selectedACL.country_codes && (
<div className="text-xs text-gray-400">
🌍 Countries: {selectedACL.country_codes}
</div>
)}
</div>
)}
<p className="text-xs text-gray-500 mt-1">
Restrict access based on IP address, CIDR ranges, or geographic location.{' '}
<a href="/access-lists" className="text-blue-400 hover:underline">
Manage lists
</a>
{' • '}
<a
href="https://wikid82.github.io/cpmp/docs/security.html#acl-best-practices-by-service-type"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="inline h-3 w-3" />
Best Practices
</a>
</p>
</div>
);
}
@@ -6,6 +6,7 @@ import { useRemoteServers } from '../hooks/useRemoteServers'
import { useDomains } from '../hooks/useDomains'
import { useCertificates } from '../hooks/useCertificates'
import { useDocker } from '../hooks/useDocker'
import AccessListSelector from './AccessListSelector'
import { parse } from 'tldts'
// Application preset configurations
@@ -57,6 +58,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
advanced_config: host?.advanced_config || '',
enabled: host?.enabled ?? true,
certificate_id: host?.certificate_id,
access_list_id: host?.access_list_id,
})
// CPMP internal IP for config helpers
@@ -490,6 +492,12 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
</p>
</div>
{/* Access Control List */}
<AccessListSelector
value={formData.access_list_id || null}
onChange={id => setFormData({ ...formData, access_list_id: id })}
/>
{/* Application Preset */}
<div>
<label htmlFor="application-preset" className="block text-sm font-medium text-gray-300 mb-2">
@@ -0,0 +1,124 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import AccessListSelector from '../AccessListSelector';
import * as useAccessListsHook from '../../hooks/useAccessLists';
import type { AccessList } from '../../api/accessLists';
// Mock the hooks
vi.mock('../../hooks/useAccessLists');
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('AccessListSelector', () => {
it('should render with no access lists', () => {
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
data: [],
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
const mockOnChange = vi.fn();
const Wrapper = createWrapper();
render(
<Wrapper>
<AccessListSelector value={null} onChange={mockOnChange} />
</Wrapper>
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(screen.getByText('No Access Control (Public)')).toBeInTheDocument();
});
it('should render with access lists and show only enabled ones', () => {
const mockLists: AccessList[] = [
{
id: 1,
uuid: 'uuid-1',
name: 'Test ACL 1',
description: 'Description 1',
type: 'whitelist',
ip_rules: '[]',
country_codes: '',
local_network_only: false,
enabled: true,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
{
id: 2,
uuid: 'uuid-2',
name: 'Test ACL 2',
description: 'Description 2',
type: 'blacklist',
ip_rules: '[]',
country_codes: '',
local_network_only: false,
enabled: false,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
];
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
data: mockLists,
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
const mockOnChange = vi.fn();
const Wrapper = createWrapper();
render(
<Wrapper>
<AccessListSelector value={null} onChange={mockOnChange} />
</Wrapper>
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(screen.getByText('Test ACL 1 (whitelist)')).toBeInTheDocument();
expect(screen.queryByText('Test ACL 2 (blacklist)')).not.toBeInTheDocument();
});
it('should show selected ACL details', () => {
const mockLists: AccessList[] = [
{
id: 1,
uuid: 'uuid-1',
name: 'Selected ACL',
description: 'This is selected',
type: 'geo_whitelist',
ip_rules: '[]',
country_codes: 'US,CA',
local_network_only: false,
enabled: true,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
];
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
data: mockLists,
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
const mockOnChange = vi.fn();
const Wrapper = createWrapper();
render(
<Wrapper>
<AccessListSelector value={1} onChange={mockOnChange} />
</Wrapper>
);
expect(screen.getByText('Selected ACL')).toBeInTheDocument();
expect(screen.getByText('This is selected')).toBeInTheDocument();
expect(screen.getByText(/Countries: US,CA/)).toBeInTheDocument();
});
});
@@ -0,0 +1,179 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useAccessLists, useAccessList, useCreateAccessList, useUpdateAccessList, useDeleteAccessList, useTestIP } from '../useAccessLists';
import { accessListsApi } from '../../api/accessLists';
import type { AccessList } from '../../api/accessLists';
// Mock the API module
vi.mock('../../api/accessLists');
// Create a wrapper with QueryClient
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('useAccessLists hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useAccessLists', () => {
it('should fetch all access lists', async () => {
const mockLists: AccessList[] = [
{
id: 1,
uuid: 'test-uuid',
name: 'Test ACL',
description: 'Test',
type: 'whitelist',
ip_rules: '[]',
country_codes: '',
local_network_only: false,
enabled: true,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
];
vi.mocked(accessListsApi.list).mockResolvedValueOnce(mockLists);
const { result } = renderHook(() => useAccessLists(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockLists);
});
});
describe('useAccessList', () => {
it('should fetch a single access list', async () => {
const mockList: AccessList = {
id: 1,
uuid: 'test-uuid',
name: 'Test ACL',
description: 'Test',
type: 'whitelist',
ip_rules: '[]',
country_codes: '',
local_network_only: false,
enabled: true,
created_at: '2024-01-01',
updated_at: '2024-01-01',
};
vi.mocked(accessListsApi.get).mockResolvedValueOnce(mockList);
const { result } = renderHook(() => useAccessList(1), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockList);
});
});
describe('useCreateAccessList', () => {
it('should create a new access list', async () => {
const newList = {
name: 'New ACL',
description: 'New',
type: 'whitelist' as const,
ip_rules: '[]',
enabled: true,
};
const mockResponse: AccessList = {
id: 1,
uuid: 'new-uuid',
...newList,
country_codes: '',
local_network_only: false,
created_at: '2024-01-01',
updated_at: '2024-01-01',
};
vi.mocked(accessListsApi.create).mockResolvedValueOnce(mockResponse);
const { result } = renderHook(() => useCreateAccessList(), {
wrapper: createWrapper(),
});
result.current.mutate(newList);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockResponse);
});
});
describe('useUpdateAccessList', () => {
it('should update an access list', async () => {
const updates = { name: 'Updated ACL' };
const mockResponse: AccessList = {
id: 1,
uuid: 'test-uuid',
name: 'Updated ACL',
description: 'Test',
type: 'whitelist',
ip_rules: '[]',
country_codes: '',
local_network_only: false,
enabled: true,
created_at: '2024-01-01',
updated_at: '2024-01-01',
};
vi.mocked(accessListsApi.update).mockResolvedValueOnce(mockResponse);
const { result } = renderHook(() => useUpdateAccessList(), {
wrapper: createWrapper(),
});
result.current.mutate({ id: 1, data: updates });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockResponse);
});
});
describe('useDeleteAccessList', () => {
it('should delete an access list', async () => {
vi.mocked(accessListsApi.delete).mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useDeleteAccessList(), {
wrapper: createWrapper(),
});
result.current.mutate(1);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(accessListsApi.delete).toHaveBeenCalledWith(1);
});
});
describe('useTestIP', () => {
it('should test an IP against an access list', async () => {
const mockResponse = { allowed: true, reason: 'Test' };
vi.mocked(accessListsApi.testIP).mockResolvedValueOnce(mockResponse);
const { result } = renderHook(() => useTestIP(), {
wrapper: createWrapper(),
});
result.current.mutate({ id: 1, ipAddress: '192.168.1.1' });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockResponse);
});
});
});
+82
View File
@@ -0,0 +1,82 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { accessListsApi, type CreateAccessListRequest } from '../api/accessLists';
import toast from 'react-hot-toast';
export function useAccessLists() {
return useQuery({
queryKey: ['accessLists'],
queryFn: accessListsApi.list,
});
}
export function useAccessList(id: number | undefined) {
return useQuery({
queryKey: ['accessList', id],
queryFn: () => accessListsApi.get(id!),
enabled: !!id,
});
}
export function useAccessListTemplates() {
return useQuery({
queryKey: ['accessListTemplates'],
queryFn: accessListsApi.getTemplates,
});
}
export function useCreateAccessList() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateAccessListRequest) => accessListsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accessLists'] });
toast.success('Access list created successfully');
},
onError: (error: Error) => {
toast.error(`Failed to create access list: ${error.message}`);
},
});
}
export function useUpdateAccessList() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<CreateAccessListRequest> }) =>
accessListsApi.update(id, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['accessLists'] });
queryClient.invalidateQueries({ queryKey: ['accessList', variables.id] });
toast.success('Access list updated successfully');
},
onError: (error: Error) => {
toast.error(`Failed to update access list: ${error.message}`);
},
});
}
export function useDeleteAccessList() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => accessListsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accessLists'] });
toast.success('Access list deleted successfully');
},
onError: (error: Error) => {
toast.error(`Failed to delete access list: ${error.message}`);
},
});
}
export function useTestIP() {
return useMutation({
mutationFn: ({ id, ipAddress }: { id: number; ipAddress: string }) =>
accessListsApi.testIP(id, ipAddress),
onError: (error: Error) => {
toast.error(`Failed to test IP: ${error.message}`);
},
});
}
+276
View File
@@ -0,0 +1,276 @@
import { useState } from 'react';
import { Button } from '../components/ui/Button';
import { Plus, Pencil, Trash2, TestTube2, ExternalLink } from 'lucide-react';
import {
useAccessLists,
useCreateAccessList,
useUpdateAccessList,
useDeleteAccessList,
useTestIP,
} from '../hooks/useAccessLists';
import { AccessListForm, type AccessListFormData } from '../components/AccessListForm';
import type { AccessList } from '../api/accessLists';
import toast from 'react-hot-toast';
export default function AccessLists() {
const { data: accessLists, isLoading } = useAccessLists();
const createMutation = useCreateAccessList();
const updateMutation = useUpdateAccessList();
const deleteMutation = useDeleteAccessList();
const testIPMutation = useTestIP();
const [showCreateForm, setShowCreateForm] = useState(false);
const [editingACL, setEditingACL] = useState<AccessList | null>(null);
const [testingACL, setTestingACL] = useState<AccessList | null>(null);
const [testIP, setTestIP] = useState('');
const handleCreate = (data: AccessListFormData) => {
createMutation.mutate(data, {
onSuccess: () => setShowCreateForm(false),
});
};
const handleUpdate = (data: AccessListFormData) => {
if (!editingACL) return;
updateMutation.mutate(
{ id: editingACL.id, data },
{
onSuccess: () => setEditingACL(null),
}
);
};
const handleDelete = (acl: AccessList) => {
if (!confirm(`Delete "${acl.name}"? This cannot be undone.`)) return;
deleteMutation.mutate(acl.id);
};
const handleTestIP = () => {
if (!testingACL || !testIP.trim()) return;
testIPMutation.mutate(
{ id: testingACL.id, ipAddress: testIP.trim() },
{
onSuccess: (result) => {
if (result.allowed) {
toast.success(`✅ IP ${testIP} would be ALLOWED\n${result.reason}`);
} else {
toast.error(`🚫 IP ${testIP} would be BLOCKED\n${result.reason}`);
}
},
}
);
};
const getRulesDisplay = (acl: AccessList) => {
if (acl.local_network_only) {
return <span className="text-xs bg-blue-900/30 text-blue-300 px-2 py-1 rounded">🏠 RFC1918 Only</span>;
}
if (acl.type.startsWith('geo_')) {
const countries = acl.country_codes?.split(',').filter(Boolean) || [];
return (
<div className="flex flex-wrap gap-1">
{countries.slice(0, 3).map((code) => (
<span key={code} className="text-xs bg-gray-700 px-2 py-1 rounded">{code}</span>
))}
{countries.length > 3 && <span className="text-xs text-gray-400">+{countries.length - 3}</span>}
</div>
);
}
try {
const rules = JSON.parse(acl.ip_rules || '[]');
return (
<div className="flex flex-wrap gap-1">
{rules.slice(0, 2).map((rule: { cidr: string }, idx: number) => (
<span key={idx} className="text-xs font-mono bg-gray-700 px-2 py-1 rounded">{rule.cidr}</span>
))}
{rules.length > 2 && <span className="text-xs text-gray-400">+{rules.length - 2}</span>}
</div>
);
} catch {
return <span className="text-gray-500">-</span>;
}
};
if (isLoading) {
return <div className="p-8 text-center text-white">Loading access lists...</div>;
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Access Control Lists</h1>
<p className="text-gray-400 mt-1">
Manage IP-based and geo-blocking rules for your proxy hosts
</p>
</div>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => window.open('https://wikid82.github.io/cpmp/docs/security.html#acl-best-practices-by-service-type', '_blank')}
>
<ExternalLink className="h-4 w-4 mr-2" />
Best Practices
</Button>
<Button onClick={() => setShowCreateForm(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Access List
</Button>
</div>
</div>
{/* Empty State */}
{(!accessLists || accessLists.length === 0) && !showCreateForm && !editingACL && (
<div className="bg-dark-card border border-gray-800 rounded-lg p-12 text-center">
<div className="text-gray-500 mb-4 text-4xl">🛡</div>
<h3 className="text-lg font-semibold text-white mb-2">No Access Lists</h3>
<p className="text-gray-400 mb-4">
Create your first access list to control who can access your services
</p>
<Button onClick={() => setShowCreateForm(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Access List
</Button>
</div>
)}
{/* Create Form */}
{showCreateForm && (
<div className="bg-dark-card border border-gray-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-white mb-4">Create Access List</h2>
<AccessListForm
onSubmit={handleCreate}
onCancel={() => setShowCreateForm(false)}
isLoading={createMutation.isPending}
/>
</div>
)}
{/* Edit Form */}
{editingACL && (
<div className="bg-dark-card border border-gray-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-white mb-4">Edit Access List</h2>
<AccessListForm
initialData={editingACL}
onSubmit={handleUpdate}
onCancel={() => setEditingACL(null)}
isLoading={updateMutation.isPending}
/>
</div>
)}
{/* Test IP Modal */}
{testingACL && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setTestingACL(null)}>
<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">Test IP Address</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Access List</label>
<p className="text-sm text-white">{testingACL.name}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">IP Address</label>
<div className="flex gap-2">
<input
type="text"
value={testIP}
onChange={(e) => setTestIP(e.target.value)}
placeholder="192.168.1.100"
onKeyDown={(e) => e.key === 'Enter' && handleTestIP()}
className="flex-1 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"
/>
<Button onClick={handleTestIP} disabled={testIPMutation.isPending}>
<TestTube2 className="h-4 w-4 mr-2" />
Test
</Button>
</div>
</div>
<div className="flex justify-end">
<Button variant="secondary" onClick={() => setTestingACL(null)}>
Close
</Button>
</div>
</div>
</div>
</div>
)}
{/* Table */}
{accessLists && accessLists.length > 0 && !showCreateForm && !editingACL && (
<div className="bg-dark-card border border-gray-800 rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-900/50 border-b border-gray-800">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase">Type</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase">Rules</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase">Status</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{accessLists.map((acl) => (
<tr key={acl.id} className="hover:bg-gray-900/30">
<td className="px-6 py-4">
<div>
<p className="font-medium text-white">{acl.name}</p>
{acl.description && (
<p className="text-sm text-gray-400">{acl.description}</p>
)}
</div>
</td>
<td className="px-6 py-4">
<span className="px-2 py-1 text-xs bg-gray-700 border border-gray-600 rounded">
{acl.type.replace('_', ' ')}
</span>
</td>
<td className="px-6 py-4">{getRulesDisplay(acl)}</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 text-xs rounded ${acl.enabled ? 'bg-green-900/30 text-green-300' : 'bg-gray-700 text-gray-400'}`}>
{acl.enabled ? 'Enabled' : 'Disabled'}
</span>
</td>
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<button
onClick={() => {
setTestingACL(acl);
setTestIP('');
}}
className="text-gray-400 hover:text-blue-400"
title="Test IP"
>
<TestTube2 className="h-4 w-4" />
</button>
<button
onClick={() => setEditingACL(acl)}
className="text-gray-400 hover:text-blue-400"
title="Edit"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(acl)}
className="text-gray-400 hover:text-red-400"
title="Delete"
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+6 -1
View File
@@ -126,7 +126,12 @@ export default function Security() {
</p>
{status.acl.enabled && (
<div className="mt-4">
<Button variant="secondary" size="sm" className="w-full">
<Button
variant="secondary"
size="sm"
className="w-full"
onClick={() => navigate('/access-lists')}
>
Manage Lists
</Button>
</div>