chore: deep cleanup of test scripts and implement glassmorphism UI enhancements
This commit is contained in:
1 parent
7777b62305
commit
58040d6e6c
81 files changed
+2247
-6734
No files matched your search
@@ -2,13 +2,17 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getAgents, createAgent, updateAgent, deleteAgent, Agent } from "@/lib/actions/agents";
|
||||
import { getAgents, createAgent, deleteAgent, Agent } from "@/lib/actions/agents";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { AgentDetailModal } from "@/components/ui/AgentDetailModal";
|
||||
import { Loader2, Plus, Edit, Trash2, Server, CheckCircle2, XCircle, ChevronRight } from "lucide-react";
|
||||
import AgentAccountModal from "@/components/admin/AgentAccountModal";
|
||||
import { Loader2, Plus, Trash2, Server, CheckCircle2, XCircle, ChevronRight, UserCog, Eye } from "lucide-react";
|
||||
|
||||
import { getAdminUsers, startViewAs, resolveAgentLabel, type ManagedUser } from "@/lib/admin-api";
|
||||
|
||||
export default function AgentsPage() {
|
||||
const router = useRouter();
|
||||
@@ -19,19 +23,20 @@ export default function AgentsPage() {
|
||||
|
||||
// Modals state
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
|
||||
// Detail modal
|
||||
const [detailAgent, setDetailAgent] = useState<Agent | null>(null);
|
||||
|
||||
// Known agent UUIDs with labels
|
||||
const AGENT_LABELS: Record<string, string> = {
|
||||
'2F-TF-1D-GK': 'JRP Cibubur',
|
||||
'8A-V3-PB-85': 'IFG LT.18',
|
||||
'F6-2V-DT-8A': 'CPI Balaraja',
|
||||
'1R-79-J9-YE': 'CPI Balaraja WAN',
|
||||
};
|
||||
// Admin: managed users (for label resolution + account management)
|
||||
const [managedUsers, setManagedUsers] = useState<ManagedUser[]>([]);
|
||||
|
||||
// Admin: AgentAccountModal state
|
||||
const [accountModalOpen, setAccountModalOpen] = useState(false);
|
||||
const [accountModalAgent, setAccountModalAgent] = useState<{ uuid: string; label: string } | null>(null);
|
||||
|
||||
// View-as loading state
|
||||
const [viewAsLoading, setViewAsLoading] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
||||
@@ -41,14 +46,32 @@ export default function AgentsPage() {
|
||||
const loadAgents = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await getAgents();
|
||||
setAgents(data);
|
||||
const [agentData, userData] = await Promise.allSettled([
|
||||
getAgents(),
|
||||
getAdminUsers(),
|
||||
]);
|
||||
if (agentData.status === 'fulfilled') setAgents(agentData.value);
|
||||
else setError((agentData.reason as Error).message);
|
||||
if (userData.status === 'fulfilled') setManagedUsers(userData.value);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleViewAs = async (agentUuid: string, agentLabel: string) => {
|
||||
setViewAsLoading(agentUuid);
|
||||
try {
|
||||
await startViewAs(agentUuid, agentLabel);
|
||||
router.refresh();
|
||||
window.location.reload();
|
||||
} catch (err: any) {
|
||||
alert('Gagal masuk mode View As: ' + err.message);
|
||||
} finally {
|
||||
setViewAsLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then(res => res.json())
|
||||
@@ -75,12 +98,6 @@ export default function AgentsPage() {
|
||||
setIsCreateOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (agent: Agent) => {
|
||||
setSelectedAgent(agent);
|
||||
setFormData({ agentId: agent.uuid || agent.serial, label: agent.label || "" });
|
||||
setIsEditOpen(true);
|
||||
};
|
||||
|
||||
const openDelete = (agent: Agent) => {
|
||||
setSelectedAgent(agent);
|
||||
setIsDeleteOpen(true);
|
||||
@@ -91,7 +108,6 @@ export default function AgentsPage() {
|
||||
setIsSubmitting(true);
|
||||
const res = await createAgent(formData.agentId, formData.label);
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (res.success) {
|
||||
setIsCreateOpen(false);
|
||||
loadAgents();
|
||||
@@ -100,29 +116,11 @@ export default function AgentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedAgent) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
const res = await updateAgent(selectedAgent.uuid || selectedAgent.serial, formData.label);
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (res.success) {
|
||||
setIsEditOpen(false);
|
||||
loadAgents();
|
||||
} else {
|
||||
alert(res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedAgent) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
const res = await deleteAgent(selectedAgent.uuid || selectedAgent.serial);
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (res.success) {
|
||||
setIsDeleteOpen(false);
|
||||
loadAgents();
|
||||
@@ -148,36 +146,78 @@ export default function AgentsPage() {
|
||||
{
|
||||
header: "Label",
|
||||
accessor: (row) => {
|
||||
const friendlyLabel = AGENT_LABELS[row.uuid || row.serial];
|
||||
// Ambil label dari account_name user yang terhubung ke agent ini
|
||||
const uuid = row.uuid || row.serial;
|
||||
const dynamicLabel = resolveAgentLabel(uuid, managedUsers);
|
||||
const hasAccount = managedUsers.some(u => u.agent_uuid === uuid && u.role === 'AGENT_VIEWER');
|
||||
return (
|
||||
<button onClick={() => setDetailAgent(row)} className="text-left hover:text-white transition-colors">
|
||||
{friendlyLabel || row.label || <span className="text-muted-foreground italic">No label</span>}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setDetailAgent(row)} className="text-left hover:text-white transition-colors">
|
||||
{dynamicLabel !== uuid ? dynamicLabel : (row.label || <span className="text-muted-foreground italic font-mono text-xs">{uuid}</span>)}
|
||||
</button>
|
||||
{!hasAccount && role === 'SUPER_ADMIN' && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-900/40 border border-amber-700/50 text-amber-400">Belum ada akun</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{ header: "Provisioned", accessor: (row) => row.provisioned ? <Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Yes</Badge> : <Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> No</Badge> },
|
||||
{ header: "Activated", accessor: (row) => row.activated ? <Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Yes</Badge> : <Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> No</Badge> },
|
||||
{ header: "Last Seen", accessor: (row) => <span className="text-muted-foreground">{row.last_seen_at?.human || '-'}</span> },
|
||||
{
|
||||
header: "Status",
|
||||
accessor: (row) => {
|
||||
const statusText = row.last_seen_at?.human || '-';
|
||||
if (statusText === 'Online') {
|
||||
return <span className="text-emerald-400 font-medium flex items-center gap-1.5"><div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></div> Online</span>;
|
||||
} else if (statusText === 'Offline') {
|
||||
return <span className="text-red-400 font-medium">Offline</span>;
|
||||
} else {
|
||||
return <span className="text-slate-400 text-xs">{statusText}</span>; // Last Seen ...
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
header: "Actions",
|
||||
accessor: (row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setDetailAgent(row)}
|
||||
className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-950/30 rounded transition-colors"
|
||||
title="View details"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); openEdit(row); }} className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-800 rounded transition-colors">
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); openDelete(row); }} className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-950/30 rounded transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
accessor: (row) => {
|
||||
const uuid = row.uuid || row.serial;
|
||||
const dynamicLabel = resolveAgentLabel(uuid, managedUsers);
|
||||
const existingUser = managedUsers.find(u => u.agent_uuid === uuid && u.role === 'AGENT_VIEWER') || null;
|
||||
const isViewAsLoading = viewAsLoading === uuid;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setDetailAgent(row)}
|
||||
className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-950/30 rounded transition-colors"
|
||||
title="Detail Agent"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
{role === 'SUPER_ADMIN' && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setAccountModalAgent({ uuid, label: dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid) }); setAccountModalOpen(true); }}
|
||||
className="p-1.5 text-emerald-400 hover:text-emerald-300 hover:bg-emerald-950/30 rounded transition-colors"
|
||||
title={existingUser ? 'Edit Akun Agent' : 'Buat Akun Agent'}
|
||||
>
|
||||
<UserCog className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleViewAs(uuid, dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid)); }}
|
||||
disabled={isViewAsLoading}
|
||||
className="p-1.5 text-amber-400 hover:text-amber-300 hover:bg-amber-950/30 rounded transition-colors disabled:opacity-50"
|
||||
title="Lihat Sebagai Agent Ini"
|
||||
>
|
||||
{isViewAsLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); openDelete(row); }} className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-950/30 rounded transition-colors" title="Hapus">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
@@ -272,39 +312,6 @@ export default function AgentsPage() {
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal isOpen={isEditOpen} onClose={() => setIsEditOpen(false)} title="Edit Agent">
|
||||
<form onSubmit={handleUpdate} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-400">Agent UUID / Serial</label>
|
||||
<input
|
||||
disabled
|
||||
type="text"
|
||||
value={selectedAgent?.uuid || selectedAgent?.serial || ""}
|
||||
className="w-full bg-slate-900/50 border border-slate-800 rounded-md px-3 py-2 text-sm text-slate-500 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">Agent Label</label>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="e.g. Branch Office Gateway"
|
||||
value={formData.label}
|
||||
onChange={e => setFormData({...formData, label: e.target.value})}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-md px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-4 flex justify-end gap-3">
|
||||
<button type="button" onClick={() => setIsEditOpen(false)} className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white transition-colors">Cancel</button>
|
||||
<button type="submit" disabled={isSubmitting} className="flex items-center gap-2 bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded-md text-sm font-medium transition-colors disabled:opacity-50">
|
||||
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<Modal isOpen={isDeleteOpen} onClose={() => setIsDeleteOpen(false)} title="Confirm Deletion">
|
||||
<div className="space-y-4">
|
||||
@@ -325,11 +332,25 @@ export default function AgentsPage() {
|
||||
{detailAgent && (
|
||||
<AgentDetailModal
|
||||
agentUuid={detailAgent.uuid || detailAgent.serial}
|
||||
agentLabel={AGENT_LABELS[detailAgent.uuid || detailAgent.serial] || detailAgent.label || detailAgent.uuid || detailAgent.serial}
|
||||
agentLabel={resolveAgentLabel(detailAgent.uuid || detailAgent.serial, managedUsers) !== (detailAgent.uuid || detailAgent.serial)
|
||||
? resolveAgentLabel(detailAgent.uuid || detailAgent.serial, managedUsers)
|
||||
: (detailAgent.label || detailAgent.uuid || detailAgent.serial)}
|
||||
onClose={() => setDetailAgent(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Agent Account Modal */}
|
||||
{accountModalOpen && accountModalAgent && (
|
||||
<AgentAccountModal
|
||||
isOpen={accountModalOpen}
|
||||
onClose={() => { setAccountModalOpen(false); setAccountModalAgent(null); }}
|
||||
onSuccess={() => { loadAgents(); }}
|
||||
agentUuid={accountModalAgent.uuid}
|
||||
agentLabel={accountModalAgent.label}
|
||||
existingUser={managedUsers.find(u => u.agent_uuid === accountModalAgent.uuid && u.role === 'AGENT_VIEWER') || null}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { Loader2, ChevronRight } from "lucide-react";
|
||||
|
||||
export default function DevicesPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const devices = useDevices(100);
|
||||
const devices = useDevices(1000);
|
||||
const [selectedDevice, setSelectedDevice] = useState<DeviceStat | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,7 +102,7 @@ export default function DevicesPage() {
|
||||
columns={columns}
|
||||
searchPlaceholder="Search devices by IP, MAC, OS..."
|
||||
searchFilter={(row, query) => {
|
||||
const q = query.toLowerCase();
|
||||
const q = query.trim().toLowerCase();
|
||||
return (
|
||||
(row.ip_address || "").toLowerCase().includes(q) ||
|
||||
(row.mac_address || "").toLowerCase().includes(q) ||
|
||||
|
||||
@@ -9,6 +9,24 @@ import { Loader2, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
|
||||
// Helper: format UTC ISO timestamp → Waktu WIB (UTC+7)
|
||||
function formatTimestampWIB(ts: string): string {
|
||||
try {
|
||||
return new Date(ts).toLocaleString('id-ID', {
|
||||
timeZone: 'Asia/Jakarta',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
function EventsContent() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { data, isLoading } = useEvents(100); // fetch 100 events
|
||||
@@ -36,18 +54,16 @@ function EventsContent() {
|
||||
|
||||
const columns: Column<typeof data[0]>[] = [
|
||||
{ header: "#", accessor: (row, i) => i + 1 },
|
||||
{
|
||||
header: "Timestamp",
|
||||
accessor: (row) => {
|
||||
try {
|
||||
return new Date(row.timestamp).toLocaleString();
|
||||
} catch {
|
||||
return row.timestamp;
|
||||
}
|
||||
}
|
||||
{
|
||||
header: "Timestamp (WIB)",
|
||||
accessor: (row) => (
|
||||
<span className="font-mono text-xs text-slate-300 whitespace-nowrap">
|
||||
{formatTimestampWIB(row.timestamp)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: "Severity",
|
||||
{
|
||||
header: "Severity",
|
||||
accessor: (row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{getSeverityIcon(row.severity)}
|
||||
@@ -56,11 +72,35 @@ function EventsContent() {
|
||||
)
|
||||
},
|
||||
{ header: "Event Type", accessor: (row) => row.event_type },
|
||||
{
|
||||
header: "Source IP",
|
||||
accessor: (row) => <span className="text-blue-400">{row.source_ip || "-"}</span>
|
||||
{
|
||||
header: "MAC Address",
|
||||
accessor: (row) => row.mac_address
|
||||
? <span className="font-mono text-xs text-emerald-400">{row.mac_address}</span>
|
||||
: <span className="text-slate-500">-</span>
|
||||
},
|
||||
{ header: "Message", accessor: (row) => row.message }
|
||||
{
|
||||
header: "Source IP",
|
||||
accessor: (row) => <span className="text-blue-400 font-mono text-xs">{row.source_ip || "-"}</span>
|
||||
},
|
||||
{
|
||||
header: "Message",
|
||||
accessor: (row) => {
|
||||
const msg = row.message;
|
||||
const mac = row.mac_address;
|
||||
if (!mac || !msg.includes(mac)) {
|
||||
return <span className="text-slate-200 text-sm">{msg}</span>;
|
||||
}
|
||||
// Highlight MAC in message with teal color (like Netify)
|
||||
const parts = msg.split(mac);
|
||||
return (
|
||||
<span className="text-slate-200 text-sm">
|
||||
{parts[0]}
|
||||
<span className="font-mono text-emerald-400 font-medium">{mac}</span>
|
||||
{parts[1]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -80,13 +120,14 @@ function EventsContent() {
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search by event type, severity, IP, or message..."
|
||||
searchPlaceholder="Search by event type, severity, MAC, IP, or message..."
|
||||
searchFilter={(row, query) => {
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
(row.event_type || "").toLowerCase().includes(q) ||
|
||||
(row.severity || "").toLowerCase().includes(q) ||
|
||||
(row.source_ip || "").toLowerCase().includes(q) ||
|
||||
(row.mac_address || "").toLowerCase().includes(q) ||
|
||||
(row.message || "").toLowerCase().includes(q)
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -58,6 +58,11 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou
|
||||
if (cookie) {
|
||||
headers.set('cookie', cookie);
|
||||
}
|
||||
// Forward X-View-As-Agent header for view-as mode data filtering
|
||||
const viewAsHeader = req.headers.get('x-view-as-agent');
|
||||
if (viewAsHeader) {
|
||||
headers.set('x-view-as-agent', viewAsHeader);
|
||||
}
|
||||
}
|
||||
|
||||
// Some requests like GET cannot have a body
|
||||
@@ -77,12 +82,30 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou
|
||||
// Return raw text so we don't try to parse it if it's not JSON
|
||||
const responseText = await response.text();
|
||||
|
||||
return new NextResponse(responseText, {
|
||||
// Build the response
|
||||
const nextResponse = new NextResponse(responseText, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': response.headers.get('content-type') || 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
// Forward ALL Set-Cookie headers individually so cookies reach the browser
|
||||
// This is critical for view_as_token and auth token cookies
|
||||
const rawCookies = response.headers.getSetCookie ? response.headers.getSetCookie() : [];
|
||||
if (rawCookies.length > 0) {
|
||||
rawCookies.forEach(cookie => {
|
||||
nextResponse.headers.append('Set-Cookie', cookie);
|
||||
});
|
||||
} else {
|
||||
// Fallback: single set-cookie header
|
||||
const setCookieHeader = response.headers.get('set-cookie');
|
||||
if (setCookieHeader) {
|
||||
nextResponse.headers.set('Set-Cookie', setCookieHeader);
|
||||
}
|
||||
}
|
||||
|
||||
return nextResponse;
|
||||
} catch (error: any) {
|
||||
console.error('[Proxy Error]', error);
|
||||
return NextResponse.json({ ok: false, message: error.message }, { status: 500 });
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { X, User, Key, Tag, Cpu, Upload, Trash2, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import type { ManagedUser } from "@/lib/admin-api";
|
||||
import { createAdminAgentUser, updateAdminAgentUser, deleteAdminAgentUser } from "@/lib/admin-api";
|
||||
|
||||
interface AgentAccountModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
agentUuid: string;
|
||||
agentLabel: string;
|
||||
existingUser: ManagedUser | null; // null = create mode
|
||||
}
|
||||
|
||||
export default function AgentAccountModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
agentUuid,
|
||||
agentLabel,
|
||||
existingUser,
|
||||
}: AgentAccountModalProps) {
|
||||
const isEdit = !!existingUser;
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [accountName, setAccountName] = useState("");
|
||||
const [previewPic, setPreviewPic] = useState<string | null>(null);
|
||||
const [picFile, setPicFile] = useState<File | null>(null);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [success, setSuccess] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setError("");
|
||||
setSuccess("");
|
||||
setPicFile(null);
|
||||
if (existingUser) {
|
||||
setUsername(existingUser.username);
|
||||
setAccountName(existingUser.account_name || "");
|
||||
setPassword("");
|
||||
setPreviewPic(
|
||||
existingUser.profile_picture
|
||||
? `/api/auth/uploads/${existingUser.profile_picture}`
|
||||
: null
|
||||
);
|
||||
} else {
|
||||
setUsername("");
|
||||
setAccountName(agentLabel !== agentUuid ? agentLabel : "");
|
||||
setPassword("");
|
||||
setPreviewPic(null);
|
||||
}
|
||||
}, [isOpen, existingUser, agentUuid, agentLabel]);
|
||||
|
||||
const handlePicChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setPicFile(file);
|
||||
setPreviewPic(URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSuccess("");
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (isEdit) {
|
||||
const fd = new FormData();
|
||||
fd.append("user_id", String(existingUser!.id));
|
||||
if (username.trim()) fd.append("username", username.trim());
|
||||
if (password) fd.append("password", password);
|
||||
fd.append("account_name", accountName.trim());
|
||||
fd.append("agent_uuid", agentUuid);
|
||||
if (picFile) fd.append("profile_picture", picFile);
|
||||
await updateAdminAgentUser(fd);
|
||||
setSuccess("Akun berhasil diperbarui!");
|
||||
} else {
|
||||
await createAdminAgentUser({
|
||||
username: username.trim(),
|
||||
password,
|
||||
account_name: accountName.trim() || undefined,
|
||||
agent_uuid: agentUuid,
|
||||
});
|
||||
setSuccess("Akun Network Agent berhasil dibuat!");
|
||||
}
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
onClose();
|
||||
}, 1200);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!existingUser) return;
|
||||
if (!confirm(`Hapus akun "${existingUser.username}"? Tindakan ini tidak dapat dibatalkan.`)) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteAdminAgentUser(existingUser.id);
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative z-10 w-full max-w-md bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-slate-700 bg-gradient-to-r from-blue-900/40 to-slate-900">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
{isEdit ? "Edit Akun Agent" : "Buat Akun Agent Baru"}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mt-0.5 font-mono">{agentUuid}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
{/* Profile Picture */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-16 h-16 rounded-full border-2 border-dashed border-slate-600 flex items-center justify-center cursor-pointer hover:border-blue-500 transition-colors overflow-hidden bg-slate-800"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{previewPic ? (
|
||||
<img src={previewPic} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Upload className="w-6 h-6 text-slate-500" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-300">Foto Profil</p>
|
||||
<p className="text-xs text-slate-500">Klik untuk upload gambar</p>
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handlePicChange} />
|
||||
</div>
|
||||
|
||||
{/* Account Name (Label) */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Tag className="w-3.5 h-3.5 inline mr-1" />
|
||||
Nama Akun (Label Agent)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={accountName}
|
||||
onChange={e => setAccountName(e.target.value)}
|
||||
placeholder="Contoh: Gateway 007"
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Nama ini akan tampil sebagai label di tab Agents</p>
|
||||
</div>
|
||||
|
||||
{/* Agent UUID (readonly info) */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Cpu className="w-3.5 h-3.5 inline mr-1" />
|
||||
UUID Agent
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={agentUuid}
|
||||
readOnly
|
||||
className="w-full px-3 py-2.5 bg-slate-800/50 border border-slate-700 rounded-lg text-slate-400 font-mono text-sm cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<User className="w-3.5 h-3.5 inline mr-1" />
|
||||
Username Login
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
placeholder="Username untuk login"
|
||||
required={!isEdit}
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Key className="w-3.5 h-3.5 inline mr-1" />
|
||||
Password {isEdit && <span className="text-slate-500">(kosongkan jika tidak ingin mengubah)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder={isEdit ? "••••••••" : "Password baru"}
|
||||
required={!isEdit}
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-900/30 border border-red-800 text-red-300 text-sm">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-900/30 border border-green-800 text-green-300 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
{isEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="flex items-center gap-1.5 px-3 py-2.5 rounded-lg bg-red-900/30 border border-red-800 text-red-300 text-sm hover:bg-red-900/50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
Hapus
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg border border-slate-600 text-slate-300 text-sm hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
{isEdit ? "Simpan Perubahan" : "Buat Akun"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Eye, ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { getViewAsStatusSync, stopViewAs, type ViewAsStatus } from "@/lib/admin-api";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function ViewAsAgentBanner() {
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState<ViewAsStatus>({ active: false });
|
||||
const [isLeaving, setIsLeaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Baca dari localStorage (sync) karena token disimpan di sana
|
||||
const current = getViewAsStatusSync();
|
||||
setStatus(current);
|
||||
}, []);
|
||||
|
||||
if (!status.active) return null;
|
||||
|
||||
const handleLeave = async () => {
|
||||
setIsLeaving(true);
|
||||
try {
|
||||
await stopViewAs(); // Hapus dari localStorage
|
||||
setStatus({ active: false });
|
||||
// Force reload agar semua data di-refresh tanpa view-as filter
|
||||
window.location.reload();
|
||||
} catch {
|
||||
setIsLeaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full bg-gradient-to-r from-amber-900/40 via-amber-800/30 to-amber-900/40 backdrop-blur-md border-b border-amber-500/20 px-4 py-2 flex items-center justify-between z-40">
|
||||
<div className="flex items-center gap-2 text-amber-200">
|
||||
<Eye className="w-4 h-4 text-amber-400 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Melihat sebagai:{" "}
|
||||
<span className="font-semibold text-amber-100">{status.agent_label}</span>
|
||||
<span className="text-amber-400 font-mono text-xs ml-1">({status.agent_uuid})</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLeave}
|
||||
disabled={isLeaving}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-amber-700/50 hover:bg-amber-700 border border-amber-600 text-amber-100 text-xs font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLeaving ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Kembali ke Admin
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Eye, EyeOff, Image as ImageIcon, Upload, Trash2 } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
|
||||
export function AccountSettingsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
user,
|
||||
setUser
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: any;
|
||||
setUser: (user: any) => void;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<'account_name' | 'username' | 'password' | 'profile_picture'>('account_name');
|
||||
|
||||
// Password state
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [successMsg, setSuccessMsg] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Username state
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [usernameErrorMsg, setUsernameErrorMsg] = useState("");
|
||||
const [usernameSuccessMsg, setUsernameSuccessMsg] = useState("");
|
||||
const [isSubmittingUsername, setIsSubmittingUsername] = useState(false);
|
||||
|
||||
// Account Name state
|
||||
const [newAccountName, setNewAccountName] = useState("");
|
||||
const [accountNameErrorMsg, setAccountNameErrorMsg] = useState("");
|
||||
const [accountNameSuccessMsg, setAccountNameSuccessMsg] = useState("");
|
||||
const [isSubmittingAccountName, setIsSubmittingAccountName] = useState(false);
|
||||
|
||||
// Profile Picture state
|
||||
const [profilePicFile, setProfilePicFile] = useState<File | null>(null);
|
||||
const [profilePicErrorMsg, setProfilePicErrorMsg] = useState("");
|
||||
const [profilePicSuccessMsg, setProfilePicSuccessMsg] = useState("");
|
||||
const [isUploadingPic, setIsUploadingPic] = useState(false);
|
||||
|
||||
// Change Password visibility state
|
||||
const [showCurrent, setShowCurrent] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
// Real-time checks for password strength validation
|
||||
const isMinLength = newPassword.length >= 6;
|
||||
const hasUppercase = /[A-Z]/.test(newPassword);
|
||||
const hasLowercase = /[a-z]/.test(newPassword);
|
||||
const hasNumber = /[0-9]/.test(newPassword);
|
||||
const passwordsMatch = newPassword === confirmPassword && confirmPassword.length > 0;
|
||||
const isPasswordValid = isMinLength && hasUppercase && hasLowercase && hasNumber && passwordsMatch;
|
||||
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isPasswordValid) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMsg("");
|
||||
setSuccessMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setSuccessMsg("Password berhasil diubah!");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} else {
|
||||
setErrorMsg(data.error || "Gagal mengubah password.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setErrorMsg(err.message || "Terjadi kesalahan sistem.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeUsername = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmittingUsername(true);
|
||||
setUsernameErrorMsg("");
|
||||
setUsernameSuccessMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/change-username', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newUsername }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setUsernameSuccessMsg("Username berhasil diubah!");
|
||||
setCurrentPassword("");
|
||||
if (user) {
|
||||
setUser({ ...user, username: data.newUsername });
|
||||
}
|
||||
} else {
|
||||
setUsernameErrorMsg(data.error || "Gagal mengubah username.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setUsernameErrorMsg(err.message || "Terjadi kesalahan sistem.");
|
||||
} finally {
|
||||
setIsSubmittingUsername(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeAccountName = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmittingAccountName(true);
|
||||
setAccountNameErrorMsg("");
|
||||
setAccountNameSuccessMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/change-account-name', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newAccountName }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setAccountNameSuccessMsg("Nama akun berhasil diubah!");
|
||||
setCurrentPassword("");
|
||||
if (user) {
|
||||
setUser({ ...user, account_name: data.newAccountName });
|
||||
}
|
||||
} else {
|
||||
setAccountNameErrorMsg(data.error || "Gagal mengubah nama akun.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setAccountNameErrorMsg(err.message || "Terjadi kesalahan sistem.");
|
||||
} finally {
|
||||
setIsSubmittingAccountName(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadProfilePicture = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!profilePicFile) return;
|
||||
|
||||
setIsUploadingPic(true);
|
||||
setProfilePicErrorMsg("");
|
||||
setProfilePicSuccessMsg("");
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('profile_picture', profilePicFile);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/upload-profile-picture', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setProfilePicSuccessMsg("Foto profil berhasil diperbarui!");
|
||||
setProfilePicFile(null);
|
||||
if (user) {
|
||||
setUser({ ...user, profile_picture: data.profile_picture });
|
||||
}
|
||||
} else {
|
||||
setProfilePicErrorMsg(data.error || "Gagal mengunggah foto profil.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setProfilePicErrorMsg(err.message || "Terjadi kesalahan sistem.");
|
||||
} finally {
|
||||
setIsUploadingPic(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveProfilePicture = async () => {
|
||||
setIsUploadingPic(true);
|
||||
setProfilePicErrorMsg("");
|
||||
setProfilePicSuccessMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/remove-profile-picture', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setProfilePicSuccessMsg("Foto profil berhasil dihapus!");
|
||||
if (user) {
|
||||
setUser({ ...user, profile_picture: undefined });
|
||||
}
|
||||
} else {
|
||||
setProfilePicErrorMsg(data.error || "Gagal menghapus foto profil.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setProfilePicErrorMsg(err.message || "Terjadi kesalahan sistem.");
|
||||
} finally {
|
||||
setIsUploadingPic(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset state when modal closes or opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setErrorMsg("");
|
||||
setSuccessMsg("");
|
||||
setUsernameErrorMsg("");
|
||||
setUsernameSuccessMsg("");
|
||||
setAccountNameErrorMsg("");
|
||||
setAccountNameSuccessMsg("");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setNewUsername("");
|
||||
setNewAccountName("");
|
||||
setProfilePicFile(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Account Settings">
|
||||
|
||||
<div className="flex mb-6 border-b border-border/50">
|
||||
<button
|
||||
onClick={() => setActiveTab('account_name')}
|
||||
className={cn(
|
||||
"flex-1 pb-3 text-sm font-medium transition-colors relative",
|
||||
activeTab === 'account_name' ? "text-primary" : "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Ubah Nama
|
||||
{activeTab === 'account_name' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full shadow-[0_-2px_10px_rgba(59,130,246,0.5)]" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('profile_picture')}
|
||||
className={cn(
|
||||
"flex-1 pb-3 text-sm font-medium transition-colors relative",
|
||||
activeTab === 'profile_picture' ? "text-primary" : "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Foto Profil
|
||||
{activeTab === 'profile_picture' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full shadow-[0_-2px_10px_rgba(59,130,246,0.5)]" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('username')}
|
||||
className={cn(
|
||||
"flex-1 pb-3 text-sm font-medium transition-colors relative",
|
||||
activeTab === 'username' ? "text-primary" : "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Ubah Username
|
||||
{activeTab === 'username' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full shadow-[0_-2px_10px_rgba(59,130,246,0.5)]" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('password')}
|
||||
className={cn(
|
||||
"flex-1 pb-3 text-sm font-medium transition-colors relative",
|
||||
activeTab === 'password' ? "text-primary" : "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Ubah Password
|
||||
{activeTab === 'password' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full shadow-[0_-2px_10px_rgba(59,130,246,0.5)]" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'profile_picture' ? (
|
||||
<div className="space-y-4">
|
||||
{profilePicErrorMsg && (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-xs text-red-400 border border-red-500/20">
|
||||
{profilePicErrorMsg}
|
||||
</div>
|
||||
)}
|
||||
{profilePicSuccessMsg && (
|
||||
<div className="rounded-lg bg-green-500/10 p-3 text-xs text-green-400 border border-green-500/20">
|
||||
{profilePicSuccessMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center justify-center py-4">
|
||||
<div className="h-24 w-24 rounded-full bg-gradient-to-tr from-primary to-blue-400 p-0.5 shadow-lg shadow-primary/20 mb-4 overflow-hidden relative group">
|
||||
<div className="flex h-full w-full items-center justify-center rounded-full bg-background overflow-hidden relative">
|
||||
{user?.profile_picture ? (
|
||||
<img src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-3xl font-bold text-white uppercase">
|
||||
{user?.account_name ? user.account_name.charAt(0) : (user?.username ? user.username.charAt(0) : (user?.role === 'SUPER_ADMIN' ? 'A' : 'V'))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 w-full">
|
||||
<form onSubmit={handleUploadProfilePicture} className="flex-1 flex flex-col space-y-3">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setProfilePicFile(e.target.files ? e.target.files[0] : null)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<div className="flex items-center justify-center w-full px-4 py-2 border border-dashed border-border rounded-lg bg-secondary/30 hover:bg-secondary/50 transition-colors">
|
||||
<Upload className="w-4 h-4 mr-2 text-slate-400" />
|
||||
<span className="text-sm text-slate-300 truncate">
|
||||
{profilePicFile ? profilePicFile.name : "Pilih File Gambar"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!profilePicFile || isUploadingPic}
|
||||
className="w-full rounded-lg px-4 py-2 text-sm font-medium text-[#fff] shadow-lg bg-gradient-to-r from-blue-600 to-blue-800 hover:from-blue-500 hover:to-blue-700 disabled:from-slate-700 disabled:to-slate-800 disabled:text-slate-500 disabled:shadow-none transition-all duration-300 flex items-center justify-center"
|
||||
>
|
||||
{isUploadingPic ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : (
|
||||
<ImageIcon className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isUploadingPic ? "Mengunggah..." : "Unggah Foto"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{user?.profile_picture && (
|
||||
<button
|
||||
onClick={handleRemoveProfilePicture}
|
||||
disabled={isUploadingPic}
|
||||
className="flex flex-col items-center justify-center px-4 py-2 border border-red-500/30 rounded-lg text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors shrink-0 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-5 h-5 mb-1" />
|
||||
<span className="text-[10px] font-medium">Hapus</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2 border-t border-border/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClose()}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Tutup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'account_name' ? (
|
||||
<form onSubmit={handleChangeAccountName} className="space-y-4">
|
||||
{accountNameErrorMsg && (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-xs text-red-400 border border-red-500/20">
|
||||
{accountNameErrorMsg}
|
||||
</div>
|
||||
)}
|
||||
{accountNameSuccessMsg && (
|
||||
<div className="rounded-lg bg-green-500/10 p-3 text-xs text-green-400 border border-green-500/20">
|
||||
{accountNameSuccessMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Nama Tampilan Baru</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newAccountName}
|
||||
onChange={(e) => setNewAccountName(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none"
|
||||
placeholder="Misal: Super Admin Budi"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Password Saat Ini (Otorisasi)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrent ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Ketik password untuk melanjutkan"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrent(!showCurrent)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showCurrent ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClose()}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newAccountName || !currentPassword || isSubmittingAccountName}
|
||||
className="rounded-lg px-6 py-2 text-sm font-medium text-[#fff] shadow-lg bg-gradient-to-r from-blue-600 to-blue-800 hover:from-blue-500 hover:to-blue-700 disabled:from-slate-700 disabled:to-slate-800 disabled:text-slate-500 disabled:shadow-none transition-all duration-300 flex items-center"
|
||||
>
|
||||
{isSubmittingAccountName ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : null}
|
||||
{isSubmittingAccountName ? "Menyimpan..." : "Simpan Nama Akun"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : activeTab === 'username' ? (
|
||||
<form onSubmit={handleChangeUsername} className="space-y-4">
|
||||
{usernameErrorMsg && (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-xs text-red-400 border border-red-500/20">
|
||||
{usernameErrorMsg}
|
||||
</div>
|
||||
)}
|
||||
{usernameSuccessMsg && (
|
||||
<div className="rounded-lg bg-green-500/10 p-3 text-xs text-green-400 border border-green-500/20">
|
||||
{usernameSuccessMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Username Baru</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none"
|
||||
placeholder="Masukkan username baru (min. 4 karakter)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Password Saat Ini (Otorisasi)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrent ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Ketik password untuk melanjutkan"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrent(!showCurrent)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showCurrent ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClose()}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newUsername || !currentPassword || isSubmittingUsername}
|
||||
className="rounded-lg px-6 py-2 text-sm font-medium text-[#fff] shadow-lg bg-gradient-to-r from-blue-600 to-blue-800 hover:from-blue-500 hover:to-blue-700 disabled:from-slate-700 disabled:to-slate-800 disabled:text-slate-500 disabled:shadow-none transition-all duration-300 flex items-center"
|
||||
>
|
||||
{isSubmittingUsername ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : null}
|
||||
{isSubmittingUsername ? "Menyimpan..." : "Simpan Username"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handlePasswordChange} className="space-y-4">
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-xs text-red-400 border border-red-500/20">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="rounded-lg bg-green-500/10 p-3 text-xs text-green-400 border border-green-500/20">
|
||||
{successMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Password Saat Ini</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrent ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Masukkan password saat ini"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrent(!showCurrent)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showCurrent ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Password Baru</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showNew ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Ketik password baru"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNew(!showNew)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showNew ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Konfirmasi Password Baru</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirm ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Ketik ulang password baru"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm(!showConfirm)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showConfirm ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Real-time Checklist */}
|
||||
<div className="rounded-xl bg-gradient-to-br from-secondary/40 to-background p-4 border border-border/50 shadow-inner space-y-3">
|
||||
<span className="text-xs font-semibold text-slate-200">Syarat Kekuatan Password:</span>
|
||||
<ul className="text-xs space-y-2">
|
||||
<li className={cn("flex items-center transition-colors duration-200", isMinLength ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{isMinLength ? "✓" : "○"}</span> Minimal 6 karakter
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", hasUppercase ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{hasUppercase ? "✓" : "○"}</span> Mengandung huruf besar (A-Z)
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", hasLowercase ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{hasLowercase ? "✓" : "○"}</span> Mengandung huruf kecil (a-z)
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", hasNumber ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{hasNumber ? "✓" : "○"}</span> Mengandung angka (0-9)
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", passwordsMatch ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{passwordsMatch ? "✓" : "○"}</span> Password cocok
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClose()}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isPasswordValid || isSubmitting}
|
||||
className="rounded-lg px-6 py-2 text-sm font-medium text-[#fff] shadow-lg bg-gradient-to-r from-blue-600 to-blue-800 hover:from-blue-500 hover:to-blue-700 disabled:from-slate-700 disabled:to-slate-800 disabled:text-slate-500 disabled:shadow-none transition-all duration-300 flex items-center"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : null}
|
||||
{isSubmitting ? "Menyimpan..." : "Simpan Password"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useState, useEffect } from "react";
|
||||
import { ReactNode } from "react";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Header } from "./Header";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState<string>("—");
|
||||
const [intervalText, setIntervalText] = useState<string>("");
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
|
||||
const pathname = usePathname();
|
||||
|
||||
const fetchDataIntervalRange = () => {
|
||||
fetch('/api/dashboard/data-interval')
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
if (res.ok && res.data) {
|
||||
const { start, end } = res.data;
|
||||
if (start && end) {
|
||||
const sDate = new Date(start);
|
||||
const eDate = new Date(end);
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
};
|
||||
setIntervalText(`${sDate.toLocaleString('id-ID', options)} - ${eDate.toLocaleString('id-ID', options)}`);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const updateTime = new Date().toLocaleString('id-ID');
|
||||
setLastUpdate(updateTime);
|
||||
fetchDataIntervalRange();
|
||||
}, []);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await fetch('/api/dashboard/refresh', { method: 'POST' });
|
||||
} catch {}
|
||||
const updateTime = new Date().toLocaleString('id-ID');
|
||||
setLastUpdate(updateTime);
|
||||
fetchDataIntervalRange();
|
||||
setIsRefreshing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-transparent transition-colors duration-500 relative">
|
||||
<Sidebar isCollapsed={isSidebarCollapsed} />
|
||||
<Sidebar />
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden w-full min-w-0 relative transition-all duration-500 ease-in-out">
|
||||
<Header
|
||||
lastUpdate={lastUpdate}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={handleRefresh}
|
||||
intervalText={intervalText}
|
||||
onMenuClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
/>
|
||||
<Header />
|
||||
<main className="flex-1 overflow-y-auto overflow-x-hidden bg-transparent p-4 md:p-6 transition-all duration-500 ease-in-out w-full relative">
|
||||
<div className="mx-auto w-full max-w-7xl space-y-6">
|
||||
{children}
|
||||
|
||||
@@ -1,319 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { Bell, RefreshCw, Calendar, AlertTriangle, Info, Clock, Check, Menu } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useThreats, useEvents } from "@/lib/api";
|
||||
import ViewAsAgentBanner from "@/components/admin/ViewAsAgentBanner";
|
||||
|
||||
const AGENT_LABELS: Record<string, string> = {
|
||||
'2F-TF-1D-GK': 'JRP Cibubur',
|
||||
'8A-V3-PB-85': 'IFG LT.18',
|
||||
'F6-2V-DT-8A': 'CPI Balaraja',
|
||||
'1R-79-J9-YE': 'CPI Balaraja WAN',
|
||||
};
|
||||
|
||||
interface AppNotification {
|
||||
id: string;
|
||||
dbId: number;
|
||||
title: string;
|
||||
message: string;
|
||||
severity: string;
|
||||
timestamp: string;
|
||||
type: 'threat' | 'event';
|
||||
}
|
||||
|
||||
function RealtimeClock() {
|
||||
const [time, setTime] = useState<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTime(new Date());
|
||||
const timer = setInterval(() => setTime(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
if (!time) return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1 text-xs text-muted-foreground font-mono shadow-sm opacity-0">
|
||||
00:00:00
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1 text-xs font-mono shadow-sm text-cyan-400">
|
||||
{time.toLocaleTimeString('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header({
|
||||
lastUpdate = "—",
|
||||
isRefreshing = false,
|
||||
onRefresh = () => {},
|
||||
intervalText = "",
|
||||
onMenuClick,
|
||||
}: {
|
||||
lastUpdate?: string;
|
||||
isRefreshing?: boolean;
|
||||
onRefresh?: () => void;
|
||||
intervalText?: string;
|
||||
onMenuClick?: () => void;
|
||||
}) {
|
||||
const [user, setUser] = useState<{ role?: string; agent_uuid?: string } | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [readIds, setReadIds] = useState<string[]>([]);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Load threats and events
|
||||
const { data: threats } = useThreats(30);
|
||||
const { data: events } = useEvents(30);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.user) {
|
||||
setUser(data.user);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error(err));
|
||||
|
||||
// Load read notifications
|
||||
const stored = localStorage.getItem('read_notifications');
|
||||
if (stored) {
|
||||
try {
|
||||
setReadIds(JSON.parse(stored));
|
||||
} catch {}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle click outside to close dropdown
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Combine threats and events into notifications
|
||||
const notifications = useMemo<AppNotification[]>(() => {
|
||||
const list: AppNotification[] = [];
|
||||
|
||||
if (threats) {
|
||||
for (const t of threats) {
|
||||
list.push({
|
||||
id: `threat-${t.id || t.detected_at}`,
|
||||
dbId: t.id,
|
||||
title: "Ancaman Terdeteksi!",
|
||||
message: `${t.threat_type || "Malicious traffic"} dari IP ${t.ip_address || "unknown"}`,
|
||||
severity: t.severity || "high",
|
||||
timestamp: t.detected_at || "",
|
||||
type: "threat"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (events) {
|
||||
for (const e of events) {
|
||||
const severity = (e.severity || "info").toLowerCase();
|
||||
if (severity === "critical" || severity === "high" || severity === "medium" || severity === "warning") {
|
||||
list.push({
|
||||
id: `event-${e.id || e.timestamp}`,
|
||||
dbId: e.id,
|
||||
title: `Event: ${e.event_type || "Sistem"}`,
|
||||
message: e.message || "",
|
||||
severity: e.severity || "warning",
|
||||
timestamp: e.timestamp || "",
|
||||
type: "event"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp desc
|
||||
list.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
return list;
|
||||
}, [threats, events]);
|
||||
|
||||
const unreadCount = useMemo(() => {
|
||||
return notifications.filter(n => !readIds.includes(n.id)).length;
|
||||
}, [notifications, readIds]);
|
||||
|
||||
const markAllAsRead = () => {
|
||||
const allIds = notifications.map(n => n.id);
|
||||
setReadIds(allIds);
|
||||
localStorage.setItem('read_notifications', JSON.stringify(allIds));
|
||||
};
|
||||
|
||||
const handleNotificationClick = (n: AppNotification) => {
|
||||
if (!readIds.includes(n.id)) {
|
||||
const updated = [...readIds, n.id];
|
||||
setReadIds(updated);
|
||||
localStorage.setItem('read_notifications', JSON.stringify(updated));
|
||||
}
|
||||
setIsOpen(false);
|
||||
|
||||
// Direct and highlight query
|
||||
const target = n.type === 'threat'
|
||||
? `/threats?highlight=${n.dbId}`
|
||||
: `/events?highlight=${n.dbId}`;
|
||||
|
||||
router.push(target);
|
||||
};
|
||||
|
||||
const getSeverityStyles = (severity: string) => {
|
||||
const s = severity.toLowerCase();
|
||||
if (s.includes('critical') || s.includes('high')) {
|
||||
return {
|
||||
bg: 'bg-red-500/10 hover:bg-red-500/15',
|
||||
border: 'border-red-500/20',
|
||||
text: 'text-red-400',
|
||||
icon: <AlertTriangle className="w-4 h-4 text-red-500 shrink-0" />
|
||||
};
|
||||
}
|
||||
if (s.includes('med') || s.includes('warning')) {
|
||||
return {
|
||||
bg: 'bg-yellow-500/10 hover:bg-yellow-500/15',
|
||||
border: 'border-yellow-500/20',
|
||||
text: 'text-yellow-400',
|
||||
icon: <AlertTriangle className="w-4 h-4 text-yellow-500 shrink-0" />
|
||||
};
|
||||
}
|
||||
return {
|
||||
bg: 'bg-blue-500/10 hover:bg-blue-500/15',
|
||||
border: 'border-blue-500/20',
|
||||
text: 'text-blue-400',
|
||||
icon: <Info className="w-4 h-4 text-blue-500 shrink-0" />
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex min-h-[4rem] h-auto py-3 shrink-0 flex-row flex-wrap items-center justify-between border-b border-border bg-background px-4 lg:px-6 gap-3 z-20">
|
||||
<div className="flex flex-wrap items-center justify-start gap-2 lg:gap-4 w-full lg:w-auto">
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="p-2 -ml-2 mr-1 text-muted-foreground hover:text-white hover:bg-secondary/50 rounded-lg transition-colors shrink-0"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1 text-xs text-muted-foreground whitespace-nowrap shadow-sm">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse shrink-0" />
|
||||
<span className="truncate max-w-[200px] sm:max-w-[250px] md:max-w-none">
|
||||
Site: {process.env.NEXT_PUBLIC_SITE_ID || "Unknown Site"} - {(() => {
|
||||
if (user?.role === 'SUPER_ADMIN') return 'Admin';
|
||||
if (user?.role === 'AGENT_VIEWER') {
|
||||
const label = AGENT_LABELS[user.agent_uuid || ''];
|
||||
return label ? `Network Agent (${label})` : 'Network Agent';
|
||||
}
|
||||
return 'User';
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
{intervalText && (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-blue-500/10 border border-blue-500/20 px-3 py-1 text-xs text-blue-400 font-medium whitespace-nowrap">
|
||||
<Calendar className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="truncate max-w-[140px] sm:max-w-[250px] md:max-w-none">
|
||||
{intervalText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<RealtimeClock />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-start lg:justify-end gap-2 lg:gap-4 w-full lg:w-auto mt-2 lg:mt-0">
|
||||
|
||||
<div className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Update: {lastUpdate}
|
||||
</div>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="flex items-center gap-2 rounded-md bg-secondary px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-secondary/80 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
|
||||
{/* 🔔 Notifications Button & Panel */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="relative rounded-full p-2 text-muted-foreground hover:bg-secondary hover:text-white transition-colors focus:outline-none"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[9px] font-bold text-white shadow-lg">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2.5 w-96 origin-top-right rounded-2xl border border-border/80 bg-card/95 backdrop-blur-xl p-4 shadow-2xl ring-1 ring-black/5 focus:outline-none z-50 animate-fade-in">
|
||||
<div className="flex items-center justify-between border-b border-border/50 pb-2.5 mb-2.5">
|
||||
<span className="text-sm font-semibold text-white flex items-center gap-1.5">
|
||||
<Bell className="w-4 h-4 text-primary" /> Notifikasi
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-[11px] text-muted-foreground font-normal">
|
||||
({unreadCount} baru)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllAsRead}
|
||||
className="text-xs text-primary hover:text-primary/80 transition-colors flex items-center gap-1 font-medium"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" /> Tandai semua dibaca
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
Tidak ada notifikasi keamanan saat ini.
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((n) => {
|
||||
const isRead = readIds.includes(n.id);
|
||||
const styles = getSeverityStyles(n.severity);
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
onClick={() => handleNotificationClick(n)}
|
||||
className={`flex gap-3 p-3 rounded-xl border transition-all cursor-pointer select-none group ${styles.bg} ${styles.border} ${!isRead ? 'ring-1 ring-primary/25 shadow-sm' : 'opacity-60 hover:opacity-100'}`}
|
||||
>
|
||||
{styles.icon}
|
||||
<div className="flex-1 space-y-0.5 min-w-0">
|
||||
<div className="flex justify-between items-start gap-1">
|
||||
<span className={`text-xs font-semibold truncate ${styles.text}`}>
|
||||
{n.title}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-500 whitespace-nowrap flex items-center gap-0.5">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
{new Date(n.timestamp).toLocaleString('id-ID', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-300 leading-normal line-clamp-2">
|
||||
{n.message}
|
||||
</p>
|
||||
</div>
|
||||
{!isRead && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary shrink-0 self-center" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
export function Header() {
|
||||
return <ViewAsAgentBanner />;
|
||||
}
|
||||
+335
-775
File diff suppressed because it is too large.
Load diff
@@ -133,7 +133,7 @@ export function AppDetailModal({ appLabel, favicon, category, onClose }: Props)
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4 text-cyan-400" />
|
||||
{data.has_domain_breakdown ? "Top Devices (via Domain Match)" : "Top Devices / IPs"}
|
||||
Top 5 Flow Records (Realtime)
|
||||
{data.top_ips.filter(ip => ip.blacklisted).length > 0 && (
|
||||
<span className="ml-auto text-xs text-red-400 bg-red-500/10 px-2 py-0.5 rounded-full ring-1 ring-red-500/30">
|
||||
🚫 {data.top_ips.filter(ip => ip.blacklisted).length} Blacklisted
|
||||
@@ -146,12 +146,14 @@ export function AppDetailModal({ appLabel, favicon, category, onClose }: Props)
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left border-b border-border">
|
||||
<th className="pb-2 text-xs font-medium text-muted-foreground">#</th>
|
||||
<th className="pb-2 text-xs font-medium text-muted-foreground">IP Address</th>
|
||||
<th className="pb-2 text-xs font-medium text-muted-foreground text-right">Download</th>
|
||||
<th className="pb-2 text-xs font-medium text-muted-foreground text-right">Upload</th>
|
||||
<th className="pb-2 text-xs font-medium text-muted-foreground">Usage</th>
|
||||
<tr className="text-left border-b border-white/10">
|
||||
<th className="pb-3 pr-4 text-xs font-medium text-muted-foreground w-8">#</th>
|
||||
<th className="pb-3 pr-4 text-xs font-medium text-muted-foreground">Source IP</th>
|
||||
<th className="pb-3 pr-4 text-xs font-medium text-muted-foreground">Dest IP</th>
|
||||
<th className="pb-3 pr-4 text-xs font-medium text-muted-foreground">Label / Domain</th>
|
||||
<th className="pb-3 pr-4 text-xs font-medium text-muted-foreground">Time</th>
|
||||
<th className="pb-3 pr-4 text-xs font-medium text-muted-foreground text-right">Download</th>
|
||||
<th className="pb-3 text-xs font-medium text-muted-foreground text-right">Upload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
@@ -159,9 +161,9 @@ export function AppDetailModal({ appLabel, favicon, category, onClose }: Props)
|
||||
const maxDl = Math.max(...data.top_ips.map(x => x.download), 1);
|
||||
const pct = (ip.download / maxDl) * 100;
|
||||
return (
|
||||
<tr key={i} className="hover:bg-white/[0.03] transition-colors">
|
||||
<td className="py-2 pr-4 text-xs text-muted-foreground">{i + 1}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">
|
||||
<tr key={i} className="hover:bg-white/[0.04] transition-colors group">
|
||||
<td className="py-3 pr-4 text-xs text-muted-foreground group-hover:text-white transition-colors">{i + 1}</td>
|
||||
<td className="py-3 pr-4 font-mono text-xs">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className={ip.blacklisted ? "text-red-400" : "text-blue-400"}>{ip.ip_address || "-"}</span>
|
||||
{ip.blacklisted && (
|
||||
@@ -172,16 +174,17 @@ export function AppDetailModal({ appLabel, favicon, category, onClose }: Props)
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right text-xs text-cyan-400">{fmtBytes(ip.download)}</td>
|
||||
<td className="py-2 pr-4 text-right text-xs text-green-400">{fmtBytes(ip.upload ?? 0)}</td>
|
||||
<td className="py-2 w-32">
|
||||
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${ip.blacklisted ? "bg-red-500" : "bg-gradient-to-r from-cyan-500 to-green-500"}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<td className="py-3 pr-4 font-mono text-xs text-muted-foreground truncate max-w-[120px]">
|
||||
{ip.dst_ip || "-"}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-xs text-slate-300 truncate max-w-[150px]">
|
||||
{ip.domain || data.app_label || "-"}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{ip.last_seen ? new Date(ip.last_seen).toLocaleString('id-ID', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) : "-"}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-right text-xs font-medium text-cyan-400">{fmtBytes(ip.download)}</td>
|
||||
<td className="py-3 text-right text-xs font-medium text-green-400">{fmtBytes(ip.upload ?? 0)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -16,6 +16,7 @@ interface DataTableProps<T> {
|
||||
searchFilter?: (row: T, query: string) => boolean;
|
||||
isLoading?: boolean;
|
||||
getRowClassName?: (row: T) => string;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
@@ -24,14 +25,20 @@ export function DataTable<T>({
|
||||
searchPlaceholder = "Search...",
|
||||
searchFilter,
|
||||
isLoading,
|
||||
getRowClassName
|
||||
getRowClassName,
|
||||
pageSize = 50
|
||||
}: DataTableProps<T>) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const filteredData = searchFilter
|
||||
? data.filter(row => searchFilter(row, query))
|
||||
? data.filter(row => searchFilter(row, query.trim()))
|
||||
: data;
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredData.length / pageSize));
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const paginatedData = filteredData.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{searchFilter && (
|
||||
@@ -41,7 +48,10 @@ export function DataTable<T>({
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full max-w-sm rounded-lg border border-border bg-card/30 backdrop-blur-sm pl-9 pr-4 py-2.5 text-sm text-white placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:bg-card/50 transition-all shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -74,7 +84,7 @@ export function DataTable<T>({
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : filteredData.length === 0 ? (
|
||||
) : paginatedData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-12 text-center">
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground">
|
||||
@@ -85,7 +95,7 @@ export function DataTable<T>({
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredData.map((row, i) => (
|
||||
paginatedData.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className={`hover:bg-primary/5 transition-colors duration-200 group ${getRowClassName ? getRowClassName(row) : ""}`}
|
||||
@@ -101,6 +111,38 @@ export function DataTable<T>({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{filteredData.length > pageSize && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border/50 bg-card/30">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-white">{startIndex + 1}</span> to{" "}
|
||||
<span className="font-medium text-white">
|
||||
{Math.min(startIndex + pageSize, filteredData.length)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-white">{filteredData.length}</span> results
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-md bg-secondary/80 text-white hover:bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-border/50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<div className="text-sm text-muted-foreground font-medium px-2">
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-md bg-secondary/80 text-white hover:bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-border/50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -78,10 +78,18 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
updateDimensions();
|
||||
window.addEventListener('resize', updateDimensions);
|
||||
|
||||
fetch('https://unpkg.com/globe.gl/example/datasets/ne_110m_admin_0_countries.geojson')
|
||||
.then(res => res.json())
|
||||
.then(setCountries)
|
||||
.catch(err => console.error('Failed to load countries', err));
|
||||
const loadCountries = async () => {
|
||||
try {
|
||||
const res = await fetch('/ne_110m_admin_0_countries.geojson');
|
||||
if (!res.ok) throw new Error('Network response was not ok');
|
||||
const data = await res.json();
|
||||
setCountries(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load countries', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadCountries();
|
||||
|
||||
return () => window.removeEventListener('resize', updateDimensions);
|
||||
}, []);
|
||||
|
||||
+53
-55
@@ -84,72 +84,70 @@ export async function getAgents(): Promise<Agent[]> {
|
||||
console.error("Failed to fetch dynamic agents from Netify API:", err);
|
||||
}
|
||||
|
||||
// If API query fails or is empty, use the hardcoded agent list as fallback
|
||||
if (apiAgents.length === 0) {
|
||||
apiAgents = [
|
||||
{ agent: { id: 4897042839, uuid: "2F-TF-1D-GK", label: "2F-TF-1D-GK" } },
|
||||
{ agent: { id: 4895530456, uuid: "8A-V3-PB-85", label: "8A-V3-PB-85" } },
|
||||
{ agent: { id: 4894730147, uuid: "F6-2V-DT-8A", label: "F6-2V-DT-8A" } },
|
||||
{ agent: { id: 4895853843, uuid: "1R-79-J9-YE", label: "1R-79-J9-YE" } }
|
||||
];
|
||||
}
|
||||
|
||||
const AGENT_MAC_MAP: Record<string, string[]> = {
|
||||
'2F-TF-1D-GK': ['60:be:b4:1f:05:96'],
|
||||
'8A-V3-PB-85': ['04:f4:1c:ce:c2:e6'],
|
||||
'F6-2V-DT-8A': [
|
||||
'2c:7b:a0:d8:86:91', '16:11:ac:73:34:1d', 'bc:45:5b:ca:d5:be',
|
||||
'de:ed:cc:57:58:34', 'f4:6d:3f:ef:01:a0', '60:be:b4:29:d3:36',
|
||||
'60:be:b4:29:d3:33', '60:be:b4:26:4c:d6', '60:be:b4:29:d3:32'
|
||||
],
|
||||
'1R-79-J9-YE': [
|
||||
'70:85:6c:6d:f7:17', '70:85:6c:81:50:d4', 'a2:cc:8e:7d:39:51',
|
||||
'f2:69:9d:a1:5e:11', '8e:91:0f:6e:24:63'
|
||||
],
|
||||
};
|
||||
|
||||
const AGENT_LABELS: Record<string, string> = {
|
||||
"2F-TF-1D-GK": "JRP Cibubur",
|
||||
"8A-V3-PB-85": "IFG LT.18",
|
||||
"F6-2V-DT-8A": "CPI Balaraja",
|
||||
"1R-79-J9-YE": "CPI Balaraja WAN"
|
||||
};
|
||||
|
||||
const latestBwTime = db.prepare("SELECT MAX(fetched_at) as t FROM mac_bandwidth").get()?.t;
|
||||
|
||||
const agents: Agent[] = apiAgents.map((item: any, idx: number) => {
|
||||
const uuid = item.agent?.uuid || "";
|
||||
const id = item.agent?.id || idx;
|
||||
const macs = AGENT_MAC_MAP[uuid] || [];
|
||||
|
||||
let dl = 0;
|
||||
let ul = 0;
|
||||
if (macs.length > 0 && latestBwTime) {
|
||||
const placeholders = macs.map(() => '?').join(',');
|
||||
const bw = db.prepare(`
|
||||
SELECT SUM(download) as dl, SUM(upload) as ul
|
||||
FROM mac_bandwidth
|
||||
WHERE mac_address IN (${placeholders}) AND fetched_at = ?
|
||||
`).get(...macs, latestBwTime);
|
||||
dl = bw?.dl ?? 0;
|
||||
ul = bw?.ul ?? 0;
|
||||
// If API query fails, apiAgents will be empty. We still process from knownAgents.
|
||||
const knownAgents = db.prepare('SELECT DISTINCT agent_uuid FROM users WHERE agent_uuid IS NOT NULL').all().map((r: any) => r.agent_uuid);
|
||||
|
||||
const agentMap = new Map<string, any>();
|
||||
|
||||
// 1. Add agents that have traffic from the API (Active in last 12 hours = Online)
|
||||
apiAgents.forEach((item: any, idx: number) => {
|
||||
const uuid = item.agent?.uuid;
|
||||
if (uuid) {
|
||||
agentMap.set(uuid, {
|
||||
id: item.agent.id,
|
||||
uuid: uuid,
|
||||
label: item.agent.label,
|
||||
isOnline: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const totalMB = ((dl + ul) / (1024 * 1024)).toFixed(2);
|
||||
// 2. Add any other configured agents from DB (These might be Last Seen or Offline)
|
||||
knownAgents.forEach((uuid: string) => {
|
||||
if (!agentMap.has(uuid)) {
|
||||
agentMap.set(uuid, {
|
||||
id: parseInt(uuid.replace(/[^0-9]/g, '')) || Math.floor(Math.random() * 100000), // Fallback ID
|
||||
uuid: uuid,
|
||||
label: uuid, // Label diselesaikan oleh frontend via resolveAgentLabel
|
||||
isOnline: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const agents: Agent[] = Array.from(agentMap.values()).map((item, idx) => {
|
||||
let statusHuman = "Offline";
|
||||
|
||||
if (item.isOnline) {
|
||||
statusHuman = "Online";
|
||||
} else {
|
||||
// Cari data terakhir dari DB
|
||||
const f = db.prepare('SELECT MAX(fetched_at) as t FROM flows WHERE agent_uuid = ?').get(item.uuid)?.t;
|
||||
const a = db.prepare('SELECT MAX(fetched_at) as t FROM bandwidth_apps WHERE agent_uuid = ?').get(item.uuid)?.t;
|
||||
const e = db.prepare('SELECT MAX(fetched_at) as t FROM events WHERE agent_uuid = ?').get(item.uuid)?.t;
|
||||
|
||||
const times = [f, a, e].filter(Boolean);
|
||||
if (times.length > 0) {
|
||||
times.sort();
|
||||
const latest = times[times.length - 1];
|
||||
const d = new Date(latest);
|
||||
const formatted = d.toLocaleString('id-ID', { timeZone: 'Asia/Jakarta', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
statusHuman = `Last Seen ${formatted} WIB`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: id,
|
||||
uuid: uuid,
|
||||
serial: uuid,
|
||||
id: item.id || idx,
|
||||
uuid: item.uuid,
|
||||
serial: item.uuid,
|
||||
site_uuid: SITE_UUID || "",
|
||||
organization_uuid: "",
|
||||
provisioned: true,
|
||||
activated: true,
|
||||
label: AGENT_LABELS[uuid] || item.agent?.label || uuid,
|
||||
label: item.label,
|
||||
created_at: { human: "N/A", date: "", unix_time: 0 },
|
||||
updated_at: { human: "N/A", date: "", unix_time: 0 },
|
||||
last_seen_at: {
|
||||
human: `Active (Traffic: ${totalMB} MB)`,
|
||||
human: statusHuman,
|
||||
date: new Date().toISOString(),
|
||||
unix_time: Date.now() / 1000
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Admin User Management API helpers
|
||||
|
||||
export interface ManagedUser {
|
||||
id: number;
|
||||
username: string;
|
||||
role: 'SUPER_ADMIN' | 'AGENT_VIEWER';
|
||||
site_uuid: string | null;
|
||||
agent_uuid: string | null;
|
||||
account_name: string | null;
|
||||
profile_picture: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ViewAsStatus {
|
||||
active: boolean;
|
||||
agent_uuid?: string;
|
||||
agent_label?: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
// ─── localStorage key untuk view-as state ─────────────────────────────────────
|
||||
const VIEW_AS_KEY = 'backone_view_as';
|
||||
|
||||
// Ambil view-as token dari localStorage (berjalan hanya di browser)
|
||||
export function getViewAsToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const stored = localStorage.getItem(VIEW_AS_KEY);
|
||||
if (!stored) return null;
|
||||
const parsed = JSON.parse(stored);
|
||||
return parsed.token || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Ambil view-as status dari localStorage
|
||||
export function getViewAsStatusSync(): ViewAsStatus {
|
||||
if (typeof window === 'undefined') return { active: false };
|
||||
try {
|
||||
const stored = localStorage.getItem(VIEW_AS_KEY);
|
||||
if (!stored) return { active: false };
|
||||
return JSON.parse(stored);
|
||||
} catch { return { active: false }; }
|
||||
}
|
||||
|
||||
// GET /api/auth/admin/users
|
||||
export async function getAdminUsers(): Promise<ManagedUser[]> {
|
||||
const res = await fetch('/api/auth/admin/users', { credentials: 'include' });
|
||||
const json = await res.json();
|
||||
if (!json.ok) throw new Error(json.error || 'Gagal mengambil daftar user');
|
||||
return json.data;
|
||||
}
|
||||
|
||||
// POST /api/auth/admin/create-agent-user
|
||||
export async function createAdminAgentUser(data: {
|
||||
username: string;
|
||||
password: string;
|
||||
account_name?: string;
|
||||
agent_uuid?: string;
|
||||
}): Promise<{ ok: boolean; message: string; userId: number }> {
|
||||
const res = await fetch('/api/auth/admin/create-agent-user', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.ok) throw new Error(json.error || 'Gagal membuat akun');
|
||||
return json;
|
||||
}
|
||||
|
||||
// POST /api/auth/admin/update-agent-user (FormData)
|
||||
export async function updateAdminAgentUser(formData: FormData): Promise<{ ok: boolean; message: string }> {
|
||||
const res = await fetch('/api/auth/admin/update-agent-user', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.ok) throw new Error(json.error || 'Gagal memperbarui akun');
|
||||
return json;
|
||||
}
|
||||
|
||||
// DELETE /api/auth/admin/delete-agent-user/:id
|
||||
export async function deleteAdminAgentUser(userId: number): Promise<void> {
|
||||
const res = await fetch('/api/auth/admin/delete-agent-user/' + userId, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.ok) throw new Error(json.error || 'Gagal menghapus akun');
|
||||
}
|
||||
|
||||
// POST /api/auth/admin/view-as — simpan token di localStorage
|
||||
export async function startViewAs(agent_uuid: string, agent_label: string): Promise<void> {
|
||||
const res = await fetch('/api/auth/admin/view-as', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ agent_uuid, agent_label }),
|
||||
});
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
throw new Error(`Server error (${res.status}): Backend mungkin perlu di-restart`);
|
||||
}
|
||||
const json = await res.json();
|
||||
if (!json.ok) throw new Error(json.error || 'Gagal masuk mode view-as');
|
||||
|
||||
// Simpan token di localStorage — akan dikirim sebagai X-View-As-Agent header
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(VIEW_AS_KEY, JSON.stringify({
|
||||
active: true,
|
||||
agent_uuid: json.agent_uuid,
|
||||
agent_label: json.agent_label,
|
||||
token: json.view_token,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Keluar dari mode view-as — hapus dari localStorage
|
||||
export async function stopViewAs(): Promise<void> {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(VIEW_AS_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
// GET view-as status (dari localStorage, bukan server)
|
||||
export async function getViewAsStatus(): Promise<ViewAsStatus> {
|
||||
return getViewAsStatusSync();
|
||||
}
|
||||
|
||||
// Buat headers untuk fetch yang menyertakan X-View-As-Agent jika aktif
|
||||
export function getViewAsHeaders(): Record<string, string> {
|
||||
const token = getViewAsToken();
|
||||
if (!token) return {};
|
||||
return { 'X-View-As-Agent': token };
|
||||
}
|
||||
|
||||
// Resolusi label agent dari daftar users (account_name) atau fallback ke uuid
|
||||
export function resolveAgentLabel(agentUuid: string, users: ManagedUser[]): string {
|
||||
const user = users.find(u => u.agent_uuid === agentUuid && u.role === 'AGENT_VIEWER');
|
||||
return user?.account_name || agentUuid;
|
||||
}
|
||||
+5
-1
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getViewAsHeaders } from './admin-api';
|
||||
|
||||
// Common Response Wrapper
|
||||
export interface ApiResponse<T> {
|
||||
@@ -48,6 +49,7 @@ export interface EventStat {
|
||||
severity: string;
|
||||
message: string;
|
||||
source_ip: string | null;
|
||||
mac_address: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@@ -166,7 +168,9 @@ export interface ContinentStat {
|
||||
// Fetcher Function
|
||||
export async function fetcher<T>(endpoint: string): Promise<T> {
|
||||
const url = `/api/dashboard${endpoint}`;
|
||||
const res = await fetch(url);
|
||||
// Tambahkan X-View-As-Agent header jika admin dalam mode view-as
|
||||
const viewAsHeaders = getViewAsHeaders();
|
||||
const res = await fetch(url, { headers: viewAsHeaders });
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user