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:
@@ -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