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.
494 lines
24 KiB
TypeScript
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’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’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 <your webhook secret></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>
|
|
</>
|
|
)
|
|
}
|