feat(door): P3-D operate UI on /door/manage (open/lock/unlock + grant/revoke)
listDoorCards (door.operate, service client, masked last4 only) lists active cards with the slot each holds on a connection. /door/manage now passes canOperate (door.operate rpc) and, when set, shows per-connection Open/Unlock/Lock and an expandable card list with Grant/Revoke. Every live action is behind a confirm; grant shows the allocated slot.
This commit is contained in:
@@ -13,6 +13,10 @@ import {
|
|||||||
updateDoorConnection,
|
updateDoorConnection,
|
||||||
deleteDoorConnection,
|
deleteDoorConnection,
|
||||||
testDoorConnection,
|
testDoorConnection,
|
||||||
|
doorControl,
|
||||||
|
grantCard,
|
||||||
|
revokeCard,
|
||||||
|
listDoorCards,
|
||||||
} from '@/lib/actions'
|
} from '@/lib/actions'
|
||||||
import { KNOWN_DOOR_CONTROLLERS, controllerForAdapter } from '@/lib/door-logic'
|
import { KNOWN_DOOR_CONTROLLERS, controllerForAdapter } from '@/lib/door-logic'
|
||||||
|
|
||||||
@@ -30,6 +34,7 @@ type Conn = {
|
|||||||
is_enabled: boolean
|
is_enabled: boolean
|
||||||
}
|
}
|
||||||
type LogRow = { id: string; action: string; success: boolean; detail: string | null; occurred_at: string }
|
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'
|
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'
|
||||||
|
|
||||||
@@ -45,18 +50,81 @@ export function DoorManageClient({
|
|||||||
initial,
|
initial,
|
||||||
secrets,
|
secrets,
|
||||||
log,
|
log,
|
||||||
|
canOperate,
|
||||||
}: {
|
}: {
|
||||||
initial: unknown[]
|
initial: unknown[]
|
||||||
secrets: { id: string; title: string }[]
|
secrets: { id: string; title: string }[]
|
||||||
log: unknown[]
|
log: unknown[]
|
||||||
|
canOperate: boolean
|
||||||
}) {
|
}) {
|
||||||
const confirm = useConfirm()
|
const confirm = useConfirm()
|
||||||
const [conns, setConns] = useState<Conn[]>(initial as Conn[])
|
const [conns, setConns] = useState<Conn[]>(initial as Conn[])
|
||||||
const [showNew, setShowNew] = useState(false)
|
const [showNew, setShowNew] = useState(false)
|
||||||
const [d, setD] = useState(emptyDraft())
|
const [d, setD] = useState(emptyDraft())
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [cardsFor, setCardsFor] = useState<string | null>(null)
|
||||||
|
const [cards, setCards] = useState<DoorCard[]>([])
|
||||||
|
const [cardsBusy, setCardsBusy] = useState(false)
|
||||||
const rows = log as LogRow[]
|
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) {
|
function buildVerbs(dr: typeof d) {
|
||||||
if (dr.adapter === 'native_heatsync') return {}
|
if (dr.adapter === 'native_heatsync') return {}
|
||||||
const v: Record<string, string> = {}
|
const v: Record<string, string> = {}
|
||||||
@@ -256,26 +324,69 @@ export function DoorManageClient({
|
|||||||
) : (
|
) : (
|
||||||
<div className="divide-y rounded-lg border border-border">
|
<div className="divide-y rounded-lg border border-border">
|
||||||
{conns.map(c => (
|
{conns.map(c => (
|
||||||
<div key={c.id} className="p-4 flex items-start justify-between gap-4 flex-wrap">
|
<div key={c.id} className="p-4 space-y-3">
|
||||||
<div className="min-w-0">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="min-w-0">
|
||||||
<span className="font-sans text-sm font-semibold text-foreground">{c.name}</span>
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Badge variant="outline">{c.adapter === 'native_heatsync' ? 'HeatSync' : 'Generic'}</Badge>
|
<span className="font-sans text-sm font-semibold text-foreground">{c.name}</span>
|
||||||
{!c.is_enabled && <Badge variant="outline">Disabled</Badge>}
|
<Badge variant="outline">{c.adapter === 'native_heatsync' ? 'HeatSync' : 'Generic'}</Badge>
|
||||||
{c.allow_member_self_entry && <span className="font-mono text-[10px] text-amber-600">self-entry on</span>}
|
{!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>}
|
||||||
|
</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={() => onDelete(c)}>Delete</Button>
|
||||||
</div>
|
</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={() => 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import { DoorManageClient } from './door-manage-client'
|
|||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export default async function DoorManagePage() {
|
export default async function DoorManagePage() {
|
||||||
await requireDoorManagerPage()
|
const { supabase, member } = await requireDoorManagerPage()
|
||||||
|
|
||||||
|
const { data: canOperate } = await supabase.rpc('user_has_permission', {
|
||||||
|
uid: member.user_id as string,
|
||||||
|
sid: member.space_id,
|
||||||
|
perm: 'door.operate',
|
||||||
|
})
|
||||||
|
|
||||||
const [conns, secrets, log] = await Promise.all([
|
const [conns, secrets, log] = await Promise.all([
|
||||||
listDoorConnections(),
|
listDoorConnections(),
|
||||||
@@ -18,6 +24,7 @@ export default async function DoorManagePage() {
|
|||||||
initial={'data' in conns ? (conns.data as unknown[]) : []}
|
initial={'data' in conns ? (conns.data as unknown[]) : []}
|
||||||
secrets={'data' in secrets ? (secrets.data as { id: string; title: string }[]) : []}
|
secrets={'data' in secrets ? (secrets.data as { id: string; title: string }[]) : []}
|
||||||
log={'data' in log ? (log.data as unknown[]) : []}
|
log={'data' in log ? (log.data as unknown[]) : []}
|
||||||
|
canOperate={!!canOperate}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
encodeHeatSyncRevoke,
|
encodeHeatSyncRevoke,
|
||||||
applyTemplate,
|
applyTemplate,
|
||||||
redactDoorSecrets,
|
redactDoorSecrets,
|
||||||
|
last4,
|
||||||
} from '@/lib/door-logic'
|
} from '@/lib/door-logic'
|
||||||
import { pickLowestFreeSlot, slotCapacity } from '@/lib/door-slots-logic'
|
import { pickLowestFreeSlot, slotCapacity } from '@/lib/door-slots-logic'
|
||||||
import { callDoor } from '@/lib/door/executor'
|
import { callDoor } from '@/lib/door/executor'
|
||||||
@@ -602,6 +603,49 @@ export async function revokeCard(input: unknown) {
|
|||||||
return { data: { ok: true } }
|
return { data: { ok: true } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Active cards in the space plus the slot (if any) each holds on this
|
||||||
|
// connection, for the operator grant/revoke UI. door.operate; service client
|
||||||
|
// (operators have no RLS read on member_cards). The raw UID is never returned
|
||||||
|
// -- only the masked last4, like the member self-view.
|
||||||
|
export async function listDoorCards(input: unknown) {
|
||||||
|
const gate = await requireDoorOperator()
|
||||||
|
if (!gate.ok) return { error: gate.error }
|
||||||
|
const { member } = gate
|
||||||
|
|
||||||
|
const v = parseInput(doorConnectionIdSchema, input)
|
||||||
|
if (!v.ok) return { error: v.error }
|
||||||
|
|
||||||
|
const admin = createAdminClient()
|
||||||
|
const { data: cards, error } = await admin
|
||||||
|
.from('member_cards')
|
||||||
|
.select('id, card_uid, label, member_id, space_members!member_cards_member_id_fkey(display_name)')
|
||||||
|
.eq('space_id', member.space_id)
|
||||||
|
.eq('is_active', true)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
if (error) return { error: error.message }
|
||||||
|
|
||||||
|
const { data: slots } = await admin
|
||||||
|
.from('door_card_slots')
|
||||||
|
.select('card_id, slot')
|
||||||
|
.eq('connection_id', v.data.connectionId)
|
||||||
|
const slotByCard = new Map<string, number>()
|
||||||
|
for (const s of slots ?? []) slotByCard.set(s.card_id as string, s.slot as number)
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: (cards ?? []).map(c => {
|
||||||
|
const sm = c.space_members as { display_name: string | null } | { display_name: string | null }[] | null
|
||||||
|
const name = Array.isArray(sm) ? sm[0]?.display_name : sm?.display_name
|
||||||
|
return {
|
||||||
|
cardId: c.id as string,
|
||||||
|
memberName: name ?? 'Unknown member',
|
||||||
|
label: (c.label as string | null) ?? null,
|
||||||
|
last4: last4(c.card_uid as string),
|
||||||
|
slot: slotByCard.has(c.id as string) ? (slotByCard.get(c.id as string) as number) : null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Open / unlock / lock. No slot involvement. HeatSync 'open' = momentary o1.
|
// Open / unlock / lock. No slot involvement. HeatSync 'open' = momentary o1.
|
||||||
// Generic controllers must have the matching verb template configured.
|
// Generic controllers must have the matching verb template configured.
|
||||||
export async function doorControl(input: unknown) {
|
export async function doorControl(input: unknown) {
|
||||||
|
|||||||
Reference in New Issue
Block a user