Files
hackerspace.sh/app/(app)/door/manage/door-manage-client.tsx
T
Moheeb Zara 7ec90b0f1e Door epic phases 4-5: inbound access-log ingest + universal API-call buttons
Phase 4 (migration 053): pull entry/denied events into door_access_log and
match each card to a member, via two transports sharing one ingest core.
Poll: a CRON_SECRET-guarded /api/cron/door-ingest reads each opted-in
native-HeatSync connection's ?z log through the existing hardened executor
and parses it (firmware-characterized, best-effort ring buffer). Webhook:
/api/door/inbound/[connection] accepts pushed event JSON authenticated by a
per-connection bearer secret from the vault. Both dedupe on
(connection_id, dedupe_key) so re-polls and retries are no-ops. Adds an
inbound config panel to /door/manage.

Phase 5 (migration 054): admins define api_buttons that fire a configured
HTTP call (full verbs + headers + body) when an authorized member presses
them, through the same hardened egress (now shared between callDoor and the
new callApi). Each button carries a required_permission (default the new
apicall.invoke code); members see only buttons they may press. Builder at
/door/buttons with a door-template preset; members press from /doors. Every
press is recorded in api_call_log with secrets redacted.

Both subsystems are inert until an admin enables inbound on a connection or
creates a button. 624 unit + 50 integration tests; migrations applied
top-to-bottom in schema.sql; types and docs updated.
2026-05-20 20:45:44 -07:00

494 lines
24 KiB
TypeScript

