fix: eliminar footer rls en login y habilitar subida y sincronizacion de logo de cliente
This commit is contained in:
@@ -12,18 +12,30 @@ import {
|
||||
sanitizeFileName,
|
||||
serviceSupabase,
|
||||
} 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 runtime = 'nodejs';
|
||||
|
||||
const MAX_LOGO = 2 * 1024 * 1024; // 2 MB
|
||||
const ALLOWED = new Set([
|
||||
const MAX_LOGO = 15 * 1024 * 1024; // 15 MB
|
||||
const ALLOWED_MIMES = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
'image/svg',
|
||||
'image/x-png',
|
||||
'image/pjpeg',
|
||||
]);
|
||||
const ALLOWED_EXTS = new Set(['png', 'jpg', 'jpeg', 'webp', 'svg']);
|
||||
|
||||
async function requireAgency() {
|
||||
const supabase = await createClient();
|
||||
@@ -64,18 +76,36 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
if (file.size > MAX_LOGO) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Logo máximo 2 MB' },
|
||||
{ error: 'Logo máximo 15 MB' },
|
||||
{ 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(
|
||||
{ error: 'Usa PNG, JPG, WEBP o SVG' },
|
||||
{ error: 'Formato no soportado. Usa PNG, JPG, WEBP o SVG.' },
|
||||
{ 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();
|
||||
if (!bucketOk.ok) {
|
||||
return NextResponse.json(
|
||||
@@ -90,15 +120,51 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
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 sha256 = createHash('sha256').update(buf).digest('hex');
|
||||
|
||||
const { error: upErr } = await sb.storage
|
||||
.from(ASSETS_BUCKET)
|
||||
.upload(path, buf, { contentType: mime, upsert: true });
|
||||
.upload(path, buf, { contentType: resolvedMime, upsert: true });
|
||||
if (upErr) {
|
||||
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
|
||||
const logoUrl = `storage:${path}`;
|
||||
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({
|
||||
logoUrl,
|
||||
serveUrl: `/api/branding/logo?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
|
||||
@@ -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({
|
||||
ok: true,
|
||||
asset,
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useState, Suspense } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
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() {
|
||||
const searchParams = useSearchParams();
|
||||
@@ -172,15 +172,6 @@ export default function LoginPage() {
|
||||
>
|
||||
<LoginForm />
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user