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 a4e3b2ccc8
5 changed files with 53 additions and 0 deletions

View file

@ -1639,6 +1639,15 @@ export const useAtmStore = defineStore('atm', () => {
send({ type: 'DEV_UNLOCK' }) send({ type: 'DEV_UNLOCK' })
} }
/**
* End the current tap-in session on demand and re-lock immediately (drops the
* loaded Bolt Card via `locked`'s entry), instead of waiting out the idle
* timeout. No-op unless the gate is active and we're on the idle menu.
*/
function endSession() {
send({ type: 'END_SESSION' })
}
// Convenience methods for common events // Convenience methods for common events
function selectCashIn() { function selectCashIn() {
send({ type: 'SELECT_CASH_IN' }) send({ type: 'SELECT_CASH_IN' })
@ -1799,6 +1808,7 @@ export const useAtmStore = defineStore('atm', () => {
grantAccess, grantAccess,
denyAccess, denyAccess,
devUnlock, devUnlock,
endSession,
// Actions // Actions
initialize, initialize,

View file

@ -183,6 +183,21 @@ function handleCashOut() {
? ?
</Button> </Button>
<!-- End-session button (top-right) — only while the access gate is engaged.
A tap-in loads the holder's Bolt Card for the whole session, so give
them an explicit way to re-lock the moment they're done instead of
relying on the idle timeout (which would leave the card usable by the
next person in the meantime). -->
<Button
v-if="atmStore.accessControl.enabled"
variant="outline"
size="kiosk"
class="absolute top-4 right-4 lg:top-8 lg:right-8 h-14 min-h-0 gap-2 rounded-full border-2 border-destructive/50 px-4 text-sm font-bold text-destructive lg:h-20 lg:px-8 lg:text-2xl"
@click="atmStore.endSession()"
>
🔒 End Session
</Button>
<!-- Debug toggle (only visible when debug bar is hidden, never in production) --> <!-- Debug toggle (only visible when debug bar is hidden, never in production) -->
<Button <Button
v-if="!atmStore.debugMode && atmStore.allowMockFallback" v-if="!atmStore.debugMode && atmStore.allowMockFallback"

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', () => { describe('build/dev bypass', () => {
it('accessBypassFlag opens the gate even when enabled', () => { it('accessBypassFlag opens the gate even when enabled', () => {
const actor = createActor( const actor = createActor(

View file

@ -549,6 +549,11 @@ export function createATMMachine(
target: 'cashOut', target: 'cashOut',
actions: ['setStartTime', 'setCashOutFee'], 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_GRANTED'; role: AccessRole; credentialIdHash: string }
| { type: 'ACCESS_DENIED'; reason: string } | { type: 'ACCESS_DENIED'; reason: string }
| { type: 'DEV_UNLOCK' } | { 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: 'SELECT_AMOUNT'; amount: number }
| { type: 'FINISH_INSERTING' } | { type: 'FINISH_INSERTING' }
| { type: 'USER_SCANNED_NPUB'; npub: string } | { type: 'USER_SCANNED_NPUB'; npub: string }