fix(import): route member CSV import through the server action (security/data)

The members branch was inserting space_members directly from the
browser with user_id: crypto.randomUUID() -- a fabricated id that
violates the auth.users FK (offline members must be user_id NULL) and
bypassed all server-side validation. Now maps columns and calls the
existing admin-gated, Zod-validated, batched importMembers action;
surfaces count + skipped. Dropped the now-unused spaceId prop.
This commit is contained in:
Moheeb Zara
2026-05-17 01:18:05 -07:00
parent 363e2a667f
commit 7054b35ddb
2 changed files with 34 additions and 39 deletions
+33 -38
View File
@@ -3,7 +3,7 @@
import { useState, useRef, useCallback, useMemo } from 'react' import { useState, useRef, useCallback, useMemo } from 'react'
import { Upload, CheckCircle, AlertCircle, X, ChevronDown, ArrowRight, FileText, RefreshCcw } from 'lucide-react' import { Upload, CheckCircle, AlertCircle, X, ChevronDown, ArrowRight, FileText, RefreshCcw } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { importPaymentsCsv } from '@/lib/actions' import { importPaymentsCsv, importMembers } from '@/lib/actions'
// ─── Lightweight CSV parser (handles quoted fields, no external deps) ────────── // ─── Lightweight CSV parser (handles quoted fields, no external deps) ──────────
function parseCSV(text: string): { headers: string[]; rows: string[][] } { function parseCSV(text: string): { headers: string[]; rows: string[][] } {
@@ -133,13 +133,12 @@ function Steps({ current }: { current: number }) {
// ─── Main component ─────────────────────────────────────────────────────────── // ─── Main component ───────────────────────────────────────────────────────────
interface Props { interface Props {
spaceId: string
role: string role: string
} }
type ImportResult = { success: number; failed: number; errors: string[] } type ImportResult = { success: number; failed: number; errors: string[] }
export default function ImportClient({ spaceId, role }: Props) { export default function ImportClient({ role }: Props) {
const [mode, setMode] = useState<ImportMode>('members') const [mode, setMode] = useState<ImportMode>('members')
const [step, setStep] = useState(0) const [step, setStep] = useState(0)
const [file, setFile] = useState<File | null>(null) const [file, setFile] = useState<File | null>(null)
@@ -226,53 +225,49 @@ export default function ImportClient({ spaceId, role }: Props) {
success = (result as any).count ?? paymentRows.length success = (result as any).count ?? paymentRows.length
} else { } else {
// Members import — call supabase directly since there's no existing action for bulk member import // Members import goes through the validated, admin-gated server action
const { createClient } = await import('@/lib/supabase/client') // (Zod per row, batched upsert on (space_id,email), user_id left NULL
const supabase = createClient() // for offline members). No direct client DB writes.
const tierMap: Record<string, 'plus' | 'basic' | 'associate'> = {
for (let i = 0; i < rows.length; i++) { plus: 'plus', premium: 'plus', full: 'plus',
const row = rows[i] basic: 'basic', member: 'basic', standard: 'basic',
associate: 'associate', visitor: 'associate', guest: 'associate',
}
const memberRows = rows.map((row, i) => {
const obj: Record<string, string> = {} const obj: Record<string, string> = {}
headers.forEach((h, j) => { headers.forEach((h, j) => {
if (columnMap[h] && columnMap[h] !== 'skip') obj[columnMap[h]] = row[j] ?? '' if (columnMap[h] && columnMap[h] !== 'skip') obj[columnMap[h]] = row[j] ?? ''
}) })
if (!obj.display_name || !obj.email) { if (!obj.display_name || !obj.email) {
errors.push(`Row ${i + 2}: missing name or email`) errors.push(`Row ${i + 2}: missing name or email`)
continue return null
} }
return {
const joined_at = obj.joined_at ? new Date(obj.joined_at).toISOString() : new Date().toISOString()
const last_paid_at = obj.last_paid_at ? new Date(obj.last_paid_at).toISOString() : null
const has_card_access = /yes|true|1|y/i.test(obj.has_card_access ?? '')
// Map tier value to valid enum or default to 'basic'
const tierMap: Record<string, 'plus' | 'basic' | 'associate'> = {
plus: 'plus', premium: 'plus', full: 'plus',
basic: 'basic', member: 'basic', standard: 'basic',
associate: 'associate', visitor: 'associate', guest: 'associate',
}
const tier = tierMap[(obj.tier || '').toLowerCase()] || 'basic'
const { error } = await supabase.from('space_members').insert({
space_id: spaceId,
user_id: crypto.randomUUID(),
display_name: obj.display_name, display_name: obj.display_name,
email: obj.email, email: obj.email,
phone: obj.phone || null, phone: obj.phone || null,
handle: obj.handle || null, tier: tierMap[(obj.tier || '').toLowerCase()] || 'basic',
tier, joined_at: obj.joined_at || undefined,
role: 'member', last_paid_at: obj.last_paid_at || undefined,
status: 'current', has_card_access: /yes|true|1|y/i.test(obj.has_card_access ?? ''),
approved: true, }
joined_at, }).filter(Boolean) as Array<Record<string, unknown>>
last_paid_at,
has_card_access,
})
if (error) errors.push(`Row ${i + 2} (${obj.email}): ${error.message}`) if (memberRows.length === 0) {
else success++ toast.error('No valid rows to import')
setImporting(false)
return
} }
const result = await importMembers(memberRows)
if ('error' in result && result.error) {
toast.error(result.error)
setImporting(false)
return
}
const r = result as { count: number; skipped: number }
success = r.count
if (r.skipped > 0) errors.push(`${r.skipped} row(s) skipped (invalid email, name, tier, or date)`)
} }
setImporting(false) setImporting(false)
+1 -1
View File
@@ -26,7 +26,7 @@ export default async function ImportPage() {
<PageTitle>Import & Sync</PageTitle> <PageTitle>Import & Sync</PageTitle>
<p className="font-mono text-xs text-sidebar-foreground/50">CSV import · member & payment data</p> <p className="font-mono text-xs text-sidebar-foreground/50">CSV import · member & payment data</p>
</div> </div>
<ImportClient spaceId={member!.space_id} role={member!.role} /> <ImportClient role={member!.role} />
</div> </div>
) )
} }