diff --git a/.claude-context/sessions/2026-09-23-visitor-kiosk-build.md b/.claude-context/sessions/2026-09-23-visitor-kiosk-build.md index c945bc6..20f73e1 100644 --- a/.claude-context/sessions/2026-09-23-visitor-kiosk-build.md +++ b/.claude-context/sessions/2026-09-23-visitor-kiosk-build.md @@ -179,3 +179,24 @@ Whole project created this session. Principal files: - `brother_ql` 0.9.4 (PyPI) — `print --help` documents the `--red` requirement. - Brother QL-820NWB specification: 12–62 mm media, 60.96 mm max print width, 300×300 dpi. - RFC 6238 (TOTP) — test vectors used to verify `src/auth.js`. + +--- + +## Addendum — 2026-09-23, PIN reset bug + +**Symptom:** after changing a recurring visitor's PIN, phone + PIN was rejected at the kiosk. + +**Cause:** the Edit form carried a "PIN" field whose value `PATCH /admin/api/frequent/:id` +never read. The record saved, the field was silently discarded, and the admin handed out a PIN +the system had never stored. (`POST /admin/api/frequent/:id/pin`, the "New PIN" button, was +always correct — verified by reproduction.) + +**Fix:** `PATCH` now honours a supplied `pin`: 4 digits, checked for collisions via +`allocatePin(pin, row.id)`, written to `pin_enc` and `pin_lookup`. Blank keeps the current PIN. +A malformed or already-used PIN is rejected with a message instead of ignored. The response +carries `pinChanged`, and the console shows the printable card when it is true. Saving a record +also clears `pin_attempts` for both the old and new phone number, so a changed number cannot +leave a stale lockout. + +**Files:** `src/routes/admin.js` (PATCH handler), `public/js/admin.js` (edit modal shows the +current PIN, relabelled field, shows the card after a change), `public/css/admin.css`, `README.md`. diff --git a/README.md b/README.md index 78366b5..c577e27 100644 --- a/README.md +++ b/README.md @@ -503,6 +503,16 @@ digits is only 10,000 combinations, so hashing would add nothing against anyone of the database — the real protection is the lockout: five wrong PINs on a number locks it for 15 minutes. +Two ways to change someone's PIN, and they now behave the same: + +- **New PIN** on the recurring visitors list issues a random one. +- **Edit** shows the current PIN and has a box to type a replacement. Leave it blank to keep the + current one. A typed PIN must be 4 digits and not already in use; either problem is reported + rather than ignored. + +Saving a record also clears any PIN lockout on that visitor, under both the old and the new +mobile number. + Four digits gives 10,000 combinations and each must be unique, so that is the ceiling on simultaneous recurring visitors. Deactivating someone frees theirs. diff --git a/public/css/admin.css b/public/css/admin.css index 2eadb42..e2fed1a 100644 --- a/public/css/admin.css +++ b/public/css/admin.css @@ -445,3 +445,9 @@ pre.raw { border-radius: 3px; background: #fff; } + +.pin-inline { + font-family: ui-monospace, Menlo, Consolas, monospace; + letter-spacing: 0.12em; + color: var(--deep); +} diff --git a/public/js/admin.js b/public/js/admin.js index f4d2573..629c28c 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -502,7 +502,13 @@ function openRecurringModal(person = null) { - ${field('PIN (leave blank to generate one)', 'pin', '')} + ${ + editing + ? `` + : field('PIN (leave blank to generate one)', 'pin', '') + } ${field('Notes', 'notes', person?.notes)} ${photoEditorMarkup(person)} @@ -519,8 +525,9 @@ function openRecurringModal(person = null) { ? await api(`/frequent/${person.id}`, { method: 'PATCH', body: payload }) : await api('/frequent', { method: 'POST', body: payload }); loadRecurring(); - if (editing) toast('Saved.'); - else showPin(saved.pin, saved.id); + if (!editing) showPin(saved.pin, saved.id); + else if (saved.pinChanged) showPin(saved.pin, saved.id); + else toast('Saved.'); } catch (err) { toast(err.message, true); } diff --git a/src/routes/admin.js b/src/routes/admin.js index c06bacb..9b701d5 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -941,6 +941,18 @@ router.patch('/frequent/:id', (req, res) => { photoPath = null; } + // A PIN typed into the edit form has to actually be set. It used to be + // accepted and ignored, so an admin would hand out a PIN the kiosk had never + // heard of. Blank means "leave the current one alone". + const requestedPin = String(req.body?.pin ?? '').trim(); + let newPin = null; + if (requestedPin) { + if (!/^\d{4}$/.test(requestedPin)) { + throw new Error('A PIN must be exactly 4 digits. Leave it blank to keep the current one.'); + } + newPin = allocatePin(requestedPin, row.id); + } + db.prepare( `UPDATE frequent_visitors SET first_name = ?, last_name = ?, company = ?, phone = ?, email = ?, check_type = ?, check_number = ?, check_expiry = ?, default_host_id = ?, site_id = ?, @@ -954,7 +966,21 @@ router.patch('/frequent/:id', (req, res) => { req.body?.active !== undefined ? (req.body.active ? 1 : 0) : row.active, row.id ); - res.json(shapeFrequent(db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(row.id), true)); + + if (newPin) { + db.prepare( + "UPDATE frequent_visitors SET pin_enc = ?, pin_lookup = ?, updated_at = datetime('now') WHERE id = ?" + ).run(encryptPin(newPin), pinLookup(newPin), row.id); + } + + // Attempt counters are keyed by phone number, so a changed number would + // otherwise leave a stale lockout behind under the old one. + db.prepare('DELETE FROM pin_attempts WHERE phone IN (?, ?)').run(row.phone, v.phone); + + res.json({ + ...shapeFrequent(db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(row.id), true), + pinChanged: Boolean(newPin), + }); } catch (err) { res.status(400).json({ error: err.message }); }