Rework self-service kiosk account lookup for privacy and safety (1.3.2)
Replaces the explicit "look up existing account" search field with automatic lookup as email/phone are entered, requires operator confirmation before any matched account's name/email/phone/preference is changed, adds a password show/hide toggle, and fixes two bugs found during testing: entering a phone number belonging to a different account could silently overwrite the form with that account's details, and re-checking an unchanged field (e.g. from tapping a ticket quantity button) could revert edits already made. Also adds a server-side check rejecting registrations whose email and phone resolve to two different existing accounts, as defense in depth. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -764,32 +764,66 @@ const createManualRegistration = async (req, res) => {
|
||||
? prefFromBody
|
||||
: (hasValidEmail && phone ? 'both' : phone ? 'whatsapp' : 'email');
|
||||
|
||||
// Always search by email AND/OR phone regardless of guestOnly
|
||||
// Also try the alternate format (27xxx ↔ 0xxx) so both representations match
|
||||
// Always search by email AND/OR phone regardless of guestOnly.
|
||||
// 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
|
||||
// whichever record happens to match first — that would let a registration
|
||||
// hijack or corrupt someone else's account. Also try the alternate phone
|
||||
// format (27xxx ↔ 0xxx) so both representations match.
|
||||
const phoneAlt = phone && phone.startsWith('27') ? '0' + phone.slice(2) : (phone && phone.length === 9 ? '27' + phone : null);
|
||||
const searchClauses = [
|
||||
...(hasValidEmail ? [{ email: user.email }] : []),
|
||||
...(phone ? [{ phoneNumber: phone }] : []),
|
||||
...(phoneAlt ? [{ phoneNumber: phoneAlt }] : []),
|
||||
];
|
||||
const existingUser = searchClauses.length > 0
|
||||
? await prisma.user.findFirst({ where: { OR: searchClauses } })
|
||||
const emailUser = hasValidEmail
|
||||
? await prisma.user.findUnique({ where: { email: user.email } })
|
||||
: null;
|
||||
const phoneUser = phone
|
||||
? await prisma.user.findFirst({ where: { OR: [{ phoneNumber: phone }, ...(phoneAlt ? [{ phoneNumber: phoneAlt }] : [])] } })
|
||||
: null;
|
||||
|
||||
if (emailUser && phoneUser && emailUser.id !== phoneUser.id) {
|
||||
res.status(409);
|
||||
throw new Error(
|
||||
`This email and phone number belong to two different existing accounts (${emailUser.name} vs ${phoneUser.name}). Please verify the visitor's details before registering.`
|
||||
);
|
||||
}
|
||||
|
||||
const existingUser = emailUser || phoneUser || null;
|
||||
|
||||
if (existingUser) {
|
||||
userId = existingUser.id;
|
||||
const updateData = {};
|
||||
// Update preference only when the caller explicitly specified one
|
||||
if (prefFromBody && validPrefs.includes(prefFromBody)) {
|
||||
updateData.notificationPreference = prefFromBody;
|
||||
// The kiosk shows the operator a diff of name/email/phone against the matched
|
||||
// account and requires explicit confirmation before submitting, so any
|
||||
// difference reaching this point is an already-confirmed correction — apply
|
||||
// it as a full overwrite rather than only filling in blanks.
|
||||
if (user.name && user.name.trim() && user.name.trim() !== existingUser.name) {
|
||||
updateData.name = user.name.trim();
|
||||
}
|
||||
// Fill in a missing contact channel with the newly supplied value, without overwriting an existing one
|
||||
if (phone && !existingUser.phoneNumber) {
|
||||
if (phone && phone !== existingUser.phoneNumber) {
|
||||
updateData.phoneNumber = phone;
|
||||
}
|
||||
if (hasValidEmail && existingUser.email !== user.email && existingUser.email.endsWith('@guest.local')) {
|
||||
if (hasValidEmail && existingUser.email !== user.email) {
|
||||
updateData.email = user.email;
|
||||
}
|
||||
|
||||
// Update preference only when the caller explicitly specified one, but validate it
|
||||
// against the contact info that will actually be on the account after this update —
|
||||
// a stale "whatsapp"/"both" preference must not survive a phone number being
|
||||
// removed, nor "email" survive an email being cleared in favor of a real phone.
|
||||
if (prefFromBody && validPrefs.includes(prefFromBody)) {
|
||||
const { isValidZAPhone } = require('../utils/whatsapp');
|
||||
const finalPhone = updateData.phoneNumber !== undefined ? updateData.phoneNumber : existingUser.phoneNumber;
|
||||
const finalEmail = updateData.email !== undefined ? updateData.email : existingUser.email;
|
||||
const finalEmailValid = !!(finalEmail && !finalEmail.endsWith('@guest.local'));
|
||||
let candidatePref = prefFromBody;
|
||||
if ((candidatePref === 'whatsapp' || candidatePref === 'both') && !isValidZAPhone(finalPhone)) {
|
||||
candidatePref = finalEmailValid ? 'email' : candidatePref;
|
||||
}
|
||||
if (candidatePref === 'email' && !finalEmailValid && isValidZAPhone(finalPhone)) {
|
||||
candidatePref = 'whatsapp';
|
||||
}
|
||||
updateData.notificationPreference = candidatePref;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await prisma.user.update({ where: { id: userId }, data: updateData }).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -482,7 +482,7 @@ const checkUserExists = async (req, res) => {
|
||||
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: { OR: searchClauses },
|
||||
select: { name: true, email: true, phoneNumber: true, notificationPreference: true },
|
||||
select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true },
|
||||
});
|
||||
|
||||
const hasEmail = !!existingUser?.email && !existingUser.email.endsWith('@guest.local');
|
||||
@@ -494,7 +494,11 @@ const checkUserExists = async (req, res) => {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user