feat: enhance Access List management with delete confirmation and backup functionality

This commit is contained in:
Wikid82
2025-11-28 05:52:17 +00:00
parent 7a37b2f480
commit 74d7bf2ac3
4 changed files with 294 additions and 33 deletions
+44 -4
View File
@@ -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 {
+30 -13
View File
@@ -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>
);
+192 -7
View File
@@ -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>