feat: access extension — NFC door access via boltcards SUN + Home Assistant

Promoted from the door-portal scratch repo to its own repo for install via the
aiolabs catalog. Authenticates a tapped Bolt Card via boltcards /verify,
authorizes against per-door grants, and fires the door's local Home Assistant
webhook to unlock a Z-Wave lock. Fails closed; logs every attempt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-08-07 22:47:41 +02:00
commit 40564f06ec
22 changed files with 986 additions and 0 deletions

141
static/js/index.js Normal file
View file

@ -0,0 +1,141 @@
// LNbits `access` extension admin app.
// Manages doors (resources), grants (card → door permissions), and shows the
// access log. The reader hits /access/api/v1/check with a door controller token;
// this UI is admin-only (wallet admin key).
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
data() {
return {
doors: [],
grants: [],
logs: [],
doorCols: [
{ name: 'name', label: 'Name', field: 'name' },
{ name: 'id', label: 'ID', field: 'id' },
{ name: 'enabled', label: 'State', field: 'enabled' },
{ name: 'actions', label: '', field: 'actions' }
],
grantCols: [
{ name: 'door', label: 'Door', field: 'door_id' },
{ name: 'external_id', label: 'Card', field: 'external_id' },
{ name: 'label', label: 'Label', field: 'label' },
{ name: 'actions', label: '', field: 'actions' }
],
logCols: [
{ name: 'time', label: 'Time', field: 'time' },
{ name: 'door', label: 'Door', field: 'door_id' },
{ name: 'decision', label: 'Decision', field: 'decision' },
{ name: 'reason', label: 'Reason', field: 'reason' }
],
doorDialog: { show: false, data: { unlock_timeout_ms: 2500 } },
grantDialog: { show: false, data: {} }
}
},
computed: {
adminkey() {
return this.g.user.wallets[0].adminkey
},
inkey() {
return this.g.user.wallets[0].inkey
},
doorOptions() {
return this.doors.map(d => ({ label: d.name, value: d.id }))
}
},
methods: {
doorName(id) {
const d = this.doors.find(x => x.id === id)
return d ? d.name : id
},
async loadAll() {
await Promise.all([this.loadDoors(), this.loadGrants(), this.loadLogs()])
},
async loadDoors() {
const { data } = await LNbits.api.request(
'GET',
'/access/api/v1/doors',
this.inkey
)
this.doors = data
},
async loadGrants() {
const { data } = await LNbits.api.request(
'GET',
'/access/api/v1/grants',
this.inkey
)
this.grants = data
},
async loadLogs() {
const { data } = await LNbits.api.request(
'GET',
'/access/api/v1/logs',
this.inkey
)
this.logs = data
},
async createDoor() {
try {
await LNbits.api.request(
'POST',
'/access/api/v1/doors',
this.adminkey,
this.doorDialog.data
)
this.doorDialog = { show: false, data: { unlock_timeout_ms: 2500 } }
await this.loadDoors()
} catch (e) {
LNbits.utils.notifyApiError(e)
}
},
async deleteDoor(id) {
try {
await LNbits.api.request(
'DELETE',
`/access/api/v1/doors/${id}`,
this.adminkey
)
await this.loadAll()
} catch (e) {
LNbits.utils.notifyApiError(e)
}
},
showToken(door) {
this.$q.dialog({
title: `${door.name} — controller token`,
message: `Set this as X-Controller-Token on the reader for doorId "${door.id}":<br><br><code>${door.controller_token}</code>`,
html: true
})
},
async createGrant() {
try {
await LNbits.api.request(
'POST',
'/access/api/v1/grants',
this.adminkey,
this.grantDialog.data
)
this.grantDialog = { show: false, data: {} }
await this.loadGrants()
} catch (e) {
LNbits.utils.notifyApiError(e)
}
},
async deleteGrant(id) {
try {
await LNbits.api.request(
'DELETE',
`/access/api/v1/grants/${id}`,
this.adminkey
)
await this.loadGrants()
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
},
created() {
this.loadAll()
}
})