Files
Charon/frontend/src/pages/AccessLists.tsx
2026-01-02 01:01:54 +00:00

481 lines
16 KiB
TypeScript

import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Pencil, Trash2, TestTube2, ExternalLink, Shield } 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';
import { PageShell } from '../components/layout/PageShell';
import {
Badge,
Button,
Alert,
DataTable,
EmptyState,
SkeletonTable,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
Input,
Card,
type Column,
} from '../components/ui';
export default function AccessLists() {
const { t } = useTranslation();
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<string>>(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(t('accessLists.creatingBackup'), { id: 'backup-toast' });
await createBackup();
toast.success(t('accessLists.backupCreated'), { id: 'backup-toast' });
// Now delete
deleteMutation.mutate(acl.id, {
onSuccess: () => {
setShowDeleteConfirm(null);
setEditingACL(null);
toast.success(`"${acl.name}" ${t('accessLists.deletedWithBackup')}`);
},
onError: (error) => {
toast.error(`${t('accessLists.failedToDelete')}: ${error.message}`);
},
onSettled: () => {
setIsDeleting(false);
},
});
} catch {
toast.error(t('accessLists.failedToCreateBackup'), { id: 'backup-toast' });
setIsDeleting(false);
}
};
const handleBulkDeleteWithBackup = async () => {
if (selectedIds.size === 0) return;
setIsDeleting(true);
try {
// Create backup before deletion
toast.loading(t('accessLists.creatingBulkBackup'), { id: 'backup-toast' });
await createBackup();
toast.success(t('accessLists.backupCreated'), { id: 'backup-toast' });
// Delete each selected ACL
const deletePromises = Array.from(selectedIds).map(
(id) =>
new Promise<void>((resolve, reject) => {
deleteMutation.mutate(Number(id), {
onSuccess: () => resolve(),
onError: (error) => reject(error),
});
})
);
await Promise.all(deletePromises);
setSelectedIds(new Set());
setShowBulkDeleteConfirm(false);
toast.success(t('accessLists.bulkDeletedWithBackup', { count: selectedIds.size }));
} catch {
toast.error(t('accessLists.failedToDeleteSome'));
} 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(`${t('accessLists.ipAllowed', { ip: testIP })}\n${result.reason}`);
} else {
toast.error(`🚫 ${t('accessLists.ipBlocked', { ip: testIP })}\n${result.reason}`);
}
},
}
);
};
const getRulesDisplay = (acl: AccessList) => {
if (acl.local_network_only) {
return <Badge variant="primary" size="sm">🏠 {t('accessLists.rfc1918Only')}</Badge>;
}
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) => (
<Badge key={code} variant="outline" size="sm">{code}</Badge>
))}
{countries.length > 3 && <span className="text-xs text-content-muted">+{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) => (
<Badge key={idx} variant="outline" size="sm" className="font-mono">{rule.cidr}</Badge>
))}
{rules.length > 2 && <span className="text-xs text-content-muted">+{rules.length - 2}</span>}
</div>
);
} catch {
return <span className="text-content-muted">-</span>;
}
};
const getTypeBadge = (acl: AccessList) => {
const type = acl.type;
if (type === 'whitelist' || type === 'geo_whitelist') {
return <Badge variant="success" size="sm">{t('accessLists.allow')}</Badge>;
}
// blacklist or geo_blacklist
return <Badge variant="error" size="sm">{t('accessLists.deny')}</Badge>;
};
const columns: Column<AccessList>[] = [
{
key: 'name',
header: t('common.name'),
sortable: true,
cell: (acl) => (
<div>
<p className="font-medium text-content-primary">{acl.name}</p>
{acl.description && (
<p className="text-sm text-content-secondary">{acl.description}</p>
)}
</div>
),
},
{
key: 'type',
header: t('common.type'),
sortable: true,
cell: (acl) => getTypeBadge(acl),
},
{
key: 'rules',
header: t('accessLists.rules'),
cell: (acl) => getRulesDisplay(acl),
},
{
key: 'status',
header: t('common.status'),
sortable: true,
cell: (acl) => (
<Badge variant={acl.enabled ? 'success' : 'default'} size="sm">
{acl.enabled ? t('common.enabled') : t('common.disabled')}
</Badge>
),
},
{
key: 'actions',
header: t('common.actions'),
cell: (acl) => (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
setTestingACL(acl);
setTestIP('');
}}
title={t('accessLists.testIp')}
>
<TestTube2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
setEditingACL(acl);
}}
title={t('common.edit')}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowDeleteConfirm(acl);
}}
title={t('common.delete')}
disabled={deleteMutation.isPending || isDeleting}
>
<Trash2 className="h-4 w-4 text-error" />
</Button>
</div>
),
},
];
// Header actions
const headerActions = (
<div className="flex items-center gap-2">
{selectedIds.size > 0 && (
<Button
variant="danger"
size="sm"
onClick={() => setShowBulkDeleteConfirm(true)}
disabled={isDeleting}
>
<Trash2 className="h-4 w-4 mr-2" />
{t('common.delete')} ({selectedIds.size})
</Button>
)}
<Button
variant="secondary"
onClick={() => window.open('https://wikid82.github.io/charon/security#acl-best-practices-by-service-type', '_blank')}
>
<ExternalLink className="h-4 w-4 mr-2" />
{t('accessLists.bestPractices')}
</Button>
<Button onClick={() => setShowCreateForm(true)}>
<Plus className="h-4 w-4 mr-2" />
{t('accessLists.createAccessList')}
</Button>
</div>
);
if (isLoading) {
return (
<PageShell
title={t('accessLists.title')}
description={t('accessLists.description')}
actions={headerActions}
>
<SkeletonTable rows={5} columns={5} />
</PageShell>
);
}
return (
<PageShell
title={t('accessLists.title')}
description={t('accessLists.description')}
actions={headerActions}
>
{/* CGNAT Warning */}
{showCGNATWarning && accessLists && accessLists.length > 0 && (
<Alert
variant="warning"
title={t('accessLists.cgnatWarningTitle')}
dismissible
onDismiss={() => setShowCGNATWarning(false)}
>
<div className="space-y-2">
<p>
{t('accessLists.cgnatWarningMessage')}
</p>
<details className="text-xs">
<summary className="cursor-pointer hover:text-content-primary font-medium mb-1">{t('accessLists.solutionsIfLockedOut')}</summary>
<ul className="list-disc list-inside space-y-1 mt-2 ml-2">
<li>{t('accessLists.solutionLocalNetwork')}</li>
<li>{t('accessLists.solutionWhitelist')}</li>
<li>{t('accessLists.solutionTestIp')}</li>
<li>{t('accessLists.solutionDisableAcl')}</li>
<li>{t('accessLists.solutionVpn')}</li>
</ul>
</details>
</div>
</Alert>
)}
{/* Create Form */}
{showCreateForm && (
<Card className="p-6">
<h2 className="text-xl font-bold text-content-primary mb-4">{t('accessLists.createAccessList')}</h2>
<AccessListForm
onSubmit={handleCreate}
onCancel={() => setShowCreateForm(false)}
isLoading={createMutation.isPending}
/>
</Card>
)}
{/* Edit Form */}
{editingACL && (
<Card className="p-6">
<h2 className="text-xl font-bold text-content-primary mb-4">{t('accessLists.editAccessList')}</h2>
<AccessListForm
initialData={editingACL}
onSubmit={handleUpdate}
onCancel={() => setEditingACL(null)}
onDelete={() => setShowDeleteConfirm(editingACL)}
isLoading={updateMutation.isPending}
isDeleting={isDeleting}
/>
</Card>
)}
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteConfirm !== null} onOpenChange={() => setShowDeleteConfirm(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('accessLists.deleteAccessList')}</DialogTitle>
</DialogHeader>
<p className="text-content-secondary py-4">
{t('accessLists.deleteConfirmation', { name: showDeleteConfirm?.name })}
</p>
<DialogFooter>
<Button variant="secondary" onClick={() => setShowDeleteConfirm(null)} disabled={isDeleting}>
{t('common.cancel')}
</Button>
<Button variant="danger" onClick={() => showDeleteConfirm && handleDeleteWithBackup(showDeleteConfirm)} disabled={isDeleting}>
{isDeleting ? t('common.deleting') : t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bulk Delete Confirmation Dialog */}
<Dialog open={showBulkDeleteConfirm} onOpenChange={setShowBulkDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('accessLists.deleteSelectedAccessLists')}</DialogTitle>
</DialogHeader>
<p className="text-content-secondary py-4">
{t('accessLists.bulkDeleteConfirmation', { count: selectedIds.size })}
</p>
<DialogFooter>
<Button variant="secondary" onClick={() => setShowBulkDeleteConfirm(false)} disabled={isDeleting}>
{t('common.cancel')}
</Button>
<Button variant="danger" onClick={handleBulkDeleteWithBackup} disabled={isDeleting}>
{isDeleting ? t('common.deleting') : t('accessLists.deleteItems', { count: selectedIds.size })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Test IP Dialog */}
<Dialog open={testingACL !== null} onOpenChange={() => setTestingACL(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('accessLists.testIpAddress')}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<label className="block text-sm font-medium text-content-secondary mb-2">{t('accessLists.accessList')}</label>
<p className="text-sm text-content-primary">{testingACL?.name}</p>
</div>
<div>
<label className="block text-sm font-medium text-content-secondary mb-2">{t('accessLists.ipAddress')}</label>
<div className="flex gap-2">
<Input
value={testIP}
onChange={(e) => setTestIP(e.target.value)}
placeholder="192.168.1.100"
onKeyDown={(e) => e.key === 'Enter' && handleTestIP()}
className="flex-1"
/>
<Button onClick={handleTestIP} disabled={testIPMutation.isPending}>
<TestTube2 className="h-4 w-4 mr-2" />
{t('common.test')}
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="secondary" onClick={() => setTestingACL(null)}>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Empty State or DataTable */}
{!showCreateForm && !editingACL && (
<>
{(!accessLists || accessLists.length === 0) ? (
<EmptyState
icon={<Shield className="h-12 w-12" />}
title={t('accessLists.noAccessLists')}
description={t('accessLists.noAccessListsDescription')}
action={{
label: t('accessLists.createAccessList'),
onClick: () => setShowCreateForm(true),
}}
/>
) : (
<DataTable
data={accessLists}
columns={columns}
rowKey={(acl) => String(acl.id)}
selectable
selectedKeys={selectedIds}
onSelectionChange={setSelectedIds}
emptyState={
<EmptyState
icon={<Shield className="h-12 w-12" />}
title={t('accessLists.noAccessLists')}
description={t('accessLists.noAccessListsDescription')}
action={{
label: t('accessLists.createAccessList'),
onClick: () => setShowCreateForm(true),
}}
/>
}
/>
)}
</>
)}
</PageShell>
);
}