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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user