diff --git a/dashboards/admin-web/src/app/(dashboard)/event-subscriptions/page.tsx b/dashboards/admin-web/src/app/(dashboard)/event-subscriptions/page.tsx new file mode 100644 index 00000000..f1424175 --- /dev/null +++ b/dashboards/admin-web/src/app/(dashboard)/event-subscriptions/page.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Radio, Plus, MoreHorizontal, Trash2, Pause, Play } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +interface EventSub { + id: string; + eventType: string; + callbackUrl: string; + status: string; + failureCount: number; + createdAt: string; +} + +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +async function apiFetch(path: string, opts?: RequestInit) { + const res = await fetch(`/api/event-subscriptions/${path}`, { + headers: { 'Content-Type': 'application/json' }, + ...opts, + }); + return res.json(); +} + +export default function EventSubscriptionsPage() { + const [subs, setSubs] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [creating, setCreating] = useState(false); + const [newEvent, setNewEvent] = useState(''); + const [newUrl, setNewUrl] = useState(''); + + const loadData = useCallback(async () => { + setLoading(true); + const data = await apiFetch('list'); + setSubs( + Array.isArray(data?.subscriptions) ? data.subscriptions : Array.isArray(data) ? data : [] + ); + setLoading(false); + }, []); + + useEffect(() => { + void loadData(); + }, [loadData]); + + async function handleCreate() { + setCreating(true); + await apiFetch('create', { + method: 'POST', + body: JSON.stringify({ eventType: newEvent, callbackUrl: newUrl }), + }); + setCreating(false); + setShowCreate(false); + setNewEvent(''); + setNewUrl(''); + loadData(); + } + + async function handleDelete(id: string) { + if (!confirm('Delete this subscription?')) return; + await apiFetch(`${id}`, { method: 'DELETE' }); + loadData(); + } + + return ( +
+
+
+

Event Subscriptions

+

+ Manage DLQ replay and event subscription routing +

+
+ +
+ +
+ + + Total + + +
{subs.length}
+
+
+ + + Active + + +
+ {subs.filter(s => s.status === 'active').length} +
+
+
+ + + Failed + + +
+ {subs.filter(s => s.status === 'disabled').length} +
+
+
+
+ + + + {loading ? ( +
Loading...
+ ) : subs.length === 0 ? ( +
+ + No event subscriptions. +
+ ) : ( + + + + Event Type + Callback URL + Status + Failures + Created + + + + + {subs.map(s => ( + + + + {s.eventType} + + + + {s.callbackUrl} + + + + {s.status} + + + {s.failureCount} + + {formatDate(s.createdAt)} + + + + + + + + handleDelete(s.id)} + className="text-destructive" + > + + Delete + + + + + + ))} + +
+ )} +
+
+ + + + + Add Event Subscription + Subscribe to domain events with a callback URL. + +
+
+ + setNewEvent(e.target.value)} + /> +
+
+ + setNewUrl(e.target.value)} + /> +
+
+ + + + +
+
+
+ ); +} diff --git a/dashboards/admin-web/src/app/(dashboard)/gdpr-export/page.tsx b/dashboards/admin-web/src/app/(dashboard)/gdpr-export/page.tsx new file mode 100644 index 00000000..a45b36d5 --- /dev/null +++ b/dashboards/admin-web/src/app/(dashboard)/gdpr-export/page.tsx @@ -0,0 +1,180 @@ +'use client'; + +import { useState } from 'react'; +import { Download, Search, FileJson, Clock, CheckCircle2 } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +interface ExportJob { + id: string; + userId: string; + email?: string; + status: string; + format: string; + requestedAt: string; + completedAt?: string; + downloadUrl?: string; +} + +function formatDate(iso: string) { + return new Date(iso).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +export default function GdprExportPage() { + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(false); + const [userId, setUserId] = useState(''); + const [exporting, setExporting] = useState(false); + const [exportResult, setExportResult] = useState(null); + + async function handleLookup() { + if (!userId.trim()) return; + setLoading(true); + try { + const res = await fetch( + `/api/maintenance/gdpr-exports?userId=${encodeURIComponent(userId)}`, + { + headers: { 'Content-Type': 'application/json' }, + } + ); + const data = await res.json(); + setJobs(Array.isArray(data?.exports) ? data.exports : Array.isArray(data) ? data : []); + } catch { + setJobs([]); + } + setLoading(false); + } + + async function handleExport() { + if (!userId.trim()) return; + setExporting(true); + setExportResult(null); + try { + const res = await fetch('/api/maintenance/gdpr-exports', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: userId.trim(), format: 'json' }), + }); + const data = await res.json(); + if (res.ok) { + setExportResult('Export job queued successfully.'); + handleLookup(); + } else { + setExportResult(data.error || 'Failed to queue export.'); + } + } catch { + setExportResult('Error requesting export.'); + } + setExporting(false); + } + + return ( +
+
+

GDPR Data Export

+

+ Export all user data for GDPR compliance (right of access / portability) +

+
+ + + + Request User Data Export + + Enter a user ID or email to export all their data as JSON. + + + +
+
+ + setUserId(e.target.value)} + /> +
+ + +
+ {exportResult && ( +

+ {exportResult} +

+ )} +
+
+ + {jobs.length > 0 && ( + + + Export History + + +
+ {jobs.map(job => ( +
+
+ +
+

{job.email || job.userId}

+

+ Requested {formatDate(job.requestedAt)} +

+
+
+
+ + {job.status} + + {job.status === 'completed' && job.downloadUrl && ( + + )} +
+
+ ))} +
+
+
+ )} + + {!loading && jobs.length === 0 && userId && ( + + + + No previous exports for this user. + + + )} +
+ ); +} diff --git a/dashboards/admin-web/src/app/(dashboard)/ip-rules/page.tsx b/dashboards/admin-web/src/app/(dashboard)/ip-rules/page.tsx new file mode 100644 index 00000000..115d46e0 --- /dev/null +++ b/dashboards/admin-web/src/app/(dashboard)/ip-rules/page.tsx @@ -0,0 +1,256 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { ShieldBan, Plus, Trash2, Clock } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +interface IpRule { + id: string; + cidr: string; + type: string; + reason?: string; + expiresAt?: string; + createdAt: string; +} + +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +async function apiFetch(path: string, opts?: RequestInit) { + const res = await fetch(`/api/ip-rules/${path}`, { + headers: { 'Content-Type': 'application/json' }, + ...opts, + }); + return res.json(); +} + +export default function IpRulesPage() { + const [rules, setRules] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [creating, setCreating] = useState(false); + const [newCidr, setNewCidr] = useState(''); + const [newType, setNewType] = useState('deny'); + const [newReason, setNewReason] = useState(''); + + const loadData = useCallback(async () => { + setLoading(true); + const data = await apiFetch('list'); + setRules(Array.isArray(data?.rules) ? data.rules : Array.isArray(data) ? data : []); + setLoading(false); + }, []); + + useEffect(() => { + void loadData(); + }, [loadData]); + + async function handleCreate() { + setCreating(true); + await apiFetch('create', { + method: 'POST', + body: JSON.stringify({ cidr: newCidr, type: newType, reason: newReason || undefined }), + }); + setCreating(false); + setShowCreate(false); + setNewCidr(''); + setNewType('deny'); + setNewReason(''); + loadData(); + } + + async function handleDelete(id: string) { + if (!confirm('Delete this IP rule?')) return; + await apiFetch(`${id}`, { method: 'DELETE' }); + loadData(); + } + + const denyCount = rules.filter(r => r.type === 'deny').length; + const allowCount = rules.filter(r => r.type === 'allow').length; + + return ( +
+
+
+

IP Rules

+

+ Manage IP allow/deny lists with CIDR matching +

+
+ +
+ +
+ + + Total Rules + + +
{rules.length}
+
+
+ + + Allow + + +
{allowCount}
+
+
+ + + Deny + + +
{denyCount}
+
+
+
+ + + + {loading ? ( +
Loading...
+ ) : rules.length === 0 ? ( +
+ + No IP rules configured. +
+ ) : ( + + + + CIDR + Type + Reason + Expires + Created + + + + + {rules.map(r => ( + + + {r.cidr} + + + + {r.type} + + + + {r.reason || '—'} + + + {r.expiresAt ? formatDate(r.expiresAt) : 'Never'} + + + {formatDate(r.createdAt)} + + + + + + ))} + +
+ )} +
+
+ + + + + Add IP Rule + Add an allow or deny rule with CIDR notation. + +
+
+ + setNewCidr(e.target.value)} + /> +
+
+ + +
+
+ + setNewReason(e.target.value)} + /> +
+
+ + + + +
+
+
+ ); +} diff --git a/dashboards/admin-web/src/app/(dashboard)/maintenance/page.tsx b/dashboards/admin-web/src/app/(dashboard)/maintenance/page.tsx new file mode 100644 index 00000000..0e62a118 --- /dev/null +++ b/dashboards/admin-web/src/app/(dashboard)/maintenance/page.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Wrench, AlertTriangle, CheckCircle2, Power } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +interface MaintenanceStatus { + mode: string; + message?: string; + bypassRoles?: string[]; + bypassIPs?: string[]; + scheduledAt?: string; + endsAt?: string; +} + +const modeConfig: Record = { + off: { + label: 'Normal', + color: 'bg-emerald-50 text-emerald-700', + description: 'All services operational', + }, + read_only: { + label: 'Read Only', + color: 'bg-amber-50 text-amber-700', + description: 'Write operations disabled', + }, + maintenance: { + label: 'Maintenance', + color: 'bg-orange-50 text-orange-700', + description: 'Platform under maintenance', + }, + emergency: { + label: 'Emergency', + color: 'bg-red-50 text-red-700', + description: 'Emergency lockdown active', + }, +}; + +async function apiFetch(path: string, opts?: RequestInit) { + const res = await fetch(`/api/maintenance/${path}`, { + headers: { 'Content-Type': 'application/json' }, + ...opts, + }); + return res.json(); +} + +export default function MaintenancePage() { + const [status, setStatus] = useState({ mode: 'off' }); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [newMode, setNewMode] = useState('off'); + const [newMessage, setNewMessage] = useState(''); + + const loadData = useCallback(async () => { + setLoading(true); + const data = await apiFetch('status'); + if (data?.mode) { + setStatus(data); + setNewMode(data.mode); + setNewMessage(data.message || ''); + } + setLoading(false); + }, []); + + useEffect(() => { + void loadData(); + }, [loadData]); + + async function handleSave() { + setSaving(true); + await apiFetch('mode', { + method: 'POST', + body: JSON.stringify({ mode: newMode, message: newMessage || undefined }), + }); + setSaving(false); + loadData(); + } + + const cfg = modeConfig[status.mode] || modeConfig.off; + + return ( +
+
+

Maintenance Mode

+

+ Control platform availability: normal, read-only, maintenance, or emergency +

+
+ + {loading ? ( +
Loading...
+ ) : ( + <> + + + + Current Status + + + +
+ {cfg.label} + {cfg.description} +
+ {status.message && ( +
+

{status.message}

+
+ )} + {status.mode !== 'off' && status.bypassRoles && status.bypassRoles.length > 0 && ( +
+ Bypass roles: {status.bypassRoles.join(', ')} +
+ )} +
+
+ + + + Change Mode + + Switch between maintenance modes. Admin roles always bypass. + + + +
+ + +
+
+ +