1557 lines
65 KiB
TypeScript
1557 lines
65 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Shield,
|
|
AlertTriangle,
|
|
CheckCircle,
|
|
XCircle,
|
|
RefreshCw,
|
|
X,
|
|
Ban,
|
|
Globe,
|
|
Lock,
|
|
Activity,
|
|
TrendingUp,
|
|
AlertCircle,
|
|
Info
|
|
} from 'lucide-react';
|
|
import { securityService, SecurityEvent, SecurityStats, OAuthProvider, DataSubjectRequest } from '../../services/api/securityService';
|
|
import { toast } from 'react-toastify';
|
|
import Loading from '../../components/common/Loading';
|
|
import Pagination from '../../components/common/Pagination';
|
|
import { formatDate } from '../../utils/format';
|
|
|
|
type SecurityTab = 'events' | 'stats' | 'ip-whitelist' | 'ip-blacklist' | 'oauth' | 'gdpr' | 'scan';
|
|
|
|
const SecurityManagementPage: React.FC = () => {
|
|
const [activeTab, setActiveTab] = useState<SecurityTab>('events');
|
|
const [loading, setLoading] = useState(false);
|
|
const [events, setEvents] = useState<SecurityEvent[]>([]);
|
|
const [stats, setStats] = useState<SecurityStats | null>(null);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
const [filters, setFilters] = useState({
|
|
event_type: '',
|
|
severity: '',
|
|
resolved: '',
|
|
days: 7,
|
|
search: ''
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'events') {
|
|
fetchEvents();
|
|
} else if (activeTab === 'stats') {
|
|
fetchStats();
|
|
}
|
|
}, [activeTab, filters, currentPage]);
|
|
|
|
const fetchEvents = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await securityService.getSecurityEvents({
|
|
event_type: filters.event_type || undefined,
|
|
severity: filters.severity || undefined,
|
|
resolved: filters.resolved ? filters.resolved === 'true' : undefined,
|
|
days: filters.days,
|
|
limit: 20,
|
|
offset: (currentPage - 1) * 20
|
|
});
|
|
setEvents(data);
|
|
setTotalPages(Math.ceil(data.length / 20));
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to fetch security events');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchStats = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await securityService.getSecurityStats(filters.days);
|
|
setStats(data);
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to fetch security statistics');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleResolveEvent = async (eventId: number) => {
|
|
try {
|
|
await securityService.resolveSecurityEvent(eventId);
|
|
toast.success('Event resolved successfully');
|
|
fetchEvents();
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to resolve event');
|
|
}
|
|
};
|
|
|
|
const getSeverityColor = (severity: string) => {
|
|
switch (severity) {
|
|
case 'critical': return 'bg-red-100 text-red-800 border-red-200';
|
|
case 'high': return 'bg-orange-100 text-orange-800 border-orange-200';
|
|
case 'medium': return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
|
case 'low': return 'bg-blue-100 text-blue-800 border-blue-200';
|
|
default: return 'bg-gray-100 text-gray-800 border-gray-200';
|
|
}
|
|
};
|
|
|
|
const getEventTypeIcon = (eventType: string) => {
|
|
if (eventType.includes('login')) return <Lock className="w-4 h-4" />;
|
|
if (eventType.includes('permission')) return <Ban className="w-4 h-4" />;
|
|
if (eventType.includes('suspicious')) return <AlertTriangle className="w-4 h-4" />;
|
|
return <Activity className="w-4 h-4" />;
|
|
};
|
|
|
|
if (loading && !events.length && !stats) {
|
|
return <Loading />;
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
|
|
<div className="max-w-7xl mx-auto px-3 sm:px-4 md:px-6 lg:px-8 py-4 sm:py-6 md:py-8 space-y-4 sm:space-y-5 md:space-y-6">
|
|
{/* Header */}
|
|
<div className="relative">
|
|
<div className="absolute inset-0 bg-gradient-to-r from-red-400/5 via-transparent to-orange-600/5 rounded-2xl sm:rounded-3xl blur-3xl"></div>
|
|
<div className="relative bg-white/80 backdrop-blur-xl rounded-2xl sm:rounded-3xl shadow-2xl border border-red-200/30 p-4 sm:p-6 md:p-8">
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 md:gap-5">
|
|
<div className="relative flex-shrink-0">
|
|
<div className="absolute inset-0 bg-gradient-to-br from-red-400 to-orange-600 rounded-xl sm:rounded-2xl blur-lg opacity-50"></div>
|
|
<div className="relative p-3 sm:p-4 rounded-xl sm:rounded-2xl bg-gradient-to-br from-red-500 via-red-500 to-orange-600 shadow-xl border border-red-400/50">
|
|
<Shield className="w-6 h-6 sm:w-7 sm:h-7 md:w-8 md:h-8 text-white" />
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h1 className="text-2xl sm:text-3xl md:text-4xl font-extrabold bg-gradient-to-r from-slate-900 via-red-700 to-slate-900 bg-clip-text text-transparent">
|
|
Security Management
|
|
</h1>
|
|
<p className="text-sm sm:text-base text-gray-600 mt-1 sm:mt-2">Monitor and manage security events, IP access, and compliance</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="bg-white rounded-xl sm:rounded-2xl shadow-lg border border-gray-200 p-2">
|
|
<div className="overflow-x-auto">
|
|
<div className="flex gap-2 min-w-max sm:min-w-0 sm:flex-wrap">
|
|
{[
|
|
{ id: 'events', label: 'Security Events', icon: Activity },
|
|
{ id: 'stats', label: 'Statistics', icon: TrendingUp },
|
|
{ id: 'ip-whitelist', label: 'IP Whitelist', icon: CheckCircle },
|
|
{ id: 'ip-blacklist', label: 'IP Blacklist', icon: Ban },
|
|
{ id: 'oauth', label: 'OAuth Providers', icon: Globe },
|
|
{ id: 'gdpr', label: 'GDPR Requests', icon: Shield },
|
|
{ id: 'scan', label: 'Security Scan', icon: Activity }
|
|
].map(tab => {
|
|
const Icon = tab.icon;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id as SecurityTab)}
|
|
className={`flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-1.5 sm:py-2 rounded-lg sm:rounded-xl font-medium transition-all text-xs sm:text-sm whitespace-nowrap ${
|
|
activeTab === tab.id
|
|
? 'bg-gradient-to-r from-red-500 to-orange-500 text-white shadow-lg'
|
|
: 'text-gray-600 hover:bg-gray-100'
|
|
}`}
|
|
>
|
|
<Icon className="w-3 h-3 sm:w-4 sm:h-4 flex-shrink-0" />
|
|
<span className="hidden xs:inline">{tab.label}</span>
|
|
<span className="xs:hidden">{tab.label.split(' ')[0]}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="bg-white rounded-xl sm:rounded-2xl shadow-lg border border-gray-200 p-3 sm:p-4 md:p-6">
|
|
{activeTab === 'events' && (
|
|
<div className="space-y-4 sm:space-y-5 md:space-y-6">
|
|
{/* Filters */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
|
<select
|
|
value={filters.event_type}
|
|
onChange={(e) => setFilters({ ...filters, event_type: e.target.value })}
|
|
className="px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
|
>
|
|
<option value="">All Event Types</option>
|
|
<option value="login_attempt">Login Attempt</option>
|
|
<option value="login_failure">Login Failure</option>
|
|
<option value="permission_denied">Permission Denied</option>
|
|
<option value="suspicious_activity">Suspicious Activity</option>
|
|
<option value="ip_blocked">IP Blocked</option>
|
|
</select>
|
|
<select
|
|
value={filters.severity}
|
|
onChange={(e) => setFilters({ ...filters, severity: e.target.value })}
|
|
className="px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
|
>
|
|
<option value="">All Severities</option>
|
|
<option value="critical">Critical</option>
|
|
<option value="high">High</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="low">Low</option>
|
|
</select>
|
|
<select
|
|
value={filters.resolved}
|
|
onChange={(e) => setFilters({ ...filters, resolved: e.target.value })}
|
|
className="px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
|
>
|
|
<option value="">All Status</option>
|
|
<option value="false">Unresolved</option>
|
|
<option value="true">Resolved</option>
|
|
</select>
|
|
<select
|
|
value={filters.days}
|
|
onChange={(e) => setFilters({ ...filters, days: parseInt(e.target.value) })}
|
|
className="px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
|
>
|
|
<option value="1">Last 24 hours</option>
|
|
<option value="7">Last 7 days</option>
|
|
<option value="30">Last 30 days</option>
|
|
<option value="90">Last 90 days</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Events Table - Desktop */}
|
|
<div className="hidden lg:block overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-gray-200">
|
|
<th className="text-left py-3 px-4 font-semibold text-gray-700 text-sm">Type</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-gray-700 text-sm">Severity</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-gray-700 text-sm">IP Address</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-gray-700 text-sm">Description</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-gray-700 text-sm">Date</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-gray-700 text-sm">Status</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-gray-700 text-sm">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{events.map((event) => (
|
|
<tr key={event.id} className="border-b border-gray-100 hover:bg-gray-50">
|
|
<td className="py-3 px-4">
|
|
<div className="flex items-center gap-2">
|
|
{getEventTypeIcon(event.event_type)}
|
|
<span className="text-sm font-medium">{event.event_type.replace(/_/g, ' ')}</span>
|
|
</div>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium border ${getSeverityColor(event.severity)}`}>
|
|
{event.severity}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-4 text-sm text-gray-600">{event.ip_address || '-'}</td>
|
|
<td className="py-3 px-4 text-sm text-gray-600">{event.description || '-'}</td>
|
|
<td className="py-3 px-4 text-sm text-gray-600">{formatDate(event.created_at)}</td>
|
|
<td className="py-3 px-4">
|
|
{event.resolved ? (
|
|
<span className="flex items-center gap-1 text-green-600">
|
|
<CheckCircle className="w-4 h-4" />
|
|
Resolved
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center gap-1 text-orange-600">
|
|
<AlertCircle className="w-4 h-4" />
|
|
Unresolved
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
{!event.resolved && (
|
|
<button
|
|
onClick={() => handleResolveEvent(event.id)}
|
|
className="px-3 py-1 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors text-sm"
|
|
>
|
|
Resolve
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Events Cards - Mobile/Tablet */}
|
|
<div className="lg:hidden space-y-3">
|
|
{events.map((event) => (
|
|
<div key={event.id} className="bg-gray-50 rounded-lg p-4 border border-gray-200 space-y-3">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
{getEventTypeIcon(event.event_type)}
|
|
<span className="text-sm font-medium truncate">{event.event_type.replace(/_/g, ' ')}</span>
|
|
</div>
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium border flex-shrink-0 ${getSeverityColor(event.severity)}`}>
|
|
{event.severity}
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
|
<div>
|
|
<span className="text-gray-500">IP:</span>
|
|
<span className="ml-1 text-gray-700">{event.ip_address || '-'}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-500">Date:</span>
|
|
<span className="ml-1 text-gray-700">{formatDate(event.created_at)}</span>
|
|
</div>
|
|
</div>
|
|
{event.description && (
|
|
<div className="text-sm">
|
|
<span className="text-gray-500">Description:</span>
|
|
<p className="text-gray-700 mt-1">{event.description}</p>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center justify-between pt-2 border-t border-gray-200">
|
|
{event.resolved ? (
|
|
<span className="flex items-center gap-1 text-green-600 text-sm">
|
|
<CheckCircle className="w-4 h-4" />
|
|
Resolved
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center gap-1 text-orange-600 text-sm">
|
|
<AlertCircle className="w-4 h-4" />
|
|
Unresolved
|
|
</span>
|
|
)}
|
|
{!event.resolved && (
|
|
<button
|
|
onClick={() => handleResolveEvent(event.id)}
|
|
className="px-3 py-1.5 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors text-sm"
|
|
>
|
|
Resolve
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
onPageChange={setCurrentPage}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'stats' && stats && (
|
|
<div className="space-y-4 sm:space-y-5 md:space-y-6">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-5 md:gap-6">
|
|
<div className="bg-gradient-to-br from-red-50 to-orange-50 rounded-xl p-4 sm:p-5 md:p-6 border border-red-100">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex-1 min-w-0 pr-2">
|
|
<p className="text-xs sm:text-sm text-gray-600">Total Events</p>
|
|
<p className="text-2xl sm:text-3xl font-bold text-gray-900 mt-1 sm:mt-2">{stats.total_events}</p>
|
|
</div>
|
|
<Activity className="w-10 h-10 sm:w-12 sm:h-12 text-red-500 opacity-50 flex-shrink-0" />
|
|
</div>
|
|
</div>
|
|
<div className="bg-gradient-to-br from-orange-50 to-yellow-50 rounded-xl p-4 sm:p-5 md:p-6 border border-orange-100">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex-1 min-w-0 pr-2">
|
|
<p className="text-xs sm:text-sm text-gray-600">Critical Unresolved</p>
|
|
<p className="text-2xl sm:text-3xl font-bold text-gray-900 mt-1 sm:mt-2">{stats.unresolved_critical}</p>
|
|
</div>
|
|
<AlertTriangle className="w-10 h-10 sm:w-12 sm:h-12 text-orange-500 opacity-50 flex-shrink-0" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5 md:gap-6">
|
|
<div className="bg-gray-50 rounded-xl p-4 sm:p-5 md:p-6">
|
|
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">Events by Type</h3>
|
|
<div className="space-y-2">
|
|
{Object.entries(stats.by_type).map(([type, count]) => (
|
|
<div key={type} className="flex items-center justify-between text-sm sm:text-base">
|
|
<span className="text-gray-600 truncate pr-2">{type.replace(/_/g, ' ')}</span>
|
|
<span className="font-semibold flex-shrink-0">{count}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-xl p-4 sm:p-5 md:p-6">
|
|
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">Events by Severity</h3>
|
|
<div className="space-y-2">
|
|
{Object.entries(stats.by_severity).map(([severity, count]) => (
|
|
<div key={severity} className="flex items-center justify-between text-sm sm:text-base">
|
|
<span className={`text-xs sm:text-sm px-2 py-1 rounded flex-shrink-0 ${getSeverityColor(severity)}`}>
|
|
{severity}
|
|
</span>
|
|
<span className="font-semibold ml-auto">{count}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'ip-whitelist' && (
|
|
<IPWhitelistTab />
|
|
)}
|
|
|
|
{activeTab === 'ip-blacklist' && (
|
|
<IPBlacklistTab />
|
|
)}
|
|
|
|
{activeTab === 'oauth' && (
|
|
<OAuthProvidersTab />
|
|
)}
|
|
|
|
{activeTab === 'gdpr' && (
|
|
<GDPRRequestsTab />
|
|
)}
|
|
|
|
{activeTab === 'scan' && (
|
|
<SecurityScanTab />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// IP Whitelist Tab Component
|
|
const IPWhitelistTab: React.FC = () => {
|
|
const [ips, setIPs] = useState<any[]>([]);
|
|
const [, setLoading] = useState(false);
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
const [newIP, setNewIP] = useState({ ip_address: '', description: '' });
|
|
|
|
useEffect(() => {
|
|
fetchIPs();
|
|
}, []);
|
|
|
|
const fetchIPs = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await securityService.getWhitelistedIPs();
|
|
setIPs(data);
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to fetch whitelisted IPs');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAdd = async () => {
|
|
try {
|
|
await securityService.addIPToWhitelist(newIP.ip_address, newIP.description);
|
|
toast.success('IP added to whitelist');
|
|
setShowAddModal(false);
|
|
setNewIP({ ip_address: '', description: '' });
|
|
fetchIPs();
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to add IP to whitelist');
|
|
}
|
|
};
|
|
|
|
const handleRemove = async (ipAddress: string) => {
|
|
if (!window.confirm('Are you sure you want to remove this IP from whitelist?')) return;
|
|
try {
|
|
await securityService.removeIPFromWhitelist(ipAddress);
|
|
toast.success('IP removed from whitelist');
|
|
fetchIPs();
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to remove IP from whitelist');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-4">
|
|
<h3 className="text-lg sm:text-xl font-semibold">IP Whitelist</h3>
|
|
<button
|
|
onClick={() => setShowAddModal(true)}
|
|
className="w-full sm:w-auto px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors text-sm sm:text-base"
|
|
>
|
|
Add IP
|
|
</button>
|
|
</div>
|
|
|
|
{showAddModal && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-xl p-4 sm:p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
|
|
<h4 className="text-base sm:text-lg font-semibold mb-4">Add IP to Whitelist</h4>
|
|
<div className="space-y-4">
|
|
<input
|
|
type="text"
|
|
placeholder="IP Address (e.g., 192.168.1.1 or 192.168.1.0/24)"
|
|
value={newIP.ip_address}
|
|
onChange={(e) => setNewIP({ ...newIP, ip_address: e.target.value })}
|
|
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg"
|
|
/>
|
|
<textarea
|
|
placeholder="Description (optional)"
|
|
value={newIP.description}
|
|
onChange={(e) => setNewIP({ ...newIP, description: e.target.value })}
|
|
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg"
|
|
rows={3}
|
|
/>
|
|
<div className="flex flex-col sm:flex-row gap-2">
|
|
<button
|
|
onClick={handleAdd}
|
|
className="flex-1 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm sm:text-base"
|
|
>
|
|
Add
|
|
</button>
|
|
<button
|
|
onClick={() => setShowAddModal(false)}
|
|
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 text-sm sm:text-base"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Desktop Table */}
|
|
<div className="hidden lg:block overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-gray-200">
|
|
<th className="text-left py-3 px-4 font-semibold text-sm">IP Address</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-sm">Description</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-sm">Status</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-sm">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{ips.map((ip) => (
|
|
<tr key={ip.id} className="border-b border-gray-100">
|
|
<td className="py-3 px-4 text-sm">{ip.ip_address}</td>
|
|
<td className="py-3 px-4 text-sm text-gray-600">{ip.description || '-'}</td>
|
|
<td className="py-3 px-4">
|
|
<span className="px-2 py-1 bg-green-100 text-green-800 rounded-full text-xs">
|
|
Active
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<button
|
|
onClick={() => handleRemove(ip.ip_address)}
|
|
className="px-3 py-1 bg-red-500 text-white rounded-lg hover:bg-red-600 text-sm"
|
|
>
|
|
Remove
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Mobile Cards */}
|
|
<div className="lg:hidden space-y-3">
|
|
{ips.map((ip) => (
|
|
<div key={ip.id} className="bg-gray-50 rounded-lg p-4 border border-gray-200 space-y-2">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-sm truncate">{ip.ip_address}</p>
|
|
{ip.description && (
|
|
<p className="text-xs sm:text-sm text-gray-600 mt-1">{ip.description}</p>
|
|
)}
|
|
</div>
|
|
<span className="px-2 py-1 bg-green-100 text-green-800 rounded-full text-xs flex-shrink-0">
|
|
Active
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => handleRemove(ip.ip_address)}
|
|
className="w-full sm:w-auto px-3 py-1.5 bg-red-500 text-white rounded-lg hover:bg-red-600 text-sm"
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// IP Blacklist Tab Component
|
|
const IPBlacklistTab: React.FC = () => {
|
|
const [ips, setIPs] = useState<any[]>([]);
|
|
const [, setLoading] = useState(false);
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
const [newIP, setNewIP] = useState({ ip_address: '', reason: '' });
|
|
|
|
useEffect(() => {
|
|
fetchIPs();
|
|
}, []);
|
|
|
|
const fetchIPs = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await securityService.getBlacklistedIPs();
|
|
setIPs(data);
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to fetch blacklisted IPs');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAdd = async () => {
|
|
try {
|
|
await securityService.addIPToBlacklist(newIP.ip_address, newIP.reason);
|
|
toast.success('IP added to blacklist');
|
|
setShowAddModal(false);
|
|
setNewIP({ ip_address: '', reason: '' });
|
|
fetchIPs();
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to add IP to blacklist');
|
|
}
|
|
};
|
|
|
|
const handleRemove = async (ipAddress: string) => {
|
|
if (!window.confirm('Are you sure you want to remove this IP from blacklist?')) return;
|
|
try {
|
|
await securityService.removeIPFromBlacklist(ipAddress);
|
|
toast.success('IP removed from blacklist');
|
|
fetchIPs();
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to remove IP from blacklist');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-4">
|
|
<h3 className="text-lg sm:text-xl font-semibold">IP Blacklist</h3>
|
|
<button
|
|
onClick={() => setShowAddModal(true)}
|
|
className="w-full sm:w-auto px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors text-sm sm:text-base"
|
|
>
|
|
Add IP
|
|
</button>
|
|
</div>
|
|
|
|
{showAddModal && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-xl p-4 sm:p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
|
|
<h4 className="text-base sm:text-lg font-semibold mb-4">Add IP to Blacklist</h4>
|
|
<div className="space-y-4">
|
|
<input
|
|
type="text"
|
|
placeholder="IP Address"
|
|
value={newIP.ip_address}
|
|
onChange={(e) => setNewIP({ ...newIP, ip_address: e.target.value })}
|
|
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg"
|
|
/>
|
|
<textarea
|
|
placeholder="Reason (optional)"
|
|
value={newIP.reason}
|
|
onChange={(e) => setNewIP({ ...newIP, reason: e.target.value })}
|
|
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg"
|
|
rows={3}
|
|
/>
|
|
<div className="flex flex-col sm:flex-row gap-2">
|
|
<button
|
|
onClick={handleAdd}
|
|
className="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 text-sm sm:text-base"
|
|
>
|
|
Add
|
|
</button>
|
|
<button
|
|
onClick={() => setShowAddModal(false)}
|
|
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 text-sm sm:text-base"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Desktop Table */}
|
|
<div className="hidden lg:block overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-gray-200">
|
|
<th className="text-left py-3 px-4 font-semibold text-sm">IP Address</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-sm">Reason</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-sm">Status</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-sm">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{ips.map((ip) => (
|
|
<tr key={ip.id} className="border-b border-gray-100">
|
|
<td className="py-3 px-4 text-sm">{ip.ip_address}</td>
|
|
<td className="py-3 px-4 text-sm text-gray-600">{ip.reason || '-'}</td>
|
|
<td className="py-3 px-4">
|
|
<span className="px-2 py-1 bg-red-100 text-red-800 rounded-full text-xs">
|
|
Blocked
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<button
|
|
onClick={() => handleRemove(ip.ip_address)}
|
|
className="px-3 py-1 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm"
|
|
>
|
|
Unblock
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Mobile Cards */}
|
|
<div className="lg:hidden space-y-3">
|
|
{ips.map((ip) => (
|
|
<div key={ip.id} className="bg-gray-50 rounded-lg p-4 border border-gray-200 space-y-2">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-sm truncate">{ip.ip_address}</p>
|
|
{ip.reason && (
|
|
<p className="text-xs sm:text-sm text-gray-600 mt-1">{ip.reason}</p>
|
|
)}
|
|
</div>
|
|
<span className="px-2 py-1 bg-red-100 text-red-800 rounded-full text-xs flex-shrink-0">
|
|
Blocked
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => handleRemove(ip.ip_address)}
|
|
className="w-full sm:w-auto px-3 py-1.5 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm"
|
|
>
|
|
Unblock
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// OAuth Providers Tab Component
|
|
const OAuthProvidersTab: React.FC = () => {
|
|
const [providers, setProviders] = useState<OAuthProvider[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
const [editingProvider, setEditingProvider] = useState<OAuthProvider | null>(null);
|
|
const [formData, setFormData] = useState({
|
|
name: '',
|
|
display_name: '',
|
|
client_id: '',
|
|
client_secret: '',
|
|
authorization_url: '',
|
|
token_url: '',
|
|
userinfo_url: '',
|
|
scopes: '',
|
|
is_active: true,
|
|
is_sso_enabled: false
|
|
});
|
|
|
|
useEffect(() => {
|
|
fetchProviders();
|
|
}, []);
|
|
|
|
const fetchProviders = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await securityService.getOAuthProviders();
|
|
setProviders(data);
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to fetch OAuth providers');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAdd = async () => {
|
|
try {
|
|
await securityService.createOAuthProvider(formData);
|
|
toast.success('OAuth provider created');
|
|
setShowAddModal(false);
|
|
resetForm();
|
|
fetchProviders();
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to create OAuth provider');
|
|
}
|
|
};
|
|
|
|
const handleUpdate = async () => {
|
|
if (!editingProvider) return;
|
|
try {
|
|
await securityService.updateOAuthProvider(editingProvider.id, formData);
|
|
toast.success('OAuth provider updated');
|
|
setEditingProvider(null);
|
|
resetForm();
|
|
fetchProviders();
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to update OAuth provider');
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (providerId: number) => {
|
|
if (!window.confirm('Are you sure you want to delete this OAuth provider?')) return;
|
|
try {
|
|
await securityService.deleteOAuthProvider(providerId);
|
|
toast.success('OAuth provider deleted');
|
|
fetchProviders();
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to delete OAuth provider');
|
|
}
|
|
};
|
|
|
|
const resetForm = () => {
|
|
setFormData({
|
|
name: '',
|
|
display_name: '',
|
|
client_id: '',
|
|
client_secret: '',
|
|
authorization_url: '',
|
|
token_url: '',
|
|
userinfo_url: '',
|
|
scopes: '',
|
|
is_active: true,
|
|
is_sso_enabled: false
|
|
});
|
|
};
|
|
|
|
const openEditModal = (provider: OAuthProvider) => {
|
|
setEditingProvider(provider);
|
|
setFormData({
|
|
name: provider.name,
|
|
display_name: provider.display_name,
|
|
client_id: '',
|
|
client_secret: '',
|
|
authorization_url: '',
|
|
token_url: '',
|
|
userinfo_url: '',
|
|
scopes: '',
|
|
is_active: provider.is_active,
|
|
is_sso_enabled: provider.is_sso_enabled
|
|
});
|
|
setShowAddModal(true);
|
|
};
|
|
|
|
if (loading && providers.length === 0) {
|
|
return <Loading />;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-4">
|
|
<h3 className="text-lg sm:text-xl font-semibold">OAuth Providers</h3>
|
|
<button
|
|
onClick={() => {
|
|
resetForm();
|
|
setEditingProvider(null);
|
|
setShowAddModal(true);
|
|
}}
|
|
className="w-full sm:w-auto px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors text-sm sm:text-base"
|
|
>
|
|
Add Provider
|
|
</button>
|
|
</div>
|
|
|
|
{showAddModal && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-xl p-4 sm:p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
|
<h4 className="text-base sm:text-lg font-semibold mb-4">
|
|
{editingProvider ? 'Edit OAuth Provider' : 'Add OAuth Provider'}
|
|
</h4>
|
|
<div className="space-y-3 sm:space-y-4">
|
|
<input
|
|
type="text"
|
|
placeholder="Name (e.g., google, microsoft)"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg"
|
|
disabled={!!editingProvider}
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Display Name"
|
|
value={formData.display_name}
|
|
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })}
|
|
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg"
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Client ID"
|
|
value={formData.client_id}
|
|
onChange={(e) => setFormData({ ...formData, client_id: e.target.value })}
|
|
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg"
|
|
/>
|
|
<input
|
|
type="password"
|
|
placeholder={editingProvider ? "Client Secret (leave blank to keep current)" : "Client Secret"}
|
|
value={formData.client_secret}
|
|
onChange={(e) => setFormData({ ...formData, client_secret: e.target.value })}
|
|
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg"
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Authorization URL"
|
|
value={formData.authorization_url}
|
|
onChange={(e) => setFormData({ ...formData, authorization_url: e.target.value })}
|
|
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg"
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Token URL"
|
|
value={formData.token_url}
|
|
onChange={(e) => setFormData({ ...formData, token_url: e.target.value })}
|
|
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg"
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Userinfo URL"
|
|
value={formData.userinfo_url}
|
|
onChange={(e) => setFormData({ ...formData, userinfo_url: e.target.value })}
|
|
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg"
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Scopes (space-separated)"
|
|
value={formData.scopes}
|
|
onChange={(e) => setFormData({ ...formData, scopes: e.target.value })}
|
|
className="w-full px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg"
|
|
/>
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
|
|
<label className="flex items-center gap-2 text-sm sm:text-base">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.is_active}
|
|
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
|
className="w-4 h-4"
|
|
/>
|
|
<span>Active</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm sm:text-base">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.is_sso_enabled}
|
|
onChange={(e) => setFormData({ ...formData, is_sso_enabled: e.target.checked })}
|
|
className="w-4 h-4"
|
|
/>
|
|
<span>SSO Enabled</span>
|
|
</label>
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row gap-2 pt-2">
|
|
<button
|
|
onClick={editingProvider ? handleUpdate : handleAdd}
|
|
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm sm:text-base"
|
|
>
|
|
{editingProvider ? 'Update' : 'Add'}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setShowAddModal(false);
|
|
setEditingProvider(null);
|
|
resetForm();
|
|
}}
|
|
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 text-sm sm:text-base"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Desktop Table */}
|
|
<div className="hidden lg:block overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-gray-200">
|
|
<th className="text-left py-3 px-4 font-semibold text-sm">Name</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-sm">Display Name</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-sm">Status</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-sm">SSO</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-sm">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{providers.map((provider) => (
|
|
<tr key={provider.id} className="border-b border-gray-100">
|
|
<td className="py-3 px-4 font-medium text-sm">{provider.name}</td>
|
|
<td className="py-3 px-4 text-sm">{provider.display_name}</td>
|
|
<td className="py-3 px-4">
|
|
<span className={`px-2 py-1 rounded-full text-xs ${
|
|
provider.is_active
|
|
? 'bg-green-100 text-green-800'
|
|
: 'bg-gray-100 text-gray-800'
|
|
}`}>
|
|
{provider.is_active ? 'Active' : 'Inactive'}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
{provider.is_sso_enabled ? (
|
|
<CheckCircle className="w-5 h-5 text-green-500" />
|
|
) : (
|
|
<XCircle className="w-5 h-5 text-gray-400" />
|
|
)}
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => openEditModal(provider)}
|
|
className="px-3 py-1 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm"
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(provider.id)}
|
|
className="px-3 py-1 bg-red-500 text-white rounded-lg hover:bg-red-600 text-sm"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Mobile Cards */}
|
|
<div className="lg:hidden space-y-3">
|
|
{providers.map((provider) => (
|
|
<div key={provider.id} className="bg-gray-50 rounded-lg p-4 border border-gray-200 space-y-3">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-sm">{provider.name}</p>
|
|
<p className="text-xs sm:text-sm text-gray-600 mt-1">{provider.display_name}</p>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-2 flex-shrink-0">
|
|
<span className={`px-2 py-1 rounded-full text-xs ${
|
|
provider.is_active
|
|
? 'bg-green-100 text-green-800'
|
|
: 'bg-gray-100 text-gray-800'
|
|
}`}>
|
|
{provider.is_active ? 'Active' : 'Inactive'}
|
|
</span>
|
|
{provider.is_sso_enabled ? (
|
|
<CheckCircle className="w-5 h-5 text-green-500" />
|
|
) : (
|
|
<XCircle className="w-5 h-5 text-gray-400" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-gray-200">
|
|
<button
|
|
onClick={() => openEditModal(provider)}
|
|
className="flex-1 px-3 py-1.5 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm"
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(provider.id)}
|
|
className="flex-1 px-3 py-1.5 bg-red-500 text-white rounded-lg hover:bg-red-600 text-sm"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// GDPR Requests Tab Component
|
|
const GDPRRequestsTab: React.FC = () => {
|
|
const [requests, setRequests] = useState<DataSubjectRequest[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [selectedRequest, setSelectedRequest] = useState<DataSubjectRequest | null>(null);
|
|
const [filters, setFilters] = useState({
|
|
status: '',
|
|
request_type: ''
|
|
});
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
|
|
useEffect(() => {
|
|
fetchRequests();
|
|
}, [filters, currentPage]);
|
|
|
|
const fetchRequests = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await securityService.getGDPRRequests({
|
|
status: filters.status || undefined,
|
|
request_type: filters.request_type || undefined,
|
|
limit: 20,
|
|
offset: (currentPage - 1) * 20
|
|
});
|
|
setRequests(data);
|
|
setTotalPages(Math.ceil(data.length / 20));
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to fetch GDPR requests');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAssign = async (requestId: number) => {
|
|
try {
|
|
await securityService.assignGDPRRequest(requestId);
|
|
toast.success('Request assigned to you');
|
|
fetchRequests();
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to assign request');
|
|
}
|
|
};
|
|
|
|
const handleComplete = async (requestId: number) => {
|
|
try {
|
|
await securityService.completeGDPRRequest(requestId);
|
|
toast.success('Request marked as completed');
|
|
setSelectedRequest(null);
|
|
fetchRequests();
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to complete request');
|
|
}
|
|
};
|
|
|
|
const handleViewDetails = async (requestId: number) => {
|
|
try {
|
|
const request = await securityService.getGDPRRequest(requestId);
|
|
setSelectedRequest(request);
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to fetch request details');
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case 'completed': return 'bg-green-100 text-green-800';
|
|
case 'in_progress': return 'bg-blue-100 text-blue-800';
|
|
case 'pending': return 'bg-yellow-100 text-yellow-800';
|
|
case 'rejected': return 'bg-red-100 text-red-800';
|
|
default: return 'bg-gray-100 text-gray-800';
|
|
}
|
|
};
|
|
|
|
if (loading && requests.length === 0) {
|
|
return <Loading />;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex justify-between items-center">
|
|
<h3 className="text-lg sm:text-xl font-semibold">GDPR Requests</h3>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
|
<select
|
|
value={filters.status}
|
|
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
|
className="px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg"
|
|
>
|
|
<option value="">All Statuses</option>
|
|
<option value="pending">Pending</option>
|
|
<option value="in_progress">In Progress</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="rejected">Rejected</option>
|
|
</select>
|
|
<select
|
|
value={filters.request_type}
|
|
onChange={(e) => setFilters({ ...filters, request_type: e.target.value })}
|
|
className="px-3 sm:px-4 py-2 text-sm sm:text-base border border-gray-300 rounded-lg"
|
|
>
|
|
<option value="">All Types</option>
|
|
<option value="access">Access</option>
|
|
<option value="erasure">Erasure</option>
|
|
<option value="portability">Portability</option>
|
|
<option value="rectification">Rectification</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Desktop Table */}
|
|
<div className="hidden lg:block overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-gray-200">
|
|
<th className="text-left py-3 px-4 font-semibold text-sm">ID</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-sm">Email</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-sm">Type</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-sm">Status</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-sm">Verified</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-sm">Date</th>
|
|
<th className="text-left py-3 px-4 font-semibold text-sm">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{requests.map((request) => (
|
|
<tr key={request.id} className="border-b border-gray-100 hover:bg-gray-50">
|
|
<td className="py-3 px-4 text-sm">#{request.id}</td>
|
|
<td className="py-3 px-4 text-sm">{request.email}</td>
|
|
<td className="py-3 px-4 text-sm">{request.request_type}</td>
|
|
<td className="py-3 px-4">
|
|
<span className={`px-2 py-1 rounded-full text-xs ${getStatusColor(request.status)}`}>
|
|
{request.status}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-4">
|
|
{request.verified ? (
|
|
<CheckCircle className="w-5 h-5 text-green-500" />
|
|
) : (
|
|
<XCircle className="w-5 h-5 text-gray-400" />
|
|
)}
|
|
</td>
|
|
<td className="py-3 px-4 text-sm">{formatDate(request.created_at)}</td>
|
|
<td className="py-3 px-4">
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => handleViewDetails(request.id)}
|
|
className="px-3 py-1 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm"
|
|
>
|
|
View
|
|
</button>
|
|
{request.status === 'pending' && (
|
|
<button
|
|
onClick={() => handleAssign(request.id)}
|
|
className="px-3 py-1 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm"
|
|
>
|
|
Assign
|
|
</button>
|
|
)}
|
|
{request.status === 'in_progress' && (
|
|
<button
|
|
onClick={() => handleComplete(request.id)}
|
|
className="px-3 py-1 bg-purple-500 text-white rounded-lg hover:bg-purple-600 text-sm"
|
|
>
|
|
Complete
|
|
</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Mobile Cards */}
|
|
<div className="lg:hidden space-y-3">
|
|
{requests.map((request) => (
|
|
<div key={request.id} className="bg-gray-50 rounded-lg p-4 border border-gray-200 space-y-3">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="font-semibold text-sm">#{request.id}</span>
|
|
<span className={`px-2 py-1 rounded-full text-xs flex-shrink-0 ${getStatusColor(request.status)}`}>
|
|
{request.status}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-gray-600 truncate">{request.email}</p>
|
|
</div>
|
|
{request.verified ? (
|
|
<CheckCircle className="w-5 h-5 text-green-500 flex-shrink-0" />
|
|
) : (
|
|
<XCircle className="w-5 h-5 text-gray-400 flex-shrink-0" />
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
|
<div>
|
|
<span className="text-gray-500">Type:</span>
|
|
<span className="ml-1 text-gray-700">{request.request_type}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-500">Date:</span>
|
|
<span className="ml-1 text-gray-700">{formatDate(request.created_at)}</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col sm:flex-row gap-2 pt-2 border-t border-gray-200">
|
|
<button
|
|
onClick={() => handleViewDetails(request.id)}
|
|
className="flex-1 px-3 py-1.5 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm"
|
|
>
|
|
View
|
|
</button>
|
|
{request.status === 'pending' && (
|
|
<button
|
|
onClick={() => handleAssign(request.id)}
|
|
className="flex-1 px-3 py-1.5 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm"
|
|
>
|
|
Assign
|
|
</button>
|
|
)}
|
|
{request.status === 'in_progress' && (
|
|
<button
|
|
onClick={() => handleComplete(request.id)}
|
|
className="flex-1 px-3 py-1.5 bg-purple-500 text-white rounded-lg hover:bg-purple-600 text-sm"
|
|
>
|
|
Complete
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
onPageChange={setCurrentPage}
|
|
/>
|
|
|
|
{/* Request Details Modal */}
|
|
{selectedRequest && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-xl p-4 sm:p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
|
<div className="flex justify-between items-start mb-4">
|
|
<h4 className="text-base sm:text-lg font-semibold">GDPR Request Details</h4>
|
|
<button
|
|
onClick={() => setSelectedRequest(null)}
|
|
className="text-gray-500 hover:text-gray-700 flex-shrink-0 ml-2"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<div className="space-y-3 sm:space-y-4">
|
|
<div>
|
|
<label className="text-xs sm:text-sm font-medium text-gray-600">Request Type</label>
|
|
<p className="mt-1 text-sm sm:text-base">{selectedRequest.request_type}</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs sm:text-sm font-medium text-gray-600">Email</label>
|
|
<p className="mt-1 text-sm sm:text-base break-words">{selectedRequest.email}</p>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs sm:text-sm font-medium text-gray-600">Status</label>
|
|
<p className="mt-1">
|
|
<span className={`px-2 py-1 rounded-full text-xs ${getStatusColor(selectedRequest.status)}`}>
|
|
{selectedRequest.status}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
{selectedRequest.description && (
|
|
<div>
|
|
<label className="text-xs sm:text-sm font-medium text-gray-600">Description</label>
|
|
<p className="mt-1 text-sm sm:text-base">{selectedRequest.description}</p>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<label className="text-xs sm:text-sm font-medium text-gray-600">Created At</label>
|
|
<p className="mt-1 text-sm sm:text-base">{formatDate(selectedRequest.created_at)}</p>
|
|
</div>
|
|
{selectedRequest.status === 'in_progress' && (
|
|
<button
|
|
onClick={() => handleComplete(selectedRequest.id)}
|
|
className="w-full px-4 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 text-sm sm:text-base"
|
|
>
|
|
Mark as Completed
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Security Scan Tab Component
|
|
const SecurityScanTab: React.FC = () => {
|
|
const [scanning, setScanning] = useState(false);
|
|
const [scanResults, setScanResults] = useState<any>(null);
|
|
const [scheduleInterval, setScheduleInterval] = useState(24);
|
|
const [scheduled, setScheduled] = useState(false);
|
|
|
|
const handleRunScan = async () => {
|
|
setScanning(true);
|
|
setScanResults(null);
|
|
try {
|
|
const results = await securityService.runSecurityScan();
|
|
setScanResults(results);
|
|
toast.success(`Security scan completed: ${results.total_issues || 0} issues found`);
|
|
} catch (error: any) {
|
|
console.error('Security scan error:', error);
|
|
const errorMessage = error.response?.data?.detail || error.response?.data?.message || error.message || 'Failed to run security scan';
|
|
toast.error(errorMessage);
|
|
} finally {
|
|
setScanning(false);
|
|
}
|
|
};
|
|
|
|
const handleScheduleScan = async () => {
|
|
try {
|
|
await securityService.scheduleSecurityScan(scheduleInterval);
|
|
setScheduled(true);
|
|
toast.success(`Security scan scheduled to run every ${scheduleInterval} hours`);
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to schedule security scan');
|
|
}
|
|
};
|
|
|
|
const getSeverityColor = (severity: string) => {
|
|
switch (severity) {
|
|
case 'critical': return 'bg-red-100 text-red-800 border-red-200';
|
|
case 'high': return 'bg-orange-100 text-orange-800 border-orange-200';
|
|
case 'medium': return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
|
case 'low': return 'bg-blue-100 text-blue-800 border-blue-200';
|
|
default: return 'bg-gray-100 text-gray-800 border-gray-200';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4 sm:space-y-5 md:space-y-6">
|
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-4">
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="text-lg sm:text-xl font-semibold">Security Scanning</h3>
|
|
<p className="text-xs sm:text-sm text-gray-600 mt-1">Run automated security scans to detect vulnerabilities</p>
|
|
</div>
|
|
<button
|
|
onClick={handleRunScan}
|
|
disabled={scanning}
|
|
className="w-full sm:w-auto px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 text-sm sm:text-base"
|
|
>
|
|
{scanning ? (
|
|
<>
|
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
|
Scanning...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Activity className="w-4 h-4" />
|
|
Run Scan Now
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Schedule Section */}
|
|
<div className="bg-gray-50 rounded-xl p-4 sm:p-5 md:p-6 border border-gray-200">
|
|
<h4 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">Schedule Automatic Scans</h4>
|
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 sm:gap-4">
|
|
<label className="flex flex-col sm:flex-row items-start sm:items-center gap-2 text-sm sm:text-base">
|
|
<span>Run scan every</span>
|
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max="168"
|
|
value={scheduleInterval}
|
|
onChange={(e) => setScheduleInterval(parseInt(e.target.value))}
|
|
className="w-20 px-3 py-2 text-sm sm:text-base border border-gray-300 rounded-lg"
|
|
/>
|
|
<span>hours</span>
|
|
</div>
|
|
</label>
|
|
<button
|
|
onClick={handleScheduleScan}
|
|
className="w-full sm:w-auto px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm sm:text-base"
|
|
>
|
|
Schedule
|
|
</button>
|
|
</div>
|
|
{scheduled && (
|
|
<p className="mt-2 text-xs sm:text-sm text-green-600">
|
|
✓ Scans scheduled to run every {scheduleInterval} hours
|
|
</p>
|
|
)}
|
|
<p className="mt-4 text-xs sm:text-sm text-gray-600">
|
|
<Info className="w-3 h-3 sm:w-4 sm:h-4 inline mr-1" />
|
|
In production, use a task scheduler (like Celery or APScheduler) to run scans automatically.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Scan Results */}
|
|
{scanResults && (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
|
<h4 className="text-base sm:text-lg font-semibold">Scan Results</h4>
|
|
<div className="text-xs sm:text-sm text-gray-600">
|
|
Completed in {scanResults.duration_seconds?.toFixed(2)}s
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Cards */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">
|
|
<div className="bg-red-50 rounded-xl p-3 sm:p-4 border border-red-200">
|
|
<div className="text-xs sm:text-sm text-red-600 font-medium">Critical</div>
|
|
<div className="text-xl sm:text-2xl font-bold text-red-800 mt-1">{scanResults.critical_issues || 0}</div>
|
|
</div>
|
|
<div className="bg-orange-50 rounded-xl p-3 sm:p-4 border border-orange-200">
|
|
<div className="text-xs sm:text-sm text-orange-600 font-medium">High</div>
|
|
<div className="text-xl sm:text-2xl font-bold text-orange-800 mt-1">{scanResults.high_issues || 0}</div>
|
|
</div>
|
|
<div className="bg-yellow-50 rounded-xl p-3 sm:p-4 border border-yellow-200">
|
|
<div className="text-xs sm:text-sm text-yellow-600 font-medium">Medium</div>
|
|
<div className="text-xl sm:text-2xl font-bold text-yellow-800 mt-1">{scanResults.medium_issues || 0}</div>
|
|
</div>
|
|
<div className="bg-blue-50 rounded-xl p-3 sm:p-4 border border-blue-200">
|
|
<div className="text-xs sm:text-sm text-blue-600 font-medium">Low</div>
|
|
<div className="text-xl sm:text-2xl font-bold text-blue-800 mt-1">{scanResults.low_issues || 0}</div>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-xl p-3 sm:p-4 border border-gray-200 col-span-2 sm:col-span-1">
|
|
<div className="text-xs sm:text-sm text-gray-600 font-medium">Total</div>
|
|
<div className="text-xl sm:text-2xl font-bold text-gray-800 mt-1">{scanResults.total_issues || 0}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Check Results */}
|
|
<div className="space-y-3">
|
|
{scanResults.checks?.map((check: any, index: number) => (
|
|
<div
|
|
key={index}
|
|
className={`border rounded-lg sm:rounded-xl p-3 sm:p-4 ${
|
|
check.status === 'failed' ? 'border-red-200 bg-red-50' :
|
|
check.status === 'warning' ? 'border-yellow-200 bg-yellow-50' :
|
|
'border-green-200 bg-green-50'
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2 mb-2">
|
|
<h5 className="font-semibold text-sm sm:text-base">{check.check_name}</h5>
|
|
<span className={`px-2 py-1 rounded-full text-xs border flex-shrink-0 ${getSeverityColor(check.severity)}`}>
|
|
{check.severity}
|
|
</span>
|
|
<span className={`px-2 py-1 rounded-full text-xs flex-shrink-0 ${
|
|
check.status === 'failed' ? 'bg-red-100 text-red-800' :
|
|
check.status === 'warning' ? 'bg-yellow-100 text-yellow-800' :
|
|
'bg-green-100 text-green-800'
|
|
}`}>
|
|
{check.status}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs sm:text-sm text-gray-700 mb-2">{check.description}</p>
|
|
{check.recommendation && (
|
|
<p className="text-xs sm:text-sm text-blue-700 font-medium">
|
|
💡 Recommendation: {check.recommendation}
|
|
</p>
|
|
)}
|
|
{check.issue_count > 0 && (
|
|
<p className="text-xs sm:text-sm text-gray-600 mt-2">
|
|
Issues found: {check.issue_count}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!scanResults && !scanning && (
|
|
<div className="text-center py-8 sm:py-12 bg-gray-50 rounded-xl border border-gray-200 px-4">
|
|
<Activity className="w-12 h-12 sm:w-16 sm:h-16 text-gray-400 mx-auto mb-3 sm:mb-4" />
|
|
<p className="text-sm sm:text-base text-gray-600">No scan results yet. Click "Run Scan Now" to start a security scan.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SecurityManagementPage;
|
|
|