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. -

- {tfStep !== "disable" ? ( - - ) : ( -
-
- - setTfPassword(e.target.value)} required autoComplete="current-password" /> -
-
- - -
-
- )} - - ) : tfStep === "code" && tfChallenge ? ( + {tfStep === "code" && tfChallenge ? (

- We sent a 6-digit code to {tfChallenge.destination} by {TWO_FACTOR_METHOD_LABEL[tfChallenge.method]}. Enter it to finish turning on two-factor authentication. + We sent a 6-digit code to {tfChallenge.destination} by {TWO_FACTOR_METHOD_LABEL[tfChallenge.method]}. {tfEnabled ? "Enter it to switch your codes to this channel." : "Enter it to finish turning on two-factor authentication."}

@@ -461,10 +434,52 @@ export default function UserProfilePage() {
+ ) : tfEnabled && tfStep !== "change" ? ( + <> +

+ 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.

+ + + ) : tfStep !== "disable" ? ( + + ) : ( +
+
+ + setTfPassword(e.target.value)} required autoComplete="current-password" /> +
+
+ + +
+
+ )} + ) : (

- Add a second step to logging in: after your password, we send a 6-digit code that you need to enter. + {tfEnabled + ? "Choose where your login codes go. We'll send a code there first to check it reaches you." + : "Add a second step to logging in: after your password, we send a 6-digit code that you need to enter."}

@@ -480,9 +495,16 @@ export default function UserProfilePage() { setTfPassword(e.target.value)} required autoComplete="current-password" />
- +
+ {tfEnabled && ( + + )} + +
)} {tfMsg && } diff --git a/frontend/src/app/self-service/page.tsx b/frontend/src/app/self-service/page.tsx index 5f7a420..c8a164b 100644 --- a/frontend/src/app/self-service/page.tsx +++ b/frontend/src/app/self-service/page.tsx @@ -735,6 +735,7 @@ export default function SelfServicePage() { required />

+ {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"}.{" "}