feat(pin): remember the admin PIN per device, with a single install-wide revoke - #160
Conversation
The admin PIN prompts on every gated action and on every visit to the admin panel. Households with a PIN set are being nagged, which is the complaint this addresses. A device told to remember the PIN now behaves exactly as if no PIN were set -- scoped to that device. That is the whole spec, and it is also the implementation: requirePin() already runs its action straight through when no PIN is configured, so remembering is the same branch with a different condition. The decision lives in one pure function, shouldPromptForPin(), because there are two independent gates -- requirePin() in ChoreWidget and checkPinStatus() in AdminPanel -- and honouring the flag in one but not the other would read as broken on whichever forgot. The flag is stored in the device's server-side settings rather than localStorage, so it can be revoked from anywhere. Clearing browser data mints a new device id, which carries no flag, so it fails closed and the PIN is required again. Revocation is a single "Require PIN on all devices again" button in Security. A per-device list was considered and deliberately left out: it needs the flag surfaced through GET /api/devices plus new list UI, and most devices are unnamed UUIDs, so the list would be unreadable in exactly the situation it exists for. The blunt revoke costs one PIN re-entry per admin device. forgetPinOnAllDevices settles rather than races, so one unreachable device cannot silently leave the rest remembered; the caller is told how many failed.
…changes
Two follow-ups on top of the per-device PIN memory, added during review.
Translations. The revoke flow added five English literals to AdminPanel, which
is otherwise fully translated -- t('admin:pin.update') sits on the line next to
the new button -- so a Spanish install showed English in the Security tab. The
strings move into admin.json under the sections they belong to, with Spanish
alongside. check:i18n enforces key parity between locales but cannot see a
hardcoded string, which is why this needed a human read rather than CI.
Resetting on a PIN change. Nothing cleared the remembered flags when the PIN was
set or changed, and the admin-pin routes never touch device settings, so a PIN
changed *because* someone learned it left every remembered device walking
straight in until an admin thought to press the revoke button. Changing the PIN
is exactly the moment trust should reset, so it now clears them, including on a
first set in case flags survive from an earlier PIN.
The result is reported rather than silent: a partial failure leaves real devices
unlocked, so that case says so and points at the revoke button, instead of the
success toast claiming otherwise. The clearing is caught separately from the
verify call, so a failure there can never surface as "Invalid PIN".
Verified merged with main: client 176/176, translation parity passes,
production build clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewed and merging. Solid work — the design note is accurate and the wiring is right where it matters. I pushed one commit to the branch first; details below so nothing lands unannounced. What I verifiedYour threat-model claim holds, and I checked it rather than taking it on trust. The PIN is touched only by the four The rest also checks out:
What I changed (commit e63cf30)1. Translations. The revoke flow added five English literals to
Worth knowing for next time: 2. Changing the PIN now resets remembered devices. This was the one functional gap. Nothing cleared the flags when the PIN was set or changed, and the admin-pin routes never touch device settings — so a PIN changed because someone learned it left every remembered device walking straight in until an admin thought to press the revoke button. Changing the PIN is exactly when trust should reset, so it now clears them (including on a first set, in case flags survive from an earlier PIN). The result is reported rather than silent: a partial failure says so and points at the revoke button instead of a success toast claiming otherwise. The clearing is caught separately from the verify call, so a failure there can never surface as "Invalid PIN". Two new strings cover it, in both languages. Left alone deliberately
Thanks for a genuinely well-reasoned PR — the "one pure function so both gates agree" call was the right one. |
The problem
With an admin PIN set, it gets asked for constantly: every visit to the admin panel, and again for each gated action in the chore widget: transfer, snooze, prize approve/decline, quick-spend. On a display a parent uses many times a day, the prompt becomes a reflex rather than a gate.
What this changes
An opt-in "Remember PIN on this device" checkbox, default off. Nothing changes for anyone who ignores it, or for households with no PIN set.
The spec is one line: a remembered device behaves exactly as if no admin PIN were set, scoped to that device. That is also the implementation -
requirePin()already runs straight through when/api/admin-pin/existsis false, so remembering is the same branch with a different condition.The flag lives in the device's server-side settings rather than
localStorage, so it can be revoked from another device, the mistake worth designing for is ticking the box on a display the kids use.upsertDeviceSettingsmerges, so it is additive: no schema change, no migration.Revoking is one button in Admin → Security.
forgetPinOnAllDevices()settles rather than races, so one unreachable device cannot leave the rest remembered. A per-device list would be richer but needs the flag surfaced throughGET /api/devicesplus new UI.shouldPromptForPin()is a pure function because two independent gates have to agree:requirePin()inChoreWidgetandcheckPinStatus()inAdminPanel.Worth knowing
Testing
Nine assertions for
shouldPromptForPin()andisPinRemembered()- notably that only a literaltruecounts, so a stray"true"in a settings blob cannot silently disable the PIN on that device.The wiring is not unit tested:
vite.config.jssetsenvironment: 'node'and there are no component tests, so I exercised it by hand - prompt appears, checkbox suppresses it after a reload for both gates, revoke restores it, clearing site data restores it. Running on my own instance.