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 { Upload, CheckCircle, AlertCircle, X, ChevronDown, ArrowRight, FileText, RefreshCcw } from 'lucide-react'
import { toast } from 'sonner'
import { importPaymentsCsv } from '@/lib/actions'
import { importPaymentsCsv, importMembers } from '@/lib/actions'
// ─── Lightweight CSV parser (handles quoted fields, no external deps) ──────────
function parseCSV(text: string): { headers: string[]; rows: string[][] } {
@@ -133,13 +133,12 @@ function Steps({ current }: { current: number }) {
// ─── Main component ───────────────────────────────────────────────────────────
interface Props {
spaceId: string
role: 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 [step, setStep] = useState(0)
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
} else {
// Members import — call supabase directly since there's no existing action for bulk member import
const { createClient } = await import('@/lib/supabase/client')
const supabase = createClient()
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
// Members import goes through the validated, admin-gated server action
// (Zod per row, batched upsert on (space_id,email), user_id left NULL
// for offline members). No direct client DB writes.
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 memberRows = rows.map((row, i) => {
const obj: Record<string, string> = {}
headers.forEach((h, j) => {
if (columnMap[h] && columnMap[h] !== 'skip') obj[columnMap[h]] = row[j] ?? ''
})
if (!obj.display_name || !obj.email) {
errors.push(`Row ${i + 2}: missing name or email`)
continue
return null
}
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(),
return {
display_name: obj.display_name,
email: obj.email,
phone: obj.phone || null,
handle: obj.handle || null,
tier,
role: 'member',
status: 'current',
approved: true,
joined_at,
last_paid_at,
has_card_access,
})
tier: tierMap[(obj.tier || '').toLowerCase()] || 'basic',
joined_at: obj.joined_at || undefined,
last_paid_at: obj.last_paid_at || undefined,
has_card_access: /yes|true|1|y/i.test(obj.has_card_access ?? ''),
}
}).filter(Boolean) as Array<Record<string, unknown>>
if (error) errors.push(`Row ${i + 2} (${obj.email}): ${error.message}`)
else success++
if (memberRows.length === 0) {
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)
+1 -1
View File
@@ -26,7 +26,7 @@ export default async function ImportPage() {
<PageTitle>Import & Sync</PageTitle>
<p className="font-mono text-xs text-sidebar-foreground/50">CSV import · member & payment data</p>
</div>
<ImportClient spaceId={member!.space_id} role={member!.role} />
<ImportClient role={member!.role} />
</div>
)
}