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
+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>