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:
@@ -56,3 +56,6 @@ export * from './classes'
|
||||
|
||||
// Equipment registry + reservations
|
||||
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.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<string, PermissionCode[]> = {
|
||||
'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'],
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user