feat: audit log, device tags, maintenance windows (v0.11.3 Beta)

- Audit log: middleware captures all write operations, paginated admin UI in Settings
- Device tags: full CRUD, device assignment, filter bar and chips on Devices page
- Maintenance windows: alert suppression during planned downtime, Settings management UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-05-16 15:33:15 -07:00
co-authored by Claude Sonnet 4.6
parent 6a4ab1fc5f
commit 49ab1b002e
16 changed files with 801 additions and 10 deletions
+60
View File
@@ -0,0 +1,60 @@
import { Request, Response, NextFunction } from 'express';
import { query } from '../config/database';
import { verifyToken } from './auth';
function extractEntity(path: string): { entityType: string | null; entityId: number | null } {
// e.g. /api/devices/42/interfaces → 'device', 42
const segments = path.replace(/^\/api\//, '').split('/');
const resourceMap: Record<string, string> = {
devices: 'device',
backups: 'backup',
clients: 'client',
events: 'event',
settings: 'settings',
alerts: 'alert',
topology: 'topology',
wireless: 'wireless',
'network-services': 'network_services',
'credential-presets': 'credential_preset',
'audit-log': 'audit_log',
users: 'user',
};
const entityType = resourceMap[segments[0]] ?? segments[0] ?? null;
const idRaw = segments[1];
const entityId = idRaw && /^\d+$/.test(idRaw) ? parseInt(idRaw, 10) : null;
return { entityType, entityId };
}
function extractUser(req: Request): { userId: number | null; username: string | null } {
if (req.user) return { userId: req.user.userId, username: req.user.username };
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
try {
const payload = verifyToken(authHeader.slice(7));
return { userId: payload.userId, username: payload.username };
} catch { /* expired/invalid — record as anonymous */ }
}
return { userId: null, username: null };
}
export function auditMiddleware(req: Request, res: Response, next: NextFunction): void {
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
next();
return;
}
res.on('finish', () => {
const { entityType, entityId } = extractEntity(req.path);
const { userId, username } = extractUser(req);
const summary = `${req.method} ${req.path}`;
const ip = (req.ip ?? '').replace(/^::ffff:/, '');
query(
`INSERT INTO audit_log (user_id, username, method, path, entity_type, entity_id, summary, ip_address, status_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[userId, username, req.method, req.path, entityType, entityId, summary, ip, res.statusCode]
).catch(() => {});
});
next();
}