From 2b453b00e6ab2bd18381cb8d22d2c5e763df1eca Mon Sep 17 00:00:00 2001 From: joshua Date: Mon, 19 Oct 2026 18:25:02 +0200 Subject: [PATCH] Add read-only admin impersonation 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 Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe --- CHANGELOG.md | 15 +++ FEATURES.md | 11 ++ SETTINGS.md | 1 + backend/API_DOCUMENTATION.md | 16 +++ .../migration.sql | 55 +++++++++ backend/prisma/schema.prisma | 35 ++++++ .../controllers/impersonationController.js | 108 ++++++++++++++++++ .../src/controllers/registrationController.js | 6 +- backend/src/index.js | 11 ++ backend/src/middleware/authMiddleware.js | 26 ++++- backend/src/routes/adminRoutes.js | 7 ++ backend/src/utils/impersonation.js | 80 +++++++++++++ backend/tests/impersonation.test.js | 43 +++++++ .../app/dashboard/admin/audit-log/page.tsx | 48 ++++++++ .../src/app/dashboard/admin/settings/page.tsx | 11 ++ .../src/app/dashboard/admin/users/page.tsx | 40 +++++++ frontend/src/app/layout.tsx | 2 + .../components/shared/ImpersonationBanner.tsx | 50 ++++++++ .../content/help/dashboard-admin-users.tsx | 5 +- frontend/src/lib/analytics.ts | 1 + frontend/src/lib/api.ts | 5 + frontend/src/lib/impersonation.ts | 72 ++++++++++++ 22 files changed, 642 insertions(+), 6 deletions(-) create mode 100644 backend/prisma/migrations/20261019181608_add_impersonation/migration.sql create mode 100644 backend/src/controllers/impersonationController.js create mode 100644 backend/src/utils/impersonation.js create mode 100644 backend/tests/impersonation.test.js create mode 100644 frontend/src/components/shared/ImpersonationBanner.tsx create mode 100644 frontend/src/lib/impersonation.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b20c1b4..acb9601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### 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 diff --git a/FEATURES.md b/FEATURES.md index 58616d9..59d0abb 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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 diff --git a/SETTINGS.md b/SETTINGS.md index 231268d..2f48ca1 100644 --- a/SETTINGS.md +++ b/SETTINGS.md @@ -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. | diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md index 6e26cfc..d73dacd 100644 --- a/backend/API_DOCUMENTATION.md +++ b/backend/API_DOCUMENTATION.md @@ -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 | diff --git a/backend/prisma/migrations/20261019181608_add_impersonation/migration.sql b/backend/prisma/migrations/20261019181608_add_impersonation/migration.sql new file mode 100644 index 0000000..cbe17a7 --- /dev/null +++ b/backend/prisma/migrations/20261019181608_add_impersonation/migration.sql @@ -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; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a65f1da..d308e36 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/controllers/impersonationController.js b/backend/src/controllers/impersonationController.js new file mode 100644 index 0000000..68440d7 --- /dev/null +++ b/backend/src/controllers/impersonationController.js @@ -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 }; diff --git a/backend/src/controllers/registrationController.js b/backend/src/controllers/registrationController.js index 9b281f0..44c59a3 100644 --- a/backend/src/controllers/registrationController.js +++ b/backend/src/controllers/registrationController.js @@ -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(() => {}))); } diff --git a/backend/src/index.js b/backend/src/index.js index 3d50593..0e6adb3 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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' }}}, diff --git a/backend/src/middleware/authMiddleware.js b/backend/src/middleware/authMiddleware.js index 36ae757..14fdb55 100644 --- a/backend/src/middleware/authMiddleware.js +++ b/backend/src/middleware/authMiddleware.js @@ -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) { - req.user = user; + 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 diff --git a/backend/src/routes/adminRoutes.js b/backend/src/routes/adminRoutes.js index 0c571a5..904f28f 100644 --- a/backend/src/routes/adminRoutes.js +++ b/backend/src/routes/adminRoutes.js @@ -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; diff --git a/backend/src/utils/impersonation.js b/backend/src/utils/impersonation.js new file mode 100644 index 0000000..9cfa5e5 --- /dev/null +++ b/backend/src/utils/impersonation.js @@ -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 }; diff --git a/backend/tests/impersonation.test.js b/backend/tests/impersonation.test.js new file mode 100644 index 0000000..d5ce1bb --- /dev/null +++ b/backend/tests/impersonation.test.js @@ -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/); + }); +}); diff --git a/frontend/src/app/dashboard/admin/audit-log/page.tsx b/frontend/src/app/dashboard/admin/audit-log/page.tsx index 78e7dab..2ba2e05 100644 --- a/frontend/src/app/dashboard/admin/audit-log/page.tsx +++ b/frontend/src/app/dashboard/admin/audit-log/page.tsx @@ -30,6 +30,8 @@ const ACTIONS = [ "scan_lock_force_released", "offline_sync", "ticket_transferred", + "impersonation_started", + "impersonation_ended", ] as const; const ACTION_LABELS: Record = { @@ -54,6 +56,8 @@ const ACTION_LABELS: Record = { 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 | 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([]); + const [open, setOpen] = useState(null); + useEffect(() => { + apiFetch("/api/admin/impersonation-sessions?limit=20", { authToken: token }).then(setSessions).catch(() => setSessions([])); + }, [token]); + if (sessions.length === 0) return null; + return ( +
+
Impersonation sessions
+
    + {sessions.map(s => ( +
  • + + {open === s.id && ( +
      + {s.requests.map((r, i) => ( +
    • + {new Date(r.createdAt).toLocaleTimeString()} {r.method} {r.path}{r.blocked ? " (blocked — read-only)" : ""} +
    • + ))} + {s.requests.length === 0 &&
    • No requests recorded.
    • } +
    + )} +
  • + ))} +
+
+ ); +} + 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. )} + {isAdmin && token && } {error &&
{error}
} diff --git a/frontend/src/app/dashboard/admin/settings/page.tsx b/frontend/src/app/dashboard/admin/settings/page.tsx index b46c727..d75f054 100644 --- a/frontend/src/app/dashboard/admin/settings/page.tsx +++ b/frontend/src/app/dashboard/admin/settings/page.tsx @@ -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() {

Collected on parental consent forms. The consent record itself (who agreed, and when) is kept.

+
+

Support access

+ +
+ )} diff --git a/frontend/src/app/dashboard/admin/users/page.tsx b/frontend/src/app/dashboard/admin/users/page.tsx index 41dfbdf..a5386e1 100644 --- a/frontend/src/app/dashboard/admin/users/page.tsx +++ b/frontend/src/app/dashboard/admin/users/page.tsx @@ -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(null); + const startImpersonation = async () => { + if (!impTarget || !token) return; + setImpBusy(true); + setImpError(null); + try { + const res = await apiFetch(`/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 && ( +
!impBusy && setImpTarget(null)}> +
e.stopPropagation()}> +

View as {impTarget.name}

+

You'll see the site exactly as they do, for up to 30 minutes. It'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.

+
+ +