feat(access): add End Session button to re-lock a tap-in session

A Bolt Card tap loads the holder's card for the whole session, so an
unattended idle menu is transactable by the next person until the 60s
IDLE_LOCK_TIMEOUT fires. Give the holder an explicit re-lock:

- END_SESSION event on `idle`, guarded to the active gate, targets
  `locked` (whose entry already clears the access session + loaded card).
  No-op on a gate-disabled machine that rests at idle.
- endSession() store action; IdleView shows a destructive-styled
  "End Session" button top-right only while accessControl.enabled.
- Tests: END_SESSION re-locks when the gate is active; no-op when off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ivBosaWmv8vwFE7ejrdHW
This commit is contained in:
Padreug 2026-08-15 12:30:45 +02:00
commit d35faf1c93
5 changed files with 53 additions and 0 deletions

View file

@ -90,6 +90,25 @@ describe('ATM access control (ADR-003)', () => {
})
})
describe('user-initiated end of session', () => {
it('END_SESSION re-locks immediately when the gate is active', () => {
const actor = createActor(createATMMachine({}, { accessControlEnabled: true }))
actor.start()
actor.send({ type: 'ACCESS_GRANTED', role: 'user', credentialIdHash: 'abc' })
expect(actor.getSnapshot().value).toBe('idle')
actor.send({ type: 'END_SESSION' })
expect(actor.getSnapshot().value).toBe('locked')
})
it('END_SESSION is a no-op when the gate is disabled (stays at idle)', () => {
const actor = createActor(createATMMachine()) // gate off → rests at idle
actor.start()
expect(actor.getSnapshot().value).toBe('idle')
actor.send({ type: 'END_SESSION' })
expect(actor.getSnapshot().value).toBe('idle')
})
})
describe('build/dev bypass', () => {
it('accessBypassFlag opens the gate even when enabled', () => {
const actor = createActor(

View file

@ -549,6 +549,11 @@ export function createATMMachine(
target: 'cashOut',
actions: ['setStartTime', 'setCashOutFee'],
},
// User taps "End session": re-lock now rather than waiting out
// IDLE_LOCK_TIMEOUT. Guarded to the active gate so a gate-disabled
// machine (which rests at idle) never leaves it via this event.
// `locked`'s entry clears the access session + loaded card.
END_SESSION: { guard: 'accessGateActive', target: 'locked' },
},
},

View file

@ -179,6 +179,10 @@ export type ATMEvent =
| { type: 'ACCESS_GRANTED'; role: AccessRole; credentialIdHash: string }
| { type: 'ACCESS_DENIED'; reason: string }
| { type: 'DEV_UNLOCK' }
// User-initiated end of a tap-in session: re-lock immediately instead of
// waiting out IDLE_LOCK_TIMEOUT, so a loaded Bolt Card can't be reused by
// the next person the moment its holder steps away.
| { type: 'END_SESSION' }
| { type: 'SELECT_AMOUNT'; amount: number }
| { type: 'FINISH_INSERTING' }
| { type: 'USER_SCANNED_NPUB'; npub: string }