From a8892a2dc8cfb3a90705db067a6b4c17618ad6db Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 20:13:22 -0700 Subject: [PATCH] feat(door): P1 member access cards + door permissions Migration 034 adds member_cards (RFID/NFC UID -> member). The card UID is treated as a credential: door.manage-only RLS with no member SELECT policy; the member's own "My access cards" view goes through a service -client action that returns only count + last4 (never the raw UID). New door.manage / door.operate permissions (group Access), seeded to board and backfilled, introduced now for the whole Door epic. Pure door-logic masking is unit-tested. A per-member "Cards" panel on /members is gated to door.manage (independent of admin). No controller calls and no anonymous path in this phase. Mirrored in schema.sql; types and schema docs updated. --- DB_SCHEMA_MAP.md | 4 +- __tests__/door-logic.test.ts | 22 +++ app/(app)/me/page.tsx | 35 ++++- app/(app)/members/members-client.tsx | 29 +++- app/(app)/members/page.tsx | 6 + components/door/member-cards-dialog.tsx | 185 ++++++++++++++++++++++++ docs/DATABASE_SCHEMA.md | 3 +- lib/actions/index.ts | 3 + lib/actions/member-cards.ts | 180 +++++++++++++++++++++++ lib/door-logic.ts | 14 ++ lib/permissions-catalog.ts | 3 + lib/validations.ts | 29 ++++ scripts/034_member_cards.sql | 102 +++++++++++++ scripts/schema.sql | 56 +++++++ types/database.ts | 6 + 15 files changed, 672 insertions(+), 5 deletions(-) create mode 100644 __tests__/door-logic.test.ts create mode 100644 components/door/member-cards-dialog.tsx create mode 100644 lib/actions/member-cards.ts create mode 100644 lib/door-logic.ts create mode 100644 scripts/034_member_cards.sql diff --git a/DB_SCHEMA_MAP.md b/DB_SCHEMA_MAP.md index c5d0eea..a06e88a 100644 --- a/DB_SCHEMA_MAP.md +++ b/DB_SCHEMA_MAP.md @@ -399,10 +399,11 @@ Default channels created by trigger on space INSERT: `general`, `announcements`, | 031 | `secrets_select` additively also honors `ops.secrets.read` (previously admin/board OR per-secret `ops_acl` only). Reveal/list gates let RLS decide; writes unchanged. Access-neutral unless the permission is granted | | 032 | `classes`, `class_sessions`, `class_signups` + `classes.manage` / `classes.instruct` permissions. Additive default-deny RLS; signup/cancel via validated service-client action (no INSERT/DELETE policy); one non-cancelled signup per member+session; optional cert-on-completion via the normal certifications path | | 033 | `equipment`, `equipment_reservations` + `equipment.manage` permission. Additive default-deny RLS; reserve/cancel via validated service-client action (no INSERT/DELETE policy) enforcing status + no-overlap + required-cert with manager override | +| 034 | `member_cards` + `door.manage` / `door.operate` permissions. Card UID is a credential: door.manage-only RLS, no member SELECT; masked (count+last4) self-view via server action. Door epic phase 1, no controller calls | --- -## Tables added by migrations 016-033 (quick map) +## Tables added by migrations 016-034 (quick map) | Table | Key columns | Purpose | |-------|-------------|---------| @@ -428,6 +429,7 @@ Default channels created by trigger on space INSERT: `general`, `announcements`, | `class_signups` | session_id, space_id, member_id, status (registered/waitlisted/cancelled), attended, signed_up_at | Member signup. Partial UNIQUE = one non-cancelled signup per member+session. SELECT = manage/instruct (all) or member (own); UPDATE = `classes.instruct`; NO INSERT/DELETE policy (signup/cancel via validated service-client action) | | `equipment` | space_id, name, description, location, status (available/maintenance/retired), required_certification_id -> certifications, asset_tag, is_active | Tool/equipment registry. SELECT = `equipment.manage` (all) or member (`is_active`); writes = `equipment.manage` | | `equipment_reservations` | equipment_id, space_id, member_id, starts_at, ends_at, status (reserved/cancelled/completed), notes | Time-window reservation. SELECT = `equipment.manage` (all) or member (own); UPDATE = `equipment.manage`; NO INSERT/DELETE policy (reserve/cancel via validated service-client action; no-overlap + required-cert enforced there) | +| `member_cards` | space_id, member_id, card_uid (credential), card_type rfid/nfc, label, is_active | RFID/NFC card association. SELECT/writes = `door.manage` only; member self-view is masked (server action, never raw UID) | Column additions: `space_members.tier_id`, `onboarding_completed_at`, `onboarding_progress`; `secrets.encrypted_value`, `encryption_version`; `knowledge_base.render_markdown`, `is_meeting_minutes`, `meeting_date`. New enum `comment_entity_type`. diff --git a/__tests__/door-logic.test.ts b/__tests__/door-logic.test.ts new file mode 100644 index 0000000..1ab26d6 --- /dev/null +++ b/__tests__/door-logic.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { last4, maskCardUid } from '@/lib/door-logic' + +describe('last4', () => { + it('returns the last 4 chars, or the whole short value', () => { + expect(last4('0123456789')).toBe('6789') + expect(last4('ABCD')).toBe('ABCD') + expect(last4('AB')).toBe('AB') + }) +}) + +describe('maskCardUid', () => { + it('reveals only the last 4 characters', () => { + expect(maskCardUid('0123456789')).toBe('••••••6789') + expect(maskCardUid('AABBCCDD')).toBe('••••CCDD') + }) + it('reveals nothing for short UIDs', () => { + expect(maskCardUid('ABCD')).toBe('••••') + expect(maskCardUid('AB')).toBe('••') + expect(maskCardUid('')).toBe('••••') + }) +}) diff --git a/app/(app)/me/page.tsx b/app/(app)/me/page.tsx index 40ced86..063f381 100644 --- a/app/(app)/me/page.tsx +++ b/app/(app)/me/page.tsx @@ -8,7 +8,7 @@ import { type CertStatus, } from '@/lib/certifications-logic' import { PERMISSIONS, PERMISSION_CODES } from '@/lib/permissions-catalog' -import { getMyClassSignups, getMyReservations } from '@/lib/actions' +import { getMyClassSignups, getMyReservations, getMyCards } from '@/lib/actions' import { SIGNUP_STATUS_LABEL, SESSION_STATUS_LABEL } from '@/lib/classes-logic' import { RESERVATION_STATUS_LABEL } from '@/lib/equipment-logic' @@ -103,6 +103,10 @@ export default async function MePage() { } const reservations: Reservation[] = 'data' in reservationRes ? (reservationRes.data as Reservation[]) : [] const equipOf = (r: Reservation) => (Array.isArray(r.equipment) ? r.equipment[0] : r.equipment) + + const cardsRes = await getMyCards() + type MyCard = { id: string; card_type: string; label: string | null; is_active: boolean; last4: string } + const myCards: MyCard[] = 'data' in cardsRes ? (cardsRes.data as MyCard[]) : [] const titleOf = (cs: ClassSignup) => { const sess = sessionOf(cs) const c = sess ? (Array.isArray(sess.classes) ? sess.classes[0] : sess.classes) : null @@ -261,6 +265,35 @@ export default async function MePage() { )} + +
+

