chore: deep cleanup of test scripts and implement glassmorphism UI enhancements

This commit is contained in:
Rafif-Riqullah-Siregar committed 2026-07-07 00:35:54 +07:00
1 parent 7777b62305
commit 58040d6e6c
81 files changed
+2247 -6734

No files matched your search

+114 -93
View File
@@ -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>
);
}
+2 -2
View File
@@ -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) ||
+57 -16
View File
@@ -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)
);
}}
+24 -1
View File
@@ -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 });
+270
View File
@@ -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>
);
}
+3 -58
View File
@@ -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}
+3 -315
View File
@@ -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 />;
}
File diff suppressed because it is too large. Load diff
+22 -19
View File
@@ -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>
);
})}
+47 -5
View File
@@ -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>
);
+12 -4
View File
@@ -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
View File
@@ -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
}
+142
View File
@@ -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
View File
@@ -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}`);
}