Files
Charon/frontend/src/pages/AccessLists.tsx
T

496 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react';
import { Button } from '../components/ui/Button';
import { Plus, Pencil, Trash2, TestTube2, ExternalLink, AlertTriangle, CheckSquare, Square } 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 { createBackup } from '../api/backups';
import toast from 'react-hot-toast';
// Confirmation Dialog Component
function ConfirmDialog({
isOpen,
title,
message,
confirmLabel,
onConfirm,
onCancel,
isLoading,
}: {
isOpen: boolean;
title: string;
message: string;
confirmLabel: string;
onConfirm: () => void;
onCancel: () => void;
isLoading?: boolean;
}) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onCancel}>
<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-2">{title}</h2>
<p className="text-gray-400 mb-6">{message}</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onCancel} disabled={isLoading}>
Cancel
</Button>
<Button variant="danger" onClick={onConfirm} disabled={isLoading}>
{isLoading ? 'Processing...' : confirmLabel}
</Button>
</div>
</div>
</div>
);
}
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 [showCGNATWarning, setShowCGNATWarning] = useState(true);
// Selection state for bulk operations
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<AccessList | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
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 handleDeleteWithBackup = async (acl: AccessList) => {
setIsDeleting(true);
try {
// Create backup before deletion
toast.loading('Creating backup before deletion...', { id: 'backup-toast' });
await createBackup();
toast.success('Backup created', { id: 'backup-toast' });
// Now delete
deleteMutation.mutate(acl.id, {
onSuccess: () => {
setShowDeleteConfirm(null);
setEditingACL(null);
toast.success(`"${acl.name}" deleted. A backup was created before deletion.`);
},
onError: (error) => {
toast.error(`Failed to delete: ${error.message}`);
},
onSettled: () => {
setIsDeleting(false);
},
});
} catch {
toast.error('Failed to create backup', { id: 'backup-toast' });
setIsDeleting(false);
}
};
const handleBulkDeleteWithBackup = async () => {
if (selectedIds.size === 0) return;
setIsDeleting(true);
try {
// Create backup before deletion
toast.loading('Creating backup before bulk deletion...', { id: 'backup-toast' });
await createBackup();
toast.success('Backup created', { id: 'backup-toast' });
// Delete each selected ACL
const deletePromises = Array.from(selectedIds).map(
(id) =>
new Promise<void>((resolve, reject) => {
deleteMutation.mutate(id, {
onSuccess: () => resolve(),
onError: (error) => reject(error),
});
})
);
await Promise.all(deletePromises);
setSelectedIds(new Set());
setShowBulkDeleteConfirm(false);
toast.success(`${selectedIds.size} access list(s) deleted. A backup was created before deletion.`);
} catch {
toast.error('Failed to delete some items');
} finally {
setIsDeleting(false);
}
};
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 toggleSelectAll = () => {
if (!accessLists) return;
if (selectedIds.size === accessLists.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(accessLists.map((acl) => acl.id)));
}
};
const toggleSelect = (id: number) => {
const newSelected = new Set(selectedIds);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
setSelectedIds(newSelected);
};
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/charon/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>
{/* CGNAT Warning */}
{showCGNATWarning && accessLists && accessLists.length > 0 && (
<div className="bg-orange-900/20 border border-orange-800/50 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-orange-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-sm font-semibold text-orange-300 mb-1">CGNAT & Mobile Network Warning</h3>
<p className="text-sm text-orange-200/90 mb-2">
If you're using T-Mobile 5G Home Internet, Starlink, or other CGNAT connections, geo-blocking may not work as expected.
Your IP may appear to be from a data center location, not your physical location.
</p>
<details className="text-xs text-orange-200/80">
<summary className="cursor-pointer hover:text-orange-100 font-medium mb-1">Solutions if you're locked out:</summary>
<ul className="list-disc list-inside space-y-1 mt-2 ml-2">
<li>Access via local network IP (192.168.x.x) - ACLs don't apply to local IPs</li>
<li>Add your current IP to a whitelist ACL</li>
<li>Use "Test IP" below to check what IP the server sees</li>
<li>Disable the ACL temporarily to regain access</li>
<li>Connect via VPN with a known good IP address</li>
</ul>
</details>
</div>
<button
onClick={() => setShowCGNATWarning(false)}
className="text-orange-400 hover:text-orange-300 text-xl leading-none"
title="Dismiss"
>
×
</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)}
onDelete={() => setShowDeleteConfirm(editingACL)}
isLoading={updateMutation.isPending}
isDeleting={isDeleting}
/>
</div>
)}
{/* Delete Confirmation Dialog */}
<ConfirmDialog
isOpen={showDeleteConfirm !== null}
title="Delete Access List"
message={`Are you sure you want to delete "${showDeleteConfirm?.name}"? A backup will be created before deletion.`}
confirmLabel="Delete"
onConfirm={() => showDeleteConfirm && handleDeleteWithBackup(showDeleteConfirm)}
onCancel={() => setShowDeleteConfirm(null)}
isLoading={isDeleting}
/>
{/* Bulk Delete Confirmation Dialog */}
<ConfirmDialog
isOpen={showBulkDeleteConfirm}
title="Delete Selected Access Lists"
message={`Are you sure you want to delete ${selectedIds.size} access list(s)? A backup will be created before deletion.`}
confirmLabel={`Delete ${selectedIds.size} Items`}
onConfirm={handleBulkDeleteWithBackup}
onCancel={() => setShowBulkDeleteConfirm(false)}
isLoading={isDeleting}
/>
{/* 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">
{/* Bulk Actions Bar */}
{selectedIds.size > 0 && (
<div className="bg-gray-900 border-b border-gray-800 px-6 py-3 flex items-center justify-between">
<span className="text-sm text-gray-300">
{selectedIds.size} item(s) selected
</span>
<Button
variant="danger"
size="sm"
onClick={() => setShowBulkDeleteConfirm(true)}
disabled={isDeleting}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Selected
</Button>
</div>
)}
<table className="w-full">
<thead className="bg-gray-900/50 border-b border-gray-800">
<tr>
<th className="px-4 py-3 text-left">
<button
onClick={toggleSelectAll}
className="text-gray-400 hover:text-white"
title={selectedIds.size === accessLists.length ? 'Deselect all' : 'Select all'}
>
{selectedIds.size === accessLists.length ? (
<CheckSquare className="h-5 w-5" />
) : (
<Square className="h-5 w-5" />
)}
</button>
</th>
<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 ${selectedIds.has(acl.id) ? 'bg-blue-900/20' : ''}`}>
<td className="px-4 py-4">
<button
onClick={() => toggleSelect(acl.id)}
className="text-gray-400 hover:text-white"
>
{selectedIds.has(acl.id) ? (
<CheckSquare className="h-5 w-5 text-blue-400" />
) : (
<Square className="h-5 w-5" />
)}
</button>
</td>
<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={() => setShowDeleteConfirm(acl)}
className="text-gray-400 hover:text-red-400"
title="Delete"
disabled={deleteMutation.isPending || isDeleting}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}