'use client'
import { useState } from 'react'
import { toast } from 'sonner'
import { DoorClosed, Plus } from 'lucide-react'
import { PageHeader, PageTitle } from '@/components/ui/page-title'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
import { useConfirm } from '@/components/ui/confirm'
import {
createDoorConnection,
updateDoorConnection,
deleteDoorConnection,
testDoorConnection,
doorControl,
grantCard,
revokeCard,
listDoorCards,
} from '@/lib/actions'
import { KNOWN_DOOR_CONTROLLERS, controllerForAdapter } from '@/lib/door-logic'
type Conn = {
id: string
name: string
adapter: string
base_url: string
pinned_host: string
auth_mode: string
auth_param: string | null
secret_ref: string | null
verbs: Record<string, string> | null
allow_member_self_entry: boolean
is_enabled: boolean
inbound_enabled: boolean
inbound_secret_ref: string | null
}
type LogRow = { id: string; action: string; success: boolean; detail: string | null; occurred_at: string }
type DoorCard = { cardId: string; memberName: string; label: string | null; last4: string; slot: number | null }
const input = 'w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary'
function emptyDraft() {
return {
name: '', adapter: 'native_heatsync', base_url: '', pinned_host: '',
secret_ref: '', allow_member_self_entry: false,
grant: '', revoke: '', open: '', status: '',
}
}
export function DoorManageClient({
initial,
secrets,
log,
canOperate,
}: {
initial: unknown[]
secrets: { id: string; title: string }[]
log: unknown[]
canOperate: boolean
}) {
const confirm = useConfirm()
const [conns, setConns] = useState<Conn[]>(initial as Conn[])
const [showNew, setShowNew] = useState(false)
const [d, setD] = useState(emptyDraft())
const [busy, setBusy] = useState(false)
const [cardsFor, setCardsFor] = useState<string | null>(null)
const [cards, setCards] = useState<DoorCard[]>([])
const [cardsBusy, setCardsBusy] = useState(false)
const [inboundFor, setInboundFor] = useState<string | null>(null)
const rows = log as LogRow[]
async function onControl(c: Conn, verb: 'open' | 'unlock' | 'lock') {
const label = verb === 'open' ? 'Open (momentary)' : verb === 'unlock' ? 'Unlock (hold open)' : 'Lock'
const ok = await confirm({
title: `${label}?`,
description: `This sends a live "${verb}" command to "${c.name}". It physically actuates the door.`,
confirmText: label,
destructive: verb === 'unlock',
})
if (!ok) return
setBusy(true)
const res = await doorControl({ connectionId: c.id, verb })
setBusy(false)
if ('error' in res && res.error) return toast.error(res.error)
toast.success(`Sent "${verb}" (HTTP ${(res as { data: { status: number } }).data.status})`)
}
async function loadCards(c: Conn) {
if (cardsFor === c.id) { setCardsFor(null); return }
setCardsFor(c.id)
setCardsBusy(true)
const res = await listDoorCards({ connectionId: c.id })
setCardsBusy(false)
if ('error' in res && res.error) { setCardsFor(null); return toast.error(res.error) }
setCards((res as { data: DoorCard[] }).data)
}
async function onGrant(c: Conn, card: DoorCard) {
const ok = await confirm({
title: 'Grant access?',
description: `Push card •••${card.last4} (${card.memberName}) to "${c.name}". This writes the card to the physical controller.`,
confirmText: 'Grant',
})
if (!ok) return
setCardsBusy(true)
const res = await grantCard({ connectionId: c.id, cardId: card.cardId })
setCardsBusy(false)
if ('error' in res && res.error) return toast.error(res.error)
const slot = (res as { data: { slot: number } }).data.slot
setCards(prev => prev.map(x => (x.cardId === card.cardId ? { ...x, slot } : x)))
toast.success(`Granted (slot ${slot})`)
}
async function onRevoke(c: Conn, card: DoorCard) {
const ok = await confirm({
title: 'Revoke access?',
description: `Remove card •••${card.last4} (${card.memberName}) from "${c.name}". This deletes it from the physical controller.`,
confirmText: 'Revoke',
destructive: true,
})
if (!ok) return
setCardsBusy(true)
const res = await revokeCard({ connectionId: c.id, cardId: card.cardId })
setCardsBusy(false)
if ('error' in res && res.error) return toast.error(res.error)
setCards(prev => prev.map(x => (x.cardId === card.cardId ? { ...x, slot: null } : x)))
toast.success('Revoked')
}
function buildVerbs(dr: typeof d) {
if (dr.adapter === 'native_heatsync') return {}
const v: Record<string, string> = {}
if (dr.grant.trim()) v.grant = dr.grant.trim()
if (dr.revoke.trim()) v.revoke = dr.revoke.trim()
if (dr.open.trim()) v.open = dr.open.trim()
if (dr.status.trim()) v.status = dr.status.trim()
return v
}
async function onCreate(e: React.FormEvent) {
e.preventDefault()
if (!d.name.trim() || !d.base_url.trim() || !d.pinned_host.trim()) {
return toast.error('Name, base URL and pinned host are required')
}
setBusy(true)
const res = await createDoorConnection({
name: d.name.trim(),
adapter: d.adapter,
base_url: d.base_url.trim(),
pinned_host: d.pinned_host.trim(),
auth_mode: d.secret_ref ? 'query' : 'none',
auth_param: d.adapter === 'native_heatsync' ? 'e' : null,
secret_ref: d.secret_ref || null,
verbs: buildVerbs(d),
allow_member_self_entry: d.allow_member_self_entry,
})
setBusy(false)
if ('error' in res && res.error) return toast.error(res.error)
const id = (res as { data: { id: string } }).data.id
setConns(prev => [
{
id, name: d.name.trim(), adapter: d.adapter, base_url: d.base_url.trim(),
pinned_host: d.pinned_host.trim(), auth_mode: d.secret_ref ? 'query' : 'none',
auth_param: d.adapter === 'native_heatsync' ? 'e' : null, secret_ref: d.secret_ref || null,
verbs: buildVerbs(d), allow_member_self_entry: d.allow_member_self_entry, is_enabled: true,
inbound_enabled: false, inbound_secret_ref: null,
},
...prev,
])
setD(emptyDraft())
setShowNew(false)
toast.success('Connection created')
}
async function onToggleEnabled(c: Conn) {
const res = await updateDoorConnection({ connectionId: c.id, is_enabled: !c.is_enabled })
if ('error' in res && res.error) return toast.error(res.error)
setConns(prev => prev.map(x => (x.id === c.id ? { ...x, is_enabled: !x.is_enabled } : x)))
}
async function onToggleSelfEntry(c: Conn) {
const next = !c.allow_member_self_entry
if (next) {
const ok = await confirm({
title: 'Allow member self-entry?',
description: 'Any active member will be able to trigger this door themselves. This is an elevated physical-security risk. Continue?',
confirmText: 'Enable self-entry',
destructive: true,
})
if (!ok) return
}
const res = await updateDoorConnection({ connectionId: c.id, allow_member_self_entry: next })
if ('error' in res && res.error) return toast.error(res.error)
setConns(prev => prev.map(x => (x.id === c.id ? { ...x, allow_member_self_entry: next } : x)))
}
async function onSetInboundSecret(c: Conn, ref: string) {
const res = await updateDoorConnection({ connectionId: c.id, inbound_secret_ref: ref || null })
if ('error' in res && res.error) return toast.error(res.error)
setConns(prev => prev.map(x => (x.id === c.id ? { ...x, inbound_secret_ref: ref || null } : x)))
}
async function onToggleInbound(c: Conn) {
const next = !c.inbound_enabled
if (next && !c.inbound_secret_ref) {
return toast.error('Choose a webhook secret first; it authenticates inbound events.')
}
const res = await updateDoorConnection({ connectionId: c.id, inbound_enabled: next })
if ('error' in res && res.error) return toast.error(res.error)
setConns(prev => prev.map(x => (x.id === c.id ? { ...x, inbound_enabled: next } : x)))
}
function webhookUrl(id: string): string {
const origin = typeof window !== 'undefined' ? window.location.origin : ''
return `${origin}/api/door/inbound/${id}`
}
async function copyWebhookUrl(id: string) {
try {
await navigator.clipboard.writeText(webhookUrl(id))
toast.success('Webhook URL copied')
} catch {
toast.error('Could not copy. Select and copy the URL manually.')
}
}
async function onTest(c: Conn) {
setBusy(true)
const res = await testDoorConnection({ connectionId: c.id })
setBusy(false)
if ('error' in res && res.error) return toast.error(`Test failed: ${res.error}`)
toast.success(`Reachable (HTTP ${(res as { data: { status: number } }).data.status})`)
}
async function onDelete(c: Conn) {
const ok = await confirm({
title: 'Delete connection',
description: `"${c.name}" will be removed. Existing access-log entries are kept.`,
confirmText: 'Delete',
destructive: true,
})
if (!ok) return
const res = await deleteDoorConnection({ connectionId: c.id })
if ('error' in res && res.error) return toast.error(res.error)
setConns(prev => prev.filter(x => x.id !== c.id))
toast.success('Deleted')
}
return (
<>
<PageHeader>
<PageTitle>Door access</PageTitle>
<Button size="sm" onClick={() => setShowNew(v => !v)}>
<Plus className="size-4" /> {showNew ? 'Cancel' : 'New connection'}
</Button>
</PageHeader>
<div className="p-4 md:p-6 space-y-4">
<p className="font-sans text-sm text-muted-foreground max-w-2xl">
A connection targets a door controller. Because this app is cloud-hosted, the
target can be a publicly reachable controller or proxy, or a VPN-reachable
device on your LAN. The shared password is read from your encrypted Secrets
vault and never stored here. The server only ever calls the exact pinned host
(any public or private host you set, except cloud-metadata/link-local), never
follows redirects, caps time and response size, and records every action in the
access log with secrets redacted.
</p>
{showNew && (
<form onSubmit={onCreate} className="rounded-lg border border-border bg-card p-4 space-y-3">
<input className={input} placeholder="Name (e.g. Front door)" maxLength={200}
value={d.name} onChange={e => setD({ ...d, name: e.target.value })} />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<select className={input} value={d.adapter} onChange={e => setD({ ...d, adapter: e.target.value })}>
{KNOWN_DOOR_CONTROLLERS.map(c => (
<option key={c.id} value={c.adapter}>{c.label}</option>
))}
</select>
<select className={input} value={d.secret_ref} onChange={e => setD({ ...d, secret_ref: e.target.value })}>
<option value="">No password (auth: none)</option>
{secrets.map(s => <option key={s.id} value={s.id}>Password: {s.title}</option>)}
</select>
<input className={input} placeholder="Base URL (http://192.168.1.50/)"
value={d.base_url} onChange={e => setD({ ...d, base_url: e.target.value })} />
<input className={input} placeholder="Pinned host (192.168.1.50)"
value={d.pinned_host} onChange={e => setD({ ...d, pinned_host: e.target.value })} />
</div>
{(() => {
const k = controllerForAdapter(d.adapter)
if (!k) return null
return (
<div className="rounded border border-border bg-background p-3 text-xs text-muted-foreground space-y-1">
<p className="font-sans">{k.note}</p>
{k.repos.length > 0 && (
<p className="font-mono text-[10px]">
Source:{' '}
{k.repos.map((r, i) => (
<span key={r.url}>
{i > 0 && ' · '}
<a href={r.url} target="_blank" rel="noopener noreferrer" className="text-primary underline">{r.label}</a>
</span>
))}
</p>
)}
</div>
)
})()}
{d.adapter === 'generic_http' && (
<div className="rounded border border-border bg-background p-3 space-y-3">
<div className="text-xs text-muted-foreground space-y-1">
<p className="font-sans">
Per-verb request templates. The template is appended to the base URL and
sent as an HTTP <code className="font-mono">GET</code> to the pinned host
(no redirects followed). Placeholders are URL-encoded and substituted:
</p>
<ul className="font-mono text-[10px] list-disc list-inside">
<li><code>{'{slot}'}</code> — the member&rsquo;s integer card slot</li>
<li><code>{'{tag}'}</code> — the card UID / tag value</li>
<li><code>{'{perm}'}</code> — permission level (default 1)</li>
<li><code>{'{door}'}</code> — door identifier (for open/lock verbs)</li>
<li><code>{'{pw}'}</code> — the shared password from your Secrets vault (server-side only; never shown or logged)</li>
</ul>
<p className="font-sans">
Leave a verb blank if your controller does not support it. Example
(HeatSync-style): <code className="font-mono">?m{'{slot}'}&p001&t{'{tag}'}&e={'{pw}'}</code>
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input className={input} placeholder="grant template — e.g. ?m{slot}&t{tag}&e={pw}"
value={d.grant} onChange={e => setD({ ...d, grant: e.target.value })} />
<input className={input} placeholder="revoke template — e.g. ?r{slot}&e={pw}"
value={d.revoke} onChange={e => setD({ ...d, revoke: e.target.value })} />
<input className={input} placeholder="open template — e.g. ?o{door}&e={pw}"
value={d.open} onChange={e => setD({ ...d, open: e.target.value })} />
<input className={input} placeholder="status template — e.g. ?9&e={pw}"
value={d.status} onChange={e => setD({ ...d, status: e.target.value })} />
</div>
</div>
)}
<label className="flex items-center gap-2 font-mono text-[11px] text-muted-foreground">
<input type="checkbox" checked={d.allow_member_self_entry}
onChange={e => setD({ ...d, allow_member_self_entry: e.target.checked })} />
Allow member self-entry (elevated risk; off by default)
</label>
<Button type="submit" size="sm" disabled={busy}>Create connection</Button>
</form>
)}
{conns.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon"><DoorClosed /></EmptyMedia>
<EmptyTitle>No door connections</EmptyTitle>
<EmptyDescription>Add a controller connection to manage physical access.</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="divide-y rounded-lg border border-border">
{conns.map(c => (
<div key={c.id} className="p-4 space-y-3">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-sans text-sm font-semibold text-foreground">{c.name}</span>
<Badge variant="outline">{c.adapter === 'native_heatsync' ? 'HeatSync' : 'Generic'}</Badge>
{!c.is_enabled && <Badge variant="outline">Disabled</Badge>}
{c.allow_member_self_entry && <span className="font-mono text-[10px] text-amber-600">self-entry on</span>}
{c.inbound_enabled && <span className="font-mono text-[10px] text-primary">inbound on</span>}
</div>
<p className="font-mono text-[10px] text-muted-foreground mt-1">
{c.base_url} · pinned {c.pinned_host} · auth {c.secret_ref ? 'secret' : 'none'}
</p>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" variant="outline" disabled={busy} onClick={() => onTest(c)}>Test</Button>
<Button size="sm" variant="outline" onClick={() => onToggleEnabled(c)}>{c.is_enabled ? 'Disable' : 'Enable'}</Button>
<Button size="sm" variant="outline" onClick={() => onToggleSelfEntry(c)}>
{c.allow_member_self_entry ? 'Self-entry off' : 'Self-entry on'}
</Button>
<Button size="sm" variant="outline" onClick={() => setInboundFor(v => (v === c.id ? null : c.id))}>
{inboundFor === c.id ? 'Hide inbound' : 'Inbound'}
</Button>
<Button size="sm" variant="outline" onClick={() => onDelete(c)}>Delete</Button>
</div>
</div>
{canOperate && c.is_enabled && (
<div className="flex items-center gap-1.5 flex-wrap border-t border-border pt-3">
<span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mr-1">Operate</span>
<Button size="sm" disabled={busy} onClick={() => onControl(c, 'open')}>Open</Button>
<Button size="sm" variant="outline" disabled={busy} onClick={() => onControl(c, 'unlock')}>Unlock</Button>
<Button size="sm" variant="outline" disabled={busy} onClick={() => onControl(c, 'lock')}>Lock</Button>
<Button size="sm" variant="outline" onClick={() => loadCards(c)}>
{cardsFor === c.id ? 'Hide cards' : 'Cards'}
</Button>
</div>
)}
{inboundFor === c.id && (
<div className="rounded border border-border bg-background p-3 space-y-3">
<div className="text-xs text-muted-foreground space-y-1">
<p className="font-sans">
Inbound ingest pulls real entry/denied events into the access log and
matches each presented card to a member. Two transports, one switch:
the once-a-minute poll reads a HeatSync controller&rsquo;s log, and the
webhook accepts events POSTed by any controller or relay. Turn it on per
connection after choosing a webhook secret.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<select
className={input}
value={c.inbound_secret_ref ?? ''}
onChange={e => onSetInboundSecret(c, e.target.value)}
>
<option value="">No webhook secret</option>
{secrets.map(s => <option key={s.id} value={s.id}>Secret: {s.title}</option>)}
</select>
<Button size="sm" variant="outline" onClick={() => onToggleInbound(c)}>
{c.inbound_enabled ? 'Turn inbound off' : 'Turn inbound on'}
</Button>
</div>
<div className="rounded border border-border bg-card p-2 space-y-2">
<p className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Webhook URL</p>
<div className="flex items-center gap-2">
<code className="font-mono text-[10px] text-foreground break-all min-w-0">{webhookUrl(c.id)}</code>
<Button size="sm" variant="outline" className="shrink-0" onClick={() => copyWebhookUrl(c.id)}>Copy</Button>
</div>
<p className="font-mono text-[10px] text-muted-foreground">
POST with header <code>Authorization: Bearer &lt;your webhook secret&gt;</code> and a JSON
body <code>{'{ "events": [{ "id": "<stable-id>", "card_uid": "<hex>", "result": "granted" }] }'}</code>.
The <code>id</code> dedupes retries; <code>card_uid</code> or <code>card_number</code> identifies the card.
</p>
</div>
</div>
)}
{canOperate && cardsFor === c.id && (
<div className="rounded border border-border bg-background p-3">
{cardsBusy && cards.length === 0 ? (
<p className="font-mono text-[10px] text-muted-foreground">Loading cards…</p>
) : cards.length === 0 ? (
<p className="font-mono text-[10px] text-muted-foreground">No active cards in this space.</p>
) : (
<ul className="divide-y">
{cards.map(card => (
<li key={card.cardId} className="py-2 flex items-center justify-between gap-3">
<span className="font-mono text-[11px] text-muted-foreground min-w-0 truncate">
{card.memberName} · •••{card.last4}
{card.label ? ` · ${card.label}` : ''}
{card.slot !== null && <span className="text-primary"> · slot {card.slot}</span>}
</span>
<span className="flex items-center gap-1.5 shrink-0">
{card.slot === null ? (
<Button size="sm" disabled={cardsBusy} onClick={() => onGrant(c, card)}>Grant</Button>
) : (
<Button size="sm" variant="outline" disabled={cardsBusy} onClick={() => onRevoke(c, card)}>Revoke</Button>
)}
</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
))}
</div>
)}
<section>
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-2 mt-6">Access log</h2>
{rows.length === 0 ? (
<p className="font-mono text-[10px] text-muted-foreground">No door activity recorded yet.</p>
) : (
<ul className="divide-y rounded border border-border">
{rows.map(r => (
<li key={r.id} className="px-3 py-2 flex items-center justify-between gap-3">
<span className="font-mono text-[11px] text-muted-foreground">
<span className={r.success ? 'text-primary' : 'text-red-500'}>{r.success ? 'ok' : 'fail'}</span>
{' · '}{r.action}{r.detail ? ` · ${r.detail}` : ''}
</span>
<span className="font-mono text-[10px] text-muted-foreground/70">{new Date(r.occurred_at).toLocaleString()}</span>
</li>
))}
</ul>
)}
</section>
</div>
</>
)
}