+ My access cards +

+ {myCards.length === 0 ? ( +

+ No access cards are registered to you. A door manager can add one. +

+ ) : ( + + )} +

+ Only the last 4 digits are shown. The full card number is never displayed here. +

+
) diff --git a/app/(app)/members/members-client.tsx b/app/(app)/members/members-client.tsx index b7a2683..f020cb4 100644 --- a/app/(app)/members/members-client.tsx +++ b/app/(app)/members/members-client.tsx @@ -11,6 +11,7 @@ import type { Tables } from '@/types/database' import { PageTitle } from '@/components/ui/page-title' import { useConfirm } from '@/components/ui/confirm' import { MemberCertificationsDialog } from '@/components/certifications/member-certifications-dialog' +import { MemberCardsDialog } from '@/components/door/member-cards-dialog' type Member = Tables<'space_members'> type AreaLeadRole = { id: string; area_name: string; lead_id: string | null } @@ -27,6 +28,9 @@ interface Props { // capability), independent of admin/board. Adds a per-member // certifications panel reachable by non-admin instructors. canGrantCerts?: boolean + // True when the viewer holds door.manage. Adds a per-member access-cards + // panel (card UID is a credential; managers only). + canManageCards?: boolean } const TIER_COLORS: Record = { @@ -38,9 +42,10 @@ const TIER_COLORS: Record = { const isAdmin = (role: string) => role === 'admin' || role === 'board' -export function MembersClient({ members: initialMembers, currentRole, areaLeadRoles = [], inviteSlot, canGrantCerts = false }: Props) { +export function MembersClient({ members: initialMembers, currentRole, areaLeadRoles = [], inviteSlot, canGrantCerts = false, canManageCards = false }: Props) { const router = useRouter() const [certMember, setCertMember] = useState(null) + const [cardMember, setCardMember] = useState(null) const confirm = useConfirm() const [members, setMembers] = useState(initialMembers) @@ -280,6 +285,9 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo {canGrantCerts && ( CERTS )} + {canManageCards && ( + CARDS + )} {isAdmin(currentRole) && ( ACTIONS )} @@ -344,6 +352,16 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo )} + {canManageCards && ( + + + + )} {isAdmin(currentRole) && (
@@ -389,7 +407,7 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo ) }) : ( - + @@ -450,6 +468,13 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo onClose={() => setCertMember(null)} /> )} + + {canManageCards && ( + setCardMember(null)} + /> + )}
) } diff --git a/app/(app)/members/page.tsx b/app/(app)/members/page.tsx index e058ba3..e803ff6 100644 --- a/app/(app)/members/page.tsx +++ b/app/(app)/members/page.tsx @@ -95,12 +95,18 @@ export default async function MembersPage() { sid: self.space_id, perm: 'certifications.grant', }) + const { data: canManageCards } = await supabase.rpc('user_has_permission', { + uid: user.id, + sid: self.space_id, + perm: 'door.manage', + }) return ( } inviteSlot={ canInvite ? ( diff --git a/components/door/member-cards-dialog.tsx b/components/door/member-cards-dialog.tsx new file mode 100644 index 0000000..89ef101 --- /dev/null +++ b/components/door/member-cards-dialog.tsx @@ -0,0 +1,185 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { toast } from 'sonner' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useConfirm } from '@/components/ui/confirm' +import { + listMemberCards, + addMemberCard, + updateMemberCard, + deleteMemberCard, +} from '@/lib/actions' + +type Card = { + id: string + card_uid: string + card_type: string + label: string | null + is_active: boolean + created_at: string +} + +export function MemberCardsDialog({ + member, + onClose, +}: { + member: { id: string; display_name: string | null } | null + onClose: () => void +}) { + const confirm = useConfirm() + const open = !!member + const [loading, setLoading] = useState(false) + const [cards, setCards] = useState([]) + const [uid, setUid] = useState('') + const [type, setType] = useState<'rfid' | 'nfc'>('rfid') + const [label, setLabel] = useState('') + const [busy, setBusy] = useState(false) + + const load = useCallback(async (memberId: string) => { + setLoading(true) + const res = await listMemberCards({ memberId }) + setLoading(false) + if ('error' in res && res.error) { + toast.error(res.error) + return + } + setCards(((res as { data: Card[] }).data) ?? []) + }, []) + + useEffect(() => { + if (member) { + setUid('') + setType('rfid') + setLabel('') + load(member.id) + } + }, [member, load]) + + async function onAdd(e: React.FormEvent) { + e.preventDefault() + if (!member) return + if (!uid.trim()) { + toast.error('Card UID is required') + return + } + setBusy(true) + const res = await addMemberCard({ + memberId: member.id, + card_uid: uid.trim(), + card_type: type, + label: label.trim() || null, + }) + setBusy(false) + if ('error' in res && res.error) { + toast.error(res.error) + return + } + toast.success('Card added') + setUid('') + setLabel('') + load(member.id) + } + + async function onToggle(c: Card) { + const res = await updateMemberCard({ cardId: c.id, is_active: !c.is_active }) + if ('error' in res && res.error) { + toast.error(res.error) + return + } + setCards(prev => prev.map(x => (x.id === c.id ? { ...x, is_active: !x.is_active } : x))) + } + + async function onDelete(c: Card) { + const ok = await confirm({ + title: 'Delete card', + description: 'This removes the card association. If a controller is connected, revoke it there too.', + confirmText: 'Delete', + destructive: true, + }) + if (!ok || !member) return + const res = await deleteMemberCard({ cardId: c.id }) + if ('error' in res && res.error) { + toast.error(res.error) + return + } + setCards(prev => prev.filter(x => x.id !== c.id)) + toast.success('Deleted') + } + + return ( + { if (!o) onClose() }}> + + + Access cards — {member?.display_name ?? 'Member'} + + +
+

Add a card

+ setUid(e.target.value)} + placeholder="Card UID (the number from the card/reader)" + className="w-full bg-background border border-border text-foreground font-mono text-sm rounded px-2 py-2 focus:outline-none focus:border-primary" + /> +
+ + setLabel(e.target.value)} + placeholder="Label (optional)" + className="bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-2 focus:outline-none focus:border-primary" + /> +
+ +
+ +
+ {loading ? ( +

Loading…

+ ) : cards.length === 0 ? ( +

No cards registered.

+ ) : ( +
    + {cards.map(c => ( +
  • +
    + {c.card_uid} + {c.card_type} + {c.label && {c.label}} + {!c.is_active && inactive} +
    +
    + + +
    +
  • + ))} +
+ )} +
+
+
+ ) +} diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 9946866..b2e9f6e 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -686,7 +686,7 @@ CREATE INDEX idx_activity_space ON activity_log(space_id, created_at); --- -## Migrations 014-033 (additions since the 13-table baseline) +## Migrations 014-034 (additions since the 13-table baseline) `scripts/schema.sql` is the canonical idempotent schema; each numbered migration is mirrored as a section in it. Tables/columns added: @@ -713,6 +713,7 @@ migration is mirrored as a section in it. Tables/columns added: | 031 | `secrets_select` additively also honors the `ops.secrets.read` role permission (was admin/board OR per-secret `ops_acl` only; the permission was never consulted). Access-neutral unless an admin has granted the permission. Reveal/list server-side gates updated to let RLS be the boundary; write paths unchanged | | 032 | `classes`, `class_sessions`, `class_signups` (class offerings, scheduled sessions, member signups; optional `payment_link` and `grants_certification_id`; partial unique = one non-cancelled signup per member+session). RLS additive + default-deny: classes SELECT = `classes.manage` (all) or member (`is_active`), class_sessions SELECT = any space member, class_signups SELECT = manage/instruct (all) or member (own), UPDATE = `classes.instruct`, NO INSERT/DELETE policy (signup/cancel via a validated service-client action enforcing capacity/waitlist/dedupe). New `classes.manage` + `classes.instruct` permissions seeded to board + backfilled. Cert-on-completion uses the normal certifications path (still needs `certifications.grant`). No anonymous path | | 033 | `equipment` (registry; status available/maintenance/retired; optional `required_certification_id`; `is_active` archive) + `equipment_reservations` (member time-window reservation; denormalized space_id; no DB overlap constraint). RLS additive + default-deny: equipment SELECT = `equipment.manage` (all) or member (`is_active`), reservations SELECT = `equipment.manage` (all) or member (own), UPDATE = `equipment.manage`, NO INSERT/DELETE policy (reserve/cancel via validated service-client action enforcing status + no-overlap + required-cert, manager override). New `equipment.manage` permission seeded to board + backfilled. No anonymous path | +| 034 | `member_cards` (RFID/NFC card UID -> member; the UID is a credential -- `door.manage`-only RLS, no member SELECT policy; masked self-view via a server action). New `door.manage` + `door.operate` permissions (group Access; the whole Door epic uses them) seeded to board + backfilled. No controller calls in this phase, no anonymous path | New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`. diff --git a/lib/actions/index.ts b/lib/actions/index.ts index 5e9fe73..f0596f2 100644 --- a/lib/actions/index.ts +++ b/lib/actions/index.ts @@ -56,3 +56,6 @@ export * from './classes' // Equipment registry + reservations export * from './equipment' + +// Member access cards (Door epic) +export * from './member-cards' diff --git a/lib/actions/member-cards.ts b/lib/actions/member-cards.ts new file mode 100644 index 0000000..9f6f9c1 --- /dev/null +++ b/lib/actions/member-cards.ts @@ -0,0 +1,180 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { createClient } from '@/lib/supabase/server' +import { createAdminClient } from '@/lib/supabase/admin' +import { + requireMember, + logActivity, + parseInput, + type Member, + type ServerSupabase, +} from '@/lib/auth-helpers' +import { + addMemberCardSchema, + updateMemberCardSchema, + cardIdSchema, + listMemberCardsSchema, +} from '@/lib/validations' +import { last4 } from '@/lib/door-logic' + +type Gate = + | { ok: true; supabase: ServerSupabase; member: Member } + | { ok: false; error: string } + +async function requireDoorManager(): Promise { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { ok: false, error: auth.error } + const { member } = auth + const { data: allowed, error } = await supabase.rpc('user_has_permission', { + uid: member.user_id as string, + sid: member.space_id, + perm: 'door.manage', + }) + if (error) return { ok: false, error: error.message } + if (!allowed) return { ok: false, error: 'You do not have permission to manage access cards' } + return { ok: true, supabase, member } +} + +function isUniqueViolation(message: string): boolean { + return /duplicate key value|already exists|unique constraint/i.test(message) +} + +export async function addMemberCard(input: unknown) { + const gate = await requireDoorManager() + if (!gate.ok) return { error: gate.error } + const { supabase, member } = gate + + const v = parseInput(addMemberCardSchema, input) + if (!v.ok) return { error: v.error } + const c = v.data + + const { data: target } = await supabase + .from('space_members') + .select('id') + .eq('id', c.memberId) + .eq('space_id', member.space_id) + .maybeSingle() + if (!target) return { error: 'Member not found in this space' } + + const { data, error } = await supabase + .from('member_cards') + .insert({ + space_id: member.space_id, + member_id: c.memberId, + card_uid: c.card_uid, + card_type: c.card_type, + label: c.label ?? null, + created_by: member.id, + }) + .select('id') + .single() + if (error) { + if (isUniqueViolation(error.message)) { + return { error: 'That card UID is already registered in this space.' } + } + return { error: error.message } + } + + // Audit without ever logging the raw UID. + await logActivity(supabase, member, 'added', 'member_card', data.id as string, `card •••${last4(c.card_uid)}`) + revalidatePath('/members') + revalidatePath('/me') + return { data: { id: data.id as string } } +} + +export async function updateMemberCard(input: unknown) { + const gate = await requireDoorManager() + if (!gate.ok) return { error: gate.error } + const { supabase, member } = gate + + const v = parseInput(updateMemberCardSchema, input) + if (!v.ok) return { error: v.error } + const u = v.data + + const patch: Record = {} + if (u.label !== undefined) patch.label = u.label ?? null + if (u.is_active !== undefined) patch.is_active = u.is_active + if (Object.keys(patch).length === 0) return { data: { id: u.cardId } } + + const { error } = await supabase + .from('member_cards') + .update(patch) + .eq('id', u.cardId) + .eq('space_id', member.space_id) + if (error) return { error: error.message } + + await logActivity(supabase, member, 'updated', 'member_card', u.cardId) + revalidatePath('/members') + revalidatePath('/me') + return { data: { id: u.cardId } } +} + +export async function deleteMemberCard(input: unknown) { + const gate = await requireDoorManager() + if (!gate.ok) return { error: gate.error } + const { supabase, member } = gate + + const v = parseInput(cardIdSchema, input) + if (!v.ok) return { error: v.error } + + const { error } = await supabase + .from('member_cards') + .delete() + .eq('id', v.data.cardId) + .eq('space_id', member.space_id) + if (error) return { error: error.message } + + await logActivity(supabase, member, 'deleted', 'member_card', v.data.cardId) + revalidatePath('/members') + revalidatePath('/me') + return { data: { id: v.data.cardId } } +} + +// Manager view: full UID (door.manage holders only, enforced by RLS too). +export async function listMemberCards(input: unknown) { + const gate = await requireDoorManager() + if (!gate.ok) return { error: gate.error } + const { supabase, member } = gate + + const v = parseInput(listMemberCardsSchema, input) + if (!v.ok) return { error: v.error } + + const { data, error } = await supabase + .from('member_cards') + .select('id, card_uid, card_type, label, is_active, created_at') + .eq('space_id', member.space_id) + .eq('member_id', v.data.memberId) + .order('created_at', { ascending: false }) + if (error) return { error: error.message } + return { data: data ?? [] } +} + +// The signed-in member's own cards, MASKED. The raw UID is a credential and +// is never returned here; member_cards has no member-facing RLS SELECT +// policy, so this uses the service client and strips card_uid to last4. +export async function getMyCards() { + 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('member_cards') + .select('id, card_uid, card_type, label, is_active') + .eq('space_id', member.space_id) + .eq('member_id', member.id) + .order('created_at', { ascending: false }) + if (error) return { error: error.message } + return { + data: (data ?? []).map(c => ({ + id: c.id as string, + card_type: c.card_type as string, + label: (c.label as string | null) ?? null, + is_active: c.is_active as boolean, + last4: last4(c.card_uid as string), + })), + } +} diff --git a/lib/door-logic.ts b/lib/door-logic.ts new file mode 100644 index 0000000..0fb5e91 --- /dev/null +++ b/lib/door-logic.ts @@ -0,0 +1,14 @@ +// Pure, dependency-free logic for the Door epic. No Supabase/React/Next. +// Unit-tested directly. The card UID is a credential, so the canonical +// masking lives here and is reused everywhere a non-manager could see it. + +export function last4(uid: string): string { + return uid.length <= 4 ? uid : uid.slice(-4) +} + +// "••••AB12" — never reveals more than the last 4 characters. For a UID of 4 +// or fewer characters nothing is revealed. +export function maskCardUid(uid: string): string { + if (uid.length <= 4) return '•'.repeat(uid.length || 4) + return '•'.repeat(uid.length - 4) + uid.slice(-4) +} diff --git a/lib/permissions-catalog.ts b/lib/permissions-catalog.ts index 0327ca4..0a529b4 100644 --- a/lib/permissions-catalog.ts +++ b/lib/permissions-catalog.ts @@ -27,6 +27,8 @@ export const PERMISSIONS = [ { code: 'classes.manage', group: 'Classes', label: 'Manage classes and schedule sessions' }, { code: 'classes.instruct', group: 'Classes', label: 'Run classes: attendance, completion, attendees' }, { code: 'equipment.manage', group: 'Equipment', label: 'Manage equipment and reservations' }, + { code: 'door.manage', group: 'Access', label: 'Configure door integrations, buttons, and member cards' }, + { code: 'door.operate', group: 'Access', label: 'Operate doors: open/lock, push/revoke cards' }, { code: 'customize.manage', group: 'Admin', label: 'Customize roles, tiers, areas, invites, onboarding' }, { code: 'settings.manage', group: 'Admin', label: 'Space settings, integrations, webhooks' }, ] as const @@ -49,6 +51,7 @@ export const DEFAULT_ROLE_PERMISSIONS: Record = { 'certifications.manage', 'certifications.grant', 'classes.manage', 'classes.instruct', 'equipment.manage', + 'door.manage', 'door.operate', 'customize.manage', 'settings.manage', ], treasurer: ['payments.manage', 'ops.kb.read', 'ops.process.read'], diff --git a/lib/validations.ts b/lib/validations.ts index 34853cf..3601961 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -874,6 +874,35 @@ export const listEquipmentReservationsSchema = z.object({ equipmentId: z.string().uuid('Invalid equipment ID'), }) +// ─── Member access cards (Door epic) ───────────────────────────────────────── + +const cardUid = z + .string() + .trim() + .min(1, 'Card UID is required') + .max(200, 'Card UID is too long') + +export const addMemberCardSchema = z.object({ + memberId: z.string().uuid('Invalid member ID'), + card_uid: cardUid, + card_type: z.enum(['rfid', 'nfc']).optional().default('rfid'), + label: z.string().max(120).optional().nullable(), +}) + +export const updateMemberCardSchema = z.object({ + cardId: z.string().uuid('Invalid card ID'), + label: z.string().max(120).optional().nullable(), + is_active: z.boolean().optional(), +}) + +export const cardIdSchema = z.object({ + cardId: z.string().uuid('Invalid card ID'), +}) + +export const listMemberCardsSchema = z.object({ + memberId: z.string().uuid('Invalid member ID'), +}) + // ─── Generic ID schemas ────────────────────────────────────────────────────── export const uuidSchema = z.string().uuid('Invalid ID format') diff --git a/scripts/034_member_cards.sql b/scripts/034_member_cards.sql new file mode 100644 index 0000000..496a158 --- /dev/null +++ b/scripts/034_member_cards.sql @@ -0,0 +1,102 @@ +-- ============================================================================= +-- 034: Member access cards + door permissions (Door epic, phase 1) +-- ============================================================================= +-- member_cards associates an RFID/NFC card UID with a member. The card UID is +-- a physical-access CREDENTIAL: it is treated like a secret. RLS exposes the +-- table only to door.manage holders; the owning member never reads the raw +-- UID through PostgREST -- a member's "my cards" view is served by a server +-- action that returns only a count and the last 4 characters. +-- +-- Two new permission codes (lib/permissions-catalog.ts, group "Access"), +-- introduced now because the whole Door epic uses them: +-- +-- door.manage configure door connections + the API-call buttons, and +-- manage member cards. +-- door.operate perform live actions (open/lock/unlock, push/revoke a +-- card, run a configured button). The Host/Champion +-- capability. +-- +-- Both are assignable to any role via the existing additive model (NOT new +-- built-in roles), seeded to board and backfilled for existing spaces. +-- +-- RLS posture (additive, default-deny; the guarded surface): +-- +-- * member_cards SELECT/INSERT/UPDATE/DELETE: door.manage only. There is +-- deliberately no member-facing SELECT policy -- the raw UID is a +-- credential. The masked self-view goes through a validated server +-- action using the service client. +-- +-- No anonymous path. No controller calls in this phase. +-- +-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS, DROP POLICY IF EXISTS then +-- CREATE, CREATE OR REPLACE FUNCTION, ON CONFLICT DO NOTHING backfill. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS public.member_cards ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE, + member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE, + card_uid text NOT NULL CHECK (char_length(card_uid) BETWEEN 1 AND 200), + card_type text NOT NULL DEFAULT 'rfid' CHECK (card_type IN ('rfid','nfc')), + label text, + is_active boolean NOT NULL DEFAULT true, + created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +-- A physical card UID maps to at most one record per space. +CREATE UNIQUE INDEX IF NOT EXISTS idx_member_cards_uid ON public.member_cards (space_id, card_uid); +CREATE INDEX IF NOT EXISTS idx_member_cards_space ON public.member_cards (space_id); +CREATE INDEX IF NOT EXISTS idx_member_cards_member ON public.member_cards (member_id); + +ALTER TABLE public.member_cards ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS member_cards_select ON public.member_cards; +DROP POLICY IF EXISTS member_cards_insert ON public.member_cards; +DROP POLICY IF EXISTS member_cards_update ON public.member_cards; +DROP POLICY IF EXISTS member_cards_delete ON public.member_cards; +CREATE POLICY member_cards_select ON public.member_cards FOR SELECT + USING (public.user_has_permission(auth.uid(), space_id, 'door.manage')); +CREATE POLICY member_cards_insert ON public.member_cards FOR INSERT + WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'door.manage')); +CREATE POLICY member_cards_update ON public.member_cards FOR UPDATE + USING (public.user_has_permission(auth.uid(), space_id, 'door.manage')); +CREATE POLICY member_cards_delete ON public.member_cards FOR DELETE + USING (public.user_has_permission(auth.uid(), space_id, 'door.manage')); + +DROP TRIGGER IF EXISTS trg_member_cards_touch ON public.member_cards; +CREATE TRIGGER trg_member_cards_touch + BEFORE UPDATE ON public.member_cards + FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); + +CREATE OR REPLACE FUNCTION public.seed_default_role_permissions() + RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public +AS $$ +BEGIN + INSERT INTO public.space_role_permissions (space_id, subject, permission) + SELECT NEW.id, d.subject, d.permission + FROM (VALUES + ('board','ops.kb.read'),('board','ops.kb.write'),('board','ops.process.read'), + ('board','ops.process.write'),('board','ops.secrets.read'),('board','ops.secrets.write'), + ('board','ops.arealeads.manage'),('board','members.manage'),('board','payments.manage'), + ('board','governance.manage'),('board','forum.moderate'),('board','forms.manage'), + ('board','certifications.manage'),('board','certifications.grant'), + ('board','classes.manage'),('board','classes.instruct'), + ('board','equipment.manage'), + ('board','door.manage'),('board','door.operate'), + ('board','customize.manage'),('board','settings.manage'), + ('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'), + ('member','ops.kb.read'),('member','ops.process.read'), + ('associate','ops.kb.read') + ) AS d(subject, permission) + ON CONFLICT (space_id, subject, permission) DO NOTHING; + RETURN NEW; +END; +$$; + +INSERT INTO public.space_role_permissions (space_id, subject, permission) +SELECT id, 'board', 'door.manage' FROM public.spaces +ON CONFLICT (space_id, subject, permission) DO NOTHING; +INSERT INTO public.space_role_permissions (space_id, subject, permission) +SELECT id, 'board', 'door.operate' FROM public.spaces +ON CONFLICT (space_id, subject, permission) DO NOTHING; diff --git a/scripts/schema.sql b/scripts/schema.sql index 455f544..19b2aca 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -1830,6 +1830,7 @@ BEGIN ('board','certifications.manage'),('board','certifications.grant'), ('board','classes.manage'),('board','classes.instruct'), ('board','equipment.manage'), + ('board','door.manage'),('board','door.operate'), ('board','customize.manage'),('board','settings.manage'), ('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'), ('member','ops.kb.read'),('member','ops.process.read'), @@ -2318,3 +2319,58 @@ CREATE TRIGGER trg_equipment_touch INSERT INTO public.space_role_permissions (space_id, subject, permission) SELECT id, 'board', 'equipment.manage' FROM public.spaces ON CONFLICT (space_id, subject, permission) DO NOTHING; + + +-- ============================================================================= +-- 21. Member access cards + door permissions (Door epic, phase 1) +-- (equivalent to scripts/034_member_cards.sql). +-- +-- member_cards (card UID is a credential; door.manage-only RLS, no +-- member SELECT policy -- the masked self-view is a server action). +-- Permissions door.manage / door.operate (group Access) introduced for +-- the whole Door epic, seeded to board (above) + backfilled below. +-- No controller calls in this phase. No anonymous path. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS public.member_cards ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE, + member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE, + card_uid text NOT NULL CHECK (char_length(card_uid) BETWEEN 1 AND 200), + card_type text NOT NULL DEFAULT 'rfid' CHECK (card_type IN ('rfid','nfc')), + label text, + is_active boolean NOT NULL DEFAULT true, + created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_member_cards_uid ON public.member_cards (space_id, card_uid); +CREATE INDEX IF NOT EXISTS idx_member_cards_space ON public.member_cards (space_id); +CREATE INDEX IF NOT EXISTS idx_member_cards_member ON public.member_cards (member_id); + +ALTER TABLE public.member_cards ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS member_cards_select ON public.member_cards; +DROP POLICY IF EXISTS member_cards_insert ON public.member_cards; +DROP POLICY IF EXISTS member_cards_update ON public.member_cards; +DROP POLICY IF EXISTS member_cards_delete ON public.member_cards; +CREATE POLICY member_cards_select ON public.member_cards FOR SELECT + USING (public.user_has_permission(auth.uid(), space_id, 'door.manage')); +CREATE POLICY member_cards_insert ON public.member_cards FOR INSERT + WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'door.manage')); +CREATE POLICY member_cards_update ON public.member_cards FOR UPDATE + USING (public.user_has_permission(auth.uid(), space_id, 'door.manage')); +CREATE POLICY member_cards_delete ON public.member_cards FOR DELETE + USING (public.user_has_permission(auth.uid(), space_id, 'door.manage')); + +DROP TRIGGER IF EXISTS trg_member_cards_touch ON public.member_cards; +CREATE TRIGGER trg_member_cards_touch + BEFORE UPDATE ON public.member_cards + FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); + +INSERT INTO public.space_role_permissions (space_id, subject, permission) +SELECT id, 'board', 'door.manage' FROM public.spaces +ON CONFLICT (space_id, subject, permission) DO NOTHING; +INSERT INTO public.space_role_permissions (space_id, subject, permission) +SELECT id, 'board', 'door.operate' FROM public.spaces +ON CONFLICT (space_id, subject, permission) DO NOTHING; diff --git a/types/database.ts b/types/database.ts index 49242a3..4f0e225 100644 --- a/types/database.ts +++ b/types/database.ts @@ -148,6 +148,12 @@ export type Database = { Update: { id?: string; equipment_id?: string; space_id?: string; member_id?: string; starts_at?: string; ends_at?: string; status?: string; notes?: string | null; created_by?: string | null; created_at?: string } Relationships: [] } + member_cards: { + Row: { id: string; space_id: string; member_id: string; card_uid: string; card_type: string; label: string | null; is_active: boolean; created_by: string | null; created_at: string; updated_at: string } + Insert: { id?: string; space_id: string; member_id: string; card_uid: string; card_type?: string; label?: string | null; is_active?: boolean; created_by?: string | null; created_at?: string; updated_at?: string } + Update: { id?: string; space_id?: string; member_id?: string; card_uid?: string; card_type?: string; label?: string | null; is_active?: boolean; created_by?: string | null; created_at?: string; updated_at?: string } + Relationships: [] + } activity_log: { Row: { action: string