Skip to content

feat(pin): remember the admin PIN per device, with a single install-wide revoke - #160

Merged
jherforth merged 2 commits into
jherforth:mainfrom
mrramam:feat/remember-admin-pin-per-device
Sep 10, 2026
Merged

feat(pin): remember the admin PIN per device, with a single install-wide revoke#160
jherforth merged 2 commits into
jherforth:mainfrom
mrramam:feat/remember-admin-pin-per-device

Conversation

@mrramam

@mrramam mrramam commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

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/exists is 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. upsertDeviceSettings merges, 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 through GET /api/devices plus new UI.

shouldPromptForPin() is a pure function because two independent gates have to agree: requirePin() in ChoreWidget and checkPinStatus() in AdminPanel.

Worth knowing

  • Remembering a device gives it the admin panel too, not just the chore actions. That is the intent, and why revoke exists.
  • Clearing browser data mints a new device id, so it fails closed.
  • Renaming a device is safe, the setting follows the row.
  • No expiry. On a wall display, trust that lapses is just a slower nag.
  • No change to the threat model: the PIN is verified client-side either way.

Testing

Nine assertions for shouldPromptForPin() and isPinRemembered() - notably that only a literal true counts, so a stray "true" in a settings blob cannot silently disable the PIN on that device.

The wiring is not unit tested: vite.config.js sets environment: '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.

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.
@jherforth jherforth added this to the 1.9 milestone Sep 10, 2026
@jherforth jherforth added the enhancement New feature or request label Sep 10, 2026
…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>
@jherforth

Copy link
Copy Markdown
Owner

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 verified

Your threat-model claim holds, and I checked it rather than taking it on trust. The PIN is touched only by the four /api/admin-pin/* routes — no other server route checks it. It is purely a client-side gate, so putting the flag in unauthenticated device settings weakens nothing: anyone who could set that flag could already call the underlying APIs directly.

The rest also checks out:

  • setPinRemembered runs only after /api/admin-pin/verify returns valid, so the flag cannot be set without knowing the PIN.
  • fetchPinRemembered catches and returns false, so it fails closed.
  • upsertDeviceSettings does {...existing, ...incoming}, so the flag does not clobber other per-device settings — the "additive" claim is true.
  • Merged with main: 176/176 client tests, translation parity passes, build clean.

What I changed (commit e63cf30)

1. Translations. The revoke flow added five English literals to AdminPanel, which is otherwise fully translated — t('admin:pin.update') is literally on the line next to the new button — so a Spanish install showed English in the Security tab. Moved into admin.json under the right sections with Spanish alongside:

pin.forgetDevices · confirm.forgetPinDevices · messages.pinDevicesForgotten · messages.pinDevicesPartlyForgotten · messages.pinDevicesForgetFailed

Worth knowing for next time: check:i18n enforces key parity between locales but cannot see a hardcoded string, so CI passed on this. It needed a human read.

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

  • PinModal's two new strings are still English — that component has no useTranslation at all on main, so yours are consistent with it rather than a regression. Translating it is a separate job, and doing it inside your PR would have widened the diff into pre-existing territory. Happy to do it as a follow-up.
  • requirePin now makes two sequential requests per gated action. Fine on a wall display; noting it only so it is a known cost.
  • The caption uses var(--text-muted), which is not defined anywhere — but ChoreWidget and ClamValueModal already do the same, so it is a pre-existing pattern, not something to fix here.

Thanks for a genuinely well-reasoned PR — the "one pure function so both gates agree" call was the right one.

@jherforth
jherforth merged commit fb30862 into jherforth:main Sep 10, 2026
2 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in HomeGlow Kanban Sep 10, 2026
@mrramam
mrramam deleted the feat/remember-admin-pin-per-device branch September 12, 2026 23:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants