feat: enhance Access List management with delete confirmation and backup functionality
This commit is contained in:
@@ -39,10 +39,50 @@
|
||||
## 🚀 Quick Start
|
||||
|
||||
```bash
|
||||
# Clone and start
|
||||
git clone https://github.com/Wikid82/cpmp.git
|
||||
cd cpmp
|
||||
docker compose up -d
|
||||
services:
|
||||
cpmp:
|
||||
image: ghcr.io/wikid82/cpmp:latest
|
||||
container_name: cpmp
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80" # HTTP (Caddy proxy)
|
||||
- "443:443" # HTTPS (Caddy proxy)
|
||||
- "443:443/udp" # HTTP/3 (Caddy proxy)
|
||||
- "8080:8080" # Management UI (CPM+)
|
||||
environment:
|
||||
- CPM_ENV=production
|
||||
- TZ=UTC # Set timezone (e.g., America/New_York)
|
||||
- CPM_HTTP_PORT=8080
|
||||
- CPM_DB_PATH=/app/data/cpm.db
|
||||
- CPM_FRONTEND_DIR=/app/frontend/dist
|
||||
- CPM_CADDY_ADMIN_API=http://localhost:2019
|
||||
- CPM_CADDY_CONFIG_DIR=/app/data/caddy
|
||||
- CPM_CADDY_BINARY=caddy
|
||||
- CPM_IMPORT_CADDYFILE=/import/Caddyfile
|
||||
- CPM_IMPORT_DIR=/app/data/imports
|
||||
# Security Services (Optional)
|
||||
#- CPM_SECURITY_CROWDSEC_MODE=disabled # disabled, local, external
|
||||
#- CPM_SECURITY_CROWDSEC_API_URL= # Required if mode is external
|
||||
#- CPM_SECURITY_CROWDSEC_API_KEY= # Required if mode is external
|
||||
#- CPM_SECURITY_WAF_MODE=disabled # disabled, enabled
|
||||
#- CPM_SECURITY_RATELIMIT_ENABLED=false
|
||||
#- CPM_SECURITY_ACL_ENABLED=false
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- <path_to_cpm_data>:/app/data
|
||||
- <path_to_caddy_data>:/data
|
||||
- <path_to_caddy_config>:/config
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro # For local container discovery
|
||||
# Mount your existing Caddyfile for automatic import (optional)
|
||||
# - ./my-existing-Caddyfile:/import/Caddyfile:ro
|
||||
# - ./sites:/import/sites:ro # If your Caddyfile imports other files
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/v1/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
```
|
||||
|
||||
Open **http://localhost:8080** — that's it! 🎉
|
||||
|
||||
@@ -36,16 +36,35 @@ var RFC1918PrivateNetworks = []string{
|
||||
"::1/128", // IPv6 localhost
|
||||
}
|
||||
|
||||
// ISO 3166-1 alpha-2 country codes (subset for validation)
|
||||
// ISO 3166-1 alpha-2 country codes (comprehensive list for validation)
|
||||
var validCountryCodes = map[string]bool{
|
||||
"US": true, "CA": true, "GB": true, "DE": true, "FR": true, "IT": true, "ES": true,
|
||||
"NL": true, "BE": true, "SE": true, "NO": true, "DK": true, "FI": true, "PL": true,
|
||||
"CZ": true, "AT": true, "CH": true, "AU": true, "NZ": true, "JP": true, "CN": true,
|
||||
"IN": true, "BR": true, "MX": true, "AR": true, "RU": true, "UA": true, "TR": true,
|
||||
"IL": true, "SA": true, "AE": true, "EG": true, "ZA": true, "KR": true, "SG": true,
|
||||
"MY": true, "TH": true, "ID": true, "PH": true, "VN": true, "IE": true, "PT": true,
|
||||
"GR": true, "HU": true, "RO": true, "BG": true, "HR": true, "SI": true, "SK": true,
|
||||
"LT": true, "LV": true, "EE": true, "IS": true, "LU": true, "MT": true, "CY": true,
|
||||
// North America
|
||||
"US": true, "CA": true, "MX": true,
|
||||
// Europe
|
||||
"GB": true, "DE": true, "FR": true, "IT": true, "ES": true, "NL": true, "BE": true,
|
||||
"SE": true, "NO": true, "DK": true, "FI": true, "PL": true, "CZ": true, "AT": true,
|
||||
"CH": true, "IE": true, "PT": true, "GR": true, "HU": true, "RO": true, "BG": true,
|
||||
"HR": true, "SI": true, "SK": true, "LT": true, "LV": true, "EE": true, "IS": true,
|
||||
"LU": true, "MT": true, "CY": true, "UA": true, "BY": true,
|
||||
// Asia
|
||||
"JP": true, "CN": true, "IN": true, "KR": true, "SG": true, "MY": true, "TH": true,
|
||||
"ID": true, "PH": true, "VN": true, "TW": true, "HK": true, "PK": true, "BD": true,
|
||||
"KP": true, "IR": true, "IQ": true, "SY": true, "AF": true, "LK": true, "MM": true,
|
||||
// Middle East
|
||||
"TR": true, "IL": true, "SA": true, "AE": true, "QA": true, "KW": true, "OM": true,
|
||||
"BH": true, "JO": true, "LB": true, "YE": true,
|
||||
// Africa
|
||||
"EG": true, "ZA": true, "NG": true, "KE": true, "ET": true, "TZ": true, "MA": true,
|
||||
"DZ": true, "SD": true, "UG": true, "GH": true,
|
||||
// South America
|
||||
"BR": true, "AR": true, "CL": true, "CO": true, "PE": true, "VE": true, "EC": true,
|
||||
"BO": true, "PY": true, "UY": true,
|
||||
// Caribbean / Central America
|
||||
"CU": true, "DO": true, "PR": true, "JM": true, "HT": true, "PA": true, "CR": true,
|
||||
// Oceania
|
||||
"AU": true, "NZ": true,
|
||||
// Russia & CIS
|
||||
"RU": true, "KZ": true, "UZ": true, "AZ": true, "GE": true, "AM": true,
|
||||
}
|
||||
|
||||
type AccessListService struct {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { Button } from './ui/Button';
|
||||
import { Input } from './ui/Input';
|
||||
import { Switch } from './ui/Switch';
|
||||
import { X, Plus, ExternalLink, Shield, AlertTriangle, Info, Download } from 'lucide-react';
|
||||
import { X, Plus, ExternalLink, Shield, AlertTriangle, Info, Download, Trash2 } from 'lucide-react';
|
||||
import type { AccessList, AccessListRule } from '../api/accessLists';
|
||||
import { SECURITY_PRESETS, calculateTotalIPs, formatIPCount, type SecurityPreset } from '../data/securityPresets';
|
||||
import { getMyIP } from '../api/system';
|
||||
@@ -12,7 +12,9 @@ interface AccessListFormProps {
|
||||
initialData?: AccessList;
|
||||
onSubmit: (data: AccessListFormData) => void;
|
||||
onCancel: () => void;
|
||||
onDelete?: () => void;
|
||||
isLoading?: boolean;
|
||||
isDeleting?: boolean;
|
||||
}
|
||||
|
||||
export interface AccessListFormData {
|
||||
@@ -68,7 +70,7 @@ const COUNTRIES = [
|
||||
{ code: 'VN', name: 'Vietnam' },
|
||||
];
|
||||
|
||||
export function AccessListForm({ initialData, onSubmit, onCancel, isLoading }: AccessListFormProps) {
|
||||
export function AccessListForm({ initialData, onSubmit, onCancel, onDelete, isLoading, isDeleting }: AccessListFormProps) {
|
||||
const [formData, setFormData] = useState<AccessListFormData>({
|
||||
name: initialData?.name || '',
|
||||
description: initialData?.description || '',
|
||||
@@ -269,11 +271,11 @@ export function AccessListForm({ initialData, onSubmit, onCancel, isLoading }: A
|
||||
Quick-start templates based on threat intelligence feeds and best practices. Hover over (i) for data sources.
|
||||
</p>
|
||||
|
||||
{/* Security Category */}
|
||||
{/* Security Category - filter by current type */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-400 uppercase mb-2">Recommended Security Presets</h4>
|
||||
<div className="space-y-2">
|
||||
{SECURITY_PRESETS.filter(p => p.category === 'security').map((preset) => (
|
||||
{SECURITY_PRESETS.filter(p => p.category === 'security' && p.type === formData.type).map((preset) => (
|
||||
<div
|
||||
key={preset.id}
|
||||
className="bg-gray-900 border border-gray-700 rounded-lg p-3 hover:border-gray-600 transition-colors"
|
||||
@@ -319,11 +321,11 @@ export function AccessListForm({ initialData, onSubmit, onCancel, isLoading }: A
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Category */}
|
||||
{/* Advanced Category - filter by current type */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-400 uppercase mb-2">Advanced Presets</h4>
|
||||
<div className="space-y-2">
|
||||
{SECURITY_PRESETS.filter(p => p.category === 'advanced').map((preset) => (
|
||||
{SECURITY_PRESETS.filter(p => p.category === 'advanced' && p.type === formData.type).map((preset) => (
|
||||
<div
|
||||
key={preset.id}
|
||||
className="bg-gray-900 border border-gray-700 rounded-lg p-3 hover:border-gray-600 transition-colors"
|
||||
@@ -522,13 +524,28 @@ export function AccessListForm({ initialData, onSubmit, onCancel, isLoading }: A
|
||||
)}
|
||||
|
||||
{/* 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 className="flex justify-between gap-2">
|
||||
<div>
|
||||
{initialData && onDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={onDelete}
|
||||
disabled={isLoading || isDeleting}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onCancel} disabled={isLoading || isDeleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={isLoading || isDeleting}>
|
||||
{isLoading ? 'Saving...' : initialData ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Plus, Pencil, Trash2, TestTube2, ExternalLink, AlertTriangle } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, TestTube2, ExternalLink, AlertTriangle, CheckSquare, Square } from 'lucide-react';
|
||||
import {
|
||||
useAccessLists,
|
||||
useCreateAccessList,
|
||||
@@ -10,8 +10,47 @@ import {
|
||||
} 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();
|
||||
@@ -25,6 +64,12 @@ export default function AccessLists() {
|
||||
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),
|
||||
@@ -41,9 +86,64 @@ export default function AccessLists() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = (acl: AccessList) => {
|
||||
if (!confirm(`Delete "${acl.name}"? This cannot be undone.`)) return;
|
||||
deleteMutation.mutate(acl.id);
|
||||
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 = () => {
|
||||
@@ -63,6 +163,25 @@ export default function AccessLists() {
|
||||
);
|
||||
};
|
||||
|
||||
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>;
|
||||
@@ -193,11 +312,35 @@ export default function AccessLists() {
|
||||
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)}>
|
||||
@@ -238,9 +381,39 @@ export default function AccessLists() {
|
||||
{/* 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>
|
||||
@@ -250,7 +423,19 @@ export default function AccessLists() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{accessLists.map((acl) => (
|
||||
<tr key={acl.id} className="hover:bg-gray-900/30">
|
||||
<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>
|
||||
@@ -290,10 +475,10 @@ export default function AccessLists() {
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(acl)}
|
||||
onClick={() => setShowDeleteConfirm(acl)}
|
||||
className="text-gray-400 hover:text-red-400"
|
||||
title="Delete"
|
||||
disabled={deleteMutation.isPending}
|
||||
disabled={deleteMutation.isPending || isDeleting}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user