v1.1: Add CIDR subnet filtering and Agent filter in Devices page
This commit is contained in:
commit
966058e2fe
422 files changed
+54656
No files matched your search
@@ -0,0 +1,205 @@
|
||||
// backend/routes/auth/core.js
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const User = require('../../models/User');
|
||||
const { makeToken, setCookieToken, requireAuth, JWT_SECRET } = require('./helpers');
|
||||
|
||||
const { TenantConfig, CustomAgentLocation } = require('../../models/Schemas');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ─── Auto-seed database records if empty ──────────────────────────────────────
|
||||
const seedAuth = require('./seed');
|
||||
seedAuth();
|
||||
|
||||
// ─── POST /api/auth/login ─────────────────────────────────────────────────────
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password are required' });
|
||||
}
|
||||
|
||||
const user = await User.findOne({ username, is_active: true }).select('+password_hash');
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Username not found' });
|
||||
}
|
||||
|
||||
// Check if account is currently locked out
|
||||
if (user.lockout_until && user.lockout_until > new Date()) {
|
||||
const remainingTime = Math.ceil((user.lockout_until - new Date()) / 60000);
|
||||
return res.status(403).json({ error: `Account is temporarily locked. Please try again in ${remainingTime} minute(s).` });
|
||||
}
|
||||
|
||||
const isValid = bcrypt.compareSync(password, user.password_hash);
|
||||
if (!isValid) {
|
||||
user.login_attempts = (user.login_attempts || 0) + 1;
|
||||
if (user.login_attempts >= 3) {
|
||||
user.lockout_until = new Date(Date.now() + 15 * 60 * 1000); // 15 mins lockout
|
||||
await user.save();
|
||||
return res.status(403).json({ error: 'Account is temporarily locked. Please try again in 15 minute(s).' });
|
||||
} else {
|
||||
await user.save();
|
||||
return res.status(401).json({ error: 'Invalid Password' });
|
||||
}
|
||||
}
|
||||
|
||||
// Reset login attempts on successful login
|
||||
user.login_attempts = 0;
|
||||
user.lockout_until = null;
|
||||
await user.save();
|
||||
|
||||
// Create session in MongoDB
|
||||
const Session = require('../../models/Session');
|
||||
const crypto = require('crypto');
|
||||
const sessionToken = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 1); // 1 day session lifetime
|
||||
|
||||
const newSession = await Session.create({
|
||||
user_id: user._id,
|
||||
ip_address: req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'Unknown',
|
||||
user_agent: req.headers['user-agent'] || 'Unknown',
|
||||
session_token: sessionToken,
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
|
||||
const token = makeToken(user, newSession._id);
|
||||
setCookieToken(res, token);
|
||||
|
||||
res.json({
|
||||
message: 'Login successful',
|
||||
user: {
|
||||
id: user._id.toString(),
|
||||
username: user.username,
|
||||
account_name: user.account_name,
|
||||
profile_picture: user.profile_picture,
|
||||
role: user.role,
|
||||
site_uuid: user.site_uuid,
|
||||
agent_uuid: user.agent_uuid,
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── POST /api/auth/renew ─────────────────────────────────────────────────────
|
||||
router.post('/renew', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = await User.findById(req.user.id);
|
||||
if (!user) return res.status(404).json({ error: 'User tidak ditemukan' });
|
||||
|
||||
const sessionId = req.user.session_id;
|
||||
if (sessionId) {
|
||||
const Session = require('../../models/Session');
|
||||
const session = await Session.findById(sessionId);
|
||||
if (session) {
|
||||
// Extend session expires_at in MongoDB by another 24h
|
||||
session.expires_at = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
await session.save();
|
||||
}
|
||||
}
|
||||
|
||||
const token = makeToken(user, sessionId);
|
||||
setCookieToken(res, token);
|
||||
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
res.json({
|
||||
ok: true,
|
||||
message: 'Sesi berhasil diperpanjang',
|
||||
user: {
|
||||
id: user._id.toString(),
|
||||
username: user.username,
|
||||
account_name: user.account_name,
|
||||
profile_picture: user.profile_picture,
|
||||
role: user.role,
|
||||
site_uuid: user.site_uuid,
|
||||
agent_uuid: user.agent_uuid,
|
||||
iat: decoded.iat,
|
||||
exp: decoded.exp,
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/auth/me ─────────────────────────────────────────────────────────
|
||||
router.get('/me', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = await User.findById(req.user.id).lean();
|
||||
if (!user) return res.json({ user: req.user });
|
||||
|
||||
const isViewAs = req.user._viewAsMode;
|
||||
res.json({
|
||||
user: {
|
||||
id: user._id.toString(),
|
||||
username: user.username,
|
||||
account_name: user.account_name,
|
||||
profile_picture: user.profile_picture,
|
||||
role: isViewAs ? req.user.role : user.role,
|
||||
site_uuid: isViewAs ? req.user.site_uuid : user.site_uuid,
|
||||
agent_uuid: isViewAs ? req.user.agent_uuid : user.agent_uuid,
|
||||
agent_uuids: isViewAs ? req.user.agent_uuids : (user.agent_uuids || []),
|
||||
company_name: user.company_name || null,
|
||||
_isViewAsMode: isViewAs || false,
|
||||
_originalRole: isViewAs ? user.role : undefined,
|
||||
_viewAsLabel: isViewAs ? req.user.agent_label : undefined,
|
||||
iat: req.user.iat,
|
||||
exp: req.user.exp,
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ─── POST /api/auth/logout ────────────────────────────────────────────────────
|
||||
router.post('/logout', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const sessionId = req.user?.session_id;
|
||||
if (sessionId) {
|
||||
const Session = require('../../models/Session');
|
||||
await Session.findByIdAndDelete(sessionId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Logout] Session deletion failed:', err.message);
|
||||
}
|
||||
res.clearCookie('token');
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
});
|
||||
|
||||
// ─── GET /api/auth/geoip?ip=x.x.x.x ─────────────────────────────────────────
|
||||
router.get('/geoip', async (req, res) => {
|
||||
const ip = req.query.ip;
|
||||
if (!ip) return res.status(400).json({ error: 'IP is required' });
|
||||
|
||||
const parts = ip.split('.');
|
||||
if (parts.length === 4) {
|
||||
const [o1, o2] = parts.map(Number);
|
||||
if (o1 === 10 || (o1 === 192 && o2 === 168) || (o1 === 172 && o2 >= 16 && o2 <= 31) || o1 === 127 || (o1 === 169 && o2 === 254)) {
|
||||
return res.json({ ip_address: ip, isp: 'Intranet / Private Network', country: 'Local', city: 'Local', as_org: 'RFC 1918 Private Range' });
|
||||
}
|
||||
} else if (ip.startsWith('fe80:') || ip === '::1' || ip.startsWith('fd')) {
|
||||
return res.json({ ip_address: ip, isp: 'Intranet / Private Network', country: 'Local', city: 'Local', as_org: 'IPv6 Link-Local' });
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
const response = await fetch(`http://ip-api.com/json/${ip}`, { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
const geo = await response.json();
|
||||
|
||||
if (geo?.status === 'success') {
|
||||
return res.json({ ip_address: ip, isp: geo.isp || 'Unknown ISP', country: geo.country || 'Unknown', city: geo.city || 'Unknown', as_org: geo.as || geo.org || 'Unknown' });
|
||||
}
|
||||
} catch (e) { /* timeout or network error — fallback */ }
|
||||
|
||||
res.json({ ip_address: ip, isp: 'Public IP', country: 'Remote', city: 'Remote', as_org: 'Public Network' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,82 @@
|
||||
// backend/routes/auth/helpers.js
|
||||
const jwt = require('jsonwebtoken');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const { requireAuth, requireAdmin, JWT_SECRET } = require('../../middleware/auth');
|
||||
|
||||
function makeToken(user, sessionId) {
|
||||
return jwt.sign(
|
||||
{
|
||||
id: user._id.toString(),
|
||||
username: user.username,
|
||||
account_name: user.account_name,
|
||||
profile_picture: user.profile_picture,
|
||||
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,
|
||||
{ expiresIn: '1d' }
|
||||
);
|
||||
}
|
||||
|
||||
function setCookieToken(res, token) {
|
||||
res.cookie('token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
});
|
||||
}
|
||||
|
||||
function getUploadsDir() {
|
||||
if (fs.existsSync('/home/adminbackend/web/demoplace.my.id/public_html')) {
|
||||
return '/home/adminbackend/web/demoplace.my.id/public_html/api/uploads';
|
||||
} else {
|
||||
return path.join(__dirname, '..', '..', 'public', 'api', 'uploads');
|
||||
}
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const dir = getUploadsDir();
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
||||
cb(null, `profile-${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// File filter — only allow image formats for profile picture uploads
|
||||
function imageFileFilter(req, file, cb) {
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||
if (allowedMimeTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type. Only JPEG, PNG, and WebP images are allowed.'), false);
|
||||
}
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter: imageFileFilter,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024, // 5 MB maximum per profile picture
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
JWT_SECRET,
|
||||
makeToken,
|
||||
setCookieToken,
|
||||
requireAuth,
|
||||
requireAdmin,
|
||||
upload,
|
||||
getUploadsDir
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
// backend/routes/auth/seed.js
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Seeding logic for default roles, site configs, and agent locations
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const bcrypt = require('bcryptjs');
|
||||
const User = require('../../models/User');
|
||||
const { TenantConfig, CustomAgentLocation } = require('../../models/Schemas');
|
||||
|
||||
async function seedAuth() {
|
||||
try {
|
||||
const count = await User.countDocuments({ role: 'SUPER_ADMIN' });
|
||||
if (count === 0) {
|
||||
const hash = bcrypt.hashSync('admin', 10);
|
||||
await User.create({
|
||||
username: 'admin',
|
||||
password_hash: hash,
|
||||
account_name: 'BackOne Administrator',
|
||||
role: 'SUPER_ADMIN',
|
||||
site_uuid: process.env.BACKONE_SITE_UUID || null,
|
||||
agent_uuid: null,
|
||||
});
|
||||
console.log('[Auth] ✓ Default SUPER_ADMIN created: admin / admin');
|
||||
console.log('[Auth] ⚠ GANTI PASSWORD INI SEGERA DI PRODUCTION!');
|
||||
}
|
||||
|
||||
const siabCount = await User.countDocuments({ username: 'siab' });
|
||||
if (siabCount === 0) {
|
||||
const hash = bcrypt.hashSync('siab', 10);
|
||||
await User.create({
|
||||
username: 'siab',
|
||||
password_hash: hash,
|
||||
account_name: 'SIAB Administrator',
|
||||
role: 'TENANT_ADMIN',
|
||||
site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e',
|
||||
agent_uuid: null,
|
||||
});
|
||||
console.log('[Auth] ✓ Default SIAB Tenant created: siab / siab');
|
||||
}
|
||||
|
||||
const officeCount = await User.countDocuments({ username: 'office' });
|
||||
if (officeCount === 0) {
|
||||
const hash = bcrypt.hashSync('office', 10);
|
||||
await User.create({
|
||||
username: 'office',
|
||||
password_hash: hash,
|
||||
account_name: 'Office Administrator',
|
||||
role: 'TENANT_ADMIN',
|
||||
site_uuid: '1959bb55_045b_47c7_bbdd_f33b7db197b9',
|
||||
agent_uuid: null,
|
||||
});
|
||||
console.log('[Auth] ✓ Default Office Tenant created: office / office');
|
||||
}
|
||||
|
||||
// Repair/Migration: Ensure legacy users have appropriate created_by values
|
||||
try {
|
||||
const missingCreatedBy = await User.find({ $or: [{ created_by: { $exists: false } }, { created_by: null }] });
|
||||
if (missingCreatedBy.length > 0) {
|
||||
console.log(`[Auth] Migrating ${missingCreatedBy.length} legacy users to set created_by...`);
|
||||
for (const u of missingCreatedBy) {
|
||||
if (u.username === 'admin') {
|
||||
u.created_by = 'admin';
|
||||
} else if (u.site_uuid === '6681452d_9cae_4ff4_8ae8_0d504774265e') {
|
||||
u.created_by = 'siab';
|
||||
} else if (u.site_uuid === '1959bb55_045b_47c7_bbdd_f33b7db197b9') {
|
||||
u.created_by = 'office';
|
||||
} else {
|
||||
u.created_by = 'admin';
|
||||
}
|
||||
await u.save();
|
||||
}
|
||||
console.log(`[Auth] Migration complete.`);
|
||||
}
|
||||
} catch (migrateErr) {
|
||||
console.error('[Auth] Migration failed:', migrateErr.message);
|
||||
}
|
||||
|
||||
const defaultConfigs = [
|
||||
{
|
||||
site_uuid: 'default',
|
||||
brand_name: 'BackOne',
|
||||
brand_logo: '/backone-logo.png',
|
||||
footer_copyright: 'PT. Data Bisnis Solusi',
|
||||
primary_color: '#E11D48',
|
||||
},
|
||||
{
|
||||
site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e',
|
||||
brand_name: 'SIAB',
|
||||
brand_logo: '/siab-logo.png',
|
||||
footer_copyright: 'PT. Data Bisnis Solusi',
|
||||
primary_color: '#3B82F6',
|
||||
},
|
||||
{
|
||||
site_uuid: '1959bb55_045b_47c7_bbdd_f33b7db197b9',
|
||||
brand_name: 'Office',
|
||||
brand_logo: '/backone-logo.png',
|
||||
footer_copyright: 'PT. Data Bisnis Solusi',
|
||||
primary_color: '#E11D48',
|
||||
}
|
||||
];
|
||||
|
||||
for (const config of defaultConfigs) {
|
||||
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
|
||||
const defaultLocations = [
|
||||
{
|
||||
agent_uuid: 'F6-2V-DT-8A',
|
||||
site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e',
|
||||
latitude: -6.2263304,
|
||||
longitude: 106.4247322,
|
||||
label: 'CPI Balaraja Agent Office'
|
||||
},
|
||||
{
|
||||
agent_uuid: '2F-TF-1D-GK',
|
||||
site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e',
|
||||
latitude: -6.3763318,
|
||||
longitude: 106.8983017,
|
||||
label: 'JRP Cibubur Agent Office'
|
||||
},
|
||||
{
|
||||
agent_uuid: '8A-V3-PB-85',
|
||||
site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e',
|
||||
latitude: -6.2253265,
|
||||
longitude: 106.8061484,
|
||||
label: 'IFG LT.18 Agent HQ'
|
||||
}
|
||||
];
|
||||
|
||||
for (const loc of defaultLocations) {
|
||||
const existing = await CustomAgentLocation.findOne({ agent_uuid: loc.agent_uuid });
|
||||
if (!existing) {
|
||||
await CustomAgentLocation.create(loc);
|
||||
console.log(`[Auth] ✓ Seeded CustomAgentLocation for: ${loc.agent_uuid}`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.warn('[Auth] Seed skipped (MongoDB not ready yet):', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = seedAuth;
|
||||
@@ -0,0 +1,126 @@
|
||||
// backend/routes/auth/sessions.js
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// User Session Management Routes (Active Sessions & Remote Revocation)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const User = require('../../models/User');
|
||||
const Session = require('../../models/Session');
|
||||
const { requireAuth, requireAdmin } = require('./helpers');
|
||||
|
||||
// Helper to block SOC_ANALYST from write actions
|
||||
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' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// ─── GET /api/auth/sessions (Current User Sessions) ──────────────────────────
|
||||
router.get('/sessions', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const sessions = await Session.find({ user_id: req.user.id }).sort({ last_active: -1 });
|
||||
|
||||
const data = sessions.map(s => ({
|
||||
id: s._id.toString(),
|
||||
ip_address: s.ip_address,
|
||||
user_agent: s.user_agent,
|
||||
last_active: s.last_active,
|
||||
created_at: s.created_at,
|
||||
is_current: req.user.session_id === s._id.toString(),
|
||||
}));
|
||||
|
||||
res.json({ ok: true, data });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── DELETE /api/auth/sessions/:id (Revoke Current User Session) ──────────────
|
||||
router.delete('/sessions/:id', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const session = await Session.findById(req.params.id);
|
||||
if (!session) {
|
||||
return res.status(404).json({ ok: false, error: 'Sesi tidak ditemukan' });
|
||||
}
|
||||
|
||||
// Users can only revoke their own sessions
|
||||
if (session.user_id.toString() !== req.user.id) {
|
||||
return res.status(403).json({ ok: false, error: 'Aksi dilarang' });
|
||||
}
|
||||
|
||||
await Session.findByIdAndDelete(req.params.id);
|
||||
|
||||
// Clear cookies if the user revokes their own current session
|
||||
if (req.user.session_id === req.params.id) {
|
||||
res.clearCookie('token');
|
||||
}
|
||||
|
||||
res.json({ ok: true, message: 'Sesi berhasil diakhiri' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /api/auth/admin/sessions (Tenant/All Sessions) ──────────────────────
|
||||
router.get('/admin/sessions', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
let userQuery = {};
|
||||
if (req.adminUser.role === 'TENANT_ADMIN') {
|
||||
userQuery = { site_uuid: req.adminUser.site_uuid };
|
||||
}
|
||||
|
||||
const users = await User.find(userQuery, 'username role account_name site_uuid');
|
||||
const userIds = users.map(u => u._id);
|
||||
|
||||
const sessions = await Session.find({ user_id: { $in: userIds } })
|
||||
.populate('user_id', 'username role account_name site_uuid')
|
||||
.sort({ last_active: -1 });
|
||||
|
||||
const data = sessions.map(s => {
|
||||
const u = s.user_id || {};
|
||||
return {
|
||||
id: s._id.toString(),
|
||||
username: u.username || 'Unknown',
|
||||
role: u.role || 'Unknown',
|
||||
account_name: u.account_name || 'Unknown',
|
||||
site_uuid: u.site_uuid || null,
|
||||
ip_address: s.ip_address,
|
||||
user_agent: s.user_agent,
|
||||
last_active: s.last_active,
|
||||
created_at: s.created_at,
|
||||
is_current: req.adminUser.session_id === s._id.toString(),
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ ok: true, data });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── DELETE /api/auth/admin/sessions/:id (Revoke Any Session by Admin) ───────
|
||||
router.delete('/admin/sessions/:id', requireAdmin, blockAnalyst, async (req, res) => {
|
||||
try {
|
||||
const session = await Session.findById(req.params.id).populate('user_id');
|
||||
if (!session) {
|
||||
return res.status(404).json({ ok: false, error: 'Sesi tidak ditemukan' });
|
||||
}
|
||||
|
||||
// Tenant Admin can only revoke sessions within their own site
|
||||
if (req.adminUser.role !== 'SUPER_ADMIN') {
|
||||
const sessionUser = session.user_id || {};
|
||||
if (sessionUser.site_uuid !== req.adminUser.site_uuid) {
|
||||
return res.status(403).json({ ok: false, error: 'Unauthorized: Sesi berada di luar site Anda' });
|
||||
}
|
||||
}
|
||||
|
||||
await Session.findByIdAndDelete(req.params.id);
|
||||
res.json({ ok: true, message: 'Sesi berhasil diakhiri oleh administrator' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,187 @@
|
||||
// backend/routes/auth/settings.js
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const User = require('../../models/User');
|
||||
const { requireAuth, makeToken, setCookieToken, upload, getUploadsDir } = require('./helpers');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ─── POST /api/auth/change-password ──────────────────────────────────────────
|
||||
router.post('/change-password', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ error: 'Current password and new password are required' });
|
||||
}
|
||||
|
||||
const user = await User.findById(req.user.id).select('+password_hash');
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
if (!bcrypt.compareSync(currentPassword, user.password_hash)) {
|
||||
return res.status(400).json({ error: 'Password saat ini salah' });
|
||||
}
|
||||
|
||||
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{6,}$/;
|
||||
if (!passwordRegex.test(newPassword)) {
|
||||
return res.status(400).json({ error: 'Password baru tidak memenuhi kriteria: minimal 6 karakter, serta mengandung huruf besar, huruf kecil, dan angka.' });
|
||||
}
|
||||
|
||||
user.password_hash = bcrypt.hashSync(newPassword, 10);
|
||||
await user.save();
|
||||
|
||||
res.json({ ok: true, message: 'Password berhasil diubah!' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── POST /api/auth/change-username ──────────────────────────────────────────
|
||||
router.post('/change-username', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { currentPassword, newUsername } = req.body;
|
||||
if (!currentPassword || !newUsername) {
|
||||
return res.status(400).json({ error: 'Current password and new username are required' });
|
||||
}
|
||||
if (newUsername.length < 4 || /[^a-zA-Z0-9_]/.test(newUsername)) {
|
||||
return res.status(400).json({ error: 'Username baru tidak valid (minimal 4 karakter, hanya huruf, angka, dan underscore).' });
|
||||
}
|
||||
|
||||
const user = await User.findById(req.user.id).select('+password_hash');
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
if (!bcrypt.compareSync(currentPassword, user.password_hash)) {
|
||||
return res.status(400).json({ error: 'Password saat ini salah' });
|
||||
}
|
||||
|
||||
const existing = await User.findOne({ username: newUsername });
|
||||
if (existing) return res.status(400).json({ error: 'Username sudah digunakan oleh akun lain' });
|
||||
|
||||
user.username = newUsername;
|
||||
await user.save();
|
||||
|
||||
const newToken = makeToken(user);
|
||||
setCookieToken(res, newToken);
|
||||
|
||||
res.json({ ok: true, message: 'Username berhasil diubah!', newUsername });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── POST /api/auth/change-account-name ──────────────────────────────────────
|
||||
router.post('/change-account-name', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { currentPassword, newAccountName } = req.body;
|
||||
if (!currentPassword || newAccountName == null) {
|
||||
return res.status(400).json({ error: 'Current password and new account name are required' });
|
||||
}
|
||||
if (!newAccountName.trim()) {
|
||||
return res.status(400).json({ error: 'Nama akun tidak boleh kosong' });
|
||||
}
|
||||
|
||||
const user = await User.findById(req.user.id).select('+password_hash');
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
if (!bcrypt.compareSync(currentPassword, user.password_hash)) {
|
||||
return res.status(400).json({ error: 'Password saat ini salah' });
|
||||
}
|
||||
|
||||
user.account_name = newAccountName.trim();
|
||||
await user.save();
|
||||
|
||||
const newToken = makeToken(user);
|
||||
setCookieToken(res, newToken);
|
||||
|
||||
res.json({ ok: true, message: 'Nama akun berhasil diubah!', newAccountName: user.account_name });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── POST /api/auth/upload-profile-picture ───────────────────────────────────
|
||||
// Menerima JSON: { profile_picture_base64: "data:image/png;base64,...", user_id? }
|
||||
// Menghindari multipart/form-data yang bermasalah melalui Apache proxy layer
|
||||
router.post('/upload-profile-picture', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { profile_picture_base64, user_id } = req.body;
|
||||
|
||||
if (!profile_picture_base64) {
|
||||
return res.status(400).json({ error: 'No image data provided. Please select an image file first.' });
|
||||
}
|
||||
|
||||
// Validasi format base64 data URL
|
||||
const matches = profile_picture_base64.match(/^data:image\/(png|jpg|jpeg|gif|webp);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
return res.status(400).json({ error: 'Invalid image format. Only PNG, JPG, GIF, WEBP are allowed.' });
|
||||
}
|
||||
|
||||
const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1];
|
||||
const base64Data = matches[2];
|
||||
|
||||
// Validasi ukuran (max 5MB uncompressed)
|
||||
const fileSizeBytes = Buffer.byteLength(base64Data, 'base64');
|
||||
if (fileSizeBytes > 5 * 1024 * 1024) {
|
||||
return res.status(400).json({ error: 'Image too large. Maximum size is 5MB.' });
|
||||
}
|
||||
|
||||
// Tentukan target user (self atau admin update user lain)
|
||||
const targetId = user_id || req.user.id;
|
||||
const user = await User.findById(targetId);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
// Hapus foto profil lama jika ada
|
||||
if (user.profile_picture) {
|
||||
const oldPath = path.join(getUploadsDir(), user.profile_picture);
|
||||
if (fs.existsSync(oldPath)) {
|
||||
try { fs.unlinkSync(oldPath); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Simpan file baru
|
||||
const filename = `profile-${targetId}-${Date.now()}.${ext}`;
|
||||
const filePath = path.join(getUploadsDir(), filename);
|
||||
fs.writeFileSync(filePath, base64Data, 'base64');
|
||||
|
||||
user.profile_picture = filename;
|
||||
await user.save();
|
||||
|
||||
// Perbarui token hanya jika user mengupdate foto dirinya sendiri
|
||||
if (String(targetId) === String(req.user.id)) {
|
||||
const newToken = makeToken(user);
|
||||
setCookieToken(res, newToken);
|
||||
}
|
||||
|
||||
res.json({ ok: true, message: 'Profile picture updated successfully.', profile_picture: filename });
|
||||
} catch (err) {
|
||||
console.error('[Upload Error]', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ─── POST /api/auth/remove-profile-picture ───────────────────────────────────
|
||||
router.post('/remove-profile-picture', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = await User.findById(req.user.id);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
if (user.profile_picture) {
|
||||
const filePath = path.join(getUploadsDir(), user.profile_picture);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
}
|
||||
|
||||
user.profile_picture = null;
|
||||
await user.save();
|
||||
|
||||
const newToken = makeToken(user);
|
||||
setCookieToken(res, newToken);
|
||||
|
||||
res.json({ ok: true, message: 'Foto profil berhasil dihapus' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,197 @@
|
||||
// backend/routes/auth/users.js
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const User = require('../../models/User');
|
||||
const { requireAdmin, upload } = require('./helpers');
|
||||
const { blockAnalyst, resolveSiteUuidForAgent, mapUserData } = require('./usersHelper');
|
||||
const { handleCreateExternalUser } = require('./usersCreateExternal');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/auth/admin/users — daftar semua users (admin & analyst)
|
||||
router.get('/admin/users', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
let query = {};
|
||||
if (req.adminUser.role === 'TENANT_ADMIN') {
|
||||
query = {
|
||||
$or: [
|
||||
{ role: 'AGENT_VIEWER', site_uuid: req.adminUser.site_uuid },
|
||||
{ created_by: req.adminUser.username }
|
||||
]
|
||||
};
|
||||
} else if (req.adminUser.role === 'COMPANY_ADMIN') {
|
||||
query = { company_name: req.adminUser.company_name };
|
||||
const users = await User.find(query, '-password_hash').sort({ created_at: 1 });
|
||||
return res.json({ ok: true, data: users.map(mapUserData) });
|
||||
}
|
||||
query.username = { $ne: req.adminUser.username };
|
||||
const users = await User.find(query, '-password_hash').sort({ created_at: 1 });
|
||||
res.json({ ok: true, data: users.map(mapUserData) });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/admin/unlock-user — unlock akun yang terkunci
|
||||
router.post('/admin/unlock-user', requireAdmin, blockAnalyst, async (req, res) => {
|
||||
try {
|
||||
const { user_id } = req.body;
|
||||
if (!user_id) return res.status(400).json({ ok: false, error: 'user_id wajib diisi' });
|
||||
|
||||
const target = await User.findById(user_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) {
|
||||
return res.status(403).json({ ok: false, error: 'Unauthorized: Account does not belong to your tenant.' });
|
||||
}
|
||||
|
||||
target.login_attempts = 0;
|
||||
target.lockout_until = null;
|
||||
await target.save();
|
||||
|
||||
res.json({ ok: true, message: 'Akun berhasil di-unlock' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/admin/create-agent-user — buat akun Network Agent baru
|
||||
router.post('/admin/create-agent-user', requireAdmin, blockAnalyst, async (req, res) => {
|
||||
try {
|
||||
const { username, password, account_name, agent_uuid } = req.body;
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ ok: false, error: 'Username dan password wajib diisi' });
|
||||
}
|
||||
const passwordHash = bcrypt.hashSync(password, 10);
|
||||
const siteUuid = await resolveSiteUuidForAgent(agent_uuid, null, req.adminUser, req.body.site_uuid);
|
||||
|
||||
const newUser = await User.create({
|
||||
username: username.trim(),
|
||||
password_hash: passwordHash,
|
||||
account_name: account_name?.trim() || null,
|
||||
agent_uuid: agent_uuid?.trim() || null,
|
||||
role: 'AGENT_VIEWER',
|
||||
site_uuid: siteUuid,
|
||||
created_by: req.adminUser.username,
|
||||
});
|
||||
|
||||
res.json({ ok: true, message: 'Akun Network Agent berhasil dibuat', userId: newUser._id.toString() });
|
||||
} catch (err) {
|
||||
const msg = err.code === 11000 ? 'Username sudah digunakan' : err.message;
|
||||
res.status(400).json({ ok: false, error: msg });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/admin/update-agent-user — update akun Network Agent / Company User
|
||||
router.post('/admin/update-agent-user', requireAdmin, blockAnalyst, async (req, res) => {
|
||||
try {
|
||||
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 === '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.' });
|
||||
}
|
||||
|
||||
if (username?.trim()) {
|
||||
const existing = await User.findOne({ username: username.trim(), _id: { $ne: user_id } });
|
||||
if (existing) return res.status(400).json({ ok: false, error: 'Username sudah digunakan' });
|
||||
target.username = username.trim();
|
||||
}
|
||||
if (password) target.password_hash = bcrypt.hashSync(password, 10);
|
||||
if (account_name != null) target.account_name = account_name?.trim() || null;
|
||||
|
||||
if (company_name !== undefined && req.adminUser.role === 'SUPER_ADMIN') {
|
||||
target.company_name = company_name?.trim() || null;
|
||||
}
|
||||
|
||||
if (agent_uuids != null) {
|
||||
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()) {
|
||||
target.site_uuid = await resolveSiteUuidForAgent(agent_uuid, target.site_uuid, req.adminUser, req.body.site_uuid);
|
||||
}
|
||||
}
|
||||
if (req.file) target.profile_picture = req.file.filename;
|
||||
|
||||
await target.save();
|
||||
const updated = await User.findById(user_id, '-password_hash');
|
||||
res.json({ ok: true, message: 'Akun berhasil diperbarui', user: updated });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/auth/admin/delete-agent-user/:id — hapus akun Network Agent
|
||||
router.delete('/admin/delete-agent-user/:id', requireAdmin, blockAnalyst, async (req, res) => {
|
||||
try {
|
||||
const target = await User.findById(req.params.id);
|
||||
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 === '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);
|
||||
res.json({ ok: true, message: 'Akun berhasil dihapus' });
|
||||
} catch (err) {
|
||||
res.status(400).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/admin/upload-agent-picture/:id — upload foto profil agent oleh admin
|
||||
router.post('/admin/upload-agent-picture/:id', requireAdmin, blockAnalyst, upload.single('profile_picture'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ ok: false, error: 'File gambar wajib diupload' });
|
||||
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 === '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;
|
||||
await target.save();
|
||||
res.json({ ok: true, message: 'Foto profil berhasil diperbarui', filename: req.file.filename });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/admin/create-external-user — buat akun Eksternal (SOC Analyst, Engineer, dll)
|
||||
router.post('/admin/create-external-user', requireAdmin, blockAnalyst, handleCreateExternalUser);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,86 @@
|
||||
// backend/routes/auth/usersCreateExternal.js
|
||||
const bcrypt = require('bcryptjs');
|
||||
const User = require('../../models/User');
|
||||
|
||||
async function handleCreateExternalUser(req, res) {
|
||||
try {
|
||||
const { username, password, account_name, role, company_name } = req.body;
|
||||
let agent_uuids = [];
|
||||
if (req.body.agent_uuids) {
|
||||
agent_uuids = Array.isArray(req.body.agent_uuids)
|
||||
? req.body.agent_uuids
|
||||
: (() => { try { return JSON.parse(req.body.agent_uuids); } catch { return [req.body.agent_uuids]; } })();
|
||||
}
|
||||
|
||||
if (!username || !password || !role) {
|
||||
return res.status(400).json({ ok: false, error: 'Username, password, dan role wajib diisi' });
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
const targetCompanyName = req.adminUser.role === 'COMPANY_ADMIN'
|
||||
? req.adminUser.company_name
|
||||
: (company_name?.trim() || null);
|
||||
|
||||
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.` });
|
||||
}
|
||||
}
|
||||
|
||||
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 = (role === 'EXECUTIVE' || role === 'COMPANY_ADMIN')
|
||||
? null
|
||||
: req.adminUser.role === 'SUPER_ADMIN'
|
||||
? (req.body.site_uuid || process.env.BACKONE_SITE_UUID || null)
|
||||
: req.adminUser.site_uuid;
|
||||
|
||||
const createdBy = req.adminUser.role === 'SUPER_ADMIN'
|
||||
? (req.body.created_by || req.adminUser.username)
|
||||
: req.adminUser.username;
|
||||
|
||||
const newUser = await User.create({
|
||||
username: username.trim(),
|
||||
password_hash: passwordHash,
|
||||
account_name: account_name?.trim() || null,
|
||||
role: role,
|
||||
site_uuid: siteUuid,
|
||||
company_name: targetCompanyName,
|
||||
agent_uuids: agent_uuids,
|
||||
created_by: createdBy,
|
||||
profile_picture: null
|
||||
});
|
||||
|
||||
res.json({ ok: true, message: 'Akun eksternal berhasil dibuat', userId: newUser._id.toString() });
|
||||
} catch (err) {
|
||||
const msg = err.code === 11000 ? 'Username sudah digunakan' : err.message;
|
||||
res.status(400).json({ ok: false, error: msg });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleCreateExternalUser };
|
||||
@@ -0,0 +1,54 @@
|
||||
// backend/routes/auth/usersHelper.js
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// User management helper logic & site UUID resolver (BackOne API compliant)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const { Summary } = require('../../models/Schemas');
|
||||
|
||||
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' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
async function resolveSiteUuidForAgent(agentUuid, fallbackSiteUuid, adminUser, bodySiteUuid) {
|
||||
let siteUuid = null;
|
||||
if (agentUuid) {
|
||||
const summaryDoc = await Summary.findOne({ agent_uuid: agentUuid.trim() });
|
||||
if (summaryDoc) {
|
||||
siteUuid = summaryDoc.site_uuid;
|
||||
}
|
||||
}
|
||||
|
||||
if (!siteUuid) {
|
||||
siteUuid = adminUser.role === 'SUPER_ADMIN'
|
||||
? (bodySiteUuid || process.env.BACKONE_SITE_UUID || fallbackSiteUuid || null)
|
||||
: adminUser.site_uuid;
|
||||
}
|
||||
|
||||
return siteUuid;
|
||||
}
|
||||
|
||||
function mapUserData(user) {
|
||||
return {
|
||||
id: user._id.toString(),
|
||||
username: user.username,
|
||||
account_name: user.account_name,
|
||||
profile_picture: user.profile_picture,
|
||||
role: user.role,
|
||||
site_uuid: user.site_uuid,
|
||||
agent_uuid: user.agent_uuid,
|
||||
company_name: user.company_name,
|
||||
agent_uuids: user.agent_uuids || [],
|
||||
is_active: user.is_active,
|
||||
login_attempts: user.login_attempts || 0,
|
||||
lockout_until: user.lockout_until || null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
blockAnalyst,
|
||||
resolveSiteUuidForAgent,
|
||||
mapUserData,
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
// backend/routes/auth/viewAs.js
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const mongoose = require('mongoose');
|
||||
const { requireAdmin, requireAuth, JWT_SECRET } = require('./helpers');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 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' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// POST /api/auth/admin/view-as — admin masuk mode "lihat sebagai user/agent"
|
||||
router.post('/admin/view-as', requireAdmin, blockAnalyst, async (req, res) => {
|
||||
const { agent_uuid, agent_label, target_user_id, target_username, target_role } = req.body;
|
||||
if (!agent_uuid) return res.status(400).json({ ok: false, error: 'agent_uuid wajib diisi' });
|
||||
|
||||
try {
|
||||
const ViewAsLog = mongoose.model('ViewAsLog');
|
||||
const User = mongoose.model('User');
|
||||
|
||||
let targetUserDoc = null;
|
||||
if (target_user_id) {
|
||||
targetUserDoc = await User.findById(target_user_id).lean();
|
||||
} else if (target_username) {
|
||||
targetUserDoc = await User.findOne({ username: target_username }).lean();
|
||||
}
|
||||
|
||||
const payload = {
|
||||
adminId: req.adminUser.id,
|
||||
adminUsername: req.adminUser.username,
|
||||
viewAs: agent_uuid,
|
||||
viewAsLabel: agent_label || agent_uuid,
|
||||
target_user_id: targetUserDoc ? (targetUserDoc.id || targetUserDoc._id) : (target_user_id || null),
|
||||
target_username: targetUserDoc ? targetUserDoc.username : (target_username || null),
|
||||
target_role: targetUserDoc ? targetUserDoc.role : (target_role || null),
|
||||
type: 'view-as'
|
||||
};
|
||||
|
||||
const viewToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '8h' });
|
||||
|
||||
// Simpan log audit lengkap ke MongoDB
|
||||
await new ViewAsLog({
|
||||
admin_id: req.adminUser.id,
|
||||
admin_username: req.adminUser.username,
|
||||
admin_role: req.adminUser.role,
|
||||
target_user_id: payload.target_user_id,
|
||||
target_username: payload.target_username,
|
||||
target_role: payload.target_role,
|
||||
agent_uuid,
|
||||
agent_label: agent_label || agent_uuid
|
||||
}).save();
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
message: `Sekarang melihat sebagai ${payload.target_username || agent_label || agent_uuid}`,
|
||||
view_token: viewToken,
|
||||
agent_uuid,
|
||||
agent_label: agent_label || agent_uuid,
|
||||
target_user_id: payload.target_user_id,
|
||||
target_username: payload.target_username,
|
||||
target_role: payload.target_role
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auth/admin/view-as/logs — ambil riwayat audit view-as
|
||||
router.get('/admin/view-as/logs', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const ViewAsLog = mongoose.model('ViewAsLog');
|
||||
|
||||
// Role-based visibility logic:
|
||||
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();
|
||||
res.json({ ok: true, data: logs });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/auth/admin/view-as — keluar dari mode view-as
|
||||
router.delete('/admin/view-as', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const ViewAsLog = mongoose.model('ViewAsLog');
|
||||
const latestLog = await ViewAsLog.findOne({
|
||||
admin_id: req.adminUser.id,
|
||||
end_timestamp: { $exists: false }
|
||||
}).sort({ timestamp: -1 });
|
||||
|
||||
if (latestLog) {
|
||||
latestLog.end_timestamp = new Date();
|
||||
const diffMs = latestLog.end_timestamp.getTime() - latestLog.timestamp.getTime();
|
||||
latestLog.duration = Math.round(diffMs / 1000); // durasi dalam detik
|
||||
await latestLog.save();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Gagal menyimpan durasi sesi view-as:", err.message);
|
||||
}
|
||||
|
||||
res.clearCookie('view_as_token');
|
||||
res.json({ ok: true, message: 'Kembali ke tampilan admin' });
|
||||
});
|
||||
|
||||
// GET /api/auth/view-as — cek status view-as
|
||||
router.get('/view-as', requireAuth, (req, res) => {
|
||||
const viewToken = req.cookies?.view_as_token;
|
||||
if (!viewToken) return res.json({ active: false });
|
||||
try {
|
||||
const decoded = jwt.verify(viewToken, JWT_SECRET);
|
||||
res.json({ active: true, agent_uuid: decoded.viewAs, agent_label: decoded.viewAsLabel });
|
||||
} catch {
|
||||
res.clearCookie('view_as_token');
|
||||
res.json({ active: false });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in new issue
Block a user