feat(access): verified Bolt Card session at entry + hidden-by-default balance #93
5 changed files with 112 additions and 6 deletions
feat(machine): show the card holder's balance, hidden by default
A tap-to-enter session is effectively the holder logging into their card wallet, so show its balance — but a kiosk in a public place must not display a stranger's balance unasked. CardChip renders the card label with the balance masked (••••••) and an eye toggle; revealed, it mirrors the LNbits wallet page: sats, then the fiat equivalent formatted with Intl currency style. Fiat comes from the card server (the wallet's own currency, else the instance default, at its rate) and falls back to the ATM's fiat at its display rate when the server priced nothing. Shown on the idle menu and both cash screens; reveal state resets on re-lock. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
commit
6779d8ef55
78
apps/machine/src/components/CardChip.vue
Normal file
78
apps/machine/src/components/CardChip.vue
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* The Bolt Card loaded for this session (ADR-003 tap-to-enter): card label,
|
||||
* and the card wallet's balance HIDDEN BY DEFAULT behind an eye toggle — a
|
||||
* kiosk in a public space must not show a stranger's balance unasked. The
|
||||
* revealed line mirrors the LNbits wallet page: sats, then the fiat
|
||||
* equivalent in the wallet's own currency (Intl currency formatting), falling
|
||||
* back to the ATM's fiat at its display rate when the card server priced
|
||||
* nothing. Reveal state lives in the store and resets on re-lock.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Nfc, Eye, EyeOff } from 'lucide-vue-next'
|
||||
|
||||
const atmStore = useAtmStore()
|
||||
const card = computed(() => atmStore.loadedBoltCard)
|
||||
|
||||
const label = computed(() => {
|
||||
const c = card.value
|
||||
if (!c) return ''
|
||||
return c.cardName
|
||||
? `${c.cardName} · ••${c.externalId.slice(-4)}`
|
||||
: `Card ••${c.externalId.slice(-4)}`
|
||||
})
|
||||
const sats = computed(() =>
|
||||
card.value ? new Intl.NumberFormat().format(card.value.balanceSats) : ''
|
||||
)
|
||||
const fiat = computed(() => {
|
||||
const f = atmStore.loadedCardFiat
|
||||
if (!f) return null
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency: f.currency }).format(
|
||||
f.amount
|
||||
)
|
||||
} catch {
|
||||
return `${f.amount.toFixed(2)} ${f.currency}`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="card"
|
||||
class="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-2 text-left lg:gap-4 lg:px-6 lg:py-3"
|
||||
>
|
||||
<Nfc class="size-6 shrink-0 text-primary lg:size-8" />
|
||||
<div class="flex min-w-0 flex-col leading-tight">
|
||||
<span class="truncate text-xs uppercase tracking-wide text-muted-foreground lg:text-sm">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="atmStore.cardBalanceRevealed"
|
||||
class="text-base font-semibold text-foreground lg:text-2xl"
|
||||
>
|
||||
{{ sats }} sats
|
||||
<span v-if="fiat" class="ml-2 font-normal text-muted-foreground">≈ {{ fiat }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-base font-semibold tracking-widest text-muted-foreground lg:text-2xl"
|
||||
aria-label="Balance hidden"
|
||||
>
|
||||
••••••
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="ml-auto h-10 w-10 shrink-0 rounded-full lg:h-14 lg:w-14"
|
||||
:aria-label="atmStore.cardBalanceRevealed ? 'Hide balance' : 'Show balance'"
|
||||
@click="atmStore.toggleCardBalance()"
|
||||
>
|
||||
<EyeOff v-if="atmStore.cardBalanceRevealed" class="size-5 lg:size-7" />
|
||||
<Eye v-else class="size-5 lg:size-7" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -324,6 +324,9 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
// hit-keyed withdraw/pay steps plus balance + fiat for display. Cleared when
|
||||
// the machine re-locks. Never logged.
|
||||
const loadedBoltCard = ref<CardSession | null>(null)
|
||||
// The card balance is hidden by default on the public screen; the holder
|
||||
// reveals it with the eye toggle. Resets on re-lock.
|
||||
const cardBalanceRevealed = ref(false)
|
||||
const fiatCode = ref('USD')
|
||||
// Defaults are 0 — the operator's fee config (received via Nostr
|
||||
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
|
||||
|
|
@ -459,6 +462,25 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
// start), so App.vue's LockedView branch never renders on a non-access machine.
|
||||
const isLocked = computed(() => currentState.value === 'locked')
|
||||
|
||||
/**
|
||||
* Fiat view of the loaded card's balance, the way the LNbits wallet page
|
||||
* prices it: the card server's own currency and rate first (the wallet's
|
||||
* currency, else the instance default); when it priced nothing, the ATM's
|
||||
* fiat at its display rate. Null when neither is available.
|
||||
*/
|
||||
const loadedCardFiat = computed<{ amount: number; currency: string } | null>(() => {
|
||||
const card = loadedBoltCard.value
|
||||
if (!card) return null
|
||||
if (card.currency && card.fiat !== null) return { amount: card.fiat, currency: card.currency }
|
||||
if (btcPrice.value && btcPrice.value > 0) {
|
||||
return { amount: (card.balanceSats / 1e8) * btcPrice.value, currency: fiatCode.value }
|
||||
}
|
||||
return null
|
||||
})
|
||||
function toggleCardBalance() {
|
||||
cardBalanceRevealed.value = !cardBalanceRevealed.value
|
||||
}
|
||||
|
||||
const isCashIn = computed(() => {
|
||||
const state = snapshot.value?.value
|
||||
return typeof state === 'object' && 'cashIn' in state
|
||||
|
|
@ -517,6 +539,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
// re-locks) so the next customer starts fresh — never carry a card over.
|
||||
if (state === 'locked' && loadedBoltCard.value) {
|
||||
loadedBoltCard.value = null
|
||||
cardBalanceRevealed.value = false
|
||||
}
|
||||
|
||||
// Detect network from first invoice we see
|
||||
|
|
@ -753,6 +776,7 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
})
|
||||
if (outcome.status === 'granted') {
|
||||
loadedBoltCard.value = opened.session
|
||||
cardBalanceRevealed.value = false
|
||||
nfcStatus.value = { state: 'accepted', message: 'Card accepted' }
|
||||
grantAccess(outcome.role, outcome.credentialIdHash)
|
||||
} else {
|
||||
|
|
@ -1869,6 +1893,9 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
simulateBoltCardReceive,
|
||||
// Tap-to-enter: card session opened at the locked screen, reused at Complete
|
||||
loadedBoltCard,
|
||||
cardBalanceRevealed,
|
||||
loadedCardFiat,
|
||||
toggleCardBalance,
|
||||
completeWithCard,
|
||||
simulateBoltCardEntry,
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { watch, computed, ref } from 'vue'
|
|||
import { useRouter } from 'vue-router'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import CardChip from '@/components/CardChip.vue'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import QRCode from '@/components/QRCode.vue'
|
||||
|
|
@ -368,9 +369,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice)
|
|||
v-if="atmStore.loadedBoltCard"
|
||||
class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
|
||||
>
|
||||
<p class="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }}
|
||||
</p>
|
||||
<CardChip class="w-full" />
|
||||
<Button
|
||||
class="w-full bg-success text-success-foreground"
|
||||
size="kiosk-lg"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { watch, computed, ref } from 'vue'
|
|||
import { useRouter } from 'vue-router'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import CardChip from '@/components/CardChip.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import QRCode from '@/components/QRCode.vue'
|
||||
|
|
@ -333,9 +334,7 @@ function formatFiat(cents: number): string {
|
|||
v-if="atmStore.loadedBoltCard"
|
||||
class="flex w-full max-w-md flex-col items-center gap-2 pt-2"
|
||||
>
|
||||
<p class="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }}
|
||||
</p>
|
||||
<CardChip class="w-full" />
|
||||
<Button
|
||||
class="w-full bg-success text-success-foreground"
|
||||
size="kiosk-lg"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useAtmStore } from '@/stores/atm'
|
|||
import { useBranding } from '@/composables/useBranding'
|
||||
import { initialContext } from '@bitSpire/state-machine'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import CardChip from '@/components/CardChip.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import BitcoinIcon from '@/components/BitcoinIcon.vue'
|
||||
import QRCode from '@/components/QRCode.vue'
|
||||
|
|
@ -104,6 +105,8 @@ function handleCashOut() {
|
|||
>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Tap-to-enter: the holder's card session, balance hidden until revealed -->
|
||||
<CardChip v-if="atmStore.loadedBoltCard" class="mt-1 w-full max-w-md" />
|
||||
</div>
|
||||
|
||||
<!-- Touch Zones -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue