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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.3.1",
"version": "1.3.2",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
+237 -114
View File
@@ -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 (
<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 ───────────────────────────────────────────────────────────────
export default function SelfServicePage() {
const [screen, setScreen] = useState<Screen>("setup");
@@ -99,6 +129,7 @@ export default function SelfServicePage() {
// ── Setup state ─────────────────────────────────────────────────
const [setupEmail, setSetupEmail] = useState("");
const [setupPassword, setSetupPassword] = useState("");
const [showSetupPassword, setShowSetupPassword] = useState(false);
const [setupLoading, setSetupLoading] = useState(false);
const [setupError, setSetupError] = useState<string | null>(null);
const [supervisorToken, setSupervisorToken] = useState<string | null>(null);
@@ -109,6 +140,7 @@ export default function SelfServicePage() {
// ── Change-event modal ───────────────────────────────────────────
const [showChangeModal, setShowChangeModal] = useState(false);
const [changePassword, setChangePassword] = useState("");
const [showChangePassword, setShowChangePassword] = useState(false);
const [changeError, setChangeError] = useState<string | null>(null);
const [changeLoading, setChangeLoading] = useState(false);
@@ -119,13 +151,23 @@ export default function SelfServicePage() {
const [notificationPref, setNotificationPref] = useState<"email" | "whatsapp" | "both">("email");
const [createAccount, setCreateAccount] = useState(false);
const [visitorPassword, setVisitorPassword] = useState("");
const [accountExists, setAccountExists] = useState(false);
const [showVisitorPassword, setShowVisitorPassword] = useState(false);
const [quantities, setQuantities] = useState<Record<string, number>>({});
// ── Account lookup (search-by-email/phone) state ──────────────────
const [lookupQuery, setLookupQuery] = useState("");
// ── Account lookup (automatic, triggered when email/phone is entered) ──
// 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 [lookupMessage, setLookupMessage] = useState<string | null>(null);
const [showUpdateConfirm, setShowUpdateConfirm] = useState(false);
const [formLoading, setFormLoading] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
@@ -186,6 +228,33 @@ export default function SelfServicePage() {
}, 0);
}, [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 ─────────────────────────
const loadEvents = useCallback(async (token: string) => {
setEventsLoading(true);
@@ -273,7 +342,9 @@ export default function SelfServicePage() {
}
// ─── 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();
setFormError(null);
if (!visitorName.trim() || (!visitorEmail.trim() && !visitorPhone.trim())) {
@@ -288,7 +359,18 @@ export default function SelfServicePage() {
setFormError("No event selected.");
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);
try {
const payload: any = {
@@ -426,8 +508,11 @@ export default function SelfServicePage() {
setNotificationPref("email");
setCreateAccount(false);
setVisitorPassword("");
setAccountExists(false);
setLookupQuery("");
setShowVisitorPassword(false);
setEmailMatch(null);
setPhoneMatch(null);
appliedMatchIdRef.current = null;
setShowUpdateConfirm(false);
setLookupMessage(null);
setFormError(null);
setCurrentRegistrationId(null);
@@ -444,52 +529,35 @@ export default function SelfServicePage() {
};
}, []);
// ─── Explicit account lookup — only runs when the operator submits the search
// (Enter or the Search button), never on keystroke. Matches exactly against a
// single email or phone value and only ever returns that one matched account
// (or nothing) — never a broader/fuzzy match. ──
async function handleLookup() {
const q = lookupQuery.trim();
if (!q || !supervisorToken) return;
// ─── Automatic account lookup — runs once the operator finishes entering the
// email or phone field (on blur), never on keystroke. Matches exactly against
// that one value and only ever returns that one matched account (or nothing) —
// never a broader/fuzzy match. Email and phone are tracked as two independent
// matches (emailMatch/phoneMatch, above) rather than being merged into a single
// "last found account" — that's what previously let changing the phone number
// 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);
setLookupMessage(null);
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();
if (isEmailLike) params.set("email", q);
else params.set("phone", q);
if (kind === "email") params.set("email", value);
else params.set("phone", value);
const data = await apiFetch<{
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 });
if (data?.exists && data.user) {
const found = data.user;
setVisitorName(found.name);
setVisitorEmail(found.email || "");
setVisitorPhone(found.phoneNumber || "");
setNotificationPref(found.notificationPreference);
setAccountExists(true);
setMatch(data.user);
setLookupMessage("Account found — details filled in below.");
} else {
// No match — still save the retype: carry the query into whichever field it
// resembles, and leave everything else blank for a fresh entry.
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.");
setMatch(null);
setLookupMessage(null);
}
} catch {
setLookupMessage("Lookup failed. Please try again.");
@@ -498,13 +566,31 @@ export default function SelfServicePage() {
}
}
function handleLookupKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
e.preventDefault();
handleLookup();
}
function handleEmailBlur() {
performLookup("email", visitorEmail);
}
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
useEffect(() => {
if (accountExists && createAccount) {
@@ -540,15 +626,18 @@ export default function SelfServicePage() {
<form onSubmit={handleChangeEvent} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
type="password"
value={changePassword}
onChange={(e) => setChangePassword(e.target.value)}
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"
placeholder="Your password"
autoFocus
required
/>
<div className="relative">
<input
type={showChangePassword ? "text" : "password"}
value={changePassword}
onChange={(e) => setChangePassword(e.target.value)}
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"
placeholder="Your password"
autoFocus
required
/>
<PasswordToggleButton shown={showChangePassword} onToggle={() => setShowChangePassword((v) => !v)} />
</div>
</div>
{changeError && <p className="text-red-600 text-sm">{changeError}</p>}
<div className="flex gap-3 pt-1">
@@ -597,15 +686,18 @@ export default function SelfServicePage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
<input
type="password"
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
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"
placeholder="••••••••"
required
autoComplete="current-password"
/>
<div className="relative">
<input
type={showSetupPassword ? "text" : "password"}
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
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"
placeholder="••••••••"
required
autoComplete="current-password"
/>
<PasswordToggleButton shown={showSetupPassword} onToggle={() => setShowSetupPassword((v) => !v)} />
</div>
</div>
{setupError && <p className="text-red-600 text-sm">{setupError}</p>}
<button
@@ -674,46 +766,7 @@ export default function SelfServicePage() {
<p className="text-gray-500 text-sm mt-1">{fmtDate(selectedEvent.startDate)}</p>
</div>
<div className="mb-5 border border-gray-200 rounded-xl p-4 bg-gray-50">
<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>
<form onSubmit={handleRegisterSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email Address
@@ -730,6 +783,7 @@ export default function SelfServicePage() {
if (v.trim() && !visitorPhone.trim()) setNotificationPref("email");
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"
placeholder="john@example.com"
/>
@@ -749,11 +803,40 @@ export default function SelfServicePage() {
if (v.trim() && !visitorEmail.trim()) setNotificationPref("whatsapp");
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"
placeholder="+27 82 000 0000"
/>
</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 */}
{visitorEmail.trim() && visitorPhone.trim() && (
<div>
@@ -888,14 +971,17 @@ export default function SelfServicePage() {
{createAccount && (
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Choose a password</label>
<input
type="password"
value={visitorPassword}
onChange={(e) => setVisitorPassword(e.target.value)}
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"
placeholder="Min. 6 characters"
autoComplete="new-password"
/>
<div className="relative">
<input
type={showVisitorPassword ? "text" : "password"}
value={visitorPassword}
onChange={(e) => setVisitorPassword(e.target.value)}
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"
placeholder="Min. 6 characters"
autoComplete="new-password"
/>
<PasswordToggleButton shown={showVisitorPassword} onToggle={() => setShowVisitorPassword((v) => !v)} />
</div>
</div>
)}
</div>
@@ -909,7 +995,7 @@ export default function SelfServicePage() {
<button
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"
>
{formLoading ? "Registering…" : eventHasRequiredForm && mainTicketCount > 0 ? "Next — Fill in Form" : "Register"}
@@ -919,6 +1005,43 @@ export default function SelfServicePage() {
</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 ──────────────────────────────────────── */}
{screen === "eventform" && selectedEvent?.form && (
<div className="flex-1 flex items-center justify-center p-6">