diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7a7b25f..64fe61a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,22 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
+## [1.3.2] - 2026-08-03
+
+### Added
+
+- Self-service kiosk: all password fields (supervisor sign-in, change event, and the visitor "Choose a password" field) now have a show/hide toggle button, so staff can verify what they've typed on the touchscreen instead of typing blind.
+
+### Fixed
+
+- Self-service kiosk: removed the separate "Look up existing account" search field — for privacy, staff no longer type a visitor's email/phone into a dedicated search box. Instead, entering an email or phone number in the registration form itself (Email and Cell Number are now the first two fields, followed by Name) automatically checks for a matching account once that field is left.
+- Self-service kiosk: matched accounts are no longer updated silently. If the operator's typed Name, Email, Cell Number, or "Send tickets via" preference differs from what's on file, a confirmation dialog now lists exactly what will change (old value → new value) and requires the operator to confirm before the account is updated.
+- Self-service kiosk / manual registration: an existing account's name is now actually updated when confirmed changed (previously silently discarded), and email/phone corrections are applied even when the account already had a real value on file (previously only blank phone numbers or guest-placeholder emails could be replaced).
+- Self-service kiosk: fixed a bug where changing the phone number to one belonging to a different account would silently replace the Name/Email fields with that other account's details, and re-editing the email back to the original value afterward would not re-check it — together this could result in a registration being (or looking like it would be) saved under the wrong account. Email and phone matches are now tracked independently; if they resolve to two different existing accounts, the kiosk shows a clear warning naming both accounts and blocks registration until the operator corrects one of the fields, instead of silently merging or overwriting details.
+- Manual registration API: added a server-side check, independent of the kiosk UI, that rejects (`409`) a registration whose submitted email and phone number belong to two different existing accounts — a defense-in-depth safeguard against one account's contact details being overwritten with, or hijacked by, another's.
+- Manual registration API: an existing account's notification preference is now validated against its final email/phone after any confirmed update (e.g. falls back off "WhatsApp"/"Both" if no valid phone remains, or onto "WhatsApp" if the email was cleared in favor of a real phone), instead of persisting a preference that no longer matches the account's actual contact info.
+- Self-service kiosk: tapping anywhere else on the page (e.g. a ticket quantity +/− button) while Email or Cell Number was focused blurred that field and silently re-ran its account lookup; even though the match hadn't changed, this reset Name/Email/Phone/preference back to the matched account's original values, discarding any edits the operator had just made. The autofill now only applies once per distinct matched account instead of on every re-check.
+
## [1.3.1] - 2026-07-28
### Fixed
diff --git a/backend/package.json b/backend/package.json
index 2fbdaaf..bd50500 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -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": {
diff --git a/backend/src/controllers/registrationController.js b/backend/src/controllers/registrationController.js
index a8fc6a9..f1d9741 100644
--- a/backend/src/controllers/registrationController.js
+++ b/backend/src/controllers/registrationController.js
@@ -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(() => {});
}
diff --git a/backend/src/controllers/userController.js b/backend/src/controllers/userController.js
index 9e96e7d..e046eb0 100644
--- a/backend/src/controllers/userController.js
+++ b/backend/src/controllers/userController.js
@@ -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,
diff --git a/frontend/package.json b/frontend/package.json
index 07024ea..d6aecb1 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
- "version": "1.3.1",
+ "version": "1.3.2",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
diff --git a/frontend/src/app/self-service/page.tsx b/frontend/src/app/self-service/page.tsx
index db16625..b4575a0 100644
--- a/frontend/src/app/self-service/page.tsx
+++ b/frontend/src/app/self-service/page.tsx
@@ -85,6 +85,10 @@ function fmtCurrency(val: number) {
return val === 0 ? "Free" : `R${val.toFixed(2)}`;
}
+function prefLabel(pref: "email" | "whatsapp" | "both") {
+ return pref === "whatsapp" ? "WhatsApp" : pref === "both" ? "Email & WhatsApp" : "Email";
+}
+
// Loose "does this look like a mobile number" check — covers 0821234567 (10, leading 0),
// 821234567 (9, no leading 0), and 27821234567 / +27821234567 (11 digits, country code).
function looksLikePhone(s: string): boolean {
@@ -92,6 +96,32 @@ function looksLikePhone(s: string): boolean {
return digits.length >= 9 && digits.length <= 11;
}
+// Show/hide toggle rendered inside a password input — absolutely positioned on its
+// right edge, so callers must wrap the input in a `relative` container and add
+// enough right padding (`pr-12`) for it not to overlap the typed text.
+function PasswordToggleButton({ shown, onToggle }: { shown: boolean; onToggle: () => void }) {
+ return (
+
+ );
+}
+
// ─── Kiosk Page ───────────────────────────────────────────────────────────────
export default function SelfServicePage() {
const [screen, setScreen] = useState
Search by email or phone number to fill in a returning visitor's details.
-- {lookupMessage} -
- )} -