Send activation link immediately for walk-in and manual registration accounts
Accounts created by staff on someone's behalf now get their activation link (email or WhatsApp) sent right away, instead of only on a first failed login attempt, matching what the Terms of Use already promised. This also fixed a real account with a real email being silently activated with a fixed, undisclosed password (Hope123). Also fixes the self-service kiosk's "Create an account" password field, which never actually took effect server-side, and removes the "Guest (no account)" checkboxes that no longer had any backend effect once every walk-in account started behaving the same way. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const axios = require("axios");
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
const { hashPassword } = require('../config/auth');
|
||||
@@ -760,7 +759,7 @@ const getRegistrationsByEvent = async (req, res) => {
|
||||
const createManualRegistration = async (req, res) => {
|
||||
let userRecord;
|
||||
try {
|
||||
const { eventId, options, user, guestOnly, notificationPreference: prefFromBody } = req.body;
|
||||
const { eventId, options, user, notificationPreference: prefFromBody, skipActivationNotice } = req.body;
|
||||
|
||||
if (!eventId || !options || !user || !user.name || (!user.email && !user.phoneNumber)) {
|
||||
res.status(400);
|
||||
@@ -844,7 +843,7 @@ const createManualRegistration = async (req, res) => {
|
||||
? prefFromBody
|
||||
: (hasValidEmail && phone ? 'both' : phone ? 'whatsapp' : 'email');
|
||||
|
||||
// Always search by email AND/OR phone regardless of guestOnly.
|
||||
// Always search by email AND/OR phone.
|
||||
// Resolve each channel independently (rather than a single findFirst with an OR
|
||||
// across both) so that an email belonging to one account and a phone number
|
||||
// belonging to a *different* account can never be silently collapsed into
|
||||
@@ -907,42 +906,58 @@ const createManualRegistration = async (req, res) => {
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await prisma.user.update({ where: { id: userId }, data: updateData }).catch(() => {});
|
||||
}
|
||||
} else if (!guestOnly && hasValidEmail) {
|
||||
// Create a real active account (non-guest with email)
|
||||
try {
|
||||
const password = 'Hope123';
|
||||
const response = await axios.post(
|
||||
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'}/api/users`,
|
||||
{ name: user.name, email: user.email, password, phoneNumber: phone || null }
|
||||
);
|
||||
const createdUser = response.data.user || response.data;
|
||||
if (!createdUser?.id) { res.status(400); throw new Error('User creation failed: No user ID returned'); }
|
||||
userId = createdUser.id;
|
||||
// Set derived preference on the new account
|
||||
await prisma.user.update({ where: { id: userId }, data: { notificationPreference: derivedPref } }).catch(() => {});
|
||||
} catch (userErr) {
|
||||
res.status(400);
|
||||
throw new Error(`Failed to create user: ${userErr.response?.data?.message || userErr.message}`);
|
||||
}
|
||||
} else {
|
||||
// Guest path: phone-only, guestOnly=true, or no valid email
|
||||
const placeholderEmail = hasValidEmail
|
||||
? user.email
|
||||
: `guest+${uuidv4().slice(0, 8)}@guest.local`;
|
||||
const hashed = await hashPassword(uuidv4());
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
name: user.name,
|
||||
email: placeholderEmail,
|
||||
password: hashed,
|
||||
phoneNumber: phone || null,
|
||||
isActive: false,
|
||||
notificationPreference: derivedPref,
|
||||
updatedAt: new Date(),
|
||||
const suppliedPassword = typeof user.password === 'string' && user.password.trim().length >= 6
|
||||
? user.password.trim()
|
||||
: null;
|
||||
|
||||
if (hasValidEmail && suppliedPassword) {
|
||||
// Caller supplied their own password (the self-service kiosk, where the
|
||||
// visitor sets it themselves on the spot) — activate immediately, since
|
||||
// there's nothing left for them to do via an activation link.
|
||||
const hashed = await hashPassword(suppliedPassword);
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
password: hashed,
|
||||
phoneNumber: phone || null,
|
||||
isActive: true,
|
||||
notificationPreference: derivedPref,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
});
|
||||
userId = created.id;
|
||||
} else {
|
||||
// New account: uses the real email if a valid one was given, otherwise a
|
||||
// guest.local placeholder (phone-only registration). Always created inactive
|
||||
// with a random password — the visitor activates it themselves via the link
|
||||
// sent immediately below (email or WhatsApp), unless the caller explicitly
|
||||
// opted out of that nudge (e.g. a self-service visitor who declined to
|
||||
// create an account at all).
|
||||
const placeholderEmail = hasValidEmail
|
||||
? user.email
|
||||
: `guest+${uuidv4().slice(0, 8)}@guest.local`;
|
||||
const hashed = await hashPassword(uuidv4());
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
name: user.name,
|
||||
email: placeholderEmail,
|
||||
password: hashed,
|
||||
phoneNumber: phone || null,
|
||||
isActive: false,
|
||||
notificationPreference: derivedPref,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
});
|
||||
userId = created.id;
|
||||
if (!skipActivationNotice) {
|
||||
const { sendActivationLink } = require('./userController');
|
||||
sendActivationLink(created);
|
||||
}
|
||||
});
|
||||
userId = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge into existing non-cancelled registration if one exists, otherwise create new
|
||||
|
||||
@@ -23,6 +23,53 @@ function getClientIp(req) {
|
||||
|
||||
const PRIVATE_IP_RE = /^(::1|::ffff:127\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
|
||||
|
||||
// Fire-and-forget: create a 24h activation token and deliver it to an inactive
|
||||
// account — via email if it has a real (non-guest) address, otherwise via
|
||||
// WhatsApp if it has a phone number. Used both when a login attempt hits an
|
||||
// inactive account, and immediately when an admin/supervisor creates an
|
||||
// account on someone's behalf (walk-in / manual registration).
|
||||
async function sendActivationLink(user) {
|
||||
const hasRealEmail = !!(user?.email && !user.email.endsWith('@guest.local'));
|
||||
if (!hasRealEmail && !user?.phoneNumber) return;
|
||||
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)}`;
|
||||
|
||||
if (hasRealEmail) {
|
||||
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));
|
||||
} else {
|
||||
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] Failed to create activation token:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget: send a login notification email with approximate geo location
|
||||
async function sendLoginNotification(user, req) {
|
||||
try {
|
||||
@@ -176,61 +223,16 @@ const loginUser = async (req, res) => {
|
||||
|
||||
// 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
|
||||
// Resend the activation link on each failed login attempt against an inactive
|
||||
// account, in case the original one (sent at creation, or a prior attempt) expired.
|
||||
await sendActivationLink(user);
|
||||
// If the account has a real email (not a guest placeholder), it went out 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
|
||||
// No real email — if they have a phone number, it went out 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.');
|
||||
}
|
||||
@@ -970,4 +972,5 @@ module.exports = {
|
||||
adminRevokeUserSessions,
|
||||
closeAccount,
|
||||
getMyActivity,
|
||||
sendActivationLink,
|
||||
};
|
||||
Reference in New Issue
Block a user