'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 | null allow_member_self_entry: boolean is_enabled: boolean } 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(initial as Conn[]) const [showNew, setShowNew] = useState(false) const [d, setD] = useState(emptyDraft()) const [busy, setBusy] = useState(false) const [cardsFor, setCardsFor] = useState(null) const [cards, setCards] = useState([]) const [cardsBusy, setCardsBusy] = useState(false) 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 = {} 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, }, ...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 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 ( <> Door access

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.

{showNew && (
setD({ ...d, name: e.target.value })} />
setD({ ...d, base_url: e.target.value })} /> setD({ ...d, pinned_host: e.target.value })} />
{(() => { const k = controllerForAdapter(d.adapter) if (!k) return null return (

{k.note}

{k.repos.length > 0 && (

Source:{' '} {k.repos.map((r, i) => ( {i > 0 && ' · '} {r.label} ))}

)}
) })()} {d.adapter === 'generic_http' && (

Per-verb request templates. The template is appended to the base URL and sent as an HTTP GET to the pinned host (no redirects followed). Placeholders are URL-encoded and substituted:

  • {'{slot}'} — the member’s integer card slot
  • {'{tag}'} — the card UID / tag value
  • {'{perm}'} — permission level (default 1)
  • {'{door}'} — door identifier (for open/lock verbs)
  • {'{pw}'} — the shared password from your Secrets vault (server-side only; never shown or logged)

Leave a verb blank if your controller does not support it. Example (HeatSync-style): ?m{'{slot}'}&p001&t{'{tag}'}&e={'{pw}'}

setD({ ...d, grant: e.target.value })} /> setD({ ...d, revoke: e.target.value })} /> setD({ ...d, open: e.target.value })} /> setD({ ...d, status: e.target.value })} />
)}
)} {conns.length === 0 ? ( No door connections Add a controller connection to manage physical access. ) : (
{conns.map(c => (
{c.name} {c.adapter === 'native_heatsync' ? 'HeatSync' : 'Generic'} {!c.is_enabled && Disabled} {c.allow_member_self_entry && self-entry on}

{c.base_url} · pinned {c.pinned_host} · auth {c.secret_ref ? 'secret' : 'none'}

{canOperate && c.is_enabled && (
Operate
)} {canOperate && cardsFor === c.id && (
{cardsBusy && cards.length === 0 ? (

Loading cards…

) : cards.length === 0 ? (

No active cards in this space.

) : (
    {cards.map(card => (
  • {card.memberName} · •••{card.last4} {card.label ? ` · ${card.label}` : ''} {card.slot !== null && · slot {card.slot}} {card.slot === null ? ( ) : ( )}
  • ))}
)}
)}
))}
)}

Access log

{rows.length === 0 ? (

No door activity recorded yet.

) : (
    {rows.map(r => (
  • {r.success ? 'ok' : 'fail'} {' · '}{r.action}{r.detail ? ` · ${r.detail}` : ''} {new Date(r.occurred_at).toLocaleString()}
  • ))}
)}
) }