feat: implement password changes, agent headers, MAC routing tags, GeoIP caching lookups, port descriptions, and network infra agent isolation

This commit is contained in:
vanne committed 2026-07-01 14:02:27 +07:00
1 parent 72ded25e4d
commit 93d4002b27
10 files changed
+1206 -139

No files matched your search

+22 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { getAgents, createAgent, updateAgent, deleteAgent, Agent } from "@/lib/actions/agents";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { DataTable, Column } from "@/components/ui/DataTable";
@@ -10,6 +11,8 @@ import { AgentDetailModal } from "@/components/ui/AgentDetailModal";
import { Loader2, Plus, Edit, Trash2, Server, CheckCircle2, XCircle, ChevronRight } from "lucide-react";
export default function AgentsPage() {
const router = useRouter();
const [role, setRole] = useState<string | null>(null);
const [agents, setAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
@@ -46,8 +49,25 @@ export default function AgentsPage() {
};
useEffect(() => {
loadAgents();
}, []);
fetch('/api/auth/me')
.then(res => res.json())
.then(data => {
if (data.user) {
setRole(data.user.role || null);
if (data.user.role === 'AGENT_VIEWER') {
router.push('/');
} else {
loadAgents();
}
} else {
setIsLoading(false);
}
})
.catch(err => {
console.error("Auth check failed:", err);
setIsLoading(false);
});
}, [router]);
const openCreate = () => {
setFormData({ agentId: "", label: "" });
+10 -3
View File
@@ -40,9 +40,16 @@ export default function DevicesPage() {
header: "MAC Address",
accessor: (row) =>
row.mac_address ? (
<code className="text-xs bg-secondary/50 px-1 py-0.5 rounded text-muted-foreground">
{row.mac_address}
</code>
<div className="flex flex-col gap-1">
<code className="text-xs bg-secondary/50 px-1 py-0.5 rounded text-muted-foreground w-fit">
{row.mac_address}
</code>
{row.is_gateway_routed === 1 && (
<span className="text-[10px] text-amber-500/80 font-sans italic">
Via Routed Gateway
</span>
)}
</div>
) : (
"-"
),
+69 -2
View File
@@ -6,6 +6,48 @@ import { DataTable, Column } from "@/components/ui/DataTable";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Loader2 } from "lucide-react";
import { fmtBytes } from "@/lib/utils";
import { IpDetails } from "@/components/ui/IpDetails";
function explainAppOrPort(appLabel: string | null, domain: string | null, port: number) {
const label = appLabel || domain || "";
const portStr = String(port);
const matches: Record<string, string> = {
"1433": "MSSQL Database Server",
"1434": "MSSQL Monitor Server",
"3306": "MySQL/MariaDB Database Server",
"5432": "PostgreSQL Database Server",
"1521": "Oracle Database Server",
"27017": "MongoDB Database Server",
"6379": "Redis Key-Value Cache",
"80": "Unencrypted Web Traffic (HTTP)",
"443": "Encrypted Web Traffic (HTTPS/TLS)",
"22": "SSH Remote Management / SFTP",
"21": "FTP File Upload/Download (Unencrypted)",
"23": "Telnet Command Shell (Insecure)",
"25": "SMTP Mail Delivery",
"587": "Secure SMTP Mail Delivery",
"110": "POP3 Mail Storage Retrieval",
"993": "Secure IMAP Mail Service",
"53": "DNS Server Query (Domain Lookup)",
"123": "NTP Network Clock Synchronizer",
"161": "SNMP Network Device Monitoring",
"3389": "RDP Remote Desktop Management",
"445": "SMB Windows File Sharing",
"137": "NetBIOS Name Service",
"138": "NetBIOS Datagram Service",
"139": "NetBIOS Session Service",
"1812": "RADIUS Authentication Server",
"1813": "RADIUS Accounting Server"
};
const explanation = matches[portStr] || (label.toLowerCase().includes("tls") || label.toLowerCase().includes("https") ? "Encrypted Connection (SSL/TLS)" : "");
return {
main: label || `Port ${port}`,
sub: explanation
};
}
export default function FlowsPage() {
const [mounted, setMounted] = useState(false);
@@ -21,10 +63,35 @@ export default function FlowsPage() {
{ header: "#", accessor: (row, i) => i + 1 },
{ header: "Flow ID", accessor: (row) => row.flow_id },
{ header: "Src IP", accessor: (row) => <span className="text-blue-400">{row.src_ip}</span> },
{ header: "Dst IP", accessor: (row) => <span className="text-blue-400">{row.dst_ip}</span> },
{
header: "Dst IP",
accessor: (row) => (
<div className="flex flex-col">
<span className="font-mono text-blue-400">{row.dst_ip}</span>
<IpDetails ip={row.dst_ip} />
</div>
)
},
{ header: "Dst Port", accessor: (row) => row.dst_port },
{ header: "Protocol", accessor: (row) => row.protocol },
{ header: "App / Domain", accessor: (row) => row.app_label || row.domain || "-" },
{
header: "App / Domain",
accessor: (row) => {
const exp = explainAppOrPort(row.app_label, row.domain, row.dst_port);
return (
<div className="flex flex-col">
<span className="font-semibold text-white truncate max-w-[200px]" title={exp.main}>
{exp.main}
</span>
{exp.sub && (
<span className="text-[10px] text-slate-400 italic">
{exp.sub}
</span>
)}
</div>
);
}
},
{
header: "Download",
accessor: (row) => <span className="text-blue-400">{fmtBytes(row.bytes_download)}</span>
+30
View File
@@ -1,7 +1,14 @@
"use client";
import { useState, useEffect } from "react";
import { Bell, RefreshCw } from "lucide-react";
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',
};
export function Header({
lastUpdate = "—",
isRefreshing = false,
@@ -11,6 +18,19 @@ export function Header({
isRefreshing?: boolean;
onRefresh?: () => void;
}) {
const [user, setUser] = useState<{ role?: string; agent_uuid?: string } | null>(null);
useEffect(() => {
fetch('/api/auth/me')
.then(res => res.json())
.then(data => {
if (data.user) {
setUser(data.user);
}
})
.catch(err => console.error(err));
}, []);
return (
<header className="flex h-16 shrink-0 items-center justify-between border-b border-border bg-background px-6">
<div className="flex items-center gap-2">
@@ -20,6 +40,16 @@ export function Header({
</div>
</div>
<div className="flex items-center gap-4">
{user?.role === 'AGENT_VIEWER' && (
<div className="flex items-center gap-2 rounded-full border border-blue-500/30 bg-blue-950/40 px-3 py-1 text-xs text-blue-300 shadow-[0_0_10px_rgba(59,130,246,0.1)]">
<span className="font-semibold">Agent:</span> {AGENT_LABELS[user.agent_uuid || ''] || user.agent_uuid} ({user.agent_uuid})
</div>
)}
{user?.role === 'SUPER_ADMIN' && (
<div className="flex items-center gap-2 rounded-full border border-red-500/30 bg-red-950/40 px-3 py-1 text-xs text-red-300 shadow-[0_0_10px_rgba(239,68,68,0.1)]">
<span className="font-semibold">Admin:</span> All Agents
</div>
)}
<div className="text-xs text-muted-foreground">
Update: {lastUpdate}
</div>
+183 -2
View File
@@ -1,5 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import {
Activity,
@@ -17,6 +18,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { Modal } from "@/components/ui/Modal";
const navigation = [
{ name: "Summary", href: "/", icon: LayoutDashboard },
@@ -39,6 +41,36 @@ const navigation = [
export function Sidebar() {
const pathname = usePathname();
const router = useRouter();
const [user, setUser] = useState<{ role?: string; agent_uuid?: string } | null>(null);
// Change Password state
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
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);
// Real-time checks for password strength validation
const isMinLength = newPassword.length >= 8;
const hasUppercase = /[A-Z]/.test(newPassword);
const hasLowercase = /[a-z]/.test(newPassword);
const hasNumber = /[0-9]/.test(newPassword);
const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(newPassword);
const passwordsMatch = newPassword === confirmPassword && confirmPassword.length > 0;
const isPasswordValid = isMinLength && hasUppercase && hasLowercase && hasNumber && hasSpecial && passwordsMatch;
useEffect(() => {
fetch('/api/auth/me')
.then(res => res.json())
.then(data => {
if (data.user) {
setUser(data.user);
}
})
.catch(err => console.error(err));
}, []);
const handleLogout = async () => {
try {
@@ -50,6 +82,46 @@ export function Sidebar() {
}
};
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("");
setTimeout(() => {
setIsChangePasswordOpen(false);
setSuccessMsg("");
}, 1500);
} else {
setErrorMsg(data.error || "Gagal mengubah password");
}
} catch (err) {
setErrorMsg("Terjadi kesalahan koneksi");
} finally {
setIsSubmitting(false);
}
};
const filteredNavigation = navigation.filter((item) => {
if (user?.role === 'AGENT_VIEWER' && item.name === 'Agents') {
return false;
}
return true;
});
return (
<div className="flex h-full w-64 flex-col border-r border-border/50 bg-sidebar/80 backdrop-blur-2xl relative">
{/* Subtle glow effect behind sidebar */}
@@ -63,7 +135,7 @@ export function Sidebar() {
</div>
<div className="flex flex-1 flex-col overflow-y-auto relative z-10">
<nav className="flex-1 space-y-1.5 px-3 py-6">
{navigation.map((item) => {
{filteredNavigation.map((item) => {
const isActive = pathname === item.href;
return (
<Link
@@ -102,7 +174,21 @@ export function Sidebar() {
</nav>
</div>
<div className="p-4 border-t border-border/50 relative z-10 shrink-0">
<div className="p-4 border-t border-border/50 relative z-10 shrink-0 space-y-1.5">
<button
onClick={() => {
setErrorMsg("");
setSuccessMsg("");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setIsChangePasswordOpen(true);
}}
className="flex w-full items-center rounded-lg px-3 py-2.5 text-sm font-medium text-slate-300 hover:bg-secondary/50 hover:text-white transition-all duration-200"
>
<Lock className="mr-3 h-5 w-5" />
Change Password
</button>
<button
onClick={handleLogout}
className="flex w-full items-center rounded-lg px-3 py-2.5 text-sm font-medium text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-all duration-200"
@@ -111,6 +197,101 @@ export function Sidebar() {
Logout
</button>
</div>
<Modal isOpen={isChangePasswordOpen} onClose={() => setIsChangePasswordOpen(false)} title="Ubah Password">
<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>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-primary focus:outline-none"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-slate-300">Password Baru</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-primary focus:outline-none"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-slate-300">Konfirmasi Password Baru</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-primary focus:outline-none"
/>
</div>
{/* Real-time Checklist */}
<div className="rounded-lg bg-secondary/30 p-3.5 border border-border/50 space-y-2">
<span className="text-xs font-semibold text-slate-300">Syarat Kekuatan Password:</span>
<ul className="text-xs space-y-1.5">
<li className={cn("flex items-center transition-colors duration-200", isMinLength ? "text-green-400 font-medium" : "text-slate-400")}>
<span className="mr-2">{isMinLength ? "✓" : "○"}</span> Minimal 8 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">{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">{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">{hasNumber ? "✓" : "○"}</span> Mengandung angka (0-9)
</li>
<li className={cn("flex items-center transition-colors duration-200", hasSpecial ? "text-green-400 font-medium" : "text-slate-400")}>
<span className="mr-2">{hasSpecial ? "✓" : "○"}</span> Mengandung karakter spesial (contoh: @$!%*?&)
</li>
<li className={cn("flex items-center transition-colors duration-200", passwordsMatch ? "text-green-400 font-medium" : "text-slate-400")}>
<span className="mr-2">{passwordsMatch ? "✓" : "○"}</span> Password cocok
</li>
</ul>
</div>
<div className="flex justify-end space-x-2 pt-2">
<button
type="button"
onClick={() => setIsChangePasswordOpen(false)}
className="rounded-lg bg-secondary px-4 py-2 text-sm font-medium text-white hover:bg-secondary/80 transition-colors"
>
Batal
</button>
<button
type="submit"
disabled={!isPasswordValid || isSubmitting}
className={cn(
"rounded-lg px-4 py-2 text-sm font-medium text-white shadow-lg transition-all duration-200",
isPasswordValid && !isSubmitting
? "bg-primary hover:bg-primary/90 shadow-primary/25 cursor-pointer"
: "bg-slate-700 text-slate-400 cursor-not-allowed"
)}
>
{isSubmitting ? "Menyimpan..." : "Simpan Password"}
</button>
</div>
</form>
</Modal>
</div>
);
}
+65 -29
View File
@@ -5,6 +5,7 @@ import {
fetchDeviceDetails, DeviceDetails,
} from "@/lib/api";
import { fmtBytes } from "@/lib/utils";
import { IpDetails } from "./IpDetails";
import {
Loader2, X, Monitor, Globe, Activity, Lock, Server,
AlertTriangle, Eye, Shield, Bell, Wifi, Unlock, Router,
@@ -21,6 +22,47 @@ interface Props {
type Tab = "info" | "flows" | "apps" | "encryption" | "servers" | "passwords" | "reputation" | "vpn_events";
// ── helpers ───────────────────────────────────────────────────────────────────
function explainAppOrPort(appLabel: string | null, domain: string | null, port: number) {
const label = appLabel || domain || "";
const portStr = String(port);
const matches: Record<string, string> = {
"1433": "MSSQL Database Server",
"1434": "MSSQL Monitor Server",
"3306": "MySQL/MariaDB Database Server",
"5432": "PostgreSQL Database Server",
"1521": "Oracle Database Server",
"27017": "MongoDB Database Server",
"6379": "Redis Key-Value Cache",
"80": "Unencrypted Web Traffic (HTTP)",
"443": "Encrypted Web Traffic (HTTPS/TLS)",
"22": "SSH Remote Management / SFTP",
"21": "FTP File Upload/Download (Unencrypted)",
"23": "Telnet Command Shell (Insecure)",
"25": "SMTP Mail Delivery",
"587": "Secure SMTP Mail Delivery",
"110": "POP3 Mail Storage Retrieval",
"993": "Secure IMAP Mail Service",
"53": "DNS Server Query (Domain Lookup)",
"123": "NTP Network Clock Synchronizer",
"161": "SNMP Network Device Monitoring",
"3389": "RDP Remote Desktop Management",
"445": "SMB Windows File Sharing",
"137": "NetBIOS Name Service",
"138": "NetBIOS Datagram Service",
"139": "NetBIOS Session Service",
"1812": "RADIUS Authentication Server",
"1813": "RADIUS Accounting Server"
};
const explanation = matches[portStr] || (label.toLowerCase().includes("tls") || label.toLowerCase().includes("https") ? "Encrypted Connection (SSL/TLS)" : "");
return {
main: label || `Port ${port}`,
sub: explanation
};
}
function sevStyle(sev: string | null) {
switch ((sev || "").toLowerCase()) {
case "critical": return "bg-red-500/15 text-red-400 ring-1 ring-red-500/30";
@@ -264,36 +306,30 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
{activeTab === "flows" && (
data.flows.length === 0 ? <Empty msg="Tidak ada flows untuk device ini." /> : (
<TableWrap head={["Destination IP", "Port", "Domain / App", "Proto", "↓", "↑", "Last Seen"]}>
{data.flows.map((f, i) => (
<tr key={i} className="hover:bg-white/[0.03] transition-colors">
<td className="px-3 py-2 font-mono text-xs text-blue-400 whitespace-nowrap">{f.dst_ip || "—"}</td>
<td className="px-3 py-2 text-xs text-slate-400">{f.dst_port ?? "—"}</td>
<td className="px-3 py-2 max-w-[180px]">
{f.domain ? (
/* Domain name takes priority — shown in teal monospace */
<span className="text-xs text-teal-300 font-mono truncate block" title={f.domain}>
{f.domain}
{data.flows.map((f, i) => {
const exp = explainAppOrPort(f.app_label, f.domain, f.dst_port || 0);
return (
<tr key={i} className="hover:bg-white/[0.03] transition-colors">
<td className="px-3 py-2 whitespace-nowrap">
<span className="font-mono text-xs text-blue-400 block">{f.dst_ip || "—"}</span>
{f.dst_ip && <IpDetails ip={f.dst_ip} />}
</td>
<td className="px-3 py-2 text-xs text-slate-400">{f.dst_port ?? "—"}</td>
<td className="px-3 py-2 max-w-[180px]">
<span className="text-xs text-teal-300 font-mono truncate block" title={exp.main}>
{exp.main}
</span>
) : f.app_label && !f.app_label.startsWith('Port ') ? (
/* Named protocol — purple */
<span className="text-xs text-purple-300 truncate block">{f.app_label}</span>
) : f.app_label ? (
/* Port-only entry — muted */
<span className="text-xs text-slate-500 truncate block">{f.app_label}</span>
) : (
<span className="text-xs text-slate-600">—</span>
)}
{/* Show app_label as secondary if domain is set */}
{f.domain && f.app_label && (
<span className="text-[10px] text-slate-500 block">{f.app_label}</span>
)}
</td>
<td className="px-3 py-2 text-xs text-slate-400">{f.protocol || "—"}</td>
<td className="px-3 py-2 text-right text-xs text-cyan-400 whitespace-nowrap">{fmtBytes(f.download)}</td>
<td className="px-3 py-2 text-right text-xs text-green-400 whitespace-nowrap">{fmtBytes(f.upload)}</td>
<td className="px-3 py-2 text-xs text-slate-500 whitespace-nowrap">{f.last_seen?.slice(0, 16) || "—"}</td>
</tr>
))}
{exp.sub && (
<span className="text-[10px] text-slate-500 block italic">{exp.sub}</span>
)}
</td>
<td className="px-3 py-2 text-xs text-slate-400">{f.protocol || "—"}</td>
<td className="px-3 py-2 text-right text-xs text-cyan-400 whitespace-nowrap">{fmtBytes(f.download)}</td>
<td className="px-3 py-2 text-right text-xs text-green-400 whitespace-nowrap">{fmtBytes(f.upload)}</td>
<td className="px-3 py-2 text-xs text-slate-500 whitespace-nowrap">{f.last_seen?.slice(0, 16) || "—"}</td>
</tr>
);
})}
</TableWrap>
)
)}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { useState, useEffect } from "react";
// Client-side cache to prevent duplicate fetches for the same IP
const geoIpCache: Record<string, { isp: string; country: string; as_org: string }> = {};
export function IpDetails({ ip }: { ip: string }) {
const [details, setDetails] = useState<{ isp: string; country: string; as_org: string } | null>(null);
useEffect(() => {
if (!ip) return;
// Check memory cache first
if (geoIpCache[ip]) {
setDetails(geoIpCache[ip]);
return;
}
let isMounted = true;
fetch(`/api/auth/geoip?ip=${encodeURIComponent(ip)}`)
.then((res) => res.json())
.then((data) => {
if (!isMounted) return;
const resolved = {
isp: data.isp || "Unknown ISP",
country: data.country || "Remote",
as_org: data.as_org || "Public Network",
};
geoIpCache[ip] = resolved;
setDetails(resolved);
})
.catch((err) => {
console.error("GeoIP lookup client error:", err);
if (isMounted) {
setDetails({
isp: "Public IP",
country: "Remote",
as_org: "Public Network",
});
}
});
return () => {
isMounted = false;
};
}, [ip]);
if (!details) {
return <span className="text-[10px] text-slate-500 animate-pulse">loading...</span>;
}
const isLocal = details.country === "Local";
return (
<div className="text-[10px] leading-tight mt-0.5 max-w-[180px] truncate">
<span className={isLocal ? "text-emerald-500/90 font-medium" : "text-cyan-400/90 font-medium"}>
{details.isp}
</span>
{!isLocal && (
<span className="text-slate-400 font-normal">
{" • "}{details.country}
</span>
)}
</div>
);
}