diff --git a/app/(app)/door/manage/door-manage-client.tsx b/app/(app)/door/manage/door-manage-client.tsx index e061447..5751290 100644 --- a/app/(app)/door/manage/door-manage-client.tsx +++ b/app/(app)/door/manage/door-manage-client.tsx @@ -13,6 +13,10 @@ import { updateDoorConnection, deleteDoorConnection, testDoorConnection, + doorControl, + grantCard, + revokeCard, + listDoorCards, } from '@/lib/actions' import { KNOWN_DOOR_CONTROLLERS, controllerForAdapter } from '@/lib/door-logic' @@ -30,6 +34,7 @@ type Conn = { 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' @@ -45,18 +50,81 @@ 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 = {} @@ -256,26 +324,69 @@ export function DoorManageClient({ ) : (
{conns.map(c => ( -
-
-
- {c.name} - {c.adapter === 'native_heatsync' ? 'HeatSync' : 'Generic'} - {!c.is_enabled && Disabled} - {c.allow_member_self_entry && self-entry on} +
+
+
+
+ {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'} +

+
+
+ + + +
-

- {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 ? ( + + ) : ( + + )} + +
  • + ))} +
+ )} +
+ )}
))}
diff --git a/app/(app)/door/manage/page.tsx b/app/(app)/door/manage/page.tsx index 5b70034..c9501c1 100644 --- a/app/(app)/door/manage/page.tsx +++ b/app/(app)/door/manage/page.tsx @@ -5,7 +5,13 @@ import { DoorManageClient } from './door-manage-client' export const dynamic = 'force-dynamic' 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([ listDoorConnections(), @@ -18,6 +24,7 @@ export default async function DoorManagePage() { initial={'data' in conns ? (conns.data as unknown[]) : []} secrets={'data' in secrets ? (secrets.data as { id: string; title: string }[]) : []} log={'data' in log ? (log.data as unknown[]) : []} + canOperate={!!canOperate} /> ) } diff --git a/lib/actions/door.ts b/lib/actions/door.ts index 146222b..fe7b044 100644 --- a/lib/actions/door.ts +++ b/lib/actions/door.ts @@ -24,6 +24,7 @@ import { encodeHeatSyncRevoke, applyTemplate, redactDoorSecrets, + last4, } from '@/lib/door-logic' import { pickLowestFreeSlot, slotCapacity } from '@/lib/door-slots-logic' import { callDoor } from '@/lib/door/executor' @@ -602,6 +603,49 @@ export async function revokeCard(input: unknown) { 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() + 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. // Generic controllers must have the matching verb template configured. export async function doorControl(input: unknown) {