From 2fdc27597e946edca04871b8d4938ab174d04f29 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 21:45:17 -0700 Subject: [PATCH] feat(door): member /doors page (self-entry + my cards + my activity) New listMyDoorActivity (service client after requireMember; rows where the caller is actor or target; detail already redacted at write). /doors composes the existing self-entry control, the masked own cards (getMyCards, last4 only), and the member's own recent door activity; empty-state when none. Sidebar 'Doors' link added under Learn. --- app/(app)/doors/page.tsx | 111 +++++++++++++++++++++++++++++++++++++ components/app-sidebar.tsx | 1 + lib/actions/door.ts | 22 ++++++++ 3 files changed, 134 insertions(+) create mode 100644 app/(app)/doors/page.tsx diff --git a/app/(app)/doors/page.tsx b/app/(app)/doors/page.tsx new file mode 100644 index 0000000..8c59131 --- /dev/null +++ b/app/(app)/doors/page.tsx @@ -0,0 +1,111 @@ +import { redirect } from 'next/navigation' +import { createClient } from '@/lib/supabase/server' +import { PageHeader, PageTitle } from '@/components/ui/page-title' +import { Badge } from '@/components/ui/badge' +import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' +import { DoorClosed } from 'lucide-react' +import { listSelfEntryDoors, getMyCards, listMyDoorActivity } from '@/lib/actions' +import { DoorSelfEntry } from '../dashboard/door-self-entry' + +export const dynamic = 'force-dynamic' + +type Card = { id: string; card_type: string; label: string | null; is_active: boolean; last4: string } +type Activity = { id: string; action: string; success: boolean; detail: string | null; occurred_at: string } + +const ACTION_LABEL: Record = { + self_entry: 'Self-entry', + grant: 'Card granted', + revoke: 'Card revoked', + open: 'Open', + unlock: 'Unlock', + lock: 'Lock', + test: 'Connection test', +} + +export default async function DoorsPage() { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const [doorsRes, cardsRes, activityRes] = await Promise.all([ + listSelfEntryDoors(), + getMyCards(), + listMyDoorActivity(), + ]) + const doors = ('data' in doorsRes ? doorsRes.data : []) as { id: string; name: string }[] + const cards = ('data' in cardsRes ? cardsRes.data : []) as Card[] + const activity = ('data' in activityRes ? activityRes.data : []) as Activity[] + + const nothing = doors.length === 0 && cards.length === 0 && activity.length === 0 + + return ( + <> + + Doors + + +
+ {nothing && ( + + + + No door access yet + + When a door manager adds you an access card or enables member self-entry on a + door, it will show up here. + + + + )} + + {doors.length > 0 && ( +
+

Open a door

+ +

+ Only use this when you are physically at the door and authorized to enter. Every + attempt is logged. +

+
+ )} + + {cards.length > 0 && ( +
+

My access cards

+
+ {cards.map(c => ( +
+ + {c.label || `${c.card_type.toUpperCase()} card`} + ••••{c.last4} + + {c.is_active ? 'Active' : 'Inactive'} +
+ ))} +
+

+ Only the last 4 characters are shown. The full card ID is a credential and is never displayed. +

+
+ )} + + {activity.length > 0 && ( +
+

My recent door activity

+
    + {activity.map(a => ( +
  • + + {a.success ? 'ok' : 'fail'} + {' · '}{ACTION_LABEL[a.action] ?? a.action} + + {new Date(a.occurred_at).toLocaleString()} +
  • + ))} +
+
+ )} +
+ + ) +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 0ce886b..118a7aa 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -176,6 +176,7 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa + diff --git a/lib/actions/door.ts b/lib/actions/door.ts index 10d00e8..8fd43a9 100644 --- a/lib/actions/door.ts +++ b/lib/actions/door.ts @@ -801,3 +801,25 @@ export async function selfEntry(input: unknown) { revalidatePath('/dashboard') return result.ok ? { data: { ok: true } } : { error: result.reason } } + +// The signed-in member's OWN door activity (rows where they were the actor or +// the target). door_access_log has no member-facing RLS SELECT, so this uses +// the service client after requireMember and filters to this member only. The +// detail column is already secret-redacted at write time. +export async function listMyDoorActivity() { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const admin = createAdminClient() + const { data, error } = await admin + .from('door_access_log') + .select('id, action, success, detail, occurred_at') + .eq('space_id', member.space_id) + .or(`actor_member_id.eq.${member.id},target_member_id.eq.${member.id}`) + .order('occurred_at', { ascending: false }) + .limit(50) + if (error) return { error: error.message } + return { data: data ?? [] } +}