diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8cf8e2f..7c0b653 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,10 @@ and this project follows [Semantic Versioning](https://semver.org/).
### Added
+- Two-factor authentication is now required for Admin and Supervisor accounts. An admin or supervisor without it is sent a code at their next login (by email, or WhatsApp when SMTP isn't set up), and entering it turns two-factor on — the same code step as before, with a note explaining why. Trusted devices don't skip this first code. They can change whether codes come by email or WhatsApp from Profile & Security, but can no longer turn two-factor off (`POST /api/users/2fa/disable` returns 403 for them).
+- Existing admin and supervisor sessions without two-factor are ended on their next request (`401` with `code: "TWO_FACTOR_REQUIRED"`), and the login page explains why. Promoting a user to supervisor or admin also ends their sessions if they don't have two-factor yet, so they enrol at their next login. An admin "Reset 2FA" for a supervisor/admin now means they set it up again at their next login.
+- The requirement is skipped when no channel can deliver a code — no SMTP configured and no WhatsApp — so a fresh install (or the setup wizard, which also gets a short grace period) never locks the first admin out. `TWO_FACTOR_REQUIRED_ROLES=false` turns the requirement off as an emergency switch.
+
- `FEATURES.md`, a repo-root overview of everything the platform does — roles, public site, events, registration, payments, tickets, messaging, dashboards, cashup, reports, settings, security, background jobs, and a page map.
### Changed
diff --git a/FEATURES.md b/FEATURES.md
index dbcd930..70d90f4 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -354,10 +354,14 @@ See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
created on someone's behalf).
- JWT authentication (HS256 pinned) with **session revocation** (token
versioning) — users can sign out everywhere, and admins can do it for any user.
-- **Two-factor authentication** — optional for every user, from Profile & Security: a
- 6-digit login code by email or WhatsApp (10-minute expiry, 5 attempts, resend),
- with "Trust this device for 30 days". Admins can reset it for a locked-out
- user, and the kiosk asks for the code at setup.
+- **Two-factor authentication** — a 6-digit login code by email or WhatsApp
+ (10-minute expiry, 5 attempts, resend), with "Trust this device for 30 days".
+ **Required for Admins and Supervisors**: their next login sends a code that turns
+ it on, and they can change the channel but not turn it off (skipped only when no
+ email or WhatsApp channel can deliver a code; `TWO_FACTOR_REQUIRED_ROLES=false`
+ is the emergency switch). Optional for everyone else from Profile & Security.
+ Admins can reset it for a locked-out user, and the kiosk asks for the code at
+ setup.
- **Login lockout** after repeated failed attempts, plus login rate limiting
(`TRUST_PROXY` to read real IPs behind a proxy).
- **Security event log** — logins, password changes and resets, with device
diff --git a/backend/.env.example b/backend/.env.example
index 50ce056..7705d72 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -4,6 +4,11 @@ DATABASE_URL="postgresql://username:password@localhost:5432/hope_events"
# ─── Auth ─────────────────────────────────────────────────────────────────────
JWT_SECRET=your_jwt_secret_here_minimum_32_characters
+# Two-factor authentication is required for admins and supervisors whenever
+# email (SMTP) or WhatsApp can deliver a code. Set to false only as an emergency
+# switch, e.g. if the mail server is down and admins are locked out.
+# TWO_FACTOR_REQUIRED_ROLES=true
+
# ─── Server ───────────────────────────────────────────────────────────────────
PORT=5000
NODE_ENV=development
diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md
index b6c69a8..6bc903f 100644
--- a/backend/API_DOCUMENTATION.md
+++ b/backend/API_DOCUMENTATION.md
@@ -86,6 +86,8 @@ If the account has two-factor authentication on and `deviceToken` is missing, ex
{ "twoFactorRequired": true, "challengeId": "uuid", "method": "email", "destination": "j•••@example.com" }
```
+**Required for admins and supervisors.** An admin or supervisor without 2FA gets the same challenge with `"enrolling": true` (trusted devices don't skip it), sent to their email when SMTP is configured, otherwise WhatsApp. Verifying it turns 2FA on with that channel. If neither channel can deliver a code, the requirement is skipped so the account isn't locked out. `TWO_FACTOR_REQUIRED_ROLES=false` turns the requirement off.
+
### POST /api/users/login/2fa
```json
{ "challengeId": "uuid", "code": "123456", "rememberDevice": true }
@@ -95,7 +97,9 @@ Returns: `{ _id, name, email, role, token }`, plus `deviceToken` when `rememberD
### Two-factor setup (`/api/users/2fa/*`)
- `POST /api/users/2fa/enable` — `{ "method": "email" | "whatsapp", "password": "..." }` sends a code to that channel and returns `{ challengeId, method, destination }`. WhatsApp needs a valid phone number on the account and a connected WhatsApp session.
- `POST /api/users/2fa/confirm` — `{ "challengeId": "uuid", "code": "123456" }` turns 2FA on.
-- `POST /api/users/2fa/disable` — `{ "password": "..." }` turns it off.
+- `POST /api/users/2fa/disable` — `{ "password": "..." }` turns it off. Returns 403 for admins and supervisors, who must keep it on (they can switch channel with `enable`/`confirm`).
+
+An admin or supervisor session without 2FA (from before the requirement, or after a promotion or 2FA reset) is rejected by every protected route with `401 { "code": "TWO_FACTOR_REQUIRED" }`; logging in again enrols them. Promoting a user to supervisor/admin ends their sessions when they don't have 2FA yet.
At login, if a WhatsApp code can't be sent, the code falls back to the account's email address.
diff --git a/backend/src/config/auth.js b/backend/src/config/auth.js
index 401e33c..7db1a5d 100644
--- a/backend/src/config/auth.js
+++ b/backend/src/config/auth.js
@@ -1,9 +1,10 @@
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
-const generateToken = (userId, role, tokenVersion = 0) => {
+// `extra` adds claims, e.g. { setup: true } for the first-run setup wizard's token.
+const generateToken = (userId, role, tokenVersion = 0, extra = {}) => {
return jwt.sign(
- { id: userId, role, tokenVersion },
+ { ...extra, id: userId, role, tokenVersion },
process.env.JWT_SECRET,
{ expiresIn: '30d' }
);
diff --git a/backend/src/controllers/settingsController.js b/backend/src/controllers/settingsController.js
index 560877c..de4225a 100644
--- a/backend/src/controllers/settingsController.js
+++ b/backend/src/controllers/settingsController.js
@@ -172,7 +172,9 @@ const setupRegister = async (req, res) => {
},
});
- const token = generateToken(user.id, user.role, user.tokenVersion ?? 0);
+ // Marked as a setup token so the 2FA requirement doesn't block the rest of the
+ // wizard; the admin enrols at their first normal login.
+ const token = generateToken(user.id, user.role, user.tokenVersion ?? 0, { setup: true });
res.json({ token, name: user.name, email: user.email });
} catch (e) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
diff --git a/backend/src/controllers/userController.js b/backend/src/controllers/userController.js
index 1bb5c2f..b1226af 100644
--- a/backend/src/controllers/userController.js
+++ b/backend/src/controllers/userController.js
@@ -13,6 +13,8 @@ const {
issueTrustedDevice,
isTrustedDevice,
clearTrustedDevices,
+ requiresTwoFactor,
+ enforcementMethod,
} = require('../utils/twoFactor');
const axios = require('axios');
@@ -327,6 +329,17 @@ const loginUser = async (req, res) => {
return res.json({ twoFactorRequired: true, ...challenge });
}
+ // Admins and supervisors must use 2FA. Not set up yet: send a code to the
+ // first channel that can deliver one, and verifying it turns 2FA on. A
+ // trusted device can't skip this — nothing has been verified yet.
+ if (!updated.twoFactorEnabled && requiresTwoFactor(updated)) {
+ const method = await enforcementMethod(updated);
+ if (method) {
+ const challenge = await createChallenge(updated, 'login', method);
+ return res.json({ twoFactorRequired: true, enrolling: true, ...challenge });
+ }
+ }
+
completeLogin(updated, req, res);
} catch (error) {
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
@@ -341,12 +354,23 @@ const verifyLoginTwoFactor = async (req, res) => {
const { challengeId, code, rememberDevice } = req.body || {};
const challenge = await verifyChallenge(challengeId, code, 'login');
- const user = await prisma.user.findUnique({ where: { id: challenge.userId }, select: LOGIN_USER_SELECT });
+ let user = await prisma.user.findUnique({ where: { id: challenge.userId }, select: LOGIN_USER_SELECT });
if (!user || !user.isActive) {
res.status(401);
throw new Error('Your account has been deactivated');
}
+ // First login since 2FA became required for this role: the code proves the
+ // channel works, so turn 2FA on with it.
+ if (!user.twoFactorEnabled && requiresTwoFactor(user)) {
+ user = await prisma.user.update({
+ where: { id: user.id },
+ data: { twoFactorEnabled: true, twoFactorMethod: challenge.method, updatedAt: new Date() },
+ select: LOGIN_USER_SELECT,
+ });
+ logSecurityEvent({ userId: user.id, type: 'two_factor_enabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
+ }
+
const extra = {};
if (rememberDevice === true) {
extra.deviceToken = await issueTrustedDevice(user.id, req.headers['user-agent']);
@@ -430,6 +454,11 @@ const disableTwoFactor = async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
if (!user) { res.status(404); throw new Error('User not found'); }
+ if (requiresTwoFactor(user)) {
+ res.status(403);
+ throw new Error("Two-factor authentication is required for your role and can't be turned off.");
+ }
+
if (typeof password !== 'string' || !(await comparePassword(password, user.password))) {
res.status(400);
throw new Error('Incorrect password');
@@ -491,7 +520,7 @@ const getUserProfile = async (req, res) => {
});
if (user) {
- res.json(user);
+ res.json({ ...user, twoFactorRequired: requiresTwoFactor(user) });
} else {
res.status(404);
throw new Error('User not found');
@@ -596,6 +625,7 @@ const updateUserProfile = async (req, res) => {
res.json({
...updatedUser,
+ twoFactorRequired: requiresTwoFactor(updatedUser),
token: generateToken(updatedUser.id, updatedUser.role, updatedUser.tokenVersion)
});
} catch (error) {
@@ -770,6 +800,13 @@ const updateUser = async (req, res) => {
data.twoFactorMethod = null;
}
+ // Promoted to a role that requires 2FA without having it: end their sessions
+ // so the next login enrols them.
+ const willHaveTwoFactor = resetTwoFactor ? false : user.twoFactorEnabled;
+ if (!willHaveTwoFactor && data.role !== user.role && requiresTwoFactor({ role: data.role })) {
+ data.tokenVersion = { increment: 1 };
+ }
+
const updatedUser = await prisma.user.update({
where: { id: req.params.id },
data,
diff --git a/backend/src/index.js b/backend/src/index.js
index 5949335..e15448a 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -392,6 +392,7 @@ app.get('/docs', async (req, res) => {
responses:[
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...' }},
{ status:200, desc:'Two-factor code sent (account has 2FA on) — no token yet', body:{ twoFactorRequired:true, challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
+ { status:200, desc:'Admin/supervisor without 2FA — code sent; verifying it turns 2FA on', body:{ twoFactorRequired:true, enrolling:true, challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
{ status:401, desc:'Wrong credentials', body:{ message:'Invalid email or password' }},
]},
{ method:'POST', path:'/api/users/login/2fa', auth:'public', desc:'Second login step — exchange the emailed/WhatsApp code for a JWT',
@@ -451,7 +452,11 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Enabled', body:{ twoFactorEnabled:true, twoFactorMethod:'email' }}]},
{ method:'POST', path:'/api/users/2fa/disable', auth:'user+', desc:'Turn off two-factor authentication',
request:{ body:{ password:'secret123' }},
- responses:[{ status:200, desc:'Disabled', body:{ twoFactorEnabled:false, twoFactorMethod:null }}]},
+ notes:'Admins and supervisors must keep two-factor on — 403 for them.',
+ responses:[
+ { status:200, desc:'Disabled', body:{ twoFactorEnabled:false, twoFactorMethod:null }},
+ { status:403, desc:'Required for this role', body:{ message:"Two-factor authentication is required for your role and can't be turned off." }},
+ ]},
{ method:'POST', path:'/api/users/verify-password', auth:'user+', desc:'Re-confirm own password without starting a new session',
request:{ body:{ password:'secret123' }},
responses:[
diff --git a/backend/src/middleware/authMiddleware.js b/backend/src/middleware/authMiddleware.js
index 07abcf0..36ae757 100644
--- a/backend/src/middleware/authMiddleware.js
+++ b/backend/src/middleware/authMiddleware.js
@@ -2,6 +2,10 @@ const rateLimit = require("express-rate-limit");
const jwt = require('jsonwebtoken');
const prisma = require('../config/db');
+const { mustEnrolTwoFactor } = require('../utils/twoFactor');
+
+// The setup wizard's token may finish setup without 2FA for this long.
+const SETUP_TOKEN_GRACE_SECONDS = 2 * 60 * 60;
// Protect routes - verify token
const protect = async (req, res, next) => {
@@ -29,7 +33,8 @@ const protect = async (req, res, next) => {
createdAt: true,
updatedAt: true,
phoneNumber: true,
- tokenVersion: true
+ tokenVersion: true,
+ twoFactorEnabled: true
}
});
@@ -51,6 +56,17 @@ const protect = async (req, res, next) => {
return next(new Error('Session has been revoked. Please log in again.'));
}
+ // Admins and supervisors must use 2FA. A session that predates the
+ // 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))) {
+ 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',
+ });
+ }
+
next();
} catch (error) {
console.error(error);
diff --git a/backend/src/utils/email.js b/backend/src/utils/email.js
index fc0d5a1..89775ba 100644
--- a/backend/src/utils/email.js
+++ b/backend/src/utils/email.js
@@ -49,6 +49,11 @@ function _getTransporter() {
return { transporter: _cachedTransporter, cfg };
}
+/** True when a real SMTP server is configured (not the jsonTransport dev fallback). */
+function isSmtpConfigured() {
+ return !!_smtpConfig().host;
+}
+
async function sendMail({ to, subject, html, text, attachments }) {
// Never send to anonymised/deleted accounts
if (!to || String(to).endsWith('@deleted.invalid')) {
@@ -417,6 +422,7 @@ function buildAccountClosedEmail({ name, dataDeleted }) {
module.exports = {
sendMail,
+ isSmtpConfigured,
emailWrapper,
ctaButton,
fallbackLink,
diff --git a/backend/src/utils/twoFactor.js b/backend/src/utils/twoFactor.js
index 6dcc4ed..a5b99a0 100644
--- a/backend/src/utils/twoFactor.js
+++ b/backend/src/utils/twoFactor.js
@@ -18,6 +18,9 @@ const TRUSTED_DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const METHODS = ['email', 'whatsapp'];
+// Roles that must use two-factor authentication.
+const REQUIRED_ROLES = ['admin', 'supervisor'];
+
// Application error carrying the HTTP status the controller should respond with.
function fail(message, statusCode = 400) {
const err = new Error(message);
@@ -67,6 +70,34 @@ async function availableMethods(user) {
return methods;
}
+/**
+ * True when this user's role must use two-factor authentication.
+ * TWO_FACTOR_REQUIRED_ROLES=false turns the requirement off (break-glass).
+ */
+function requiresTwoFactor(user) {
+ if (String(process.env.TWO_FACTOR_REQUIRED_ROLES || 'true').toLowerCase() === 'false') return false;
+ return REQUIRED_ROLES.includes(user?.role);
+}
+
+/**
+ * The channel a required-2FA user should be enrolled on, or null when no channel
+ * can actually deliver a code — SMTP not set up and no WhatsApp — in which case the
+ * requirement is skipped rather than locking the account out.
+ */
+async function enforcementMethod(user) {
+ const { isSmtpConfigured } = require('./email');
+ if (hasRealEmail(user) && isSmtpConfigured()) return 'email';
+ const { isValidZAPhone, isConfigured } = require('./whatsapp');
+ if (isValidZAPhone(user?.phoneNumber) && (await isConfigured())) return 'whatsapp';
+ return null;
+}
+
+/** True when this user must enrol in 2FA before using their account. */
+async function mustEnrolTwoFactor(user) {
+ if (!user || user.twoFactorEnabled || !requiresTwoFactor(user)) return false;
+ return (await enforcementMethod(user)) !== null;
+}
+
/**
* Sends the code and reports where it actually went. A WhatsApp failure (session
* disconnected, not configured) falls back to email so a user is never locked out
@@ -233,6 +264,10 @@ async function clearTrustedDevices(userId) {
module.exports = {
METHODS,
+ REQUIRED_ROLES,
+ requiresTwoFactor,
+ enforcementMethod,
+ mustEnrolTwoFactor,
MAX_ATTEMPTS,
generateCode,
hashCode,
diff --git a/backend/tests/requiredTwoFactor.test.js b/backend/tests/requiredTwoFactor.test.js
new file mode 100644
index 0000000..e88983a
--- /dev/null
+++ b/backend/tests/requiredTwoFactor.test.js
@@ -0,0 +1,125 @@
+process.env.JWT_SECRET = 'test-secret';
+process.env.NODE_ENV = 'test';
+
+// Two-factor is required for admins and supervisors — but only when some channel
+// can actually deliver a code, so a site with no SMTP/WhatsApp never locks them out.
+
+const mockChannels = { smtp: true, whatsapp: true };
+const mockUsers = new Map();
+
+jest.mock('../src/config/db', () => ({
+ user: { findUnique: async ({ where }) => mockUsers.get(where.id) || null },
+}));
+jest.mock('../src/utils/settingsCache', () => ({ getSettingSync: (_key, fallback) => fallback }));
+jest.mock('../src/utils/email', () => ({ isSmtpConfigured: () => mockChannels.smtp }));
+jest.mock('../src/utils/whatsapp', () => ({
+ isValidZAPhone: (p) => /^27\d{9}$/.test(String(p || '')),
+ isConfigured: async () => mockChannels.whatsapp,
+}));
+
+const jwt = require('jsonwebtoken');
+const { requiresTwoFactor, enforcementMethod, mustEnrolTwoFactor } = require('../src/utils/twoFactor');
+const { protect } = require('../src/middleware/authMiddleware');
+
+const ADMIN = { id: 'a1', name: 'Ann', email: 'ann@example.com', phoneNumber: '27821234567', role: 'admin', isActive: true, tokenVersion: 0, twoFactorEnabled: false };
+
+beforeEach(() => {
+ mockChannels.smtp = true;
+ mockChannels.whatsapp = true;
+ mockUsers.clear();
+ delete process.env.TWO_FACTOR_REQUIRED_ROLES;
+});
+
+describe('requiresTwoFactor', () => {
+ test('applies to admins and supervisors only', () => {
+ expect(requiresTwoFactor({ role: 'admin' })).toBe(true);
+ expect(requiresTwoFactor({ role: 'supervisor' })).toBe(true);
+ expect(requiresTwoFactor({ role: 'staff' })).toBe(false);
+ expect(requiresTwoFactor({ role: 'user' })).toBe(false);
+ });
+
+ test('TWO_FACTOR_REQUIRED_ROLES=false turns the requirement off', () => {
+ process.env.TWO_FACTOR_REQUIRED_ROLES = 'false';
+ expect(requiresTwoFactor({ role: 'admin' })).toBe(false);
+ });
+});
+
+describe('enforcementMethod', () => {
+ test('prefers email when SMTP is configured', async () => {
+ expect(await enforcementMethod(ADMIN)).toBe('email');
+ });
+
+ test('falls back to WhatsApp without SMTP', async () => {
+ mockChannels.smtp = false;
+ expect(await enforcementMethod(ADMIN)).toBe('whatsapp');
+ });
+
+ test('skips a guest placeholder email', async () => {
+ expect(await enforcementMethod({ ...ADMIN, email: 'x@guest.local' })).toBe('whatsapp');
+ });
+
+ test('is null when no channel can deliver a code', async () => {
+ mockChannels.smtp = false;
+ mockChannels.whatsapp = false;
+ expect(await enforcementMethod(ADMIN)).toBeNull();
+ });
+});
+
+describe('mustEnrolTwoFactor', () => {
+ test('true for an admin without 2FA', async () => {
+ expect(await mustEnrolTwoFactor(ADMIN)).toBe(true);
+ });
+
+ test('false once enrolled, for other roles, or with no channel', async () => {
+ expect(await mustEnrolTwoFactor({ ...ADMIN, twoFactorEnabled: true })).toBe(false);
+ expect(await mustEnrolTwoFactor({ ...ADMIN, role: 'staff' })).toBe(false);
+ mockChannels.smtp = false;
+ mockChannels.whatsapp = false;
+ expect(await mustEnrolTwoFactor(ADMIN)).toBe(false);
+ });
+});
+
+describe('protect', () => {
+ function run(user, claims = {}) {
+ mockUsers.set(user.id, user);
+ const token = jwt.sign({ id: user.id, role: user.role, tokenVersion: 0, ...claims }, process.env.JWT_SECRET);
+ const req = { headers: { authorization: `Bearer ${token}` } };
+ const res = { statusCode: 200, body: null };
+ res.status = (c) => { res.statusCode = c; return res; };
+ res.json = (b) => { res.body = b; return res; };
+ return new Promise((resolve) => {
+ protect(req, res, (err) => resolve({ req, res, err, passed: !err })).then(() => {
+ if (res.body) resolve({ req, res, passed: false });
+ });
+ });
+ }
+
+ test('rejects an un-enrolled admin session with TWO_FACTOR_REQUIRED', async () => {
+ const { res, passed } = await run(ADMIN);
+ expect(passed).toBe(false);
+ expect(res.statusCode).toBe(401);
+ expect(res.body.code).toBe('TWO_FACTOR_REQUIRED');
+ });
+
+ test('lets an enrolled admin through', async () => {
+ const { passed } = await run({ ...ADMIN, twoFactorEnabled: true });
+ expect(passed).toBe(true);
+ });
+
+ test('lets a regular user through without 2FA', async () => {
+ const { passed } = await run({ ...ADMIN, id: 'u1', role: 'user' });
+ expect(passed).toBe(true);
+ });
+
+ test('gives the setup wizard token a grace period', async () => {
+ const { passed } = await run(ADMIN, { setup: true });
+ expect(passed).toBe(true);
+ });
+
+ test('does not enforce when no channel can deliver a code', async () => {
+ mockChannels.smtp = false;
+ mockChannels.whatsapp = false;
+ const { passed } = await run(ADMIN);
+ expect(passed).toBe(true);
+ });
+});
diff --git a/backend/tests/twoFactorFlow.test.js b/backend/tests/twoFactorFlow.test.js
index b940fe0..cc49d4e 100644
--- a/backend/tests/twoFactorFlow.test.js
+++ b/backend/tests/twoFactorFlow.test.js
@@ -74,6 +74,7 @@ jest.mock('../src/config/db', () => mockDb);
jest.mock('../src/utils/settingsCache', () => ({ getSettingSync: (_key, fallback) => fallback }));
jest.mock('../src/utils/email', () => ({
sendMail: async (mail) => { mockSent.emails.push(mail); },
+ isSmtpConfigured: () => true,
buildTwoFactorCodeEmail: ({ code }) => ({ text: `code:${code}`, html: '' }),
}));
jest.mock('../src/utils/waMessages', () => ({ buildWATwoFactorCode: ({ code }) => `code:${code}` }));
diff --git a/frontend/src/app/dashboard/admin/users/page.tsx b/frontend/src/app/dashboard/admin/users/page.tsx
index 3efdb26..41dfbdf 100644
--- a/frontend/src/app/dashboard/admin/users/page.tsx
+++ b/frontend/src/app/dashboard/admin/users/page.tsx
@@ -187,8 +187,13 @@ export default function AdminUsersPage() {
}
};
- const resetTwoFactor = async (id: string, name: string) => {
- if (!confirm(`Turn off two-factor authentication for ${name}? They will be able to log in with just their password until they turn it back on.`)) return;
+ 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.
+ const message = role === "admin" || role === "supervisor"
+ ? `Reset two-factor authentication for ${name}? Their sessions end, and they'll set it up again with a new code at their next login.`
+ : `Turn off two-factor authentication for ${name}? They will be able to log in with just their password until they turn it back on.`;
+ if (!confirm(message)) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "PUT", authToken: token!, body: { twoFactorEnabled: false } });
await loadUsers(page);
@@ -364,7 +369,7 @@ export default function AdminUsersPage() {
{u.twoFactorEnabled && (
-
+
)}
diff --git a/frontend/src/app/dashboard/user/profile/page.tsx b/frontend/src/app/dashboard/user/profile/page.tsx
index 50382b4..ccfb541 100644
--- a/frontend/src/app/dashboard/user/profile/page.tsx
+++ b/frontend/src/app/dashboard/user/profile/page.tsx
@@ -162,7 +162,7 @@ export default function UserProfilePage() {
// loaded once per page load, so it wouldn't reflect a change made here.
const [tfEnabled, setTfEnabled] = useState(false);
const [tfMethod, setTfMethod] = useState("email");
- const [tfStep, setTfStep] = useState<"idle" | "code" | "disable">("idle");
+ const [tfStep, setTfStep] = useState<"idle" | "code" | "disable" | "change">("idle");
const [tfPassword, setTfPassword] = useState("");
const [tfCode, setTfCode] = useState("");
const [tfChallenge, setTfChallenge] = useState(null);
@@ -171,6 +171,8 @@ export default function UserProfilePage() {
// WhatsApp codes need the number that's actually saved, not one still being typed.
const canWhatsAppCodes = isValidZAPhone(user?.phoneNumber || "");
+ // Admins and supervisors must keep 2FA on; they can only change the channel.
+ const tfRequired = !!user?.twoFactorRequired;
useEffect(() => {
if (user) {
@@ -219,10 +221,11 @@ export default function UserProfilePage() {
authToken: token,
body: { challengeId: tfChallenge.challengeId, code: tfCode },
});
+ const wasEnabled = tfEnabled;
setTfEnabled(true);
setTfMethod(res.twoFactorMethod);
resetTwoFactorForm();
- setTfMsg({ type: "ok", text: "Two-factor authentication is on." });
+ setTfMsg({ type: "ok", text: wasEnabled ? `Codes will now be sent by ${TWO_FACTOR_METHOD_LABEL[res.twoFactorMethod]}.` : "Two-factor authentication is on." });
loadActivity();
} catch (e: any) {
setTfMsg({ type: "err", text: e?.message || "Failed to verify the code." });
@@ -404,40 +407,10 @@ export default function UserProfilePage() {
- {tfEnabled ? (
- <>
-
- On. When you log in we send a 6-digit code by{" "}
- {TWO_FACTOR_METHOD_LABEL[tfMethod]}. Devices you've chosen to trust skip the code for 30 days.
-
+ On. When you log in we send a 6-digit code by{" "}
+ {TWO_FACTOR_METHOD_LABEL[tfMethod]}. Devices you've chosen to trust skip the code for 30 days.
+
+ {tfRequired ? (
+ <>
+
Required for your role, so it can't be turned off.
+ {setupChallenge.enrolling && "Two-factor authentication is required for your role — this code turns it on. "}
We sent a code to {setupChallenge.destination} by {setupChallenge.method === "whatsapp" ? "WhatsApp" : "email"}.{" "}