feat(admin-web): Phase 4 — event-subscriptions, ip-rules, maintenance, sessions, status, gdpr-export pages
This commit is contained in:
parent
aaceba2ee5
commit
e0bc38c365
@ -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<EventSub[]>([]);
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Event Subscriptions</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage DLQ replay and event subscription routing
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Add Subscription
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{subs.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Active</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-emerald-600">
|
||||
{subs.filter(s => s.status === 'active').length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Failed</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{subs.filter(s => s.status === 'disabled').length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-muted-foreground">Loading...</div>
|
||||
) : subs.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<Radio className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
No event subscriptions.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Event Type</TableHead>
|
||||
<TableHead>Callback URL</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Failures</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-[50px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subs.map(s => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{s.eventType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs max-w-[250px] truncate">
|
||||
{s.callbackUrl}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
s.status === 'active'
|
||||
? 'bg-emerald-50 text-emerald-700 border-0'
|
||||
: 'bg-red-50 text-red-700 border-0'
|
||||
}
|
||||
>
|
||||
{s.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{s.failureCount}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDate(s.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(s.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Event Subscription</DialogTitle>
|
||||
<DialogDescription>Subscribe to domain events with a callback URL.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Event Type</Label>
|
||||
<Input
|
||||
placeholder="e.g. user.created"
|
||||
value={newEvent}
|
||||
onChange={e => setNewEvent(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Callback URL</Label>
|
||||
<Input
|
||||
placeholder="https://example.com/callback"
|
||||
value={newUrl}
|
||||
onChange={e => setNewUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !newEvent.trim() || !newUrl.trim()}
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
dashboards/admin-web/src/app/(dashboard)/gdpr-export/page.tsx
Normal file
180
dashboards/admin-web/src/app/(dashboard)/gdpr-export/page.tsx
Normal file
@ -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<ExportJob[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [userId, setUserId] = useState('');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportResult, setExportResult] = useState<string | null>(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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">GDPR Data Export</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Export all user data for GDPR compliance (right of access / portability)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Request User Data Export</CardTitle>
|
||||
<CardDescription>
|
||||
Enter a user ID or email to export all their data as JSON.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label>User ID or Email</Label>
|
||||
<Input
|
||||
placeholder="user-id or user@example.com"
|
||||
value={userId}
|
||||
onChange={e => setUserId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleLookup} disabled={loading || !userId.trim()}>
|
||||
<Search className="mr-2 h-4 w-4" /> Lookup
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={exporting || !userId.trim()}>
|
||||
<Download className="mr-2 h-4 w-4" /> {exporting ? 'Exporting...' : 'Export Data'}
|
||||
</Button>
|
||||
</div>
|
||||
{exportResult && (
|
||||
<p
|
||||
className={`text-sm ${exportResult.includes('success') ? 'text-emerald-600' : 'text-destructive'}`}
|
||||
>
|
||||
{exportResult}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{jobs.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Export History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{jobs.map(job => (
|
||||
<div
|
||||
key={job.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileJson className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{job.email || job.userId}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Requested {formatDate(job.requestedAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
className={
|
||||
job.status === 'completed'
|
||||
? 'bg-emerald-50 text-emerald-700 border-0'
|
||||
: job.status === 'pending'
|
||||
? 'bg-amber-50 text-amber-700 border-0'
|
||||
: 'bg-gray-50 text-gray-600 border-0'
|
||||
}
|
||||
>
|
||||
{job.status}
|
||||
</Badge>
|
||||
{job.status === 'completed' && job.downloadUrl && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={job.downloadUrl} download>
|
||||
<Download className="mr-1 h-3 w-3" /> Download
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && jobs.length === 0 && userId && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
<Download className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
No previous exports for this user.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
256
dashboards/admin-web/src/app/(dashboard)/ip-rules/page.tsx
Normal file
256
dashboards/admin-web/src/app/(dashboard)/ip-rules/page.tsx
Normal file
@ -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<IpRule[]>([]);
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">IP Rules</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage IP allow/deny lists with CIDR matching
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Rules</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{rules.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Allow</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-emerald-600">{allowCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Deny</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">{denyCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-muted-foreground">Loading...</div>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<ShieldBan className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
No IP rules configured.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>CIDR</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Reason</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-[50px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rules.map(r => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-muted px-1.5 py-0.5 rounded">{r.cidr}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
r.type === 'allow'
|
||||
? 'bg-emerald-50 text-emerald-700 border-0'
|
||||
: 'bg-red-50 text-red-700 border-0'
|
||||
}
|
||||
>
|
||||
{r.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{r.reason || '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{r.expiresAt ? formatDate(r.expiresAt) : 'Never'}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDate(r.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => handleDelete(r.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add IP Rule</DialogTitle>
|
||||
<DialogDescription>Add an allow or deny rule with CIDR notation.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>CIDR</Label>
|
||||
<Input
|
||||
placeholder="e.g. 192.168.1.0/24 or 10.0.0.1/32"
|
||||
value={newCidr}
|
||||
onChange={e => setNewCidr(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<Select value={newType} onValueChange={setNewType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="allow">Allow</SelectItem>
|
||||
<SelectItem value="deny">Deny</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Reason (optional)</Label>
|
||||
<Input
|
||||
placeholder="Reason for this rule"
|
||||
value={newReason}
|
||||
onChange={e => setNewReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={creating || !newCidr.trim()}>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
dashboards/admin-web/src/app/(dashboard)/maintenance/page.tsx
Normal file
184
dashboards/admin-web/src/app/(dashboard)/maintenance/page.tsx
Normal file
@ -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<string, { label: string; color: string; description: string }> = {
|
||||
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<MaintenanceStatus>({ 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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Maintenance Mode</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Control platform availability: normal, read-only, maintenance, or emergency
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-muted-foreground py-8">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Power className="h-4 w-4" /> Current Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge className={`${cfg.color} border-0 text-sm px-3 py-1`}>{cfg.label}</Badge>
|
||||
<span className="text-sm text-muted-foreground">{cfg.description}</span>
|
||||
</div>
|
||||
{status.message && (
|
||||
<div className="mt-3 p-3 bg-muted rounded-lg">
|
||||
<p className="text-sm">{status.message}</p>
|
||||
</div>
|
||||
)}
|
||||
{status.mode !== 'off' && status.bypassRoles && status.bypassRoles.length > 0 && (
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
Bypass roles: {status.bypassRoles.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Change Mode</CardTitle>
|
||||
<CardDescription>
|
||||
Switch between maintenance modes. Admin roles always bypass.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Mode</Label>
|
||||
<Select value={newMode} onValueChange={setNewMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="off">Normal (all services active)</SelectItem>
|
||||
<SelectItem value="read_only">Read Only (writes blocked)</SelectItem>
|
||||
<SelectItem value="maintenance">Maintenance (platform unavailable)</SelectItem>
|
||||
<SelectItem value="emergency">Emergency (full lockdown)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Message (shown to users)</Label>
|
||||
<Textarea
|
||||
placeholder="Optional message..."
|
||||
value={newMessage}
|
||||
onChange={e => setNewMessage(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
{newMode === 'emergency' && (
|
||||
<div className="flex items-center gap-2 text-red-600 text-sm">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Emergency mode blocks ALL requests except admin bypass.
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
variant={newMode === 'emergency' ? 'destructive' : 'default'}
|
||||
>
|
||||
{saving
|
||||
? 'Saving...'
|
||||
: newMode === status.mode
|
||||
? 'Update Message'
|
||||
: `Switch to ${modeConfig[newMode]?.label || newMode}`}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
dashboards/admin-web/src/app/(dashboard)/sessions-admin/page.tsx
Normal file
196
dashboards/admin-web/src/app/(dashboard)/sessions-admin/page.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { MonitorSmartphone, Search, Trash2, Clock, Globe } 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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
|
||||
interface SessionEntry {
|
||||
id: string;
|
||||
userId: string;
|
||||
email?: string;
|
||||
device?: string;
|
||||
ip?: string;
|
||||
location?: string;
|
||||
lastActive: string;
|
||||
createdAt: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
async function apiFetch(path: string, opts?: RequestInit) {
|
||||
const res = await fetch(`/api/sessions/${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...opts,
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export default function SessionsAdminPage() {
|
||||
const [sessions, setSessions] = useState<SessionEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const qs = search ? `?search=${encodeURIComponent(search)}` : '';
|
||||
const data = await apiFetch(`list${qs}`);
|
||||
setSessions(Array.isArray(data?.sessions) ? data.sessions : Array.isArray(data) ? data : []);
|
||||
setLoading(false);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
async function handleRevoke(id: string) {
|
||||
if (!confirm('Revoke this session?')) return;
|
||||
await apiFetch(`${id}/revoke`, { method: 'POST' });
|
||||
loadData();
|
||||
}
|
||||
|
||||
async function handleRevokeAll(userId: string) {
|
||||
if (!confirm(`Revoke all sessions for this user?`)) return;
|
||||
await apiFetch(`revoke-all`, { method: 'POST', body: JSON.stringify({ userId }) });
|
||||
loadData();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Active Sessions</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
View and revoke user sessions across all devices
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Active Sessions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{sessions.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Unique Users
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{new Set(sessions.map(s => s.userId)).size}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Unique IPs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{new Set(sessions.filter(s => s.ip).map(s => s.ip)).size}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by email or IP..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-muted-foreground">Loading...</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<MonitorSmartphone className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||
No active sessions.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Device</TableHead>
|
||||
<TableHead>IP / Location</TableHead>
|
||||
<TableHead>Last Active</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-[80px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.map(s => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell>
|
||||
<div className="text-sm font-medium">{s.email || s.userId}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{s.device || '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<Globe className="h-3 w-3 text-muted-foreground" />
|
||||
{s.ip || '—'}
|
||||
{s.location && (
|
||||
<span className="text-muted-foreground">· {s.location}</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDate(s.lastActive)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDate(s.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive text-xs"
|
||||
onClick={() => handleRevoke(s.id)}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
dashboards/admin-web/src/app/(dashboard)/status/page.tsx
Normal file
149
dashboards/admin-web/src/app/(dashboard)/status/page.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Activity, CheckCircle2, XCircle, AlertTriangle, RefreshCw, Globe } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ServiceStatus {
|
||||
name: string;
|
||||
status: string;
|
||||
latency?: number;
|
||||
lastChecked: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { color: string; icon: typeof CheckCircle2 }> = {
|
||||
healthy: { color: 'bg-emerald-50 text-emerald-700', icon: CheckCircle2 },
|
||||
degraded: { color: 'bg-amber-50 text-amber-700', icon: AlertTriangle },
|
||||
down: { color: 'bg-red-50 text-red-700', icon: XCircle },
|
||||
unknown: { color: 'bg-gray-50 text-gray-600', icon: AlertTriangle },
|
||||
};
|
||||
|
||||
export default function StatusPage() {
|
||||
const [services, setServices] = useState<ServiceStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const checks = [
|
||||
{
|
||||
name: 'Platform Service',
|
||||
url: process.env.NEXT_PUBLIC_PLATFORM_URL || '/api/settings/kill-switch',
|
||||
},
|
||||
{ name: 'Admin Dashboard', url: '/api/health' },
|
||||
];
|
||||
|
||||
const results: ServiceStatus[] = [];
|
||||
for (const svc of checks) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await fetch(svc.url, { signal: AbortSignal.timeout(5000) });
|
||||
results.push({
|
||||
name: svc.name,
|
||||
status: res.ok ? 'healthy' : 'degraded',
|
||||
latency: Date.now() - start,
|
||||
lastChecked: new Date().toISOString(),
|
||||
url: svc.url,
|
||||
});
|
||||
} catch {
|
||||
results.push({
|
||||
name: svc.name,
|
||||
status: 'down',
|
||||
latency: Date.now() - start,
|
||||
lastChecked: new Date().toISOString(),
|
||||
url: svc.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
setServices(results);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const healthyCount = services.filter(s => s.status === 'healthy').length;
|
||||
const overallStatus =
|
||||
services.length === 0
|
||||
? 'unknown'
|
||||
: healthyCount === services.length
|
||||
? 'healthy'
|
||||
: healthyCount > 0
|
||||
? 'degraded'
|
||||
: 'down';
|
||||
const overallCfg = statusConfig[overallStatus];
|
||||
const OverallIcon = overallCfg.icon;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Platform Status</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Real-time health check of all platform services
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={loadData} disabled={loading}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} /> Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-8">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<OverallIcon
|
||||
className={`h-12 w-12 ${overallStatus === 'healthy' ? 'text-emerald-500' : overallStatus === 'degraded' ? 'text-amber-500' : 'text-red-500'}`}
|
||||
/>
|
||||
<h2 className="text-xl font-bold">
|
||||
{overallStatus === 'healthy'
|
||||
? 'All Systems Operational'
|
||||
: overallStatus === 'degraded'
|
||||
? 'Partial Degradation'
|
||||
: 'Service Disruption'}
|
||||
</h2>
|
||||
<Badge className={`${overallCfg.color} border-0 text-sm`}>
|
||||
{overallStatus.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-3">
|
||||
{services.map(svc => {
|
||||
const cfg = statusConfig[svc.status] || statusConfig.unknown;
|
||||
const StatusIcon = cfg.icon;
|
||||
return (
|
||||
<Card key={svc.name}>
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusIcon
|
||||
className={`h-5 w-5 ${svc.status === 'healthy' ? 'text-emerald-500' : svc.status === 'degraded' ? 'text-amber-500' : 'text-red-500'}`}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{svc.name}</p>
|
||||
{svc.url && (
|
||||
<p className="text-xs text-muted-foreground font-mono">{svc.url}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{svc.latency != null && (
|
||||
<span className="text-xs text-muted-foreground">{svc.latency}ms</span>
|
||||
)}
|
||||
<Badge className={`${cfg.color} border-0`}>{svc.status}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Event Subscriptions API proxy — forwards to platform-service.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth-server';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
|
||||
|
||||
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
try {
|
||||
const caller = await getCurrentUser(req.headers.get('authorization'));
|
||||
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const { path } = await params;
|
||||
const targetPath = `/api/event-subscriptions/${path.join('/')}`;
|
||||
const qs = new URL(req.url).search;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
|
||||
'x-user-id': caller.id,
|
||||
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
|
||||
};
|
||||
const fetchOptions: RequestInit = { method: req.method, headers };
|
||||
if (['POST', 'PUT', 'PATCH'].includes(req.method)) fetchOptions.body = await req.text();
|
||||
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
|
||||
} catch (error) {
|
||||
logError('Event subscriptions proxy error', error);
|
||||
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
43
dashboards/admin-web/src/app/api/ip-rules/[...path]/route.ts
Normal file
43
dashboards/admin-web/src/app/api/ip-rules/[...path]/route.ts
Normal file
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* IP Rules API proxy — forwards to platform-service.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth-server';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
|
||||
|
||||
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
try {
|
||||
const caller = await getCurrentUser(req.headers.get('authorization'));
|
||||
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const { path } = await params;
|
||||
const targetPath = `/api/ip-rules/${path.join('/')}`;
|
||||
const qs = new URL(req.url).search;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
|
||||
'x-user-id': caller.id,
|
||||
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
|
||||
};
|
||||
const fetchOptions: RequestInit = { method: req.method, headers };
|
||||
if (['POST', 'PUT', 'PATCH'].includes(req.method)) fetchOptions.body = await req.text();
|
||||
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
|
||||
} catch (error) {
|
||||
logError('IP rules proxy error', error);
|
||||
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Maintenance API proxy — forwards to platform-service.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth-server';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
|
||||
|
||||
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
try {
|
||||
const caller = await getCurrentUser(req.headers.get('authorization'));
|
||||
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const { path } = await params;
|
||||
const targetPath = `/api/maintenance/${path.join('/')}`;
|
||||
const qs = new URL(req.url).search;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
|
||||
'x-user-id': caller.id,
|
||||
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
|
||||
};
|
||||
const fetchOptions: RequestInit = { method: req.method, headers };
|
||||
if (['POST', 'PUT', 'PATCH'].includes(req.method)) fetchOptions.body = await req.text();
|
||||
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
|
||||
} catch (error) {
|
||||
logError('Maintenance proxy error', error);
|
||||
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
43
dashboards/admin-web/src/app/api/sessions/[...path]/route.ts
Normal file
43
dashboards/admin-web/src/app/api/sessions/[...path]/route.ts
Normal file
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Sessions API proxy — forwards to platform-service.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/auth-server';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
|
||||
|
||||
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
try {
|
||||
const caller = await getCurrentUser(req.headers.get('authorization'));
|
||||
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const { path } = await params;
|
||||
const targetPath = `/api/sessions/${path.join('/')}`;
|
||||
const qs = new URL(req.url).search;
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
|
||||
'x-user-id': caller.id,
|
||||
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
|
||||
};
|
||||
const fetchOptions: RequestInit = { method: req.method, headers };
|
||||
if (['POST', 'PUT', 'PATCH'].includes(req.method)) fetchOptions.body = await req.text();
|
||||
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
|
||||
const data = await res.json().catch(() => null);
|
||||
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
|
||||
} catch (error) {
|
||||
logError('Sessions proxy error', error);
|
||||
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
return proxy(req, ctx);
|
||||
}
|
||||
@ -49,6 +49,12 @@ import {
|
||||
Store,
|
||||
Mail,
|
||||
Timer,
|
||||
Radio,
|
||||
ShieldBan,
|
||||
Wrench,
|
||||
MonitorSmartphone,
|
||||
Globe,
|
||||
Download,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
@ -86,6 +92,12 @@ const navItems = [
|
||||
{ href: '/marketplace', label: 'Marketplace', icon: Store },
|
||||
{ href: '/delivery', label: 'Delivery Log', icon: Mail },
|
||||
{ href: '/jobs', label: 'Scheduled Jobs', icon: Timer },
|
||||
{ href: '/event-subscriptions', label: 'Event Subs', icon: Radio },
|
||||
{ href: '/ip-rules', label: 'IP Rules', icon: ShieldBan },
|
||||
{ href: '/maintenance', label: 'Maintenance', icon: Wrench },
|
||||
{ href: '/sessions-admin', label: 'Sessions', icon: MonitorSmartphone },
|
||||
{ href: '/status', label: 'Status', icon: Globe },
|
||||
{ href: '/gdpr-export', label: 'GDPR Export', icon: Download },
|
||||
{ href: '/experiments', label: 'Experiments', icon: FlaskConical },
|
||||
{ href: '/predictive/at-risk', label: 'Predictive', icon: TrendingDown },
|
||||
{ href: '/ops', label: 'Mission Control', icon: Activity },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user