fix(ui): resolve dark mode layout gray colors in light-mode browser and fix row index looping on paginated tables

This commit is contained in:
Rafif-Riqullah-Siregar committed 2026-07-16 14:30:23 +07:00
1 parent 46f33eb088
commit 202ecfa485
19 files changed
+374 -34

No files matched your search

+3
View File
@@ -66,3 +66,6 @@ CLAUDE.md
docs/
plans/
.env*
# Local uploads
backend/public/api/uploads/
+22 -3
View File
@@ -101,26 +101,45 @@ router.post('/change-account-name', requireAuth, async (req, res) => {
});
// ─── POST /api/auth/upload-profile-picture ───────────────────────────────────
router.post('/upload-profile-picture', requireAuth, upload.single('profile_picture'), async (req, res) => {
// Multer errors TIDAK tertangkap oleh try/catch di route handler.
// Wajib menggunakan callback agar error multer dikembalikan sebagai JSON, bukan HTML.
router.post('/upload-profile-picture', requireAuth, (req, res, next) => {
upload.single('profile_picture')(req, res, (multerErr) => {
if (multerErr) {
console.error('[Upload] Multer error:', multerErr.message);
return res.status(400).json({ error: `Upload gagal: ${multerErr.message}` });
}
next();
});
}, async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
if (!req.file) return res.status(400).json({ error: 'No image uploaded. Please select an image file first.' });
const user = await User.findById(req.user.id);
if (!user) return res.status(404).json({ error: 'User not found' });
// Hapus foto profil lama jika ada
if (user.profile_picture) {
const oldPath = path.join(getUploadsDir(), user.profile_picture);
if (fs.existsSync(oldPath)) {
try { fs.unlinkSync(oldPath); } catch (_) {}
}
}
user.profile_picture = req.file.filename;
await user.save();
const newToken = makeToken(user);
setCookieToken(res, newToken);
res.json({ ok: true, message: 'Foto profil berhasil diperbarui', profile_picture: req.file.filename });
res.json({ ok: true, message: 'Profile picture updated successfully.', profile_picture: req.file.filename });
} catch (err) {
console.error('[Upload Error]', err);
res.status(500).json({ error: err.message });
}
});
// ─── POST /api/auth/remove-profile-picture ───────────────────────────────────
router.post('/remove-profile-picture', requireAuth, async (req, res) => {
try {
+13
View File
@@ -122,6 +122,19 @@ app.get('/api/health', (req, res) => {
});
});
// ─── Global JSON Error Handler ────────────────────────────────────────────────
// Menangkap semua error yang tidak di-handle (termasuk multer, mongoose, dll.)
// dan memastikan response selalu JSON, BUKAN HTML default Express.
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
console.error('[Global Error Handler]', err.message || err);
const status = err.status || err.statusCode || 500;
res.status(status).json({
error: err.message || 'Internal server error',
code: err.code || undefined,
});
});
// ─── Start Server ─────────────────────────────────────────────────────────────
// Bind to 127.0.0.1 in production to prevent direct external access to port 3001.
// All external traffic must go through the reverse proxy (Apache/Nginx) at port 80/443.
+87
View File
@@ -0,0 +1,87 @@
// scripts/upload-profile-pictures.js
// Upload semua foto profil dari lokal ke server production
'use strict';
const SftpClient = require('ssh2-sftp-client');
const { Client: SshClient } = require('ssh2');
const fs = require('fs');
const path = require('path');
const CONFIG = {
host: '103.185.47.52',
port: 2222,
username: 'adminbackend',
password: 'htEo7x6LsBQiEHHH',
readyTimeout: 30000,
retries: 3,
retry_factor: 2,
retry_minTimeout: 2000,
};
const LOCAL_DIR = path.join(__dirname, '..', 'backend', 'public', 'api', 'uploads');
const REMOTE_DIR = '/home/adminbackend/web/demoplace.my.id/public_html/api/uploads';
function sshExec(cmd) {
return new Promise((resolve, reject) => {
const conn = new SshClient();
conn.on('ready', () => {
conn.exec(cmd, (err, stream) => {
if (err) { conn.end(); return reject(err); }
let out = '';
stream
.on('data', d => { out += d; })
.stderr.on('data', d => { out += d; });
stream.on('close', () => { conn.end(); resolve(out); });
});
}).on('error', reject)
.connect({ host: CONFIG.host, port: CONFIG.port, username: CONFIG.username, password: CONFIG.password, readyTimeout: CONFIG.readyTimeout });
});
}
async function main() {
if (!fs.existsSync(LOCAL_DIR)) {
console.log('Folder uploads lokal tidak ditemukan:', LOCAL_DIR);
return;
}
const files = fs.readdirSync(LOCAL_DIR).filter(f => /\.(png|jpg|jpeg|webp)$/i.test(f));
if (files.length === 0) {
console.log('Tidak ada file gambar di folder uploads lokal.');
return;
}
console.log(`Ditemukan ${files.length} file foto profil untuk diupload:\n ${files.join('\n ')}\n`);
// Buat direktori di server jika belum ada
await sshExec(`mkdir -p ${REMOTE_DIR} && chmod 755 ${REMOTE_DIR}`);
console.log(`[Server] Direktori ${REMOTE_DIR} siap\n`);
// Upload semua file via SFTP
const sftp = new SftpClient();
await sftp.connect(CONFIG);
console.log('[SFTP] Terhubung ke server\n');
let uploaded = 0;
let failed = 0;
for (const file of files) {
const localPath = path.join(LOCAL_DIR, file);
const remotePath = `${REMOTE_DIR}/${file}`;
try {
await sftp.put(localPath, remotePath);
console.log(`✅ Upload: ${file}`);
uploaded++;
} catch (err) {
console.error(`❌ Gagal upload ${file}:`, err.message);
failed++;
}
}
await sftp.end();
// Verifikasi
const remoteList = await sshExec(`ls -lh ${REMOTE_DIR}`);
console.log('\n[Server] Isi folder uploads:\n' + remoteList);
console.log(`\n✅ Selesai! ${uploaded} berhasil, ${failed} gagal.`);
}
main().catch(e => console.error('[FATAL]', e.message));
@@ -2,6 +2,7 @@
import { Column } from "@/components/ui/DataTable";
import { Badge } from "@/components/ui/Badge";
import { SafeImage } from "@/components/ui/SafeImage";
import { type ManagedUser } from "@/lib/admin-api";
import { UserCog, CheckCircle2, XCircle } from "lucide-react";
@@ -16,7 +17,12 @@ export function getExternalAccountColumns(
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center border border-slate-700 shrink-0 overflow-hidden">
{row.profile_picture ? (
<img src={`/api/uploads/${row.profile_picture}`} alt={row.account_name || row.username} className="w-full h-full object-cover" />
<SafeImage
src={`/api/uploads/${row.profile_picture}`}
alt={row.account_name || row.username}
fallbackName={row.account_name || row.username}
className="w-full h-full object-cover"
/>
) : (
<span className="text-xs font-bold text-slate-400">{(row.account_name || row.username).substring(0, 2).toUpperCase()}</span>
)}
+7
View File
@@ -72,6 +72,13 @@ export default function AppsPage() {
processedData.sort((a, b) => sortUpload === "Descending" ? (b.upload ?? 0) - (a.upload ?? 0) : (a.upload ?? 0) - (b.upload ?? 0));
} else if (sortDownload !== "All") {
processedData.sort((a, b) => sortDownload === "Descending" ? (b.download ?? 0) - (a.download ?? 0) : (a.download ?? 0) - (b.download ?? 0));
} else {
// Default: sort by latest activity (newest first)
processedData.sort((a, b) => {
const timeA = a.last_seen ? new Date(a.last_seen).getTime() : 0;
const timeB = b.last_seen ? new Date(b.last_seen).getTime() : 0;
return timeB - timeA;
});
}
// Use the data retention window (7 days) as the minimum selectable date.
+7
View File
@@ -148,6 +148,13 @@ export default function DevicesPage() {
processedData.sort((a, b) => sortUpload === "Descending" ? b.upload - a.upload : a.upload - b.upload);
} else if (sortDownload !== "All") {
processedData.sort((a, b) => sortDownload === "Descending" ? b.download - a.download : a.download - b.download);
} else {
// Default: sort by latest activity (newest first)
processedData.sort((a, b) => {
const timeA = a.last_seen ? new Date(a.last_seen).getTime() : 0;
const timeB = b.last_seen ? new Date(b.last_seen).getTime() : 0;
return timeB - timeA;
});
}
// Use the data retention window (7 days) as the minimum selectable date.
@@ -0,0 +1,53 @@
// src/app/api/auth/remove-profile-picture/route.ts
// Next.js API Route — proxies remove-profile-picture ke backend Express.
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3001";
export async function POST(req: NextRequest) {
try {
const cookieStore = await cookies();
const token = cookieStore.get("token")?.value;
const headers: HeadersInit = { "Content-Type": "application/json" };
if (token) {
headers["Cookie"] = `token=${token}`;
}
const backendRes = await fetch(
`${BACKEND_URL}/api/auth/remove-profile-picture`,
{
method: "POST",
headers,
}
);
const contentType = backendRes.headers.get("content-type") || "";
if (!contentType.includes("application/json")) {
const text = await backendRes.text();
console.error("[Remove-Picture Proxy] Backend returned non-JSON:", text.slice(0, 200));
return NextResponse.json(
{ error: `Backend error (HTTP ${backendRes.status}): Mohon coba lagi.` },
{ status: backendRes.status }
);
}
const data = await backendRes.json();
const response = NextResponse.json(data, { status: backendRes.status });
const setCookie = backendRes.headers.get("set-cookie");
if (setCookie) {
response.headers.set("set-cookie", setCookie);
}
return response;
} catch (err: any) {
console.error("[Remove-Picture Proxy] Error:", err.message);
return NextResponse.json(
{ error: "Gagal terhubung ke backend. Pastikan server berjalan." },
{ status: 502 }
);
}
}
@@ -0,0 +1,69 @@
// src/app/api/auth/upload-profile-picture/route.ts
// Next.js API Route — proxies multipart upload ke backend Express.
// Menggantikan next.config.ts rewrite yang tidak reliable untuk multipart/form-data.
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3001";
export async function POST(req: NextRequest) {
try {
// Ambil auth cookie dan teruskan ke backend
const cookieStore = await cookies();
const token = cookieStore.get("token")?.value;
// Baca form data dari request asli
const formData = await req.formData();
// Buat header — teruskan cookie auth
const headers: HeadersInit = {};
if (token) {
headers["Cookie"] = `token=${token}`;
}
// Forward ke backend Express
const backendRes = await fetch(
`${BACKEND_URL}/api/auth/upload-profile-picture`,
{
method: "POST",
headers,
body: formData,
}
);
const contentType = backendRes.headers.get("content-type") || "";
// Jika backend mengembalikan HTML (error), beri JSON error yang informatif
if (!contentType.includes("application/json")) {
const text = await backendRes.text();
console.error("[Upload Proxy] Backend returned non-JSON:", text.slice(0, 200));
return NextResponse.json(
{ error: `Backend error (HTTP ${backendRes.status}): Mohon coba lagi.` },
{ status: backendRes.status }
);
}
const data = await backendRes.json();
// Teruskan Set-Cookie dari backend (refresh token) ke browser
const response = NextResponse.json(data, { status: backendRes.status });
const setCookie = backendRes.headers.get("set-cookie");
if (setCookie) {
response.headers.set("set-cookie", setCookie);
}
return response;
} catch (err: any) {
console.error("[Upload Proxy] Error:", err.message);
return NextResponse.json(
{ error: "Gagal terhubung ke backend. Pastikan server berjalan." },
{ status: 502 }
);
}
}
// Nonaktifkan body parser bawaan Next.js — biar formData diurus manual
export const config = {
api: { bodyParser: false },
};
+2
View File
@@ -1,5 +1,7 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
+1 -1
View File
@@ -92,7 +92,7 @@ export default function ProfilePictureTab({ user, setUser, onClose }: Props) {
<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 ? (
<SafeImage src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
<SafeImage src={`/api/uploads/${user.profile_picture}`} alt="Profile" fallbackName={user.account_name || user.username} 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) : 'A')}
+2 -2
View File
@@ -52,7 +52,7 @@ export default function SidebarProfile({ user, onOpenSettings }: ProfileProps) {
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-tr from-primary to-blue-400 overflow-hidden">
{user?.profile_picture ? (
<SafeImage src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
<SafeImage src={`/api/uploads/${user.profile_picture}`} alt="Profile" fallbackName={user.account_name || user.username} className="w-full h-full object-cover" />
) : (
<span className="font-bold text-white uppercase text-xs">
{initial}
@@ -70,7 +70,7 @@ export default function SidebarProfile({ user, onOpenSettings }: ProfileProps) {
<div className="flex flex-col items-center justify-center p-3 mb-2 border-b border-border/50">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-gradient-to-tr from-primary to-blue-400 overflow-hidden mb-2">
{user?.profile_picture ? (
<SafeImage src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
<SafeImage src={`/api/uploads/${user.profile_picture}`} alt="Profile" fallbackName={user.account_name || user.username} className="w-full h-full object-cover" />
) : (
<span className="font-bold text-white uppercase text-lg">
{initial}
+9 -1
View File
@@ -63,6 +63,14 @@ export default function ThreatsContent() {
});
}, [all, fType, fSev, fSrcIp, fDstIp, fMac, fApp, fDomain, dateFrom, dateTo]);
// Default sort: newest detected threats first (for table display)
const sortedFiltered = useMemo(() =>
[...filtered].sort((a, b) =>
new Date(b.detected_at || 0).getTime() - new Date(a.detected_at || 0).getTime()
),
[filtered]
);
// Automatically select a threat if highlightId is passed
useEffect(() => {
if (highlightId && all.length > 0) {
@@ -206,7 +214,7 @@ export default function ThreatsContent() {
</CardHeader>
<CardContent>
<DataTable
data={filtered}
data={sortedFiltered}
columns={columns}
searchPlaceholder="Search threats by IP, domain, type..."
searchFilter={(row, query) => {
@@ -108,7 +108,18 @@ export default function ThreatsSummaryPdfDocument({
.sort((a, b) => b.count - a.count)
.slice(0, 10);
const incidents = threatsData.slice(0, 50);
// Sort incidents by severity (Critical > High > Medium > Low > Info) for PDF export
const SEVERITY_RANK: Record<string, number> = {
critical: 0, high: 1, medium: 2, med: 2, low: 3, info: 4,
};
const getSeverityRank = (sev: string): number => {
const s = (sev || "").toLowerCase();
const match = Object.entries(SEVERITY_RANK).find(([k]) => s.includes(k));
return match ? match[1] : 5;
};
const incidents = [...threatsData]
.sort((a, b) => getSeverityRank(a.severity) - getSeverityRank(b.severity))
.slice(0, 50);
return (
<Document>
+15 -8
View File
@@ -86,14 +86,21 @@ export default function AgentPdfDocument({
}: AgentPdfDocumentProps) {
const summary = data.summary || {};
// Compliance check: show all data up to 50 threshold
const devices = (data.devices || []).slice(0, 50);
const flows = (data.flows || []).slice(0, 50);
const topApps = (data.top_apps || []).slice(0, 50);
const insecure = (data.security?.insecure_protocols || []).slice(0, 50);
const reputation = (data.security?.ip_reputation || []).slice(0, 50);
const events = (data.events || []).slice(0, 50);
const macBw = (data.mac_bandwidth || []).slice(0, 50);
// Sort by total bandwidth (download + upload) descending for PDF export
const sortByBandwidth = (arr: any[]) =>
[...arr].sort((a, b) =>
((b.download ?? b.bytes_download ?? 0) + (b.upload ?? b.bytes_upload ?? 0)) -
((a.download ?? a.bytes_download ?? 0) + (a.upload ?? a.bytes_upload ?? 0))
);
const devices = sortByBandwidth(data.devices || []).slice(0, 50);
const flows = sortByBandwidth(data.flows || []).slice(0, 50);
const topApps = sortByBandwidth(data.top_apps || []).slice(0, 50);
const macBw = sortByBandwidth(data.mac_bandwidth || []).slice(0, 50);
// Security alerts: not sorted by bandwidth — keep original order
const insecure = (data.security?.insecure_protocols || []).slice(0, 50);
const reputation = (data.security?.ip_reputation || []).slice(0, 50);
const events = (data.events || []).slice(0, 50);
return (
<Document>
+8 -2
View File
@@ -84,8 +84,14 @@ export default function AppPdfDocument({
category,
reportDateStr,
}: AppPdfDocumentProps) {
const topIps = (data.top_ips || []).slice(0, 50);
const agents = (data.agent_scorecard || []).slice(0, 50);
// Sort by total bandwidth (download + upload) descending for PDF export
const sortByBandwidth = (arr: any[]) =>
[...arr].sort((a, b) =>
((b.download ?? 0) + (b.upload ?? 0)) - ((a.download ?? 0) + (a.upload ?? 0))
);
const topIps = sortByBandwidth(data.top_ips || []).slice(0, 50);
const agents = sortByBandwidth(data.agent_scorecard || []).slice(0, 50);
return (
<Document>
+1 -1
View File
@@ -197,7 +197,7 @@ export function DataTable<T>({
>
{columns.map((col, j) => (
<td key={j} className={`px-2 py-3 h-[48px] max-h-[48px] overflow-hidden text-foreground transition-colors truncate whitespace-nowrap ${col.className?.includes("text-") ? "" : "text-center"} ${col.className || ""}`}>
{col.accessor(row, i)}
{col.accessor(row, startIndex + i)}
</td>
))}
</tr>
+15 -6
View File
@@ -86,12 +86,21 @@ export default function DevicePdfDocument({
deviceData,
reportDateStr,
}: DevicePdfDocumentProps) {
// Compliance check: show all data up to 50 threshold
const topApps = (data.apps || []).slice(0, 50);
const topProtocols = (data.protocols || []).slice(0, 50);
const topDomains = (data.domains || []).slice(0, 50);
const detectedThreats = (data.threats || []).slice(0, 50);
const flows = (data.flows || []).slice(0, 50);
// Sort by total bandwidth (download + upload) descending for PDF export
const sortByBandwidth = (arr: any[]) =>
[...arr].sort((a, b) =>
((b.download ?? b.bytes_download ?? 0) + (b.upload ?? b.bytes_upload ?? 0)) -
((a.download ?? a.bytes_download ?? 0) + (a.upload ?? a.bytes_upload ?? 0))
);
const topApps = sortByBandwidth(data.apps || []).slice(0, 50);
const topProtocols = sortByBandwidth(data.protocols || []).slice(0, 50);
const topDomains = sortByBandwidth(data.domains || []).slice(0, 50);
// Threats: sorted by detected_at descending (newest first), not by bandwidth
const detectedThreats = [...(data.threats || [])]
.sort((a, b) => new Date(b.detected_at || 0).getTime() - new Date(a.detected_at || 0).getTime())
.slice(0, 50);
const flows = sortByBandwidth(data.flows || []).slice(0, 50);
return (
<Document>
+41 -8
View File
@@ -4,9 +4,30 @@ import React, { useState, useEffect } from "react";
interface SafeImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
/** Nama pengguna untuk ditampilkan sebagai letter avatar saat gambar gagal dimuat */
fallbackName?: string;
}
export function SafeImage({ src, ...props }: SafeImageProps) {
/** Hasilkan warna HSL deterministik berdasarkan string nama */
function getAvatarColor(name: string): string {
if (!name) return "hsl(220, 30%, 25%)";
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 45%, 30%)`;
}
/** Ambil 1-2 inisial huruf dari nama */
function getInitials(name: string): string {
if (!name) return "?";
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
}
export function SafeImage({ src, fallbackName, ...props }: SafeImageProps) {
const [imageSrc, setImageSrc] = useState<string>("");
const [error, setError] = useState<boolean>(false);
@@ -44,18 +65,29 @@ export function SafeImage({ src, ...props }: SafeImageProps) {
}, [src]);
if (error || !imageSrc) {
const initials = getInitials(fallbackName || "");
const bgColor = getAvatarColor(fallbackName || "");
return (
<div
className={props.className}
style={{
backgroundColor: "rgba(30, 41, 59, 0.5)",
<div
className={props.className}
style={{
backgroundColor: bgColor,
display: "flex",
alignItems: "center",
justifyContent: "center"
justifyContent: "center",
userSelect: "none",
}}
>
<span className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">
Profile
<span
style={{
color: "rgba(255,255,255,0.85)",
fontWeight: 700,
fontSize: "inherit",
lineHeight: 1,
letterSpacing: "0.05em",
}}
>
{initials}
</span>
</div>
);
@@ -63,3 +95,4 @@ export function SafeImage({ src, ...props }: SafeImageProps) {
return <img src={imageSrc} {...props} />;
}