Admin Dashboard Backend (Phases 1-4) #5
197
routes/admin.js
197
routes/admin.js
@ -196,17 +196,36 @@ router.patch('/users/:id/role', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Invalid role. Must be "user" or "admin"' });
|
return res.status(400).json({ error: 'Invalid role. Must be "user" or "admin"' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if admin is trying to demote themselves
|
|
||||||
if (req.user._id.toString() === id && role === 'user') {
|
|
||||||
return res.status(400).json({ error: 'Cannot demote yourself from admin' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user exists
|
// Check if user exists
|
||||||
const existingUser = await findById(db, id);
|
const existingUser = await findById(db, id);
|
||||||
if (!existingUser) {
|
if (!existingUser) {
|
||||||
return res.status(404).json({ error: 'User not found' });
|
return res.status(404).json({ error: 'User not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this would demote the last admin (before self-demotion check)
|
||||||
|
// This is the more specific error when both conditions are true
|
||||||
|
if (existingUser.role === 'admin' && role === 'user') {
|
||||||
|
// Count active admins
|
||||||
|
const activeAdminCount = await db.collection('users').countDocuments({
|
||||||
|
role: 'admin',
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeAdminCount <= 1) {
|
||||||
|
// Customize message if admin is demoting themselves AND they're the last admin
|
||||||
|
const isSelfDemotion = req.user._id.toString() === id;
|
||||||
|
const errorMsg = isSelfDemotion
|
||||||
|
? 'Cannot demote yourself. You are the last admin and at least one admin must exist.'
|
||||||
|
: 'Cannot demote the last admin. At least one admin must exist.';
|
||||||
|
return res.status(400).json({ error: errorMsg });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if admin is trying to demote themselves (only reached if not last admin)
|
||||||
|
if (req.user._id.toString() === id && role === 'user') {
|
||||||
|
return res.status(400).json({ error: 'Cannot demote yourself from admin' });
|
||||||
|
}
|
||||||
|
|
||||||
// Update user role
|
// Update user role
|
||||||
const updatedUser = await updateUserRole(db, id, role);
|
const updatedUser = await updateUserRole(db, id, role);
|
||||||
|
|
||||||
@ -943,5 +962,173 @@ router.get('/stats/peak-times', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Settings Endpoints (Phase 4)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const SETTINGS_COLLECTION = 'settings';
|
||||||
|
const SETTINGS_DOC_ID = 'admin_settings';
|
||||||
|
const DEFAULT_RETENTION_DAYS = 90;
|
||||||
|
const DEFAULT_LOG_LEVEL = 'all';
|
||||||
|
const MIN_RETENTION_DAYS = 30;
|
||||||
|
const MAX_RETENTION_DAYS = 365;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get settings document or return defaults
|
||||||
|
* @param {Db} db - MongoDB database instance
|
||||||
|
* @returns {Promise<Object>} Settings object
|
||||||
|
*/
|
||||||
|
async function getSettings(db) {
|
||||||
|
const settings = await db.collection(SETTINGS_COLLECTION).findOne({ _id: SETTINGS_DOC_ID });
|
||||||
|
if (!settings) {
|
||||||
|
return {
|
||||||
|
activityRetentionDays: DEFAULT_RETENTION_DAYS,
|
||||||
|
activityLogLevel: DEFAULT_LOG_LEVEL
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
activityRetentionDays: settings.activityRetentionDays ?? DEFAULT_RETENTION_DAYS,
|
||||||
|
activityLogLevel: settings.activityLogLevel ?? DEFAULT_LOG_LEVEL
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update TTL index on user_activity collection
|
||||||
|
* @param {Db} db - MongoDB database instance
|
||||||
|
* @param {number} days - Retention period in days
|
||||||
|
*/
|
||||||
|
async function updateTTLIndex(db, days) {
|
||||||
|
const collection = db.collection(ACTIVITY_COLLECTION);
|
||||||
|
const expireAfterSeconds = days * 24 * 60 * 60;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to drop existing TTL index
|
||||||
|
await collection.dropIndex('timestamp_ttl');
|
||||||
|
} catch (err) {
|
||||||
|
// Index may not exist, which is fine
|
||||||
|
if (err.codeName !== 'IndexNotFound') {
|
||||||
|
console.warn('Note: timestamp_ttl index not found, will create new one');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new TTL index with updated expiry
|
||||||
|
await collection.createIndex(
|
||||||
|
{ timestamp: 1 },
|
||||||
|
{
|
||||||
|
name: 'timestamp_ttl',
|
||||||
|
expireAfterSeconds: expireAfterSeconds
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/settings
|
||||||
|
* Returns current admin settings
|
||||||
|
*/
|
||||||
|
router.get('/settings', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = req.app.locals.db;
|
||||||
|
const settings = await getSettings(db);
|
||||||
|
res.json(settings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching settings:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch settings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/admin/settings
|
||||||
|
* Update admin settings
|
||||||
|
*/
|
||||||
|
router.patch('/settings', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = req.app.locals.db;
|
||||||
|
const { activityRetentionDays, activityLogLevel } = req.body;
|
||||||
|
|
||||||
|
// Validate retention period if provided
|
||||||
|
if (activityRetentionDays !== undefined) {
|
||||||
|
// Check if it's a valid number
|
||||||
|
if (typeof activityRetentionDays !== 'number' || !Number.isInteger(activityRetentionDays)) {
|
||||||
|
return res.status(400).json({ error: 'activityRetentionDays must be an integer' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check range
|
||||||
|
if (activityRetentionDays < MIN_RETENTION_DAYS || activityRetentionDays > MAX_RETENTION_DAYS) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `activityRetentionDays must be between ${MIN_RETENTION_DAYS} and ${MAX_RETENTION_DAYS} days`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate log level if provided
|
||||||
|
const validLogLevels = ['all', 'navigation', 'none'];
|
||||||
|
if (activityLogLevel !== undefined && !validLogLevels.includes(activityLogLevel)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `activityLogLevel must be one of: ${validLogLevels.join(', ')}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update object
|
||||||
|
const updateFields = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: req.user._id
|
||||||
|
};
|
||||||
|
|
||||||
|
if (activityRetentionDays !== undefined) {
|
||||||
|
updateFields.activityRetentionDays = activityRetentionDays;
|
||||||
|
}
|
||||||
|
if (activityLogLevel !== undefined) {
|
||||||
|
updateFields.activityLogLevel = activityLogLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build $setOnInsert only for fields NOT in $set
|
||||||
|
const setOnInsertFields = {};
|
||||||
|
if (activityRetentionDays === undefined) {
|
||||||
|
setOnInsertFields.activityRetentionDays = DEFAULT_RETENTION_DAYS;
|
||||||
|
}
|
||||||
|
if (activityLogLevel === undefined) {
|
||||||
|
setOnInsertFields.activityLogLevel = DEFAULT_LOG_LEVEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert settings document
|
||||||
|
const updateDoc = { $set: updateFields };
|
||||||
|
if (Object.keys(setOnInsertFields).length > 0) {
|
||||||
|
updateDoc.$setOnInsert = setOnInsertFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.collection(SETTINGS_COLLECTION).updateOne(
|
||||||
|
{ _id: SETTINGS_DOC_ID },
|
||||||
|
updateDoc,
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update TTL index if retention period changed
|
||||||
|
if (activityRetentionDays !== undefined) {
|
||||||
|
await updateTTLIndex(db, activityRetentionDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log admin action
|
||||||
|
await logActivity(db, {
|
||||||
|
userId: req.user._id.toString(),
|
||||||
|
action: 'ADMIN_UPDATE_SETTINGS',
|
||||||
|
metadata: {
|
||||||
|
...(activityRetentionDays !== undefined && { activityRetentionDays }),
|
||||||
|
...(activityLogLevel !== undefined && { activityLogLevel })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch updated settings
|
||||||
|
const updatedSettings = await getSettings(db);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
settings: updatedSettings,
|
||||||
|
message: 'Settings updated successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating settings:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update settings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
module.exports.clearStatsCache = clearStatsCache;
|
module.exports.clearStatsCache = clearStatsCache;
|
||||||
|
|||||||
@ -69,37 +69,68 @@ function shouldLog(action) {
|
|||||||
return allowedActions.includes(action);
|
return allowedActions.includes(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an action is an admin action (always logged regardless of log level)
|
||||||
|
* @param {string} action - Action to check
|
||||||
|
* @returns {boolean} True if admin action
|
||||||
|
*/
|
||||||
|
function isAdminAction(action) {
|
||||||
|
return action && action.startsWith('ADMIN_');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log a user activity to the database
|
* Log a user activity to the database
|
||||||
|
* Supports two calling conventions:
|
||||||
|
* 1. logActivity(db, userId, action, metadata, req) - positional parameters
|
||||||
|
* 2. logActivity(db, { userId, action, metadata }) - object parameter for admin actions
|
||||||
|
*
|
||||||
* @param {Db} db - MongoDB database instance
|
* @param {Db} db - MongoDB database instance
|
||||||
* @param {string} userId - User ID (will be converted to ObjectId)
|
* @param {string|Object} userIdOrOptions - User ID string or options object
|
||||||
* @param {string} action - Action type (use ACTIONS constants)
|
* @param {string} [action] - Action type (use ACTIONS constants)
|
||||||
* @param {Object} metadata - Additional action-specific data
|
* @param {Object} [metadata] - Additional action-specific data
|
||||||
* @param {Object} req - Express request object (for IP and user agent)
|
* @param {Object} [req] - Express request object (for IP and user agent)
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function logActivity(db, userId, action, metadata = {}, req = null) {
|
async function logActivity(db, userIdOrOptions, action, metadata = {}, req = null) {
|
||||||
// Only log if this action type is enabled at current log level
|
let userId;
|
||||||
if (!shouldLog(action)) {
|
let actualAction;
|
||||||
|
let actualMetadata;
|
||||||
|
let actualReq;
|
||||||
|
|
||||||
|
// Support object-based call for admin actions
|
||||||
|
if (typeof userIdOrOptions === 'object' && userIdOrOptions !== null) {
|
||||||
|
userId = userIdOrOptions.userId;
|
||||||
|
actualAction = userIdOrOptions.action;
|
||||||
|
actualMetadata = userIdOrOptions.metadata || {};
|
||||||
|
actualReq = userIdOrOptions.req || null;
|
||||||
|
} else {
|
||||||
|
userId = userIdOrOptions;
|
||||||
|
actualAction = action;
|
||||||
|
actualMetadata = metadata;
|
||||||
|
actualReq = req;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin actions bypass log level filtering
|
||||||
|
if (!isAdminAction(actualAction) && !shouldLog(actualAction)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Extract IP address (handle proxy forwarding)
|
// Extract IP address (handle proxy forwarding)
|
||||||
let ip = null;
|
let ip = null;
|
||||||
if (req) {
|
if (actualReq) {
|
||||||
ip = req.ip || req.headers?.['x-forwarded-for']?.split(',')[0] || null;
|
ip = actualReq.ip || actualReq.headers?.['x-forwarded-for']?.split(',')[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract user agent
|
// Extract user agent
|
||||||
const userAgent = req?.headers?.['user-agent'] || null;
|
const userAgent = actualReq?.headers?.['user-agent'] || null;
|
||||||
|
|
||||||
// Create activity document
|
// Create activity document
|
||||||
const activityDoc = {
|
const activityDoc = {
|
||||||
userId: new ObjectId(userId),
|
userId: new ObjectId(userId),
|
||||||
action: action,
|
action: actualAction,
|
||||||
metadata: metadata || {},
|
metadata: actualMetadata || {},
|
||||||
page: metadata?.page || null,
|
page: actualMetadata?.page || null,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
userAgent: userAgent,
|
userAgent: userAgent,
|
||||||
ip: ip
|
ip: ip
|
||||||
|
|||||||
Reference in New Issue
Block a user