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.
This commit is contained in:
+3
-1
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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) |
|
| `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` | 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) |
|
| `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`.
|
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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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('••••')
|
||||||
|
})
|
||||||
|
})
|
||||||
+34
-1
@@ -8,7 +8,7 @@ import {
|
|||||||
type CertStatus,
|
type CertStatus,
|
||||||
} from '@/lib/certifications-logic'
|
} from '@/lib/certifications-logic'
|
||||||
import { PERMISSIONS, PERMISSION_CODES } from '@/lib/permissions-catalog'
|
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 { SIGNUP_STATUS_LABEL, SESSION_STATUS_LABEL } from '@/lib/classes-logic'
|
||||||
import { RESERVATION_STATUS_LABEL } from '@/lib/equipment-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 reservations: Reservation[] = 'data' in reservationRes ? (reservationRes.data as Reservation[]) : []
|
||||||
const equipOf = (r: Reservation) => (Array.isArray(r.equipment) ? r.equipment[0] : r.equipment)
|
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 titleOf = (cs: ClassSignup) => {
|
||||||
const sess = sessionOf(cs)
|
const sess = sessionOf(cs)
|
||||||
const c = sess ? (Array.isArray(sess.classes) ? sess.classes[0] : sess.classes) : null
|
const c = sess ? (Array.isArray(sess.classes) ? sess.classes[0] : sess.classes) : null
|
||||||
@@ -261,6 +265,35 @@ export default async function MePage() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-3">
|
||||||
|
My access cards
|
||||||
|
</h2>
|
||||||
|
{myCards.length === 0 ? (
|
||||||
|
<p className="font-sans text-sm text-muted-foreground">
|
||||||
|
No access cards are registered to you. A door manager can add one.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-lg border border-border">
|
||||||
|
{myCards.map(c => (
|
||||||
|
<li key={c.id} className="p-4 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<span className="font-mono text-sm text-foreground">•••• {c.last4}</span>
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground ml-2 uppercase">{c.card_type}</span>
|
||||||
|
{c.label && <span className="font-sans text-xs text-muted-foreground ml-2">{c.label}</span>}
|
||||||
|
</div>
|
||||||
|
<Badge variant={c.is_active ? 'default' : 'outline'}>
|
||||||
|
{c.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<p className="font-sans text-xs text-muted-foreground mt-3">
|
||||||
|
Only the last 4 digits are shown. The full card number is never displayed here.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { Tables } from '@/types/database'
|
|||||||
import { PageTitle } from '@/components/ui/page-title'
|
import { PageTitle } from '@/components/ui/page-title'
|
||||||
import { useConfirm } from '@/components/ui/confirm'
|
import { useConfirm } from '@/components/ui/confirm'
|
||||||
import { MemberCertificationsDialog } from '@/components/certifications/member-certifications-dialog'
|
import { MemberCertificationsDialog } from '@/components/certifications/member-certifications-dialog'
|
||||||
|
import { MemberCardsDialog } from '@/components/door/member-cards-dialog'
|
||||||
|
|
||||||
type Member = Tables<'space_members'>
|
type Member = Tables<'space_members'>
|
||||||
type AreaLeadRole = { id: string; area_name: string; lead_id: string | null }
|
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
|
// capability), independent of admin/board. Adds a per-member
|
||||||
// certifications panel reachable by non-admin instructors.
|
// certifications panel reachable by non-admin instructors.
|
||||||
canGrantCerts?: boolean
|
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<string, string> = {
|
const TIER_COLORS: Record<string, string> = {
|
||||||
@@ -38,9 +42,10 @@ const TIER_COLORS: Record<string, string> = {
|
|||||||
|
|
||||||
const isAdmin = (role: string) => role === 'admin' || role === 'board'
|
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 router = useRouter()
|
||||||
const [certMember, setCertMember] = useState<Member | null>(null)
|
const [certMember, setCertMember] = useState<Member | null>(null)
|
||||||
|
const [cardMember, setCardMember] = useState<Member | null>(null)
|
||||||
const confirm = useConfirm()
|
const confirm = useConfirm()
|
||||||
const [members, setMembers] = useState<Member[]>(initialMembers)
|
const [members, setMembers] = useState<Member[]>(initialMembers)
|
||||||
|
|
||||||
@@ -280,6 +285,9 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
|
|||||||
{canGrantCerts && (
|
{canGrantCerts && (
|
||||||
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">CERTS</th>
|
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">CERTS</th>
|
||||||
)}
|
)}
|
||||||
|
{canManageCards && (
|
||||||
|
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">CARDS</th>
|
||||||
|
)}
|
||||||
{isAdmin(currentRole) && (
|
{isAdmin(currentRole) && (
|
||||||
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">ACTIONS</th>
|
<th className="px-4 py-3 text-left font-mono text-[10px] tracking-widest text-muted-foreground">ACTIONS</th>
|
||||||
)}
|
)}
|
||||||
@@ -344,6 +352,16 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
|
{canManageCards && (
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setCardMember(m)}
|
||||||
|
className="font-mono text-[10px] border border-border px-3 py-2 min-h-[44px] rounded hover:border-primary hover:text-primary transition"
|
||||||
|
>
|
||||||
|
CARDS
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
{isAdmin(currentRole) && (
|
{isAdmin(currentRole) && (
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -389,7 +407,7 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
|
|||||||
)
|
)
|
||||||
}) : (
|
}) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5 + (canGrantCerts ? 1 : 0) + (isAdmin(currentRole) ? 1 : 0)} className="p-0">
|
<td colSpan={5 + (canGrantCerts ? 1 : 0) + (canManageCards ? 1 : 0) + (isAdmin(currentRole) ? 1 : 0)} className="p-0">
|
||||||
<Empty className="border-0">
|
<Empty className="border-0">
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
<EmptyMedia variant="icon"><Users /></EmptyMedia>
|
<EmptyMedia variant="icon"><Users /></EmptyMedia>
|
||||||
@@ -450,6 +468,13 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
|
|||||||
onClose={() => setCertMember(null)}
|
onClose={() => setCertMember(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canManageCards && (
|
||||||
|
<MemberCardsDialog
|
||||||
|
member={cardMember ? { id: cardMember.id, display_name: cardMember.display_name } : null}
|
||||||
|
onClose={() => setCardMember(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,12 +95,18 @@ export default async function MembersPage() {
|
|||||||
sid: self.space_id,
|
sid: self.space_id,
|
||||||
perm: 'certifications.grant',
|
perm: 'certifications.grant',
|
||||||
})
|
})
|
||||||
|
const { data: canManageCards } = await supabase.rpc('user_has_permission', {
|
||||||
|
uid: user.id,
|
||||||
|
sid: self.space_id,
|
||||||
|
perm: 'door.manage',
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MembersClient
|
<MembersClient
|
||||||
members={members ?? []}
|
members={members ?? []}
|
||||||
currentRole={self.role}
|
currentRole={self.role}
|
||||||
canGrantCerts={!!canGrantCerts}
|
canGrantCerts={!!canGrantCerts}
|
||||||
|
canManageCards={!!canManageCards}
|
||||||
areaLeadRoles={(areaLeadRoles ?? []) as Array<{ id: string; area_name: string; lead_id: string | null }>}
|
areaLeadRoles={(areaLeadRoles ?? []) as Array<{ id: string; area_name: string; lead_id: string | null }>}
|
||||||
inviteSlot={
|
inviteSlot={
|
||||||
canInvite ? (
|
canInvite ? (
|
||||||
|
|||||||
@@ -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<Card[]>([])
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={o => { if (!o) onClose() }}>
|
||||||
|
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Access cards — {member?.display_name ?? 'Member'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={onAdd} className="space-y-2 border border-border rounded p-3 bg-background">
|
||||||
|
<p className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">Add a card</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={uid}
|
||||||
|
maxLength={200}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<select
|
||||||
|
value={type}
|
||||||
|
onChange={e => setType(e.target.value as 'rfid' | 'nfc')}
|
||||||
|
className="bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-2 focus:outline-none focus:border-primary"
|
||||||
|
>
|
||||||
|
<option value="rfid">RFID</option>
|
||||||
|
<option value="nfc">NFC</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
maxLength={120}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className="bg-primary text-white text-xs font-sans px-3 py-2 rounded hover:bg-primary/90 transition disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Add card
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
{loading ? (
|
||||||
|
<p className="font-mono text-xs text-muted-foreground py-4">Loading…</p>
|
||||||
|
) : cards.length === 0 ? (
|
||||||
|
<p className="font-mono text-xs text-muted-foreground py-4">No cards registered.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{cards.map(c => (
|
||||||
|
<li key={c.id} className="py-2 flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<code className="font-mono text-sm text-foreground">{c.card_uid}</code>
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground ml-2 uppercase">{c.card_type}</span>
|
||||||
|
{c.label && <span className="font-sans text-xs text-muted-foreground ml-2">{c.label}</span>}
|
||||||
|
{!c.is_active && <span className="font-mono text-[10px] text-red-500 ml-2">inactive</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button onClick={() => onToggle(c)} className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-primary hover:text-primary transition">
|
||||||
|
{c.is_active ? 'Deactivate' : 'Activate'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onDelete(c)} className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
`scripts/schema.sql` is the canonical idempotent schema; each numbered
|
||||||
migration is mirrored as a section in it. Tables/columns added:
|
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 |
|
| 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 |
|
| 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 |
|
| 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`.
|
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.
|
||||||
|
|
||||||
|
|||||||
@@ -56,3 +56,6 @@ export * from './classes'
|
|||||||
|
|
||||||
// Equipment registry + reservations
|
// Equipment registry + reservations
|
||||||
export * from './equipment'
|
export * from './equipment'
|
||||||
|
|
||||||
|
// Member access cards (Door epic)
|
||||||
|
export * from './member-cards'
|
||||||
|
|||||||
@@ -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<Gate> {
|
||||||
|
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<string, unknown> = {}
|
||||||
|
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),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ export const PERMISSIONS = [
|
|||||||
{ code: 'classes.manage', group: 'Classes', label: 'Manage classes and schedule sessions' },
|
{ code: 'classes.manage', group: 'Classes', label: 'Manage classes and schedule sessions' },
|
||||||
{ code: 'classes.instruct', group: 'Classes', label: 'Run classes: attendance, completion, attendees' },
|
{ code: 'classes.instruct', group: 'Classes', label: 'Run classes: attendance, completion, attendees' },
|
||||||
{ code: 'equipment.manage', group: 'Equipment', label: 'Manage equipment and reservations' },
|
{ 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: 'customize.manage', group: 'Admin', label: 'Customize roles, tiers, areas, invites, onboarding' },
|
||||||
{ code: 'settings.manage', group: 'Admin', label: 'Space settings, integrations, webhooks' },
|
{ code: 'settings.manage', group: 'Admin', label: 'Space settings, integrations, webhooks' },
|
||||||
] as const
|
] as const
|
||||||
@@ -49,6 +51,7 @@ export const DEFAULT_ROLE_PERMISSIONS: Record<string, PermissionCode[]> = {
|
|||||||
'certifications.manage', 'certifications.grant',
|
'certifications.manage', 'certifications.grant',
|
||||||
'classes.manage', 'classes.instruct',
|
'classes.manage', 'classes.instruct',
|
||||||
'equipment.manage',
|
'equipment.manage',
|
||||||
|
'door.manage', 'door.operate',
|
||||||
'customize.manage', 'settings.manage',
|
'customize.manage', 'settings.manage',
|
||||||
],
|
],
|
||||||
treasurer: ['payments.manage', 'ops.kb.read', 'ops.process.read'],
|
treasurer: ['payments.manage', 'ops.kb.read', 'ops.process.read'],
|
||||||
|
|||||||
@@ -874,6 +874,35 @@ export const listEquipmentReservationsSchema = z.object({
|
|||||||
equipmentId: z.string().uuid('Invalid equipment ID'),
|
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 ──────────────────────────────────────────────────────
|
// ─── Generic ID schemas ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const uuidSchema = z.string().uuid('Invalid ID format')
|
export const uuidSchema = z.string().uuid('Invalid ID format')
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1830,6 +1830,7 @@ BEGIN
|
|||||||
('board','certifications.manage'),('board','certifications.grant'),
|
('board','certifications.manage'),('board','certifications.grant'),
|
||||||
('board','classes.manage'),('board','classes.instruct'),
|
('board','classes.manage'),('board','classes.instruct'),
|
||||||
('board','equipment.manage'),
|
('board','equipment.manage'),
|
||||||
|
('board','door.manage'),('board','door.operate'),
|
||||||
('board','customize.manage'),('board','settings.manage'),
|
('board','customize.manage'),('board','settings.manage'),
|
||||||
('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
|
('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
|
||||||
('member','ops.kb.read'),('member','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)
|
INSERT INTO public.space_role_permissions (space_id, subject, permission)
|
||||||
SELECT id, 'board', 'equipment.manage' FROM public.spaces
|
SELECT id, 'board', 'equipment.manage' FROM public.spaces
|
||||||
ON CONFLICT (space_id, subject, permission) DO NOTHING;
|
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;
|
||||||
|
|||||||
@@ -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 }
|
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: []
|
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: {
|
activity_log: {
|
||||||
Row: {
|
Row: {
|
||||||
action: string
|
action: string
|
||||||
|
|||||||
Reference in New Issue
Block a user