Admins (with 2FA) can view the site as a non-admin user for up to 30 minutes, with a required reason and a "Viewing as" banner. It is strictly read-only: a guard mounted before every route refuses any write made with an impersonation token, protect/optionalAuth check the session is live, and GET handlers skip the price-refresh writes. Start, end, reason and every page/API call (including blocked attempts) are audited; optional email to the user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
109 lines
5.6 KiB
JavaScript
109 lines
5.6 KiB
JavaScript
const jwt = require('jsonwebtoken');
|
|
const prisma = require('../config/db');
|
|
const { safeErrorMessage } = require('../utils/errorUtils');
|
|
const { logAdminAction } = require('../utils/adminAudit');
|
|
const { getClientIp } = require('../utils/requestUtils');
|
|
const { MAX_MINUTES, impersonationBlocker, logRequest } = require('../utils/impersonation');
|
|
|
|
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
|
const err = (message, statusCode = 400) => Object.assign(new Error(message), { statusCode });
|
|
|
|
// @desc Start viewing the site as a user (read-only, 30 minutes)
|
|
// @route POST /api/admin/impersonate/:userId { reason }
|
|
// @access Private/Admin (with 2FA)
|
|
const startImpersonation = async (req, res) => {
|
|
try {
|
|
if (req.readOnly) throw err('End the current impersonation first.', 403);
|
|
const reason = String(req.body?.reason || '').trim();
|
|
if (reason.length < 5) throw err('Give a reason (at least a few words) — it is recorded in the audit log.');
|
|
const target = await prisma.user.findUnique({ where: { id: req.params.userId }, select: { id: true, name: true, email: true, role: true, isActive: true, tokenVersion: true } });
|
|
const blocker = impersonationBlocker(req.user, target);
|
|
if (blocker) throw err(blocker, target ? 403 : 404);
|
|
|
|
// One live session per admin.
|
|
await prisma.impersonationSession.updateMany({ where: { adminId: req.user.id, endedAt: null }, data: { endedAt: new Date(), endedReason: 'replaced' } });
|
|
const expiresAt = new Date(Date.now() + MAX_MINUTES * 60 * 1000);
|
|
const session = await prisma.impersonationSession.create({ data: { adminId: req.user.id, targetUserId: target.id, reason: reason.slice(0, 500), expiresAt } });
|
|
const token = jwt.sign({ id: target.id, role: target.role, tokenVersion: target.tokenVersion, imp: session.id }, process.env.JWT_SECRET, { expiresIn: `${MAX_MINUTES}m` });
|
|
|
|
logAdminAction({
|
|
actorId: req.user.id, actorRole: req.user.role, action: 'impersonation_started', targetType: 'User', targetId: target.id,
|
|
metadata: { sessionId: session.id, reason: session.reason, targetName: target.name, expiresAt }, ip: getClientIp(req),
|
|
});
|
|
|
|
// Optional: tell the user (Site Settings → Security).
|
|
const { getSettingSync } = require('../utils/settingsCache');
|
|
if (getSettingSync('notify_user_on_impersonation', 'false') === 'true' && target.email && !target.email.endsWith('@guest.local')) {
|
|
const { sendMail } = require('../utils/email');
|
|
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
|
sendMail({
|
|
to: target.email,
|
|
subject: `An administrator viewed your account — ${orgName}`,
|
|
text: `Hi ${target.name || 'there'},\n\nAn administrator at ${orgName} viewed your account (read-only) to help with support.\nReason given: ${session.reason}\n\nNothing was changed. If you have questions, reply to this email.\n\n${orgName}`,
|
|
}).catch(() => {});
|
|
}
|
|
|
|
res.status(201).json({ token, session: { id: session.id, expiresAt, target: { id: target.id, name: target.name, email: target.email, role: target.role } } });
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @desc End impersonation (called with the impersonation token)
|
|
// @route POST /api/admin/impersonate/end
|
|
// @access Impersonation token
|
|
const endImpersonation = async (req, res) => {
|
|
try {
|
|
const sessionId = req.impersonation?.id;
|
|
if (!sessionId) throw err('Not impersonating.', 400);
|
|
const s = await prisma.impersonationSession.update({ where: { id: sessionId }, data: { endedAt: new Date(), endedReason: 'ended' } });
|
|
const count = await prisma.impersonationRequestLog.count({ where: { sessionId } });
|
|
logAdminAction({
|
|
actorId: s.adminId, actorRole: 'admin', action: 'impersonation_ended', targetType: 'User', targetId: s.targetUserId,
|
|
metadata: { sessionId, minutes: Math.round((Date.now() - s.startedAt.getTime()) / 60000), requests: count }, ip: getClientIp(req),
|
|
});
|
|
res.json({ message: 'Impersonation ended.' });
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
// @desc Record a page the admin viewed while impersonating
|
|
// @route POST /api/admin/impersonate/log { path }
|
|
// @access Impersonation token
|
|
const logPageView = async (req, res) => {
|
|
if (req.impersonation?.id) logRequest(req.impersonation.id, 'PAGE', String(req.body?.path || '').slice(0, 500));
|
|
res.status(204).end();
|
|
};
|
|
|
|
// @desc The current impersonation session (for the banner)
|
|
// @route GET /api/admin/impersonate/current
|
|
// @access Impersonation token
|
|
const currentImpersonation = async (req, res) => {
|
|
const s = req.impersonation;
|
|
if (!s) return res.json(null);
|
|
res.json({ id: s.id, expiresAt: s.expiresAt, target: { id: req.user.id, name: req.user.name, email: req.user.email } });
|
|
};
|
|
|
|
// @desc Impersonation sessions with their request trail (audit log page)
|
|
// @route GET /api/admin/impersonation-sessions
|
|
// @access Private/Admin
|
|
const listSessions = async (req, res) => {
|
|
try {
|
|
const sessions = await prisma.impersonationSession.findMany({
|
|
orderBy: { startedAt: 'desc' },
|
|
take: Math.min(parseInt(req.query.limit, 10) || 50, 200),
|
|
include: {
|
|
admin: { select: { name: true, email: true } },
|
|
target: { select: { name: true, email: true } },
|
|
requests: { orderBy: { createdAt: 'asc' }, take: 500, select: { method: true, path: true, blocked: true, createdAt: true } },
|
|
},
|
|
});
|
|
res.json(sessions);
|
|
} catch (error) {
|
|
fail(res, error);
|
|
}
|
|
};
|
|
|
|
module.exports = { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions };
|