Files
hackerspace.sh/lib/actions/member-cards.ts
T
Moheeb Zara a8892a2dc8 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.
2026-05-16 20:13:22 -07:00

181 lines
5.6 KiB
TypeScript

'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),
})),
}
}