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>
973 lines
35 KiB
JavaScript
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,
|
|
}; |