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.
| test-self-pin-prod.mjs | 6/6 |
| test-phase1-prod.mjs | 11/11 |
| test-phase2-sso-outdoor-prod.mjs | 6/6 |
| test-phase2-cafe-multishop-prod.mjs | 6/6 |
| test-m1-prod.mjs | 10/10 |
| test-r7-prod.mjs | 14/14 |
| test-r8-prod.mjs | 4/4 |
All node-fetch regressions run with NODE_TLS_REJECT_UNAUTHORIZED=0 for the local Windows schannel cert-chain workaround (not a code regression).
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
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.
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.