From d6da2c82279034ccd983328aeb57d54d6079c326 Mon Sep 17 00:00:00 2001 From: joshua Date: Mon, 24 Aug 2026 13:29:38 +0200 Subject: [PATCH 1/3] 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 --- CHANGELOG.md | 7 ++ .../src/controllers/registrationController.js | 89 ++++++++------- backend/src/controllers/userController.js | 103 +++++++++--------- .../dashboard/supervisor/at-the-door/page.tsx | 3 +- .../supervisor/manual-registration/page.tsx | 9 +- .../app/dashboard/supervisor/manual/page.tsx | 15 +-- frontend/src/app/self-service/page.tsx | 4 +- .../src/content/help/supervisor-manual.tsx | 2 +- 8 files changed, 121 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6597773..a568876 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed + +- Accounts created on someone's behalf (at-the-door walk-in registration, or manual registration from the Admin/Supervisor dashboard) now get their activation link (email or WhatsApp, whichever they have) sent immediately when the account is created, instead of only on their first failed login attempt — matching what the Terms of Use already promised. +- Manual registration with a real email address used to create the account already active with a fixed, undisclosed password (`Hope123`) — the visitor had no way to know it. That account is now created inactive and gets the same immediate activation link, so the visitor sets their own password — unless a password was supplied directly (see below), in which case it's activated immediately with no link needed. +- The self-service kiosk's "Create an account" password field never actually worked — the account was always created with a different password behind the scenes, so visitors who set one couldn't log in with it. Manual registration now honours a caller-supplied password and activates the account immediately instead of discarding it. +- Removed the "Guest (no account)" checkboxes from the Manual Registration pages (both the current one and the legacy form) and the equivalent flag from the at-the-door kiosk — they stopped affecting backend behaviour once every walk-in account started being created inactive with an activation link. The self-service kiosk's own "Create an account" toggle still controls whether that link is sent, since that one is the visitor's own choice rather than staff acting on their behalf. + ## [1.9.2] - 2026-08-22 ### Fixed diff --git a/backend/src/controllers/registrationController.js b/backend/src/controllers/registrationController.js index cb4779e..d67f6df 100644 --- a/backend/src/controllers/registrationController.js +++ b/backend/src/controllers/registrationController.js @@ -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 diff --git a/backend/src/controllers/userController.js b/backend/src/controllers/userController.js index 0dd4b17..e42e2e4 100644 --- a/backend/src/controllers/userController.js +++ b/backend/src/controllers/userController.js @@ -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, }; \ No newline at end of file diff --git a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx index 57e703f..1c1b520 100644 --- a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx +++ b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx @@ -242,7 +242,6 @@ export default function AtTheDoorPage() { authToken: token, body: { eventId, - guestOnly: pendingUser.guestOnly, user: { name: pendingUser.name, ...(pendingUser.email ? { email: pendingUser.email } : {}), @@ -282,7 +281,7 @@ export default function AtTheDoorPage() { }); setQuantities(qtyMap); setMinQuantities({}); - setPendingUser({ guestOnly: true, name, email: email || null, phone: phone || null, notifPref }); + setPendingUser({ name, email: email || null, phone: phone || null, notifPref }); setPendingEditReg(null); setShowNewAttendeeModal(false); setShowOptionsModal(true); diff --git a/frontend/src/app/dashboard/supervisor/manual-registration/page.tsx b/frontend/src/app/dashboard/supervisor/manual-registration/page.tsx index 3ba415b..f4568d8 100644 --- a/frontend/src/app/dashboard/supervisor/manual-registration/page.tsx +++ b/frontend/src/app/dashboard/supervisor/manual-registration/page.tsx @@ -18,7 +18,6 @@ export default function ManualRegistrationPage() { const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [phoneNumber, setPhoneNumber] = useState(""); - const [registerAsGuest, setRegisterAsGuest] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useDismissingState(null); const [createdReg, setCreatedReg] = useState(null); @@ -43,7 +42,6 @@ export default function ManualRegistrationPage() { eventId, options: [{ eventOptionId: optionId, quantity }], user: { name, ...(email ? { email } : {}), ...(phoneNumber ? { phoneNumber } : {}) }, - guestOnly: registerAsGuest, }, }); setCreatedReg(res); @@ -89,10 +87,7 @@ export default function ManualRegistrationPage() { setName(e.target.value)} required />
-
- - -
+ setEmail(e.target.value)} placeholder="email@example.com" />
@@ -100,7 +95,7 @@ export default function ManualRegistrationPage() { setPhoneNumber(e.target.value)} placeholder="+27…" /> -

At least one of email or cell number is required. If no email is provided, a guest account is created automatically.

+

At least one of email or cell number is required. The account is created inactive, and an activation link is sent immediately (by email if provided, otherwise WhatsApp) so the attendee can set their own password.

{error &&

{error}

}