From 66e5e308f4bb6a0f51002f7258dadd88c12db8ca Mon Sep 17 00:00:00 2001 From: Marcos Fargas Date: Mon, 21 Sep 2026 02:26:40 +0200 Subject: [PATCH] feat(admin): add direct client onboarding modal and management directly in app.4ventures.es --- src/app/api/admin/create-client/route.ts | 46 +++ src/app/dashboard/agencia/page.tsx | 47 +-- src/components/ClientManagement.tsx | 442 +++++++++++++++++++++++ 3 files changed, 491 insertions(+), 44 deletions(-) create mode 100644 src/app/api/admin/create-client/route.ts create mode 100644 src/components/ClientManagement.tsx diff --git a/src/app/api/admin/create-client/route.ts b/src/app/api/admin/create-client/route.ts new file mode 100644 index 0000000..a4727c3 --- /dev/null +++ b/src/app/api/admin/create-client/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; + +export async function POST(req: NextRequest) { + try { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: 'No autorizado. Inicia sesión.' }, { status: 401 }); + } + + const userRole = user.app_metadata?.role || user.user_metadata?.role || 'client'; + if (userRole !== 'admin' && userRole !== 'team') { + return NextResponse.json({ error: 'Permisos insuficientes. Solo administradores pueden crear clientes.' }, { status: 403 }); + } + + const body = await req.json(); + const { companyName, slug, email, password, role } = body; + + if (!companyName || !slug || !email || !password) { + return NextResponse.json({ error: 'Todos los campos son obligatorios.' }, { status: 400 }); + } + + const sanitizedSlug = slug.toLowerCase().trim().replace(/[^a-z0-9-]/g, '-'); + const targetRole = role === 'admin' || role === 'team' ? role : 'client'; + + const { data, error } = await supabase.rpc('provision_client', { + p_company_name: companyName.trim(), + p_slug: sanitizedSlug, + p_email: email.toLowerCase().trim(), + p_password: password, + p_role: targetRole, + }); + + if (error) { + console.error('Error in provision_client RPC:', error); + return NextResponse.json({ error: error.message || 'Error al provisionar cliente en base de datos.' }, { status: 500 }); + } + + return NextResponse.json({ success: true, client: data }); + } catch (err: any) { + console.error('Server error creating client:', err); + return NextResponse.json({ error: err?.message || 'Error interno del servidor.' }, { status: 500 }); + } +} diff --git a/src/app/dashboard/agencia/page.tsx b/src/app/dashboard/agencia/page.tsx index 2df4899..3cdc4c3 100644 --- a/src/app/dashboard/agencia/page.tsx +++ b/src/app/dashboard/agencia/page.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Building2, DollarSign, Users, TrendingUp, Layers } from 'lucide-react'; +import ClientManagement from '@/components/ClientManagement'; export const revalidate = 0; @@ -101,50 +102,8 @@ export default async function AgencyPage() { - {/* Tenants Table */} -
-
-

Cartera de Clientes Activos

-

- Cada cliente cuenta con aislamiento nativo Row Level Security (RLS) en Postgres. -

-
-
- - - - - - - - - - - - {tenants.map((t) => ( - - - - - - - - ))} - -
Nombre del ClienteTenant SlugUUIDAcceso PortalEstado
{t.name}{t.slug}{t.id} - - Abrir Portal ↗ - - - - Activo - -
-
-
+ {/* Interactive Tenants Management with Onboarding Form */} + ); } diff --git a/src/components/ClientManagement.tsx b/src/components/ClientManagement.tsx new file mode 100644 index 0000000..77041a7 --- /dev/null +++ b/src/components/ClientManagement.tsx @@ -0,0 +1,442 @@ +'use client'; + +import React, { useState } from 'react'; +import { + Plus, + Key, + Copy, + Check, + X, + ExternalLink, + ShieldCheck, + Building2, + Mail, + Lock, + Sparkles, + Loader2, + CheckCircle2 +} from 'lucide-react'; + +interface Tenant { + id: string; + name: string; + slug: string; + created_at: string; +} + +interface ClientManagementProps { + initialTenants: Tenant[]; +} + +export default function ClientManagement({ initialTenants }: ClientManagementProps) { + const [tenants, setTenants] = useState(initialTenants); + const [isOpen, setIsOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + + // Form state + const [companyName, setCompanyName] = useState(''); + const [slug, setSlug] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [role, setRole] = useState<'client' | 'team' | 'admin'>('client'); + const [autoSlug, setAutoSlug] = useState(true); + + // Created access card state + const [createdClient, setCreatedClient] = useState<{ + company: string; + slug: string; + email: string; + password: string; + role: string; + } | null>(null); + const [copied, setCopied] = useState(false); + + // Auto slug generation from company name + const handleNameChange = (val: string) => { + setCompanyName(val); + if (autoSlug) { + const generated = val + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)+/g, ''); + setSlug(generated); + } + }; + + const generateRandomPassword = () => { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%&*'; + let pw = ''; + for (let i = 0; i < 14; i++) { + pw += chars.charAt(Math.floor(Math.random() * chars.length)); + } + setPassword(pw); + }; + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + setErrorMsg(null); + setLoading(true); + + try { + const res = await fetch('/api/admin/create-client', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + companyName, + slug, + email, + password, + role + }) + }); + + const data = await res.json(); + + if (!res.ok || data.error) { + throw new Error(data.error || 'Error al crear cliente'); + } + + // Add to local state if not exists + const newTenant: Tenant = { + id: data.client.tenant_id, + name: companyName, + slug: slug, + created_at: new Date().toISOString() + }; + + setTenants(prev => { + const filtered = prev.filter(t => t.slug !== slug); + return [...filtered, newTenant]; + }); + + setCreatedClient({ + company: companyName, + slug: slug, + email: email, + password: password, + role: role + }); + + // Clear input fields + setCompanyName(''); + setSlug(''); + setEmail(''); + setPassword(''); + } catch (err: any) { + setErrorMsg(err.message || 'Error inesperado'); + } finally { + setLoading(false); + } + }; + + const copyCredentials = () => { + if (!createdClient) return; + const text = `🔐 *Acceso a tu Centro de Mando — 4Ventures* +Empresa: ${createdClient.company} +Portal Web: https://app.4ventures.es/login +Usuario: ${createdClient.email} +Contraseña: ${createdClient.password} +Rol: ${createdClient.role === 'client' ? 'Cliente' : 'Agencia'} + +Este portal cuenta con aislamiento de datos cifrado y métricas en tiempo real.`; + + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2500); + }; + + return ( +
+ {/* Table Header with Action Button */} +
+
+

+ + Cartera de Clientes Activos ({tenants.length}) +

+

+ Cada cliente cuenta con aislamiento nativo Row Level Security (RLS) en Postgres. +

+
+ + +
+ + {/* Tenants Table */} +
+ + + + + + + + + + + + {tenants.map((t) => ( + + + + + + + + ))} + +
Nombre del ClienteTenant SlugUUIDAcceso PortalEstado
+ + {t.name} + {t.slug}{t.id} + + Abrir Portal + + + + Activo + +
+
+ + {/* Modal: Onboard New Client */} + {isOpen && ( +
+
+ {/* Modal Header */} +
+
+
+ +
+
+

Alta de Nuevo Cliente

+

Crea el portal corporativo y activa sus credenciales de acceso

+
+
+ +
+ + {/* Modal Body */} +
+ {createdClient ? ( + /* Success Screen with Credentials */ +
+
+ +
+

¡Cliente Creado con Éxito!

+

+ El tenant ha sido configurado en Postgres con aislamiento RLS y el usuario ya puede iniciar sesión. +

+
+
+ +
+
+ Empresa: + {createdClient.company} +
+
+ URL Acceso: + https://app.4ventures.es/login +
+
+ Email: + {createdClient.email} +
+
+ Contraseña: + + {createdClient.password} + +
+
+ +
+ + +
+
+ ) : ( + /* Creation Form */ +
+ {errorMsg && ( +
+ {errorMsg} +
+ )} + +
+ +
+ + handleNameChange(e.target.value)} + className="w-full pl-9 pr-3 py-2 bg-gray-900 border border-gray-800 rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:border-brand-500" + /> +
+
+ +
+
+ + +
+ { + setAutoSlug(false); + setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '')); + }} + className="w-full px-3 py-2 bg-gray-900 border border-gray-800 rounded-lg text-sm font-mono text-brand-400 placeholder-gray-500 focus:outline-none focus:border-brand-500" + /> +
+ +
+ +
+ + setEmail(e.target.value)} + className="w-full pl-9 pr-3 py-2 bg-gray-900 border border-gray-800 rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:border-brand-500" + /> +
+
+ +
+
+ + +
+
+ + setPassword(e.target.value)} + className="w-full pl-9 pr-3 py-2 bg-gray-900 border border-gray-800 rounded-lg text-sm font-mono text-white placeholder-gray-500 focus:outline-none focus:border-brand-500" + /> +
+
+ +
+ + +
+ +
+ + +
+
+ )} +
+
+
+ )} +
+ ); +}