Merge branch 'feat/impersonate-user'

This commit is contained in:
2026-10-19 18:25:10 +02:00
28 changed files with 653 additions and 15 deletions
+17
View File
@@ -7,6 +7,23 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.23.0] - 2026-10-19
### Added
- Impersonate user (Admin → Manage users → Impersonate): an admin can see the site exactly as a user does, to help with problems such as "my ticket isn't showing".
- A reason is required, and the session lasts at most 30 minutes.
- A bright "Viewing as [name]" banner with a countdown and "End impersonation" shows on every page.
- Impersonation is strictly read-only.
- A server-side guard, mounted before every route, refuses any change (registrations, payments, refunds, cancellations, profile, consent, messages and everything else) made with an impersonation token.
- Viewing pages doesn't trigger the price refresh it normally does.
- Admins can't be impersonated, and the admin must have two-factor authentication on.
- Impersonation is recorded in the audit log: start, end, reason, and every page and API call, including blocked attempts.
- The audit log page lists recent sessions with their full trail.
- New setting: email users when an admin views their account (Site Settings → Legal → Support access; off by default).
- New `/api/admin/impersonate` endpoints.
- Requires the `add_impersonation` database migration.
## [1.22.0] - 2026-10-19
### Added
+11
View File
@@ -171,6 +171,17 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
Each payment records **who recorded it** as well as who it's for.
- **Refunds** — negative payments against an original payment, netted against
the original method.
- **Impersonate user** (Admin → Manage users) — an admin with 2FA can view the site as a
user, to debug problems.
- It needs a reason, lasts at most 30 minutes, and shows a bright "Viewing as" banner
on every page.
- It is strictly read-only. A server-side guard refuses every write made with the
impersonation token before it reaches any route, and pages don't even refresh
prices while viewing.
- Admins can't be impersonated.
- The start, end, reason and every page and API call are in the audit log, with each
session's trail.
- Optional email to the user (`notify_user_on_impersonation`).
- **Registration funnel stats** — first-party and consent-based, with no third-party analytics.
- A cookie notice asks once. Only visitors who allow it get a random visit id.
- We record event page views and registration starts. Submits and payments come
+1
View File
@@ -66,6 +66,7 @@ or Branding tab has been saved even once — the wizard always writes
| `legal_io_email` | `"io@example.com"` | Rendered as a `mailto:` link, so the placeholder stays a syntactically valid address on the IANA-reserved `example.com` domain rather than a bracketed non-email string that would break the link. |
| `legal_website_url` | `"example.com"` | Same reasoning — used to build an `https://` link. |
| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. |
| `notify_user_on_impersonation` | `"false"` | When `"true"`, a user is emailed (with the reason) whenever an admin views their account with Impersonate. Set under Site Settings → Legal → Support access. |
| `consent_medical_retention_days` | `30` | Youth events: medical and emergency details from parental consent forms are deleted this many days after the event ends (daily 09:00 job). The consent record itself — who agreed, when, to which text — is kept. Set under Site Settings → Legal. |
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
+16
View File
@@ -27,6 +27,7 @@ Tokens are issued by `POST /api/users/login`.
- [Sponsor a spot](#sponsor-a-spot)
- [Post-event feedback](#post-event-feedback)
- [Registration funnel](#registration-funnel)
- [Impersonation](#impersonation)
- [Payments](#payments)
- [Tickets](#tickets)
- [Broadcasts (Email)](#broadcasts-email)
@@ -284,6 +285,21 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
---
## Impersonation
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/admin/impersonate/:userId` | admin (2FA on) | `{ reason }` → `{ token, session }`. The token acts as the user, is read-only, and lasts 30 minutes. Refused for admins and inactive users |
| POST | `/api/admin/impersonate/end` | impersonation token | End the session |
| POST | `/api/admin/impersonate/log` | impersonation token | `{ path }` — page viewed (audit trail) |
| GET | `/api/admin/impersonate/current` | impersonation token | The live session |
| GET | `/api/admin/impersonation-sessions` | admin | Sessions with their request trail |
Read-only enforcement:
- A guard mounted before every route returns `403 { code: "IMPERSONATION_READ_ONLY" }` for any non-GET/HEAD/OPTIONS request made with an impersonation token. The only exceptions are `end` and `log`. Blocked attempts are logged.
- An ended or expired session gets `401 { code: "IMPERSONATION_ENDED" }`.
- GET handlers skip side-effect writes (pricing refresh) while `req.readOnly` is set.
## Registration funnel
| Method | Path | Auth | Description |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.22.0",
"version": "1.23.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.22.0",
"version": "1.23.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/client": "^5.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.22.0",
"version": "1.23.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,55 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AdminAuditAction" ADD VALUE 'impersonation_started';
ALTER TYPE "AdminAuditAction" ADD VALUE 'impersonation_ended';
-- CreateTable
CREATE TABLE "ImpersonationSession" (
"id" TEXT NOT NULL,
"adminId" TEXT NOT NULL,
"targetUserId" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"endedAt" TIMESTAMP(3),
"endedReason" TEXT,
CONSTRAINT "ImpersonationSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ImpersonationRequestLog" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"method" TEXT NOT NULL,
"path" TEXT NOT NULL,
"blocked" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ImpersonationRequestLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ImpersonationSession_adminId_idx" ON "ImpersonationSession"("adminId");
-- CreateIndex
CREATE INDEX "ImpersonationSession_targetUserId_idx" ON "ImpersonationSession"("targetUserId");
-- CreateIndex
CREATE INDEX "ImpersonationRequestLog_sessionId_createdAt_idx" ON "ImpersonationRequestLog"("sessionId", "createdAt");
-- AddForeignKey
ALTER TABLE "ImpersonationSession" ADD CONSTRAINT "ImpersonationSession_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImpersonationSession" ADD CONSTRAINT "ImpersonationSession_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImpersonationRequestLog" ADD CONSTRAINT "ImpersonationRequestLog_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "ImpersonationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+35
View File
@@ -111,6 +111,8 @@ model User {
ledGroups AllocationGroup[] @relation("AllocationGroupLeader")
feedbackInvites FeedbackInvite[]
feedbackGiven FeedbackResponse[]
impersonationsDone ImpersonationSession[] @relation("ImpersonationAdmin")
impersonatedIn ImpersonationSession[] @relation("ImpersonationTarget")
scanLocksForced ScanLock[] @relation("ScanLockForcedBy")
syncReportsDone OfflineSyncReport[] @relation("SyncReportReviewer")
@@ -499,6 +501,8 @@ enum AdminAuditAction {
scan_lock_force_released
offline_sync
ticket_transferred
impersonation_started
impersonation_ended
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
@@ -854,6 +858,37 @@ enum TicketDelivery {
// One named person on a group registration, for one main-ticket place. Gets their
// own quantity-1 ticket and their own attendee form response.
/// An admin viewing the site as another user (strictly read-only, at most 30 minutes).
model ImpersonationSession {
id String @id @default(uuid())
adminId String
targetUserId String
reason String
startedAt DateTime @default(now())
expiresAt DateTime
endedAt DateTime?
endedReason String? // 'ended' | 'expired'
admin User @relation("ImpersonationAdmin", fields: [adminId], references: [id], onDelete: Cascade)
target User @relation("ImpersonationTarget", fields: [targetUserId], references: [id], onDelete: Cascade)
requests ImpersonationRequestLog[]
@@index([adminId])
@@index([targetUserId])
}
/// Every page viewed and API call made during an impersonation session.
model ImpersonationRequestLog {
id String @id @default(uuid())
sessionId String
method String
path String
blocked Boolean @default(false)
createdAt DateTime @default(now())
session ImpersonationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@index([sessionId, createdAt])
}
/// First-party funnel tracking (only after cookie consent): one row per anonymous
/// session, event and stage ('view' | 'start' | 'submit'). "Paid" comes from the
/// registration that carries the session id. Rows older than 90 days are rolled up
@@ -0,0 +1,108 @@
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 };
@@ -586,7 +586,7 @@ const getUserRegistrations = async (req, res) => {
where: { ...whereClause, status: { in: ['pending', 'partial_paid'] } },
select: { id: true }
});
if (staleCandidates.length > 0) {
if (staleCandidates.length > 0 && !req.readOnly) {
await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {})));
}
@@ -663,7 +663,7 @@ const getRegistrationById = async (req, res) => {
// See getUserRegistrations — keep an unpaid/partially-paid registration's price current
// whenever it's viewed, not just at payment time.
if (registration.status === 'pending' || registration.status === 'partial_paid') {
if ((registration.status === 'pending' || registration.status === 'partial_paid') && !req.readOnly) {
await refreshPricingForRegistration(registration.id).catch(() => {});
registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include });
}
@@ -876,7 +876,7 @@ const getRegistrationsByEvent = async (req, res) => {
where: { eventId: req.params.eventId, status: { in: ['pending', 'partial_paid'] } },
select: { id: true }
});
if (staleCandidates.length > 0) {
if (staleCandidates.length > 0 && !req.readOnly) {
await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {})));
}
+11
View File
@@ -103,6 +103,10 @@ const globalLimiter = rateLimit({
});
app.use(globalLimiter);
// Impersonation is strictly read-only: refuse every write made with an impersonation
// token before it can reach any route (see utils/impersonation.js).
app.use(require('./utils/impersonation').readOnlyGuard);
// Raw body parsing middleware for webhooks
app.use((req, res, next) => {
if (req.path.startsWith('/api/webhooks')) {
@@ -639,6 +643,13 @@ app.get('/docs', async (req, res) => {
]},
]},
{ title: 'Impersonation', base: '/api/admin', endpoints: [
{ method:'POST', path:'/api/admin/impersonate/:userId', auth:'admin (2FA)', desc:'View the site as a user — read-only token, 30 minutes, audited', request:{ body:{ reason:'Ticket not showing in My Events' }}, responses:[{ status:201, desc:'Started', body:{ token:'jwt…', session:{ id:'sess-uuid', expiresAt:'2025-07-10T08:45:00.000Z' }}}, { status:403, desc:'Not allowed', body:{ message:"Admins can't be impersonated." }}]},
{ method:'POST', path:'/api/admin/impersonate/end', auth:'impersonation token', desc:'End the impersonation session' },
{ method:'POST', path:'/api/admin/impersonate/log', auth:'impersonation token', desc:'Record a page viewed during the session' },
{ method:'GET', path:'/api/admin/impersonate/current', auth:'impersonation token', desc:'The live impersonation session' },
{ method:'GET', path:'/api/admin/impersonation-sessions', auth:'admin', desc:'Impersonation sessions with their request trail' },
]},
{ title: 'Registration funnel', base: '/api/analytics', endpoints: [
{ method:'POST', path:'/api/analytics/track', auth:'Public (rate-limited)', desc:'Record a funnel step from a cookie-consented browser (always 204)',
request:{ body:{ sessionId:'random-uuid', eventId:'ev-uuid-...', stage:'view', source:'camp-2025' }}},
+23 -1
View File
@@ -48,6 +48,17 @@ const protect = async (req, res, next) => {
return next(new Error('User account is deactivated'));
}
// An admin viewing as this user: only while the session is live, and read-only.
if (decoded.imp) {
const { liveSession } = require('../utils/impersonation');
const session = await liveSession(String(decoded.imp));
if (!session || session.targetUserId !== req.user.id) {
return res.status(401).json({ message: 'The impersonation session has ended.', code: 'IMPERSONATION_ENDED' });
}
req.impersonation = session;
req.readOnly = true;
}
// Revocation check — tokenVersion in JWT must match DB
// Old tokens without tokenVersion are treated as version 0
const tokenVer = decoded.tokenVersion ?? 0;
@@ -60,7 +71,8 @@ const protect = async (req, res, next) => {
// requirement (or the user's promotion) is sent back to log in, where they
// enrol. The setup wizard's own token gets a short grace period.
const inSetupGrace = decoded.setup === true && Date.now() / 1000 - (decoded.iat || 0) < SETUP_TOKEN_GRACE_SECONDS;
if (!inSetupGrace && (await mustEnrolTwoFactor(req.user))) {
// (An impersonating admin has already passed 2FA themselves.)
if (!inSetupGrace && !req.impersonation && (await mustEnrolTwoFactor(req.user))) {
return res.status(401).json({
message: 'Two-factor authentication is required for your role. Please log in again to set it up.',
code: 'TWO_FACTOR_REQUIRED',
@@ -128,7 +140,17 @@ const optionalAuth = async (req, res, next) => {
select: { id: true, name: true, email: true, role: true, isActive: true, createdAt: true, updatedAt: true, phoneNumber: true, tokenVersion: true }
});
if (user && user.isActive && (decoded.tokenVersion ?? 0) === user.tokenVersion) {
if (decoded.imp) {
const { liveSession } = require('../utils/impersonation');
const session = await liveSession(String(decoded.imp));
req.readOnly = true;
if (session && session.targetUserId === user.id) {
req.user = user;
req.impersonation = session;
}
} else {
req.user = user;
}
}
} catch {
// Token invalid or expired — proceed without user
+7
View File
@@ -1,8 +1,15 @@
const express = require('express');
const router = express.Router();
const { listAuditLog } = require('../controllers/adminAuditController');
const { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions } = require('../controllers/impersonationController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/audit-log', protect, admin, listAuditLog);
router.get('/impersonation-sessions', protect, admin, listSessions);
// Called with the impersonation token itself (which belongs to the viewed user).
router.post('/impersonate/end', protect, endImpersonation);
router.post('/impersonate/log', protect, logPageView);
router.get('/impersonate/current', protect, currentImpersonation);
router.post('/impersonate/:userId', protect, admin, startImpersonation);
module.exports = router;
+80
View File
@@ -0,0 +1,80 @@
/**
* Admin "view as user" — strictly read-only.
*
* An impersonation token is an ordinary JWT for the target user plus an `imp` claim (the
* ImpersonationSession id), valid for at most 30 minutes. Three layers keep it read-only:
* 1. readOnlyGuard (mounted before every route): any request carrying an impersonation
* token that isn't GET/HEAD/OPTIONS is refused, apart from ending the session and
* logging page views. No controller can be reached for a write.
* 2. protect / optionalAuth check the session is live and set req.readOnly.
* 3. GET handlers that write as a side effect (price refresh on view) skip it when
* req.readOnly is set.
* The only rows an impersonation session writes are its own session and request log.
*/
const jwt = require('jsonwebtoken');
const prisma = require('../config/db');
const MAX_MINUTES = 30;
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
const ALLOWED_WRITES = ['/api/admin/impersonate/end', '/api/admin/impersonate/log'];
/** The `imp` claim of a bearer token, or null (no token, invalid token, or not impersonating). */
function impersonationClaim(req) {
const h = req.headers.authorization;
if (!h || !h.startsWith('Bearer ')) return null;
try {
const decoded = jwt.verify(h.slice(7), process.env.JWT_SECRET, { algorithms: ['HS256'] });
return decoded.imp ? String(decoded.imp) : null;
} catch {
return null;
}
}
/** Whether this request may go ahead while impersonating. Pure. */
function allowedWhileImpersonating(method, path) {
if (SAFE_METHODS.includes(String(method).toUpperCase())) return true;
const p = String(path || '').split('?')[0].replace(/\/+$/, '');
return method.toUpperCase() === 'POST' && ALLOWED_WRITES.includes(p);
}
function logRequest(sessionId, method, path, blocked = false) {
prisma.impersonationRequestLog.create({ data: { sessionId, method, path: String(path).slice(0, 500), blocked } }).catch(() => {});
}
/** Layer 1 — mounted in index.js before every route (webhooks never carry user tokens). */
function readOnlyGuard(req, res, next) {
const sessionId = impersonationClaim(req);
if (!sessionId) return next();
const path = req.originalUrl || req.url;
if (!allowedWhileImpersonating(req.method, path)) {
logRequest(sessionId, req.method, path, true);
return res.status(403).json({ message: 'Read-only while viewing as another user. End impersonation to make changes.', code: 'IMPERSONATION_READ_ONLY' });
}
if (SAFE_METHODS.includes(req.method) && path.startsWith('/api/')) logRequest(sessionId, req.method, path);
next();
}
/** Layer 2 — the live session for an `imp` claim, or null when ended/expired. */
async function liveSession(sessionId) {
const s = await prisma.impersonationSession.findUnique({ where: { id: sessionId } });
if (!s || s.endedAt) return null;
if (s.expiresAt <= new Date()) {
await prisma.impersonationSession.updateMany({ where: { id: s.id, endedAt: null }, data: { endedAt: new Date(), endedReason: 'expired' } });
return null;
}
return s;
}
/** Why `admin` can't view as `target`, or null. Pure. */
function impersonationBlocker(admin, target) {
if (!admin || admin.role !== 'admin') return 'Only admins can view as another user.';
if (!admin.twoFactorEnabled) return 'Turn on two-factor authentication for your account first.';
if (!target) return 'User not found.';
if (target.id === admin.id) return "You can't view as yourself.";
if (target.role === 'admin') return "Admins can't be impersonated.";
if (!target.isActive) return 'That account is deactivated.';
return null;
}
module.exports = { MAX_MINUTES, impersonationClaim, allowedWhileImpersonating, readOnlyGuard, liveSession, impersonationBlocker, logRequest };
+43
View File
@@ -0,0 +1,43 @@
const { allowedWhileImpersonating, impersonationBlocker } = require('../src/utils/impersonation');
describe('allowedWhileImpersonating', () => {
test('reads are allowed', () => {
expect(allowedWhileImpersonating('GET', '/api/registrations/myregistrations')).toBe(true);
expect(allowedWhileImpersonating('HEAD', '/api/events')).toBe(true);
expect(allowedWhileImpersonating('OPTIONS', '/api/payments')).toBe(true);
});
test('every write is refused', () => {
for (const m of ['POST', 'PUT', 'PATCH', 'DELETE']) {
expect(allowedWhileImpersonating(m, '/api/registrations')).toBe(false);
expect(allowedWhileImpersonating(m, '/api/payments/yoco-checkout')).toBe(false);
expect(allowedWhileImpersonating(m, '/api/users/profile')).toBe(false);
}
});
test('except ending the session and logging page views', () => {
expect(allowedWhileImpersonating('POST', '/api/admin/impersonate/end')).toBe(true);
expect(allowedWhileImpersonating('POST', '/api/admin/impersonate/log?x=1')).toBe(true);
expect(allowedWhileImpersonating('DELETE', '/api/admin/impersonate/end')).toBe(false);
expect(allowedWhileImpersonating('POST', '/api/admin/impersonate/end/../../users')).toBe(false);
});
});
describe('impersonationBlocker', () => {
const admin = { id: 'a', role: 'admin', twoFactorEnabled: true };
const user = { id: 'u', role: 'user', isActive: true };
test('an admin with 2FA can view a user', () => {
expect(impersonationBlocker(admin, user)).toBeNull();
expect(impersonationBlocker(admin, { ...user, role: 'supervisor' })).toBeNull();
});
test('refusals', () => {
expect(impersonationBlocker({ ...admin, role: 'supervisor' }, user)).toMatch(/Only admins/);
expect(impersonationBlocker({ ...admin, twoFactorEnabled: false }, user)).toMatch(/two-factor/);
expect(impersonationBlocker(admin, { ...user, role: 'admin' })).toMatch(/Admins can't/);
expect(impersonationBlocker(admin, { ...admin })).toMatch(/yourself/);
expect(impersonationBlocker(admin, { ...user, isActive: false })).toMatch(/deactivated/);
expect(impersonationBlocker(admin, null)).toMatch(/not found/);
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.22.0",
"version": "1.23.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.22.0",
"version": "1.23.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.22.0",
"version": "1.23.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -30,6 +30,8 @@ const ACTIONS = [
"scan_lock_force_released",
"offline_sync",
"ticket_transferred",
"impersonation_started",
"impersonation_ended",
] as const;
const ACTION_LABELS: Record<string, string> = {
@@ -54,6 +56,8 @@ const ACTION_LABELS: Record<string, string> = {
scan_lock_force_released: "Offline lock force-released",
offline_sync: "Offline scans synced",
ticket_transferred: "Ticket transferred",
impersonation_started: "Impersonation started",
impersonation_ended: "Impersonation ended",
};
interface AuditLogEntry {
@@ -81,6 +85,49 @@ function formatMetadata(metadata: Record<string, unknown> | null): string {
}
}
type ImpSession = {
id: string; reason: string; startedAt: string; expiresAt: string; endedAt: string | null; endedReason: string | null;
admin: { name: string; email: string }; target: { name: string; email: string };
requests: { method: string; path: string; blocked: boolean; createdAt: string }[];
};
/** "View as" sessions, each with the trail of pages and API calls made during it. */
function ImpersonationSessions({ token }: { token: string }) {
const [sessions, setSessions] = useState<ImpSession[]>([]);
const [open, setOpen] = useState<string | null>(null);
useEffect(() => {
apiFetch<ImpSession[]>("/api/admin/impersonation-sessions?limit=20", { authToken: token }).then(setSessions).catch(() => setSessions([]));
}, [token]);
if (sessions.length === 0) return null;
return (
<div className="border rounded-xl bg-white shadow-sm p-4 mb-4">
<div className="font-semibold mb-2">Impersonation sessions</div>
<ul className="divide-y text-sm">
{sessions.map(s => (
<li key={s.id} className="py-2">
<button className="w-full text-left flex flex-wrap items-baseline justify-between gap-2" onClick={() => setOpen(open === s.id ? null : s.id)}>
<span><strong>{s.admin.name}</strong> viewed as <strong>{s.target.name}</strong> — “{s.reason}”</span>
<span className="text-xs text-gray-500">
{new Date(s.startedAt).toLocaleString()} · {s.endedAt ? `${s.endedReason || "ended"} ${new Date(s.endedAt).toLocaleTimeString()}` : new Date(s.expiresAt) > new Date() ? "active" : "expired"} · {s.requests.length} request(s){s.requests.some(r => r.blocked) ? ` · ${s.requests.filter(r => r.blocked).length} blocked` : ""}
</span>
</button>
{open === s.id && (
<ul className="mt-2 text-xs font-mono bg-gray-50 rounded p-2 max-h-64 overflow-auto space-y-0.5">
{s.requests.map((r, i) => (
<li key={i} className={r.blocked ? "text-red-700" : "text-gray-700"}>
{new Date(r.createdAt).toLocaleTimeString()} {r.method} {r.path}{r.blocked ? " (blocked — read-only)" : ""}
</li>
))}
{s.requests.length === 0 && <li className="text-gray-500">No requests recorded.</li>}
</ul>
)}
</li>
))}
</ul>
</div>
);
}
export default function AdminAuditLogPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
@@ -148,6 +195,7 @@ export default function AdminAuditLogPage() {
You need admin access to view the audit log.
</div>
)}
{isAdmin && token && <ImpersonationSessions token={token} />}
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
@@ -137,6 +137,7 @@ function SiteSettingsPageInner() {
const [legalWebsiteUrl, setLegalWebsiteUrl] = useState("");
const [legalEffectiveDate, setLegalEffectiveDate] = useState("");
const [consentRetentionDays, setConsentRetentionDays] = useState("30");
const [notifyOnImpersonation, setNotifyOnImpersonation] = useState(false);
// ── Load all settings once ────────────────────────────────────────────────
useEffect(() => {
@@ -173,6 +174,7 @@ function SiteSettingsPageInner() {
setLegalWebsiteUrl(s.legal_website_url || "");
setLegalEffectiveDate(s.legal_effective_date || "");
setConsentRetentionDays(s.consent_medical_retention_days || "30");
setNotifyOnImpersonation(s.notify_user_on_impersonation === "true");
})
.catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" }))
.finally(() => setLoadingInitial(false));
@@ -322,6 +324,7 @@ function SiteSettingsPageInner() {
legal_website_url: legalWebsiteUrl.trim(),
legal_effective_date: legalEffectiveDate.trim(),
consent_medical_retention_days: String(Math.max(0, parseInt(consentRetentionDays, 10) || 0)),
notify_user_on_impersonation: notifyOnImpersonation ? "true" : "false",
});
const handleTestSmtp = async () => {
@@ -670,6 +673,14 @@ function SiteSettingsPageInner() {
<p className="text-xs text-gray-500 mt-1">Collected on parental consent forms. The consent record itself (who agreed, and when) is kept.</p>
</div>
<div className="border-t pt-4">
<p className="text-sm font-medium text-gray-700 mb-3">Support access</p>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={notifyOnImpersonation} onChange={e => setNotifyOnImpersonation(e.target.checked)} />
<span>Email a user when an admin views their account with &quot;Impersonate&quot; (read-only support access), including the reason given</span>
</label>
</div>
<SaveBar saving={saving} onSave={saveLegal} result={result} />
</div>
)}
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { beginImpersonation } from "@/lib/impersonation";
import { Users as UsersIcon } from "lucide-react";
import { RoleBadge, type Role as RoleBadgeRole } from "@/components/shared/RoleBadge";
@@ -187,6 +188,23 @@ export default function AdminUsersPage() {
}
};
const [impTarget, setImpTarget] = useState<{ id: string; name: string } | null>(null);
const [impReason, setImpReason] = useState("");
const [impBusy, setImpBusy] = useState(false);
const [impError, setImpError] = useState<string | null>(null);
const startImpersonation = async () => {
if (!impTarget || !token) return;
setImpBusy(true);
setImpError(null);
try {
const res = await apiFetch<any>(`/api/admin/impersonate/${encodeURIComponent(impTarget.id)}`, { method: "POST", authToken: token, body: { reason: impReason } });
beginImpersonation(res);
} catch (e: any) {
setImpError(e?.message || "Couldn't start impersonation");
setImpBusy(false);
}
};
const resetTwoFactor = async (id: string, name: string, role: string) => {
// Admins and supervisors can't go without 2FA: a reset just makes them set it up
// again (on a channel that works) at their next login.
@@ -222,6 +240,24 @@ export default function AdminUsersPage() {
}
};
const impersonateModal = impTarget && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => !impBusy && setImpTarget(null)}>
<div className="bg-white rounded-xl shadow-lg max-w-md w-full mx-4 p-5 space-y-3" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-semibold">View as {impTarget.name}</h3>
<p className="text-sm text-gray-600">You&apos;ll see the site exactly as they do, for up to 30 minutes. It&apos;s strictly read-only: nothing can be registered, paid, changed or sent. The session, your reason and every page you open are recorded in the audit log.</p>
<div>
<label className="block text-xs text-gray-600 mb-1">Reason (required)</label>
<textarea className="w-full border rounded-lg px-3 py-2 text-sm" rows={2} value={impReason} onChange={e => setImpReason(e.target.value)} placeholder="e.g. Says their ticket isn't showing in My Events" maxLength={500} autoFocus />
</div>
{impError && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{impError}</div>}
<div className="flex justify-end gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => setImpTarget(null)} disabled={impBusy}>Cancel</button>
<button className="px-3 py-1.5 text-sm rounded bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-50" onClick={startImpersonation} disabled={impBusy || impReason.trim().length < 5}>{impBusy ? "Starting…" : "Start viewing"}</button>
</div>
</div>
</div>
);
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
@@ -367,6 +403,9 @@ export default function AdminUsersPage() {
<td className="p-2">
<div className="flex gap-1 flex-wrap">
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={() => startEdit(u)}>Edit</button>
{user?.role === "admin" && u.role !== "admin" && u.isActive && (
<button className="px-2 py-1 text-xs rounded bg-violet-600 text-white hover:bg-violet-700" onClick={() => { setImpTarget({ id: u.id, name: u.name }); setImpReason(""); setImpError(null); }} title="See the site exactly as this user sees it (read-only, 30 minutes, audited)">Impersonate</button>
)}
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => revokeUserSessions(u.id, u.name)} title="Force this user to sign in again on every device where they're currently logged in">Sign out everywhere</button>
{u.twoFactorEnabled && (
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => resetTwoFactor(u.id, u.name, u.role)} title={u.role === "admin" || u.role === "supervisor" ? "Reset two-factor authentication — they set it up again at their next login" : "Turn off two-factor authentication for a user who can no longer receive their codes"}>Reset 2FA</button>
@@ -498,6 +537,7 @@ export default function AdminUsersPage() {
</div>
</div>
)}
{impersonateModal}
</div>
);
}
+2
View File
@@ -9,6 +9,7 @@ import { BannerBar } from "@/components/shared/BannerBar";
import { SetupGuard } from "@/components/shared/SetupGuard";
import HelpFab from "@/components/shared/HelpFab";
import { CookieConsent } from "@/components/shared/CookieConsent";
import { ImpersonationBanner } from "@/components/shared/ImpersonationBanner";
import { API_BASE, resolveToApiOrigin } from "@/lib/api";
import { buildThemeCssVars } from "@/lib/colorScale";
import { appUrl } from "@/lib/siteConfig";
@@ -94,6 +95,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<AuthProvider>
<ToastProvider>
<SetupGuard />
<ImpersonationBanner />
<BannerBar />
{children}
<HelpFab />
@@ -0,0 +1,50 @@
"use client";
import React, { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { Eye } from "lucide-react";
import { currentImpersonation, endImpersonation, logImpersonatedPage, type Impersonation } from "@/lib/impersonation";
/** Bright bar on every page while an admin is viewing the site as someone else. */
export function ImpersonationBanner() {
const pathname = usePathname();
const [imp, setImp] = useState<Impersonation | null>(null);
const [left, setLeft] = useState("");
const [ending, setEnding] = useState(false);
useEffect(() => { setImp(currentImpersonation()); }, []);
// Every page viewed goes into the session's audit trail.
useEffect(() => {
if (imp && pathname) logImpersonatedPage(pathname + (typeof window !== "undefined" ? window.location.search : ""));
}, [imp, pathname]);
useEffect(() => {
if (!imp) return;
const tick = () => {
const ms = new Date(imp.expiresAt).getTime() - Date.now();
if (ms <= 0) { endImpersonation(); return; }
const m = Math.floor(ms / 60000), s = Math.floor((ms % 60000) / 1000);
setLeft(`${m}:${String(s).padStart(2, "0")}`);
};
tick();
const t = setInterval(tick, 1000);
return () => clearInterval(t);
}, [imp]);
if (!imp) return null;
return (
<div className="sticky top-0 z-[60] bg-amber-400 text-amber-950 px-4 py-2 text-sm flex flex-wrap items-center justify-center gap-x-3 gap-y-1 shadow" role="status">
<Eye className="w-4 h-4 shrink-0" />
<span><strong>Viewing as {imp.name}</strong> ({imp.email}) — read-only, nothing can be changed.</span>
<span className="tabular-nums">{left} left</span>
<button
onClick={() => { setEnding(true); endImpersonation(); }}
disabled={ending}
className="px-2.5 py-1 rounded bg-amber-950 text-amber-50 text-xs font-medium hover:bg-black disabled:opacity-60"
>
{ending ? "Ending…" : "End impersonation"}
</button>
</div>
);
}
@@ -1,5 +1,5 @@
import React from "react";
import { Users, UserPlus, ShieldAlert } from "lucide-react";
import { Users, UserPlus, ShieldAlert, Eye } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -30,6 +30,9 @@ export const dashboardAdminUsersHelpContent: HelpContent = {
content: (
<div className="space-y-4">
<div className="space-y-4">
<GuideItem icon={Eye} title="Impersonate" tone="violet">
See the site exactly as a user does, to help with a problem (&quot;my ticket isn&apos;t showing&quot;). Give a reason. A bright banner shows while you&apos;re viewing, for up to 30 minutes. It&apos;s strictly read-only: nothing can be registered, paid, changed or sent. The reason and every page you open are recorded in the audit log. You need two-factor authentication on, and admins can&apos;t be impersonated. Optionally, users get an email when this happens (Site Settings → Legal → Support access).
</GuideItem>
<GuideItem icon={Users} title="Sign out everywhere" tone="amber">
Revokes all of that user&apos;s active sessions — useful if a device was lost or access needs to be cut immediately.
</GuideItem>
+1
View File
@@ -36,6 +36,7 @@ export function setConsent(v: "accepted" | "declined") {
export function analyticsSessionId(): string | undefined {
if (getConsent() !== "accepted") return undefined;
try {
if (localStorage.getItem("hope_events_impersonation")) return undefined;
let id = localStorage.getItem(SESSION_KEY);
if (!id) {
id = crypto.randomUUID();
+5
View File
@@ -119,6 +119,11 @@ export async function apiFetch<T = any>(path: string, options: ApiOptions = {}):
} catch { /* not JSON — use raw text */ }
// An admin/supervisor session from before 2FA was required for their role:
// end it and send them to log in, where they enrol.
// The admin's "view as" session ended or expired: put their own session back.
if (res.status === 401 && data?.code === "IMPERSONATION_ENDED" && typeof window !== "undefined") {
const { restoreAdmin } = await import("@/lib/impersonation");
restoreAdmin();
}
if (res.status === 401 && data?.code === "TWO_FACTOR_REQUIRED" && typeof window !== "undefined") {
localStorage.removeItem("hope_events_token");
localStorage.removeItem("hope_events_user_cache");
+72
View File
@@ -0,0 +1,72 @@
"use client";
/**
* Admin "view as user". The admin's own token (and cached profile) are put aside while
* the impersonation token is the active one, and restored when it ends. Everything is
* read-only on the server (any write is refused), so this is only about which token the
* browser sends.
*/
import { API_BASE } from "@/lib/api";
const TOKEN_KEY = "hope_events_token";
const USER_CACHE_KEY = "hope_events_user_cache";
const ADMIN_TOKEN_KEY = "hope_events_admin_token";
const ADMIN_CACHE_KEY = "hope_events_admin_user_cache";
const IMP_KEY = "hope_events_impersonation";
export type Impersonation = { sessionId: string; expiresAt: string; name: string; email: string };
export function currentImpersonation(): Impersonation | null {
try {
const raw = localStorage.getItem(IMP_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export const isImpersonating = () => !!currentImpersonation();
/** Switches this browser to the impersonation token and opens the user's dashboard. */
export function beginImpersonation(res: { token: string; session: { id: string; expiresAt: string; target: { name: string; email: string } } }) {
try {
localStorage.setItem(ADMIN_TOKEN_KEY, localStorage.getItem(TOKEN_KEY) || "");
localStorage.setItem(ADMIN_CACHE_KEY, localStorage.getItem(USER_CACHE_KEY) || "");
localStorage.setItem(TOKEN_KEY, res.token);
localStorage.removeItem(USER_CACHE_KEY);
localStorage.setItem(IMP_KEY, JSON.stringify({ sessionId: res.session.id, expiresAt: res.session.expiresAt, name: res.session.target.name, email: res.session.target.email }));
} catch { /* storage unavailable */ }
window.location.href = "/dashboard/user";
}
/** Puts the admin's own session back (no server call). */
export function restoreAdmin(redirect = true) {
try {
const adminToken = localStorage.getItem(ADMIN_TOKEN_KEY);
const adminCache = localStorage.getItem(ADMIN_CACHE_KEY);
if (adminToken) localStorage.setItem(TOKEN_KEY, adminToken); else localStorage.removeItem(TOKEN_KEY);
if (adminCache) localStorage.setItem(USER_CACHE_KEY, adminCache); else localStorage.removeItem(USER_CACHE_KEY);
localStorage.removeItem(ADMIN_TOKEN_KEY);
localStorage.removeItem(ADMIN_CACHE_KEY);
localStorage.removeItem(IMP_KEY);
} catch { /* storage unavailable */ }
if (redirect) window.location.href = "/dashboard/admin/users";
}
/** Ends the session on the server (audited), then restores the admin. */
export async function endImpersonation() {
try {
const token = localStorage.getItem(TOKEN_KEY);
if (token) await fetch(`${API_BASE}/api/admin/impersonate/end`, { method: "POST", headers: { Authorization: `Bearer ${token}` } });
} catch { /* the session expires by itself anyway */ }
restoreAdmin();
}
/** Records a page view in the session's audit trail. */
export function logImpersonatedPage(path: string) {
try {
const token = localStorage.getItem(TOKEN_KEY);
if (!token) return;
fetch(`${API_BASE}/api/admin/impersonate/log`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ path }), keepalive: true }).catch(() => {});
} catch { /* ignore */ }
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.22.0",
"version": "1.23.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.22.0",
"version": "1.23.0",
"license": "ISC",
"devDependencies": {
"concurrently": "^9.2.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.22.0",
"version": "1.23.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",