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:
2026-08-03 12:56:26 +02:00
co-authored by Claude Sonnet 5
parent 8a75c9155b
commit b081ed3c8b
7 changed files with 310 additions and 133 deletions
+16
View File
@@ -7,6 +7,22 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [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 ## [1.3.1] - 2026-07-28
### Fixed ### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "event-management-backend", "name": "event-management-backend",
"version": "1.3.1", "version": "1.3.2",
"description": "Event Management System Backend", "description": "Event Management System Backend",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
@@ -764,32 +764,66 @@ const createManualRegistration = async (req, res) => {
? prefFromBody ? prefFromBody
: (hasValidEmail && phone ? 'both' : phone ? 'whatsapp' : 'email'); : (hasValidEmail && phone ? 'both' : phone ? 'whatsapp' : 'email');
// Always search by email AND/OR phone regardless of guestOnly // Always search by email AND/OR phone regardless of guestOnly.
// Also try the alternate format (27xxx ↔ 0xxx) so both representations match // 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 phoneAlt = phone && phone.startsWith('27') ? '0' + phone.slice(2) : (phone && phone.length === 9 ? '27' + phone : null);
const searchClauses = [ const emailUser = hasValidEmail
...(hasValidEmail ? [{ email: user.email }] : []), ? await prisma.user.findUnique({ where: { email: user.email } })
...(phone ? [{ phoneNumber: phone }] : []),
...(phoneAlt ? [{ phoneNumber: phoneAlt }] : []),
];
const existingUser = searchClauses.length > 0
? await prisma.user.findFirst({ where: { OR: searchClauses } })
: null; : 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) { if (existingUser) {
userId = existingUser.id; userId = existingUser.id;
const updateData = {}; const updateData = {};
// Update preference only when the caller explicitly specified one // The kiosk shows the operator a diff of name/email/phone against the matched
if (prefFromBody && validPrefs.includes(prefFromBody)) { // account and requires explicit confirmation before submitting, so any
updateData.notificationPreference = prefFromBody; // 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 && phone !== existingUser.phoneNumber) {
if (phone && !existingUser.phoneNumber) {
updateData.phoneNumber = phone; updateData.phoneNumber = phone;
} }
if (hasValidEmail && existingUser.email !== user.email && existingUser.email.endsWith('@guest.local')) { if (hasValidEmail && existingUser.email !== user.email) {
updateData.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) { if (Object.keys(updateData).length > 0) {
await prisma.user.update({ where: { id: userId }, data: updateData }).catch(() => {}); await prisma.user.update({ where: { id: userId }, data: updateData }).catch(() => {});
} }
+5 -1
View File
@@ -482,7 +482,7 @@ const checkUserExists = async (req, res) => {
const existingUser = await prisma.user.findFirst({ const existingUser = await prisma.user.findFirst({
where: { OR: searchClauses }, 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'); const hasEmail = !!existingUser?.email && !existingUser.email.endsWith('@guest.local');
@@ -494,7 +494,11 @@ const checkUserExists = async (req, res) => {
hasPhone, hasPhone,
// Safe-to-display fields only, for autofilling a lookup form — never the password. // 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. // 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 ? { user: existingUser ? {
id: existingUser.id,
name: existingUser.name, name: existingUser.name,
email: hasEmail ? existingUser.email : null, email: hasEmail ? existingUser.email : null,
phoneNumber: hasPhone ? existingUser.phoneNumber : null, phoneNumber: hasPhone ? existingUser.phoneNumber : null,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hope-events-frontend", "name": "hope-events-frontend",
"version": "1.3.1", "version": "1.3.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
+237 -114
View File
@@ -85,6 +85,10 @@ function fmtCurrency(val: number) {
return val === 0 ? "Free" : `R${val.toFixed(2)}`; 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), // 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). // 821234567 (9, no leading 0), and 27821234567 / +27821234567 (11 digits, country code).
function looksLikePhone(s: string): boolean { function looksLikePhone(s: string): boolean {
@@ -92,6 +96,32 @@ function looksLikePhone(s: string): boolean {
return digits.length >= 9 && digits.length <= 11; 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 (
<button
type="button"
onClick={onToggle}
tabIndex={-1}
className="absolute inset-y-0 right-0 flex items-center px-4 text-gray-400 hover:text-gray-600"
aria-label={shown ? "Hide password" : "Show password"}
>
{shown ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c1.563 0 3.042-.34 4.377-.955M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)}
</button>
);
}
// ─── Kiosk Page ─────────────────────────────────────────────────────────────── // ─── Kiosk Page ───────────────────────────────────────────────────────────────
export default function SelfServicePage() { export default function SelfServicePage() {
const [screen, setScreen] = useState<Screen>("setup"); const [screen, setScreen] = useState<Screen>("setup");
@@ -99,6 +129,7 @@ export default function SelfServicePage() {
// ── Setup state ───────────────────────────────────────────────── // ── Setup state ─────────────────────────────────────────────────
const [setupEmail, setSetupEmail] = useState(""); const [setupEmail, setSetupEmail] = useState("");
const [setupPassword, setSetupPassword] = useState(""); const [setupPassword, setSetupPassword] = useState("");
const [showSetupPassword, setShowSetupPassword] = useState(false);
const [setupLoading, setSetupLoading] = useState(false); const [setupLoading, setSetupLoading] = useState(false);
const [setupError, setSetupError] = useState<string | null>(null); const [setupError, setSetupError] = useState<string | null>(null);
const [supervisorToken, setSupervisorToken] = useState<string | null>(null); const [supervisorToken, setSupervisorToken] = useState<string | null>(null);
@@ -109,6 +140,7 @@ export default function SelfServicePage() {
// ── Change-event modal ─────────────────────────────────────────── // ── Change-event modal ───────────────────────────────────────────
const [showChangeModal, setShowChangeModal] = useState(false); const [showChangeModal, setShowChangeModal] = useState(false);
const [changePassword, setChangePassword] = useState(""); const [changePassword, setChangePassword] = useState("");
const [showChangePassword, setShowChangePassword] = useState(false);
const [changeError, setChangeError] = useState<string | null>(null); const [changeError, setChangeError] = useState<string | null>(null);
const [changeLoading, setChangeLoading] = useState(false); const [changeLoading, setChangeLoading] = useState(false);
@@ -119,13 +151,23 @@ export default function SelfServicePage() {
const [notificationPref, setNotificationPref] = useState<"email" | "whatsapp" | "both">("email"); const [notificationPref, setNotificationPref] = useState<"email" | "whatsapp" | "both">("email");
const [createAccount, setCreateAccount] = useState(false); const [createAccount, setCreateAccount] = useState(false);
const [visitorPassword, setVisitorPassword] = useState(""); const [visitorPassword, setVisitorPassword] = useState("");
const [accountExists, setAccountExists] = useState(false); const [showVisitorPassword, setShowVisitorPassword] = useState(false);
const [quantities, setQuantities] = useState<Record<string, number>>({}); const [quantities, setQuantities] = useState<Record<string, number>>({});
// ── Account lookup (search-by-email/phone) state ────────────────── // ── Account lookup (automatic, triggered when email/phone is entered) ──
const [lookupQuery, setLookupQuery] = useState(""); // Email and phone are resolved to an existing account independently. If they
// resolve to the SAME account, that account is "matched". If they resolve to
// two DIFFERENT accounts, that's a conflict — surfaced to the operator instead
// of silently overwriting one field's details with the other's account.
type MatchedAccount = { id: string; name: string; email: string | null; phoneNumber: string | null; notificationPreference: "email" | "whatsapp" | "both" };
const [emailMatch, setEmailMatch] = useState<MatchedAccount | null>(null);
const [phoneMatch, setPhoneMatch] = useState<MatchedAccount | null>(null);
const accountConflict = !!(emailMatch && phoneMatch && emailMatch.id !== phoneMatch.id);
const matchedAccount = accountConflict ? null : (emailMatch || phoneMatch);
const accountExists = !!matchedAccount;
const [lookupLoading, setLookupLoading] = useState(false); const [lookupLoading, setLookupLoading] = useState(false);
const [lookupMessage, setLookupMessage] = useState<string | null>(null); const [lookupMessage, setLookupMessage] = useState<string | null>(null);
const [showUpdateConfirm, setShowUpdateConfirm] = useState(false);
const [formLoading, setFormLoading] = useState(false); const [formLoading, setFormLoading] = useState(false);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
@@ -186,6 +228,33 @@ export default function SelfServicePage() {
}, 0); }, 0);
}, [selectedEvent, quantities]); }, [selectedEvent, quantities]);
// Differences between what's on file for the matched account and what's currently
// typed in the form — shown to the operator for confirmation before anything is saved.
const pendingChanges = useMemo(() => {
if (!matchedAccount) return [];
const changes: { field: string; from: string; to: string }[] = [];
const name = visitorName.trim();
const email = visitorEmail.trim();
const phone = visitorPhone.trim();
if (name && name !== matchedAccount.name) {
changes.push({ field: "Name", from: matchedAccount.name, to: name });
}
if (email && email !== (matchedAccount.email || "")) {
changes.push({ field: "Email", from: matchedAccount.email || "(none on file)", to: email });
}
if (phone && phone !== (matchedAccount.phoneNumber || "")) {
changes.push({ field: "Phone", from: matchedAccount.phoneNumber || "(none on file)", to: phone });
}
if (notificationPref !== matchedAccount.notificationPreference) {
changes.push({
field: "Send tickets via",
from: prefLabel(matchedAccount.notificationPreference),
to: prefLabel(notificationPref),
});
}
return changes;
}, [matchedAccount, visitorName, visitorEmail, visitorPhone, notificationPref]);
// ─── Load events after supervisor login ───────────────────────── // ─── Load events after supervisor login ─────────────────────────
const loadEvents = useCallback(async (token: string) => { const loadEvents = useCallback(async (token: string) => {
setEventsLoading(true); setEventsLoading(true);
@@ -273,7 +342,9 @@ export default function SelfServicePage() {
} }
// ─── Visitor registration ──────────────────────────────────────── // ─── Visitor registration ────────────────────────────────────────
async function handleRegister(e: React.FormEvent) { // Validates the form and, if the typed name/email/phone differ from the matched
// account's details, shows a confirmation modal before anything is saved.
function handleRegisterSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setFormError(null); setFormError(null);
if (!visitorName.trim() || (!visitorEmail.trim() && !visitorPhone.trim())) { if (!visitorName.trim() || (!visitorEmail.trim() && !visitorPhone.trim())) {
@@ -288,7 +359,18 @@ export default function SelfServicePage() {
setFormError("No event selected."); setFormError("No event selected.");
return; return;
} }
if (accountConflict) {
setFormError("The email and phone number entered belong to two different existing accounts. Please check and correct one of them before continuing.");
return;
}
if (pendingChanges.length > 0) {
setShowUpdateConfirm(true);
return;
}
handleRegister();
}
async function handleRegister() {
setFormLoading(true); setFormLoading(true);
try { try {
const payload: any = { const payload: any = {
@@ -426,8 +508,11 @@ export default function SelfServicePage() {
setNotificationPref("email"); setNotificationPref("email");
setCreateAccount(false); setCreateAccount(false);
setVisitorPassword(""); setVisitorPassword("");
setAccountExists(false); setShowVisitorPassword(false);
setLookupQuery(""); setEmailMatch(null);
setPhoneMatch(null);
appliedMatchIdRef.current = null;
setShowUpdateConfirm(false);
setLookupMessage(null); setLookupMessage(null);
setFormError(null); setFormError(null);
setCurrentRegistrationId(null); setCurrentRegistrationId(null);
@@ -444,52 +529,35 @@ export default function SelfServicePage() {
}; };
}, []); }, []);
// ─── Explicit account lookup — only runs when the operator submits the search // ─── Automatic account lookup — runs once the operator finishes entering the
// (Enter or the Search button), never on keystroke. Matches exactly against a // email or phone field (on blur), never on keystroke. Matches exactly against
// single email or phone value and only ever returns that one matched account // that one value and only ever returns that one matched account (or nothing) —
// (or nothing) — never a broader/fuzzy match. ── // never a broader/fuzzy match. Email and phone are tracked as two independent
async function handleLookup() { // matches (emailMatch/phoneMatch, above) rather than being merged into a single
const q = lookupQuery.trim(); // "last found account" — that's what previously let changing the phone number
if (!q || !supervisorToken) return; // silently pull in and overwrite the form with an unrelated account's details. ──
async function performLookup(kind: "email" | "phone", rawValue: string) {
const value = rawValue.trim();
const setMatch = kind === "email" ? setEmailMatch : setPhoneMatch;
if (!value || !supervisorToken) { setMatch(null); return; }
if (kind === "email" ? !value.includes("@") : !looksLikePhone(value)) { setMatch(null); return; }
setLookupLoading(true); setLookupLoading(true);
setLookupMessage(null); setLookupMessage(null);
try { try {
// Classify the query as email- or phone-shaped and send it as only that param —
// sending the same raw string as both could let digits embedded in an email
// (e.g. a numeric local-part) get misread as an unrelated phone number.
const isEmailLike = q.includes("@");
const params = new URLSearchParams(); const params = new URLSearchParams();
if (isEmailLike) params.set("email", q); if (kind === "email") params.set("email", value);
else params.set("phone", q); else params.set("phone", value);
const data = await apiFetch<{ const data = await apiFetch<{
exists: boolean; exists: boolean;
user?: { name: string; email: string | null; phoneNumber: string | null; notificationPreference: "email" | "whatsapp" | "both" } | null; user?: MatchedAccount | null;
}>(`/api/users/check-exists?${params.toString()}`, { authToken: supervisorToken }); }>(`/api/users/check-exists?${params.toString()}`, { authToken: supervisorToken });
if (data?.exists && data.user) { if (data?.exists && data.user) {
const found = data.user; setMatch(data.user);
setVisitorName(found.name);
setVisitorEmail(found.email || "");
setVisitorPhone(found.phoneNumber || "");
setNotificationPref(found.notificationPreference);
setAccountExists(true);
setLookupMessage("Account found — details filled in below."); setLookupMessage("Account found — details filled in below.");
} else { } else {
// No match — still save the retype: carry the query into whichever field it setMatch(null);
// resembles, and leave everything else blank for a fresh entry. setLookupMessage(null);
setAccountExists(false);
setVisitorName("");
setNotificationPref("email");
if (isEmailLike) {
setVisitorEmail(q);
setVisitorPhone("");
} else if (looksLikePhone(q)) {
setVisitorPhone(q);
setVisitorEmail("");
} else {
setVisitorEmail("");
setVisitorPhone("");
}
setLookupMessage("No matching account found.");
} }
} catch { } catch {
setLookupMessage("Lookup failed. Please try again."); setLookupMessage("Lookup failed. Please try again.");
@@ -498,13 +566,31 @@ export default function SelfServicePage() {
} }
} }
function handleLookupKeyDown(e: React.KeyboardEvent<HTMLInputElement>) { function handleEmailBlur() {
if (e.key === "Enter") { performLookup("email", visitorEmail);
e.preventDefault();
handleLookup();
}
} }
function handlePhoneBlur() {
performLookup("phone", visitorPhone);
}
// Autofill Name/Email/Phone/preference from the matched account — but only once per
// distinct account id. Re-focusing elsewhere on the page (e.g. tapping a ticket
// quantity button) blurs whatever field was last active and re-runs its lookup;
// that returns the same account as a new object each time, and keying this off
// object identity instead of id made it re-fire and stomp every field — including
// ones the operator had already deliberately edited — back to the account's
// original values on every unrelated tap.
const appliedMatchIdRef = useRef<string | null>(null);
useEffect(() => {
if (!matchedAccount || appliedMatchIdRef.current === matchedAccount.id) return;
appliedMatchIdRef.current = matchedAccount.id;
setVisitorName(matchedAccount.name);
if (matchedAccount.email) setVisitorEmail(matchedAccount.email);
if (matchedAccount.phoneNumber) setVisitorPhone(matchedAccount.phoneNumber);
setNotificationPref(matchedAccount.notificationPreference);
}, [matchedAccount]);
// Existing accounts are linked automatically — never show the "create account" toggle for them // Existing accounts are linked automatically — never show the "create account" toggle for them
useEffect(() => { useEffect(() => {
if (accountExists && createAccount) { if (accountExists && createAccount) {
@@ -540,15 +626,18 @@ export default function SelfServicePage() {
<form onSubmit={handleChangeEvent} className="space-y-4"> <form onSubmit={handleChangeEvent} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label> <label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input <div className="relative">
type="password" <input
value={changePassword} type={showChangePassword ? "text" : "password"}
onChange={(e) => setChangePassword(e.target.value)} value={changePassword}
className="w-full border border-gray-300 rounded-lg px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500" onChange={(e) => setChangePassword(e.target.value)}
placeholder="Your password" className="w-full border border-gray-300 rounded-lg px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
autoFocus placeholder="Your password"
required autoFocus
/> required
/>
<PasswordToggleButton shown={showChangePassword} onToggle={() => setShowChangePassword((v) => !v)} />
</div>
</div> </div>
{changeError && <p className="text-red-600 text-sm">{changeError}</p>} {changeError && <p className="text-red-600 text-sm">{changeError}</p>}
<div className="flex gap-3 pt-1"> <div className="flex gap-3 pt-1">
@@ -597,15 +686,18 @@ export default function SelfServicePage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label> <label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input <div className="relative">
type="password" <input
value={setupPassword} type={showSetupPassword ? "text" : "password"}
onChange={(e) => setSetupPassword(e.target.value)} value={setupPassword}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500" onChange={(e) => setSetupPassword(e.target.value)}
placeholder="••••••••" className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
required placeholder="••••••••"
autoComplete="current-password" required
/> autoComplete="current-password"
/>
<PasswordToggleButton shown={showSetupPassword} onToggle={() => setShowSetupPassword((v) => !v)} />
</div>
</div> </div>
{setupError && <p className="text-red-600 text-sm">{setupError}</p>} {setupError && <p className="text-red-600 text-sm">{setupError}</p>}
<button <button
@@ -674,46 +766,7 @@ export default function SelfServicePage() {
<p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p> <p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p>
</div> </div>
<div className="mb-5 border border-gray-200 rounded-xl p-4 bg-gray-50"> <form onSubmit={handleRegisterSubmit} className="space-y-5">
<label className="block text-sm font-medium text-gray-700 mb-1">Look up existing account</label>
<p className="text-xs text-gray-500 mb-2">Search by email or phone number to fill in a returning visitor&apos;s details.</p>
<div className="flex gap-2">
<input
type="text"
value={lookupQuery}
onChange={(e) => setLookupQuery(e.target.value)}
onKeyDown={handleLookupKeyDown}
className="flex-1 border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Email or phone number"
/>
<button
type="button"
onClick={handleLookup}
disabled={lookupLoading || !lookupQuery.trim()}
className="px-5 rounded-xl bg-gray-700 text-white font-medium hover:bg-gray-800 disabled:opacity-50 transition"
>
{lookupLoading ? "Searching…" : "Search"}
</button>
</div>
{lookupMessage && (
<p className={`text-sm mt-2 ${lookupMessage.startsWith("Account found") ? "text-green-700" : "text-gray-500"}`}>
{lookupMessage}
</p>
)}
</div>
<form onSubmit={handleRegister} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
<input
type="text"
value={visitorName}
onChange={(e) => setVisitorName(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="John Smith"
required
/>
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Email Address Email Address
@@ -730,6 +783,7 @@ export default function SelfServicePage() {
if (v.trim() && !visitorPhone.trim()) setNotificationPref("email"); if (v.trim() && !visitorPhone.trim()) setNotificationPref("email");
else if (!v.trim() && visitorPhone.trim()) setNotificationPref("whatsapp"); else if (!v.trim() && visitorPhone.trim()) setNotificationPref("whatsapp");
}} }}
onBlur={handleEmailBlur}
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="john@example.com" placeholder="john@example.com"
/> />
@@ -749,11 +803,40 @@ export default function SelfServicePage() {
if (v.trim() && !visitorEmail.trim()) setNotificationPref("whatsapp"); if (v.trim() && !visitorEmail.trim()) setNotificationPref("whatsapp");
else if (!v.trim() && visitorEmail.trim()) setNotificationPref("email"); else if (!v.trim() && visitorEmail.trim()) setNotificationPref("email");
}} }}
onBlur={handlePhoneBlur}
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="+27 82 000 0000" placeholder="+27 82 000 0000"
/> />
</div> </div>
{lookupLoading && (
<p className="text-sm text-gray-500 -mt-2">Checking for an existing account</p>
)}
{!lookupLoading && accountConflict && emailMatch && phoneMatch && (
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-red-700 text-sm -mt-2">
This email matches an existing account for <strong>{emailMatch.name}</strong>, but this phone number
matches a different existing account for <strong>{phoneMatch.name}</strong>. Please check and correct
one of these fields before continuing.
</div>
)}
{!lookupLoading && !accountConflict && lookupMessage && (
<p className={`text-sm -mt-2 ${lookupMessage.startsWith("Account found") ? "text-green-700" : "text-gray-500"}`}>
{lookupMessage}
</p>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
<input
type="text"
value={visitorName}
onChange={(e) => setVisitorName(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="John Smith"
required
/>
</div>
{/* Preference selector — only shown when both channels are available */} {/* Preference selector — only shown when both channels are available */}
{visitorEmail.trim() && visitorPhone.trim() && ( {visitorEmail.trim() && visitorPhone.trim() && (
<div> <div>
@@ -888,14 +971,17 @@ export default function SelfServicePage() {
{createAccount && ( {createAccount && (
<div className="mt-3"> <div className="mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Choose a password</label> <label className="block text-sm font-medium text-gray-700 mb-1">Choose a password</label>
<input <div className="relative">
type="password" <input
value={visitorPassword} type={showVisitorPassword ? "text" : "password"}
onChange={(e) => setVisitorPassword(e.target.value)} value={visitorPassword}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500" onChange={(e) => setVisitorPassword(e.target.value)}
placeholder="Min. 6 characters" className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
autoComplete="new-password" placeholder="Min. 6 characters"
/> autoComplete="new-password"
/>
<PasswordToggleButton shown={showVisitorPassword} onToggle={() => setShowVisitorPassword((v) => !v)} />
</div>
</div> </div>
)} )}
</div> </div>
@@ -909,7 +995,7 @@ export default function SelfServicePage() {
<button <button
type="submit" type="submit"
disabled={formLoading} disabled={formLoading || accountConflict}
className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 disabled:opacity-60 transition" className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 disabled:opacity-60 transition"
> >
{formLoading ? "Registering…" : eventHasRequiredForm && mainTicketCount > 0 ? "Next — Fill in Form" : "Register"} {formLoading ? "Registering…" : eventHasRequiredForm && mainTicketCount > 0 ? "Next — Fill in Form" : "Register"}
@@ -919,6 +1005,43 @@ export default function SelfServicePage() {
</div> </div>
)} )}
{/* ── Confirm Account Changes Modal ──────────────────────────── */}
{showUpdateConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-sm mx-4">
<h2 className="text-xl font-bold text-gray-800 mb-1">Confirm account changes</h2>
<p className="text-sm text-gray-500 mb-5">
These details differ from what&apos;s on file for this account. Confirm to update them.
</p>
<div className="space-y-3 mb-6">
{pendingChanges.map((c) => (
<div key={c.field} className="border border-gray-200 rounded-lg px-3 py-2">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{c.field}</p>
<p className="text-sm text-gray-400 line-through">{c.from}</p>
<p className="text-sm text-green-700 font-medium">{c.to}</p>
</div>
))}
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => setShowUpdateConfirm(false)}
className="flex-1 border border-gray-300 text-gray-700 rounded-lg py-3 text-base font-medium hover:bg-gray-50 transition"
>
Cancel
</button>
<button
type="button"
onClick={() => { setShowUpdateConfirm(false); handleRegister(); }}
className="flex-1 bg-blue-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-blue-700 transition"
>
Confirm &amp; Update
</button>
</div>
</div>
</div>
)}
{/* ── Event Form Screen ──────────────────────────────────────── */} {/* ── Event Form Screen ──────────────────────────────────────── */}
{screen === "eventform" && selectedEvent?.form && ( {screen === "eventform" && selectedEvent?.form && (
<div className="flex-1 flex items-center justify-center p-6"> <div className="flex-1 flex items-center justify-center p-6">
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hope-events", "name": "hope-events",
"version": "1.3.1", "version": "1.3.2",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev:backend": "cd backend && npm run dev", "dev:backend": "cd backend && npm run dev",