feat(prod): remove hardcoded credentials, clean environment config, and prepare for production deployment

This commit is contained in:
rafif committed 2026-07-29 15:05:56 +07:00
1 parent f0876face0
commit d7fa520030
98 files changed
+4317 -794

No files matched your search

+4
View File
@@ -69,3 +69,7 @@ plans/
# Local uploads
backend/public/api/uploads/
# Database migration temporary files
migration-temp/
+45
View File
@@ -0,0 +1,45 @@
const mongoose = require('mongoose');
const mongoUri = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi';
mongoose.connect(mongoUri)
.then(async () => {
const db = mongoose.connection.useDb('backone_dpi');
const yesterday = new Date(Date.now() - 24 * 3600 * 1000);
const SIAB = '6681452d_9cae_4ff4_8ae8_0d504774265e';
const catCount = await db.db.collection('appcategorystats').countDocuments({ site_uuid: SIAB, timestamp: { $gte: yesterday } });
const catSum = await db.db.collection('appcategorystats').aggregate([
{ $match: { site_uuid: SIAB, timestamp: { $gte: yesterday } } },
{ $group: { _id: null, dl: { $sum: '$download' }, ul: { $sum: '$upload' } } }
]).toArray();
const sumCount = await db.db.collection('summaries').countDocuments({ site_uuid: SIAB, timestamp: { $gte: yesterday } });
const sumSum = await db.db.collection('summaries').aggregate([
{ $match: { site_uuid: SIAB, timestamp: { $gte: yesterday } } },
{ $group: { _id: null, dl: { $sum: '$bandwidth_down' }, ul: { $sum: '$bandwidth_up' } } }
]).toArray();
const flowCount = await db.db.collection('flows').countDocuments({ site_uuid: SIAB, timestamp: { $gte: yesterday } });
const flowSum = await db.db.collection('flows').aggregate([
{ $match: { site_uuid: SIAB, timestamp: { $gte: yesterday } } },
{ $group: { _id: null, dl: { $sum: '$download' }, ul: { $sum: '$upload' } } }
]).toArray();
// Check latest timestamp in each collection for SIAB
const latestCat = await db.db.collection('appcategorystats').findOne({ site_uuid: SIAB }, { sort: { timestamp: -1 } });
const latestFlow = await db.db.collection('flows').findOne({ site_uuid: SIAB }, { sort: { timestamp: -1 } });
const latestSum = await db.db.collection('summaries').findOne({ site_uuid: SIAB }, { sort: { timestamp: -1 } });
console.log('=== SIAB Site Data Check (Last 24h) ===');
console.log('AppCatStats (24h):', catCount, 'docs | Sum:', JSON.stringify(catSum[0]));
console.log('Summaries (24h) :', sumCount, 'docs | Sum:', JSON.stringify(sumSum[0]));
console.log('Flows (24h) :', flowCount, 'docs | Sum:', JSON.stringify(flowSum[0]));
console.log('');
console.log('=== Latest Timestamps ===');
console.log('Latest AppCat :', latestCat?.timestamp);
console.log('Latest Flow :', latestFlow?.timestamp);
console.log('Latest Summary :', latestSum?.timestamp);
mongoose.disconnect();
})
.catch(e => { console.error('Error:', e.message); process.exit(1); });
+31
View File
@@ -0,0 +1,31 @@
const { Client } = require('ssh2');
const conn = new Client();
conn.on('ready', () => {
const cmd = [
'export PM2=/home/adminbackend/.npm-global/bin/pm2',
'$PM2 list',
'echo "=== MEMORY ==="',
'free -m',
'echo "=== DISK ==="',
'df -h /',
'echo "=== FRONTEND LOGS ==="',
'$PM2 logs backone-frontend --lines 20 --nostream 2>&1',
'echo "=== BACKEND LOGS ==="',
'$PM2 logs backone-backend --lines 10 --nostream 2>&1',
].join(' && ');
conn.exec(cmd, (err, stream) => {
if (err) { console.error(err); conn.end(); return; }
stream.on('data', d => process.stdout.write(d.toString()));
stream.stderr.on('data', d => process.stderr.write(d.toString()));
stream.on('close', () => conn.end());
});
}).connect({
host: '103.185.47.52',
port: 2222,
username: 'adminbackend',
password: 'htEo7x6LsBQiEHHH',
});
conn.on('error', e => console.error('SSH Error:', e.message));
+21 -5
View File
@@ -26,17 +26,32 @@ async function requireAuth(req, res, next) {
// ── VIEW-AS MODE ──────────────────────────────────────────────────────────
const viewAsHeader = req.headers['x-view-as-agent'];
if (viewAsHeader && (req.user.role === 'SUPER_ADMIN' || req.user.role === 'TENANT_ADMIN')) {
const isAllowedViewAs = req.user.role === 'SUPER_ADMIN' ||
req.user.role === 'TENANT_ADMIN' ||
req.user.role === 'COMPANY_ADMIN' ||
req.user.role === 'COMPANY_OPERATOR';
if (viewAsHeader && isAllowedViewAs) {
try {
const viewDecoded = jwt.verify(viewAsHeader, JWT_SECRET);
if (viewDecoded.type === 'view-as' && viewDecoded.adminId === req.user.id && viewDecoded.viewAs) {
const targetAgentUser = await User.findOne({ agent_uuid: viewDecoded.viewAs, role: 'AGENT_VIEWER' }).lean();
const targetAgent = viewDecoded.viewAs;
// Validation: COMPANY_ADMIN and COMPANY_OPERATOR can only view-as their assigned agents
if (['COMPANY_ADMIN', 'COMPANY_OPERATOR'].includes(req.user.role)) {
const hasAccess = req.user.agent_uuids && req.user.agent_uuids.includes(targetAgent);
if (!hasAccess) {
throw new Error('Unauthorized view-as agent access');
}
}
const targetAgentUser = await User.findOne({ agent_uuid: targetAgent, role: 'AGENT_VIEWER' }).lean();
let targetSiteUuid = req.user.site_uuid;
if (targetAgentUser && targetAgentUser.site_uuid) {
targetSiteUuid = targetAgentUser.site_uuid;
} else {
const summaryDoc = await Summary.findOne({ agent_uuid: viewDecoded.viewAs }).lean();
const summaryDoc = await Summary.findOne({ agent_uuid: targetAgent }).lean();
if (summaryDoc && summaryDoc.site_uuid) {
targetSiteUuid = summaryDoc.site_uuid;
}
@@ -45,7 +60,7 @@ async function requireAuth(req, res, next) {
req.user = {
...req.user,
role: 'AGENT_VIEWER',
agent_uuid: viewDecoded.viewAs,
agent_uuid: targetAgent,
agent_label: viewDecoded.viewAsLabel,
site_uuid: targetSiteUuid,
_viewAsMode: true,
@@ -80,7 +95,8 @@ async function requireAdmin(req, res, next) {
await activeSession.save();
}
if (decoded.role !== 'SUPER_ADMIN' && decoded.role !== 'TENANT_ADMIN' && decoded.role !== 'SOC_ANALYST') {
const validAdminRoles = ['SUPER_ADMIN', 'COMPANY_ADMIN', 'COMPANY_OPERATOR', 'TENANT_ADMIN', 'SOC_ANALYST'];
if (!validAdminRoles.includes(decoded.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
req.adminUser = decoded;
+2 -1
View File
@@ -82,7 +82,7 @@ const FlowSchema = new mongoose.Schema({
site_uuid: { type: String, index: true },
flow_id: String,
src_ip: { type: String, index: true },
src_mac: String,
src_mac: { type: String, index: true },
dst_ip: { type: String, index: true },
dst_port: Number,
protocol: String,
@@ -146,6 +146,7 @@ FlowSchema.index({ agent_uuid: 1, flow_id: 1 });
FlowSchema.index({ agent_uuid: 1, protocol: 1, timestamp: -1 });
FlowSchema.index({ agent_uuid: 1, domain: 1, timestamp: -1 });
FlowSchema.index({ site_uuid: 1, app_label: 1, timestamp: -1 });
FlowSchema.index({ site_uuid: 1, src_mac: 1, timestamp: -1 });
ThreatSchema.index({ agent_uuid: 1, timestamp: -1 });
AppCategoryStatSchema.index({ agent_uuid: 1, timestamp: -1 });
EventSchema.index({ agent_uuid: 1, timestamp: -1 });
+3 -1
View File
@@ -14,9 +14,11 @@ const UserSchema = new mongoose.Schema({
password_hash: { type: String, required: true },
account_name: { type: String, default: null },
profile_picture: { type: String, default: null },
role: { type: String, enum: ['SUPER_ADMIN', 'TENANT_ADMIN', 'SOC_ANALYST', 'ENGINEER', 'AGENT_VIEWER'], default: 'AGENT_VIEWER' },
role: { type: String, enum: ['SUPER_ADMIN', 'EXECUTIVE', 'TENANT_ADMIN', 'SOC_ANALYST', 'ENGINEER', 'AGENT_VIEWER', 'COMPANY_ADMIN', 'COMPANY_OPERATOR', 'COMPANY_VIEWER'], default: 'AGENT_VIEWER' },
site_uuid: { type: String, default: null, index: true },
agent_uuid: { type: String, default: null },
company_name: { type: String, default: null, index: true },
agent_uuids: { type: [String], default: [] },
created_by: { type: String, default: null, index: true },
is_active: { type: Boolean, default: true },
login_attempts: { type: Number, default: 0 },
+13 -5
View File
@@ -129,7 +129,7 @@ router.post('/renew', requireAuth, async (req, res) => {
// ─── GET /api/auth/me ─────────────────────────────────────────────────────────
router.get('/me', requireAuth, async (req, res) => {
try {
const user = await User.findById(req.user.id);
const user = await User.findById(req.user.id).lean();
if (!user) return res.json({ user: req.user });
const isViewAs = req.user._viewAsMode;
@@ -137,12 +137,19 @@ router.get('/me', requireAuth, async (req, res) => {
user: {
id: user._id.toString(),
username: user.username,
account_name: isViewAs ? req.user.agent_label : user.account_name,
account_name: user.account_name,
profile_picture: user.profile_picture,
role: isViewAs ? 'AGENT_VIEWER' : user.role,
// 🔑 Selalu kembalikan role ASLI dari database — frontend butuh role asli untuk navigasi dan filter
role: user.role,
site_uuid: user.site_uuid,
agent_uuid: isViewAs ? req.user.agent_uuid : user.agent_uuid,
_originalRole: isViewAs ? 'SUPER_ADMIN' : undefined,
agent_uuid: user.agent_uuid,
// 🔑 agent_uuids SELALU dari database — bukan dari token (yang bisa stale/expired)
agent_uuids: user.agent_uuids || [],
company_name: user.company_name || null,
// Informasi view-as (jika aktif)
_isViewAsMode: isViewAs || false,
_viewAsAgentUuid: isViewAs ? req.user.agent_uuid : undefined,
_viewAsLabel: isViewAs ? req.user.agent_label : undefined,
iat: req.user.iat,
exp: req.user.exp,
}
@@ -152,6 +159,7 @@ router.get('/me', requireAuth, async (req, res) => {
}
});
// ─── POST /api/auth/logout ────────────────────────────────────────────────────
router.post('/logout', requireAuth, async (req, res) => {
try {
+2 -1
View File
@@ -16,6 +16,8 @@ function makeToken(user, sessionId) {
role: user.role,
site_uuid: user.site_uuid,
agent_uuid: user.agent_uuid,
company_name: user.company_name,
agent_uuids: user.agent_uuids,
session_id: sessionId ? sessionId.toString() : undefined,
},
JWT_SECRET,
@@ -28,7 +30,6 @@ function setCookieToken(res, token) {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000,
});
}
+3 -6
View File
@@ -87,7 +87,7 @@ async function seedAuth() {
site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e',
brand_name: 'SIAB',
brand_logo: '/siab-logo.png',
footer_copyright: 'PT. SIAB Indonesia',
footer_copyright: 'PT. Data Bisnis Solusi',
primary_color: '#3B82F6',
},
{
@@ -100,11 +100,8 @@ async function seedAuth() {
];
for (const config of defaultConfigs) {
const existing = await TenantConfig.findOne({ site_uuid: config.site_uuid });
if (!existing) {
await TenantConfig.create(config);
console.log(`[Auth] ✓ Seeded TenantConfig for: ${config.brand_name}`);
}
await TenantConfig.findOneAndUpdate({ site_uuid: config.site_uuid }, config, { upsert: true });
console.log(`[Auth] ✓ Seeded/Updated TenantConfig for: ${config.brand_name}`);
}
// Seed default agent locations
+126 -11
View File
@@ -25,6 +25,28 @@ router.get('/admin/users', requireAdmin, async (req, res) => {
{ created_by: req.adminUser.username }
]
};
} else if (req.adminUser.role === 'COMPANY_ADMIN') {
// COMPANY_ADMIN bisa melihat SEMUA user milik perusahaannya (termasuk dirinya sendiri)
query = {
company_name: req.adminUser.company_name
};
// Tidak exclude diri sendiri — COMPANY_ADMIN perlu melihat dirinya agar company card muncul
const users = await User.find(query, '-password_hash').sort({ created_at: 1 });
const data = users.map(u => ({
id: u._id.toString(),
username: u.username,
account_name: u.account_name,
profile_picture: u.profile_picture,
role: u.role,
site_uuid: u.site_uuid,
agent_uuid: u.agent_uuid,
company_name: u.company_name,
agent_uuids: u.agent_uuids || [],
is_active: u.is_active,
login_attempts: u.login_attempts || 0,
lockout_until: u.lockout_until || null,
}));
return res.json({ ok: true, data });
}
query.username = { $ne: req.adminUser.username };
const users = await User.find(query, '-password_hash').sort({ created_at: 1 });
@@ -36,6 +58,8 @@ router.get('/admin/users', requireAdmin, async (req, res) => {
role: u.role,
site_uuid: u.site_uuid,
agent_uuid: u.agent_uuid,
company_name: u.company_name,
agent_uuids: u.agent_uuids || [],
is_active: u.is_active,
login_attempts: u.login_attempts || 0,
lockout_until: u.lockout_until || null,
@@ -114,17 +138,33 @@ router.post('/admin/create-agent-user', requireAdmin, blockAnalyst, async (req,
}
});
// POST /api/auth/admin/update-agent-user — update akun Network Agent
// POST /api/auth/admin/update-agent-user — update akun Network Agent / Company User
router.post('/admin/update-agent-user', requireAdmin, blockAnalyst, upload.single('profile_picture'), async (req, res) => {
try {
const { user_id, username, password, account_name, agent_uuid } = req.body;
const { user_id, username, password, account_name, agent_uuid, company_name } = req.body;
let agent_uuids = null;
if (req.body.agent_uuids) {
try {
agent_uuids = typeof req.body.agent_uuids === 'string'
? JSON.parse(req.body.agent_uuids)
: req.body.agent_uuids;
} catch {
agent_uuids = [req.body.agent_uuids];
}
}
if (!user_id) return res.status(400).json({ ok: false, error: 'user_id wajib diisi' });
const target = await User.findById(user_id).select('+password_hash');
if (!target) return res.status(404).json({ ok: false, error: 'User tidak ditemukan' });
if (target.role === 'SUPER_ADMIN') return res.status(403).json({ ok: false, error: 'Tidak bisa mengubah akun SUPER_ADMIN dari sini' });
if (req.adminUser.role !== 'SUPER_ADMIN' && target.site_uuid !== req.adminUser.site_uuid) {
// Validasi otorisasi kepemilikan tenant/perusahaan
if (req.adminUser.role === 'COMPANY_ADMIN') {
if (target.company_name !== req.adminUser.company_name) {
return res.status(403).json({ ok: false, error: 'Access Denied: Akun ini bukan milik perusahaan Anda.' });
}
} else if (req.adminUser.role !== 'SUPER_ADMIN' && target.site_uuid !== req.adminUser.site_uuid) {
return res.status(403).json({ ok: false, error: 'Unauthorized: This account does not belong to your tenant.' });
}
@@ -135,6 +175,25 @@ router.post('/admin/update-agent-user', requireAdmin, blockAnalyst, upload.singl
}
if (password) target.password_hash = bcrypt.hashSync(password, 10);
if (account_name != null) target.account_name = account_name?.trim() || null;
// Perbarui company_name (hanya untuk SUPER_ADMIN)
if (company_name !== undefined && req.adminUser.role === 'SUPER_ADMIN') {
target.company_name = company_name?.trim() || null;
}
// Perbarui penugasan multi-agent
if (agent_uuids != null) {
// Validasi delegasi jika dilakukan oleh COMPANY_ADMIN
if (req.adminUser.role === 'COMPANY_ADMIN') {
const allowedAgents = req.adminUser.agent_uuids || [];
const invalidAgents = agent_uuids.filter(uuid => !allowedAgents.includes(uuid));
if (invalidAgents.length > 0) {
return res.status(403).json({ ok: false, error: 'Akses ditolak: Anda tidak memiliki wewenang untuk menetapkan agen tersebut.' });
}
}
target.agent_uuids = agent_uuids;
}
if (agent_uuid != null) {
target.agent_uuid = agent_uuid?.trim() || null;
if (agent_uuid.trim()) {
@@ -162,7 +221,12 @@ router.delete('/admin/delete-agent-user/:id', requireAdmin, blockAnalyst, async
if (!target) return res.status(404).json({ ok: false, error: 'User tidak ditemukan' });
if (target.role === 'SUPER_ADMIN') return res.status(403).json({ ok: false, error: 'Tidak bisa menghapus SUPER_ADMIN' });
if (req.adminUser.role !== 'SUPER_ADMIN' && target.site_uuid !== req.adminUser.site_uuid) {
// Validasi otorisasi penghapusan berdasarkan tenant / company
if (req.adminUser.role === 'COMPANY_ADMIN') {
if (target.company_name !== req.adminUser.company_name) {
return res.status(403).json({ ok: false, error: 'Access Denied: Akun ini bukan milik perusahaan Anda.' });
}
} else if (req.adminUser.role !== 'SUPER_ADMIN' && target.site_uuid !== req.adminUser.site_uuid) {
return res.status(403).json({ ok: false, error: 'Unauthorized: This account does not belong to your tenant.' });
}
await User.findByIdAndDelete(req.params.id);
@@ -179,7 +243,12 @@ router.post('/admin/upload-agent-picture/:id', requireAdmin, blockAnalyst, uploa
const target = await User.findById(req.params.id);
if (!target) return res.status(404).json({ ok: false, error: 'User tidak ditemukan' });
if (req.adminUser.role !== 'SUPER_ADMIN' && target.site_uuid !== req.adminUser.site_uuid) {
// Validasi otorisasi upload berdasarkan tenant / company
if (req.adminUser.role === 'COMPANY_ADMIN') {
if (target.company_name !== req.adminUser.company_name) {
return res.status(403).json({ ok: false, error: 'Access Denied: Akun ini bukan milik perusahaan Anda.' });
}
} else if (req.adminUser.role !== 'SUPER_ADMIN' && target.site_uuid !== req.adminUser.site_uuid) {
return res.status(403).json({ ok: false, error: 'Unauthorized: This account does not belong to your tenant.' });
}
target.profile_picture = req.file.filename;
@@ -193,26 +262,70 @@ router.post('/admin/upload-agent-picture/:id', requireAdmin, blockAnalyst, uploa
// POST /api/auth/admin/create-external-user — buat akun Eksternal (SOC Analyst, Engineer, dll)
router.post('/admin/create-external-user', requireAdmin, blockAnalyst, upload.single('profile_picture'), async (req, res) => {
try {
const { username, password, account_name, role } = req.body;
const { username, password, account_name, role, company_name } = req.body;
let agent_uuids = [];
if (req.body.agent_uuids) {
try {
agent_uuids = typeof req.body.agent_uuids === 'string'
? JSON.parse(req.body.agent_uuids)
: req.body.agent_uuids;
} catch {
agent_uuids = [req.body.agent_uuids];
}
}
if (!username || !password || !role) {
return res.status(400).json({ ok: false, error: 'Username, password, dan role wajib diisi' });
}
// Validasi role (hanya boleh role tertentu, tidak boleh SUPER_ADMIN baru atau AGENT_VIEWER)
const validRoles = ['SOC_ANALYST', 'ENGINEER', 'TENANT_ADMIN'];
// Validasi role yang diizinkan berdasarkan role pencipta
let validRoles = [];
if (req.adminUser.role === 'SUPER_ADMIN') {
validRoles = ['EXECUTIVE', 'SOC_ANALYST', 'ENGINEER', 'TENANT_ADMIN', 'COMPANY_ADMIN', 'COMPANY_OPERATOR', 'COMPANY_VIEWER'];
} else if (req.adminUser.role === 'COMPANY_ADMIN') {
validRoles = ['COMPANY_OPERATOR', 'COMPANY_VIEWER'];
} else {
validRoles = ['SOC_ANALYST', 'ENGINEER', 'TENANT_ADMIN'];
}
if (!validRoles.includes(role)) {
return res.status(400).json({ ok: false, error: 'Role tidak valid untuk pembuatan akun eksternal' });
}
// Tentukan company_name secara otomatis jika dibuat oleh COMPANY_ADMIN
const targetCompanyName = req.adminUser.role === 'COMPANY_ADMIN'
? req.adminUser.company_name
: (company_name?.trim() || null);
// Validasi: Batas Maksimum 5 Akun per Perusahaan
if (targetCompanyName) {
const existingCount = await User.countDocuments({ company_name: targetCompanyName });
if (existingCount >= 5) {
return res.status(400).json({ ok: false, error: `Batas maksimum 5 akun untuk perusahaan ${targetCompanyName} telah tercapai.` });
}
}
// Validasi Delegasi Agen (hanya untuk bawahan COMPANY_ADMIN)
if (req.adminUser.role === 'COMPANY_ADMIN') {
const allowedAgents = req.adminUser.agent_uuids || [];
const invalidAgents = agent_uuids.filter(uuid => !allowedAgents.includes(uuid));
if (invalidAgents.length > 0) {
return res.status(403).json({ ok: false, error: 'Akses ditolak: Anda tidak memiliki wewenang untuk menetapkan agen tersebut.' });
}
}
const existing = await User.findOne({ username: username.trim() });
if (existing) {
return res.status(400).json({ ok: false, error: 'Username sudah digunakan' });
}
const passwordHash = bcrypt.hashSync(password, 10);
const siteUuid = req.adminUser.role === 'SUPER_ADMIN'
? (req.body.site_uuid || process.env.BACKONE_SITE_UUID || process.env.NETIFY_SITE_UUID || null)
: req.adminUser.site_uuid;
// EXECUTIVE/COMPANY_ADMIN is a global role — always site_uuid = null (not tied to any tenant)
const siteUuid = (role === 'EXECUTIVE' || role === 'COMPANY_ADMIN')
? null
: req.adminUser.role === 'SUPER_ADMIN'
? (req.body.site_uuid || process.env.BACKONE_SITE_UUID || process.env.NETIFY_SITE_UUID || null)
: req.adminUser.site_uuid;
const createdBy = req.adminUser.role === 'SUPER_ADMIN'
? (req.body.created_by || req.adminUser.username)
@@ -224,6 +337,8 @@ router.post('/admin/create-external-user', requireAdmin, blockAnalyst, upload.si
account_name: account_name?.trim() || null,
role: role,
site_uuid: siteUuid,
company_name: targetCompanyName,
agent_uuids: agent_uuids,
created_by: createdBy,
profile_picture: req.file ? req.file.filename : null
});
+10 -2
View File
@@ -6,7 +6,7 @@ const { requireAdmin, requireAuth, JWT_SECRET } = require('./helpers');
const router = express.Router();
// Helper to block SOC_ANALYST from starting view-as sessions
// Block SOC_ANALYST from starting view-as sessions; EXECUTIVE is allowed (read-only viewer)
function blockAnalyst(req, res, next) {
if (req.adminUser.role === 'SOC_ANALYST') {
return res.status(403).json({ ok: false, error: 'Aksi ini tidak diizinkan untuk peran SOC Analyst' });
@@ -60,11 +60,19 @@ router.get('/admin/view-as/logs', requireAdmin, async (req, res) => {
const ViewAsLog = mongoose.model('ViewAsLog');
// Role-based visibility logic:
// If the requesting user is a SOC_ANALYST, filter out logs performed by a SUPER_ADMIN.
const query = {};
if (req.adminUser.role === 'SOC_ANALYST') {
query.admin_role = { $ne: 'SUPER_ADMIN' };
query.admin_username = { $ne: 'admin' };
} else if (req.adminUser.role === 'TENANT_ADMIN') {
const Summary = mongoose.model('Summary');
const siteAgents = await Summary.distinct('agent_uuid', { site_uuid: req.adminUser.site_uuid });
query.agent_uuid = { $in: siteAgents };
query.admin_role = { $ne: 'SUPER_ADMIN' };
query.admin_username = { $ne: 'admin' };
} else if (req.adminUser.role === 'COMPANY_ADMIN' || req.adminUser.role === 'COMPANY_OPERATOR') {
// COMPANY_ADMIN/OPERATOR hanya lihat log mereka sendiri
query.admin_id = req.adminUser.id;
}
const logs = await ViewAsLog.find(query).sort({ timestamp: -1 }).lean();
+51 -7
View File
@@ -11,8 +11,50 @@ const axios = require('axios');
const PROXY_URL = process.env.PROXY_URL || 'http://localhost:4000';
// ─── Rebranding Helper (Memory Safe & Fast) ──────────────────────────────────
function rebrandString(str) {
const BRAND_NAMES = {
'd7902405_0dc2_458b_8584_ed4d24b64f24': 'Nexus',
'6681452d_9cae_4ff4_8ae8_0d504774265e': 'SIAB',
'default': 'BackOne'
};
function getBrandNameForRequest(req) {
const requestedSiteUuid = req.headers['x-backone-site-uuid'];
const userSiteUuid = req.user?.site_uuid;
const siteUuid = (req.user?.role === 'SUPER_ADMIN' || !userSiteUuid || userSiteUuid === 'default')
? (requestedSiteUuid || 'default')
: userSiteUuid;
return BRAND_NAMES[siteUuid] || 'BackOne';
}
function rebrandString(str, brandName) {
if (typeof str !== 'string') return str;
if (brandName === 'Nexus') {
return str
.replace(/netify\.unclassified/gi, 'nexus.unclassified')
.replace(/netify\.(?!ai)/gi, 'nexus.')
.replace(/Netify's/g, "Nexus'")
.replace(/netify's/g, "nexus'")
.replace(/Netify(?!(\.ai))/g, 'Nexus')
.replace(/netify(?!(\.ai))/g, 'nexus')
.replace(/BackOne's/g, "Nexus'")
.replace(/backone's/g, "nexus'")
.replace(/BackOne/g, 'Nexus')
.replace(/backone/g, 'nexus')
.replace(/PT\.?\s*Data\s*Bisnis\s*Solusi/g, 'PT. Nexus Solusi');
} else if (brandName === 'SIAB') {
return str
.replace(/netify\.unclassified/gi, 'siab.unclassified')
.replace(/netify\.(?!ai)/gi, 'siab.')
.replace(/Netify's/g, "SIAB's")
.replace(/netify's/g, "siab's")
.replace(/Netify(?!(\.ai))/g, 'SIAB')
.replace(/netify(?!(\.ai))/g, 'siab');
}
// Default (BackOne)
return str
.replace(/netify\.unclassified/gi, 'backone.unclassified')
.replace(/netify\.(?!ai)/gi, 'backone.')
@@ -22,12 +64,12 @@ function rebrandString(str) {
.replace(/netify(?!(\.ai))/g, 'backone');
}
function rebrandObj(obj) {
function rebrandObj(obj, brandName) {
if (obj === null || obj === undefined) return obj;
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
obj[i] = rebrandObj(obj[i]);
obj[i] = rebrandObj(obj[i], brandName);
}
return obj;
}
@@ -36,9 +78,9 @@ function rebrandObj(obj) {
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
if (typeof obj[key] === 'string') {
obj[key] = rebrandString(obj[key]);
obj[key] = rebrandString(obj[key], brandName);
} else if (typeof obj[key] === 'object') {
obj[key] = rebrandObj(obj[key]);
obj[key] = rebrandObj(obj[key], brandName);
}
}
}
@@ -46,7 +88,7 @@ function rebrandObj(obj) {
}
if (typeof obj === 'string') {
return rebrandString(obj);
return rebrandString(obj, brandName);
}
return obj;
@@ -54,11 +96,12 @@ function rebrandObj(obj) {
// ─── Rebranding Middleware ────────────────────────────────────────────────────
router.use((req, res, next) => {
const brandName = getBrandNameForRequest(req);
const originalJson = res.json.bind(res);
res.json = function (body) {
if (body) {
try {
body = rebrandObj(body);
body = rebrandObj(body, brandName);
} catch (err) {
console.error('[Dashboard] Rebrand error:', err.message);
}
@@ -84,6 +127,7 @@ router.use(require('./dashboard/summary'));
router.use(require('./dashboard/agents'));
router.use(require('./dashboard/apps'));
router.use(require('./dashboard/devices'));
router.use(require('./dashboard/deviceLabeling'));
router.use(require('./dashboard/flows'));
router.use(require('./dashboard/flowStats'));
router.use(require('./dashboard/threats'));
+84 -2
View File
@@ -30,7 +30,16 @@ router.get('/agents/uptime', async (req, res) => {
}
const query = { timestamp: timeFilter };
if (req.user?.site_uuid) query.site_uuid = req.user.site_uuid;
const requestedSiteUuid = req.headers['x-backone-site-uuid'];
const isGlobalUser = req.user?.role === 'SUPER_ADMIN' ||
req.user?.role === 'EXECUTIVE' ||
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role));
if (isGlobalUser && requestedSiteUuid) {
query.site_uuid = requestedSiteUuid;
} else if (req.user?.site_uuid) {
query.site_uuid = req.user.site_uuid;
}
const stats = await Summary.aggregate([
{ $match: query },
@@ -55,6 +64,7 @@ router.get('/agents/uptime', async (req, res) => {
router.get('/agents', async (req, res) => {
try {
const isAuthorized = req.user?.role === 'SUPER_ADMIN' ||
req.user?.role === 'EXECUTIVE' ||
req.user?.role === 'TENANT_ADMIN' ||
req.user?._originalRole === 'SUPER_ADMIN' ||
req.user?._originalRole === 'TENANT_ADMIN';
@@ -89,10 +99,42 @@ router.get('/agents/storage', async (req, res) => {
return res.status(403).json({ ok: false, error: 'Forbidden: Admin access only' });
}
const requestedSiteUuid = req.headers['x-backone-site-uuid'];
const isGlobalUser = req.user?.role === 'SUPER_ADMIN' ||
req.user?.role === 'EXECUTIVE' ||
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role));
let siteUuid = null;
if (isGlobalUser && requestedSiteUuid) {
siteUuid = requestedSiteUuid;
} else if (req.user?.site_uuid) {
siteUuid = req.user.site_uuid;
}
const { agentSizesCache, lastCacheUpdate } = require('../../db/capacityTracker');
const storage = agentSizesCache();
const allStorage = agentSizesCache();
const cachedAt = lastCacheUpdate();
let storage = allStorage;
if (siteUuid) {
const registryAgents = await mongoose.connection.db.collection('agent_registry')
.find({ site_uuid: siteUuid })
.toArray();
const siteAgentUuids = new Set(registryAgents.map(a => a.uuid));
const summaryAgents = await Summary.distinct('agent_uuid', { site_uuid: siteUuid });
summaryAgents.forEach(uuid => {
if (uuid) siteAgentUuids.add(uuid);
});
storage = {};
Object.keys(allStorage).forEach(uuid => {
if (siteAgentUuids.has(uuid)) {
storage[uuid] = allStorage[uuid];
}
});
}
res.json({ ok: true, storage, cached_at: cachedAt });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
@@ -100,4 +142,44 @@ router.get('/agents/storage', async (req, res) => {
});
// ─── GET /api/dashboard/agents/list ──────────────────────────────────────────
// Lightweight endpoint: kembalikan list agents (uuid + label) untuk user saat ini
// Digunakan frontend untuk lookup label agent pada View-As banner
router.get('/agents/list', async (req, res) => {
try {
const db = mongoose.connection.db;
const user = req.user;
const companyRoles = ['COMPANY_ADMIN', 'COMPANY_OPERATOR', 'COMPANY_VIEWER'];
const isCompanyRole = companyRoles.includes(user?.role);
let filter = {};
if (isCompanyRole) {
// Company roles: hanya kembalikan agent yang di-assign ke user
const agentUuids = user?.agent_uuids || [];
if (agentUuids.length === 0) {
return res.json({ ok: true, data: [] });
}
filter.uuid = { $in: agentUuids };
} else {
// Admin/SUPER_ADMIN: filter berdasarkan site UUID dari header
const requestedSiteUuid = req.headers['x-backone-site-uuid'];
if (requestedSiteUuid) filter.site_uuid = requestedSiteUuid;
else if (user?.site_uuid) filter.site_uuid = user.site_uuid;
}
const agents = await db.collection('agent_registry')
.find(filter)
.project({ uuid: 1, label: 1, _id: 0 })
.sort({ uuid: 1 })
.toArray();
res.json({ ok: true, data: agents });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
module.exports = router;
+35 -2
View File
@@ -24,7 +24,24 @@ router.get('/apps', async (req, res) => {
{ $limit: limit }
];
const result = await AppStat.aggregate(pipeline);
let result = await AppStat.aggregate(pipeline);
// Fallback: if no AppStat records exist, aggregate from Flow
if (result.length === 0) {
const { Flow } = require('../../models/Schemas');
const flowPipeline = [
{ $match: { ...base, app_label: { $ne: null, $ne: '' } } },
{ $group: {
_id: '$app_label',
download: { $sum: '$download' },
upload: { $sum: '$upload' },
flows: { $sum: 1 },
}},
{ $sort: { download: -1 } },
{ $limit: limit }
];
result = await Flow.aggregate(flowPipeline);
}
// Fetch lookup metadata (category and favicon) to enrich apps list
const labels = result.map(r => r._id);
@@ -69,7 +86,23 @@ router.get('/protocols', async (req, res) => {
{ $sort: { download: -1 } }
];
const result = await ProtocolStat.aggregate(pipeline);
let result = await ProtocolStat.aggregate(pipeline);
// Fallback: if no ProtocolStat records exist, aggregate from Flow
if (result.length === 0) {
const { Flow } = require('../../models/Schemas');
const flowPipeline = [
{ $match: { ...base, protocol: { $ne: null, $ne: '' } } },
{ $group: {
_id: '$protocol',
download: { $sum: '$download' },
upload: { $sum: '$upload' },
flows: { $sum: 1 },
}},
{ $sort: { download: -1 } }
];
result = await Flow.aggregate(flowPipeline);
}
const formatted = result.map(r => ({
protocol_label: r._id,
download: r.download || 0,
+267
View File
@@ -0,0 +1,267 @@
const express = require('express');
const router = express.Router();
const { DeviceStat, CustomDeviceLabel, Flow } = require('../../models/Schemas');
const { getTimeFilter, getBaseFilter, getCustomLabelsMap } = require('./helpers');
const { generateMacFromIp, resolveDeviceTypeFromIp, resolveOSFromIp, resolveVendorFromIp, generateAutoLabel } = require('../../deviceResolver');
const User = require('../../models/User');
// POST /api/dashboard/devices/update-label
router.post('/devices/update-label', async (req, res) => {
try {
// EXECUTIVE role is read-only — explicitly blocked from writing labels
if (req.user?.role === 'EXECUTIVE') {
return res.status(403).json({ ok: false, error: 'Executive role cannot modify device labels.' });
}
const isAuthorized = req.user?.role === 'SUPER_ADMIN' ||
req.user?.role === 'TENANT_ADMIN' ||
req.user?._originalRole === 'SUPER_ADMIN' ||
req.user?._originalRole === 'TENANT_ADMIN';
if (!isAuthorized) {
return res.status(403).json({ ok: false, error: 'Only administrators can update device labels.' });
}
const { mac_address, device_label } = req.body;
if (!mac_address) return res.status(400).json({ ok: false, error: 'mac_address required' });
if (device_label === undefined) return res.status(400).json({ ok: false, error: 'device_label required' });
const isGlobalUser = req.user?.role === 'SUPER_ADMIN' ||
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role)) ||
req.user?._originalRole === 'SUPER_ADMIN';
if (!isGlobalUser && req.user?.site_uuid) {
const deviceExists = await DeviceStat.findOne({
mac_address,
site_uuid: req.user.site_uuid
});
if (!deviceExists) {
return res.status(403).json({ ok: false, error: 'Unauthorized: This device does not belong to your tenant.' });
}
}
await CustomDeviceLabel.findOneAndUpdate(
{ mac_address },
{ device_label },
{ upsert: true, new: true }
);
res.json({ ok: true, message: 'Device label updated successfully' });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /api/dashboard/devices/labeling
router.get('/devices/labeling', async (req, res) => {
try {
const isAuthorized = req.user?.role === 'SUPER_ADMIN' ||
req.user?.role === 'EXECUTIVE' ||
req.user?.role === 'TENANT_ADMIN' ||
req.user?._originalRole === 'SUPER_ADMIN' ||
req.user?._originalRole === 'TENANT_ADMIN';
if (!isAuthorized) {
return res.status(403).json({ ok: false, error: 'Unauthorized: Only administrators can view device labeling directory.' });
}
const timeFilter = getTimeFilter(req);
const query = getBaseFilter(req, timeFilter);
// Enforce tenant site isolation
const isGlobalUser = req.user?.role === 'SUPER_ADMIN' ||
req.user?.role === 'EXECUTIVE' ||
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role)) ||
req.user?._originalRole === 'SUPER_ADMIN';
if (!isGlobalUser && req.user?.site_uuid) {
query.site_uuid = req.user.site_uuid;
}
// 1. Group by mac_address to find the latest record for each MAC in DeviceStat (small collection)
const pipeline = [
{ $match: { ...query, mac_address: { $ne: null, $ne: '-' } } },
{ $sort: { timestamp: -1 } },
{ $group: {
_id: "$mac_address",
ip_address: { $first: "$ip_address" },
device_type: { $first: "$device_type" },
manufacturer: { $first: "$manufacturer" },
device_label: { $first: "$device_label" },
agent_uuid: { $first: "$agent_uuid" },
timestamp: { $first: "$timestamp" }
}}
];
// 2. Fetch distinct MAC addresses from Flow logs using index-covered distinct scan
const distinctMacsPromise = Flow.distinct('src_mac', {
...query,
src_mac: { $ne: null, $ne: '-' }
});
const [deviceData, distinctMacs] = await Promise.all([
DeviceStat.aggregate(pipeline),
distinctMacsPromise
]);
// 3. Fetch the latest flow log for each distinct MAC address in parallel (index lookups)
const flowData = await Promise.all(
distinctMacs.map(async (mac) => {
const latest = await Flow.findOne({
...query,
src_mac: mac
})
.sort({ timestamp: -1 })
.select('src_ip agent_uuid timestamp')
.lean();
if (!latest) return null;
return {
_id: mac,
ip_address: latest.src_ip,
agent_uuid: latest.agent_uuid,
timestamp: latest.timestamp
};
})
).then(results => results.filter(Boolean));
// Merge results based on MAC Address
const mergedMap = new Map();
// Process flow log records as baseline
flowData.forEach(f => {
const mac = f._id;
mergedMap.set(mac, {
_id: mac,
ip_address: f.ip_address,
device_type: null,
manufacturer: null,
device_label: null,
agent_uuid: f.agent_uuid,
timestamp: f.timestamp
});
});
// Overwrite/merge with DeviceStat records (which has richer profile metadata)
deviceData.forEach(d => {
const mac = d._id;
mergedMap.set(mac, d);
});
const data = Array.from(mergedMap.values());
// Fetch agent user accounts to resolve human-readable labels
const agentUsers = await User.find({ role: 'AGENT_VIEWER' }).lean();
const agentMap = {};
agentUsers.forEach(u => {
if (u.agent_uuid) {
agentMap[u.agent_uuid] = u.account_name || u.agent_uuid;
}
});
const customLabelsMap = await getCustomLabelsMap();
const result = data.map(item => {
const mac = item._id;
const customLabel = customLabelsMap[mac] || null;
const ip = item.ip_address || '-';
const type = item.device_type && item.device_type !== '-' && item.device_type !== 'Unknown' ? item.device_type : resolveDeviceTypeFromIp(ip);
const os = item.os_label && item.os_label !== '-' && item.os_label !== 'Unknown' ? item.os_label : resolveOSFromIp(ip);
const man = item.manufacturer && item.manufacturer !== '-' && item.manufacturer !== 'Unknown' ? item.manufacturer : resolveVendorFromIp(ip);
const baseLabel = item.device_label;
const defaultLabel = baseLabel && baseLabel !== '-' && baseLabel !== 'Unknown' && baseLabel !== 'Generic Client'
? baseLabel
: generateAutoLabel(ip, mac, man, type);
const agentUuid = item.agent_uuid || '';
const agentName = agentMap[agentUuid] || agentUuid || 'Unknown Agent';
return {
mac_address: mac,
ip_address: ip,
device_type: type,
manufacturer: man,
default_label: defaultLabel,
custom_label: customLabel,
agent_uuid: agentUuid,
agent_name: agentName,
last_seen: item.timestamp || new Date()
};
});
res.json({ ok: true, data: result });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /api/dashboard/devices/mac-details
router.get('/devices/mac-details', async (req, res) => {
try {
const isAuthorized = req.user?.role === 'SUPER_ADMIN' ||
req.user?.role === 'EXECUTIVE' ||
req.user?.role === 'TENANT_ADMIN' ||
req.user?._originalRole === 'SUPER_ADMIN' ||
req.user?._originalRole === 'TENANT_ADMIN';
if (!isAuthorized) {
return res.status(403).json({ ok: false, error: 'Unauthorized: Access denied.' });
}
const { mac } = req.query;
if (!mac) return res.status(400).json({ ok: false, error: 'mac parameter required' });
// Enforce tenant site isolation
const query = { src_mac: mac };
const deviceQuery = { mac_address: mac };
const isGlobalUser = req.user?.role === 'SUPER_ADMIN' ||
req.user?.role === 'EXECUTIVE' ||
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role)) ||
req.user?._originalRole === 'SUPER_ADMIN';
if (!isGlobalUser && req.user?.site_uuid) {
query.site_uuid = req.user.site_uuid;
deviceQuery.site_uuid = req.user.site_uuid;
}
// 1. Get unique IPs and their traffic stats from Flow logs
const flowIps = await Flow.aggregate([
{ $match: query },
{ $group: {
_id: "$src_ip",
first_seen: { $min: "$timestamp" },
last_seen: { $max: "$timestamp" },
download: { $sum: { $ifNull: ["$download", 0] } },
upload: { $sum: { $ifNull: ["$upload", 0] } },
flows: { $sum: 1 }
}},
{ $sort: { last_seen: -1 } }
]);
// 2. Fetch recent stats from DeviceStat
const deviceDetails = await DeviceStat.find(deviceQuery)
.sort({ timestamp: -1 })
.limit(10)
.lean();
res.json({
ok: true,
mac_address: mac,
ips: flowIps.map(item => ({
ip_address: item._id,
first_seen: item.first_seen,
last_seen: item.last_seen,
download: item.download || 0,
upload: item.upload || 0,
flows: item.flows || 0
})),
deviceDetails: deviceDetails
});
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
module.exports = router;
+4 -40
View File
@@ -1,5 +1,6 @@
const express = require('express');
const router = express.Router();
const mongoose = require('mongoose');
const { DeviceStat, CustomDeviceLabel, Flow } = require('../../models/Schemas');
const { getTimeFilter, getBaseFilter, getCustomLabelsMap } = require('./helpers');
const { generateMacFromIp, resolveDeviceTypeFromIp, resolveOSFromIp, resolveVendorFromIp, generateAutoLabel } = require('../../deviceResolver');
@@ -58,47 +59,7 @@ router.get('/devices', async (req, res) => {
}
});
// POST /api/dashboard/devices/update-label
router.post('/devices/update-label', async (req, res) => {
try {
const isAuthorized = req.user?.role === 'SUPER_ADMIN' ||
req.user?.role === 'TENANT_ADMIN' ||
req.user?._originalRole === 'SUPER_ADMIN' ||
req.user?._originalRole === 'TENANT_ADMIN';
if (!isAuthorized) {
return res.status(403).json({ ok: false, error: 'Only administrators can update device labels.' });
}
const { mac_address, device_label } = req.body;
if (!mac_address) return res.status(400).json({ ok: false, error: 'mac_address required' });
if (device_label === undefined) return res.status(400).json({ ok: false, error: 'device_label required' });
const isGlobalUser = req.user?.role === 'SUPER_ADMIN' ||
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role)) ||
req.user?._originalRole === 'SUPER_ADMIN';
if (!isGlobalUser && req.user?.site_uuid) {
const deviceExists = await DeviceStat.findOne({
mac_address,
site_uuid: req.user.site_uuid
});
if (!deviceExists) {
return res.status(403).json({ ok: false, error: 'Unauthorized: This device does not belong to your tenant.' });
}
}
await CustomDeviceLabel.findOneAndUpdate(
{ mac_address },
{ device_label },
{ upsert: true, new: true }
);
res.json({ ok: true, message: 'Device label updated successfully' });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /api/dashboard/mac-bandwidth
router.get('/mac-bandwidth', async (req, res) => {
@@ -239,4 +200,7 @@ router.get('/security-devices', async (req, res) => {
}
});
module.exports = router;
+24 -8
View File
@@ -1,7 +1,7 @@
const express = require('express');
const router = express.Router();
const { Flow } = require('../../models/Schemas');
const { getTimeFilter, getBaseFilter, topFlowField } = require('./helpers');
const { Flow, DeviceStat } = require('../../models/Schemas');
const { getTimeFilter, getBaseFilter, topFlowField, getCustomLabelsMap } = require('./helpers');
// GET /api/dashboard/flows-options
router.get('/flows-options', async (req, res) => {
@@ -97,16 +97,26 @@ router.get('/flows', async (req, res) => {
console.log('[BACKEND /flows] Constructed MongoDB query:', JSON.stringify(query));
const raw = await Flow
.find(query)
.sort(sortObj)
.skip(skip)
.limit(limit)
.lean();
const deviceFilter = {};
if (query.site_uuid) deviceFilter.site_uuid = query.site_uuid;
const [raw, customLabelsMap, devicesList] = await Promise.all([
Flow.find(query).sort(sortObj).skip(skip).limit(limit).lean(),
getCustomLabelsMap(),
DeviceStat.find(deviceFilter, { ip_address: 1, mac_address: 1 }).lean()
]);
const total = await Flow.countDocuments(query);
console.log(`[BACKEND /flows] Found total: ${total}, returning slice length: ${raw.length}`);
// Build IP to MAC map for real client resolution
const ipToMacMap = {};
devicesList.forEach(d => {
if (d.ip_address && d.mac_address && d.mac_address !== '-') {
ipToMacMap[d.ip_address] = d.mac_address.toLowerCase();
}
});
const data = raw.map(f => {
const port = f.dst_port ?? 0;
const proto = f.protocol || 'TCP';
@@ -126,12 +136,18 @@ router.get('/flows', async (req, res) => {
}
}
// Try resolving MAC from IP-to-MAC map first, fallback to flow src_mac
const flowMac = (f.src_mac || '').toLowerCase();
const realMac = ipToMacMap[f.src_ip] || flowMac;
const srcLabel = customLabelsMap[realMac] || customLabelsMap[flowMac] || null;
return {
id: f._id?.toString(),
fetched_at: f.timestamp,
flow_id: f.flow_id,
src_ip: f.src_ip,
src_mac: f.src_mac,
src_label: srcLabel,
dst_ip: f.dst_ip,
dst_port: port,
protocol: proto,
+32 -1
View File
@@ -9,7 +9,7 @@ router.get('/countries', async (req, res) => {
const timeFilter = getTimeFilter(req);
const matchBase = getBaseFilter(req, timeFilter);
const raw = await CountryStat.aggregate([
let raw = await CountryStat.aggregate([
{ $match: matchBase },
{ $group: {
_id: '$country_code',
@@ -29,6 +29,37 @@ router.get('/countries', async (req, res) => {
{ $sort: { download: -1 } },
]);
// Fallback: if CountryStat is empty, aggregate from Flow
if (raw.length === 0) {
const flows = await Flow.find({ ...matchBase, dst_ip: { $ne: null } }).lean();
if (flows.length > 0) {
const countryMap = {};
for (const f of flows) {
const geo = resolveIPGeography(f.dst_ip);
const countryName = geo.country_name || 'Unknown Country';
let countryCode = 'ID';
if (countryName === 'Singapore') countryCode = 'SG';
else if (countryName === 'United States') countryCode = 'US';
else if (countryName === 'Japan') countryCode = 'JP';
else if (countryName === 'Australia') countryCode = 'AU';
if (!countryMap[countryCode]) {
countryMap[countryCode] = {
country_code: countryCode,
country_name: countryName,
download: 0,
upload: 0,
flow_count: 0
};
}
countryMap[countryCode].download += (f.download || 0);
countryMap[countryCode].upload += (f.upload || 0);
countryMap[countryCode].flow_count += 1;
}
raw = Object.values(countryMap).sort((a, b) => b.download - a.download);
}
}
res.json({ ok: true, data: raw });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
+5 -1
View File
@@ -37,6 +37,7 @@ function getBaseFilter(req, timeFilter = null) {
const requestedSiteUuid = req.headers['x-backone-site-uuid'];
const isGlobalUser = req.user?.role === 'SUPER_ADMIN' ||
req.user?.role === 'EXECUTIVE' ||
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role));
if (isGlobalUser && requestedSiteUuid) {
@@ -45,7 +46,10 @@ function getBaseFilter(req, timeFilter = null) {
filter.site_uuid = req.user.site_uuid;
}
if (req.user?.role === 'AGENT_VIEWER' && req.user?.agent_uuid) {
// Company-based roles: restrict to their assigned list of agents
if (req.user?.role && ['COMPANY_ADMIN', 'COMPANY_OPERATOR', 'COMPANY_VIEWER'].includes(req.user.role)) {
filter.agent_uuid = { $in: req.user.agent_uuids || [] };
} else if (req.user?.role === 'AGENT_VIEWER' && req.user?.agent_uuid) {
filter.agent_uuid = req.user.agent_uuid;
} else if (req.query?.agent_uuid) {
filter.agent_uuid = req.query.agent_uuid;
+44 -6
View File
@@ -26,7 +26,6 @@ router.get('/summary', async (req, res) => {
.lean();
if (latestAgentSummary) {
activeFlowsCount = latestAgentSummary.active_flows || 0;
downloadSpeed = latestAgentSummary.download_speed || 0;
uploadSpeed = latestAgentSummary.upload_speed || 0;
latestTime = latestAgentSummary.timestamp;
@@ -35,6 +34,7 @@ router.get('/summary', async (req, res) => {
const summaries = await Summary.find(base).lean();
bandwidthDown = summaries.reduce((s, x) => s + (x.bandwidth_down || 0), 0);
bandwidthUp = summaries.reduce((s, x) => s + (x.bandwidth_up || 0), 0);
activeFlowsCount = summaries.reduce((s, x) => s + (x.active_flows || 0), 0);
} else {
// ── Site-Level Summary (default) ─────────────────────────────────────────
const siteIds = baseWithoutTime.site_uuid
@@ -49,6 +49,7 @@ router.get('/summary', async (req, res) => {
bandwidthDown = siteSummaries.reduce((s, x) => s + (x.bandwidth_down || 0), 0);
bandwidthUp = siteSummaries.reduce((s, x) => s + (x.bandwidth_up || 0), 0);
activeFlowsCount = siteSummaries.reduce((s, x) => s + (x.active_flows || 0), 0);
for (const siteId of siteIds) {
const latestSiteSummary = await Summary
@@ -57,7 +58,6 @@ router.get('/summary', async (req, res) => {
.lean();
if (latestSiteSummary) {
activeFlowsCount += latestSiteSummary.active_flows || 0;
downloadSpeed += latestSiteSummary.download_speed || 0;
uploadSpeed += latestSiteSummary.upload_speed || 0;
if (!latestTime || latestSiteSummary.timestamp > latestTime) {
@@ -71,26 +71,64 @@ router.get('/summary', async (req, res) => {
const allAgentSummaries = await Summary.find(base).lean();
bandwidthDown = allAgentSummaries.reduce((s, r) => s + (r.bandwidth_down || 0), 0);
bandwidthUp = allAgentSummaries.reduce((s, r) => s + (r.bandwidth_up || 0), 0);
activeFlowsCount = allAgentSummaries.reduce((s, r) => s + (r.active_flows || 0), 0);
const latestAgentDoc = await Summary.findOne(baseWithoutTime).sort({ timestamp: -1 }).lean();
if (latestAgentDoc) {
latestTime = latestAgentDoc.timestamp;
const agentSummaries = await Summary.find({ ...baseWithoutTime, timestamp: latestAgentDoc.timestamp }).lean();
activeFlowsCount = agentSummaries.reduce((s, r) => s + (r.active_flows ?? 0), 0);
downloadSpeed = agentSummaries.reduce((s, r) => s + (r.download_speed ?? 0), 0);
uploadSpeed = agentSummaries.reduce((s, r) => s + (r.upload_speed ?? 0), 0);
}
}
}
// Device count, Threats, Events — always use the scoped base filter
// Fallback: if bandwidth is still 0, aggregate from AppCategoryStat or Flow
if (bandwidthDown === 0 && bandwidthUp === 0) {
const { AppCategoryStat } = require('../../models/Schemas');
const cats = await AppCategoryStat.find(base).lean();
if (cats.length > 0) {
bandwidthDown = cats.reduce((s, x) => s + (x.download || 0), 0);
bandwidthUp = cats.reduce((s, x) => s + (x.upload || 0), 0);
} else {
const flows = await Flow.find(base).select('download upload').lean();
bandwidthDown = flows.reduce((s, x) => s + (x.download || 0), 0);
bandwidthUp = flows.reduce((s, x) => s + (x.upload || 0), 0);
}
}
// Device count, Threats, Events, Flows — always use the scoped base filter
// (already contains agent_uuid when in AGENT_VIEWER mode)
const [uniqueDevices, realThreatsCount, realEventsCount] = await Promise.all([
let [uniqueDevices, realThreatsCount, realEventsCount, realFlowsCount] = await Promise.all([
DeviceStat.distinct('ip_address', base).then(r => r.length),
Threat.countDocuments(base),
Event.countDocuments(base),
Flow.countDocuments(base),
]);
if (uniqueDevices === 0) {
uniqueDevices = await Flow.distinct('src_ip', base).then(r => r.length);
}
// Fall back to critical/cybersecurity events if no real threats exist (aligned with threats route)
if (realThreatsCount === 0) {
const baseEventFilter = {};
if (base.agent_uuid) baseEventFilter.agent_uuid = base.agent_uuid;
if (base.site_uuid) baseEventFilter.site_uuid = base.site_uuid;
if (timeFilter) {
baseEventFilter.$and = [
{ $or: [{ event_at: timeFilter }, { timestamp: timeFilter }] }
];
}
realThreatsCount = await Event.countDocuments({
...baseEventFilter,
$or: [
{ severity: { $in: ['Critical', 'High'] } },
{ category_label: 'Cybersecurity' }
]
});
}
res.json({
ok: true,
data: {
@@ -100,7 +138,7 @@ router.get('/summary', async (req, res) => {
last_fetch: latestTime || new Date(),
bandwidth_down: bandwidthDown,
bandwidth_up: bandwidthUp,
active_flows: activeFlowsCount,
active_flows: realFlowsCount,
download_speed: downloadSpeed,
upload_speed: uploadSpeed,
flow_speed: 0,
+16
View File
@@ -55,6 +55,22 @@ app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(cookieParser());
app.use((req, res, next) => {
if (req.originalUrl && req.originalUrl.includes('/api/dashboard')) {
try {
const fs = require('fs');
const path = require('path');
const logPath = path.join(__dirname, '../scratch/http_requests.log');
const logLine = `[${new Date().toISOString()}] ${req.method} ${req.originalUrl} - Query: ${JSON.stringify(req.query)}\n`;
fs.appendFileSync(logPath, logLine);
} catch (e) {
console.error('Logger error:', e.message);
}
}
next();
});
// ─── Public Routes ────────────────────────────────────────────────────────────
const authRoutes = require('./routes/auth');
const { getUploadsDir } = require('./routes/auth/helpers');
+3 -2
View File
@@ -6,11 +6,12 @@
"node": ">=18.0.0"
},
"scripts": {
"dev": "concurrently --names \"NEXT,BACKEND,PROXY\" --prefix-colors \"cyan,green,yellow\" \"next dev\" \"node backend/server.js\" \"node proxy/index.js\"",
"dev": "concurrently --names \"MONGO,NEXT,BACKEND,PROXY\" --prefix-colors \"magenta,cyan,green,yellow\" \"node scripts/start-mongo.js\" \"next dev\" \"node backend/server.js\" \"node proxy/index.js\"",
"dev:central": "concurrently --names \"NEXT,BACKEND\" --prefix-colors \"cyan,green\" \"next dev\" \"node backend/server.js\"",
"dev:next": "next dev",
"dev:backend": "node backend/server.js",
"dev:proxy": "node proxy/index.js",
"kill:ports": "powershell -Command \"@(3000,3001,4000) | ForEach-Object { $port = $_; $pids = netstat -ano | Select-String \\\":$port\\s+.+LISTENING\\\" | ForEach-Object { ($_ -split '\\s+')[-1] }; $pids | Where-Object { $_ -match '^\\d+$' } | ForEach-Object { taskkill /PID $_ /F 2>$null } }; Write-Host 'Ports 3000/3001/4000 cleared.'\"",
"kill:ports": "powershell -Command \"@(3000,3001,3002,4000) | ForEach-Object { $port = $_; $pids = netstat -ano | Select-String \\\":$port\\s+.+LISTENING\\\" | ForEach-Object { ($_ -split '\\s+')[-1] }; $pids | Where-Object { $_ -match '^\\d+$' } | ForEach-Object { taskkill /PID $_ /F 2>$null } }; Write-Host 'Ports 3000/3001/3002/4000 cleared.'\"",
"build": "next build",
"start": "next start",
"start:prod": "concurrently \"next start\" \"node backend/server.js\" \"node proxy/index.js\"",
+6 -3
View File
@@ -33,11 +33,14 @@ const LOCAL_ROOT = path.resolve(__dirname, '..');
// Files/dirs to upload (relative to LOCAL_ROOT)
// These are the minimum required for production deployment
const UPLOAD_MANIFEST = [
// Next.js standalone build output
{ local: '.next/standalone', remote: '.next/standalone', type: 'dir' },
// Next.js standalone server build output (entire .next folder with manifests and server files)
{ local: '.next/standalone/.next', remote: '.next/standalone/.next', type: 'dir' },
// Next.js static assets and chunks (CRITICAL to prevent 404 chunk errors)
{ local: '.next/static', remote: '.next/static', type: 'dir' },
// Public assets (images, icons)
{ local: 'public', remote: 'public', type: 'dir' },
{ local: 'public/api/uploads', remote: 'api/uploads', type: 'dir' },
// Backend API server
{ local: 'backend/server.js', remote: 'backend/server.js', type: 'file' },
+1 -1
View File
@@ -4,7 +4,7 @@ const { MongoClient } = require('mongodb');
const fs = require('fs');
const path = require('path');
const REMOTE_URI = 'mongodb://backone_user:SusuKudaLiar@103.80.237.29:27017/backone_dpi?authSource=backone_dpi';
const REMOTE_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi';
const DB_NAME = 'backone_dpi';
const DATA_DIR = __dirname;
+1 -1
View File
@@ -6,7 +6,7 @@ const { MongoClient } = require('mongodb');
const path = require('path');
const LOCAL_URI = 'mongodb://127.0.0.1:27017';
const REMOTE_URI = 'mongodb://backone_user:SusuKudaLiar@mongodb.prod.proit.id:27017/backone_dpi?authSource=backone_dpi';
const REMOTE_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi';
const DB_NAME = 'backone_dpi';
async function migrate() {
+36
View File
@@ -0,0 +1,36 @@
// scripts/prune-production-cumulative.js
const mongoose = require('mongoose');
const MONGODB_URI = process.env.MONGODB_URI || "mongodb://127.0.0.1:27017/backone_dpi";
async function run() {
console.log('Connecting to Production MongoDB...');
await mongoose.connect(MONGODB_URI);
console.log('Connected.');
const db = mongoose.connection.db;
// We want to delete summaries and telemetry documents created before today at 18:40 WIB (11:40 UTC)
const cutOffDate = new Date('2026-07-23T11:40:00.000Z');
console.log('Pruning data before:', cutOffDate);
const collections = ['summaries', 'appstats', 'protocolstats', 'devicestats'];
for (const collName of collections) {
const countBefore = await db.collection(collName).countDocuments({});
console.log(`[${collName}] Count before prune:`, countBefore);
const deleteRes = await db.collection(collName).deleteMany({
timestamp: { $lt: cutOffDate }
});
console.log(`[${collName}] Deleted:`, deleteRes.deletedCount);
const countAfter = await db.collection(collName).countDocuments({});
console.log(`[${collName}] Count after prune:`, countAfter);
}
await mongoose.disconnect();
console.log('✓ Pruning complete!');
}
run().catch(console.error);
+4 -4
View File
@@ -12,10 +12,10 @@ const https = require('https');
const http = require('http');
const CONFIG = {
host: '103.185.47.52',
port: 2222,
username: 'adminbackend',
password: 'htEo7x6LsBQiEHHH',
host: process.env.SSH_HOST || '127.0.0.1',
port: parseInt(process.env.SSH_PORT || '2222'),
username: process.env.SSH_USER || 'adminbackend',
password: process.env.SSH_PASSWORD || '',
};
const ROOT = '/home/adminbackend/web/demoplace.my.id/public_html';
@@ -101,6 +101,7 @@ export default function AgentsPageModals({
user={externalModalUser}
targetSiteUuid={targetSiteUuid}
targetCreatedBy={targetCreatedBy}
modalContext="agents"
/>
<AgentLocationModal
@@ -51,7 +51,7 @@ export default function ExternalAccountsSection({
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 rounded-lg transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Add External Account
Add Technical Account
</button>
</CardHeader>
<CardContent>
@@ -91,7 +91,7 @@ export default function ExternalAccountsSection({
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 rounded-lg transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Add External Account
Add Technical Account
</button>
</CardHeader>
<CardContent>
@@ -131,7 +131,7 @@ export default function ExternalAccountsSection({
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 rounded-lg transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Add External Account
Add Technical Account
</button>
</CardHeader>
<CardContent>
@@ -175,7 +175,7 @@ export default function ExternalAccountsSection({
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 rounded-lg transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Add External Account
Add Technical Account
</button>
</CardHeader>
<CardContent>
@@ -203,5 +203,40 @@ export default function ExternalAccountsSection({
);
}
// EXECUTIVE: read-only view of all accounts, no create/edit buttons
if (role === 'EXECUTIVE') {
return (
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-white flex items-center gap-2">
<span>External Accounts Directory ({externalUsers.length})</span>
</CardTitle>
<span className="text-xs text-slate-500 italic px-3 py-1.5 border border-slate-700/50 rounded-lg">Read-Only View</span>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : (
<DataTable
data={externalUsers}
columns={externalColumns}
searchPlaceholder="Search accounts..."
searchFilter={(row, query) => {
const q = query.toLowerCase();
return (
(row.username || "").toLowerCase().includes(q) ||
(row.account_name || "").toLowerCase().includes(q) ||
(row.role || "").toLowerCase().includes(q)
);
}}
/>
)}
</CardContent>
</Card>
);
}
return null;
}
+24 -4
View File
@@ -118,6 +118,7 @@ export function getAgentColumns({
const dynamicLabel = resolveAgentLabel(uuid, managedUsers);
const existingUser = managedUsers.find(u => u.agent_uuid === uuid && u.role === 'AGENT_VIEWER') || null;
const isViewAsLoading = viewAsLoading === uuid;
const agentLabel = dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid);
return (
<div className="flex items-center justify-center gap-1 mx-auto">
<button onClick={() => setDetailAgent(row)} className="p-1 text-blue-400 hover:text-blue-300 hover:bg-blue-950/30 rounded transition-colors" title="Detail Agent">
@@ -126,7 +127,7 @@ export function getAgentColumns({
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && (
<>
<button
onClick={(e) => { e.stopPropagation(); setAccountModalAgent({ uuid, label: dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid) }); setAccountModalOpen(true); }}
onClick={(e) => { e.stopPropagation(); setAccountModalAgent({ uuid, label: agentLabel }); setAccountModalOpen(true); }}
className="p-1 text-emerald-400 hover:text-emerald-300 hover:bg-emerald-950/30 rounded transition-colors"
title={existingUser ? 'Edit Agent Account' : 'Create Agent Account'}
>
@@ -136,7 +137,7 @@ export function getAgentColumns({
const hasLocation = locationUuids.has(uuid);
return (
<button
onClick={(e) => { e.stopPropagation(); handleEditLocation(uuid, dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid)); }}
onClick={(e) => { e.stopPropagation(); handleEditLocation(uuid, agentLabel); }}
className={`relative p-1 rounded transition-colors ${
hasLocation
? "text-teal-400 hover:text-teal-300 hover:bg-teal-950/30"
@@ -152,7 +153,7 @@ export function getAgentColumns({
);
})()}
<button
onClick={(e) => { e.stopPropagation(); handleViewAs(uuid, dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid)); }}
onClick={(e) => { e.stopPropagation(); handleViewAs(uuid, agentLabel); }}
disabled={isViewAsLoading}
className="p-1 text-amber-400 hover:text-amber-300 hover:bg-amber-950/30 rounded transition-colors disabled:opacity-50"
title="View As Agent"
@@ -164,6 +165,17 @@ export function getAgentColumns({
</button>
</>
)}
{/* COMPANY_ADMIN dan COMPANY_OPERATOR: hanya View-As */}
{(role === 'COMPANY_ADMIN' || role === 'COMPANY_OPERATOR') && (
<button
onClick={(e) => { e.stopPropagation(); handleViewAs(uuid, agentLabel); }}
disabled={isViewAsLoading}
className="p-1 text-amber-400 hover:text-amber-300 hover:bg-amber-950/30 rounded transition-colors disabled:opacity-50"
title="View As Agent — see dashboard from this agent's perspective"
>
{isViewAsLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
</button>
)}
</div>
);
}
@@ -177,7 +189,15 @@ export function getAgentColumns({
accessor: (row) => {
const uuid = row.uuid || row.serial;
const sizeMB = storageMap[uuid] ?? 0;
return <span className="font-mono text-xs font-normal text-slate-300 tracking-wide">{sizeMB.toFixed(2)} MB</span>;
let displaySize = "";
if (sizeMB >= 1024 * 1024) {
displaySize = `${(sizeMB / (1024 * 1024)).toFixed(2)} TB`;
} else if (sizeMB >= 1024) {
displaySize = `${(sizeMB / 1024).toFixed(2)} GB`;
} else {
displaySize = `${sizeMB.toFixed(2)} MB`;
}
return <span className="font-mono text-xs font-normal text-slate-300 tracking-wide">{displaySize}</span>;
}
});
}
@@ -42,6 +42,7 @@ export function getExternalAccountColumns(
accessor: (row) => {
let badgeColor = "bg-slate-500/20 text-slate-400 border-slate-500/30";
if (row.role === 'SUPER_ADMIN') badgeColor = "bg-purple-500/20 text-purple-400 border-purple-500/30";
if (row.role === 'EXECUTIVE') badgeColor = "bg-yellow-500/20 text-yellow-400 border-yellow-500/30";
if (row.role === 'SOC_ANALYST') badgeColor = "bg-amber-500/20 text-amber-400 border-amber-500/30";
if (row.role === 'ENGINEER') badgeColor = "bg-blue-500/20 text-blue-400 border-blue-500/30";
if (row.role === 'TENANT_ADMIN') badgeColor = "bg-emerald-500/20 text-emerald-400 border-emerald-500/30";
+12 -7
View File
@@ -90,13 +90,18 @@ export default function AgentsPage() {
const uptimeValues = agents.map(a => uptimeMap[a.uuid || a.serial] ?? 100);
const avgUptime = uptimeValues.length > 0 ? uptimeValues.reduce((s, v) => s + v, 0) / uptimeValues.length : 100;
const externalUsers = managedUsers.filter(u => u.role !== 'AGENT_VIEWER');
const superadminUsers = externalUsers.filter(u => u.role === 'SUPER_ADMIN' || !u.site_uuid);
const siabUsers = externalUsers.filter(u => u.site_uuid === '6681452d_9cae_4ff4_8ae8_0d504774265e');
const nexusUsers = externalUsers.filter(u => u.site_uuid === 'd7902405_0dc2_458b_8584_ed4d24b64f24');
// External Accounts di halaman Agents = akun eksternal teknikal/operasional saja
// AGENT_VIEWER = akun network agent (sudah ada di tabel agents) → BUKAN akun eksternal
// COMPANY_* = akun customer/client → sudah ada di halaman User Accounts
const EXCLUDED_ROLES = ['COMPANY_ADMIN', 'COMPANY_OPERATOR', 'COMPANY_VIEWER', 'AGENT_VIEWER'];
const externalUsers = managedUsers.filter(u => !EXCLUDED_ROLES.includes(u.role || ''));
// Grouping by site — semua sudah dipastikan hanya SUPER_ADMIN, EXECUTIVE, TENANT_ADMIN, SOC_ANALYST, ENGINEER
const superadminUsers = externalUsers.filter(u => u.role === 'SUPER_ADMIN' || u.role === 'EXECUTIVE' || !u.site_uuid || u.site_uuid === 'default');
const siabUsers = externalUsers.filter(u => u.site_uuid === '6681452d_9cae_4ff4_8ae8_0d504774265e' && u.role !== 'SUPER_ADMIN' && u.role !== 'EXECUTIVE');
const nexusUsers = externalUsers.filter(u => u.site_uuid === 'd7902405_0dc2_458b_8584_ed4d24b64f24' && u.role !== 'SUPER_ADMIN' && u.role !== 'EXECUTIVE');
return (
<div className="space-y-6" style={{ fontFamily: "Georgia, serif" }}>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight text-white flex items-center gap-3">
@@ -121,8 +126,8 @@ export default function AgentsPage() {
avgUptime={avgUptime}
/>
{/* Agent Location Map */}
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && (
{/* Agent Location Map — tampil untuk SUPER_ADMIN, TENANT_ADMIN, dan COMPANY_ADMIN */}
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN' || role === 'COMPANY_ADMIN') && (
<div className="rounded-2xl border border-white/8 bg-slate-900/40 overflow-hidden relative z-0">
<div className="flex items-center justify-between px-5 py-3.5 border-b border-white/8">
<div className="flex items-center gap-2.5">
+22 -13
View File
@@ -11,6 +11,7 @@ import { usePollingKey } from "@/lib/usePolling";
export function useAgentsData() {
const router = useRouter();
const [role, setRole] = useState<string | null>(null);
const [myAgentUuids, setMyAgentUuids] = useState<string[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [uptimeMap, setUptimeMap] = useState<Record<string, number>>({});
const [storageMap, setStorageMap] = useState<Record<string, number>>({});
@@ -90,7 +91,7 @@ export function useAgentsData() {
}
};
const loadAgents = async () => {
const loadAgents = async (allowedUuids?: string[], currentRole?: string) => {
setIsLoading(true);
try {
const siteUuid = localStorage.getItem("backone_site_uuid") || undefined;
@@ -98,8 +99,18 @@ export function useAgentsData() {
getAgents(siteUuid),
getAdminUsers(),
]);
if (agentData.status === 'fulfilled') setAgents(agentData.value);
else setError((agentData.reason as Error).message);
if (agentData.status === 'fulfilled') {
let allAgents = agentData.value;
// Filter agents untuk COMPANY_ADMIN dan COMPANY_OPERATOR — hanya tampilkan yang di-assign
const uuids = allowedUuids ?? myAgentUuids;
const r = currentRole ?? role;
if ((r === 'COMPANY_ADMIN' || r === 'COMPANY_OPERATOR') && uuids.length > 0) {
allAgents = allAgents.filter(a => uuids.includes(a.uuid || a.serial));
}
setAgents(allAgents);
} else {
setError((agentData.reason as Error).message);
}
if (userData.status === 'fulfilled') setManagedUsers(userData.value);
await loadUptime();
await loadLocations();
@@ -128,11 +139,14 @@ export function useAgentsData() {
.then(data => {
if (data.user) {
const userRole = data.user.role || null;
const uuids: string[] = data.user.agent_uuids || [];
setRole(userRole);
if (userRole !== 'SUPER_ADMIN' && userRole !== 'TENANT_ADMIN') {
setMyAgentUuids(uuids);
const allowed = ['SUPER_ADMIN', 'TENANT_ADMIN', 'EXECUTIVE', 'COMPANY_ADMIN', 'COMPANY_OPERATOR'];
if (!allowed.includes(userRole || '')) {
router.push('/');
} else {
loadAgents();
loadAgents(uuids, userRole);
}
} else {
setIsLoading(false);
@@ -149,7 +163,7 @@ export function useAgentsData() {
}, [timeRange]);
useEffect(() => {
if (role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') {
if (role === 'SUPER_ADMIN') {
loadStorage();
}
if (role && role !== 'AGENT_VIEWER') {
@@ -159,13 +173,8 @@ export function useAgentsData() {
useEffect(() => {
if (role && role !== 'AGENT_VIEWER' && refreshKey > 0) {
const siteUuid = localStorage.getItem("backone_site_uuid") || undefined;
getAgents(siteUuid)
.then(data => setAgents(data))
.catch(err => console.warn("Failed to auto-refresh agents:", err));
loadUptime();
loadLocations();
if (role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') {
loadAgents();
if (role === 'SUPER_ADMIN') {
loadStorage();
}
}
@@ -0,0 +1,153 @@
"use client";
import { useState, useEffect } from "react";
import { Modal } from "@/components/ui/Modal";
import { Loader2 } from "lucide-react";
import { getViewAsHeaders } from "@/lib/admin-api";
import { DeviceLabelItem } from "./columns";
interface EditOwnerModalProps {
isOpen: boolean;
onClose: () => void;
device: DeviceLabelItem | null;
onSaveSuccess: (mac: string, newLabel: string) => void;
}
export default function EditOwnerModal({
isOpen,
onClose,
device,
onSaveSuccess,
}: EditOwnerModalProps) {
const [customLabelValue, setCustomLabelValue] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saveSuccessMsg, setSaveSuccessMsg] = useState<string | null>(null);
useEffect(() => {
if (isOpen && device) {
setCustomLabelValue(device.custom_label || "");
setError(null);
setSaveSuccessMsg(null);
}
}, [isOpen, device]);
const handleSaveLabel = async (e: React.FormEvent) => {
e.preventDefault();
if (!device) return;
setIsSaving(true);
setError(null);
setSaveSuccessMsg(null);
try {
const res = await fetch("/api/dashboard/devices/update-label", {
method: "POST",
headers: {
"Content-Type": "application/json",
...getViewAsHeaders(),
},
body: JSON.stringify({
mac_address: device.mac_address,
device_label: customLabelValue.trim(),
}),
});
const resData = await res.json();
if (resData.ok) {
setSaveSuccessMsg("Owner label updated successfully!");
onSaveSuccess(device.mac_address, customLabelValue.trim());
// Delay closing modal slightly so user sees success state
setTimeout(() => {
onClose();
}, 800);
} else {
setError(resData.error || "Failed to update label.");
}
} catch (err: any) {
setError(err.message || "Failed to update device label.");
} finally {
setIsSaving(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={() => !isSaving && onClose()}
title="Edit Device Owner Label"
>
{device && (
<form onSubmit={handleSaveLabel} className="space-y-5">
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-wider text-slate-400">
MAC Address
</label>
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-lg">
<code className="text-sm text-slate-200 font-mono">{device.mac_address}</code>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-wider text-slate-400">
Default System Label
</label>
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-lg text-sm text-slate-400">
{device.default_label}
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-wider text-slate-400">
Custom Owner Label
</label>
<input
type="text"
value={customLabelValue}
onChange={(e) => setCustomLabelValue(e.target.value)}
placeholder="e.g. Finance Department Laptop"
className="w-full bg-white/[0.03] border border-white/10 rounded-lg px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/50 transition-all"
disabled={isSaving}
autoFocus
/>
<p className="text-[10px] text-slate-500">
Leave empty to clear the custom label and restore the system default.
</p>
</div>
{error && (
<div className="p-3 bg-rose-500/10 border border-rose-500/20 text-rose-400 rounded-lg text-xs">
⚠️ {error}
</div>
)}
{saveSuccessMsg && (
<div className="p-3 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 rounded-lg text-xs font-semibold">
✓ {saveSuccessMsg}
</div>
)}
<div className="flex justify-end gap-3 pt-2 border-t border-white/5">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-transparent hover:bg-white/5 text-slate-300 hover:text-white rounded-lg text-xs font-medium transition-all"
disabled={isSaving}
>
Cancel
</button>
<button
type="submit"
className="inline-flex items-center gap-1.5 px-4 py-2 bg-primary hover:bg-primary/90 text-white rounded-lg text-xs font-semibold shadow-md shadow-primary/10 transition-all"
disabled={isSaving}
>
{isSaving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
Save Changes
</button>
</div>
</form>
)}
</Modal>
);
}
@@ -0,0 +1,102 @@
"use client";
import { Column } from "@/components/ui/DataTable";
export interface DeviceLabelItem {
mac_address: string;
ip_address: string;
device_type: string;
manufacturer: string;
default_label: string;
custom_label: string | null;
agent_uuid: string;
agent_name: string;
last_seen: string;
}
interface GetColumnsProps {
userRole: string;
onEditClick: (device: DeviceLabelItem) => void;
onMacClick: (device: DeviceLabelItem) => void;
}
export function getColumns({
userRole,
onEditClick,
onMacClick,
}: GetColumnsProps): Column<DeviceLabelItem>[] {
return [
{
header: "#",
className: "w-[4%] text-center",
accessor: (_, i) => <span className="text-slate-400 font-mono">{i + 1}</span>
},
{
header: "MAC Address",
className: "w-[15%] text-center",
accessor: (row) => (
<button
type="button"
onClick={() => onMacClick(row)}
className="text-xs bg-white/5 border border-white/10 hover:border-amber-500/30 hover:bg-amber-500/5 px-2 py-0.5 rounded font-mono text-slate-300 hover:text-amber-400 transition-all cursor-pointer outline-none"
title="Click to view device details & IP history"
>
{row.mac_address}
</button>
)
},
{
header: "Custom Owner Label",
className: "w-[20%] text-center",
accessor: (row) => row.custom_label ? (
<span className="text-xs font-semibold text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 px-2.5 py-1 rounded-full">
{row.custom_label}
</span>
) : (
<span className="text-xs text-slate-500 italic">No label configured</span>
)
},
{
header: "Default System Label",
className: "w-[20%] text-center",
accessor: (row) => (
<span className="text-xs text-slate-400">
{row.default_label}
</span>
)
},
{
header: "Network Agent",
className: "w-[18%] text-center",
accessor: (row) => (
<span className="text-xs font-semibold text-purple-400 bg-purple-500/10 border border-purple-500/20 px-2.5 py-1 rounded-full">
{row.agent_name}
</span>
)
},
{
header: "Last IP Address",
className: "w-[11%] text-center",
accessor: (row) => (
<span className="text-xs font-mono text-slate-400">
{row.ip_address}
</span>
)
},
{
header: "Action",
className: "w-[12%] text-center",
accessor: (row) => userRole === 'EXECUTIVE' ? (
<span className="text-xs text-slate-500 italic">View Only</span>
) : (
<button
type="button"
onClick={() => onEditClick(row)}
className="inline-flex items-center gap-1.5 text-xs text-blue-400 hover:text-blue-300 border border-blue-500/20 hover:border-blue-500/40 bg-blue-500/5 hover:bg-blue-500/10 px-3 py-1.5 rounded-lg transition-all font-medium cursor-pointer"
>
✏️ Edit Owner
</button>
)
}
];
}
@@ -0,0 +1,173 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { DataTable } from "@/components/ui/DataTable";
import { getViewAsHeaders } from "@/lib/admin-api";
import { useTimeFilter } from "@/contexts/TimeFilterContext";
import { Loader2, Tag } from "lucide-react";
import { getColumns, DeviceLabelItem } from "./columns";
import EditOwnerModal from "./EditOwnerModal";
import DeviceMacDetailsModal from "@/components/admin/DeviceMacDetailsModal";
export default function DeviceLabelingPage() {
const [mounted, setMounted] = useState(false);
const [devices, setDevices] = useState<DeviceLabelItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [userRole, setUserRole] = useState<string>("");
const { timeRange } = useTimeFilter();
// Modal states
const [isEditOpen, setIsEditOpen] = useState(false);
const [editingDevice, setEditingDevice] = useState<DeviceLabelItem | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [detailedDevice, setDetailedDevice] = useState<DeviceLabelItem | null>(null);
const fetchDevices = async () => {
setIsLoading(true);
setError(null);
try {
const res = await fetch(`/api/dashboard/devices/labeling?timeRange=${timeRange}`, {
headers: getViewAsHeaders(),
});
const resData = await res.json();
if (resData.ok) {
setDevices(resData.data || []);
} else {
setError(resData.error || "Failed to load device listing.");
}
} catch (err: any) {
setError(err.message || "An error occurred while fetching devices.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
setMounted(true);
document.title = "Device Labeling | BackOne - Deep Package Inspection";
// Fetch current user role to conditionally show/hide edit controls
fetch("/api/auth/me", { headers: getViewAsHeaders() })
.then((r) => r.json())
.then((data) => {
if (data.user?.role) setUserRole(data.user.role);
})
.catch(() => {});
}, []);
useEffect(() => {
if (mounted) {
fetchDevices();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeRange, mounted]);
const handleEditClick = (device: DeviceLabelItem) => {
setEditingDevice(device);
setIsEditOpen(true);
};
const handleMacClick = (device: DeviceLabelItem) => {
setDetailedDevice(device);
setIsDetailOpen(true);
};
const handleSaveSuccess = (mac: string, newLabel: string) => {
setDevices((prev) =>
prev.map((d) =>
d.mac_address === mac
? { ...d, custom_label: newLabel || null }
: d
)
);
};
const searchFilter = (row: DeviceLabelItem, q: string) => {
const queryStr = q.toLowerCase();
return (
row.mac_address.toLowerCase().includes(queryStr) ||
row.ip_address.toLowerCase().includes(queryStr) ||
row.default_label.toLowerCase().includes(queryStr) ||
(row.custom_label || "").toLowerCase().includes(queryStr)
);
};
if (!mounted) return null;
const columns = getColumns({
userRole,
onEditClick: handleEditClick,
onMacClick: handleMacClick,
});
return (
<div className="space-y-8 animate-fade-in pb-10">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
Device Labeling
<Tag className="w-6 h-6 text-primary" />
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
</h2>
<p className="text-muted-foreground mt-1">
Assign custom names to MAC addresses to identify device owners across all charts and flows.
</p>
</div>
</div>
<Card className="border-white/5 bg-black/40 backdrop-blur-md">
<CardHeader>
<CardTitle className="text-sm font-semibold tracking-wide uppercase text-slate-300">
Device Asset Directory
</CardTitle>
</CardHeader>
<CardContent>
{error && !isEditOpen && !isDetailOpen && (
<div className="mb-6 p-4 bg-rose-500/10 border border-rose-500/20 text-rose-400 rounded-lg text-sm">
⚠️ {error}
</div>
)}
<DataTable
data={devices}
columns={columns}
searchPlaceholder="Search MAC, IP, vendor, or custom label..."
searchFilter={searchFilter}
isLoading={isLoading}
/>
</CardContent>
</Card>
{/* Edit Owner Modal */}
<EditOwnerModal
isOpen={isEditOpen}
onClose={() => {
setIsEditOpen(false);
setEditingDevice(null);
}}
device={editingDevice}
onSaveSuccess={handleSaveSuccess}
/>
{/* Device Mac Details Modal */}
{detailedDevice && (
<DeviceMacDetailsModal
isOpen={isDetailOpen}
onClose={() => {
setIsDetailOpen(false);
setDetailedDevice(null);
}}
macAddress={detailedDevice.mac_address}
customLabel={detailedDevice.custom_label}
defaultLabel={detailedDevice.default_label}
agentName={detailedDevice.agent_name}
manufacturer={detailedDevice.manufacturer}
deviceType={detailedDevice.device_type}
/>
)}
</div>
);
}
+9 -9
View File
@@ -86,11 +86,11 @@ function EventsContent() {
};
const columns: Column<typeof data[0]>[] = [
{ header: "#", accessor: (row, i) => i + 1, className: "w-[60px] min-w-[60px] max-w-[60px]" },
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%] text-center" },
{
header: "Timestamp (WIB)",
accessor: (row) => <TimestampCell timestamp={row.timestamp} showActiveStatus={false} />,
className: "w-[180px] min-w-[180px] max-w-[180px]"
className: "w-[15%] text-center"
},
{
header: "Severity",
@@ -100,32 +100,32 @@ function EventsContent() {
<span className="capitalize">{row.severity}</span>
</div>
),
className: "w-[120px] min-w-[120px] max-w-[120px]"
className: "w-[10%] text-center"
},
{ header: "Event Type", accessor: (row) => row.event_type, className: "w-[130px] min-w-[130px] max-w-[130px]" },
{ header: "Event Type", accessor: (row) => row.event_type, className: "w-[12%] text-center" },
{
header: "MAC Address",
accessor: (row) => row.mac_address ? (
<button
onClick={() => setSelectedMac(row.mac_address)}
className="font-mono text-xs text-emerald-400 hover:text-emerald-300 hover:underline text-center w-full focus:outline-none"
className="font-mono text-xs text-emerald-400 hover:text-emerald-300 hover:underline text-center w-full focus:outline-none truncate"
>
{row.mac_address}
</button>
) : <span className="text-slate-500">-</span>,
className: "w-[160px] min-w-[160px] max-w-[160px]"
className: "w-[14%] text-center"
},
{
header: "Source IP",
accessor: (row) => row.source_ip ? (
<button
onClick={() => setSelectedIp(row.source_ip)}
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs text-center w-full focus:outline-none"
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs text-center w-full focus:outline-none truncate"
>
{row.source_ip}
</button>
) : <span className="text-slate-500">-</span>,
className: "w-[140px] min-w-[140px] max-w-[140px]"
className: "w-[12%] text-center"
},
{
header: "Message",
@@ -152,7 +152,7 @@ function EventsContent() {
</div>
);
},
className: "text-center w-full min-w-0 max-w-full"
className: "w-[33%] text-center"
}
];
+26 -19
View File
@@ -93,31 +93,38 @@ export default function FlowsPage() {
showActiveStatus={true}
/>
),
className: "w-[170px]",
className: "w-[14%] text-center",
tooltip: (row) => new Date(row.last_seen).toLocaleString()
},
{
header: "#",
accessor: (row, i) => i + 1,
className: "w-[50px]"
className: "w-[4%] text-center"
},
{
header: "Flow ID",
accessor: (row) => row.flow_id,
className: "w-[130px]"
className: "w-[11%] text-center font-mono text-xs truncate"
},
{
header: "Src IP",
accessor: (row) => (
<button
onClick={() => setSelectedIp(row.src_ip)}
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-left focus:outline-none"
>
{row.src_ip}
</button>
<div className="flex flex-col items-center justify-center text-center">
<button
onClick={() => setSelectedIp(row.src_ip)}
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-center focus:outline-none truncate w-full"
>
{row.src_ip}
</button>
{row.src_label && (
<span className="text-[10px] text-cyan-400 font-semibold tracking-wide mt-0.5 truncate w-full block">
{row.src_label}
</span>
)}
</div>
),
className: "w-[120px]",
tooltip: (row) => row.src_ip
className: "w-[11%] text-center",
tooltip: (row) => row.src_label ? `${row.src_ip} (${row.src_label})` : row.src_ip
},
{
header: "Dst IP",
@@ -125,25 +132,25 @@ export default function FlowsPage() {
<div className="flex flex-col items-center justify-center">
<button
onClick={() => setSelectedIp(row.dst_ip)}
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-center focus:outline-none"
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-center focus:outline-none truncate w-full"
>
{row.dst_ip}
</button>
<IpDetails ip={row.dst_ip} />
</div>
),
className: "w-[150px]",
className: "w-[13%] text-center",
tooltip: (row) => row.dst_ip
},
{
header: "Dst Port",
accessor: (row) => row.dst_port,
className: "w-[80px]"
className: "w-[7%] text-center font-mono text-xs"
},
{
header: "Protocol",
accessor: (row) => row.protocol,
className: "w-[80px]"
className: "w-[7%] text-center text-xs"
},
{
header: "App / Domain",
@@ -155,14 +162,14 @@ export default function FlowsPage() {
{exp.main}
</span>
{exp.sub && (
<span className="text-[10px] text-slate-400 italic text-center">
<span className="text-[10px] text-slate-400 italic text-center truncate max-w-[200px]">
{exp.sub}
</span>
)}
</div>
);
},
className: "w-[220px]",
className: "w-[18%] text-center",
tooltip: (row) => {
const exp = explainAppOrPort(row.app_label, row.domain, row.dst_port);
return exp.sub ? `${exp.main} (${exp.sub})` : exp.main;
@@ -171,13 +178,13 @@ export default function FlowsPage() {
{
header: "Download",
accessor: (row) => <span className="text-blue-400">{fmtBytes(row.bytes_download)}</span>,
className: "w-[90px]",
className: "w-[8%] text-center",
tooltip: (row) => `${fmtBytes(row.bytes_download)} (${(row.bytes_download || 0).toLocaleString()} bytes)`
},
{
header: "Upload",
accessor: (row) => <span className="text-green-500">{fmtBytes(row.bytes_upload)}</span>,
className: "w-[90px]",
className: "w-[7%] text-center",
tooltip: (row) => `${fmtBytes(row.bytes_upload)} (${(row.bytes_upload || 0).toLocaleString()} bytes)`
}
];
+6 -3
View File
@@ -6,13 +6,16 @@ import { ApplicationCatalog } from "@/components/lookup/ApplicationCatalog";
import { BlacklistConfiguration } from "@/components/lookup/BlacklistConfiguration";
import { HelpTrigger } from "@/components/help/HelpTrigger";
import { getSiteBranding } from "@/lib/branding";
export default function LookupPage() {
const [mounted, setMounted] = useState(false);
const branding = getSiteBranding();
useEffect(() => {
setMounted(true);
document.title = "App Lookup | BackOne - Deep Package Inspection";
}, []);
document.title = `App Lookup | ${branding.name} - Deep Package Inspection`;
}, [branding.name]);
if (!mounted) return null;
@@ -24,7 +27,7 @@ export default function LookupPage() {
App Lookup
</h1>
<p className="text-slate-400 mt-2">
Search BackOne's extensive classification database of over 2,500+ apps, protocols, and services, and manage your blacklist configuration.
Search {branding.name === "BackOne" ? "BackOne's" : `${branding.name}'s`} extensive classification database of over 2,500+ apps, protocols, and services, and manage your blacklist configuration.
</p>
</div>
<HelpTrigger pageId="lookup" />
@@ -65,19 +65,19 @@ export default function NetworkIntelligencePage() {
}
const appCols: Column<AppCategoryStat>[] = [
{ header: "#", accessor: (row, i) => i + 1, className: "w-[60px] min-w-[60px] max-w-[60px]" },
{ header: "#", accessor: (row, i) => i + 1, className: "w-[10%] text-center" },
{
header: "Category",
accessor: (row) => (
<div title={row.category_label || '-'} className="font-semibold text-white truncate w-full block text-center cursor-help">
<div title={row.category_label || '-'} className="font-semibold text-white truncate w-full block text-center cursor-help animate-pulse-subtle">
<span className="border-b border-dashed border-white/30 pb-0.5">{row.category_label || '-'}</span>
</div>
),
className: "w-[180px] min-w-[180px] max-w-[180px]"
className: "w-[35%] text-center"
},
{ header: "Download", accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span>, className: "w-[120px] min-w-[120px] max-w-[120px]" },
{ header: "Upload", accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>, className: "w-[120px] min-w-[120px] max-w-[120px]" },
{ header: "Total", accessor: (row) => fmtBytes(row.total), className: "w-[120px] min-w-[120px] max-w-[120px]" },
{ header: "Download", accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span>, className: "w-[18%] text-center" },
{ header: "Upload", accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>, className: "w-[18%] text-center" },
{ header: "Total", accessor: (row) => fmtBytes(row.total), className: "w-[19%] text-center" },
];
const isLoading = appCategories.isLoading || continents.isLoading;
@@ -0,0 +1,89 @@
"use client";
import { Users, Plus, ShieldAlert } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { DataTable } from "@/components/ui/DataTable";
import { type ManagedUser } from "@/lib/admin-api";
import { Column } from "@/components/ui/DataTable";
export interface CompanyGroup {
name: string;
users: ManagedUser[];
}
interface CompanyCardProps {
company: CompanyGroup;
role: string | null;
columns: Column<ManagedUser>[];
onAddUserClick: (companyName: string) => void;
}
export default function CompanyCard({
company,
role,
columns,
onAddUserClick,
}: CompanyCardProps) {
const count = company.users.length;
const isLimitReached = count >= 5;
return (
<Card className="bg-card/50 backdrop-blur-sm border-border/50 overflow-hidden shadow-xl shadow-black/10">
<CardHeader className="flex flex-row items-center justify-between pb-3 border-b border-white/5 bg-white/[0.01]">
<div className="flex items-center gap-3">
<div className="p-2 bg-amber-500/10 border border-amber-500/20 rounded-lg shadow-sm">
<Users className="w-4 h-4 text-amber-400" />
</div>
<div>
<CardTitle className="text-white text-base font-bold">{company.name} Accounts</CardTitle>
<div className="flex items-center gap-2 mt-1">
<span className={`text-xs px-2.5 py-0.5 rounded-full font-semibold border ${
isLimitReached
? "bg-red-500/10 text-red-400 border-red-500/20"
: "bg-slate-500/10 text-slate-400 border-slate-500/20"
}`}>
Accounts: {count} / 5
</span>
{isLimitReached && (
<span className="text-[10px] text-red-400 flex items-center gap-1">
<ShieldAlert className="w-3.5 h-3.5" /> Max limit reached
</span>
)}
</div>
</div>
</div>
{role !== "EXECUTIVE" && (
<button
type="button"
onClick={() => onAddUserClick(company.name)}
disabled={isLimitReached}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg transition-all border cursor-pointer ${
isLimitReached
? "bg-slate-800 text-slate-500 border-slate-700 cursor-not-allowed"
: "bg-amber-600/10 text-amber-400 hover:bg-amber-600/20 border-amber-500/30 hover:border-amber-500/50 shadow-md shadow-amber-600/5"
}`}
>
<Plus className="w-3.5 h-3.5" />
Add User for {company.name}
</button>
)}
</CardHeader>
<CardContent className="pt-6">
<DataTable
data={company.users}
columns={columns}
searchPlaceholder={`Search users in ${company.name}...`}
searchFilter={(row, query) => {
const q = query.toLowerCase();
return (
(row.username || "").toLowerCase().includes(q) ||
(row.account_name || "").toLowerCase().includes(q) ||
(row.role || "").toLowerCase().includes(q)
);
}}
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,128 @@
// src/app/(dashboard)/user-accounts/columns.tsx
"use client";
import { Column } from "@/components/ui/DataTable";
import { type ManagedUser } from "@/lib/admin-api";
import { Eye, Loader2 } from "lucide-react";
interface GetColumnsProps {
role: string | null;
currentUsername?: string | null;
viewAsLoading?: string | null;
onEditClick: (user: ManagedUser) => void;
onViewAsClick?: (user: ManagedUser) => void;
}
export function getColumns({
role,
currentUsername,
viewAsLoading,
onEditClick,
onViewAsClick,
}: GetColumnsProps): Column<ManagedUser>[] {
return [
{
header: "#",
className: "w-[5%] text-center",
accessor: (_, i) => <span className="text-slate-400 font-mono">{i + 1}</span>
},
{
header: "Account Name",
className: "w-[22%] text-center",
accessor: (row) => (
<span className="text-sm font-medium text-slate-200">
{row.account_name || row.username}
</span>
)
},
{
header: "Username",
className: "w-[18%] text-center",
accessor: (row) => <code className="text-xs text-slate-400 font-mono">{row.username}</code>
},
{
header: "Role",
className: "w-[18%] text-center",
accessor: (row) => {
let badgeColor = "bg-slate-500/20 text-slate-400 border-slate-500/30";
if (row.role === "COMPANY_ADMIN") badgeColor = "bg-purple-500/20 text-purple-400 border-purple-500/30";
if (row.role === "COMPANY_OPERATOR") badgeColor = "bg-amber-500/20 text-amber-400 border-amber-500/30";
if (row.role === "COMPANY_VIEWER") badgeColor = "bg-blue-500/20 text-blue-400 border-blue-500/30";
return (
<span className={`px-2.5 py-1 rounded-full text-[10px] font-bold border uppercase tracking-wider ${badgeColor}`}>
{row.role.replace(/_/g, " ")}
</span>
);
}
},
{
header: "Assigned Devices",
className: "w-[15%] text-center",
accessor: (row) => {
const uuids = row.agent_uuids || [];
if (uuids.length === 0) return <span className="text-xs text-slate-500 italic">No device assigned</span>;
return (
<span className="text-xs font-mono text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 px-2 py-0.5 rounded">
{uuids.length} Device{uuids.length > 1 ? "s" : ""}
</span>
);
}
},
{
header: "View As",
className: "w-[10%] text-center",
accessor: (row) => {
const isSelf = currentUsername && row.username === currentUsername;
const isCompanyAdmin = row.role === "COMPANY_ADMIN";
// View-As tersedia untuk COMPANY_OPERATOR dan COMPANY_VIEWER (bukan diri sendiri, bukan admin)
const canViewAs = onViewAsClick &&
!isSelf &&
!isCompanyAdmin &&
(row.role === "COMPANY_OPERATOR" || row.role === "COMPANY_VIEWER") &&
(role === "SUPER_ADMIN" || role === "COMPANY_ADMIN");
if (!canViewAs) {
return <span className="text-slate-600 text-xs">—</span>;
}
const isLoading = viewAsLoading === row.username;
return (
<button
type="button"
onClick={() => onViewAsClick(row)}
disabled={isLoading}
title={`View dashboard as ${row.account_name || row.username}`}
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] font-semibold text-amber-400 bg-amber-400/10 border border-amber-400/20 hover:bg-amber-400/20 hover:border-amber-400/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{isLoading ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Eye className="w-3 h-3" />
)}
View As
</button>
);
}
},
{
header: "Actions",
className: "w-[12%] text-center",
accessor: (row) => {
const isSelf = currentUsername && row.username === currentUsername;
const isDisabled = role === "EXECUTIVE" || isSelf;
return (
<button
type="button"
onClick={() => !isSelf && onEditClick(row)}
disabled={!!isDisabled}
title={isSelf ? "Cannot edit your own account from here" : undefined}
className="text-xs text-blue-400 hover:text-blue-300 disabled:opacity-50 disabled:cursor-not-allowed hover:underline font-medium cursor-pointer"
>
{isSelf ? "👤 (You)" : "⚙️ Edit User"}
</button>
);
}
}
];
}
+252
View File
@@ -0,0 +1,252 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Users, Plus } from "lucide-react";
import { getAdminUsers, getViewAsHeaders, startViewAsUser, type ManagedUser } from "@/lib/admin-api";
import { HelpTrigger } from "@/components/help/HelpTrigger";
import ExternalAccountModal from "@/components/admin/ExternalAccountModal";
import { getColumns } from "./columns";
import CompanyCard, { CompanyGroup } from "./CompanyCard";
export default function UserAccountsPage() {
const router = useRouter();
const [mounted, setMounted] = useState(false);
const [role, setRole] = useState<string | null>(null);
const [currentUsername, setCurrentUsername] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [managedUsers, setManagedUsers] = useState<ManagedUser[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [modalUser, setModalUser] = useState<ManagedUser | null>(null);
const [targetCompany, setTargetCompany] = useState<string | null>(null);
const [viewAsLoading, setViewAsLoading] = useState<string | null>(null);
// Registry agents untuk lookup label agent (digunakan pada View-As banner)
const [agentRegistry, setAgentRegistry] = useState<Record<string, string>>({});
const loadData = async () => {
setIsLoading(true);
setError(null);
try {
const users = await getAdminUsers();
setManagedUsers(users);
} catch (err: any) {
setError(err.message || "Failed to retrieve user data.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
setMounted(true);
document.title = "User Accounts | BackOne - Deep Package Inspection";
fetch("/api/auth/me", { headers: getViewAsHeaders() })
.then(res => res.json())
.then(data => {
if (data.user) {
const userRole = data.user.role || null;
setRole(userRole);
setCurrentUsername(data.user.username || null);
if (userRole !== "SUPER_ADMIN" && userRole !== "EXECUTIVE" && userRole !== "COMPANY_ADMIN") {
router.push("/");
} else {
loadData();
// Juga load agent registry untuk label lookup (digunakan pada banner View-As)
fetch("/api/dashboard/agents/list", { headers: getViewAsHeaders() })
.then(r => r.json())
.then(json => {
if (json.ok && Array.isArray(json.data)) {
const map: Record<string, string> = {};
json.data.forEach((a: any) => {
if (a.uuid) map[a.uuid] = a.label || a.uuid;
});
setAgentRegistry(map);
}
})
.catch(() => {});
}
} else {
setIsLoading(false);
}
})
.catch(() => {
setIsLoading(false);
});
}, [router]);
if (!mounted) return null;
// User Accounts = HANYA akun customer/client (COMPANY_* roles)
// Role teknikal/operasional (SOC_ANALYST, EXECUTIVE, ENGINEER, dll) ada di halaman Agents
const COMPANY_ROLES = ['COMPANY_ADMIN', 'COMPANY_OPERATOR', 'COMPANY_VIEWER'];
const companyUsers = managedUsers.filter(u => COMPANY_ROLES.includes(u.role || '') && u.company_name);
const companiesMap: Record<string, ManagedUser[]> = {};
companyUsers.forEach(u => {
if (u.company_name) {
if (!companiesMap[u.company_name]) {
companiesMap[u.company_name] = [];
}
companiesMap[u.company_name].push(u);
}
});
const companies: CompanyGroup[] = Object.entries(companiesMap).map(([name, users]) => ({
name,
users
}));
const handleEditClick = (user: ManagedUser) => {
setModalUser(user);
setTargetCompany(user.company_name || null);
setIsModalOpen(true);
};
const handleAddUserClick = (companyName: string) => {
setModalUser(null);
setTargetCompany(companyName);
setIsModalOpen(true);
};
const handleViewAsUser = async (user: ManagedUser) => {
// View-As berdasarkan agent_uuids pertama yang di-assign ke user target
const agentUuids = user.agent_uuids || [];
if (agentUuids.length === 0) {
alert(`User "${user.account_name || user.username}" has no assigned agents. Cannot enter View-As mode.`);
return;
}
const targetAgentUuid = agentUuids[0];
// Gunakan label agent dari registry (bukan UUID) untuk banner yang informatif
const agentLabel = agentRegistry[targetAgentUuid] || targetAgentUuid;
const userName = user.account_name || user.username;
const userRole = user.role;
setViewAsLoading(user.username);
try {
// startViewAsUser menyimpan: agent_label (label agent), user_name, user_role, view_as_type='user'
await startViewAsUser(targetAgentUuid, agentLabel, userName, userRole);
router.refresh();
window.location.reload();
} catch (err: any) {
alert('Failed to enter View As mode: ' + err.message);
} finally {
setViewAsLoading(null);
}
};
const columns = getColumns({
role,
currentUsername,
viewAsLoading,
onEditClick: handleEditClick,
onViewAsClick: (role === "SUPER_ADMIN" || role === "COMPANY_ADMIN") ? handleViewAsUser : undefined,
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-tight text-white flex items-center gap-3">
User Accounts Directory
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-amber-500" />}
</h2>
<p className="text-sm text-muted-foreground mt-1">
Manage company tenant user access, roles, and device ownership delegation.
</p>
</div>
<HelpTrigger pageId="user-accounts" />
</div>
{error && (
<div className="bg-red-500/15 border border-red-500/30 text-red-400 px-4 py-3 rounded-xl text-sm font-medium">
{error}
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-24">
<Loader2 className="w-10 h-10 animate-spin text-amber-500" />
</div>
) : (
<div className="space-y-8">
{companies.length === 0 ? (
<div className="max-w-md mx-auto py-12 px-6">
<div className="relative overflow-hidden bg-white/[0.02] backdrop-blur-md border border-white/10 rounded-3xl p-8 text-center shadow-2xl shadow-black/50">
{/* Ambient glow highlight */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-48 h-48 bg-amber-500/5 rounded-full blur-3xl pointer-events-none" />
<div className="relative w-16 h-16 rounded-full bg-amber-500/10 border border-amber-500/20 flex items-center justify-center mx-auto mb-5 text-amber-400 shadow-lg shadow-amber-500/5 animate-pulse-subtle">
<Users className="w-8 h-8" />
</div>
<h3 className="relative text-white font-bold text-lg mb-1.5">No Company Accounts</h3>
<p className="relative text-sm text-slate-400 mb-6 max-w-xs mx-auto">
Create the first company tenant admin account to get started.
</p>
{role === "SUPER_ADMIN" && (
<button
type="button"
onClick={() => handleAddUserClick("")}
className="relative inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-amber-600 to-amber-700 hover:from-amber-500 hover:to-amber-600 text-white rounded-xl text-xs font-semibold shadow-md shadow-amber-600/10 hover:shadow-lg transition-all cursor-pointer"
>
<Plus className="w-4 h-4" />
Add Company Admin
</button>
)}
</div>
</div>
) : (
companies.map((company) => (
<CompanyCard
key={company.name}
company={company}
role={role}
columns={columns}
onAddUserClick={handleAddUserClick}
/>
))
)}
{role === "SUPER_ADMIN" && companies.length > 0 && (
<div className="flex justify-end pt-2">
<button
type="button"
onClick={() => handleAddUserClick("")}
className="flex items-center gap-2 px-5 py-2.5 text-xs font-semibold bg-amber-600 hover:bg-amber-700 text-white rounded-xl transition-all shadow-md shadow-amber-600/10 hover:shadow-lg cursor-pointer"
>
<Plus className="w-4 h-4" />
Add New Company Admin
</button>
</div>
)}
</div>
)}
{/* Account Creation / Edit Modal */}
<ExternalAccountModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
setModalUser(null);
setTargetCompany(null);
}}
onSuccess={() => {
setIsModalOpen(false);
setModalUser(null);
setTargetCompany(null);
loadData();
}}
user={modalUser}
targetCompany={targetCompany}
targetSiteUuid={null}
targetCreatedBy={role === "SUPER_ADMIN" ? "admin" : undefined}
/>
</div>
);
}
+12 -2
View File
@@ -2,9 +2,19 @@
@import "./theme.css";
@import "./variables.css";
/* ── Zero Horizontal Scrollbar – Global Enforcement ── */
/* Per AGENTS.md §9: horizontal scrollbar strictly prohibited at any level. */
html {
overflow-x: hidden;
}
body {
/* Use Georgia font style globally */
font-family: Georgia, serif, var(--font-sans);
overflow-x: hidden;
width: 100%;
}
body {
font-family: var(--font-sans);
color: var(--foreground);
/* Optical sizing + smoothing */
font-optical-sizing: auto;
+2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import { ThemeProvider } from "@/components/ThemeProvider";
import { ViewportScaler } from "@/components/layout/ViewportScaler";
import "./globals.css";
// Inter font — local variable font file, matches demoplace.my.id
@@ -43,6 +44,7 @@ export default function RootLayout({
suppressHydrationWarning
>
<body className="min-h-full flex flex-col" suppressHydrationWarning>
<ViewportScaler />
<ThemeProvider
attribute="class"
defaultTheme="dark"
+1 -2
View File
@@ -30,8 +30,7 @@ export default function LoginPage() {
throw new Error(data.error || "Login failed");
}
router.push("/");
router.refresh(); // Refresh to update middleware state
window.location.href = "/";
} catch (err: any) {
setError(err.message);
} finally {
+3 -2
View File
@@ -4,8 +4,9 @@
--font-backone: var(--font-backone);
/* Set Georgia as the default sans-serif replacement globally */
--font-sans: Georgia, serif, var(--font-inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
/* Set Inter/sans-serif as the default globally */
--font-sans: var(--font-inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
/* Mono also uses Inter first — matches demoplace (--default-mono-font-family: var(--font-inter)) */
--font-mono: var(--font-inter, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
+58
View File
@@ -0,0 +1,58 @@
"use client";
interface AgentChecklistProps {
availableAgents: string[];
selectedAgents: string[];
onAgentCheck: (uuid: string, checked: boolean) => void;
agentNamesMap: Record<string, string>;
}
export default function AgentChecklist({
availableAgents,
selectedAgents,
onAgentCheck,
agentNamesMap,
}: AgentChecklistProps) {
const filteredAgents = availableAgents
.filter(Boolean)
.filter((uuid) => uuid.trim() !== "");
return (
<div>
<label className="block text-xs font-medium text-slate-400 mb-2">
🎯 Assign Network Agents ({selectedAgents.length} selected)
</label>
<div className="w-full max-h-40 overflow-y-auto bg-slate-800/40 border border-slate-700 rounded-lg p-3 space-y-2.5 custom-scrollbar">
{filteredAgents.length === 0 ? (
<p className="text-xs text-slate-500 italic">No network agents available</p>
) : (
filteredAgents.map((uuid) => {
const isChecked = selectedAgents.includes(uuid);
const agentName = agentNamesMap[uuid] || uuid;
return (
<label
key={uuid}
className="flex items-center gap-3 cursor-pointer group text-xs text-slate-300 select-none"
>
<input
type="checkbox"
checked={isChecked}
onChange={(e) => onAgentCheck(uuid, e.target.checked)}
className="rounded border-slate-600 bg-slate-800 text-amber-500 focus:ring-amber-500/50 w-4 h-4 cursor-pointer"
/>
<span className="group-hover:text-white transition-colors">
{agentName}
</span>
{agentName !== uuid && (
<span className="text-[10px] text-slate-500 font-mono">
({uuid})
</span>
)}
</label>
);
})
)}
</div>
</div>
);
}
@@ -110,7 +110,7 @@ export function drawMarkers(
padding:4px 10px 4px 8px; border-radius:8px; white-space:nowrap;
background:rgba(10,18,36,0.93); border:1.5px solid ${colorL};
color:${colorL}; font-size:11px; font-weight:700;
font-family:Georgia,serif; letter-spacing:0.02em;
font-family:var(--font-sans),sans-serif; letter-spacing:0.02em;
box-shadow:0 0 10px ${color}55, 0 2px 12px rgba(0,0,0,0.7);
display:flex; align-items:center; gap:6px;">
<span style="width:7px;height:7px;border-radius:50%;background:${color};
@@ -133,13 +133,13 @@ export function drawMarkers(
? `<button id="el-${pin.agent_uuid.replace(/\W/g, "")}"
style="margin-top:10px;width:100%;padding:6px;background:rgba(99,102,241,0.15);
border:1px solid rgba(99,102,241,0.35);border-radius:8px;color:#818cf8;
font-size:11px;font-weight:700;cursor:pointer;font-family:Georgia,serif;"
font-size:11px;font-weight:700;cursor:pointer;font-family:var(--font-sans),sans-serif;"
onmouseover="this.style.background='rgba(99,102,241,0.3)'"
onmouseout="this.style.background='rgba(99,102,241,0.15)'">✎ Update Location</button>`
: "";
marker.bindPopup(
`<div style="background:#0d1526;border:1px solid ${colorL}44;border-radius:14px;padding:14px 16px;min-width:200px;font-family:Georgia,serif;box-shadow:0 20px 60px rgba(0,0,0,0.85);">
`<div style="background:#0d1526;border:1px solid ${colorL}44;border-radius:14px;padding:14px 16px;min-width:200px;font-family:var(--font-sans),sans-serif;box-shadow:0 20px 60px rgba(0,0,0,0.85);">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
<span style="width:9px;height:9px;border-radius:50%;background:${color};${
online ? `box-shadow:0 0 8px ${color};` : ""
@@ -0,0 +1,220 @@
"use client";
import { useState, useEffect } from "react";
import { Modal } from "@/components/ui/Modal";
import { fmtBytes } from "@/lib/utils";
import { getViewAsHeaders } from "@/lib/admin-api";
import { Loader2, Monitor, Shield, Tag, Cpu, Clock, Network } from "lucide-react";
interface IpHistoryItem {
ip_address: string;
first_seen: string;
last_seen: string;
download: number;
upload: number;
flows: number;
}
interface DeviceMacDetailsModalProps {
isOpen: boolean;
onClose: () => void;
macAddress: string;
customLabel: string | null;
defaultLabel: string;
agentName: string;
manufacturer: string;
deviceType: string;
}
export default function DeviceMacDetailsModal({
isOpen,
onClose,
macAddress,
customLabel,
defaultLabel,
agentName,
manufacturer,
deviceType,
}: DeviceMacDetailsModalProps) {
const [ips, setIps] = useState<IpHistoryItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isOpen || !macAddress) return;
setIsLoading(true);
setError(null);
fetch(`/api/dashboard/devices/mac-details?mac=${encodeURIComponent(macAddress)}`, {
headers: getViewAsHeaders(),
})
.then((res) => res.json())
.then((data) => {
if (data.ok) {
setIps(data.ips || []);
} else {
setError(data.error || "Failed to load device details.");
}
})
.catch((err) => {
setError(err.message || "An error occurred while loading details.");
})
.finally(() => {
setIsLoading(false);
});
}, [isOpen, macAddress]);
const formatFriendlyDate = (dateStr?: string) => {
if (!dateStr) return "-";
try {
const date = new Date(dateStr);
return date.toLocaleString("id-ID", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}) + " WIB";
} catch {
return dateStr;
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="🔍 Device Network Details"
>
<div className="space-y-6 max-h-[75vh] overflow-y-auto pr-1 custom-scrollbar">
{/* Profile Card */}
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1">
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1">
<Tag className="w-3 h-3" /> MAC Address
</span>
<code className="text-xs text-white font-mono block">{macAddress}</code>
</div>
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1">
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1">
<Cpu className="w-3 h-3" /> Manufacturer
</span>
<span className="text-xs text-slate-200 block truncate" title={manufacturer}>
{manufacturer || "Unknown"}
</span>
</div>
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1 col-span-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1">
<Monitor className="w-3 h-3" /> Device Labels
</span>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-[10px] text-slate-500 font-medium">Custom:</span>
{customLabel ? (
<span className="text-xs font-semibold text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 px-2 py-0.5 rounded-full">
{customLabel}
</span>
) : (
<span className="text-xs text-slate-500 italic">No label configured</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] text-slate-500 font-medium">System:</span>
<span className="text-xs text-slate-400 truncate">{defaultLabel}</span>
</div>
</div>
</div>
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1">
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1">
<Shield className="w-3 h-3" /> Device Type
</span>
<span className="text-xs text-slate-200 block capitalize">{deviceType || "Unknown"}</span>
</div>
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1">
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1">
<Network className="w-3 h-3" /> Network Agent
</span>
<span className="text-xs text-purple-400 font-semibold block truncate" title={agentName}>
{agentName}
</span>
</div>
</div>
{/* IPs Section */}
<div className="space-y-3">
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-400 flex items-center gap-2">
🔗 Associated IP Addresses
</h4>
{isLoading ? (
<div className="py-8 flex justify-center items-center gap-2 text-slate-400 text-xs">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
Resolving active IP address histories...
</div>
) : error ? (
<div className="p-4 bg-rose-500/10 border border-rose-500/20 text-rose-400 rounded-lg text-xs">
⚠️ {error}
</div>
) : ips.length === 0 ? (
<div className="py-8 text-center text-slate-500 italic text-xs border border-white/5 rounded-xl bg-white/[0.01]">
No IP addresses recorded for this MAC address.
</div>
) : (
<div className="border border-white/5 rounded-xl overflow-hidden bg-white/[0.01]">
<div className="max-h-60 overflow-y-auto custom-scrollbar">
<table className="w-full text-xs text-slate-300">
<thead className="bg-white/[0.03] text-slate-400 uppercase text-[9px] tracking-wider sticky top-0 z-10 border-b border-white/5">
<tr>
<th className="px-3 py-2 text-center">IP Address</th>
<th className="px-3 py-2 text-center">First Seen</th>
<th className="px-3 py-2 text-center">Last Seen</th>
<th className="px-3 py-2 text-center">Traffic (Down / Up)</th>
<th className="px-3 py-2 text-center">Flows</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{ips.map((ip) => (
<tr key={ip.ip_address} className="hover:bg-white/[0.02] transition-colors">
<td className="px-3 py-2.5 font-mono text-center text-slate-200">
{ip.ip_address}
</td>
<td className="px-3 py-2.5 text-center text-slate-400">
{formatFriendlyDate(ip.first_seen)}
</td>
<td className="px-3 py-2.5 text-center text-slate-400 flex items-center justify-center gap-1">
<Clock className="w-3 h-3 text-slate-500" />
{formatFriendlyDate(ip.last_seen)}
</td>
<td className="px-3 py-2.5 text-center font-mono">
{fmtBytes(ip.download)} / {fmtBytes(ip.upload)}
</td>
<td className="px-3 py-2.5 text-center font-mono text-slate-400">
{ip.flows}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
<div className="flex justify-end pt-4 border-t border-white/5">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white rounded-lg text-xs font-semibold transition-all"
>
Close Details
</button>
</div>
</div>
</Modal>
);
}
+215 -250
View File
@@ -1,10 +1,12 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X, User, Key, Tag, Upload, Loader2, CheckCircle2, AlertCircle, Trash2, Shield } from "lucide-react";
import { X, User, Key, Tag, Loader2, AlertCircle, CheckCircle2, Trash2, Shield } from "lucide-react";
import type { ManagedUser } from "@/lib/admin-api";
import { updateAdminAgentUser, deleteAdminAgentUser, createAdminExternalUser } from "@/lib/admin-api";
import { useExternalAccountForm } from "./useExternalAccountForm";
import RoleSelect from "./RoleSelect";
import AgentChecklist from "./AgentChecklist";
import ProfilePictureInput from "./ProfilePictureInput";
interface ExternalAccountModalProps {
isOpen: boolean;
@@ -13,6 +15,8 @@ interface ExternalAccountModalProps {
user?: ManagedUser | null;
targetSiteUuid?: string | null;
targetCreatedBy?: string | null;
targetCompany?: string | null;
modalContext?: 'agents' | 'user-accounts';
}
export default function ExternalAccountModal({
@@ -22,105 +26,41 @@ export default function ExternalAccountModal({
user,
targetSiteUuid,
targetCreatedBy,
targetCompany,
modalContext = 'user-accounts',
}: ExternalAccountModalProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [accountName, setAccountName] = useState("");
const [role, setRole] = useState("SOC_ANALYST");
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 (user) {
setUsername(user.username);
setAccountName(user.account_name || "");
setRole(user.role);
setPassword(""); // Selalu kosong agar admin bisa memasukkan password baru untuk di-reset
setPreviewPic(
user.profile_picture
? `/api/auth/uploads/${user.profile_picture}`
: null
);
} else {
setUsername("");
setAccountName("");
setRole("SOC_ANALYST");
setPassword("");
setPreviewPic(null);
}
}, [isOpen, user]);
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();
const isEdit = !!user;
setError("");
setSuccess("");
setIsSubmitting(true);
try {
const fd = new FormData();
if (username.trim()) fd.append("username", username.trim());
if (password) fd.append("password", password);
fd.append("account_name", accountName.trim());
if (picFile) fd.append("profile_picture", picFile);
if (isEdit) {
fd.append("user_id", String(user.id));
await updateAdminAgentUser(fd);
setSuccess(password ? "Password successfully reset & Account updated!" : "Account successfully updated!");
} else {
fd.append("role", role);
await createAdminExternalUser(fd);
setSuccess("External account successfully created!");
}
setTimeout(() => {
onSuccess();
onClose();
}, 1500);
} catch (err: any) {
setError(err.message);
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async () => {
if (!user) return;
if (!confirm(`Delete account "${user.username}"? This action cannot be undone.`)) return;
setIsDeleting(true);
try {
await deleteAdminAgentUser(user.id);
setSuccess("Account successfully deleted!");
setTimeout(() => {
onSuccess();
onClose();
}, 1000);
} catch (err: any) {
setError(err.message);
} finally {
setIsDeleting(false);
}
};
const {
adminRole,
username,
setUsername,
password,
setPassword,
accountName,
setAccountName,
role,
setRole,
companyName,
setCompanyName,
selectedAgents,
previewPic,
isSubmitting,
isDeleting,
success,
error,
availableAgents,
agentNamesMap,
handlePicChange,
handleAgentCheck,
handleSubmit,
handleDelete,
} = useExternalAccountForm({
isOpen,
onClose,
onSuccess,
user,
targetCompany,
modalContext,
});
const isEdit = !!user;
@@ -128,7 +68,6 @@ export default function ExternalAccountModal({
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -138,164 +77,190 @@ export default function ExternalAccountModal({
onClick={onClose}
/>
{/* Modal */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.5, ease: "easeInOut" }}
className="relative z-10 w-full max-w-md bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden"
className="relative z-10 w-full max-w-md bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden max-h-[90vh] flex flex-col"
>
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-slate-700 bg-gradient-to-r from-amber-900/40 to-slate-900">
<div>
<h3 className="text-lg font-bold text-white">
{isEdit ? "Manage External Account" : "Create External Account"}
</h3>
{isEdit && <p className="text-xs text-slate-400 mt-0.5 font-mono">Role: <span className="font-semibold text-amber-400">{user.role.replace('_', ' ')}</span></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">
{isEdit && (
<div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg flex gap-3 text-xs text-amber-200/80 mb-2">
<AlertCircle className="w-4 h-4 shrink-0 text-amber-400" />
<p><strong>Password Recovery:</strong> For security reasons, original passwords cannot be viewed. To recover an account, enter a new password below to reset it.</p>
</div>
)}
{/* 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-amber-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">Profile Picture</p>
<p className="text-xs text-slate-500">Click to upload image</p>
</div>
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handlePicChange} />
</div>
{/* Account Name */}
<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" />
Account Name
</label>
<input
type="text"
value={accountName}
onChange={e => setAccountName(e.target.value)}
placeholder="Full Name"
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-amber-500 transition-colors"
/>
</div>
{/* Role (Only for Create mode) */}
{!isEdit && (
<div>
<label className="block text-xs font-medium text-slate-400 mb-1.5">
<Shield className="w-3.5 h-3.5 inline mr-1" />
Account Role
</label>
<select
value={role}
onChange={e => setRole(e.target.value)}
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:border-amber-500 transition-colors"
>
<option value="SOC_ANALYST">SOC Analyst</option>
<option value="ENGINEER">Engineer</option>
<option value="TENANT_ADMIN">Tenant Admin</option>
</select>
</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" />
Login Username
</label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="Username for login"
required
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-amber-500 transition-colors"
/>
</div>
{/* Password Reset / Create */}
<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" />
{isEdit ? "Reset Password" : "Password"} {isEdit && <span className="text-slate-500">(leave blank to keep unchanged)</span>}
</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder={isEdit ? "Type new password to override" : "Create password"}
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-amber-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 && (
<div className="flex items-center justify-between p-5 border-b border-slate-700 bg-gradient-to-r from-amber-900/40 to-slate-900 shrink-0">
<div>
<h3 className="text-lg font-bold text-white">
{isEdit
? "Manage Account"
: modalContext === 'agents'
? "Create Technical Account"
: "Create Company Account"}
</h3>
{isEdit && (
<p className="text-xs text-slate-400 mt-0.5 font-mono">
Role:{" "}
<span className="font-semibold text-amber-400">
{user.role.replace("_", " ")}
</span>
</p>
)}
</div>
<button
type="button"
onClick={handleDelete}
disabled={isDeleting || isSubmitting}
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"
onClick={onClose}
className="p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition-colors"
>
{isDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Delete
<X className="w-5 h-5" />
</button>
)}
<button
type="button"
onClick={onClose}
disabled={isDeleting || isSubmitting}
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 disabled:opacity-50"
</div>
<form
onSubmit={handleSubmit}
className="p-5 space-y-4 overflow-y-auto flex-1 custom-scrollbar"
>
Cancel</button>
<button
type="submit"
disabled={isSubmitting || isDeleting}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium transition-colors disabled:opacity-50"
>
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
{isEdit ? "Update & Reset" : "Create Account"}
</button>
</div>
</form>
{isEdit && (
<div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg flex gap-3 text-xs text-amber-200/80 mb-2">
<AlertCircle className="w-4 h-4 shrink-0 text-amber-400" />
<p>
<strong>Password Recovery:</strong> For security reasons, original passwords cannot
be viewed. To recover an account, enter a new password below to reset it.
</p>
</div>
)}
<ProfilePictureInput previewPic={previewPic} onChange={handlePicChange} />
<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" />
Account Name
</label>
<input
type="text"
value={accountName}
onChange={(e) => setAccountName(e.target.value)}
placeholder="Full Name"
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-amber-500 transition-colors"
/>
</div>
{!isEdit && (
<div>
<label className="block text-xs font-medium text-slate-400 mb-1.5">
<Shield className="w-3.5 h-3.5 inline mr-1" />
Account Role
</label>
<RoleSelect adminRole={adminRole} value={role} onChange={setRole} modalContext={modalContext} />
</div>
)}
{/* Company Name: hanya untuk User Accounts context */}
{modalContext === 'user-accounts' && adminRole === "SUPER_ADMIN" && (role === "COMPANY_ADMIN" || isEdit) && (
<div>
<label className="block text-xs font-medium text-slate-400 mb-1.5">
🏢 Company Name
</label>
<input
type="text"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
placeholder="Nama Perusahaan (e.g. IFG)"
required
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-amber-500 transition-colors"
/>
</div>
)}
{/* Agent Checklist: hanya untuk User Accounts context (COMPANY_*) */}
{modalContext === 'user-accounts' && (
role === "COMPANY_ADMIN" ||
role === "COMPANY_OPERATOR" ||
role === "COMPANY_VIEWER"
) && (
<AgentChecklist
availableAgents={availableAgents}
selectedAgents={selectedAgents}
onAgentCheck={handleAgentCheck}
agentNamesMap={agentNamesMap}
/>
)}
<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" />
Login Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username for login"
required
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-amber-500 transition-colors"
/>
</div>
<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" />
{isEdit ? "Reset Password" : "Password"}{" "}
{isEdit && (
<span className="text-slate-500">(leave blank to keep unchanged)</span>
)}
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={isEdit ? "Type new password to override" : "Create password"}
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-amber-500 transition-colors"
/>
</div>
{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>
)}
<div className="flex gap-2 pt-2 shrink-0">
{isEdit && (
<button
type="button"
onClick={handleDelete}
disabled={isDeleting || isSubmitting}
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" />
)}
Delete
</button>
)}
<button
type="button"
onClick={onClose}
disabled={isDeleting || isSubmitting}
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 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || isDeleting}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium transition-colors disabled:opacity-50"
>
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
{isEdit ? "Update & Reset" : "Create Account"}
</button>
</div>
</form>
</motion.div>
</div>
)}
@@ -0,0 +1,49 @@
"use client";
import { useRef } from "react";
import { Upload } from "lucide-react";
interface ProfilePictureInputProps {
previewPic: string | null;
onChange: (file: File) => void;
}
export default function ProfilePictureInput({
previewPic,
onChange,
}: ProfilePictureInputProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
onChange(file);
}
};
return (
<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-amber-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">Profile Picture</p>
<p className="text-xs text-slate-500">Click to upload image</p>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { ChevronDown, Shield } from "lucide-react";
interface RoleOption {
value: string;
label: string;
}
interface RoleSelectProps {
adminRole: string;
value: string;
onChange: (val: string) => void;
modalContext?: 'agents' | 'user-accounts';
}
// Role untuk konteks Agents (teknikal/operasional)
const AGENTS_ROLES: RoleOption[] = [
{ value: "EXECUTIVE", label: "Executive" },
{ value: "TENANT_ADMIN", label: "Tenant Admin" },
{ value: "SOC_ANALYST", label: "SOC Analyst" },
{ value: "ENGINEER", label: "Engineer" },
];
// Role untuk konteks User Accounts (customer/client)
const USER_ACCOUNTS_ROLES_SUPER: RoleOption[] = [
{ value: "COMPANY_ADMIN", label: "Company Admin" },
{ value: "COMPANY_OPERATOR", label: "Company Operator" },
{ value: "COMPANY_VIEWER", label: "Company Viewer" },
];
const USER_ACCOUNTS_ROLES_COMPANY: RoleOption[] = [
{ value: "COMPANY_OPERATOR", label: "Company Operator" },
{ value: "COMPANY_VIEWER", label: "Company Viewer" },
];
export default function RoleSelect({ adminRole, value, onChange, modalContext = 'user-accounts' }: RoleSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Pilih role options berdasarkan konteks modal
const options: RoleOption[] = (() => {
if (modalContext === 'agents') {
// Agents page: selalu tampilkan role teknikal saja
return AGENTS_ROLES;
}
// User Accounts page: tampilkan COMPANY_* sesuai admin role
if (adminRole === 'SUPER_ADMIN' || adminRole === 'EXECUTIVE') return USER_ACCOUNTS_ROLES_SUPER;
return USER_ACCOUNTS_ROLES_COMPANY;
})();
const currentOption = options.find((opt) => opt.value === value) || options[0];
// Set default role if current value is not in the options
useEffect(() => {
if (options.length > 0 && !options.some((opt) => opt.value === value)) {
onChange(options[0].value);
}
}, [adminRole, value, onChange, options]);
useEffect(() => {
const handleOutsideClick = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleOutsideClick);
return () => document.removeEventListener("mousedown", handleOutsideClick);
}, []);
return (
<div className="relative w-full" ref={containerRef}>
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className="flex items-center justify-between w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:border-amber-500 hover:border-slate-500 transition-colors text-left"
>
<span className="truncate">{currentOption?.label || value}</span>
<ChevronDown
className={`w-4 h-4 text-slate-400 transition-transform duration-300 ${
isOpen ? "rotate-180 text-amber-500" : ""
}`}
/>
</button>
{isOpen && (
<div className="absolute top-full left-0 right-0 mt-1 max-h-60 overflow-y-auto bg-slate-900 border border-slate-700 rounded-lg shadow-xl z-50 py-1 custom-scrollbar">
{options.map((opt) => {
const isSelected = opt.value === value;
return (
<button
key={opt.value}
type="button"
onClick={() => {
onChange(opt.value);
setIsOpen(false);
}}
className={`w-full px-3 py-2 text-sm text-left transition-colors truncate flex items-center justify-between ${
isSelected
? "bg-amber-600/20 text-amber-400 font-medium"
: "text-slate-300 hover:bg-slate-800 hover:text-white"
}`}
>
<span>{opt.label}</span>
{isSelected && <Shield className="w-3.5 h-3.5 text-amber-400" />}
</button>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,224 @@
import { useState, useEffect } from "react";
import type { ManagedUser } from "@/lib/admin-api";
import { updateAdminAgentUser, deleteAdminAgentUser, createAdminExternalUser, getViewAsHeaders } from "@/lib/admin-api";
interface UseExternalAccountFormProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
user?: ManagedUser | null;
targetCompany?: string | null;
modalContext?: 'agents' | 'user-accounts';
}
export function useExternalAccountForm({
isOpen,
onClose,
onSuccess,
user,
targetCompany,
modalContext = 'user-accounts',
}: UseExternalAccountFormProps) {
const [adminRole, setAdminRole] = useState<string>("");
const [adminCompany, setAdminCompany] = useState<string>("");
const [adminAgentUuids, setAdminAgentUuids] = useState<string[]>([]);
const [globalAgents, setGlobalAgents] = useState<string[]>([]);
const [agentNamesMap, setAgentNamesMap] = useState<Record<string, string>>({});
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [accountName, setAccountName] = useState("");
const [role, setRole] = useState("COMPANY_ADMIN");
const [companyName, setCompanyName] = useState("");
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
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("");
// Get current admin user context & global agent registry
useEffect(() => {
if (!isOpen) return;
fetch("/api/auth/me", { headers: getViewAsHeaders() })
.then(res => res.json())
.then(data => {
if (data.user) {
setAdminRole(data.user.role || "");
setAdminCompany(data.user.company_name || "");
setAdminAgentUuids(data.user.agent_uuids || []);
// Default role berdasarkan konteks modal
if (modalContext === 'agents') {
// Agents: default ke TENANT_ADMIN (role teknikal)
setRole("TENANT_ADMIN");
} else if (data.user.role === "COMPANY_ADMIN") {
setRole("COMPANY_OPERATOR");
} else {
setRole("COMPANY_ADMIN");
}
}
})
.catch(console.error);
fetch("/api/dashboard/agents", { headers: getViewAsHeaders() })
.then(res => res.json())
.then(json => {
if (json.ok && json.agents) {
setGlobalAgents(json.agents);
}
})
.catch(console.error);
fetch("/api/auth/admin/users", { credentials: "include", headers: getViewAsHeaders() })
.then(res => {
if (!res.ok) return null;
return res.json();
})
.then(data => {
if (data && data.ok && Array.isArray(data.data)) {
const map: Record<string, string> = {};
data.data.forEach((u: any) => {
if (u.role === "AGENT_VIEWER" && u.agent_uuid) {
map[u.agent_uuid] = u.account_name || u.agent_uuid;
}
});
setAgentNamesMap(map);
}
})
.catch(console.error);
}, [isOpen]);
// Load existing user data when in edit mode
useEffect(() => {
if (!isOpen) return;
setError("");
setSuccess("");
setPicFile(null);
if (user) {
setUsername(user.username);
setAccountName(user.account_name || "");
setRole(user.role);
setCompanyName(user.company_name || "");
setSelectedAgents(user.agent_uuids || []);
setPassword("");
setPreviewPic(
user.profile_picture
? `/api/auth/uploads/${user.profile_picture}`
: null
);
} else {
setUsername("");
setAccountName("");
setCompanyName(targetCompany || "");
setSelectedAgents([]);
setPassword("");
setPreviewPic(null);
}
}, [isOpen, user, targetCompany]);
const handlePicChange = (file: File) => {
setPicFile(file);
setPreviewPic(URL.createObjectURL(file));
};
const handleAgentCheck = (agentUuid: string, checked: boolean) => {
if (checked) {
setSelectedAgents(prev => [...prev, agentUuid]);
} else {
setSelectedAgents(prev => prev.filter(uuid => uuid !== agentUuid));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const isEdit = !!user;
setError("");
setSuccess("");
setIsSubmitting(true);
try {
const fd = new FormData();
if (username.trim()) fd.append("username", username.trim());
if (password) fd.append("password", password);
fd.append("account_name", accountName.trim());
if (picFile) fd.append("profile_picture", picFile);
const finalCompany = adminRole === "COMPANY_ADMIN" ? adminCompany : companyName;
if (finalCompany) fd.append("company_name", finalCompany);
fd.append("agent_uuids", JSON.stringify(selectedAgents));
if (isEdit) {
fd.append("user_id", String(user.id));
await updateAdminAgentUser(fd);
setSuccess(password ? "Password successfully reset & Account updated!" : "Account successfully updated!");
} else {
fd.append("role", role);
await createAdminExternalUser(fd);
setSuccess("External account successfully created!");
}
setTimeout(() => {
onSuccess();
onClose();
}, 1500);
} catch (err: any) {
setError(err.message);
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async () => {
if (!user) return;
if (!confirm(`Delete account "${user.username}"? This action cannot be undone.`)) return;
setIsDeleting(true);
try {
await deleteAdminAgentUser(user.id);
setSuccess("Account successfully deleted!");
setTimeout(() => {
onSuccess();
onClose();
}, 1000);
} catch (err: any) {
setError(err.message);
} finally {
setIsDeleting(false);
}
};
const availableAgents = adminRole === "COMPANY_ADMIN" ? adminAgentUuids : globalAgents;
return {
adminRole,
adminCompany,
username,
setUsername,
password,
setPassword,
accountName,
setAccountName,
role,
setRole,
companyName,
setCompanyName,
selectedAgents,
previewPic,
setPreviewPic,
isSubmitting,
isDeleting,
success,
error,
setError,
availableAgents,
agentNamesMap,
handlePicChange,
handleAgentCheck,
handleSubmit,
handleDelete,
};
}
@@ -150,9 +150,6 @@ export function ContextualHelpModal({ pageId, isOpen, onClose }: ContextualHelpM
<div>
<div className="flex items-center gap-2">
<h3 className="text-base font-bold text-white">Guide: {guide.title}</h3>
<code className="text-[10px] px-1.5 py-0.5 rounded font-mono bg-white/5 text-slate-400 border border-white/5">
{guide.url}
</code>
</div>
<p className="text-xs text-slate-400 mt-0.5">Metric columns and data layout guide</p>
</div>
+132 -6
View File
@@ -7,11 +7,87 @@ import { AlertTriangle, LogOut, Clock, ShieldAlert, Loader2 } from "lucide-react
import { Modal } from "@/components/ui/Modal";
import { HelpCenterFAB } from "@/components/ui/HelpCenterFAB";
import { useInactivityTimeout } from "@/hooks/useInactivityTimeout";
import { usePathname } from "next/navigation";
import { getSiteBranding } from "@/lib/branding";
export function DashboardLayout({ children }: { children: ReactNode }) {
const [viewAs, setViewAs] = useState<{ active: boolean; agent_label?: string; agent_uuid?: string }>({ active: false });
const [user, setUser] = useState<any>(null);
const pathname = usePathname();
useEffect(() => {
const branding = getSiteBranding();
const getPageTitlePrefix = (path: string): string => {
switch (path) {
case "/":
return "Overview Dashboard";
case "/agents":
return "Agents";
case "/apps":
return "Apps";
case "/devices":
return "Devices";
case "/dns":
return "DNS";
case "/dpi-analytics":
return "DPI MetaData";
case "/events":
return "Recent Events";
case "/flows":
return "Flows";
case "/geography":
return "Geo Traffic";
case "/intelligence":
return "Threat Intelligence";
case "/lookup":
return "App Lookup";
case "/network-infrastructure":
return "Network Topology";
case "/network-intelligence":
return "Traffic Categories";
case "/threats":
return "Detected Threats";
case "/help":
return "User Guide";
default:
return "";
}
};
const updateTitleAndFavicon = () => {
if (typeof document !== 'undefined') {
const prefix = getPageTitlePrefix(pathname);
if (prefix) {
document.title = `${prefix} | ${branding.name} - Deep Package Inspection`;
} else {
document.title = `${branding.name} - Deep Package Inspection`;
}
// Update favicon robustly
const logoUrl = branding.logo;
const links = document.querySelectorAll("link[rel*='icon']");
if (links.length > 0) {
links.forEach(l => (l as HTMLLinkElement).href = logoUrl);
} else {
const l = document.createElement('link');
l.rel = 'icon';
l.href = logoUrl;
document.head.appendChild(l);
}
}
};
// Run immediately
updateTitleAndFavicon();
// Run in microtask to ensure we execute after children's mounting effects
Promise.resolve().then(updateTitleAndFavicon);
// Run in a short timeout as a fallback for any late asynchronous updates
const timer = setTimeout(updateTitleAndFavicon, 50);
return () => clearTimeout(timer);
}, [pathname]);
const {
showWarning: showTimeoutWarning,
timeLeft,
@@ -45,11 +121,59 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
<div className="flex flex-1 flex-col overflow-hidden w-full min-w-0 relative transition-all duration-500 ease-in-out">
{viewAs.active && (
<div className="bg-gradient-to-r from-amber-600/90 via-amber-700/90 to-amber-900/90 border-b border-amber-500/30 px-6 py-2.5 flex items-center justify-between text-white shadow-lg backdrop-blur-sm z-30 animate-pulse-subtle">
<div className="bg-gradient-to-r from-amber-600/90 via-amber-700/90 to-amber-900/90 border-b border-amber-500/30 px-6 py-2.5 flex items-center justify-between text-white shadow-lg backdrop-blur-sm z-30">
<div className="flex items-center gap-2 text-sm font-medium">
<AlertTriangle className="w-4 h-4 text-amber-300 animate-bounce" />
<span>
You are currently viewing data as Network Agent: <strong className="text-amber-200">{viewAs.agent_label || "Unknown Agent"}</strong> <span className="text-xs text-white/70">({viewAs.agent_uuid})</span>
<span className="leading-relaxed">
{(() => {
const viewAsType = (viewAs as any).view_as_type;
const agentLabel = viewAs.agent_label || viewAs.agent_uuid || 'Unknown';
const agentUuid = viewAs.agent_uuid || '';
if (viewAsType === 'user') {
// View-As USER: tampilkan nama user + role + label agent
const userName = (viewAs as any).user_name || 'Unknown';
const userRole = (viewAs as any).user_role || '';
const roleLabel = userRole.replace(/_/g, ' ');
return (
<>
<span className="text-amber-100/80">View-As Mode</span>
{' — '}
Viewing as{' '}
<strong className="text-amber-200">{userName}</strong>
{' '}
<span className="text-xs font-normal text-white/70 bg-amber-950/40 px-1.5 py-0.5 rounded">
{roleLabel}
</span>
{agentLabel && agentLabel !== agentUuid && (
<>
{' '}via Agent{' '}
<strong className="text-amber-200">{agentLabel}</strong>
</>
)}
{(!agentLabel || agentLabel === agentUuid) && (
<>
{' '}
<span className="text-xs text-white/50">({agentUuid})</span>
</>
)}
</>
);
}
// View-As AGENT (default / direct): tampilkan label agent
return (
<>
<span className="text-amber-100/80">View-As Mode</span>
{' — '}
Viewing as Agent{' '}
<strong className="text-amber-200">{agentLabel}</strong>
{agentLabel !== agentUuid && (
<span className="text-xs text-white/50 ml-1">({agentUuid})</span>
)}
</>
);
})()}
</span>
</div>
<button
@@ -61,8 +185,10 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
</button>
</div>
)}
<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">
<main suppressHydrationWarning 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 min-w-0">
<div suppressHydrationWarning className="w-full min-w-0 space-y-6">
{children}
</div>
</main>
+47 -16
View File
@@ -12,19 +12,46 @@ import SidebarProfile from "./SidebarProfile";
import SidebarNotifications from "./SidebarNotifications";
import SidebarSiteSelector from "./SidebarSiteSelector";
import SidebarTimeSelector from "./SidebarTimeSelector";
import { getSiteBranding } from "@/lib/branding";
export function Sidebar() {
const pathname = usePathname();
const [user, setUser] = useState<{ username?: string; account_name?: string; profile_picture?: string; role?: string; agent_uuid?: string } | null>(null);
const [user, setUser] = useState<{ username?: string; account_name?: string; profile_picture?: string; role?: string; agent_uuid?: string; agent_uuids?: string[]; company_name?: string; _isViewAsMode?: boolean } | null>(null);
const [isAccountOpen, setIsAccountOpen] = useState(false);
const [intervalText, setIntervalText] = useState<string>("");
const [selectedSite, setSelectedSite] = useState<string>("6681452d_9cae_4ff4_8ae8_0d504774265e");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
fetch('/api/auth/me', { headers: getViewAsHeaders() })
.then(res => res.json())
.then(data => {
if (data.user) setUser(data.user);
if (data.user) {
setUser(data.user);
// Role-aware site UUID logic:
// - Global roles (SUPER_ADMIN, EXECUTIVE, SOC_ANALYST, ENGINEER):
// Prioritaskan pilihan yang TERSIMPAN di localStorage.
// Hanya set dari DB jika belum ada pilihan tersimpan.
// Ini agar site switcher tidak reset ke SIAB setiap page reload.
// - Restricted roles (TENANT_ADMIN, AGENT_VIEWER, COMPANY_*):
// Selalu pakai site_uuid dari DB (mereka tidak bisa ganti site).
const globalRoles = ['SUPER_ADMIN', 'EXECUTIVE', 'SOC_ANALYST', 'ENGINEER'];
const isGlobalRole = globalRoles.includes(data.user.role || '');
const storedSiteUuid = localStorage.getItem('backone_site_uuid');
if (isGlobalRole) {
// Pakai localStorage jika sudah ada, fallback ke DB, fallback ke SIAB default
const siteToUse = storedSiteUuid || data.user.site_uuid || '6681452d_9cae_4ff4_8ae8_0d504774265e';
localStorage.setItem('backone_site_uuid', siteToUse);
setSelectedSite(siteToUse);
} else if (data.user.site_uuid) {
// Restricted roles: selalu pakai site_uuid dari DB (tidak bisa switch)
localStorage.setItem('backone_site_uuid', data.user.site_uuid);
setSelectedSite(data.user.site_uuid);
}
}
})
.catch(console.error);
@@ -50,27 +77,21 @@ export function Sidebar() {
const stored = localStorage.getItem('backone_site_uuid');
if (stored) {
setSelectedSite(stored);
const isNx = stored === 'd7902405_0dc2_458b_8584_ed4d24b64f24';
const logoUrl = isNx ? '/nexus-logo.png' : '/backone-logo.png';
const links = document.querySelectorAll("link[rel*='icon']");
links.forEach(l => (l as HTMLLinkElement).href = logoUrl);
if (links.length === 0) {
const l = document.createElement('link');
l.type = 'image/x-icon'; l.rel = 'shortcut icon'; l.href = logoUrl;
document.getElementsByTagName('head')[0].appendChild(l);
}
}
}
}, []);
const branding = getSiteBranding(selectedSite);
const isNexus = branding.name === "Nexus";
const handleSiteSelect = (val: string) => {
setSelectedSite(val);
localStorage.setItem('backone_site_uuid', val);
window.location.reload();
};
const isNexus = selectedSite === 'd7902405_0dc2_458b_8584_ed4d24b64f24';
return (
<div
className="flex h-full max-h-screen flex-col border-r border-white/5 bg-black/20 backdrop-blur-3xl transition-all w-64 shrink-0 relative z-40 shadow-[4px_0_24px_-12px_rgba(0,0,0,0.5)]"
@@ -79,11 +100,11 @@ export function Sidebar() {
<div className="flex flex-col p-2 shrink-0 gap-4 border-b border-white/5">
<div className="flex flex-col items-center justify-center w-full pt-6 pb-3 px-3 text-center mx-auto">
<img
src={isNexus ? "/nexus-logo.png" : "/backone-logo.png"}
src={mounted ? branding.logo : "/backone-logo.png"}
alt="Logo"
className={cn(
"w-12 h-12 object-contain shrink-0 mb-3",
isNexus
(mounted && isNexus)
? "drop-shadow-[0_0_12px_rgba(6,182,212,0.45)]"
: "drop-shadow-[0_0_12px_rgba(168,85,247,0.45)]"
)}
@@ -119,7 +140,17 @@ export function Sidebar() {
{NAVIGATION_GROUPS.map((group) => {
const allowedItems = group.items.filter((item) => {
if (item.href === "/agents") {
return user?.role === "SUPER_ADMIN";
// Agents visible untuk: admin roles + COMPANY_ADMIN + COMPANY_OPERATOR
// COMPANY_VIEWER TIDAK mendapat tab Agents — mereka hanya butuh dashboard data
return user?.role === "SUPER_ADMIN" || user?.role === "EXECUTIVE" || user?.role === "TENANT_ADMIN" || user?.role === "COMPANY_ADMIN" || user?.role === "COMPANY_OPERATOR";
}
if (item.href === "/device-labeling") {
return user?.role === "SUPER_ADMIN" || user?.role === "EXECUTIVE" || user?.role === "TENANT_ADMIN" || user?.role === "COMPANY_ADMIN" || user?.role === "COMPANY_OPERATOR";
}
if (item.href === "/user-accounts") {
// User Accounts hanya untuk: admin + COMPANY_ADMIN (yang mengelola sub-user)
// COMPANY_OPERATOR dan COMPANY_VIEWER TIDAK bisa akses halaman ini
return user?.role === "SUPER_ADMIN" || user?.role === "EXECUTIVE" || user?.role === "COMPANY_ADMIN";
}
return true;
});
+3 -1
View File
@@ -1,7 +1,7 @@
import {
LayoutDashboard, Server, Activity, Globe, Radio,
ShieldAlert, Shield, AppWindow, Search, Router,
PieChart, Database, Network, Zap
PieChart, Database, Network, Zap, Tag, Users
} from "lucide-react";
export const NAVIGATION_GROUPS = [
@@ -17,6 +17,8 @@ export const NAVIGATION_GROUPS = [
{ name: "Network Topology", href: "/network-infrastructure", icon: Network },
{ name: "Agents", href: "/agents", icon: Server },
{ name: "Devices", href: "/devices", icon: Router },
{ name: "Device Labeling", href: "/device-labeling", icon: Tag },
{ name: "User Account", href: "/user-accounts", icon: Users },
]
},
{
+78 -19
View File
@@ -2,17 +2,23 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { User as UserIcon, Sun, Moon, LogOut } from "lucide-react";
import { User as UserIcon, Sun, Moon, LogOut, Eye } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import { SafeImage } from "../ui/SafeImage";
import { getViewAsStatusSync } from "@/lib/admin-api";
const ROLE_DISPLAY_NAMES: Record<string, string> = {
SUPER_ADMIN: 'Super Admin',
EXECUTIVE: 'Executive',
TENANT_ADMIN: 'Tenant Admin',
SOC_ANALYST: 'SOC Analyst',
ENGINEER: 'Engineer',
AGENT_VIEWER: 'Agent Viewer',
// Company-facing roles — client/customer accounts
COMPANY_ADMIN: 'Company Admin',
COMPANY_OPERATOR: 'Company Operator',
COMPANY_VIEWER: 'Company Viewer',
};
const getRoleDisplayName = (role?: string) => {
@@ -27,7 +33,9 @@ interface ProfileProps {
export default function SidebarProfile({ user, onOpenSettings }: ProfileProps) {
const [isAccountDropdownOpen, setIsAccountDropdownOpen] = useState(false);
const [viewAsStatus, setViewAsStatus] = useState<any | null>(null);
const accountDropdownRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const { theme, setTheme } = useTheme();
@@ -41,8 +49,34 @@ export default function SidebarProfile({ user, onOpenSettings }: ProfileProps) {
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const roleLabel = getRoleDisplayName(user?.role);
const initial = user?.account_name ? user.account_name.charAt(0) : (user?.username ? user.username.charAt(0) : (user?.role === 'SUPER_ADMIN' ? 'A' : 'V'));
useEffect(() => {
// Cek apakah sedang dalam mode View-As untuk menampilkan label yang benar
const status = getViewAsStatusSync();
setViewAsStatus(status);
}, []);
// Jika dalam View-As mode, gunakan label dari view-as (bukan role dari server yang jadi AGENT_VIEWER)
const isViewAsMode = viewAsStatus?.active;
// Bangun label yang ditampilkan di sidebar bawah profil
const roleLabel = (() => {
if (!isViewAsMode) return getRoleDisplayName(user?.role);
const viewAsType = viewAsStatus?.view_as_type;
if (viewAsType === 'user') {
const userName = viewAsStatus?.user_name || 'Unknown';
const userRole = viewAsStatus?.user_role || '';
const roleShort = ROLE_DISPLAY_NAMES[userRole] || userRole.replace(/_/g, ' ');
return `Viewing as: ${userName} (${roleShort})`;
}
// Direct agent view-as: tampilkan label agent
const agentLabel = viewAsStatus?.agent_label || viewAsStatus?.agent_uuid || 'Unknown';
return `Viewing Agent: ${agentLabel}`;
})();
const initial = user?.account_name
? user.account_name.charAt(0)
: (user?.username ? user.username.charAt(0) : 'U');
return (
<div className="relative flex-1 min-w-0" ref={accountDropdownRef}>
@@ -60,13 +94,18 @@ export default function SidebarProfile({ user, onOpenSettings }: ProfileProps) {
)}
</div>
<div className="flex flex-col items-start overflow-hidden text-left min-w-0 flex-1">
<span className="text-sm font-semibold text-foreground truncate w-full">{user?.account_name || user?.username || 'Loading...'}</span>
<span className="text-[10px] text-primary truncate w-full">{roleLabel}</span>
<span className="text-sm font-semibold text-foreground truncate w-full">
{user?.account_name || user?.username || 'Loading...'}
</span>
<span className={`text-[10px] truncate w-full ${isViewAsMode ? 'text-amber-400' : 'text-primary'}`}>
{isViewAsMode && <Eye className="inline w-2.5 h-2.5 mr-0.5" />}
{roleLabel}
</span>
</div>
</button>
{isAccountDropdownOpen && (
<div className="absolute bottom-0 left-full ml-4 w-56 rounded-xl border border-border bg-card/95 backdrop-blur-xl p-2 shadow-2xl z-50 animate-fade-in">
<div className="absolute bottom-0 left-full ml-4 w-60 rounded-xl border border-border bg-card/95 backdrop-blur-xl p-2 shadow-2xl z-50 animate-fade-in">
<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 ? (
@@ -77,19 +116,40 @@ export default function SidebarProfile({ user, onOpenSettings }: ProfileProps) {
</span>
)}
</div>
<span className="text-sm font-bold text-foreground text-center break-words w-full leading-tight">{user?.account_name || user?.username || 'Loading...'}</span>
<span className="text-xs text-primary mt-1 text-center font-medium">{roleLabel}</span>
<span className="text-sm font-bold text-foreground text-center break-words w-full leading-tight">
{user?.account_name || user?.username || 'Loading...'}
</span>
{isViewAsMode ? (
<span className="text-xs text-amber-400 mt-1 text-center font-medium flex items-center gap-1">
<Eye className="w-3 h-3" />
{(() => {
const vt = viewAsStatus?.view_as_type;
if (vt === 'user') {
const un = viewAsStatus?.user_name || 'Unknown';
const ur = ROLE_DISPLAY_NAMES[viewAsStatus?.user_role] || viewAsStatus?.user_role?.replace(/_/g, ' ') || '';
return `${un} (${ur})`;
}
return viewAsStatus?.agent_label || viewAsStatus?.agent_uuid || 'Unknown';
})()}
</span>
) : (
<span className="text-xs text-primary mt-1 text-center font-medium">
{getRoleDisplayName(user?.role)}
</span>
)}
</div>
<button
onClick={() => {
setIsAccountDropdownOpen(false);
onOpenSettings();
}}
className="w-full flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
>
<UserIcon className="h-4 w-4" /> Account Settings
</button>
{!isViewAsMode && (
<button
onClick={() => {
setIsAccountDropdownOpen(false);
onOpenSettings();
}}
className="w-full flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
>
<UserIcon className="h-4 w-4" /> Account Settings
</button>
)}
<button
onClick={() => {
setTheme(theme === 'dark' ? 'light' : 'dark');
@@ -104,8 +164,7 @@ export default function SidebarProfile({ user, onOpenSettings }: ProfileProps) {
<button
onClick={async () => {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/login');
router.refresh();
window.location.href = "/login";
}}
className="w-full flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-red-500 hover:bg-red-500/10 transition-colors"
>
@@ -28,7 +28,7 @@ export default function SidebarSiteSelector({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
if (!user || !['SUPER_ADMIN', 'SOC_ANALYST', 'ENGINEER'].includes(user.role || '')) {
if (!user || !['SUPER_ADMIN', 'EXECUTIVE', 'SOC_ANALYST', 'ENGINEER'].includes(user.role || '')) {
return null;
}
@@ -24,6 +24,7 @@ export function SidebarSiteSwitcher({
getSiteLabel,
}: SidebarSiteSwitcherProps) {
const isGlobalUser = user?.role === 'SUPER_ADMIN' ||
user?.role === 'EXECUTIVE' ||
((!user?.site_uuid || user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(user?.role || ''));
if (!user || !isGlobalUser) {
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { useEffect } from "react";
/**
* ViewportScaler – Forces the dashboard to appear identical on any screen size.
*
* Strategy (cross-browser, zero feedback-loop):
* - Reference design width: 1536px.
* - Source of truth: `window.outerWidth` — the physical pixel width of the
* OS browser window INCLUDING chrome/scrollbars. This value is NEVER
* modified by CSS transforms or `document.documentElement.style.width`,
* so reading it inside a resize handler cannot trigger another resize event.
* - We set `document.documentElement.style.width = "1536px"` so the layout
* always renders as if the screen is 1536px wide.
* - Then `transform: scale(outerWidth / 1536)` + `transform-origin: top left`
* shrinks the full layout to exactly fit the browser window.
* - `minHeight` is compensated for the scale shrink to avoid bottom whitespace.
* - On screens >= 1536px, all overrides are removed (native behaviour).
*/
export function ViewportScaler() {
useEffect(() => {
const REFERENCE_WIDTH = 1536;
let lastWidth = -1;
let rafId = 0;
const apply = () => {
// outerWidth = physical width of the browser window chrome.
// It is NOT affected by CSS zoom / transform so there is no loop.
const browserWidth = window.outerWidth;
// Debounce: skip if nothing changed
if (browserWidth === lastWidth) return;
lastWidth = browserWidth;
const el = document.documentElement;
if (browserWidth > 0 && browserWidth < REFERENCE_WIDTH) {
const scale = browserWidth / REFERENCE_WIDTH;
el.style.width = `${REFERENCE_WIDTH}px`;
el.style.transform = `scale(${scale})`;
el.style.transformOrigin = "top left";
el.style.overflowX = "hidden";
// Prevent blank space at bottom due to scale shrink
el.style.minHeight = `${Math.ceil(window.innerHeight / scale)}px`;
} else {
// Full-size screen or server-side: remove all overrides
el.style.width = "";
el.style.transform = "";
el.style.transformOrigin = "";
el.style.overflowX = "";
el.style.minHeight = "";
}
};
const onResize = () => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(apply);
};
// Initial apply
apply();
window.addEventListener("resize", onResize, { passive: true });
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener("resize", onResize);
const el = document.documentElement;
el.style.width = "";
el.style.transform = "";
el.style.transformOrigin = "";
el.style.overflowX = "";
el.style.minHeight = "";
};
}, []);
return null;
}
+1 -1
View File
@@ -142,7 +142,7 @@ export default function ThreatsContent() {
<p className="text-muted-foreground mt-1">Monitor and respond to network security threats.</p>
</div>
<div className="flex items-center gap-3">
<HelpTrigger pageId="intelligence" />
<HelpTrigger pageId="threats" />
<div className="flex items-center gap-2 text-sm font-medium text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-4 py-2 rounded-lg shadow-[0_0_15px_rgba(16,185,129,0.1)]">
<ShieldCheck className="h-4 w-4" />
<span>Active Monitoring</span>
+13 -13
View File
@@ -22,13 +22,13 @@ export function getThreatColumns(
return [
{
header: "Timestamp",
className: "w-[155px] min-w-[155px] max-w-[155px] text-center",
className: "w-[14%] text-center",
accessor: (row) => <TimestampCell timestamp={row.detected_at} showActiveStatus={false} />,
},
{ header: "#", className: "w-[40px] min-w-[40px] max-w-[40px] text-center", accessor: (_row, i) => i + 1 },
{ header: "#", className: "w-[4%] text-center", accessor: (_row, i) => i + 1 },
{
header: "Type",
className: "w-[170px] min-w-[170px] max-w-[170px]",
className: "w-[16%] text-center",
accessor: (row) => (
<div
title={row.description || row.threat_type || "Unknown"}
@@ -40,17 +40,17 @@ export function getThreatColumns(
},
{
header: "Severity",
className: "w-[90px] min-w-[90px] max-w-[90px] text-center",
className: "w-[8%] text-center",
accessor: (row) => getSeverityBadge(row.severity),
},
{
header: "Source IP",
className: "w-[120px] min-w-[120px] max-w-[120px] text-center",
className: "w-[12%] text-center",
accessor: (row) =>
row.ip_address ? (
<button
onClick={() => setSelectedIp(row.ip_address || "")}
className="font-medium text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs focus:outline-none w-full text-center"
className="font-medium text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs focus:outline-none w-full text-center truncate"
>
{row.ip_address}
</button>
@@ -60,12 +60,12 @@ export function getThreatColumns(
},
{
header: "Dest IP",
className: "w-[120px] min-w-[120px] max-w-[120px] text-center",
className: "w-[12%] text-center",
accessor: (row) =>
row.dst_ip ? (
<button
onClick={() => setSelectedIp(row.dst_ip || "")}
className="font-mono text-xs text-blue-400 hover:text-blue-300 hover:underline focus:outline-none w-full text-center"
className="font-mono text-xs text-blue-400 hover:text-blue-300 hover:underline focus:outline-none w-full text-center truncate"
>
{row.dst_ip}
</button>
@@ -75,7 +75,7 @@ export function getThreatColumns(
},
{
header: "MAC Addr",
className: "w-[140px] min-w-[140px] max-w-[140px] text-center",
className: "w-[13%] text-center",
accessor: (row) =>
row.mac_address ? (
<button
@@ -89,7 +89,7 @@ export function getThreatColumns(
},
{
header: "App",
className: "w-[110px] min-w-[110px] max-w-[110px] text-center",
className: "w-[9%] text-center",
accessor: (row) => {
const formatted = formatAppLabel(row.app_label);
return formatted ? (
@@ -103,7 +103,7 @@ export function getThreatColumns(
},
{
header: "Domain",
className: "w-[160px] min-w-[160px] max-w-[160px] text-center",
className: "w-[12%] text-center",
accessor: (row) => row.domain ? (
<div title={row.domain} className="truncate w-full text-center text-xs cursor-help">
{row.domain}
@@ -114,11 +114,11 @@ export function getThreatColumns(
},
{
header: "Action",
className: "w-[130px] min-w-[130px] max-w-[130px] text-center",
className: "w-[10%] text-center",
accessor: (row) => (
<button
onClick={() => setSelectedThreat(row)}
className="px-2 py-1 text-[11px] font-bold text-primary bg-primary/10 border border-primary/20 hover:bg-primary/20 hover:text-white rounded-lg transition-all shadow-sm cursor-pointer whitespace-nowrap focus:outline-none"
className="px-2 py-1 text-[11px] font-bold text-primary bg-primary/10 border border-primary/20 hover:bg-primary/20 hover:text-white rounded-lg transition-all shadow-sm cursor-pointer focus:outline-none leading-tight"
>
View Mitigation
</button>
+22 -57
View File
@@ -3,6 +3,7 @@
import { useState, useRef, useEffect } from "react";
import { Search, Download, ChevronDown, FileText, FileSpreadsheet } from "lucide-react";
import { CsvExportOptions, downloadCsv } from "./DataTableCsvHelper";
import { DataTablePagination } from "./DataTablePagination";
export interface Column<T> {
header: React.ReactNode;
@@ -53,12 +54,9 @@ export function DataTable<T>({
const activePage = serverSide ? currentPage : localCurrentPage;
const setActivePage = serverSide ? (onPageChange || (() => {})) : setLocalCurrentPage;
const handleSort = (columnIndex: number) => {
let direction: 'asc' | 'desc' = 'asc';
if (sortConfig && sortConfig.key === columnIndex && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key: columnIndex, direction });
const handleSort = (key: number) => {
const direction = sortConfig?.key === key && sortConfig.direction === 'asc' ? 'desc' : 'asc';
setSortConfig({ key, direction });
};
let processedData = [...data];
@@ -71,14 +69,12 @@ export function DataTable<T>({
const column = columns[sortConfig.key];
if (column && column.sortable && column.sortAccessor) {
processedData.sort((a, b) => {
const valA = column.sortAccessor!(a);
const valB = column.sortAccessor!(b);
const valA = column.sortAccessor!(a), valB = column.sortAccessor!(b);
if (valA === valB) return 0;
if (valA === null || valA === undefined) return 1;
if (valB === null || valB === undefined) return -1;
if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1;
if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
if (valA == null) return 1;
if (valB == null) return -1;
const asc = sortConfig.direction === 'asc';
return valA < valB ? (asc ? -1 : 1) : (asc ? 1 : -1);
});
}
}
@@ -94,22 +90,14 @@ export function DataTable<T>({
const exportRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (exportRef.current && !exportRef.current.contains(e.target as Node)) {
setExportOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
const onClick = (e: MouseEvent) => exportRef.current && !exportRef.current.contains(e.target as Node) && setExportOpen(false);
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, []);
const handleCsvExport = () => {
if (!csvExport) return;
const rows = [
csvExport.headers,
...filteredData.map(row => csvExport.rowSerializer(row)),
];
downloadCsv(csvExport.filename, rows);
downloadCsv(csvExport.filename, [csvExport.headers, ...filteredData.map(csvExport.rowSerializer)]);
setExportOpen(false);
};
@@ -179,7 +167,7 @@ export function DataTable<T>({
<th
key={i}
onClick={() => col.sortable && handleSort(i)}
className={`px-4 py-3.5 h-[48px] font-medium tracking-wide whitespace-nowrap text-center ${col.className || ""} ${col.sortable ? "cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 transition-colors group select-none" : ""}`}
className={`px-4 py-3.5 h-[48px] font-medium tracking-wide text-center ${col.className || ""} ${col.sortable ? "cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 transition-colors group select-none" : ""}`}
style={widthStyle}
>
<div className="flex items-center gap-1 justify-center">
@@ -238,7 +226,7 @@ export function DataTable<T>({
return (
<td
key={j}
className={`px-2 py-3 h-[48px] max-h-[48px] overflow-hidden text-foreground text-center transition-colors truncate whitespace-nowrap ${col.className || ""}`}
className={`px-2 py-3 h-[48px] max-h-[48px] overflow-hidden text-foreground text-center transition-colors ${col.className || ""}`}
style={widthStyle}
title={tooltipText}
>
@@ -253,36 +241,13 @@ export function DataTable<T>({
</table>
</div>
{totalItems > 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-foreground">{startIndex + 1}</span> to{" "}
<span className="font-medium text-foreground">
{Math.min(startIndex + pageSize, totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{totalItems}</span> results
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setActivePage(Math.max(1, activePage - 1))}
disabled={activePage === 1}
className="px-3 py-1.5 text-sm font-medium rounded-md bg-secondary/80 text-foreground 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 {activePage} of {totalPages}
</div>
<button
onClick={() => setActivePage(Math.min(totalPages, activePage + 1))}
disabled={activePage === totalPages}
className="px-3 py-1.5 text-sm font-medium rounded-md bg-secondary/80 text-foreground hover:bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-border/50"
>
Next
</button>
</div>
</div>
)}
<DataTablePagination
totalItems={totalItems}
pageSize={pageSize}
currentPage={activePage}
totalPages={totalPages}
onPageChange={setActivePage}
/>
</div>
</div>
);
+54
View File
@@ -0,0 +1,54 @@
"use client";
import React from "react";
interface DataTablePaginationProps {
totalItems: number;
pageSize: number;
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export function DataTablePagination({
totalItems,
pageSize,
currentPage,
totalPages,
onPageChange,
}: DataTablePaginationProps) {
if (totalItems <= pageSize) return null;
const startIndex = (currentPage - 1) * pageSize;
return (
<div className="flex items-center justify-between px-4 py-3 border-t border-border/50 bg-card/30 flex-wrap gap-3">
<div className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{startIndex + 1}</span> to{" "}
<span className="font-medium text-foreground">
{Math.min(startIndex + pageSize, totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{totalItems}</span> results
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="px-3 py-1.5 text-sm font-medium rounded-md bg-secondary/80 text-foreground hover:bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-border/50 cursor-pointer"
>
Previous
</button>
<div className="text-sm text-muted-foreground font-medium px-2">
Page {currentPage} of {totalPages}
</div>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="px-3 py-1.5 text-sm font-medium rounded-md bg-secondary/80 text-foreground hover:bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-border/50 cursor-pointer"
>
Next
</button>
</div>
</div>
);
}
+118 -61
View File
@@ -11,13 +11,28 @@ import {
} from "./agentsCore";
import type { Agent } from "./agentsCore";
import { getAuthUser } from "./authHelper";
// Re-export mutations for backwards compatibility
export { createAgent, updateAgent, deleteAgent };
export async function getAgents(clientSiteUuid?: string): Promise<Agent[]> {
try {
const user = await getAuthUser();
if (!user) {
throw new Error("Unauthorized: Please log in first.");
}
const API_KEY = getApiKey();
const SITE_UUID = clientSiteUuid || getSiteUuid();
// Resolve the SITE_UUID strictly based on user role and permissions
let SITE_UUID = clientSiteUuid || getSiteUuid();
if (user.role === 'TENANT_ADMIN') {
SITE_UUID = user.site_uuid;
} else if (user.role === 'AGENT_VIEWER') {
SITE_UUID = user.site_uuid;
}
// All agent data comes from MongoDB agent_registry (single source of truth)
// agent_registry is populated by the proxy collector every cycle from /data/agents
@@ -26,16 +41,57 @@ export async function getAgents(clientSiteUuid?: string): Promise<Agent[]> {
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi';
// Connect to MongoDB if not already connected
if (mongoose.connection.readyState === 0) {
await mongoose.connect(MONGODB_URI);
console.log("[getAgents Action] MONGODB_URI:", MONGODB_URI);
console.log("[getAgents Action] Mongoose readyState:", mongoose.connection.readyState);
try {
if (mongoose.connection.readyState === 0) {
console.log("[getAgents Action] Connecting to MongoDB...");
await mongoose.connect(MONGODB_URI, { serverSelectionTimeoutMS: 5000 });
console.log("[getAgents Action] ✓ Mongoose connected successfully!");
}
} catch (connErr: any) {
console.error("[getAgents Action] ✗ Mongoose connection failed:", connErr.message);
throw connErr;
}
const db = mongoose.connection.db;
// Build site filter
// CATATAN: Untuk company roles, jangan terapkan site_uuid filter global
// karena agent yang di-assign ke company user bisa berasal dari site mana saja.
// Filter keamanan untuk company roles HANYA menggunakan uuid: { $in: freshAgentUuids }
const companyRoles = ['COMPANY_ADMIN', 'COMPANY_OPERATOR', 'COMPANY_VIEWER'];
const isCompanyRole = companyRoles.includes(user.role);
const filter: any = {};
if (SITE_UUID) filter.site_uuid = SITE_UUID;
// Hanya apply site_uuid filter untuk NON-company roles
if (SITE_UUID && !isCompanyRole) filter.site_uuid = SITE_UUID;
// 1. Get ALL agents from agent_registry
// 🔒 Security: COMPANY_ADMIN/OPERATOR/VIEWER hanya boleh melihat agent yang di-assign
// freshAgentUuids dideklarasikan di outer scope agar bisa dipakai di fallback summaries
let freshAgentUuids: string[] = [];
if (isCompanyRole) {
// Selalu baca agent_uuids dari database — token bisa stale jika assignment berubah setelah login
freshAgentUuids = user.agent_uuids || [];
try {
const UserModel = mongoose.models.User || mongoose.model('User', new mongoose.Schema({ agent_uuids: [String] }, { strict: false }));
const dbUser = await UserModel.findById(user.id).select('agent_uuids').lean() as any;
if (dbUser?.agent_uuids && dbUser.agent_uuids.length > 0) {
freshAgentUuids = dbUser.agent_uuids;
}
} catch (dbErr: any) {
console.warn('[getAgents] Could not fetch fresh agent_uuids from DB, using token:', dbErr.message);
}
if (freshAgentUuids.length > 0) {
filter.uuid = { $in: freshAgentUuids };
} else {
// Tidak ada agent yang di-assign → kembalikan list kosong (secure by default)
console.log(`[getAgents] ${user.role} ${user.username} has no assigned agents — returning []`);
return [];
}
}
// 1. Get agents from agent_registry (filtered by role)
const registryAgents = await db.collection('agent_registry')
.find(filter)
.sort({ uuid: 1 })
@@ -45,7 +101,14 @@ export async function getAgents(clientSiteUuid?: string): Promise<Agent[]> {
let agentSource = registryAgents;
if (registryAgents.length === 0) {
const summaryFilter: any = { agent_uuid: { $nin: [null, ''], $exists: true } };
if (SITE_UUID) summaryFilter.site_uuid = SITE_UUID;
// Hanya apply site_uuid filter untuk non-company roles
if (SITE_UUID && !isCompanyRole) summaryFilter.site_uuid = SITE_UUID;
// Apply company role filter on summaries fallback using fresh agent_uuids from DB
if (isCompanyRole && freshAgentUuids.length > 0) {
summaryFilter.agent_uuid = { $in: freshAgentUuids };
} else if (isCompanyRole && freshAgentUuids.length === 0) {
return [];
}
const agentSummaries = await db.collection('summaries')
.aggregate([
{ $match: summaryFilter },
@@ -59,62 +122,56 @@ export async function getAgents(clientSiteUuid?: string): Promise<Agent[]> {
}));
}
// 3. Check for any flows recorded in the last 24 hours to determine online status
// 3. Optimize status check and last seen per agent using indexed queries
const twentyFourHoursAgo = new Date(Date.now() - 24 * 3600000);
const activeAgentUuids = await db.collection('flows').distinct('agent_uuid', {
timestamp: { $gte: twentyFourHoursAgo }
});
const activeSet = new Set(activeAgentUuids);
const agents: Agent[] = [];
// Query latest timestamps from flows and summaries to get exact last seen
const lastSeenFlows = await db.collection('flows')
.aggregate([
{ $match: { agent_uuid: { $nin: [null, ''], $exists: true } } },
{ $sort: { timestamp: -1 } },
{ $group: { _id: '$agent_uuid', lastSeen: { $first: '$timestamp' } } }
]).toArray();
for (let idx = 0; idx < agentSource.length; idx++) {
const item = agentSource[idx];
const uuid = item.uuid || item.serial;
if (!uuid) continue;
const lastSeenMap = new Map<string, Date>();
for (const f of lastSeenFlows) {
if (f._id && f.lastSeen) {
lastSeenMap.set(f._id, new Date(f.lastSeen));
// Check if there was any flow in the last 24 hours to determine online status
// Using index: { agent_uuid: 1, timestamp: -1 }
const activeFlow = await db.collection('flows')
.findOne({ agent_uuid: uuid, timestamp: { $gte: twentyFourHoursAgo } }, { projection: { _id: 1 } });
const isOnline = !!activeFlow;
// Check latest timestamp from flows
// Using index: { agent_uuid: 1, timestamp: -1 }
const latestFlow = await db.collection('flows')
.findOne({ agent_uuid: uuid }, { projection: { timestamp: 1 }, sort: { timestamp: -1 } });
// Check latest timestamp from summaries
// Using index: { agent_uuid: 1, timestamp: -1 }
const latestSummary = await db.collection('summaries')
.findOne({ agent_uuid: uuid }, { projection: { timestamp: 1 }, sort: { timestamp: -1 } });
const registryLastSeenRaw = item.last_seen_at;
let lastSeenDate: Date | null = null;
if (registryLastSeenRaw?.date) {
lastSeenDate = new Date(registryLastSeenRaw.date);
} else if (registryLastSeenRaw instanceof Date) {
lastSeenDate = registryLastSeenRaw;
} else if (typeof registryLastSeenRaw === 'string') {
lastSeenDate = new Date(registryLastSeenRaw);
}
}
const lastSeenSummaries = await db.collection('summaries')
.aggregate([
{ $match: { agent_uuid: { $nin: [null, ''], $exists: true } } },
{ $sort: { timestamp: -1 } },
{ $group: { _id: '$agent_uuid', lastSeen: { $first: '$timestamp' } } }
]).toArray();
for (const s of lastSeenSummaries) {
if (s._id && s.lastSeen) {
const current = lastSeenMap.get(s._id);
const date = new Date(s.lastSeen);
if (!current || date > current) {
lastSeenMap.set(s._id, date);
if (latestFlow && latestFlow.timestamp) {
const flowDate = new Date(latestFlow.timestamp);
if (!lastSeenDate || flowDate > lastSeenDate) {
lastSeenDate = flowDate;
}
}
}
const agents: Agent[] = agentSource.map((item: any, idx: number) => {
const isOnline = activeSet.has(item.uuid);
// Use last_seen_at from registry, enriched with online status and telemetry
const lastSeenRaw = item.last_seen_at;
let lastSeenDate: Date | null = null;
if (lastSeenRaw?.date) {
lastSeenDate = new Date(lastSeenRaw.date);
} else if (lastSeenRaw instanceof Date) {
lastSeenDate = lastSeenRaw;
} else if (typeof lastSeenRaw === 'string') {
lastSeenDate = new Date(lastSeenRaw);
}
const telemetryLastSeen = lastSeenMap.get(item.uuid || item.serial);
if (telemetryLastSeen && (!lastSeenDate || telemetryLastSeen > lastSeenDate)) {
lastSeenDate = telemetryLastSeen;
if (latestSummary && latestSummary.timestamp) {
const summaryDate = new Date(latestSummary.timestamp);
if (!lastSeenDate || summaryDate > lastSeenDate) {
lastSeenDate = summaryDate;
}
}
let statusHuman = 'No Flows Detected';
@@ -132,15 +189,15 @@ export async function getAgents(clientSiteUuid?: string): Promise<Agent[]> {
}
}
return {
agents.push({
id: idx + 1,
uuid: item.uuid || item.serial,
serial: item.serial || item.uuid,
uuid: uuid,
serial: item.serial || uuid,
site_uuid: item.site_uuid || SITE_UUID || '',
organization_uuid: '',
provisioned: item.provisioned ?? true,
activated: true, // All retrieved agents are active/activated on the Netify platform
label: item.label || item.uuid,
label: item.label || uuid,
created_at: { human: 'N/A', date: '', unix_time: 0 },
updated_at: { human: 'N/A', date: '', unix_time: 0 },
last_seen_at: {
@@ -148,8 +205,8 @@ export async function getAgents(clientSiteUuid?: string): Promise<Agent[]> {
date: lastSeenDate?.toISOString() || '',
unix_time: lastSeenDate ? lastSeenDate.getTime() / 1000 : 0,
},
};
});
});
}
return agents;
}
+27
View File
@@ -0,0 +1,27 @@
// src/lib/actions/authHelper.ts
import { cookies } from "next/headers";
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET || 'super-secret-backone-key';
export interface AuthenticatedUser {
id: string;
username: string;
role: string;
site_uuid?: string;
agent_uuid?: string;
agent_uuids?: string[];
}
export async function getAuthUser(): Promise<AuthenticatedUser | null> {
try {
const cookieStore = await cookies();
const token = cookieStore.get('token')?.value;
if (!token) return null;
const decoded = jwt.verify(token, JWT_SECRET) as any;
return decoded || null;
} catch (err) {
console.error('[getAuthUser] Failed to decode JWT token:', err);
return null;
}
}
+48 -4
View File
@@ -3,9 +3,11 @@
export interface ManagedUser {
id: number;
username: string;
role: 'SUPER_ADMIN' | 'AGENT_VIEWER' | 'SOC_ANALYST' | 'ENGINEER' | 'TENANT_ADMIN' | string;
role: 'SUPER_ADMIN' | 'EXECUTIVE' | 'AGENT_VIEWER' | 'SOC_ANALYST' | 'ENGINEER' | 'TENANT_ADMIN' | string;
site_uuid: string | null;
agent_uuid: string | null;
company_name?: string | null;
agent_uuids?: string[];
account_name: string | null;
profile_picture: string | null;
created_at: string;
@@ -14,10 +16,14 @@ export interface ManagedUser {
export interface ViewAsStatus {
active: boolean;
agent_uuid?: string;
agent_label?: string;
agent_label?: string; // Label agent (dari registry)
token?: string;
view_as_type?: 'user' | 'agent'; // 'user' = view-as user tertentu, 'agent' = direct agent view
user_name?: string; // Nama user yang di-view (hanya untuk view_as_type='user')
user_role?: string; // Role user yang di-view (hanya untuk view_as_type='user')
}
// ─── localStorage key untuk view-as state ─────────────────────────────────────
const VIEW_AS_KEY = 'backone_view_as';
@@ -102,7 +108,7 @@ export async function deleteAdminAgentUser(userId: number): Promise<void> {
if (!json.ok) throw new Error(json.error || 'Failed to delete account');
}
// POST /api/auth/admin/view-as — simpan token di localStorage
// POST /api/auth/admin/view-as — simpan token di localStorage (direct agent view-as)
export async function startViewAs(agent_uuid: string, agent_label: string): Promise<void> {
const res = await fetch('/api/auth/admin/view-as', {
method: 'POST',
@@ -122,12 +128,50 @@ export async function startViewAs(agent_uuid: string, agent_label: string): Prom
localStorage.setItem(VIEW_AS_KEY, JSON.stringify({
active: true,
agent_uuid: json.agent_uuid,
agent_label: json.agent_label,
agent_label: json.agent_label, // Label agent dari registry
token: json.view_token,
view_as_type: 'agent', // Direct agent view-as
}));
}
}
// POST /api/auth/admin/view-as — view-as USER tertentu (COMPANY_ADMIN → OPERATOR/VIEWER)
// userName: nama user yang di-view, userRole: role user, agentUuid: agent pertama user, agentLabel: label agent dari registry
export async function startViewAsUser(
agentUuid: string,
agentLabel: string,
userName: string,
userRole: string,
): Promise<void> {
const labelForToken = `${userName} (${userRole.replace(/_/g, ' ')})`;
const res = await fetch('/api/auth/admin/view-as', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ agent_uuid: agentUuid, agent_label: labelForToken }),
});
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 || 'Failed to enter view-as mode');
// Simpan token di localStorage dengan konteks user yang lebih kaya
if (typeof window !== 'undefined') {
localStorage.setItem(VIEW_AS_KEY, JSON.stringify({
active: true,
agent_uuid: json.agent_uuid,
agent_label: agentLabel, // Label AGENT dari registry (bukan nama user)
token: json.view_token,
view_as_type: 'user', // User view-as mode
user_name: userName, // Nama user yang di-view
user_role: userRole, // Role user yang di-view
}));
}
}
// Keluar dari mode view-as — hapus dari localStorage dan server session
export async function stopViewAs(): Promise<void> {
try {
+45
View File
@@ -0,0 +1,45 @@
// src/lib/branding.ts
export interface Branding {
name: string;
logo: string;
title: string;
copyright: string;
primaryColor: string;
}
export function getSiteBranding(selectedSiteUuid?: string): Branding {
if (typeof window !== "undefined") {
const siteUuid = selectedSiteUuid || localStorage.getItem('backone_site_uuid');
const isSiab = siteUuid === '6681452d_9cae_4ff4_8ae8_0d504774265e';
const isNexus = !isSiab && (
siteUuid === 'd7902405_0dc2_458b_8584_ed4d24b64f24' ||
window.location.hostname.includes("demoplace.my.id") ||
window.location.search.includes("nexus")
);
if (isNexus) {
return {
name: "Nexus",
logo: "/nexus-logo.png",
title: "Nexus Dashboard",
copyright: "PT. Nexus Solusi",
primaryColor: "#8B5CF6"
};
}
if (isSiab) {
return {
name: "BackOne",
logo: "/backone-logo.png",
title: "BackOne Dashboard",
copyright: "PT. Data Bisnis Solusi",
primaryColor: "#E11D48"
};
}
}
return {
name: "BackOne",
logo: "/backone-logo.png",
title: "BackOne Dashboard",
copyright: "PT. Data Bisnis Solusi",
primaryColor: "#E11D48"
};
}
+100
View File
@@ -0,0 +1,100 @@
import { PageGuide } from "../types";
export const AGENTS_GUIDES: PageGuide[] = [
{
id: "agents",
title: "Agents Inventory",
url: "/agents",
icon: "📡",
summary: "Full management of all registered DPI Network Agent probes at your site.",
sections: [
{
heading: "Statistic Cards — Quick Status Overview",
content: "Four summary cards at the top of the page give an instant health snapshot of all registered DPI probes:",
columns: [
{ column: "Total Agents", description: "Total number of DPI probe devices registered in the system database for this site. Each agent is a physical or virtual network sensor deployed at a specific location." },
{ column: "Online Agents", description: "Agents that successfully transmitted telemetry within the last 24 hours. Marked with a pulsing green dot and 'Last seen: HH:MM WIB'. An online agent is actively monitoring and sending data." },
{ column: "Offline Agents", description: "Agents that have not sent any data for more than 24 hours. Marked with a grey dot and 'No Flows Detected'. Check whether the device is still powered on, connected to the network, and that the DPI service is running." },
{ column: "Avg. Uptime", description: "Average availability percentage across all agents calculated from the last 30 days. Ideal value is >99%. Below 90% indicates persistent connectivity or hardware stability issues that require urgent on-site investigation." },
],
},
{
heading: "Agent Location Map — Interactive Network Overview",
content: "An interactive tile map displaying the geographic position of each agent based on GPS coordinates set by the administrator. Zoom in to street-level detail (zoom level 15+). Online agents show a pulsing green beacon; offline agents show a grey beacon.",
items: [
{ label: "Flow lines", description: "Animated arcs drawn ONLY between agents that have active inter-agent traffic flowing between them — not all agents are connected by default." },
{ label: "Hover on agent marker", description: "Shows the agent's friendly name (label), online/offline status, and uptime percentage in a tooltip." },
{ label: "Hover on flow arc", description: "Shows a tooltip with the top active sub-flows, bandwidth volume, and total connection count between the two agents." },
{ label: "Blue panel (No GPS set)", description: "Lists agents that have no GPS coordinates configured. Click the '📍 Set Location' button inside the panel or the 📍 pin icon in the table to open the coordinate input modal." },
],
},
{
heading: "Table: Provisioned Network Agents — Column Reference",
content: "Each row represents one registered DPI probe. The columns are:",
columns: [
{ column: "#", description: "Sequential row index. Used to quickly reference a specific agent row during review or support calls." },
{ column: "Agent Name", description: "The friendly display name of the agent — set by the administrator in the Edit Label modal. This name appears across all dashboard pages (Flows, Apps, Devices, etc.) to identify the data source. If not set, a short identifier is used as fallback." },
{ column: "Label / Location Tag", description: "User-assigned descriptive tag indicating physical location (e.g., 'Server Room Floor 3', 'Branch Office Cibubur'). Used across all dashboard tables and charts to identify which sensor detected the traffic." },
{ column: "Provisioned", description: "Whether the agent is registered in the central management system. Green 'Yes' = registered and data will be processed. Red 'No' = not yet registered — data from this agent will be silently discarded." },
{ column: "Activated", description: "Whether the agent has ever successfully connected and transmitted telemetry at least once. An unactivated agent will not appear on the map or in any traffic table even if provisioned." },
{ column: "Traffic Status", description: "Real-time heartbeat indicator. Pulsing green dot + 'Last seen: HH:MM WIB' = active, sending data within the last 24 hours. Grey dot + 'No Flows Detected' = no data for over 24 hours — the agent may be offline or misconfigured." },
{ column: "Avg Uptime", description: "Historical availability percentage specific to this agent over the last 30 days. Values below 90% require investigation — common causes include unstable ISP connections, power interruptions, or hardware issues." },
{ column: "Data Size", description: "Total size of telemetry data currently stored in the database for this agent. Data is automatically purged after 30 days per the retention policy to maintain system efficiency." },
{ column: "Actions", description: "Up to 5 action buttons available per agent row — see the Agent Row Actions section below for details on each button." },
],
},
{
heading: "Agent Row Actions — What Each Button Does",
items: [
{ label: "Detail ( › ) Icon", description: "Opens the Agent Detail Modal with comprehensive information: firmware version, operating system type, CPU and memory utilization percentages, network interface configurations, and full connection history log." },
{ label: "User ( 👤 ) Icon", description: "Opens the Agent Viewer Accounts panel — manage user accounts that are granted access to view ONLY this specific agent's traffic data. Useful for provisioning Company Viewer accounts locked to this single probe." },
{ label: "Pin ( 📍 ) Icon", description: "Opens the GPS Coordinate Modal to set or update this agent's geographic location. Enter the latitude and longitude values (e.g., -6.2088, 106.8456 for Jakarta). The agent marker will appear on the map immediately after saving." },
{ label: "Eye ( 👁️ ) Icon — View As Agent", description: "Locks the entire dashboard scope to this specific agent only. ALL pages (Flows, Apps, Devices, DNS, Events, etc.) will show only data from this single agent. A blue 'Viewing as: [Agent Name]' banner appears in the sidebar. Click 'Exit View As' in the sidebar to return to the global multi-agent view." },
{ label: "Trash ( 🗑️ ) Icon — Delete Agent", description: "Permanently removes the agent registration from the database. All associated telemetry data collected by this agent is also deleted. This action is irreversible — use with caution." },
],
},
{
heading: "Setting Agent GPS Location (Step-by-Step)",
content: "To place an agent on the interactive map:",
steps: [
"Click the 📍 pin icon in the Actions column of the target agent row.",
"The GPS Coordinate Modal will open. Enter the Latitude value (e.g., -6.2088 for South Jakarta). Negative values = South hemisphere.",
"Enter the Longitude value (e.g., 106.8456 for East Jakarta). Positive values = East hemisphere.",
"Click 'Save Location'. The agent marker will appear on the map immediately.",
"If the agent was in the 'No GPS Set' blue panel, it will automatically disappear from that panel and appear on the map at the saved coordinates.",
"To update coordinates, simply click the pin icon again and enter new values.",
],
},
{
heading: "Using View As Agent — Scoped Monitoring",
content: "The View As Agent feature lets you temporarily lock the entire dashboard to a single agent's perspective:",
steps: [
"Click the 👁️ Eye icon in the Actions column of the target agent.",
"A blue confirmation banner appears in the sidebar showing 'Viewing as: [Agent Name]'.",
"All dashboard pages (Flows, Apps, Devices, DNS, Geography, Threats, etc.) now show ONLY data from this agent.",
"Navigate to any page — the agent scope filter is applied globally and automatically.",
"To return to the full multi-agent view, click 'Exit View As' in the sidebar banner, or navigate to the Agents page and click the exit button.",
],
},
{
heading: "Device Labeling — MAC Address Detail Pop-up",
content: "From the Device Labeling page, click on any MAC address to open a detail pop-up showing all information known about that physical device:",
items: [
{ label: "All Associated IPs", description: "Every IP address ever assigned to or used by this MAC address, resolved from historical flow logs. DHCP environments may show many IPs over time as the device reconnects to the network." },
{ label: "Activity Dates", description: "First Seen and Last Seen timestamps for each IP-MAC pairing, helping you track when a device was active at a specific IP address over time." },
{ label: "Traffic Usage per IP", description: "Total download and upload bandwidth attributed to each IP address used by this MAC, aggregated from historical flow records in the database." },
{ label: "Vendor / OUI Identification", description: "The hardware manufacturer identified from the first 3 octets of the MAC address using the IEEE OUI database (e.g., 'Apple Inc.', 'Intel Corporate', 'Samsung Electronics', 'Cisco Systems')." },
],
},
{
heading: "Monitoring Tips",
items: [
{ label: "Check Avg Uptime weekly", description: "Any agent consistently below 95% uptime needs on-site inspection. Common fixes: check power supply, verify network cable connectivity, and confirm the DPI service is configured to auto-start on system reboot." },
{ label: "Set GPS coordinates first", description: "Configure GPS coordinates for all agents before using the map view. Agents without GPS appear in the blue 'No GPS' panel and are excluded from the geographic visualization." },
{ label: "Use View As for branch support", description: "When troubleshooting a specific branch, use 'View As Agent' to scope the entire dashboard to only that branch's sensor — eliminates noise from other agents during the investigation." },
{ label: "Monitor Data Size column", description: "If an agent's Data Size grows abnormally fast, it may indicate a traffic spike or security event generating unusually high flow counts. Investigate via the Flows page using 'View As' on that specific agent." },
],
},
],
},
];
+62 -55
View File
@@ -1,5 +1,6 @@
import { PageGuide } from "../types";
// NOTE: Agents guide has been moved to guides/agents.ts
export const INFRASTRUCTURE_GUIDES: PageGuide[] = [
{
id: "overview",
@@ -53,61 +54,6 @@ export const INFRASTRUCTURE_GUIDES: PageGuide[] = [
},
],
},
{
id: "agents",
title: "Agents Inventory",
url: "/agents",
icon: "📡",
summary: "Full management of all registered DPI Network Agent probes at your site.",
sections: [
{
heading: "Statistic Cards",
columns: [
{ column: "Total Agents", description: "Total number of DPI probe devices registered in the system database for this site." },
{ column: "Online Agents", description: "Agents that successfully transmitted telemetry within the last 24 hours. Marked with a pulsing green dot and 'Last seen: HH:MM WIB'." },
{ column: "Offline Agents", description: "Agents that have not sent any data for more than 24 hours. Marked with a grey dot and 'No Flows Detected'. Check whether the device is still powered on." },
{ column: "Avg. Uptime", description: "Average availability percentage across all agents, calculated from the last 30 days of historical data. Ideal value is >99%. Below 90% indicates persistent connectivity or hardware stability issues." },
],
},
{
heading: "Agent Location Map",
content: "An interactive map of Indonesia displaying the geographic position of each agent based on configured GPS coordinates. Online agents show a pulsing green beacon; offline agents show a grey/red beacon.",
items: [
{ label: "Flow lines", description: "Drawn dynamically ONLY between agents that have active traffic flowing between them — not all agents are connected by default." },
{ label: "Hover on agent", description: "Shows the agent name/label, UUID, online status, and uptime percentage." },
{ label: "Hover on flow line", description: "Shows a tooltip with top active sub-flows, bandwidth volume, and connection count." },
{ label: "Blue panel (no GPS)", description: "Lists agents with no GPS coordinates set — click the pin icon (📍) in the table to configure their location." },
],
},
{
heading: "Table: Provisioned Network Agents",
columns: [
{ column: "UUID / Serial", description: "Unique hardware identifier of the DPI probe. Click to open the Agent Detail Modal containing firmware version, OS, CPU/memory utilization, and connection history." },
{ column: "Label", description: "User-assigned friendly name, typically indicating physical location (e.g., 'Server Room Floor 3'). Used as the identifier across the entire dashboard." },
{ column: "Provisioned", description: "Whether the agent is registered in the central system. Green 'Yes' = registered. Red 'No' = not registered and data will not be processed." },
{ column: "Activated", description: "Whether the agent has ever successfully connected and transmitted telemetry at least once. An unactivated agent will not appear on the map or in traffic tables." },
{ column: "Traffic Status", description: "Real-time indicator: pulsing green dot + 'Last seen: HH:MM WIB' means actively sending data within the last 24 hours. Grey dot + 'No Flows Detected' means no data for over 24 hours." },
{ column: "Avg Uptime", description: "Historical availability percentage specific to this agent over the last 30 days. Below 90% requires attention." },
{ column: "Data Size", description: "Total size of telemetry data currently stored in MongoDB for this agent. Data is automatically purged after 30 days per the retention policy." },
{ column: "Actions", description: "Five action buttons are available per agent row — see the section below for details." },
],
},
{
heading: "Agent Row Actions",
items: [
{ label: "Detail Modal ( > )", description: "Opens the Agent Detail Modal showing firmware version, OS type, CPU/memory utilization, and full connection history." },
{ label: "User Icon", description: "Manage viewer accounts that are granted access to view this specific agent's data only." },
{ label: "Pin Icon 📍", description: "Set or update GPS coordinates for this agent so it appears in the correct position on the location map." },
{ label: "Eye Icon", description: "View As Agent: locks the entire dashboard scope to this agent only. All pages (Flows, Apps, Devices, etc.) will show only data from this agent. Click 'Exit View As' in the sidebar to return to the global view." },
{ label: "Delete Icon", description: "Permanently removes the agent from the registry. This action cannot be undone." },
],
},
{
heading: "Feature: View As Agent",
content: "The eye icon in the Actions column locks the entire dashboard scope to a single specific agent — all pages (Flows, Apps, Devices, etc.) will show only data from that agent. To return to the global view, click 'Exit View As' at the top of the sidebar.",
},
],
},
{
id: "network-infrastructure",
title: "Network Topology",
@@ -180,4 +126,65 @@ export const INFRASTRUCTURE_GUIDES: PageGuide[] = [
},
],
},
{
id: "user-accounts",
title: "User Accounts Directory",
url: "/user-accounts",
icon: "👥",
summary: "Manage company tenant accounts, role scopes, and multi-agent assignments.",
sections: [
{
heading: "Company Tenant Cards — What You See on This Page",
content: "The page is organized into cards, one per client company. Each card displays the company name and a user count badge (e.g. 'Accounts: 2 / 5'). Inside every company card, there is a table listing all accounts that belong to that company. Use the search box inside each card to quickly filter by account name, username, or role.",
},
{
heading: "Account Table — Column Reference",
content: "Each row in the company card's table represents one registered user account. The columns are:",
columns: [
{ column: "#", description: "Sequential row index within the current company card. Resets to 1 for each company. Helps you quickly count and reference a specific row during review." },
{ column: "Account Name", description: "The full human-readable display name of the account holder (e.g. 'John Doe' or 'IT Team - Cibubur Branch'). This name is set during account creation and can be updated via the Edit User action. If no account name was set, the username is shown as a fallback." },
{ column: "Username", description: "The unique login credential (username) used by this account to sign in to the dashboard. Displayed in monospace font. Usernames cannot be changed after creation — to rename, delete the account and re-create." },
{ column: "Role", description: "The permission level of this account within the system. Displayed as a color-coded badge: purple = Company Admin, amber = Company Operator, blue = Company Viewer. Each role controls which pages, agents, and actions the user can access." },
{ column: "Assigned Devices", description: "Shows how many network agents (DPI probes) are assigned to this account. Company Admins typically see all agents. Company Operators may be restricted to specific agents. Company Viewers are usually locked to exactly one agent. A value of 'No device assigned' means the account has no agent scope configured — the user will see an empty dashboard after login." },
{ column: "Actions", description: "The '⚙️ Edit User' button opens the account management modal where you can update the account name, change the role, reassign agents, or delete the account. This button is disabled (greyed out) for Executive-role viewers, who have read-only access." },
],
},
{
heading: "Role Hierarchies — Access Level Reference",
content: "There are three customer-tier roles, each with different permissions and agent scopes:",
items: [
{ label: "Company Admin", description: "Full administrator access for the client company. Can view all network agents assigned to their company, edit device labels, view all traffic pages, and manage sub-accounts (Company Operators and Company Viewers) under their organization. Cannot add other Company Admins (only Super Admin can)." },
{ label: "Company Operator", description: "Designed for regional IT leads or branch managers. Assigned a custom subset of network agents (e.g., 2 out of 5 agents for their branch). Can monitor traffic, view metrics, and edit device labels only for their assigned agents. Cannot manage user accounts." },
{ label: "Company Viewer", description: "Designed for on-site technicians or monitoring staff. Locked to a single specific network agent. The entire dashboard view is automatically filtered to show only that agent's traffic. Has read-only access — cannot edit device labels or manage user accounts." },
],
},
{
heading: "Adding a New Account",
content: "To provision a new account for a company:",
steps: [
"Click the '+ Add User for [Company]' button on the top-right of the target company card, or click '+ Add New Company Admin' at the bottom of the page.",
"Fill in the Account Name field with the user's full name or team identifier.",
"Enter a Username — this will be the login credential. Use a consistent naming convention (e.g. firstname.lastname or dept.city).",
"Set a secure Password. Inform the user to change it on first login via Settings.",
"Select the Role (Company Admin, Company Operator, or Company Viewer) based on the user's responsibilities.",
"In the Agent Assignment checklist, select which network agents (DPI probes) this account should be able to see. For Company Viewers, select exactly one agent.",
"Click 'Create Account'. The new account will appear in the company card immediately.",
],
},
{
heading: "Editing or Deleting an Account",
content: "To modify an existing account:",
steps: [
"Click '⚙️ Edit User' in the Actions column of the target account row.",
"In the modal, you can update the Account Name, change the Role, or reassign the Agent Assignment (check/uncheck agents in the checklist).",
"Click 'Save Changes' to apply. Changes take effect immediately on the user's next page load.",
"To permanently delete an account, scroll down in the edit modal and click the red 'Delete Account' button. This action is irreversible — the user will be immediately logged out and unable to sign in.",
],
},
{
heading: "5-Account Quota Limit per Company",
content: "To maintain system efficiency, each client company is limited to a maximum of 5 active user accounts. The badge on each company card shows the current count (e.g. 'Accounts: 3 / 5'). When the limit is reached, the badge turns red and the '+ Add User' button is disabled. The backend also enforces this limit — any API request exceeding the quota is rejected with an error.",
},
],
},
];
+21 -88
View File
@@ -6,102 +6,35 @@ export const SECURITY_GUIDES: PageGuide[] = [
title: "Threat Intelligence",
url: "/intelligence",
icon: "🛡️",
summary: "Detection and analysis of active network security threats identified by the DPI engine.",
summary: "Overview of security intelligence feeds, anomalies, and active network risk indicators.",
sections: [
{
heading: "How Threats Are Detected",
content: "The system uses multiple analysis layers to detect threats:",
steps: [
"Signature-based detection — matching traffic patterns against a database of 10,000+ known threat signatures, updated regularly.",
"Behavioral analysis — detecting abnormal patterns such as one device connecting to hundreds of IPs in a short time (port scanning).",
"Domain/IP reputation — matching connection destinations against blacklists of known malicious IPs and domains.",
"Protocol anomaly — detecting abnormal protocol usage, such as the Stratum mining protocol appearing on non-standard ports.",
],
heading: "Security Intelligence Feeds",
content: "The Threat Intelligence dashboard provides a consolidated view of potential security issues, risky behavior, and unencrypted credentials detected on the network. These indicators help security teams perform proactive threat hunting.",
},
{
heading: "Threat Categories",
heading: "Intelligence Categories",
items: [
{ label: "Crypto Mining (Illicit)", description: "Traffic matching the Stratum mining protocol — used for communication between mining software and pool servers. Key indicators: connections to domains like 'pool.minexmr.com' or common mining ports (3333, 4444, 5555, 14444). This activity consumes CPU/GPU resources and bandwidth without authorization." },
{ label: "Botnet Command & Control", description: "Infected devices often make regular heartbeat connections to a C2 server to receive attacker commands. Key indicators: periodic connections to blacklisted botnet IPs/domains, encrypted traffic to non-standard ports, or DNS queries to domains with DGA-like random patterns." },
{ label: "Malware", description: "Traffic signatures matching known malware communication patterns — Trojans (remote access), Ransomware (file encryption + C2), Spyware (keystroke or screenshot exfiltration), or Worms (self-propagating scanning). Refer to the 'Evidence' column for the specific technical details that triggered the detection." },
{ label: "Phishing", description: "Connections to domains known to impersonate legitimate services (banking, email, social media) to steal credentials. Domains are matched against a phishing database updated hourly. A detection means a user device accessed a malicious URL." },
],
{ label: "Crypto Mining", description: "Identifies devices actively participating in cryptocurrency mining pools. Mining traffic uses specific protocols (e.g. Stratum) and ports that consume CPU/GPU and network resources." },
{ label: "Tor Detection", description: "Detects connections routing through the Onion Router (Tor) network, which is used for anonymous browsing and can indicate malware C2 channels or unauthorized employee activity." },
{ label: "VPN Detection", description: "Identifies active VPN connections. VPN tunnels bypass corporate network controls and obscure traffic inspection, posing security and compliance risks." },
{ label: "IP Reputation", description: "Flags connections to remote servers with known bad reputation history (malware distribution, phishing, spam sources) based on dynamic threat feeds." },
{ label: "Insecure Protocols", description: "Identifies legacy or plaintext protocols (HTTP, FTP, Telnet) in use that expose data to interception or spoofing." },
{ label: "Unencrypted Passwords", description: "Critical alert showing plaintext passwords captured in unencrypted transit (e.g., via HTTP Basic Auth or plaintext FTP logins)." },
{ label: "Encryption Audit", description: "Reviews the TLS/SSL versions and cipher strength of encrypted connections on the network to identify legacy ciphers (RC4, 3DES) or vulnerable TLS 1.0/1.1 protocols." },
{ label: "Device Discovery", description: "Tracks new or newly active devices communicating on the network to prevent rogue/unauthorized hardware from connecting." },
{ label: "Server Discovery", description: "Detects active local servers hosting services on corporate subnets, ensuring all local services are cataloged and authorized." }
]
},
{
heading: "Available Threat Filters",
content: "The filter panel above the table allows searching by any combination of the following fields simultaneously:",
heading: "Action and Remediation",
items: [
{ label: "Date Range", description: "Filter by specific date and time of occurrence." },
{ label: "Type", description: "Filter by threat category: Crypto Mining, Botnet C2, Malware, or Phishing." },
{ label: "Severity", description: "Filter by impact level: Critical, High, Medium, or Low." },
{ label: "Source IP", description: "Filter by the local infected or suspicious device's IP address." },
{ label: "Dest IP", description: "Filter by the malicious remote server IP address." },
{ label: "MAC Address", description: "Filter by hardware identifier of the local device — useful when the IP changes dynamically." },
{ label: "App", description: "Filter by the application associated with the threat (e.g., 'Stratum', 'BitTorrent')." },
{ label: "Domain", description: "Filter by the specific malicious domain that was accessed." },
],
},
{
heading: "Table: Detected Threats",
columns: [
{ column: "Timestamp", description: "Date and time the threat was first detected, in Indonesia timezone (WIB). Click the header to sort newest or oldest first." },
{ column: "#", description: "Sequential row number, continuous across pages (server-side pagination at 50 rows per page)." },
{ column: "Type", description: "The detected threat category: Crypto Mining, Botnet C2, Malware, Phishing, or another type from the signature database." },
{ column: "Severity", description: "Impact level — see the Severity Levels section below for response guidance per level." },
{ column: "Source IP", description: "The local device IP that is the source or victim of the threat. Click to open the Device Detail Modal and view all activity from this device." },
{ column: "Dest IP", description: "The malicious remote server IP. Hover to see geolocation and ISP information for that server." },
{ column: "MAC Addr", description: "Hardware MAC address of the local device — useful for physically identifying a device even when its IP changes due to dynamic DHCP." },
{ column: "App", description: "The application or protocol used in the malicious connection (e.g., 'BitTorrent', 'Stratum', or a specific identified application)." },
{ column: "Domain", description: "The malicious domain that was accessed. Truncated — hover to see the full domain. This domain has been verified as malicious in the reputation database." },
{ column: "Action", description: "Click the recommendation icon (💡) to open the Threat Recommendation Drawer — a panel with specific mitigation guidance, incident response steps, and relevant CVE references for this threat type." },
],
},
{
heading: "Severity Levels",
items: [
{ label: "Critical", description: "Active threat with direct and immediate impact. Response required within 1 hour. Isolate the device immediately." },
{ label: "High", description: "Serious threat confirmed. Investigate and take action within 24 hours before the situation escalates." },
{ label: "Medium", description: "Suspicious activity detected. Needs further analysis and monitoring. Escalate to High if the pattern repeats." },
{ label: "Low", description: "Potentially suspicious activity — may be a false positive from a legitimate application. Log and review periodically." },
],
},
{
heading: "Malware Sub-Types",
items: [
{ label: "Trojan", description: "Remote access malware disguised as a legitimate file. Allows attackers to control the infected device silently." },
{ label: "Ransomware", description: "Encrypts local files and establishes a C2 connection to demand payment. Immediate isolation is critical." },
{ label: "Spyware", description: "Silently records keystrokes, screenshots, or clipboard content and exfiltrates them to an attacker's server." },
{ label: "Worm", description: "Self-propagating malware that scans the network for other vulnerable devices to infect automatically." },
],
},
{
heading: "Threat Recommendation Drawer",
content: "Click the action icon on any threat row to open the recommendation panel. The drawer contains:",
items: [
{ label: "Technical explanation", description: "A detailed explanation of why this specific activity was classified as a threat, including the matching signatures and evidence." },
{ label: "Mitigation steps", description: "Step-by-step instructions for IT/security teams to contain and remediate this specific threat type." },
{ label: "CVE references", description: "Relevant CVE (Common Vulnerabilities and Exposures) IDs where applicable, linking to known vulnerability databases." },
{ label: "Incident response", description: "Recommended device isolation procedures and incident response workflow." },
],
},
{
heading: "Exporting Threat Data",
content: "Click the 'Export ▾' button at the top-right of the table to open the format dropdown. Always apply your desired filters first — both formats only export data matching the active filter.",
items: [
{ label: "Export CSV", description: "Downloads a .csv file with raw threat data columns: Timestamp, Type, Severity, Source IP, Dest IP, MAC Address, App, Domain. Compatible with Excel, Google Sheets, or import into SIEM platforms (Splunk, IBM QRadar, Elastic)." },
{ label: "Export PDF", description: "Generates a professionally formatted threat summary report as a .pdf with severity breakdown statistics. The button displays 'Generating...' during PDF rendering — wait for the file to download automatically without clicking again." },
],
},
{
heading: "Response Priority Guidelines",
items: [
{ label: "Critical — Respond immediately (<1 hour)", description: "Isolate the device from the network (disconnect cable or block at switch/access point). Document all flow details and take screenshots. Report to the security team or management immediately." },
{ label: "High — Investigate within 24 hours", description: "Open the Device Detail Modal for the affected device. Review all applications and domains accessed. Determine whether this is a false positive or a genuine threat." },
{ label: "Medium — Monitor and analyze", description: "Flag for monitoring. Check whether the same pattern recurs over the following days. If it does, escalate to High severity." },
{ label: "Low — Log and review periodically", description: "Record in the incident log. Review weekly to check for any severity escalation. May be a false positive from a legitimate application." },
],
},
],
{ label: "Plaintext Passwords", description: "Investigate immediately. Identify the affected user, enforce a password reset, and configure the target application to use secure TLS/HTTPS." },
{ label: "Tor / VPN Tunnels", description: "Identify the device establishing the tunnel. Confirm if it is a business requirement or a violation of corporate network access policies." },
{ label: "IP Reputation Flags", description: "Cross-reference destination IPs. Block malicious destination IPs at the firewall and run a security scan on the internal source device." }
]
}
]
},
{
id: "events",
+106
View File
@@ -0,0 +1,106 @@
import { PageGuide } from "../types";
export const THREATS_GUIDES: PageGuide[] = [
{
id: "threats",
title: "Detected Threats",
url: "/threats",
icon: "🛡️",
summary: "Detection and analysis of active network security threats identified by the DPI engine.",
sections: [
{
heading: "How Threats Are Detected",
content: "The system uses multiple analysis layers to detect threats:",
steps: [
"Signature-based detection — matching traffic patterns against a database of 10,000+ known threat signatures, updated regularly.",
"Behavioral analysis — detecting abnormal patterns such as one device connecting to hundreds of IPs in a short time (port scanning).",
"Domain/IP reputation — matching connection destinations against blacklists of known malicious IPs and domains.",
"Protocol anomaly — detecting abnormal protocol usage, such as the Stratum mining protocol appearing on non-standard ports.",
],
},
{
heading: "Threat Categories",
items: [
{ label: "Crypto Mining (Illicit)", description: "Traffic matching the Stratum mining protocol — used for communication between mining software and pool servers. Key indicators: connections to domains like 'pool.minexmr.com' or common mining ports (3333, 4444, 5555, 14444). This activity consumes CPU/GPU resources and bandwidth without authorization." },
{ label: "Botnet Command & Control", description: "Infected devices often make regular heartbeat connections to a C2 server to receive attacker commands. Key indicators: periodic connections to blacklisted botnet IPs/domains, encrypted traffic to non-standard ports, or DNS queries to domains with DGA-like random patterns." },
{ label: "Malware", description: "Traffic signatures matching known malware communication patterns — Trojans (remote access), Ransomware (file encryption + C2), Spyware (keystroke or screenshot exfiltration), or Worms (self-propagating scanning). Refer to the 'Evidence' column for the specific technical details that triggered the detection." },
{ label: "Phishing", description: "Connections to domains known to impersonate legitimate services (banking, email, social media) to steal credentials. Domains are matched against a phishing database updated hourly. A detection means a user device accessed a malicious URL." },
],
},
{
heading: "Available Threat Filters",
content: "The filter panel above the table allows searching by any combination of the following fields simultaneously:",
items: [
{ label: "Date Range", description: "Filter by specific date and time of occurrence." },
{ label: "Type", description: "Filter by threat category: Crypto Mining, Botnet C2, Malware, or Phishing." },
{ label: "Severity", description: "Filter by impact level: Critical, High, Medium, or Low." },
{ label: "Source IP", description: "Filter by the local infected or suspicious device's IP address." },
{ label: "Dest IP", description: "Filter by the malicious remote server IP address." },
{ label: "MAC Address", description: "Filter by hardware identifier of the local device — useful when the IP changes dynamically." },
{ label: "App", description: "Filter by the application associated with the threat (e.g., 'Stratum', 'BitTorrent')." },
{ label: "Domain", description: "Filter by the specific malicious domain that was accessed." },
],
},
{
heading: "Table: Detected Threats",
columns: [
{ column: "Timestamp", description: "Date and time the threat was first detected, in Indonesia timezone (WIB). Click the header to sort newest or oldest first." },
{ column: "#", description: "Sequential row number, continuous across pages (server-side pagination at 50 rows per page)." },
{ column: "Type", description: "The detected threat category: Crypto Mining, Botnet C2, Malware, Phishing, or another type from the signature database." },
{ column: "Severity", description: "Impact level — see the Severity Levels section below for response guidance per level." },
{ column: "Source IP", description: "The local device IP that is the source or victim of the threat. Click to open the Device Detail Modal and view all activity from this device." },
{ column: "Dest IP", description: "The malicious remote server IP. Hover to see geolocation and ISP information for that server." },
{ column: "MAC Addr", description: "Hardware MAC address of the local device — useful for physically identifying a device even when its IP changes due to dynamic DHCP." },
{ column: "App", description: "The application or protocol used in the malicious connection (e.g., 'BitTorrent', 'Stratum', or a specific identified application)." },
{ column: "Domain", description: "The malicious domain that was accessed. Truncated — hover to see the full domain. This domain has been verified as malicious in the reputation database." },
{ column: "Action", description: "Click the recommendation icon (💡) to open the Threat Recommendation Drawer — a panel with specific mitigation guidance, incident response steps, and relevant CVE references for this threat type." },
],
},
{
heading: "Severity Levels",
items: [
{ label: "Critical", description: "Active threat with direct and immediate impact. Response required within 1 hour. Isolate the device immediately." },
{ label: "High", description: "Serious threat confirmed. Investigate and take action within 24 hours before the situation escalates." },
{ label: "Medium", description: "Suspicious activity detected. Needs further analysis and monitoring. Escalate to High if the pattern repeats." },
{ label: "Low", description: "Potentially suspicious activity — may be a false positive from a legitimate application. Log and review periodically." },
],
},
{
heading: "Malware Sub-Types",
items: [
{ label: "Trojan", description: "Remote access malware disguised as a legitimate file. Allows attackers to control the infected device silently." },
{ label: "Ransomware", description: "Encrypts local files and establishes a C2 connection to demand payment. Immediate isolation is critical." },
{ label: "Spyware", description: "Silently records keystrokes, screenshots, or clipboard content and exfiltrates them to an attacker's server." },
{ label: "Worm", description: "Self-propagating malware that scans the network for other vulnerable devices to infect automatically." },
],
},
{
heading: "Threat Recommendation Drawer",
content: "Click the action icon on any threat row to open the recommendation panel. The drawer contains:",
items: [
{ label: "Technical explanation", description: "A detailed explanation of why this specific activity was classified as a threat, including the matching signatures and evidence." },
{ label: "Mitigation steps", description: "Step-by-step instructions for IT/security teams to contain and remediate this specific threat type." },
{ label: "CVE references", description: "Relevant CVE (Common Vulnerabilities and Exposures) IDs where applicable, linking to known vulnerability databases." },
{ label: "Incident response", description: "Recommended device isolation procedures and incident response workflow." },
],
},
{
heading: "Exporting Threat Data",
content: "Click the 'Export ▾' button at the top-right of the table to open the format dropdown. Always apply your desired filters first — both formats only export data matching the active filter.",
items: [
{ label: "Export CSV", description: "Downloads a .csv file with raw threat data columns: Timestamp, Type, Severity, Source IP, Dest IP, MAC Address, App, Domain. Compatible with Excel, Google Sheets, or import into SIEM platforms (Splunk, IBM QRadar, Elastic)." },
{ label: "Export PDF", description: "Generates a professionally formatted threat summary report as a .pdf with severity breakdown statistics. The button displays 'Generating...' during PDF rendering — wait for the file to download automatically without clicking again." },
],
},
{
heading: "Response Priority Guidelines",
items: [
{ label: "Critical — Respond immediately (<1 hour)", description: "Isolate the device from the network (disconnect cable or block at switch/access point). Document all flow details and take screenshots. Report to the security team or management immediately." },
{ label: "High — Investigate within 24 hours", description: "Open the Device Detail Modal for the affected device. Review all applications and domains accessed. Determine whether this is a false positive or a genuine threat." },
{ label: "Medium — Monitor and analyze", description: "Flag for monitoring. Check whether the same pattern recurs over the following days. If it does, escalate to High severity." },
{ label: "Low — Log and review periodically", description: "Record in the incident log. Review weekly to check for any severity escalation. May be a false positive from a legitimate application." },
],
},
],
},
];
+4
View File
@@ -8,17 +8,21 @@ import { FAQItem, TableColumn, PageGuide } from "./help/types";
import { FAQS } from "./help/faqs";
import { UI_CONVENTIONS } from "./help/conventions";
import { INFRASTRUCTURE_GUIDES } from "./help/guides/infrastructure";
import { AGENTS_GUIDES } from "./help/guides/agents";
import { TRAFFIC_CORE_GUIDES } from "./help/guides/traffic";
import { TRAFFIC_ANALYTICS_GUIDES } from "./help/guides/traffic-analytics";
import { SECURITY_GUIDES } from "./help/guides/security";
import { THREATS_GUIDES } from "./help/guides/threats";
export type { FAQItem, TableColumn, PageGuide };
export { FAQS, UI_CONVENTIONS };
export const PAGE_GUIDES: PageGuide[] = [
...INFRASTRUCTURE_GUIDES,
...AGENTS_GUIDES,
...TRAFFIC_CORE_GUIDES,
...TRAFFIC_ANALYTICS_GUIDES,
...SECURITY_GUIDES,
...THREATS_GUIDES,
];
+1 -4
View File
@@ -1,16 +1,13 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function proxy(request: NextRequest) {
export function middleware(request: NextRequest) {
const token = request.cookies.get('token')?.value;
const { pathname } = request.nextUrl;
const isAuthPage = pathname.startsWith('/login');
// Protect all pages except /login and static assets/api
// The actual API routes inside /api are protected by Express backend (or Next.js API routes)
// We don't intercept /api requests here to avoid breaking them, but we protect the UI pages
if (pathname.startsWith('/api') || pathname.startsWith('/_next') || pathname === '/favicon.ico' || pathname.endsWith('.svg') || pathname.endsWith('.png')) {
return NextResponse.next();
}
+98
View File
@@ -0,0 +1,98 @@
// test/branding_unit_test.js
const fs = require('fs');
const path = require('path');
const vm = require('vm');
console.log('=========================================');
console.log(' RUNNING TDD UNIT TEST FOR BRANDING LOGIC');
console.log('=========================================\n');
// 1. Read and transpile src/lib/branding.ts slightly to make it run in Node.js
const brandingCodePath = path.join(__dirname, '../src/lib/branding.ts');
let code = fs.readFileSync(brandingCodePath, 'utf8');
// Strip TypeScript interfaces, return types, parameter types, and export keywords
code = code.replace(/export\s+interface\s+\w+\s*\{[^}]*\}/g, '');
code = code.replace(/:\s*Branding\b/g, '');
code = code.replace(/selectedSiteUuid\?\s*:\s*string/g, 'selectedSiteUuid');
code = code.replace(/export\s+/g, '');
let testCount = 0;
let passedCount = 0;
function assert(label, condition) {
testCount++;
if (condition) {
console.log(` ✓ PASSED: ${label}`);
passedCount++;
} else {
console.error(` ✗ FAILED: ${label}`);
}
}
// Helper to run getSiteBranding with mocked environment
function runGetSiteBranding({ selectedSiteUuid, hostname, search, localStorageVal }) {
const sandbox = {
window: {
location: {
hostname: hostname || 'localhost',
search: search || ''
}
},
localStorage: {
getItem: () => localStorageVal || null
},
console
};
vm.createContext(sandbox);
vm.runInContext(code, sandbox);
return sandbox.getSiteBranding(selectedSiteUuid);
}
try {
// Test Case 1: Default branding (no local storage, localhost)
const brand1 = runGetSiteBranding({
hostname: 'localhost'
});
assert('Default branding should be BackOne', brand1.name === 'BackOne' && brand1.logo === '/backone-logo.png');
// Test Case 2: SIAB site UUID passed explicitly on demoplace.my.id
const brand2 = runGetSiteBranding({
selectedSiteUuid: '6681452d_9cae_4ff4_8ae8_0d504774265e',
hostname: 'demoplace.my.id'
});
assert('SIAB site UUID on demoplace.my.id should return BackOne branding', brand2.name === 'BackOne' && brand2.logo === '/backone-logo.png');
// Test Case 3: SIAB site UUID in localStorage on demoplace.my.id
const brand3 = runGetSiteBranding({
localStorageVal: '6681452d_9cae_4ff4_8ae8_0d504774265e',
hostname: 'demoplace.my.id'
});
assert('SIAB site UUID in localStorage on demoplace.my.id should return BackOne branding', brand3.name === 'BackOne' && brand3.logo === '/backone-logo.png');
// Test Case 4: Nexus site UUID passed explicitly
const brand4 = runGetSiteBranding({
selectedSiteUuid: 'd7902405_0dc2_458b_8584_ed4d24b64f24',
hostname: 'localhost'
});
assert('Nexus site UUID should return Nexus branding', brand4.name === 'Nexus' && brand4.logo === '/nexus-logo.png');
// Test Case 5: Nexus fallback on demoplace.my.id when no site is selected
const brand5 = runGetSiteBranding({
hostname: 'demoplace.my.id'
});
assert('Fallback on demoplace.my.id should be Nexus branding', brand5.name === 'Nexus' && brand5.logo === '/nexus-logo.png');
console.log(`\n=========================================`);
console.log(` RESULT: ${passedCount} / ${testCount} tests passed.`);
console.log(`=========================================\n`);
if (passedCount !== testCount) {
process.exit(1);
} else {
process.exit(0);
}
} catch (err) {
console.error('Test execution failed with error:', err);
process.exit(1);
}
+1 -1
View File
@@ -1,7 +1,7 @@
// test/check-ip-mongo.js
const { MongoClient } = require('mongodb');
const URI = 'mongodb://backone_user:SusuKudaLiar@103.80.237.29:27017/backone_dpi?authSource=backone_dpi';
const URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi';
async function main() {
console.log('Connecting to MongoDB via IP 103.80.237.29...');
+11
View File
@@ -0,0 +1,11 @@
// test/check-logo-size.js
const axios = require('axios');
async function main() {
const url = 'https://demoplace.my.id/nexus-logo.png';
const res = await axios.head(url);
console.log('Remote nexus-logo.png content-length:', res.headers['content-length']);
console.log('Remote nexus-logo.png content-type:', res.headers['content-type']);
}
main().catch(err => console.error(err.message));
+1 -1
View File
@@ -1,7 +1,7 @@
// test/check_prod_after_migration.js
const { MongoClient } = require('mongodb');
const REMOTE_URI = 'mongodb://backone_user:SusuKudaLiar@mongodb.prod.proit.id:27017/backone_dpi?authSource=backone_dpi';
const REMOTE_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi';
const DB_NAME = 'backone_dpi';
async function main() {
+5 -5
View File
@@ -1,10 +1,10 @@
const { Client } = require('ssh2');
const SSH = {
host: '103.185.47.52',
port: 2222,
username: 'adminbackend',
password: 'htEo7x6LsBQiEHHH',
host: process.env.SSH_HOST || '127.0.0.1',
port: parseInt(process.env.SSH_PORT || '2222'),
username: process.env.SSH_USER || 'adminbackend',
password: process.env.SSH_PASSWORD || '',
readyTimeout: 60000
};
@@ -13,7 +13,7 @@ const mongoose = require('/home/adminbackend/web/demoplace.my.id/public_html/bac
async function main() {
console.log('Remote: Requiring mongoose succeeded. Connecting...');
const URI = 'mongodb://backone_user:SusuKudaLiar@103.80.237.29:27017/backone_dpi?authSource=backone_dpi';
const URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi';
try {
await mongoose.connect(URI, {
serverSelectionTimeoutMS: 10000,
+26
View File
@@ -0,0 +1,26 @@
const axios = require('axios');
async function main() {
console.log('Logging in to remote server...');
const loginRes = await axios.post('https://demoplace.my.id/api/auth/login', {
username: 'nexus',
password: 'nexus'
});
const cookie = loginRes.headers['set-cookie'];
console.log('Login successful. Cookies:', cookie);
console.log('Fetching / (Overview page) with cookie...');
const pageRes = await axios.get('https://demoplace.my.id/', {
headers: {
Cookie: cookie ? cookie.join('; ') : ''
}
});
console.log('Page HTTP Status:', pageRes.status);
console.log('Page HTML length:', pageRes.data.length);
console.log('HTML snippet (first 500 chars):');
console.log(pageRes.data.substring(0, 500));
}
main().catch(console.error);
+30
View File
@@ -0,0 +1,30 @@
// test/fetch-sidebar-logo.js
const axios = require('axios');
async function main() {
console.log('Logging in as nexus...');
const loginRes = await axios.post('https://demoplace.my.id/api/auth/login', {
username: 'nexus',
password: 'nexus'
});
const cookie = loginRes.headers['set-cookie'];
const headers = { Cookie: cookie ? cookie.join('; ') : '' };
console.log('Fetching overview page...');
const pageRes = await axios.get('https://demoplace.my.id/', { headers });
const html = pageRes.data;
// Find all img tags using a regex
const imgRegex = /<img[^>]+src=["']([^"']+)["']/g;
let match;
console.log('Found image tags in HTML:');
while ((match = imgRegex.exec(html)) !== null) {
console.log(' - src:', match[1]);
}
}
main().catch(err => {
console.error('Error:', err.message);
});
+1 -1
View File
@@ -1,7 +1,7 @@
// test/get_db_date_range.js
const mongoose = require('mongoose');
const MONGODB_URI = 'mongodb://backone_user:SusuKudaLiar@mongodb.prod.proit.id:27017/backone_dpi?authSource=backone_dpi';
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi';
async function main() {
try {
+20
View File
@@ -0,0 +1,20 @@
const mongoose = require('mongoose');
const MONGODB_URI = process.env.MONGODB_URI || "mongodb://127.0.0.1:27017/backone_dpi";
async function run() {
console.log('Connecting to Production MongoDB...');
await mongoose.connect(MONGODB_URI);
console.log('Connected.');
const db = mongoose.connection.db;
const agents = await db.collection('agent_registry').find({}).toArray();
console.log('Total agents in agent_registry:', agents.length);
for (const agent of agents) {
console.log(`Agent UUID: ${agent.uuid}, Label: ${agent.label}, Site UUID: ${agent.site_uuid}, Organization: ${agent.organization_uuid}`);
}
await mongoose.disconnect();
}
run().catch(console.error);
+54
View File
@@ -0,0 +1,54 @@
// test/inspect-production-mongodb.js
const mongoose = require('c:/Users/demo/Downloads/Deep Package Inspection/backend/node_modules/mongoose');
const MONGODB_URI = process.env.MONGODB_URI || "mongodb://127.0.0.1:27017/backone_dpi";
async function run() {
console.log('Connecting to Production MongoDB...');
await mongoose.connect(MONGODB_URI);
console.log('Connected.');
const db = mongoose.connection.db;
const count = await db.collection('summaries').countDocuments({});
console.log('Total summaries documents in DB:', count);
// Find latest documents
console.log('--- LATEST 5 DOCUMENTS ---');
const latest = await db.collection('summaries').find({}).sort({ timestamp: -1 }).limit(5).toArray();
for (const doc of latest) {
console.log(`ID: ${doc._id}, Time: ${doc.timestamp}, Agent: ${doc.agent_uuid}, Site: ${doc.site_uuid}, Down: ${doc.bandwidth_down}, Up: ${doc.bandwidth_up}, Flows: ${doc.active_flows}`);
}
// Find site-level summaries in last 24 hours
const twentyFourHoursAgo = new Date(Date.now() - 24 * 3600000);
const siteSummaries = await db.collection('summaries').find({
agent_uuid: null,
timestamp: { $gte: twentyFourHoursAgo }
}).toArray();
console.log('--- SITE-LEVEL SUMMARIES IN LAST 24 HOURS ---');
console.log('Count:', siteSummaries.length);
const massiveDocs = siteSummaries.filter(d => (d.bandwidth_down || 0) > 1e9);
console.log('Number of documents with Down > 1 GB:', massiveDocs.length);
for (const doc of massiveDocs.slice(0, 10)) {
console.log(`ID: ${doc._id}, Time: ${doc.timestamp}, Down: ${doc.bandwidth_down}`);
}
if (siteSummaries.length > 0) {
let sumDown = 0;
let sumUp = 0;
for (const doc of siteSummaries) {
sumDown += (doc.bandwidth_down || 0);
sumUp += (doc.bandwidth_up || 0);
}
console.log(`Total Download (Sum): ${sumDown} bytes (${(sumDown / 1e12).toFixed(2)} TB)`);
console.log(`Total Upload (Sum): ${sumUp} bytes (${(sumUp / 1e12).toFixed(2)} TB)`);
console.log(`Average Flows: ${siteSummaries[0].active_flows}`);
console.log('Sample Document from last 24 hours:');
console.log(JSON.stringify(siteSummaries[0], null, 2));
}
await mongoose.disconnect();
}
run().catch(console.error);
+31
View File
@@ -0,0 +1,31 @@
const mongoose = require('mongoose');
const dotenv = require('dotenv');
const path = require('path');
// Load environment variables
dotenv.config({ path: path.join(__dirname, '../.env.production') });
const { Threat, Event } = require('../backend/models/Schemas');
async function main() {
console.log('Connecting to database...');
await mongoose.connect(process.env.MONGODB_URI);
console.log('Connected!');
console.log('\n--- RAW THREAT DOCS ---');
const threats = await Threat.find().lean();
console.log(JSON.stringify(threats, null, 2));
console.log('\n--- RAW EVENTS (Cybersecurity / High severity) ---');
const events = await Event.find({
$or: [
{ severity: { $in: ['Critical', 'High'] } },
{ category_label: 'Cybersecurity' }
]
}).lean();
console.log(JSON.stringify(events, null, 2));
await mongoose.disconnect();
}
main().catch(console.error);
+28
View File
@@ -0,0 +1,28 @@
// test/middleware_verification_test.js
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const ROOT = path.join(__dirname, '..');
console.log('=== Running TDD Middleware Verification Test ===');
try {
// 1. Verify src/middleware.ts exists
const middlewarePath = path.join(ROOT, 'src', 'middleware.ts');
const exists = fs.existsSync(middlewarePath);
assert.ok(exists, 'src/middleware.ts must exist');
console.log('✓ src/middleware.ts exists');
// 2. Verify it exports 'middleware' function
const content = fs.readFileSync(middlewarePath, 'utf8');
assert.ok(content.includes('export function middleware'), 'src/middleware.ts must export function middleware');
assert.ok(!content.includes('export function proxy'), 'src/middleware.ts must NOT export function proxy');
console.log('✓ src/middleware.ts exports function middleware correctly');
console.log('=== ALL TESTS PASSED ===');
process.exit(0);
} catch (err) {
console.error('✗ TEST FAILED:', err.message);
process.exit(1);
}
+3 -8
View File
@@ -61,7 +61,7 @@ async function run() {
site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e',
brand_name: 'SIAB',
brand_logo: '/siab-logo.png',
footer_copyright: 'PT. SIAB Indonesia',
footer_copyright: 'PT. Data Bisnis Solusi',
primary_color: '#3B82F6',
},
{
@@ -74,13 +74,8 @@ async function run() {
];
for (const config of defaultConfigs) {
const existing = await TenantConfig.findOne({ site_uuid: config.site_uuid });
if (!existing) {
await TenantConfig.create(config);
console.log(`✓ Seeded TenantConfig for: ${config.brand_name}`);
} else {
console.log(`TenantConfig for ${config.brand_name} already exists`);
}
await TenantConfig.findOneAndUpdate({ site_uuid: config.site_uuid }, config, { upsert: true });
console.log(`✓ Seeded/Updated TenantConfig for: ${config.brand_name}`);
}
// Seed default agent locations
+29
View File
@@ -0,0 +1,29 @@
// test/sftp_manifest_test.js
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const ROOT = path.join(__dirname, '..');
console.log('=== Running TDD SFTP Manifest Verification Test ===');
try {
const deployScriptPath = path.join(ROOT, 'scripts', 'deploy-sftp.js');
const exists = fs.existsSync(deployScriptPath);
assert.ok(exists, 'scripts/deploy-sftp.js must exist');
const content = fs.readFileSync(deployScriptPath, 'utf8');
// Verify .next/static is in the upload manifest
assert.ok(
content.includes("local: '.next/static'") || content.includes('local: ".next/static"'),
'scripts/deploy-sftp.js must include local: \'.next/static\' in UPLOAD_MANIFEST'
);
console.log('✓ scripts/deploy-sftp.js includes .next/static in UPLOAD_MANIFEST');
console.log('=== ALL TESTS PASSED ===');
process.exit(0);
} catch (err) {
console.error('✗ TEST FAILED:', err.message);
process.exit(1);
}
+32
View File
@@ -0,0 +1,32 @@
const axios = require('axios');
async function main() {
console.log('Fetching /login HTML to find all asset paths...');
const res = await axios.get('https://demoplace.my.id/login');
const html = res.data;
// Extract all /_next/static/ paths
const matches = html.match(/\/(_next|public)\/[a-zA-Z0-9_\-\.\/]+/g) || [];
const uniquePaths = [...new Set(matches)];
console.log(`Found ${uniquePaths.length} unique asset paths to check:`);
let failed = 0;
for (const assetPath of uniquePaths) {
const url = `https://demoplace.my.id${assetPath}`;
try {
const assetRes = await axios.get(url);
console.log(`✓ [200 OK] ${assetPath} (${assetRes.headers['content-length'] || assetRes.data.length} bytes)`);
} catch (err) {
console.error(`✗ [FAIL] ${assetPath}: ${err.message}`);
failed++;
}
}
console.log(`\n=== Verification Complete: ${uniquePaths.length - failed} passed, ${failed} failed ===`);
if (failed > 0) {
process.exit(1);
}
}
main().catch(console.error);
+35
View File
@@ -0,0 +1,35 @@
// test/test-data-size-format.js
const assert = require('assert');
function formatDataSize(sizeMB) {
let displaySize = "";
if (sizeMB >= 1024 * 1024) {
displaySize = `${(sizeMB / (1024 * 1024)).toFixed(2)} TB`;
} else if (sizeMB >= 1024) {
displaySize = `${(sizeMB / 1024).toFixed(2)} GB`;
} else {
displaySize = `${sizeMB.toFixed(2)} MB`;
}
return displaySize;
}
try {
// Test case 1: Less than 1024 MB
assert.strictEqual(formatDataSize(0.38), "0.38 MB");
assert.strictEqual(formatDataSize(95.68), "95.68 MB");
assert.strictEqual(formatDataSize(999.99), "999.99 MB");
// Test case 2: Over 1024 MB (GB)
assert.strictEqual(formatDataSize(1024), "1.00 GB");
assert.strictEqual(formatDataSize(1536), "1.50 GB");
assert.strictEqual(formatDataSize(7654.67), "7.48 GB");
// Test case 3: Over 1024 * 1024 MB (TB)
assert.strictEqual(formatDataSize(1048576), "1.00 TB");
assert.strictEqual(formatDataSize(1572864), "1.50 TB");
console.log("✓ All Data Size formatting unit tests passed successfully!");
} catch (err) {
console.error("✗ Unit tests failed:", err.message);
process.exit(1);
}
+5 -5
View File
@@ -3,10 +3,10 @@ const net = require('net');
const { MongoClient } = require('mongodb');
const sshConfig = {
host: '103.185.47.52',
port: 2222,
username: 'adminbackend',
password: 'htEo7x6LsBQiEHHH',
host: process.env.SSH_HOST || '127.0.0.1',
port: parseInt(process.env.SSH_PORT || '2222'),
username: process.env.SSH_USER || 'adminbackend',
password: process.env.SSH_PASSWORD || '',
readyTimeout: 60000
};
@@ -36,7 +36,7 @@ conn.on('ready', () => {
server.listen(LOCAL_PORT, '127.0.0.1', async () => {
console.log(`SSH Tunnel established on port ${LOCAL_PORT}`);
try {
const REMOTE_URI = `mongodb://backone_user:SusuKudaLiar@127.0.0.1:${LOCAL_PORT}/backone_dpi?authSource=backone_dpi&directConnection=true`;
const REMOTE_URI = process.env.MONGODB_URI || `mongodb://backone_user:password@127.0.0.1:${LOCAL_PORT}/backone_dpi?authSource=backone_dpi&directConnection=true`;
const client = new MongoClient(REMOTE_URI, { serverSelectionTimeoutMS: 5000 });
await client.connect();
const db = client.db('backone_dpi');
+5 -5
View File
@@ -3,10 +3,10 @@ const net = require('net');
const { MongoClient } = require('mongodb');
const sshConfig = {
host: '103.185.47.52',
port: 2222,
username: 'adminbackend',
password: 'htEo7x6LsBQiEHHH',
host: process.env.SSH_HOST || '127.0.0.1',
port: parseInt(process.env.SSH_PORT || '2222'),
username: process.env.SSH_USER || 'adminbackend',
password: process.env.SSH_PASSWORD || '',
readyTimeout: 60000
};
@@ -36,7 +36,7 @@ conn.on('ready', () => {
server.listen(LOCAL_PORT, '127.0.0.1', async () => {
console.log(`SSH Tunnel established on port ${LOCAL_PORT}`);
try {
const REMOTE_URI = `mongodb://backone_user:SusuKudaLiar@127.0.0.1:${LOCAL_PORT}/backone_dpi?authSource=backone_dpi`;
const REMOTE_URI = process.env.MONGODB_URI || `mongodb://backone_user:password@127.0.0.1:${LOCAL_PORT}/backone_dpi?authSource=backone_dpi`;
const client = new MongoClient(REMOTE_URI, { serverSelectionTimeoutMS: 5000 });
await client.connect();
const db = client.db('backone_dpi');
+32
View File
@@ -0,0 +1,32 @@
const axios = require('axios');
async function main() {
console.log('Logging in to remote server...');
const loginRes = await axios.post('https://demoplace.my.id/api/auth/login', {
username: 'admin',
password: 'admin'
});
const cookie = loginRes.headers['set-cookie'];
const headers = {
Cookie: cookie ? cookie.join('; ') : ''
};
console.log('\n--- API: /api/dashboard/summary ---');
try {
const res = await axios.get('https://demoplace.my.id/api/dashboard/summary', { headers });
console.log(JSON.stringify(res.data, null, 2));
} catch (err) {
console.error(err.message);
}
console.log('\n--- API: /api/dashboard/timeline ---');
try {
const res = await axios.get('https://demoplace.my.id/api/dashboard/timeline?points=5', { headers });
console.log(JSON.stringify(res.data, null, 2));
} catch (err) {
console.error(err.message);
}
}
main().catch(console.error);
+32
View File
@@ -0,0 +1,32 @@
const axios = require('axios');
async function main() {
console.log('Logging in to remote server...');
const loginRes = await axios.post('https://demoplace.my.id/api/auth/login', {
username: 'admin',
password: 'admin'
});
const cookie = loginRes.headers['set-cookie'];
const headers = {
Cookie: cookie ? cookie.join('; ') : ''
};
console.log('\n--- API: /api/dashboard/threats?timeRange=1d ---');
try {
const res = await axios.get('https://demoplace.my.id/api/dashboard/threats?timeRange=1d', { headers });
console.log(JSON.stringify(res.data, null, 2));
} catch (err) {
console.error(err.message);
}
console.log('\n--- API: /api/dashboard/threats?timeRange=all ---');
try {
const res = await axios.get('https://demoplace.my.id/api/dashboard/threats?timeRange=all', { headers });
console.log(JSON.stringify(res.data, null, 2));
} catch (err) {
console.error(err.message);
}
}
main().catch(console.error);
+53
View File
@@ -0,0 +1,53 @@
// test/test-site-isolation.js
const axios = require('axios');
const assert = require('assert');
async function main() {
console.log('=== Running Site Isolation Integration Test ===');
console.log('1. Logging in as Nexus Tenant Admin...');
const loginRes = await axios.post('https://demoplace.my.id/api/auth/login', {
username: 'nexus',
password: 'nexus'
});
const cookie = loginRes.headers['set-cookie'];
const headers = { Cookie: cookie ? cookie.join('; ') : '' };
console.log('2. Fetching /api/dashboard/agents/uptime...');
const uptimeRes = await axios.get('https://demoplace.my.id/api/dashboard/agents/uptime?timeRange=1d', { headers });
assert.ok(uptimeRes.data.ok, 'Uptime response must be ok');
const uptimeKeys = Object.keys(uptimeRes.data.uptime);
console.log('Uptime keys returned:', uptimeKeys);
const siabAgentUuids = ['F6-2V-DT-8A', '2F-TF-1D-GK', 'YW-6I-61-LL', '8A-V3-PB-85', '1R-79-J9-YE'];
const nexusAgentUuids = ['1T-5Q-RC-AS', '2N-ID-VQ-AL'];
for (const uuid of uptimeKeys) {
assert.ok(!siabAgentUuids.includes(uuid), `Security violation: SIAB agent ${uuid} leaked to Nexus admin!`);
}
console.log('✓ No SIAB agent uptimes leaked to Nexus.');
console.log('3. Fetching /api/dashboard/agents/storage...');
const storageRes = await axios.get('https://demoplace.my.id/api/dashboard/agents/storage', { headers });
assert.ok(storageRes.data.ok, 'Storage response must be ok');
const storageKeys = Object.keys(storageRes.data.storage);
console.log('Storage keys returned:', storageKeys);
for (const uuid of storageKeys) {
assert.ok(!siabAgentUuids.includes(uuid), `Security violation: SIAB agent storage ${uuid} leaked to Nexus admin!`);
}
console.log('✓ No SIAB agent storage sizes leaked to Nexus.');
console.log('=== SITE ISOLATION TEST PASSED SUCCESSFULY ===');
}
main().catch(err => {
console.error('✗ TEST FAILED:', err.message);
if (err.response) {
console.error('Response data:', err.response.data);
}
process.exit(1);
});
+5 -5
View File
@@ -4,10 +4,10 @@ const net = require('net');
const { MongoClient } = require('mongodb');
const sshConfig = {
host: '103.185.47.52',
port: 2222,
username: 'adminbackend',
password: 'htEo7x6LsBQiEHHH',
host: process.env.SSH_HOST || '127.0.0.1',
port: parseInt(process.env.SSH_PORT || '2222'),
username: process.env.SSH_USER || 'adminbackend',
password: process.env.SSH_PASSWORD || '',
readyTimeout: 60000
};
@@ -52,7 +52,7 @@ conn.on('ready', () => {
async function runMigration() {
const LOCAL_URI = 'mongodb://127.0.0.1:27017';
const REMOTE_URI = `mongodb://backone_user:SusuKudaLiar@127.0.0.1:${LOCAL_PORT}/backone_dpi?authSource=backone_dpi`;
const REMOTE_URI = process.env.MONGODB_URI || `mongodb://backone_user:password@127.0.0.1:${LOCAL_PORT}/backone_dpi?authSource=backone_dpi`;
const DB_NAME = 'backone_dpi';
console.log("Connecting to local MongoDB...");