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:
commit
40564f06ec
22 changed files with 986 additions and 0 deletions
BIN
static/image/access.png
Normal file
BIN
static/image/access.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 199 B |
141
static/js/index.js
Normal file
141
static/js/index.js
Normal 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()
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue