← All tests

Self POS PIN management PROD

2026-05-12 on prod (get-coffee). Owner/manager can now Set, Reset, and View their OWN POS PIN from /cafe/team — every action gated by a password 2FA prompt (the user's existing Commerce login password). Complements the earlier pin-view-prod bundle which handled cashiers + other members; this closes the loop for the logged-in user themselves. Threat model preserved: a stolen session cookie alone can't escalate to physical POS access — the password is also required.

6/6 prod checks PASS on get-coffee.nixtech.app via SSO as narongix (owner).
57/57 total prod tests green — no regressions from this push.
test-self-pin-prod.mjs6/6
test-phase1-prod.mjs11/11
test-phase2-sso-outdoor-prod.mjs6/6
test-phase2-cafe-multishop-prod.mjs6/6
test-m1-prod.mjs10/10
test-r7-prod.mjs14/14
test-r8-prod.mjs4/4

All node-fetch regressions run with NODE_TLS_REJECT_UNAUTHORIZED=0 for the local Windows schannel cert-chain workaround (not a code regression).

What ships

1 cafe commit (8695eae) — no migration, no backend change.

lib/actions/profile.ts:
  + verifyOwnPassword(userId, tenantId, password) — shared bcrypt 2FA gate
  + setOwnPosPinAction refactored to use the helper (1 less code path)
  + resetOwnPosPinAction({ currentPassword })
        verify password → resetMemberPosPin (self) → audit-log
        action="profile.pos_pin_reset"
  + viewOwnPosPinAction({ currentPassword })
        verify password → getMemberPosPin (self) → audit-log
        action="profile.pos_pin_view"
        returns {stale:true} for legacy NULL-enc rows (UI prompts Reset)

app/(authed)/team/team-client.tsx:
  + KeyRound + Eye lucide icon imports
  + setOwn/resetOwn/viewOwn action imports
  + "My POS PIN" button in top bar — always visible (every authed user
    has a tenant_user record on /cafe/team)
  + <SelfPinManager> modal component:
      mode="menu"  → 3 action buttons
      mode="set"   → password + new PIN + confirm
      mode="reset" → password only
      mode="view"  → password only
      reveal       → reuses existing PinRevealOverlay
      Back button returns to menu without closing the modal
      max-h-[90vh] overflow-y-auto for tall content

Security model

Same password 2FA as setOwnPosPinAction has always required. Now applied
consistently across all 3 self-actions:

  Set new PIN     → setOwnPosPinAction    → verifyOwnPassword + setUserPosPin
  Reset to random → resetOwnPosPinAction  → verifyOwnPassword + resetMemberPosPin
  View current    → viewOwnPosPinAction   → verifyOwnPassword + getMemberPosPin

verifyOwnPassword is a single function (in profile.ts) that:
  - reads tenant_users.password_hash for the session user
  - bcrypt.compare against the submitted password
  - returns {ok:false, error:"..."} on any mismatch; returns {ok:true} on match

Threat:
  - Stolen session cookie alone → cannot set/reset/view PIN → cannot escalate
    to physical POS access. Attacker would also need the user's password,
    which is not in the JWT or the cookie.

Audit:
  cafe.audit_log writes on every reveal/set/reset with caller's
  tenant_user_id + action codes profile.pos_pin_set / .pos_pin_reset /
  .pos_pin_view.

Backfill:
  Legacy rows with pos_pin_enc=NULL return {stale:true} from
  viewOwnPosPinAction — UI prompts the user to click Reset, which writes
  the encrypted copy going forward.

One lesson from Gate 2

React re-render after state update needs a settle moment before Playwright
can query the new DOM tree. After clicking the Set/Reset/View button in
menu mode, the mode transition (setMode("set")) re-renders the modal with
the password input. A bare waitForSelector('[data-testid="self-pin-password"]')
times out on prod's slower-cold-Worker render because the locator probes
the OLD render's DOM where the password input doesn't exist yet.

Fix: tiny waitForTimeout(500) after the menu-button click, BEFORE
waitForSelector. Cheap insurance. Not needed on every action — only on
the first one that transitions menu→form.

Reusable pattern: when a click triggers a useState transition that
SUBSTITUTES one DOM subtree for another (not just toggles visibility),
add a small settle wait between click and locator.