fix: eliminar footer rls en login y habilitar subida y sincronizacion de logo de cliente

This commit is contained in:
Marcos Fargas
2026-10-15 04:16:53 +02:00
parent 49d3529525
commit d049fa0f3b
5 changed files with 363 additions and 64 deletions

View File

@@ -12,18 +12,30 @@ import {
sanitizeFileName, sanitizeFileName,
serviceSupabase, serviceSupabase,
} from '@/lib/assets'; } from '@/lib/assets';
import { randomUUID } from 'crypto'; import { randomUUID, createHash } from 'crypto';
import {
newId,
parseBrandbook,
type BrandAsset,
BRANDBOOK_DOC_TYPE,
BRANDBOOK_DOC_TITLE,
} from '@/lib/brandbook';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
const MAX_LOGO = 2 * 1024 * 1024; // 2 MB const MAX_LOGO = 15 * 1024 * 1024; // 15 MB
const ALLOWED = new Set([ const ALLOWED_MIMES = new Set([
'image/png', 'image/png',
'image/jpeg', 'image/jpeg',
'image/jpg',
'image/webp', 'image/webp',
'image/svg+xml', 'image/svg+xml',
'image/svg',
'image/x-png',
'image/pjpeg',
]); ]);
const ALLOWED_EXTS = new Set(['png', 'jpg', 'jpeg', 'webp', 'svg']);
async function requireAgency() { async function requireAgency() {
const supabase = await createClient(); const supabase = await createClient();
@@ -64,18 +76,36 @@ export async function POST(req: NextRequest) {
} }
if (file.size > MAX_LOGO) { if (file.size > MAX_LOGO) {
return NextResponse.json( return NextResponse.json(
{ error: 'Logo máximo 2 MB' }, { error: 'Logo máximo 15 MB' },
{ status: 400 } { status: 400 }
); );
} }
const mime = file.type || 'application/octet-stream';
if (!ALLOWED.has(mime)) { const rawMime = (file.type || '').split(';')[0].trim().toLowerCase();
const ext = (file.name || '').split('.').pop()?.toLowerCase() || '';
const isAllowed =
ALLOWED_MIMES.has(rawMime) ||
rawMime.startsWith('image/') ||
ALLOWED_EXTS.has(ext);
if (!isAllowed) {
return NextResponse.json( return NextResponse.json(
{ error: 'Usa PNG, JPG, WEBP o SVG' }, { error: 'Formato no soportado. Usa PNG, JPG, WEBP o SVG.' },
{ status: 400 } { status: 400 }
); );
} }
const resolvedMime =
rawMime && rawMime !== 'application/octet-stream'
? rawMime
: ext === 'svg'
? 'image/svg+xml'
: ext === 'webp'
? 'image/webp'
: ext === 'png'
? 'image/png'
: 'image/jpeg';
const bucketOk = await ensureAssetsBucket(); const bucketOk = await ensureAssetsBucket();
if (!bucketOk.ok) { if (!bucketOk.ok) {
return NextResponse.json( return NextResponse.json(
@@ -90,15 +120,51 @@ export async function POST(req: NextRequest) {
} }
const safe = sanitizeFileName(file.name || 'logo.png'); const safe = sanitizeFileName(file.name || 'logo.png');
const path = `${tenantId}/branding/${randomUUID()}_${safe}`; const assetId = randomUUID();
const folder = 'branding';
const path = `${tenantId}/${folder}/${assetId}_${safe}`;
const buf = Buffer.from(await file.arrayBuffer()); const buf = Buffer.from(await file.arrayBuffer());
const sha256 = createHash('sha256').update(buf).digest('hex');
const { error: upErr } = await sb.storage const { error: upErr } = await sb.storage
.from(ASSETS_BUCKET) .from(ASSETS_BUCKET)
.upload(path, buf, { contentType: mime, upsert: true }); .upload(path, buf, { contentType: resolvedMime, upsert: true });
if (upErr) { if (upErr) {
return NextResponse.json({ error: upErr.message }, { status: 500 }); return NextResponse.json({ error: upErr.message }, { status: 500 });
} }
// Register in asset_files table so it can be previewed/downloaded cleanly
try {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
await fetch(`${supabaseUrl()}/rest/v1/asset_files`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({
id: assetId,
tenant_id: tenantId,
title: safe,
folder,
file_name: safe,
mime_type: resolvedMime,
bytes: file.size,
storage_path: path,
sha256,
status: 'approved',
created_by: user?.id || null,
}),
});
} catch {
// Non-blocking
}
// Store storage path; served via /api/branding/logo // Store storage path; served via /api/branding/logo
const logoUrl = `storage:${path}`; const logoUrl = `storage:${path}`;
const patch = await fetch( const patch = await fetch(
@@ -125,6 +191,51 @@ export async function POST(req: NextRequest) {
); );
} }
// Also sync into documents (brandbook) kit_assets so it appears in Zona de marca
try {
const docRes = await fetch(
`${supabaseUrl()}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(tenantId)}&type=eq.${BRANDBOOK_DOC_TYPE}&select=id,content&order=updated_at.desc&limit=1`,
{ headers, cache: 'no-store' }
);
if (docRes.ok) {
const docs = await docRes.json();
const doc = docs[0];
if (doc) {
const book = parseBrandbook(doc.content);
if (book) {
const asset: BrandAsset = {
id: newId('kit'),
asset_file_id: assetId,
kind: 'logo',
role: 'primary',
label: safe,
file_name: safe,
mime_type: resolvedMime,
added_at: new Date().toISOString(),
};
book.kit_assets = [
asset,
...(book.kit_assets || []).filter((a) => a.role !== 'primary'),
];
await fetch(`${supabaseUrl()}/rest/v1/documents?id=eq.${doc.id}`, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({
content: book,
updated_at: new Date().toISOString(),
}),
});
}
}
}
} catch {
// Non-blocking
}
return NextResponse.json({ return NextResponse.json({
logoUrl, logoUrl,
serveUrl: `/api/branding/logo?tenantId=${encodeURIComponent(tenantId)}`, serveUrl: `/api/branding/logo?tenantId=${encodeURIComponent(tenantId)}`,

View File

@@ -235,6 +235,25 @@ export async function POST(req: NextRequest) {
); );
} }
if (kind === 'logo') {
try {
await fetch(
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({ logo_url: `storage:${storagePath}` }),
}
);
} catch {
// Non-blocking
}
}
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
asset, asset,

View File

@@ -4,7 +4,7 @@ import React, { useState, Suspense } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { Mail, Lock, Eye, EyeOff, ShieldCheck, ArrowRight, Loader2 } from 'lucide-react'; import { Mail, Lock, Eye, EyeOff, ArrowRight, Loader2 } from 'lucide-react';
function LoginForm() { function LoginForm() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -172,15 +172,6 @@ export default function LoginPage() {
> >
<LoginForm /> <LoginForm />
</Suspense> </Suspense>
<div className="mt-8 flex items-center justify-center gap-4 text-[11px] text-gray-500">
<div className="flex items-center gap-1.5">
<ShieldCheck className="w-3.5 h-3.5 text-emerald-500" />
<span>Aislamiento RLS multi-tenant</span>
</div>
<span>·</span>
<span>TLS 1.3</span>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -39,6 +39,7 @@ import {
listToLines, listToLines,
newId, newId,
normalizeHex, normalizeHex,
primaryLogo,
type BrandAssetKind, type BrandAssetKind,
type Brandbook, type Brandbook,
type BrandbookSectionId, type BrandbookSectionId,
@@ -503,14 +504,20 @@ export default function BrandZone({
} }
} }
async function uploadKit(file: File) { async function uploadKit(
file: File,
overrideKind?: BrandAssetKind,
overrideRole?: string
) {
const kind = overrideKind || kitKind;
const role = overrideRole || kitRole;
setKitUploading(true); setKitUploading(true);
setErr(null); setErr(null);
try { try {
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); fd.append('file', file);
fd.append('kind', kitKind); fd.append('kind', kind);
fd.append('role', kitRole); fd.append('role', role);
fd.append('label', file.name); fd.append('label', file.name);
const r = await fetch('/api/brandbook/kit', { method: 'POST', body: fd }); const r = await fetch('/api/brandbook/kit', { method: 'POST', body: fd });
const j = await r.json(); const j = await r.json();
@@ -519,7 +526,7 @@ export default function BrandZone({
setScore(j.completeness?.score ?? 0); setScore(j.completeness?.score ?? 0);
setMissing(j.completeness?.missing || []); setMissing(j.completeness?.missing || []);
setExists(true); setExists(true);
setOkMsg(`${ASSET_KIND_LABELS[kitKind]} añadido: ${file.name}`); setOkMsg(`${ASSET_KIND_LABELS[kind]} añadido: ${file.name}`);
setSection('visual'); setSection('visual');
} catch (e) { } catch (e) {
setErr(e instanceof Error ? e.message : 'Error kit'); setErr(e instanceof Error ? e.message : 'Error kit');
@@ -696,6 +703,77 @@ export default function BrandZone({
</div> </div>
</div> </div>
{/* Barra rápida de subida de archivos al kit */}
<div className="rounded-2xl border border-gray-200 bg-white p-3.5 shadow-2xs">
<div className="flex flex-col md:flex-row md:items-center gap-3">
<div className="flex items-center gap-2 text-xs font-bold text-gray-900 shrink-0">
<Upload className="w-4 h-4 text-brand-600" />
Añadir archivo al kit:
</div>
<div className="flex flex-wrap items-center gap-2 flex-1">
<select
className="rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-xs font-semibold text-gray-800 shadow-2xs focus:border-brand-500 focus:outline-hidden"
value={kitKind}
onChange={(e) => {
const k = e.target.value as BrandAssetKind;
setKitKind(k);
setKitRole(
k === 'logo'
? 'primary'
: k === 'font'
? 'body'
: k === 'image'
? 'moodboard'
: 'asset'
);
}}
>
{Object.entries(ASSET_KIND_LABELS).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
<input
className="rounded-lg border border-gray-300 bg-white px-2.5 py-1.5 text-xs text-gray-800 shadow-2xs focus:border-brand-500 focus:outline-hidden w-36"
placeholder="Rol (ej. primary, dark)"
value={kitRole}
onChange={(e) => setKitRole(e.target.value)}
/>
<label className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-brand-600 px-3.5 py-1.5 text-xs font-semibold text-white shadow-2xs hover:bg-brand-700 cursor-pointer disabled:opacity-50">
{kitUploading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Plus className="w-3.5 h-3.5" />
)}
{kitUploading ? 'Subiendo…' : 'Subir archivo'}
<input
type="file"
className="hidden"
accept={
kitKind === 'logo' || kitKind === 'image' || kitKind === 'icon'
? 'image/*,.svg,.pdf'
: kitKind === 'font'
? '.ttf,.otf,.woff,.woff2,.zip'
: kitKind === 'video'
? 'video/*'
: '*/*'
}
disabled={kitUploading}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void uploadKit(f);
e.target.value = '';
}}
/>
</label>
<span className="text-[11px] text-gray-500">
PNG/SVG para logos · TTF/WOFF para tipografías · PDFs o imágenes (máx. 80 MB)
</span>
</div>
</div>
</div>
{err ? ( {err ? (
<div className="rounded-xl border border-red-200 bg-red-50 p-3.5 text-xs font-medium text-red-700"> <div className="rounded-xl border border-red-200 bg-red-50 p-3.5 text-xs font-medium text-red-700">
{err} {err}
@@ -1394,6 +1472,65 @@ export default function BrandZone({
</p> </p>
</PanelCard> </PanelCard>
{/* Logotipo oficial de la marca */}
<PanelCard
eyebrow="Logotipo"
title="Logotipo oficial de la marca"
description="Sube el archivo oficial (SVG, PNG transparente o JPG) que se usará en cabeceras y creatividades."
accent="rose"
cols={1}
>
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 p-2">
<div className="flex items-center justify-center w-full sm:w-64 h-32 rounded-xl border border-dashed border-gray-300 bg-gray-50/80 p-3 overflow-hidden">
{primaryLogo(book) ? (
<KitThumb
assetFileId={primaryLogo(book)!.asset_file_id}
alt="Logo principal"
/>
) : (
<div className="text-center px-4">
<p className="text-xs font-medium text-gray-500">
Sin logotipo subido todavía
</p>
<p className="text-[10px] text-gray-400 mt-0.5">
SVG vectorial, PNG transparente o JPG
</p>
</div>
)}
</div>
<div className="flex-1 space-y-3 w-full">
<div className="text-xs text-gray-600 space-y-1">
<p>
<strong className="text-gray-900 font-semibold">Archivo actual:</strong>{' '}
{primaryLogo(book)?.label || 'Ninguno'}
</p>
<p className="text-[11px] text-gray-500 leading-relaxed">
Se sincroniza automáticamente con el menú del portal, la ficha del cliente y las plantillas de ads/Canva.
</p>
</div>
<label className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-brand-600 px-4 py-2 text-xs font-semibold text-white hover:bg-brand-700 cursor-pointer shadow-2xs disabled:opacity-50">
{kitUploading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
{kitUploading ? 'Subiendo logo…' : primaryLogo(book) ? 'Cambiar logotipo oficial' : 'Subir logotipo oficial'}
<input
type="file"
className="hidden"
accept="image/*,.svg"
disabled={kitUploading}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void uploadKit(f, 'logo', 'primary');
e.target.value = '';
}}
/>
</label>
</div>
</div>
</PanelCard>
<PanelCard <PanelCard
eyebrow="Estilo" eyebrow="Estilo"
title="Logo, foto y reglas de composición" title="Logo, foto y reglas de composición"
@@ -1494,10 +1631,24 @@ export default function BrandZone({
cols={1} cols={1}
> >
{book.kit_assets.length === 0 ? ( {book.kit_assets.length === 0 ? (
<p className="text-xs text-gray-500"> <div className="rounded-xl border border-dashed border-gray-300 bg-gray-50/70 p-6 text-center space-y-2">
Todavía no hay logos ni archivos. Usa «Añadir al kit» <p className="text-xs text-gray-500">
arriba. Todavía no hay logos ni archivos en el kit de marca.
</p> </p>
<label className="inline-flex items-center gap-1.5 rounded-lg bg-white border border-gray-300 px-3.5 py-1.5 text-xs font-semibold text-gray-700 shadow-2xs hover:bg-gray-50 cursor-pointer">
<Plus className="w-3.5 h-3.5 text-brand-600" />
Subir archivo al kit
<input
type="file"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void uploadKit(f);
e.target.value = '';
}}
/>
</label>
</div>
) : ( ) : (
<ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3"> <ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{book.kit_assets.map((a) => ( {book.kit_assets.map((a) => (

View File

@@ -13,6 +13,7 @@ import {
Shield, Shield,
Sparkles, Sparkles,
Trash2, Trash2,
Upload,
UserPlus, UserPlus,
Users, Users,
} from 'lucide-react'; } from 'lucide-react';
@@ -162,25 +163,32 @@ export default function ClientFicha({
setModules(suggestModulesFromIntegrations(integrations)); setModules(suggestModulesFromIntegrations(integrations));
} }
const [logoUploading, setLogoUploading] = useState(false);
async function onLogoFile(file: File | null) { async function onLogoFile(file: File | null) {
if (!file) return; if (!file) return;
setSaving(true); setLogoUploading(true);
setErr(null); setErr(null);
const fd = new FormData(); try {
fd.set('tenantId', tenantId); const fd = new FormData();
fd.set('file', file); fd.set('tenantId', tenantId);
const res = await fetch('/api/admin/tenants/logo', { fd.set('file', file);
method: 'POST', const res = await fetch('/api/admin/tenants/logo', {
body: fd, method: 'POST',
}); body: fd,
const data = await res.json(); });
setSaving(false); const data = await res.json();
if (!res.ok) { if (!res.ok) {
setErr(data.error || 'Error al subir logo'); setErr(data.error || 'Error al subir logo');
return; return;
}
setLogoUrl(data.logoUrl);
setMsg('Logo del cliente actualizado con éxito — visible en la barra lateral y en la ficha');
} catch {
setErr('Error de red al subir el logo');
} finally {
setLogoUploading(false);
} }
setLogoUrl(data.logoUrl);
setMsg('Logo actualizado — se muestra en la vista del cliente');
} }
async function clearLogo() { async function clearLogo() {
@@ -385,12 +393,29 @@ export default function ClientFicha({
</section> </section>
<section className="space-y-4 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm"> <section className="space-y-4 rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900"> <div className="flex items-center justify-between">
<ImageIcon className="w-4 h-4 text-brand-600" /> <div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
Logo en vista cliente <ImageIcon className="w-4 h-4 text-brand-600" />
Logo en vista cliente
</div>
{logoUrl ? (
<button
type="button"
onClick={() => void clearLogo()}
className="text-xs text-red-600 hover:underline font-medium"
>
Quitar logo
</button>
) : null}
</div> </div>
<div className="flex items-center justify-center h-28 rounded-xl border border-dashed border-gray-300 bg-gray-50 overflow-hidden">
{logoSrc ? ( <div className="relative flex items-center justify-center h-28 rounded-xl border border-dashed border-gray-300 bg-gray-50 overflow-hidden p-2">
{logoUploading ? (
<div className="flex flex-col items-center gap-1.5 text-xs text-brand-600 font-medium">
<Loader2 className="w-5 h-5 animate-spin" />
<span>Subiendo logo…</span>
</div>
) : logoSrc ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
src={logoSrc} src={logoSrc}
@@ -403,27 +428,29 @@ export default function ClientFicha({
</p> </p>
)} )}
</div> </div>
<label className="block">
<span className="sr-only">Subir logo</span> <label className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-brand-600 px-4 py-2.5 text-xs font-semibold text-white shadow-2xs hover:bg-brand-700 cursor-pointer disabled:opacity-50">
{logoUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Upload className="w-4 h-4" />
)}
{logoUploading ? 'Subiendo logo…' : logoUrl ? 'Cambiar logotipo' : 'Subir logotipo'}
<input <input
type="file" type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml" accept="image/*,.svg"
className="block w-full text-xs text-gray-600 file:mr-3 file:rounded-lg file:border-0 file:bg-brand-50 file:px-3 file:py-1.5 file:text-xs file:font-semibold file:text-brand-700" disabled={logoUploading}
onChange={(e) => void onLogoFile(e.target.files?.[0] || null)} className="hidden"
onChange={(e) => {
const f = e.target.files?.[0] || null;
void onLogoFile(f);
e.target.value = '';
}}
/> />
</label> </label>
{logoUrl ? (
<button <p className="text-[11px] text-gray-500 leading-relaxed">
type="button" PNG, JPG, SVG o WEBP · máx. 15 MB. Aparece automáticamente en el menú lateral y en el kit visual del cliente.
onClick={() => void clearLogo()}
className="text-xs text-red-600 hover:underline"
>
Quitar logo
</button>
) : null}
<p className="text-[11px] text-gray-600 leading-relaxed">
PNG/JPG/WEBP/SVG · máx. 2 MB. Aparece en el menú lateral cuando el
cliente (o tú en su contexto) abre el portal.
</p> </p>
</section> </section>
</div> </div>