diff --git a/scripts/dokploy_apply_tenant_ficha.py b/scripts/dokploy_apply_tenant_ficha.py new file mode 100644 index 0000000..44997b5 --- /dev/null +++ b/scripts/dokploy_apply_tenant_ficha.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Apply sql/tenant_ficha.sql via Dokploy supabase-db terminal.""" +from __future__ import annotations + +import base64 +import os +import re +import sys +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path("/Users/4ventures/4ventures-client-portal") +BPS = Path("/Users/4ventures/Projects/bps-facturacion") +SQL = ROOT / "sql" / "tenant_ficha.sql" +BASE = "https://dokploy.4ventures.es" + + +def env(key: str) -> str: + v = os.environ.get(key, "").strip() + if v: + return v + p = BPS / ".env" + if not p.is_file(): + return "" + for line in p.read_text().splitlines(): + if line.startswith(f"{key}="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + return "" + + +def main() -> int: + email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es" + password = env("DOKPLOY_PASSWORD") + b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode() + chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)] + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1400, "height": 1000}) + page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000) + page.wait_for_timeout(800) + if "/dashboard" not in page.url: + page.locator('input[type="email"], input[name="email"]').first.fill(email) + page.locator('input[type="password"]').first.fill(password) + page.locator('button[type="submit"]').first.click() + page.wait_for_url(re.compile(r"/dashboard"), timeout=90000) + print("login ok", flush=True) + page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000) + page.wait_for_timeout(800) + page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill( + "supabase-db" + ) + page.wait_for_timeout(1000) + page.keyboard.press("Escape") + page.wait_for_timeout(200) + page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click() + page.wait_for_timeout(400) + page.get_by_text("Terminal", exact=True).click() + page.wait_for_timeout(1000) + if page.locator("button", has_text="Bash").count(): + page.locator("button", has_text="Bash").first.click() + page.wait_for_timeout(2800) + if page.locator("textarea.xterm-helper-textarea").count(): + page.locator("textarea.xterm-helper-textarea").first.click() + + def run(cmd: str, wait: int = 800) -> None: + page.keyboard.type(cmd, delay=1) + page.keyboard.press("Enter") + page.wait_for_timeout(wait) + + run("rm -f /tmp/tf.b64 /tmp/tf.sql; echo READY", 1200) + for i, ch in enumerate(chunks): + op = ">" if i == 0 else ">>" + run(f"printf '%s' '{ch}' {op} /tmp/tf.b64", 400) + run( + "base64 -d /tmp/tf.b64 > /tmp/tf.sql && " + "psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/tf.sql; echo EXIT:$?", + 8000, + ) + text = page.evaluate( + "() => (document.querySelector('.xterm-rows')||{}).innerText||''" + ) + browser.close() + ok = "EXIT:0" in text + print("SQL_OK", ok, flush=True) + return 0 if ok else 6 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sql/tenant_ficha.sql b/sql/tenant_ficha.sql new file mode 100644 index 0000000..4e36a43 --- /dev/null +++ b/sql/tenant_ficha.sql @@ -0,0 +1,13 @@ +-- Per-tenant ficha: logo + dashboard module settings +-- Idempotent. + +alter table public.tenants + add column if not exists logo_url text; + +alter table public.tenants + add column if not exists settings jsonb not null default '{}'::jsonb; + +comment on column public.tenants.logo_url is + 'Client logo for portal shell (URL or storage path under tenant-assets).'; +comment on column public.tenants.settings is + 'JSON: { modules: { kpis, crm, seguimiento, estrategia, calendario, ad-tracker, activos, creatives, asistente }, branding: {} }'; diff --git a/src/app/api/admin/tenants/logo/route.ts b/src/app/api/admin/tenants/logo/route.ts new file mode 100644 index 0000000..74fcf83 --- /dev/null +++ b/src/app/api/admin/tenants/logo/route.ts @@ -0,0 +1,132 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; +import { + ASSETS_BUCKET, + ensureAssetsBucket, + sanitizeFileName, + serviceSupabase, +} from '@/lib/assets'; +import { randomUUID } from 'crypto'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +const MAX_LOGO = 2 * 1024 * 1024; // 2 MB +const ALLOWED = new Set([ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/svg+xml', +]); + +async function requireAgency() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }; + } + if (!isAgencyRole(userRole(user))) { + return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) }; + } + const headers = serviceHeaders(); + if (!headers) { + return { + error: NextResponse.json({ error: 'no service role' }, { status: 503 }), + }; + } + return { headers }; +} + +/** POST multipart: tenantId + file — upload logo and set tenants.logo_url */ +export async function POST(req: NextRequest) { + const gate = await requireAgency(); + if ('error' in gate && gate.error) return gate.error; + const { headers } = gate as { + headers: { apikey: string; Authorization: string }; + }; + + const form = await req.formData(); + const tenantId = String(form.get('tenantId') || '').trim(); + const file = form.get('file'); + if (!tenantId || !(file instanceof File)) { + return NextResponse.json( + { error: 'tenantId y file requeridos' }, + { status: 400 } + ); + } + if (file.size > MAX_LOGO) { + return NextResponse.json( + { error: 'Logo máximo 2 MB' }, + { status: 400 } + ); + } + const mime = file.type || 'application/octet-stream'; + if (!ALLOWED.has(mime)) { + return NextResponse.json( + { error: 'Usa PNG, JPG, WEBP o SVG' }, + { status: 400 } + ); + } + + const bucketOk = await ensureAssetsBucket(); + if (!bucketOk.ok) { + return NextResponse.json( + { error: bucketOk.error || 'bucket' }, + { status: 503 } + ); + } + + const sb = serviceSupabase(); + if (!sb) { + return NextResponse.json({ error: 'no service role' }, { status: 503 }); + } + + const safe = sanitizeFileName(file.name || 'logo.png'); + const path = `${tenantId}/branding/${randomUUID()}_${safe}`; + const buf = Buffer.from(await file.arrayBuffer()); + const { error: upErr } = await sb.storage + .from(ASSETS_BUCKET) + .upload(path, buf, { contentType: mime, upsert: true }); + if (upErr) { + return NextResponse.json({ error: upErr.message }, { status: 500 }); + } + + // Store storage path; served via /api/branding/logo + const logoUrl = `storage:${path}`; + const patch = await fetch( + `${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}`, + { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=representation', + }, + body: JSON.stringify({ logo_url: logoUrl }), + } + ); + if (!patch.ok) { + const msg = await patch.text(); + return NextResponse.json( + { + error: msg.includes('logo_url') + ? 'Aplicar sql/tenant_ficha.sql' + : msg.slice(0, 300), + }, + { status: 500 } + ); + } + + return NextResponse.json({ + logoUrl, + serveUrl: `/api/branding/logo?tenantId=${encodeURIComponent(tenantId)}`, + }); +} diff --git a/src/app/api/admin/tenants/route.ts b/src/app/api/admin/tenants/route.ts index e8ca657..13cd27c 100644 --- a/src/app/api/admin/tenants/route.ts +++ b/src/app/api/admin/tenants/route.ts @@ -6,6 +6,7 @@ import { supabaseUrl, userRole, } from '@/lib/agency'; +import type { TenantSettings } from '@/lib/tenant-modules'; export const dynamic = 'force-dynamic'; @@ -29,7 +30,74 @@ async function requireAgency() { return { headers }; } -/** PATCH — follow/unfollow client or set notes. */ +/** GET ?tenantId= — full ficha payload */ +export async function GET(req: NextRequest) { + const gate = await requireAgency(); + if ('error' in gate && gate.error) return gate.error; + const { headers } = gate as { + headers: { apikey: string; Authorization: string }; + }; + + const tenantId = req.nextUrl.searchParams.get('tenantId')?.trim() || ''; + if (!tenantId) { + return NextResponse.json({ error: 'tenantId requerido' }, { status: 400 }); + } + + const base = supabaseUrl(); + const [tRes, iRes] = await Promise.all([ + fetch( + `${base}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=id,name,slug,logo_url,settings,agency_followed,agency_notes,created_at`, + { headers, cache: 'no-store' } + ), + fetch( + `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&select=provider,status,enabled,external_account_id,label,last_sync_at,error_message,meta`, + { headers, cache: 'no-store' } + ), + ]); + + if (!tRes.ok) { + const msg = await tRes.text(); + return NextResponse.json( + { + error: msg.includes('logo_url') || msg.includes('settings') + ? 'Aplicar sql/tenant_ficha.sql' + : msg.slice(0, 300), + }, + { status: 500 } + ); + } + + const tenants = await tRes.json(); + const tenant = tenants[0]; + if (!tenant) { + return NextResponse.json({ error: 'Cliente no encontrado' }, { status: 404 }); + } + + let integrations: unknown[] = []; + let integWarning: string | null = null; + if (iRes.ok) { + integrations = await iRes.json(); + } else { + integWarning = (await iRes.text()).slice(0, 200); + } + + return NextResponse.json({ + tenant: { + id: tenant.id, + name: tenant.name, + slug: tenant.slug, + logoUrl: tenant.logo_url || null, + settings: (tenant.settings || {}) as TenantSettings, + followed: tenant.agency_followed !== false, + notes: tenant.agency_notes || '', + createdAt: tenant.created_at, + }, + integrations, + warning: integWarning, + }); +} + +/** PATCH — follow/notes + ficha fields (name, logoUrl, settings). */ export async function PATCH(req: NextRequest) { const gate = await requireAgency(); if ('error' in gate && gate.error) return gate.error; @@ -51,9 +119,22 @@ export async function PATCH(req: NextRequest) { if (typeof body.notes === 'string') { patch.agency_notes = body.notes.trim() || null; } + if (typeof body.name === 'string' && body.name.trim()) { + patch.name = body.name.trim(); + } + if (typeof body.logoUrl === 'string') { + patch.logo_url = body.logoUrl.trim() || null; + } + if (body.logoUrl === null) { + patch.logo_url = null; + } + if (body.settings && typeof body.settings === 'object') { + patch.settings = body.settings as TenantSettings; + } + if (!Object.keys(patch).length) { return NextResponse.json( - { error: 'followed o notes requeridos' }, + { error: 'Nada que actualizar' }, { status: 400 } ); } @@ -74,9 +155,12 @@ export async function PATCH(req: NextRequest) { const msg = await r.text(); return NextResponse.json( { - error: msg.includes('agency_followed') - ? 'Aplicar sql/agency_control.sql' - : msg.slice(0, 300), + error: + msg.includes('logo_url') || msg.includes('settings') + ? 'Aplicar sql/tenant_ficha.sql en Supabase' + : msg.includes('agency_followed') + ? 'Aplicar sql/agency_control.sql' + : msg.slice(0, 300), }, { status: 500 } ); diff --git a/src/app/api/admin/tenants/users/route.ts b/src/app/api/admin/tenants/users/route.ts new file mode 100644 index 0000000..a55b2df --- /dev/null +++ b/src/app/api/admin/tenants/users/route.ts @@ -0,0 +1,257 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { createClient as createServiceClient } from '@supabase/supabase-js'; +import { + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; + +export const dynamic = 'force-dynamic'; + +async function requireAgency() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }; + } + if (!isAgencyRole(userRole(user))) { + return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) }; + } + const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim(); + if (!key) { + return { + error: NextResponse.json({ error: 'no service role' }, { status: 503 }), + }; + } + const admin = createServiceClient(supabaseUrl(), key, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + return { admin, headers: serviceHeaders()! }; +} + +function tenantOf(u: { + app_metadata?: Record; + user_metadata?: Record; +}): string | null { + return ( + (u.app_metadata?.tenant_id as string) || + (u.user_metadata?.tenant_id as string) || + null + ); +} + +/** GET ?tenantId= — users with access to this client portal. */ +export async function GET(req: NextRequest) { + const gate = await requireAgency(); + if ('error' in gate && gate.error) return gate.error; + const { admin } = gate as Awaited> & { + admin: ReturnType; + }; + + const tenantId = req.nextUrl.searchParams.get('tenantId')?.trim() || ''; + if (!tenantId) { + return NextResponse.json({ error: 'tenantId requerido' }, { status: 400 }); + } + + const users: { + id: string; + email: string; + role: string; + createdAt: string; + lastSignIn: string | null; + }[] = []; + + let page = 1; + for (;;) { + const { data, error } = await admin.auth.admin.listUsers({ + page, + perPage: 200, + }); + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + for (const u of data.users) { + if (tenantOf(u) !== tenantId) continue; + users.push({ + id: u.id, + email: u.email || '', + role: + (u.app_metadata?.role as string) || + (u.user_metadata?.role as string) || + 'client', + createdAt: u.created_at, + lastSignIn: u.last_sign_in_at || null, + }); + } + if (data.users.length < 200) break; + page += 1; + if (page > 20) break; + } + + users.sort((a, b) => a.email.localeCompare(b.email)); + return NextResponse.json({ users }); +} + +/** + * POST — invite email to client portal view. + * Body: { tenantId, email, companyName? } + */ +export async function POST(req: NextRequest) { + const gate = await requireAgency(); + if ('error' in gate && gate.error) return gate.error; + const { admin, headers } = gate as Awaited< + ReturnType + > & { + admin: ReturnType; + headers: { apikey: string; Authorization: string }; + }; + + const body = await req.json().catch(() => ({})); + const tenantId = + typeof body.tenantId === 'string' ? body.tenantId.trim() : ''; + const email = + typeof body.email === 'string' ? body.email.toLowerCase().trim() : ''; + if (!tenantId || !email || !email.includes('@')) { + return NextResponse.json( + { error: 'tenantId y email válidos requeridos' }, + { status: 400 } + ); + } + + let company = + typeof body.companyName === 'string' ? body.companyName.trim() : ''; + if (!company) { + const tr = await fetch( + `${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=name`, + { headers, cache: 'no-store' } + ); + if (tr.ok) { + const rows = await tr.json(); + company = rows[0]?.name || 'Cliente'; + } else { + company = 'Cliente'; + } + } + + const site = + process.env.NEXT_PUBLIC_SITE_URL?.trim() || 'https://app.4ventures.es'; + + const { data: invited, error: inviteErr } = + await admin.auth.admin.inviteUserByEmail(email, { + redirectTo: `${site}/login`, + data: { + tenant_id: tenantId, + role: 'client', + company, + }, + }); + + if (inviteErr) { + // Fallback: create user with temp password if invite not configured + if ( + inviteErr.message.toLowerCase().includes('sending') || + inviteErr.message.toLowerCase().includes('smtp') || + inviteErr.message.toLowerCase().includes('email') + ) { + const temp = + typeof body.password === 'string' && body.password.length >= 8 + ? body.password + : `4V-${Math.random().toString(36).slice(2, 10)}!aA1`; + const { data: created, error: createErr } = + await admin.auth.admin.createUser({ + email, + password: temp, + email_confirm: true, + user_metadata: { + tenant_id: tenantId, + role: 'client', + company, + }, + app_metadata: { + tenant_id: tenantId, + role: 'client', + company, + }, + }); + if (createErr) { + return NextResponse.json( + { error: createErr.message }, + { status: 500 } + ); + } + return NextResponse.json({ + user: { + id: created.user?.id, + email, + role: 'client', + }, + invited: false, + temporaryPassword: temp, + hint: 'SMTP no configurado: se creó usuario con contraseña temporal. Compártela al cliente.', + }); + } + return NextResponse.json({ error: inviteErr.message }, { status: 500 }); + } + + const uid = invited.user?.id; + if (uid) { + await admin.auth.admin.updateUserById(uid, { + app_metadata: { + tenant_id: tenantId, + role: 'client', + company, + }, + user_metadata: { + tenant_id: tenantId, + role: 'client', + company, + }, + }); + } + + return NextResponse.json({ + user: { id: uid, email, role: 'client' }, + invited: true, + }); +} + +/** DELETE — remove portal access (delete auth user). Body: { tenantId, userId } */ +export async function DELETE(req: NextRequest) { + const gate = await requireAgency(); + if ('error' in gate && gate.error) return gate.error; + const { admin } = gate as Awaited> & { + admin: ReturnType; + }; + + const body = await req.json().catch(() => ({})); + const tenantId = + typeof body.tenantId === 'string' ? body.tenantId.trim() : ''; + const userId = typeof body.userId === 'string' ? body.userId.trim() : ''; + if (!tenantId || !userId) { + return NextResponse.json( + { error: 'tenantId y userId requeridos' }, + { status: 400 } + ); + } + + const { data, error } = await admin.auth.admin.getUserById(userId); + if (error || !data.user) { + return NextResponse.json({ error: 'Usuario no encontrado' }, { status: 404 }); + } + if (tenantOf(data.user) !== tenantId) { + return NextResponse.json( + { error: 'El usuario no pertenece a este cliente' }, + { status: 403 } + ); + } + + const del = await admin.auth.admin.deleteUser(userId); + if (del.error) { + return NextResponse.json({ error: del.error.message }, { status: 500 }); + } + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/branding/logo/route.ts b/src/app/api/branding/logo/route.ts new file mode 100644 index 0000000..b7434c9 --- /dev/null +++ b/src/app/api/branding/logo/route.ts @@ -0,0 +1,97 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { + homeTenantId, + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, + VIEW_TENANT_COOKIE, +} from '@/lib/agency'; +import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets'; +import { cookies } from 'next/headers'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +/** + * GET ?tenantId= — stream client logo (agency any tenant; client only own). + * logo_url may be https://… or storage:{path}. + */ +export async function GET(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const agency = isAgencyRole(userRole(user)); + const cookieStore = await cookies(); + const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null; + const paramId = req.nextUrl.searchParams.get('tenantId')?.trim() || ''; + const ownId = homeTenantId(user); + + let tenantId = paramId || viewId || ownId || ''; + if (!tenantId) { + return NextResponse.json({ error: 'sin tenant' }, { status: 400 }); + } + if (!agency && tenantId !== ownId) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const headers = serviceHeaders(); + if (!headers) { + return NextResponse.json({ error: 'no service role' }, { status: 503 }); + } + + const tr = await fetch( + `${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=logo_url`, + { headers, cache: 'no-store' } + ); + if (!tr.ok) { + return NextResponse.json({ error: 'tenant' }, { status: 404 }); + } + const rows = await tr.json(); + const logoUrl: string | null = rows[0]?.logo_url || null; + if (!logoUrl) { + return NextResponse.json({ error: 'sin logo' }, { status: 404 }); + } + + if (logoUrl.startsWith('http://') || logoUrl.startsWith('https://')) { + return NextResponse.redirect(logoUrl); + } + + const path = logoUrl.startsWith('storage:') + ? logoUrl.slice('storage:'.length) + : logoUrl; + + const sb = serviceSupabase(); + if (!sb) { + return NextResponse.json({ error: 'no service role' }, { status: 503 }); + } + const { data, error } = await sb.storage.from(ASSETS_BUCKET).download(path); + if (error || !data) { + return NextResponse.json( + { error: error?.message || 'not found' }, + { status: 404 } + ); + } + const buf = Buffer.from(await data.arrayBuffer()); + const ext = path.split('.').pop()?.toLowerCase(); + const type = + ext === 'png' + ? 'image/png' + : ext === 'webp' + ? 'image/webp' + : ext === 'svg' + ? 'image/svg+xml' + : 'image/jpeg'; + return new NextResponse(buf, { + headers: { + 'Content-Type': type, + 'Cache-Control': 'private, max-age=300', + }, + }); +} diff --git a/src/app/dashboard/agencia/cliente/[id]/page.tsx b/src/app/dashboard/agencia/cliente/[id]/page.tsx index a9a199d..d284716 100644 --- a/src/app/dashboard/agencia/cliente/[id]/page.tsx +++ b/src/app/dashboard/agencia/cliente/[id]/page.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import ClientIntegrationsPanel from '@/components/ClientIntegrationsPanel'; +import ClientFicha from '@/components/ClientFicha'; import { createClient } from '@/lib/supabase/server'; import { redirect } from 'next/navigation'; import { @@ -11,7 +11,7 @@ import { export const dynamic = 'force-dynamic'; -export default async function ClienteIntegrationsPage({ +export default async function ClienteFichaPage({ params, }: { params: Promise<{ id: string }>; @@ -37,5 +37,5 @@ export default async function ClienteIntegrationsPage({ } } - return ; + return ; } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 76027a2..d1c4ff4 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -2,8 +2,21 @@ import React from 'react'; import { createClient } from '@/lib/supabase/server'; import { logoutAction } from '@/app/auth/actions'; import { cookies } from 'next/headers'; -import { VIEW_TENANT_NAME_COOKIE } from '@/lib/agency'; +import { + VIEW_TENANT_COOKIE, + VIEW_TENANT_NAME_COOKIE, + homeTenantId, + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; import DashboardShell from '@/components/DashboardShell'; +import { + DashboardModuleId, + TenantSettings, + resolveTenantModules, +} from '@/lib/tenant-modules'; export default async function DashboardLayout({ children, @@ -15,13 +28,56 @@ export default async function DashboardLayout({ data: { user }, } = await supabase.auth.getUser(); - const role = - user?.user_metadata?.role || user?.app_metadata?.role || 'client'; - const company = user?.user_metadata?.company || '4Ventures Central'; + const role = userRole(user); + const company = + (user?.user_metadata?.company as string) || + (user?.app_metadata?.company as string) || + '4Ventures Central'; const email = user?.email || 'usuario@4ventures.es'; - const isAgency = role === 'admin' || role === 'team'; + const isAgency = isAgencyRole(role); const cookieStore = await cookies(); const viewName = cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value; + const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null; + const ownId = homeTenantId(user); + + let enabledModules: Record | null = null; + let clientLogoUrl: string | null = null; + let brandLabel = company; + + // Client users (and agency zoomed into a client for branding): load ficha settings + const brandingTenantId = isAgency ? viewId : ownId; + if (brandingTenantId) { + const headers = serviceHeaders(); + if (headers) { + const [tRes, iRes] = await Promise.all([ + fetch( + `${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(brandingTenantId)}&select=name,logo_url,settings`, + { headers, cache: 'no-store' } + ), + fetch( + `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(brandingTenantId)}&select=provider,status,enabled`, + { headers, cache: 'no-store' } + ), + ]); + if (tRes.ok) { + const rows = await tRes.json(); + const t = rows[0]; + if (t) { + brandLabel = t.name || brandLabel; + if (t.logo_url) { + clientLogoUrl = `/api/branding/logo?tenantId=${encodeURIComponent(brandingTenantId)}`; + } + const integrations = iRes.ok ? await iRes.json() : []; + if (!isAgency) { + enabledModules = resolveTenantModules( + (t.settings || {}) as TenantSettings, + integrations || [] + ); + } + } + } + } + } return ( {children} diff --git a/src/components/AgencyPortfolioBoard.tsx b/src/components/AgencyPortfolioBoard.tsx index 65662d6..0f0cdce 100644 --- a/src/components/AgencyPortfolioBoard.tsx +++ b/src/components/AgencyPortfolioBoard.tsx @@ -345,9 +345,9 @@ export default function AgencyPortfolioBoard() { - Conexiones + Ficha + + } + /> + + {(msg || err) && ( +

+ {err || msg} +

+ )} + + + + {tab === 'general' && ( +
+
+
+ + Identidad +
+ + +