feat(access): access-control gate — npub-QR badge + PIN + dev bypass (ADR-003)
Squashed skeleton (was 11 commits on feat/access-control-skeleton) for a clean rebase onto dev. Adds a `locked` gate the terminal boots into until a credential is presented; opt-in and non-breaking (defaults off → boots straight to idle as before). - state-machine: `locked` state + ACCESS_GRANTED/ACCESS_DENIED/DEV_UNLOCK events + accessBypass/devUnlockAllowed guards (packages/state-machine). - services/access: reader abstraction, npub+PIN authorize() (nostr-tools nip19; accepts nostr:/nprofile), camera npub-QR reader, mock reader. - LockedView.vue + ColorModeToggle: branded viewfinder, PIN pad, denied reason, dev-unlock; camera off-by-default + idle return. - store/main/electron.d.ts: seed gate config, grant/deny/devUnlock wiring, access.json provisioning (no rebuild), get-config surface. - deploy: access.example.json + provision-access.sh; ADR-003. Credential union is npub today; UID (NFC tap) is the next step.
This commit is contained in:
parent
2ea3df01d1
commit
a7b409b109
20 changed files with 1534 additions and 28 deletions
|
|
@ -7,8 +7,9 @@ import { setBranding } from '@/composables/useBranding'
|
|||
import { classifyInitError } from '@/services/init-error'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Sun, Moon } from 'lucide-vue-next'
|
||||
import PairingWizard from '@/components/PairingWizard.vue'
|
||||
import LockedView from '@/views/LockedView.vue'
|
||||
import ColorModeToggle from '@/components/ColorModeToggle.vue'
|
||||
|
||||
const atmStore = useAtmStore()
|
||||
const route = useRoute()
|
||||
|
|
@ -289,6 +290,11 @@ function toggleLiveServices() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Access gate (ADR-003): shown when the machine is healthy but locked,
|
||||
below the init/maintenance gates above. Never renders when access
|
||||
control is disabled (the machine never dwells in `locked`). -->
|
||||
<LockedView v-else-if="atmStore.isLocked" />
|
||||
|
||||
<template v-else>
|
||||
<router-view />
|
||||
|
||||
|
|
@ -340,16 +346,10 @@ function toggleLiveServices() {
|
|||
</div>
|
||||
|
||||
<!-- Light/dark toggle (production only — debug panel has this in dev) -->
|
||||
<Button
|
||||
<ColorModeToggle
|
||||
v-if="!atmStore.allowMockFallback"
|
||||
variant="outline"
|
||||
class="fixed bottom-3 right-3 lg:bottom-6 lg:right-6 z-50 h-10 px-3 py-1 text-sm rounded-lg lg:h-[7vh] lg:min-h-[70px] lg:px-8 lg:py-3 lg:text-2xl lg:rounded-xl gap-2 lg:gap-3"
|
||||
@click="colorMode = colorMode === 'dark' ? 'light' : 'dark'"
|
||||
>
|
||||
<Sun v-if="colorMode === 'dark'" class="w-5 h-5 lg:w-7 lg:h-7" />
|
||||
<Moon v-else class="w-5 h-5 lg:w-7 lg:h-7" />
|
||||
{{ colorMode === 'dark' ? 'Light' : 'Dark' }}
|
||||
</Button>
|
||||
class="fixed bottom-3 right-3 z-50 lg:bottom-6 lg:right-6"
|
||||
/>
|
||||
|
||||
<!-- Debug overlay (dev only) -->
|
||||
<div
|
||||
|
|
|
|||
33
apps/machine/src/components/ColorModeToggle.vue
Normal file
33
apps/machine/src/components/ColorModeToggle.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Light/dark toggle — the single reusable control for switching color mode.
|
||||
*
|
||||
* Kiosk-sized by default (large touch target for a public display). colorMode
|
||||
* is global + persisted (toggles `.dark` on <html>, and dark mode pulls
|
||||
* branding.json's dark palette + logo-dark.png), so this stays in sync
|
||||
* wherever it's used. Position it via a fallthrough `class` on the consumer,
|
||||
* e.g. `<ColorModeToggle class="fixed bottom-6 right-6" />`.
|
||||
*/
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Sun, Moon } from 'lucide-vue-next'
|
||||
|
||||
const { colorMode } = useTheme()
|
||||
|
||||
function toggle() {
|
||||
colorMode.value = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="h-10 gap-2 rounded-lg px-3 py-1 text-sm lg:h-[7vh] lg:min-h-[70px] lg:gap-3 lg:rounded-xl lg:px-8 lg:py-3 lg:text-2xl"
|
||||
:aria-label="colorMode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
@click="toggle"
|
||||
>
|
||||
<Sun v-if="colorMode === 'dark'" class="h-5 w-5 lg:h-7 lg:w-7" />
|
||||
<Moon v-else class="h-5 w-5 lg:h-7 lg:w-7" />
|
||||
{{ colorMode === 'dark' ? 'Light' : 'Dark' }}
|
||||
</Button>
|
||||
</template>
|
||||
113
apps/machine/src/services/access/__tests__/authorize.test.ts
Normal file
113
apps/machine/src/services/access/__tests__/authorize.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { npubEncode, nprofileEncode } from 'nostr-tools/nip19'
|
||||
import { authorize, hashId, hashPin, type AllowListEntry } from '../authorize'
|
||||
|
||||
const SALT = 'test-salt'
|
||||
const HEX_A = 'aa'.repeat(32)
|
||||
const HEX_B = 'bb'.repeat(32)
|
||||
const NPUB_A = npubEncode(HEX_A)
|
||||
const NPUB_B = npubEncode(HEX_B)
|
||||
|
||||
describe('access authorize (ADR-003)', () => {
|
||||
describe('open enrollment (prototype)', () => {
|
||||
it('grants any valid npub as user', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
expect(out.status).toBe('granted')
|
||||
expect(out).toMatchObject({ status: 'granted', role: 'user' })
|
||||
})
|
||||
|
||||
it('rejects a stray / non-npub QR', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: 'https://example.com/not-an-npub' }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
expect(out.status).toBe('denied')
|
||||
expect(out).toMatchObject({ reason: 'not a valid npub' })
|
||||
})
|
||||
|
||||
it('accepts a `nostr:` URI prefix (with surrounding whitespace)', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: ` nostr:${NPUB_A}\n` }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
expect(out.status).toBe('granted')
|
||||
})
|
||||
|
||||
it('accepts an nprofile and resolves to the same identity as its npub', async () => {
|
||||
const nprofile = nprofileEncode({ pubkey: HEX_A, relays: ['wss://relay.example'] })
|
||||
const viaNprofile = await authorize({ kind: 'npub', npub: nprofile }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
const viaNpub = await authorize({ kind: 'npub', npub: NPUB_A }, [], {
|
||||
salt: SALT,
|
||||
openEnrollment: true,
|
||||
})
|
||||
expect(viaNprofile.status).toBe('granted')
|
||||
// Same underlying pubkey → same credential hash.
|
||||
expect(viaNprofile.credentialIdHash).toBe(viaNpub.credentialIdHash)
|
||||
})
|
||||
})
|
||||
|
||||
describe('allow-list (closed)', () => {
|
||||
it('denies an unlisted npub when not open-enrollment', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [], { salt: SALT })
|
||||
expect(out).toMatchObject({ status: 'denied', reason: 'not authorized' })
|
||||
})
|
||||
|
||||
it('grants a listed npub with its role, no PIN', async () => {
|
||||
const entry: AllowListEntry = { idHash: await hashId(HEX_A, SALT), role: 'operator' }
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [entry], { salt: SALT })
|
||||
expect(out).toMatchObject({ status: 'granted', role: 'operator' })
|
||||
})
|
||||
|
||||
it('does not match npub B against npub A entry', async () => {
|
||||
const entry: AllowListEntry = { idHash: await hashId(HEX_A, SALT), role: 'user' }
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_B }, [entry], { salt: SALT })
|
||||
expect(out.status).toBe('denied')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PIN second factor', () => {
|
||||
const makeEntry = async (): Promise<AllowListEntry> => ({
|
||||
idHash: await hashId(HEX_A, SALT),
|
||||
role: 'user',
|
||||
pinHash: await hashPin('1234', SALT),
|
||||
})
|
||||
|
||||
it('asks for a PIN when one is configured and none supplied', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], { salt: SALT })
|
||||
expect(out.status).toBe('pin-required')
|
||||
})
|
||||
|
||||
it('grants on correct PIN', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
|
||||
salt: SALT,
|
||||
pin: '1234',
|
||||
})
|
||||
expect(out).toMatchObject({ status: 'granted', role: 'user' })
|
||||
})
|
||||
|
||||
it('denies on wrong PIN', async () => {
|
||||
const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], {
|
||||
salt: SALT,
|
||||
pin: '9999',
|
||||
})
|
||||
expect(out).toMatchObject({ status: 'denied', reason: 'incorrect PIN' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('challenge credential (v2 seam)', () => {
|
||||
it('is not yet authorized', async () => {
|
||||
const out = await authorize(
|
||||
{ kind: 'challenge', pubkey: HEX_A, nonce: 'n', sig: 's' },
|
||||
[],
|
||||
{ salt: SALT, openEnrollment: true }
|
||||
)
|
||||
expect(out.status).toBe('denied')
|
||||
})
|
||||
})
|
||||
})
|
||||
141
apps/machine/src/services/access/authorize.ts
Normal file
141
apps/machine/src/services/access/authorize.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* Credential authorization (ADR-003).
|
||||
*
|
||||
* PROTOTYPE (this PR): a QR "badge" carrying an npub grants terminal access,
|
||||
* with an OPTIONAL PIN as a second factor. Matching is against a local
|
||||
* allow-list of hashed identities; `openEnrollment` admits any valid npub
|
||||
* (no allow-list) for early prototyping. Only salted hashes are compared or
|
||||
* stored — never the raw npub/UID (KYC-free).
|
||||
*
|
||||
* Identity id per scan kind:
|
||||
* - npub → hex pubkey (decoded, canonical), then hashed
|
||||
* - uid → raw UID hashed (NFC, PR4)
|
||||
* - challenge → v2 seam (PR5), not yet authorized
|
||||
*/
|
||||
|
||||
import { decode as nip19Decode } from 'nostr-tools/nip19'
|
||||
import type { AccessRole } from '@bitSpire/state-machine'
|
||||
import type { AccessScan } from './types'
|
||||
|
||||
/** One authorized identity. `idHash` = hashId(<canonical id>, salt). */
|
||||
export interface AllowListEntry {
|
||||
idHash: string
|
||||
role: AccessRole
|
||||
/** When set, access requires this PIN (hashPin(pin, salt)) as a 2nd factor. */
|
||||
pinHash?: string
|
||||
/** Optional operator-facing label (never a person's real identity). */
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface AuthorizeOptions {
|
||||
/** Per-machine salt for all hashing. */
|
||||
salt: string
|
||||
/** Admit any valid credential when the allow-list has no match (prototype). */
|
||||
openEnrollment?: boolean
|
||||
/** PIN supplied on the follow-up call after a `pin-required` outcome. */
|
||||
pin?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Three outcomes, so the caller can drive a two-step flow:
|
||||
* - `granted` → send ACCESS_GRANTED
|
||||
* - `pin-required` → prompt for a PIN, then call authorize() again with `pin`
|
||||
* - `denied` → send ACCESS_DENIED(reason)
|
||||
*/
|
||||
export type AuthorizeOutcome =
|
||||
| { status: 'granted'; role: AccessRole; credentialIdHash: string }
|
||||
| { status: 'pin-required'; credentialIdHash: string }
|
||||
| { status: 'denied'; credentialIdHash: string; reason: string }
|
||||
|
||||
/** Salted SHA-256, hex-encoded. */
|
||||
async function sha256Hex(input: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(input)
|
||||
const digest = await crypto.subtle.digest('SHA-256', data)
|
||||
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
export const hashId = (id: string, salt: string): Promise<string> => sha256Hex(`id:${salt}:${id}`)
|
||||
export const hashPin = (pin: string, salt: string): Promise<string> => sha256Hex(`pin:${salt}:${pin}`)
|
||||
|
||||
/**
|
||||
* Resolve a scan to a canonical identity string, or `null` if malformed.
|
||||
* npub is decoded to its hex pubkey so npub/hex forms compare equal and a
|
||||
* stray (non-npub) QR is rejected.
|
||||
*/
|
||||
function canonicalId(scan: AccessScan): string | null {
|
||||
if (scan.kind === 'uid') return scan.uid || null
|
||||
if (scan.kind === 'challenge') return null // v2 — handled separately
|
||||
// Tolerate real-world nostr QR shapes: a bare `npub1…`, a `nostr:` URI
|
||||
// prefix, and `nprofile1…` (npub + relay hints, what many clients export).
|
||||
const raw = scan.npub.trim().replace(/^nostr:/i, '')
|
||||
try {
|
||||
const decoded = nip19Decode(raw)
|
||||
if (decoded.type === 'npub' && typeof decoded.data === 'string') {
|
||||
return decoded.data
|
||||
}
|
||||
if (
|
||||
decoded.type === 'nprofile' &&
|
||||
decoded.data &&
|
||||
typeof (decoded.data as { pubkey?: unknown }).pubkey === 'string'
|
||||
) {
|
||||
return (decoded.data as { pubkey: string }).pubkey
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a scanned credential is authorized.
|
||||
* Always resolves (never throws) so the caller can uniformly react.
|
||||
*/
|
||||
export async function authorize(
|
||||
scan: AccessScan,
|
||||
allowList: AllowListEntry[],
|
||||
opts: AuthorizeOptions
|
||||
): Promise<AuthorizeOutcome> {
|
||||
if (scan.kind === 'challenge') {
|
||||
return {
|
||||
status: 'denied',
|
||||
credentialIdHash: '',
|
||||
reason: 'challenge-response credentials not yet supported',
|
||||
}
|
||||
}
|
||||
|
||||
const id = canonicalId(scan)
|
||||
if (!id) {
|
||||
return {
|
||||
status: 'denied',
|
||||
credentialIdHash: '',
|
||||
reason: scan.kind === 'npub' ? 'not a valid npub' : 'invalid credential',
|
||||
}
|
||||
}
|
||||
|
||||
const credentialIdHash = await hashId(id, opts.salt)
|
||||
const entry = allowList.find((e) => e.idHash === credentialIdHash)
|
||||
|
||||
if (!entry) {
|
||||
if (opts.openEnrollment) {
|
||||
return { status: 'granted', role: 'user', credentialIdHash }
|
||||
}
|
||||
return { status: 'denied', credentialIdHash, reason: 'not authorized' }
|
||||
}
|
||||
|
||||
// No PIN configured → single-factor grant.
|
||||
if (!entry.pinHash) {
|
||||
return { status: 'granted', role: entry.role, credentialIdHash }
|
||||
}
|
||||
|
||||
// PIN configured but not yet supplied → ask for it.
|
||||
if (opts.pin === undefined) {
|
||||
return { status: 'pin-required', credentialIdHash }
|
||||
}
|
||||
|
||||
// PIN supplied → verify.
|
||||
const pinHash = await hashPin(opts.pin, opts.salt)
|
||||
if (pinHash !== entry.pinHash) {
|
||||
return { status: 'denied', credentialIdHash, reason: 'incorrect PIN' }
|
||||
}
|
||||
return { status: 'granted', role: entry.role, credentialIdHash }
|
||||
}
|
||||
42
apps/machine/src/services/access/index.ts
Normal file
42
apps/machine/src/services/access/index.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Access-control module surface (ADR-003).
|
||||
*
|
||||
* `availableAccessReaders()` returns the readers this device can run, in
|
||||
* preference order: the camera npub-QR badge first (prototype, works on the
|
||||
* batm3 today), the mock reader as a keyboard/console fallback. PR3 adds a
|
||||
* Web-NFC reader and PR4 the serial `/dev/ttyNFC` reader ahead of these.
|
||||
*/
|
||||
|
||||
import { QrNpubAccessReader } from './qr-npub-reader'
|
||||
import { MockAccessReader } from './mock-reader'
|
||||
import type { AccessReader } from './types'
|
||||
|
||||
export type {
|
||||
AccessReader,
|
||||
AccessReaderKind,
|
||||
AccessReaderStartOptions,
|
||||
AccessScan,
|
||||
AccessRole,
|
||||
StopCapture,
|
||||
} from './types'
|
||||
export { QrNpubAccessReader } from './qr-npub-reader'
|
||||
export { MockAccessReader, MOCK_NPUB } from './mock-reader'
|
||||
export { authorize, hashId, hashPin } from './authorize'
|
||||
export type { AllowListEntry, AuthorizeOptions, AuthorizeOutcome } from './authorize'
|
||||
|
||||
/** All readers in preference order, regardless of availability. */
|
||||
export function allAccessReaders(): AccessReader[] {
|
||||
// PR3: WebNfcAccessReader, PR4: SerialNfcAccessReader — inserted ahead of the
|
||||
// camera once real NFC hardware is present.
|
||||
return [new QrNpubAccessReader(), new MockAccessReader()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the readers this device can run, in preference order. The mock reader
|
||||
* is always available, so it lands last as a guaranteed fallback.
|
||||
*/
|
||||
export async function availableAccessReaders(): Promise<AccessReader[]> {
|
||||
const readers = allAccessReaders()
|
||||
const flags = await Promise.all(readers.map((r) => r.isAvailable()))
|
||||
return readers.filter((_, i) => flags[i])
|
||||
}
|
||||
53
apps/machine/src/services/access/mock-reader.ts
Normal file
53
apps/machine/src/services/access/mock-reader.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Mock access reader (ADR-003) — SCAFFOLD, no hardware.
|
||||
*
|
||||
* A keyboard/console fallback for when no camera or NFC reader is present
|
||||
* (headless dev, CI, a batm3 with a dead camera). Emits an npub scan on
|
||||
* demand via two triggers:
|
||||
* - `window.__bitspireMockCard(npub?)` — from the LockedView dev button or
|
||||
* the devtools console.
|
||||
* - the `F9` key — a quick tap on the physical machine.
|
||||
*
|
||||
* Registered only when it is the sole available reader (see `index.ts`), so it
|
||||
* never shadows the real camera/NFC path.
|
||||
*/
|
||||
|
||||
import { npubEncode } from 'nostr-tools/nip19'
|
||||
import type { AccessReader, AccessReaderStartOptions, StopCapture } from './types'
|
||||
|
||||
/**
|
||||
* Default npub the mock emits when none is supplied — derived from a fixed
|
||||
* (all-ones) hex pubkey so it carries a valid bech32 checksum and survives
|
||||
* `authorize()`'s nip19 decode. Not a real key; dev-only.
|
||||
*/
|
||||
export const MOCK_NPUB = npubEncode('11'.repeat(32))
|
||||
|
||||
interface MockCardGlobal {
|
||||
__bitspireMockCard?: (npub?: string) => void
|
||||
}
|
||||
|
||||
export class MockAccessReader implements AccessReader {
|
||||
readonly kind = 'mock' as const
|
||||
readonly label = 'Mock reader (dev)'
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true
|
||||
}
|
||||
|
||||
async start(opts: AccessReaderStartOptions): Promise<StopCapture> {
|
||||
const emit = (npub: string = MOCK_NPUB) => opts.onScan({ kind: 'npub', npub })
|
||||
|
||||
const g = globalThis as unknown as MockCardGlobal
|
||||
g.__bitspireMockCard = emit
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'F9') emit()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
if (g.__bitspireMockCard === emit) delete g.__bitspireMockCard
|
||||
}
|
||||
}
|
||||
}
|
||||
79
apps/machine/src/services/access/qr-npub-reader.ts
Normal file
79
apps/machine/src/services/access/qr-npub-reader.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* QR-npub access reader (ADR-003, PROTOTYPE).
|
||||
*
|
||||
* Until the NFC reader hardware exists, the batm3's camera — the same one the
|
||||
* pairing wizard uses — reads a QR "badge" that encodes the user's npub. The
|
||||
* decoded npub is handed to `authorize()`, which admits it (optionally behind
|
||||
* a PIN). This is a thin adapter onto the same `qr/dom.js` decode loop as
|
||||
* `pairing/qr-source.ts`; see that file for the capture-resolution rationale.
|
||||
*
|
||||
* It emits the raw decoded string as an `npub` scan and lets `authorize()`
|
||||
* validate it — a stray, non-npub QR is rejected there, not here.
|
||||
*/
|
||||
|
||||
import { QRCanvas, frontalCamera, frameLoop } from 'qr/dom.js'
|
||||
import type { AccessReader, AccessReaderStartOptions, StopCapture } from './types'
|
||||
|
||||
export class QrNpubAccessReader implements AccessReader {
|
||||
readonly kind = 'qr-npub' as const
|
||||
readonly label = 'Camera (npub QR)'
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return (
|
||||
typeof navigator !== 'undefined' &&
|
||||
!!navigator.mediaDevices &&
|
||||
typeof navigator.mediaDevices.getUserMedia === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
async start(opts: AccessReaderStartOptions): Promise<StopCapture> {
|
||||
const { onScan, onError, video } = opts
|
||||
if (!video) throw new Error('QrNpubAccessReader requires a <video> element')
|
||||
|
||||
const camera = await frontalCamera(video)
|
||||
|
||||
// Match the pairing source's deliberate capture resolution (see
|
||||
// pairing/qr-source.ts) — 1280x960 balances px/module against decode speed
|
||||
// on this fixed-focus panel. Soft `ideal` so a camera that can't honor it
|
||||
// degrades instead of throwing.
|
||||
try {
|
||||
const stream = video.srcObject
|
||||
if (stream instanceof MediaStream) {
|
||||
await stream.getVideoTracks()[0]?.applyConstraints({
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 960 },
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
onError?.(e)
|
||||
}
|
||||
|
||||
const canvas = new QRCanvas() // decode-only
|
||||
|
||||
let stopped = false
|
||||
let cancel: (() => void) | null = null
|
||||
const stop: StopCapture = () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
cancel?.()
|
||||
camera.stop()
|
||||
}
|
||||
|
||||
cancel = frameLoop(() => {
|
||||
if (stopped) return
|
||||
try {
|
||||
const result = camera.readFrame(canvas, true)
|
||||
if (result) {
|
||||
// Stop on first decode so one badge isn't ingested repeatedly; the
|
||||
// store restarts the reader if authorization fails.
|
||||
stop()
|
||||
onScan({ kind: 'npub', npub: result.trim() })
|
||||
}
|
||||
} catch (e) {
|
||||
onError?.(e)
|
||||
}
|
||||
})
|
||||
|
||||
return stop
|
||||
}
|
||||
}
|
||||
60
apps/machine/src/services/access/types.ts
Normal file
60
apps/machine/src/services/access/types.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* Access-control reader abstraction (ADR-003).
|
||||
*
|
||||
* Mirrors the `services/pairing` `PairingSource` seam: an `AccessReader`
|
||||
* captures a credential from whatever hardware the machine has and hands an
|
||||
* `AccessScan` to the store, which authorizes it and grants/denies terminal
|
||||
* access. Implementations live next to this file:
|
||||
* - `qr-npub-reader.ts` — camera scans an npub QR "badge" (PROTOTYPE, works
|
||||
* on batm3 today — same camera the pairing wizard uses)
|
||||
* - `mock-reader.ts` — dev, no hardware (keyboard / console trigger)
|
||||
* - `web-nfc-reader.ts` — Web NFC / NDEFReader, laptop/phone dev (PR3)
|
||||
* - `serial-reader.ts` — /dev/ttyNFC via main-process HAL + IPC (PR4)
|
||||
*
|
||||
* The reader emits a RAW credential; hashing/authorization (and the optional
|
||||
* PIN second factor) is the store's job (see `authorize.ts`), so raw ids never
|
||||
* leave this layer (KYC-free).
|
||||
*/
|
||||
|
||||
import type { AccessRole } from '@bitSpire/state-machine'
|
||||
|
||||
export type { AccessRole }
|
||||
|
||||
export type AccessReaderKind = 'qr-npub' | 'mock' | 'nfc-web' | 'nfc-serial'
|
||||
|
||||
/**
|
||||
* A raw credential captured by a reader. Discriminated union so new factors
|
||||
* are additive:
|
||||
* - `npub` — prototype QR badge (this PR)
|
||||
* - `uid` — NFC card UID (PR4)
|
||||
* - `challenge` — card-signed nonce, challenge-response (PR5)
|
||||
*/
|
||||
export type AccessScan =
|
||||
| { kind: 'npub'; npub: string }
|
||||
| { kind: 'uid'; uid: string }
|
||||
| { kind: 'challenge'; pubkey: string; nonce: string; sig: string }
|
||||
|
||||
export interface AccessReaderStartOptions {
|
||||
/** Called with each captured credential. */
|
||||
onScan: (scan: AccessScan) => void
|
||||
/** Non-fatal capture error (e.g. a frame decode glitch). */
|
||||
onError?: (error: unknown) => void
|
||||
/**
|
||||
* The <video> element a camera reader renders into. Required by camera
|
||||
* readers (qr-npub); ignored by readers with no viewfinder (mock, NFC).
|
||||
*/
|
||||
video?: HTMLVideoElement
|
||||
}
|
||||
|
||||
/** Releases the reader (camera stream, event listeners, serial handle). Idempotent. */
|
||||
export type StopCapture = () => void
|
||||
|
||||
export interface AccessReader {
|
||||
readonly kind: AccessReaderKind
|
||||
/** Short human label for status/debug UI. */
|
||||
readonly label: string
|
||||
/** Whether this reader can run in the current environment. */
|
||||
isAvailable(): Promise<boolean>
|
||||
/** Begin capturing; resolves once the reader is live. */
|
||||
start(opts: AccessReaderStartOptions): Promise<StopCapture>
|
||||
}
|
||||
|
|
@ -8,7 +8,9 @@ import {
|
|||
type ActorRefFrom,
|
||||
type SnapshotFrom,
|
||||
type ATMMachine,
|
||||
type AccessRole,
|
||||
} from '@bitSpire/state-machine'
|
||||
import type { AccessControlConfig } from '@/types/electron'
|
||||
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
||||
import { classifyInitError } from '@/services/init-error'
|
||||
import { startOperatorConfigService, type OperatorConfigService } from '@/services/operator-config'
|
||||
|
|
@ -291,6 +293,18 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
// is in flight (settlement still arrives via the normal invoice watcher).
|
||||
const nfcStatus = ref<{ state: string; message?: string } | null>(null)
|
||||
const boltCardProcessing = ref(false)
|
||||
// Access-control gate config (ADR-003). Defaults disabled → the machine's
|
||||
// `locked` state bypasses straight to `idle` (behaviour identical to no gate).
|
||||
// Populated from RuntimeConfig.accessControl in initializeForProduction.
|
||||
const accessControl = ref<AccessControlConfig>({
|
||||
enabled: false,
|
||||
devUnlock: false,
|
||||
openEnrollment: false,
|
||||
salt: 'bitspire-access-v1',
|
||||
allowList: [],
|
||||
})
|
||||
// Build/dev bypass — opens the gate even when enabled (browser dev / CI).
|
||||
const accessBypassFlag = import.meta.env.VITE_SKIP_ACCESS_GATE === 'true'
|
||||
const fiatCode = ref('USD')
|
||||
// Defaults are 0 — the operator's fee config (received via Nostr
|
||||
// kind-30078 `bitspire-fees:<atm_pubkey>` envelope from satmachineadmin)
|
||||
|
|
@ -421,6 +435,11 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
|
||||
const isIdle = computed(() => currentState.value === 'idle')
|
||||
|
||||
// ADR-003: the machine is sitting at the access gate. When the gate is
|
||||
// disabled this is never true (the `locked` state bypasses to `idle` on
|
||||
// start), so App.vue's LockedView branch never renders on a non-access machine.
|
||||
const isLocked = computed(() => currentState.value === 'locked')
|
||||
|
||||
const isCashIn = computed(() => {
|
||||
const state = snapshot.value?.value
|
||||
return typeof state === 'object' && 'cashIn' in state
|
||||
|
|
@ -448,6 +467,8 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
currency: fiatCode.value,
|
||||
cashInFeeFraction: cashInFeeFraction.value,
|
||||
cashOutFeeFraction: cashOutFeeFraction.value,
|
||||
accessControlEnabled: accessControl.value.enabled,
|
||||
accessBypassFlag,
|
||||
})
|
||||
actor.value = createActor(machine)
|
||||
|
||||
|
|
@ -1183,6 +1204,19 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
const runtimeFiatCode = runtimeConfig.fiatCode || 'USD'
|
||||
fiatCode.value = runtimeFiatCode
|
||||
|
||||
// Access-control gate (ADR-003). Must be set BEFORE the machine is built
|
||||
// (initialize() reads accessControl.value to seed the `locked` state).
|
||||
if (runtimeConfig.accessControl) {
|
||||
accessControl.value = runtimeConfig.accessControl
|
||||
if (runtimeConfig.accessControl.enabled) {
|
||||
console.log(
|
||||
`[ATM] Access control ENABLED (openEnrollment=${runtimeConfig.accessControl.openEnrollment}, ` +
|
||||
`allowList=${runtimeConfig.accessControl.allowList.length} entries, ` +
|
||||
`devUnlock=${runtimeConfig.accessControl.devUnlock})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Load persisted operator fee config (aiolabs/lamassu-next#57). If no
|
||||
// config has ever been applied (fresh ATM, pre-operator-publish),
|
||||
// enter the 'awaiting-fees' maintenance state — UI shows the operator
|
||||
|
|
@ -1491,6 +1525,48 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
actor.value.send(event)
|
||||
}
|
||||
|
||||
// === Access control (ADR-003) ===
|
||||
|
||||
/**
|
||||
* Audit stub — records an access decision. PR1 logs only (hashed id, never a
|
||||
* raw credential); a fast-follow persists to state.db and optionally a Nostr
|
||||
* event (see ADR-003).
|
||||
*/
|
||||
function recordAccessAudit(outcome: {
|
||||
result: 'granted' | 'denied'
|
||||
role?: AccessRole
|
||||
credentialIdHash: string
|
||||
reason?: string
|
||||
}) {
|
||||
console.info('[Access] audit', {
|
||||
result: outcome.result,
|
||||
role: outcome.role ?? null,
|
||||
// Truncate the hash in logs — it's already non-reversible, but no need to
|
||||
// splash the full value across the journal.
|
||||
credentialIdHash: outcome.credentialIdHash.slice(0, 12),
|
||||
reason: outcome.reason ?? null,
|
||||
at: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
/** Grant terminal access after a credential (and any PIN) is authorized. */
|
||||
function grantAccess(role: AccessRole, credentialIdHash: string) {
|
||||
recordAccessAudit({ result: 'granted', role, credentialIdHash })
|
||||
send({ type: 'ACCESS_GRANTED', role, credentialIdHash })
|
||||
}
|
||||
|
||||
/** Reject an access attempt; the machine stays locked and shows the reason. */
|
||||
function denyAccess(reason: string, credentialIdHash = '') {
|
||||
recordAccessAudit({ result: 'denied', credentialIdHash, reason })
|
||||
send({ type: 'ACCESS_DENIED', reason })
|
||||
}
|
||||
|
||||
/** Runtime dev/operator unlock (gated by the machine's devUnlockAllowed guard). */
|
||||
function devUnlock() {
|
||||
recordAccessAudit({ result: 'granted', role: 'operator', credentialIdHash: 'dev-unlock' })
|
||||
send({ type: 'DEV_UNLOCK' })
|
||||
}
|
||||
|
||||
// Convenience methods for common events
|
||||
function selectCashIn() {
|
||||
send({ type: 'SELECT_CASH_IN' })
|
||||
|
|
@ -1641,6 +1717,13 @@ export const useAtmStore = defineStore('atm', () => {
|
|||
simulateBoltCardTap,
|
||||
simulateBoltCardReceive,
|
||||
|
||||
// Access control (ADR-003)
|
||||
accessControl,
|
||||
isLocked,
|
||||
grantAccess,
|
||||
denyAccess,
|
||||
devUnlock,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
initializeWithLightning,
|
||||
|
|
|
|||
22
apps/machine/src/types/electron.d.ts
vendored
22
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -2,6 +2,26 @@
|
|||
* Type declarations for Electron API exposed via preload
|
||||
*/
|
||||
|
||||
import type { AllowListEntry } from '../services/access/authorize'
|
||||
|
||||
/**
|
||||
* Access-control config (ADR-003). Loaded by the main process from env +
|
||||
* an optional /var/lib/bitspire/access.json. `enabled` defaults false, so a
|
||||
* machine with no access config behaves exactly as before.
|
||||
*/
|
||||
export interface AccessControlConfig {
|
||||
/** Master switch for the badge-to-enter gate. */
|
||||
enabled: boolean
|
||||
/** Allow the runtime dev/operator unlock gesture on the locked screen. */
|
||||
devUnlock: boolean
|
||||
/** Prototype: admit any valid npub when the allow-list has no match. */
|
||||
openEnrollment: boolean
|
||||
/** Per-machine salt for hashing credentials/PINs. */
|
||||
salt: string
|
||||
/** Authorized identities (hashed). Empty in open-enrollment prototype mode. */
|
||||
allowList: AllowListEntry[]
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
relayUrl: string
|
||||
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
||||
|
|
@ -17,6 +37,8 @@ export interface RuntimeConfig {
|
|||
maintenanceMode: boolean
|
||||
/** Operator branding override loaded from /var/lib/bitspire/branding/. Null when no override. */
|
||||
branding: BrandingConfig | null
|
||||
/** Access-control gate config (ADR-003). Always present; `enabled` defaults false. */
|
||||
accessControl: AccessControlConfig
|
||||
}
|
||||
|
||||
/** Operator branding config. Wire payload from Electron IPC; renderer applies via useBranding(). */
|
||||
|
|
|
|||
316
apps/machine/src/views/LockedView.vue
Normal file
316
apps/machine/src/views/LockedView.vue
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Access gate — "present your badge" screen (ADR-003).
|
||||
*
|
||||
* Shown when the machine is healthy but locked (App.vue's `isLocked` branch).
|
||||
* Until NFC hardware exists, the PROTOTYPE reader is the camera: the user
|
||||
* shows a QR encoding their npub. On decode we authorize() it (optionally
|
||||
* behind a PIN) and grant/deny access on the state machine. A mock reader
|
||||
* (F9 / console) is the keyboard fallback when no camera is present.
|
||||
*
|
||||
* Reader lifecycle lives here (this view owns the <video>), mirroring
|
||||
* PairingWizard; the store owns the machine events (grant/deny/devUnlock).
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { useBranding } from '@/composables/useBranding'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import ColorModeToggle from '@/components/ColorModeToggle.vue'
|
||||
import { ScanLine } from 'lucide-vue-next'
|
||||
import {
|
||||
availableAccessReaders,
|
||||
authorize,
|
||||
type AccessReader,
|
||||
type AccessScan,
|
||||
type StopCapture,
|
||||
} from '@/services/access'
|
||||
|
||||
const atmStore = useAtmStore()
|
||||
const { logoUrl, title } = useBranding()
|
||||
|
||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
const reader = shallowRef<AccessReader | null>(null)
|
||||
let stopCapture: StopCapture | null = null
|
||||
|
||||
// Two-step PIN flow: set when a scanned credential needs a PIN. Holds the
|
||||
// original scan so the PIN can be verified against the same identity.
|
||||
const pinPending = ref<AccessScan | null>(null)
|
||||
const pinEntry = ref('')
|
||||
const statusMessage = ref('')
|
||||
|
||||
const denyReason = computed(() => atmStore.snapshot?.context.accessDenyReason ?? null)
|
||||
const showDevUnlock = computed(() => atmStore.accessControl.devUnlock)
|
||||
|
||||
// The camera stays ON (always ready to scan), but the live stream is HIDDEN
|
||||
// behind a branded overlay by default — showing a moving camera feed to every
|
||||
// passer-by is distracting. Tapping the overlay reveals the preview so the
|
||||
// person can aim their QR; after 90s of no interaction we hide it again.
|
||||
const showStream = ref(false)
|
||||
const INACTIVITY_MS = 90_000
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function clearIdle() {
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer)
|
||||
idleTimer = null
|
||||
}
|
||||
}
|
||||
/** Hide the stream (and drop any half-entered PIN) back to the overlay. */
|
||||
function returnToOverlay() {
|
||||
clearIdle()
|
||||
showStream.value = false
|
||||
pinPending.value = null
|
||||
pinEntry.value = ''
|
||||
handling = false
|
||||
}
|
||||
function armIdle() {
|
||||
clearIdle()
|
||||
idleTimer = setTimeout(returnToOverlay, INACTIVITY_MS)
|
||||
}
|
||||
/** Reveal the live preview and (re)start the inactivity countdown. */
|
||||
function revealStream() {
|
||||
showStream.value = true
|
||||
armIdle()
|
||||
}
|
||||
/** Any user interaction while the stream/PIN is up keeps it awake. */
|
||||
function noteInteraction() {
|
||||
if (showStream.value || pinPending.value) armIdle()
|
||||
}
|
||||
|
||||
// Camera-preview rotation is a per-hardware-mount value (the batm3's webcam
|
||||
// sits differently from the Sintra's). Rather than hardcode-and-rebuild to
|
||||
// find it, make it adjustable live: press "r" to rotate 90° per press. The
|
||||
// choice persists (localStorage on the writable root) so it survives restarts,
|
||||
// and QR decode is rotation-invariant so this is purely cosmetic.
|
||||
const ROTATION_KEY = 'access-preview-rotation'
|
||||
const previewRotation = ref(((Number(localStorage.getItem(ROTATION_KEY)) % 360) + 360) % 360)
|
||||
const rotationClass = computed(
|
||||
() =>
|
||||
({ 0: '', 90: 'rotate-90', 180: 'rotate-180', 270: '-rotate-90' })[previewRotation.value] ?? ''
|
||||
)
|
||||
function cyclePreviewRotation() {
|
||||
previewRotation.value = (previewRotation.value + 90) % 360
|
||||
localStorage.setItem(ROTATION_KEY, String(previewRotation.value))
|
||||
}
|
||||
function onRotateKey(e: KeyboardEvent) {
|
||||
noteInteraction()
|
||||
// Only an operator/dev may re-orient the preview (gated like the unlock).
|
||||
if (!showDevUnlock.value) return
|
||||
if (e.key === 'r' || e.key === 'R') cyclePreviewRotation()
|
||||
}
|
||||
|
||||
async function teardown() {
|
||||
if (stopCapture) {
|
||||
try {
|
||||
stopCapture()
|
||||
} catch {
|
||||
/* idempotent */
|
||||
}
|
||||
stopCapture = null
|
||||
}
|
||||
}
|
||||
|
||||
async function startReader() {
|
||||
await teardown()
|
||||
const [first] = await availableAccessReaders()
|
||||
reader.value = first ?? null
|
||||
if (!first) {
|
||||
statusMessage.value = 'No reader available.'
|
||||
return
|
||||
}
|
||||
try {
|
||||
stopCapture = await first.start({
|
||||
video: first.kind === 'qr-npub' ? (videoEl.value ?? undefined) : undefined,
|
||||
onScan: handleScan,
|
||||
onError: (e) => console.warn('[Access] capture glitch:', e),
|
||||
})
|
||||
} catch (e) {
|
||||
statusMessage.value =
|
||||
e instanceof Error ? e.message : 'Could not start the reader.'
|
||||
}
|
||||
}
|
||||
|
||||
let handling = false
|
||||
async function handleScan(scan: AccessScan) {
|
||||
if (handling) return
|
||||
handling = true
|
||||
// A detected QR is an interaction — surface the preview so the result
|
||||
// (grant / PIN prompt / denial) is visible even if the overlay was up.
|
||||
revealStream()
|
||||
await teardown() // reader off while we decide
|
||||
|
||||
const cfg = atmStore.accessControl
|
||||
const outcome = await authorize(scan, cfg.allowList, {
|
||||
salt: cfg.salt,
|
||||
openEnrollment: cfg.openEnrollment,
|
||||
})
|
||||
|
||||
if (outcome.status === 'granted') {
|
||||
atmStore.grantAccess(outcome.role, outcome.credentialIdHash)
|
||||
return // machine leaves `locked`; view unmounts
|
||||
}
|
||||
if (outcome.status === 'pin-required') {
|
||||
pinPending.value = scan
|
||||
pinEntry.value = ''
|
||||
statusMessage.value = ''
|
||||
handling = false
|
||||
return
|
||||
}
|
||||
// denied — show reason and resume scanning
|
||||
atmStore.denyAccess(outcome.reason, outcome.credentialIdHash)
|
||||
handling = false
|
||||
await startReader()
|
||||
}
|
||||
|
||||
async function submitPin() {
|
||||
const scan = pinPending.value
|
||||
if (!scan) return
|
||||
const cfg = atmStore.accessControl
|
||||
const outcome = await authorize(scan, cfg.allowList, {
|
||||
salt: cfg.salt,
|
||||
openEnrollment: cfg.openEnrollment,
|
||||
pin: pinEntry.value,
|
||||
})
|
||||
if (outcome.status === 'granted') {
|
||||
atmStore.grantAccess(outcome.role, outcome.credentialIdHash)
|
||||
return
|
||||
}
|
||||
// wrong PIN (or anything else) — back to scanning
|
||||
atmStore.denyAccess(outcome.status === 'denied' ? outcome.reason : 'access denied')
|
||||
pinPending.value = null
|
||||
pinEntry.value = ''
|
||||
handling = false
|
||||
await startReader()
|
||||
}
|
||||
|
||||
function cancelPin() {
|
||||
pinPending.value = null
|
||||
pinEntry.value = ''
|
||||
handling = false
|
||||
void startReader()
|
||||
}
|
||||
|
||||
function pressDigit(d: string) {
|
||||
if (pinEntry.value.length < 12) pinEntry.value += d
|
||||
}
|
||||
function backspacePin() {
|
||||
pinEntry.value = pinEntry.value.slice(0, -1)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startReader()
|
||||
window.addEventListener('keydown', onRotateKey)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
clearIdle()
|
||||
teardown()
|
||||
window.removeEventListener('keydown', onRotateKey)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex flex-1 flex-col items-center justify-center gap-10 bg-background p-8 text-foreground"
|
||||
@pointerdown="noteInteraction"
|
||||
>
|
||||
<!-- Light/dark toggle — shared kiosk-sized component -->
|
||||
<ColorModeToggle class="absolute right-4 top-4 z-10" />
|
||||
|
||||
<!-- Brand: logo + title only, colours from the active theme (branding.json) -->
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<img
|
||||
v-if="logoUrl"
|
||||
:src="logoUrl"
|
||||
alt=""
|
||||
class="h-[16vh] max-h-44 w-auto object-contain"
|
||||
/>
|
||||
<h1 class="text-3xl font-bold tracking-tight lg:text-5xl">{{ title }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- PIN entry (second factor) -->
|
||||
<div v-if="pinPending" class="flex flex-col items-center gap-6">
|
||||
<p class="text-lg text-muted-foreground lg:text-2xl">Enter your PIN</p>
|
||||
<div class="font-mono text-4xl tracking-[0.5em] text-foreground">
|
||||
{{ '•'.repeat(pinEntry.length) || '—' }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<Button
|
||||
v-for="d in ['1', '2', '3', '4', '5', '6', '7', '8', '9']"
|
||||
:key="d"
|
||||
size="kiosk-icon"
|
||||
variant="outline"
|
||||
@click="pressDigit(d)"
|
||||
>{{ d }}</Button
|
||||
>
|
||||
<Button size="kiosk-icon" variant="ghost" @click="backspacePin">⌫</Button>
|
||||
<Button size="kiosk-icon" variant="outline" @click="pressDigit('0')">0</Button>
|
||||
<Button size="kiosk-icon" variant="default" @click="submitPin">✓</Button>
|
||||
</div>
|
||||
<Button variant="ghost" @click="cancelPin">Cancel</Button>
|
||||
</div>
|
||||
|
||||
<!-- Camera viewfinder (npub QR badge). The camera keeps running underneath;
|
||||
an opaque, pressable overlay hides the live feed until someone taps. -->
|
||||
<template v-else>
|
||||
<div class="flex flex-col items-center gap-5">
|
||||
<div
|
||||
class="relative overflow-hidden rounded-3xl border-4 border-primary bg-black shadow-xl"
|
||||
style="width: min(72vw, 26rem); aspect-ratio: 1 / 1"
|
||||
>
|
||||
<!-- Rotation is adjustable live (press "r"); persisted per machine. -->
|
||||
<video
|
||||
ref="videoEl"
|
||||
class="h-full w-full object-cover"
|
||||
:class="rotationClass"
|
||||
muted
|
||||
autoplay
|
||||
playsinline
|
||||
></video>
|
||||
<div class="pointer-events-none absolute inset-6 rounded-2xl border-2 border-primary/50"></div>
|
||||
|
||||
<!-- Default overlay: hides the stream + invites a tap to reveal it. -->
|
||||
<button
|
||||
v-if="!showStream"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-card text-card-foreground transition-colors hover:bg-card/90"
|
||||
@click="revealStream"
|
||||
>
|
||||
<ScanLine class="size-16 text-primary" />
|
||||
<span class="text-xl font-semibold lg:text-2xl">Tap to scan</span>
|
||||
<span class="max-w-[80%] text-center text-sm text-muted-foreground lg:text-base">
|
||||
Show the camera to scan your access QR
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="showStream">
|
||||
<p class="text-2xl font-semibold text-foreground lg:text-3xl">Scan to enter</p>
|
||||
<p class="max-w-md text-center text-base text-muted-foreground lg:text-xl">
|
||||
Present your access QR to the camera
|
||||
</p>
|
||||
<p v-if="denyReason" class="text-lg font-medium text-destructive lg:text-xl">
|
||||
{{ denyReason }}
|
||||
</p>
|
||||
<p v-else-if="statusMessage" class="text-base text-muted-foreground">
|
||||
{{ statusMessage }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="showDevUnlock" class="mt-2 flex flex-col items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-muted-foreground opacity-40 transition-opacity hover:opacity-100"
|
||||
@click="atmStore.devUnlock()"
|
||||
>
|
||||
Dev unlock
|
||||
</Button>
|
||||
<button
|
||||
class="text-xs text-muted-foreground opacity-40 transition-opacity hover:opacity-100"
|
||||
@click="cyclePreviewRotation"
|
||||
>
|
||||
press “r” to rotate camera · {{ previewRotation }}°
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
Loading…
Add table
Add a link
Reference in a new issue