Files
hope-events/backend/src/controllers/userController.js
T
joshuaandClaude Sonnet 5 86af7093ac Add admin-configurable branding (colors, logo, favicon) and generic default fallbacks
Site Settings -> Branding now supports a Primary/Secondary/Accent brand color
system applied site-wide (buttons, nav, hover states, links) and to outgoing
email header/CTA colors, plus a favicon upload alongside the existing logo
upload, a live preview panel (website/email x desktop/mobile), and
logo-based color suggestions. The setup wizard's Branding step got the same
treatment. Fixes two related bugs found along the way: the setup wizard's
logo/favicon upload was missing its auth token, and a static favicon.ico in
Next's special app/ convention path was silently overriding the dynamic one.

Also replaces every "Hope Events"/"Hope Family Church" default (org name,
email subjects, WhatsApp messages, report metadata, API docs) with a neutral
"Cross Code" placeholder, and the optional legal settings (operator name, IO
details, website URL, effective date) with obviously-generic placeholders
instead of defaulting to real personal/organisational details -- since this
platform is deployed for multiple organisations. Adds SETTINGS.md documenting
every setting's default behaviour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 14:49:51 +02:00

973 lines
35 KiB
JavaScript

const prisma = require('../config/db');
const { generateToken, hashPassword, comparePassword } = require('../config/auth');
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents');
const { getSettingSync } = require('../utils/settingsCache');
const axios = require('axios');
// ─── Helpers ────────────────────────────────────────────────────────────────
// Org name for email subjects — falls back to Cross Code (the platform vendor)
// rather than any specific customer's name when unconfigured.
function getOrgName() {
return getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
}
// Resolve a client IP from the request (works behind proxies)
function getClientIp(req) {
const forwarded = req.headers['x-forwarded-for'];
if (forwarded) return forwarded.split(',')[0].trim();
return req.socket?.remoteAddress || 'unknown';
}
const PRIVATE_IP_RE = /^(::1|::ffff:127\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
// Fire-and-forget: send a login notification email with approximate geo location
async function sendLoginNotification(user, req) {
try {
const ip = getClientIp(req);
const userAgent = req.headers['user-agent'] || 'Unknown device';
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
let location = 'Unknown location';
if (ip !== 'unknown' && !PRIVATE_IP_RE.test(ip)) {
try {
const geo = await axios.get(
`http://ip-api.com/json/${ip}?fields=status,city,regionName,country`,
{ timeout: 3000 }
);
if (geo.data?.status === 'success') {
const parts = [geo.data.city, geo.data.regionName, geo.data.country].filter(Boolean);
if (parts.length) location = parts.join(', ');
}
} catch { /* geo lookup failure is non-fatal */ }
}
const { sendMail, buildLoginNotificationEmail } = require('../utils/email');
const { buildWALogin } = require('../utils/waMessages');
const content = buildLoginNotificationEmail({ name: user.name, when, location, userAgent });
// Security: always email; also WhatsApp if preferred
await sendMail({ to: user.email, subject: `New login to your ${getOrgName()} account`, ...content });
const { waText } = require('../utils/notify');
await waText(user, buildWALogin({ name: user.name, when, location, userAgent })).catch(() => {});
} catch (e) {
console.warn('[login notification] Failed:', e?.message || e);
}
}
// Fire-and-forget: send welcome email with next upcoming events
async function sendWelcomeEmail(user) {
try {
const events = await prisma.event.findMany({
where: { isActive: true, startDate: { gt: new Date() } },
orderBy: { startDate: 'asc' },
take: 3,
select: { title: true, startDate: true },
});
const { sendMail, buildWelcomeEmail } = require('../utils/email');
const { buildWAWelcome } = require('../utils/waMessages');
const content = buildWelcomeEmail({ name: user.name, events });
const { shouldEmail, waText } = require('../utils/notify');
// Welcome is always sent via email; also via WhatsApp if preferred
await sendMail({ to: user.email, subject: `Welcome to ${getOrgName()}!`, ...content });
await waText(user, buildWAWelcome({ name: user.name, events })).catch(() => {});
} catch (e) {
console.warn('[welcome email] Failed:', e?.message || e);
}
}
// @desc Register a new user
// @route POST /api/users
// @access Public
const registerUser = async (req, res) => {
try {
const { name, email, password, phoneNumber, notificationPreference } = req.body;
// Normalize phone using SA-aware normalisation
const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp');
const phone = normalizeZAPhone(phoneNumber) || (phoneNumber ? phoneNumber.replace(/\D/g, '') || null : null);
// Validate preference — WhatsApp requires a valid phone number
const allowedPrefs = ['email', 'whatsapp', 'both'];
let pref = allowedPrefs.includes(notificationPreference) ? notificationPreference : 'email';
if ((pref === 'whatsapp' || pref === 'both') && !isValidZAPhone(phoneNumber)) {
pref = 'email'; // silently fall back if no valid number
}
// Check if user already exists
const userExists = await prisma.user.findFirst({
where: {
OR: [
{ email },
...(phone ? [{ phoneNumber: phone }] : [])
]
}
});
if (userExists) {
res.status(400);
throw new Error('User already exists');
}
// Hash password
const hashedPassword = await hashPassword(password);
// Create user
const user = await prisma.user.create({
data: {
id: uuidv4(),
name,
email,
password: hashedPassword,
phoneNumber: phone,
notificationPreference: pref,
updatedAt: new Date()
}
});
if (user) {
// Send welcome email in the background — don't block the response
sendWelcomeEmail(user).catch(() => {});
res.status(201).json({
id: user.id,
name: user.name,
email: user.email,
role: user.role,
token: generateToken(user.id, user.role, user.tokenVersion)
});
} else {
res.status(400);
throw new Error('Invalid user data');
}
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Auth user & get token
// @route POST /api/users/login
// @access Public
const loginUser = async (req, res) => {
try {
const {email, password} = req.body;
const rawInput = email;
// Normalize phone input — convert SA local format (0xx) → international (27xx)
const { normalizeZAPhone } = require('../utils/whatsapp');
const normalizedPhone = normalizeZAPhone(rawInput) || rawInput.replace(/\D/g, '');
// Check for user email or phone
const user = await prisma.user.findFirst({
where: {
OR: [
{ email: rawInput },
...(normalizedPhone ? [{ phoneNumber: normalizedPhone }] : []),
]
}
});
if (!user) {
res.status(401);
throw new Error('User does not exist');
}
// Check if user is active
if (!user.isActive) {
// If the account has a real email (not a guest placeholder), send an activation link via email
if (user.email && !user.email.endsWith('@guest.local')) {
try {
const token = uuidv4();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false },
data: { used: true }
});
await prisma.passwordReset.create({
data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false }
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
sendMail({ to: user.email, subject: `Activate your ${getOrgName()} account`, ...content })
.catch(e => console.warn('[activation email] Failed:', e?.message || e));
} catch (e) {
console.warn('[activation token] Failed to create activation token:', e?.message || e);
}
res.status(401);
throw new Error('Your account is not yet active. We\'ve sent you an email with a link to activate your account.');
}
// No real email — if they have a phone number, send the activation link via WhatsApp
if (user.phoneNumber) {
try {
const token = uuidv4();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false },
data: { used: true }
});
await prisma.passwordReset.create({
data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false }
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
const orgName = getOrgName();
const waMessage = [
`🔓 *Activate your ${orgName} account*`,
'',
`Hi ${user.name || 'there'},`,
'',
`Your account needs to be activated before you can log in. Tap the link below to set a password and activate your account:`,
'',
activationUrl,
'',
`_This link expires in 24 hours._`,
].join('\n');
const { waTextAny } = require('../utils/notify');
waTextAny(user, waMessage).catch(e => console.warn('[activation WA] Failed:', e?.message || e));
} catch (e) {
console.warn('[activation token WA] Failed to create activation token:', e?.message || e);
}
res.status(401);
throw new Error('Your account is not yet active. We\'ve sent you a WhatsApp message with a link to activate your account.');
}
res.status(401);
throw new Error('Your account has been deactivated');
}
const MAX_ATTEMPTS = 5;
const LOCK_TIME = 5 * 60 * 1000; // 5 min
// Check lock
if (user.lockUntil && user.lockUntil > new Date()) {
const now = new Date().getTime();
const lockTime = new Date(user.lockUntil).getTime();
const diffMs = lockTime - now;
const diffMinutes = Math.ceil(diffMs / (1000 * 60));
throw new Error(`Too many attempts. Try again in ${diffMinutes} minute(s).`);
}
// Check password
const isMatch = await comparePassword(password, user.password);
if (!isMatch) {
const attempts = user.failedAttempts + 1;
await prisma.user.update({
where: { id: user.id },
data: {
failedAttempts: attempts,
lockUntil:
attempts >= MAX_ATTEMPTS
? new Date(Date.now() + LOCK_TIME)
: null,
},
});
throw new Error("Invalid email or password");
}
// ✅ SUCCESS → reset attempts
const updated = await prisma.user.update({
where: { id: user.id },
data: { failedAttempts: 0, lockUntil: null },
select: { id: true, name: true, email: true, role: true, tokenVersion: true, phoneNumber: true, notificationPreference: true },
});
// Send login notification in the background
sendLoginNotification(updated, req).catch(() => {});
logSecurityEvent({ userId: updated.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
res.json({
id: updated.id,
name: updated.name,
email: updated.email,
role: updated.role,
token: generateToken(updated.id, updated.role, updated.tokenVersion)
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Get user profile
// @route GET /api/users/profile
// @access Private
const getUserProfile = async (req, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user.id },
select: {
id: true,
name: true,
email: true,
role: true,
phoneNumber: true,
notificationPreference: true,
createdAt: true,
updatedAt: true,
isActive: true
}
});
if (user) {
res.json(user);
} else {
res.status(404);
throw new Error('User not found');
}
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Update user profile
// @route PUT /api/users/profile
// @access Private
const updateUserProfile = async (req, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user.id }
});
if (!user) {
res.status(404);
throw new Error('User not found');
}
const { name, email, password, phoneNumber, currentPassword, notificationPreference } = req.body || {};
// If a password change is requested, verify current password for safety
let newHashedPassword = undefined;
if (typeof password === 'string' && password.trim().length > 0) {
const newPw = password.trim();
if (!currentPassword || typeof currentPassword !== 'string' || currentPassword.length === 0) {
res.status(400);
throw new Error('Current password is required to set a new password');
}
const matches = await comparePassword(currentPassword, user.password);
if (!matches) {
res.status(400);
throw new Error('Current password is incorrect');
}
if (newPw.length < 8) {
res.status(400);
throw new Error('New password must be at least 8 characters long');
}
newHashedPassword = await hashPassword(newPw);
}
// Normalize phone
const { normalizeZAPhone } = require('../utils/whatsapp');
let newPhone = user.phoneNumber;
if (phoneNumber !== undefined) {
newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null;
}
// Validate notification preference — WhatsApp requires a valid SA phone number
const { resolveNotificationPreference } = require('../utils/notificationPreference');
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
// Update user data
const updatedUser = await prisma.user.update({
where: { id: req.user.id },
data: {
name: name || user.name,
email: email || user.email,
password: newHashedPassword ? newHashedPassword : user.password,
phoneNumber: newPhone,
notificationPreference: newPref,
updatedAt: new Date()
},
select: {
id: true,
name: true,
email: true,
role: true,
phoneNumber: true,
notificationPreference: true,
createdAt: true,
updatedAt: true,
isActive: true,
tokenVersion: true
}
});
// If the password was changed, send a security alert email (fire-and-forget)
if (newHashedPassword) {
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail });
sendMail({ to: updatedUser.email, subject: `Your ${getOrgName()} password was changed`, ...content })
.catch(e => console.warn('[email] Failed to send password changed alert:', e?.message || e));
const { waText } = require('../utils/notify');
waText(updatedUser, content.text).catch(() => {});
logSecurityEvent({ userId: updatedUser.id, type: 'password_changed', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
}
res.json({
...updatedUser,
token: generateToken(updatedUser.id, updatedUser.role, updatedUser.tokenVersion)
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Get all users
// @route GET /api/users
// @access Private/Admin
const getUsers = async (req, res) => {
try {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100));
const skip = (page - 1) * limit;
const select = {
id: true, name: true, email: true, role: true,
phoneNumber: true, notificationPreference: true, createdAt: true, updatedAt: true, isActive: true
};
// Build filters
const where = {};
if (req.query.isActive !== undefined) {
where.isActive = req.query.isActive === 'true';
}
if (req.query.role) {
where.role = req.query.role;
}
if (req.query.search) {
const s = req.query.search.trim();
where.OR = [
{ name: { contains: s, mode: 'insensitive' } },
{ email: { contains: s, mode: 'insensitive' } },
{ phoneNumber: { contains: s, mode: 'insensitive' } },
];
}
const [users, total] = await prisma.$transaction([
prisma.user.findMany({ where, select, orderBy: { name: 'asc' }, skip, take: limit }),
prisma.user.count({ where })
]);
res.json({ data: users, total, page, limit, pages: Math.ceil(total / limit) });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Check whether an account already exists for a given email and/or phone
// @route GET /api/users/check-exists
// @access Private/Supervisor
const checkUserExists = async (req, res) => {
try {
const email = typeof req.query.email === 'string' ? req.query.email.trim() : '';
const rawPhone = typeof req.query.phone === 'string' ? req.query.phone.trim() : '';
const { normalizeZAPhone } = require('../utils/whatsapp');
const phone = normalizeZAPhone(rawPhone) || (rawPhone ? rawPhone.replace(/\D/g, '') : '');
const phoneAlt = phone && phone.startsWith('27') ? '0' + phone.slice(2) : (phone && phone.length === 9 ? '27' + phone : null);
const searchClauses = [
...(email ? [{ email }] : []),
...(phone ? [{ phoneNumber: phone }] : []),
...(phoneAlt ? [{ phoneNumber: phoneAlt }] : []),
];
if (searchClauses.length === 0) {
return res.json({ exists: false });
}
const existingUser = await prisma.user.findFirst({
where: { OR: searchClauses },
select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true },
});
const hasEmail = !!existingUser?.email && !existingUser.email.endsWith('@guest.local');
const hasPhone = !!existingUser?.phoneNumber;
res.json({
exists: !!existingUser,
hasEmail,
hasPhone,
// Safe-to-display fields only, for autofilling a lookup form — never the password.
// Guest placeholder emails are withheld the same way hasEmail already treats them.
// `id` lets the kiosk tell two different matched accounts apart (e.g. when the
// typed email and phone number resolve to different people) — it's never shown,
// only compared client-side, and this endpoint is already Private/Supervisor.
user: existingUser ? {
id: existingUser.id,
name: existingUser.name,
email: hasEmail ? existingUser.email : null,
phoneNumber: hasPhone ? existingUser.phoneNumber : null,
notificationPreference: existingUser.notificationPreference,
} : null,
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Get user by ID
// @route GET /api/users/:id
// @access Private/Admin
const getUserById = async (req, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.params.id },
select: {
id: true,
name: true,
email: true,
role: true,
phoneNumber: true,
createdAt: true,
updatedAt: true,
isActive: true
}
});
if (user) {
res.json(user);
} else {
res.status(404);
throw new Error('User not found');
}
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Update user
// @route PUT /api/users/:id
// @access Private/Admin
const updateUser = async (req, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.params.id }
});
if (!user) {
res.status(404);
throw new Error('User not found');
}
const { name, email, role, isActive, phoneNumber, password, notificationPreference } = req.body;
const newPhone = phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber;
const { resolveNotificationPreference } = require('../utils/notificationPreference');
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
// Prepare data update, allow admin to set a new password
const data = {
name: name || user.name,
email: email || user.email,
role: role || user.role,
isActive: isActive !== undefined ? isActive : user.isActive,
phoneNumber: newPhone,
notificationPreference: newPref,
updatedAt: new Date()
};
if (password && typeof password === 'string' && password.trim().length > 0) {
data.password = await hashPassword(password.trim());
}
const updatedUser = await prisma.user.update({
where: { id: req.params.id },
data,
select: {
id: true,
name: true,
email: true,
role: true,
phoneNumber: true,
notificationPreference: true,
createdAt: true,
updatedAt: true,
isActive: true
}
});
res.json(updatedUser);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Delete user
// @route DELETE /api/users/:id
// @access Private/Admin
const deleteUser = async (req, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.params.id }
});
if (!user) {
res.status(404);
throw new Error('User not found');
}
// Instead of deleting, we deactivate the user
await prisma.user.update({
where: { id: req.params.id },
data: {
isActive: false,
updatedAt: new Date()
}
});
res.json({ message: 'User deactivated' });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Anonymize user data (GDPR / "right to be forgotten")
// @route POST /api/users/:id/anonymize
// @access Private/Admin
const anonymizeUser = async (req, res) => {
try {
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
if (!user) { res.status(404); throw new Error('User not found'); }
await prisma.user.update({
where: { id: req.params.id },
data: {
name: 'Deleted User',
email: `deleted-${req.params.id}@deleted.local`,
phoneNumber: null,
isActive: false,
tokenVersion: { increment: 1 },
updatedAt: new Date()
}
});
res.json({ message: 'User data deleted' });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Request password reset
// @route POST /api/users/forgot
// @access Public
const requestPasswordReset = async (req, res) => {
try {
const { email } = req.body;
if (!email || typeof email !== 'string') {
res.status(400);
throw new Error('Email is required');
}
const user = await prisma.user.findUnique({ where: { email } });
// Explicitly check existence as requested
if (!user) {
return res.status(404).json({ message: 'Email not found' });
}
// Create token valid for 1 hour
const token = uuidv4();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
// If PasswordReset model isn't available (migration not run), return clear error
if (!prisma.passwordReset || typeof prisma.passwordReset.create !== 'function' || typeof prisma.passwordReset.updateMany !== 'function') {
console.warn('[PasswordReset] Prisma model not available. Run Prisma migrations to enable password reset tokens.');
return res.status(500).json({ message: 'Password reset is not available. Please contact support.' });
}
// Invalidate previous tokens (optional)
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
data: { used: true }
});
await prisma.passwordReset.create({
data: {
id: uuidv4(),
userId: user.id,
token,
expiresAt,
used: false
}
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
// Security: always email
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
// Also WhatsApp if preferred (security message — sent in addition to email)
const { waText } = require('../utils/notify');
waText(user, emailContent.text).catch(() => {});
return res.json({ message: 'If that email exists, a password reset link has been sent.' });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Reset password using token
// @route POST /api/users/reset
// @access Public
const resetPassword = async (req, res) => {
try {
const { token, password } = req.body;
if (!token || !password) {
res.status(400);
throw new Error('Token and new password are required');
}
// If PasswordReset model isn't available, avoid crashing and return a generic error
if (!prisma.passwordReset || typeof prisma.passwordReset.findUnique !== 'function' || typeof prisma.passwordReset.update !== 'function') {
res.status(400);
throw new Error('Invalid or expired token');
}
const reset = await prisma.passwordReset.findUnique({ where: { token } });
if (!reset || reset.used || reset.expiresAt < new Date()) {
res.status(400);
throw new Error('Invalid or expired token');
}
const user = await prisma.user.findUnique({ where: { id: reset.userId } });
if (!user || !user.isActive) {
res.status(400);
throw new Error('User not found or inactive');
}
const newHashed = await hashPassword(password);
await prisma.$transaction([
prisma.user.update({ where: { id: user.id }, data: { password: newHashed, updatedAt: new Date() } }),
prisma.passwordReset.update({ where: { token }, data: { used: true } })
]);
logSecurityEvent({ userId: user.id, type: 'password_reset', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
res.json({ message: 'Password has been reset successfully' });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Activate account using token (for inactive/guest accounts)
// @route POST /api/users/activate
// @access Public
const activateAccount = async (req, res) => {
try {
const { token, password } = req.body;
if (!token || !password) {
res.status(400);
throw new Error('Token and password are required');
}
if (typeof password !== 'string' || password.trim().length < 8) {
res.status(400);
throw new Error('Password must be at least 8 characters');
}
if (!prisma.passwordReset || typeof prisma.passwordReset.findUnique !== 'function') {
res.status(400);
throw new Error('Invalid or expired token');
}
const reset = await prisma.passwordReset.findUnique({ where: { token } });
if (!reset || reset.used || reset.expiresAt < new Date()) {
res.status(400);
throw new Error('Invalid or expired activation link');
}
const user = await prisma.user.findUnique({ where: { id: reset.userId } });
if (!user) {
res.status(400);
throw new Error('User not found');
}
const newHashed = await hashPassword(password.trim());
await prisma.$transaction([
prisma.user.update({
where: { id: user.id },
data: { password: newHashed, isActive: true, updatedAt: new Date() }
}),
prisma.passwordReset.update({ where: { token }, data: { used: true } })
]);
const updated = await prisma.user.findUnique({
where: { id: user.id },
select: { id: true, name: true, email: true, role: true, tokenVersion: true }
});
// Send welcome email (skip guest/placeholder accounts)
if (updated.email && !updated.email.endsWith('@guest.local')) {
sendWelcomeEmail(updated).catch(() => {});
}
res.json({
message: 'Account activated successfully.',
id: updated.id,
name: updated.name,
email: updated.email,
role: updated.role,
token: generateToken(updated.id, updated.role, updated.tokenVersion)
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Revoke all active sessions for the logged-in user (increments tokenVersion)
// @route POST /api/users/revoke-sessions
// @access Private
const revokeMySession = async (req, res) => {
try {
const updated = await prisma.user.update({
where: { id: req.user.id },
data: { tokenVersion: { increment: 1 } },
select: { id: true, role: true, tokenVersion: true },
});
// Bumping tokenVersion invalidates every existing token, including the one
// this request just used. Issue a fresh token for the current device so it
// isn't logged out too.
res.json({
message: 'All other sessions have been signed out.',
token: generateToken(updated.id, updated.role, updated.tokenVersion),
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Admin: revoke all sessions for a specific user
// @route POST /api/users/:id/revoke-sessions
// @access Private/Admin
const adminRevokeUserSessions = async (req, res) => {
try {
const target = await prisma.user.findUnique({ where: { id: req.params.id } });
if (!target) {
res.status(404);
throw new Error('User not found');
}
await prisma.user.update({
where: { id: req.params.id },
data: { tokenVersion: { increment: 1 } },
});
res.json({ message: `Sessions revoked for ${target.name}.` });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Close (and optionally anonymise) the logged-in user's own account
// @route POST /api/users/close-account
// @access Private
const closeAccount = async (req, res) => {
try {
const { deleteData = false, password } = req.body || {};
// Re-verify password before allowing account closure
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
if (!user) { res.status(404); throw new Error('User not found'); }
const passwordOk = await comparePassword(password, user.password);
if (!passwordOk) {
res.status(400);
throw new Error('Incorrect password');
}
// Capture real details before any anonymisation so the email goes to the right address
const realEmail = user.email;
const realName = user.name;
if (deleteData) {
// Anonymise: wipe all personal data while keeping the row intact for referential integrity
await prisma.user.update({
where: { id: req.user.id },
data: {
name: 'Deleted User',
email: `deleted-${uuidv4()}@deleted.invalid`,
password: '',
phoneNumber: null,
isActive: false,
tokenVersion: { increment: 1 },
updatedAt: new Date(),
},
});
// Send closure confirmation to the real address (before it was wiped)
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
const { waText } = require('../utils/notify');
const { buildWAAccountClosed } = require('../utils/waMessages');
const content = buildAccountClosedEmail({ name: realName, dataDeleted: true });
sendMail({ to: realEmail, subject: `Your ${getOrgName()} account has been closed`, ...content }).catch(() => {});
// WhatsApp while we still have phone (send before data wipe completes in-flight)
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: true })).catch(() => {});
}
return res.json({ message: 'Your account and personal data have been removed.' });
} else {
// Soft-deactivate only
await prisma.user.update({
where: { id: req.user.id },
data: {
isActive: false,
tokenVersion: { increment: 1 },
updatedAt: new Date(),
},
});
// Send closure confirmation
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
const { waText } = require('../utils/notify');
const { buildWAAccountClosed } = require('../utils/waMessages');
const content = buildAccountClosedEmail({ name: realName, dataDeleted: false });
sendMail({ to: realEmail, subject: `Your ${getOrgName()} account has been closed`, ...content }).catch(() => {});
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: false })).catch(() => {});
}
return res.json({ message: 'Your account has been deactivated.' });
}
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Recent account activity (logins, password changes) for the current user
// @route GET /api/users/activity
// @access Private
const getMyActivity = async (req, res) => {
try {
const events = await getRecentSecurityEvents(req.user.id, 10);
res.json(events);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
module.exports = {
registerUser,
loginUser,
getUserProfile,
updateUserProfile,
getUsers,
checkUserExists,
getUserById,
updateUser,
deleteUser,
anonymizeUser,
requestPasswordReset,
resetPassword,
activateAccount,
revokeMySession,
adminRevokeUserSessions,
closeAccount,
getMyActivity,
};