Limit client asset library to app-generated folders only.

Agency still sees the full Drive tree and portal uploads; clients only see Pipeline-creatividades / calendarios / brandbook-style paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-13 17:48:47 +02:00
parent 1cf8e7c1bd
commit d3e383dd08
5 changed files with 310 additions and 109 deletions

View File

@@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole } from '@/lib/agency'; import { isAgencyRole, userRole } from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context'; import { resolveActiveTenant } from '@/lib/tenant-context';
import {
filterDriveEntriesForClientRoot,
isAppGeneratedDriveFolderName,
} from '@/lib/assets-client-scope';
import { import {
findClientesRootFolder, findClientesRootFolder,
getDriveDownloadUrl, getDriveDownloadUrl,
@@ -15,7 +19,33 @@ export const dynamic = 'force-dynamic';
const FOLDER_MIME = 'application/vnd.google-apps.folder'; const FOLDER_MIME = 'application/vnd.google-apps.folder';
/** GET — exact replica of the tenant's Drive folder tree (under Clientes). */ async function listAppGeneratedRootFolderIds(
clientRootId: string
): Promise<string[]> {
const rootFiles = await listFilesInFolder(clientRootId);
return rootFiles
.filter(
(f) =>
f.mimeType === FOLDER_MIME && isAppGeneratedDriveFolderName(f.name)
)
.map((f) => f.id);
}
async function isUnderAppGeneratedTree(
fileOrFolderId: string,
clientRootId: string,
appRootIds?: string[]
): Promise<boolean> {
const roots =
appRootIds ?? (await listAppGeneratedRootFolderIds(clientRootId));
for (const appId of roots) {
if (fileOrFolderId === appId) return true;
if (await isUnderDriveFolder(fileOrFolderId, appId)) return true;
}
return false;
}
/** GET — Drive tree for active tenant. Agency: full tree. Client: app folders only. */
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const supabase = await createClient(); const supabase = await createClient();
const { const {
@@ -33,7 +63,7 @@ export async function GET(req: NextRequest) {
); );
} }
void isAgencyRole(userRole(user)); const agency = isAgencyRole(userRole(user));
const downloadId = req.nextUrl.searchParams.get('download')?.trim(); const downloadId = req.nextUrl.searchParams.get('download')?.trim();
if (downloadId) { if (downloadId) {
@@ -52,6 +82,15 @@ export async function GET(req: NextRequest) {
{ status: 403 } { status: 403 }
); );
} }
if (!agency) {
const inApp = await isUnderAppGeneratedTree(downloadId, link.folderId);
if (!inApp) {
return NextResponse.json(
{ error: 'Archivo fuera del alcance del cliente' },
{ status: 403 }
);
}
}
const url = await getDriveDownloadUrl(downloadId); const url = await getDriveDownloadUrl(downloadId);
return NextResponse.json({ url }); return NextResponse.json({ url });
} catch (e) { } catch (e) {
@@ -67,6 +106,8 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ return NextResponse.json({
files: [], files: [],
linked: false, linked: false,
agency,
clientScope: agency ? 'full' : 'app_generated',
warning: warning:
'Sin carpeta Drive del cliente. En Agencia → auto-match bajo la carpeta «Clientes».', 'Sin carpeta Drive del cliente. En Agencia → auto-match bajo la carpeta «Clientes».',
}); });
@@ -88,6 +129,7 @@ export async function GET(req: NextRequest) {
const requested = req.nextUrl.searchParams.get('folderId')?.trim(); const requested = req.nextUrl.searchParams.get('folderId')?.trim();
let folderId = link.folderId; let folderId = link.folderId;
let appRootIds: string[] | undefined;
if (requested && requested !== link.folderId) { if (requested && requested !== link.folderId) {
const allowed = await isUnderDriveFolder(requested, link.folderId); const allowed = await isUnderDriveFolder(requested, link.folderId);
@@ -102,13 +144,32 @@ export async function GET(req: NextRequest) {
{ status: 403 } { status: 403 }
); );
} }
if (!agency) {
appRootIds = await listAppGeneratedRootFolderIds(link.folderId);
const inApp = await isUnderAppGeneratedTree(
requested,
link.folderId,
appRootIds
);
if (!inApp) {
return NextResponse.json(
{
error:
'Solo puedes abrir carpetas generadas por el Centro de Mando (creatividades, calendarios…).',
rootFolderId: link.folderId,
folderName: link.folderName,
},
{ status: 403 }
);
}
}
folderId = requested; folderId = requested;
} }
try { try {
const currentMeta = await getDriveFileMeta(folderId); const currentMeta = await getDriveFileMeta(folderId);
const files = await listFilesInFolder(folderId); const files = await listFilesInFolder(folderId);
const mapped = files let mapped = files
.map((f) => ({ .map((f) => ({
id: f.id, id: f.id,
name: f.name, name: f.name,
@@ -124,19 +185,34 @@ export async function GET(req: NextRequest) {
return a.name.localeCompare(b.name, 'es'); return a.name.localeCompare(b.name, 'es');
}); });
const atClientRoot = folderId === link.folderId;
if (!agency && atClientRoot) {
mapped = filterDriveEntriesForClientRoot(mapped);
if (!mapped.length && !scopeWarning) {
scopeWarning =
'Aún no hay carpetas generadas por la app (p. ej. Pipeline-creatividades). El resto del Drive solo lo ve la agencia.';
}
}
return NextResponse.json({ return NextResponse.json({
linked: true, linked: true,
agency,
clientScope: agency ? 'full' : 'app_generated',
rootFolderId: link.folderId, rootFolderId: link.folderId,
rootFolderName: link.folderName, rootFolderName: link.folderName,
folderId, folderId,
folderName: currentMeta?.name || link.folderName, folderName: currentMeta?.name || link.folderName,
driveOpenUrl: `https://drive.google.com/drive/folders/${encodeURIComponent(folderId)}`, driveOpenUrl: agency
? `https://drive.google.com/drive/folders/${encodeURIComponent(folderId)}`
: null,
files: mapped, files: mapped,
warning: scopeWarning, warning: scopeWarning,
}); });
} catch (e) { } catch (e) {
return NextResponse.json({ return NextResponse.json({
linked: true, linked: true,
agency,
clientScope: agency ? 'full' : 'app_generated',
rootFolderId: link.folderId, rootFolderId: link.folderId,
rootFolderName: link.folderName, rootFolderName: link.folderName,
folderId, folderId,

View File

@@ -15,6 +15,10 @@ import {
sanitizeFolder, sanitizeFolder,
serviceSupabase, serviceSupabase,
} from '@/lib/assets'; } from '@/lib/assets';
import {
filterPortalAssetsForClient,
isAppGeneratedPortalFolder,
} from '@/lib/assets-client-scope';
import { import {
ensureChildFolder, ensureChildFolder,
getTenantDriveFolderId, getTenantDriveFolderId,
@@ -64,12 +68,18 @@ export async function GET() {
const msg = await r.text(); const msg = await r.text();
return NextResponse.json({ return NextResponse.json({
assets: [], assets: [],
agency: true,
clientScope: 'full',
warning: msg.includes('does not exist') warning: msg.includes('does not exist')
? 'Aplicar sql/asset_files.sql' ? 'Aplicar sql/asset_files.sql'
: msg.slice(0, 200), : msg.slice(0, 200),
}); });
} }
return NextResponse.json({ assets: await r.json() }); return NextResponse.json({
assets: await r.json(),
agency: true,
clientScope: 'full',
});
} }
const supabase = await createClient(); const supabase = await createClient();
@@ -81,19 +91,27 @@ export async function GET() {
if (error) { if (error) {
return NextResponse.json({ return NextResponse.json({
assets: [], assets: [],
agency: false,
clientScope: 'app_generated',
warning: error.message, warning: error.message,
}); });
} }
return NextResponse.json({ assets: data ?? [] }); // Client JWT path: still filter to app-generated folders only
return NextResponse.json({
assets: filterPortalAssetsForClient(data ?? []),
agency: false,
clientScope: 'app_generated',
});
} }
/** POST multipart: files[] + optional folder */ /** POST multipart: files[] + optional folder */
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const g = await gate(); const g = await gate();
if ('error' in g && g.error) return g.error; if ('error' in g && g.error) return g.error;
const { user, ctx } = g as Awaited<ReturnType<typeof gate>> & { const { user, ctx, agency } = g as Awaited<ReturnType<typeof gate>> & {
user: { id: string }; user: { id: string };
ctx: { tenantId: string }; ctx: { tenantId: string };
agency: boolean;
}; };
const bucketOk = await ensureAssetsBucket(); const bucketOk = await ensureAssetsBucket();
@@ -115,6 +133,15 @@ export async function POST(req: NextRequest) {
const form = await req.formData(); const form = await req.formData();
const folder = sanitizeFolder(String(form.get('folder') || '')); const folder = sanitizeFolder(String(form.get('folder') || ''));
if (!agency && !isAppGeneratedPortalFolder(folder)) {
return NextResponse.json(
{
error:
'Como cliente solo puedes usar carpetas generadas por la app (creatividades, calendarios, brandbook…).',
},
{ status: 403 }
);
}
const files = form.getAll('files').filter((f): f is File => f instanceof File); const files = form.getAll('files').filter((f): f is File => f instanceof File);
if (!files.length) { if (!files.length) {
@@ -234,10 +261,18 @@ export async function POST(req: NextRequest) {
export async function DELETE(req: NextRequest) { export async function DELETE(req: NextRequest) {
const g = await gate(); const g = await gate();
if ('error' in g && g.error) return g.error; if ('error' in g && g.error) return g.error;
const { ctx } = g as Awaited<ReturnType<typeof gate>> & { const { ctx, agency } = g as Awaited<ReturnType<typeof gate>> & {
ctx: { tenantId: string }; ctx: { tenantId: string };
agency: boolean;
}; };
if (!agency) {
return NextResponse.json(
{ error: 'Solo la agencia puede eliminar archivos de la biblioteca' },
{ status: 403 }
);
}
const id = req.nextUrl.searchParams.get('id')?.trim(); const id = req.nextUrl.searchParams.get('id')?.trim();
if (!id) { if (!id) {
return NextResponse.json({ error: 'id requerido' }, { status: 400 }); return NextResponse.json({ error: 'id requerido' }, { status: 400 });

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { resolveActiveTenant } from '@/lib/tenant-context'; import { resolveActiveTenant } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency';
import AssetsLibrary from '@/components/AssetsLibrary'; import AssetsLibrary from '@/components/AssetsLibrary';
import { EmptyState, PageHeader } from '@/components/ui'; import { EmptyState, PageHeader } from '@/components/ui';
@@ -15,6 +16,7 @@ export default async function ActivosPage() {
if (!user) redirect('/login'); if (!user) redirect('/login');
const ctx = await resolveActiveTenant(user); const ctx = await resolveActiveTenant(user);
const agency = isAgencyRole(userRole(user));
if (ctx.portfolioWide) { if (ctx.portfolioWide) {
return ( return (
@@ -32,5 +34,5 @@ export default async function ActivosPage() {
); );
} }
return <AssetsLibrary tenantLabel={ctx.label} />; return <AssetsLibrary tenantLabel={ctx.label} isAgency={agency} />;
} }

View File

@@ -86,7 +86,13 @@ function iconHint(mime?: string | null) {
return 'FILE'; return 'FILE';
} }
export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string }) { export default function AssetsLibrary({
tenantLabel,
isAgency = false,
}: {
tenantLabel: string;
isAgency?: boolean;
}) {
const [tab, setTab] = useState<'portal' | 'drive'>('drive'); const [tab, setTab] = useState<'portal' | 'drive'>('drive');
const [assets, setAssets] = useState<Asset[]>([]); const [assets, setAssets] = useState<Asset[]>([]);
const [driveFiles, setDriveFiles] = useState<DriveFile[]>([]); const [driveFiles, setDriveFiles] = useState<DriveFile[]>([]);
@@ -257,8 +263,14 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
<PageHeader <PageHeader
eyebrow="Contenido" eyebrow="Contenido"
title="Activos" title="Activos"
description={`Réplica exacta de Drive · carpeta del cliente «${tenantLabel}» (bajo Clientes). Solo ese árbol; sin mezclar otros clientes.`} description={
actions={<SyncNowButton forceModule="activos" />} isAgency
? `Réplica completa de Drive · carpeta «${tenantLabel}» (bajo Clientes) + subidas del portal.`
: `Archivos generados por el Centro de Mando para «${tenantLabel}»: creatividades, calendarios y carpetas de la app. El resto del Drive solo lo ve la agencia.`
}
actions={
isAgency ? <SyncNowButton forceModule="activos" /> : undefined
}
/> />
<div className="flex gap-2"> <div className="flex gap-2">
@@ -272,7 +284,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
}`} }`}
> >
<HardDrive className="w-3.5 h-3.5" /> <HardDrive className="w-3.5 h-3.5" />
Google Drive {isAgency ? 'Google Drive' : 'Generados por la app (Drive)'}
{driveFolder ? ` · ${driveFolder}` : ''} {driveFolder ? ` · ${driveFolder}` : ''}
</button> </button>
<button <button
@@ -284,7 +296,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
: 'border-gray-200 text-gray-500' : 'border-gray-200 text-gray-500'
}`} }`}
> >
Subidas portal {isAgency ? 'Subidas portal' : 'Archivos del portal'}
</button> </button>
</div> </div>
@@ -296,6 +308,8 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
{err && <p className="text-xs text-red-400">{err}</p>} {err && <p className="text-xs text-red-400">{err}</p>}
{tab === 'portal' && ( {tab === 'portal' && (
<>
{isAgency ? (
<> <>
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end"> <div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end">
<div className="flex-1"> <div className="flex-1">
@@ -342,8 +356,11 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
onChange={(e) => { onChange={(e) => {
if (e.target.files?.length) { if (e.target.files?.length) {
const rel = const rel =
(e.target.files[0] as File & { webkitRelativePath?: string }) (
.webkitRelativePath || ''; e.target.files[0] as File & {
webkitRelativePath?: string;
}
).webkitRelativePath || '';
const top = rel.split('/')[0]; const top = rel.split('/')[0];
if (top && !folder) setFolder(top); if (top && !folder) setFolder(top);
void uploadFiles(e.target.files); void uploadFiles(e.target.files);
@@ -383,6 +400,14 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
enlazado enlazado
</p> </p>
</div> </div>
</>
) : (
<p className="text-xs text-gray-500 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
Solo archivos del portal en carpetas de la app (creatividades,
calendarios, brandbook…). Las subidas manuales las gestiona la
agencia.
</p>
)}
{folders.length > 0 && ( {folders.length > 0 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -481,6 +506,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
> >
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</button> </button>
{isAgency ? (
<button <button
type="button" type="button"
onClick={() => remove(a.id)} onClick={() => remove(a.id)}
@@ -489,6 +515,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
) : null}
</li> </li>
))} ))}
</ul> </ul>
@@ -518,7 +545,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
</button> </button>
</span> </span>
))} ))}
{driveOpenUrl && ( {isAgency && driveOpenUrl && (
<a <a
href={driveOpenUrl} href={driveOpenUrl}
target="_blank" target="_blank"
@@ -530,8 +557,9 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
)} )}
</div> </div>
<p className="text-[11px] text-gray-500"> <p className="text-[11px] text-gray-500">
Misma carpeta enlazada en Agencia (Clientes → {driveFolder || '…'}). {isAgency
Embed iframe no aplica a carpetas privadas; listamos el contenido vía API. ? `Misma carpeta enlazada en Agencia (Clientes → ${driveFolder || '…'}).`
: 'Solo carpetas creadas por la app (p. ej. Pipeline-creatividades). El resto del Drive del cliente no se muestra.'}
</p> </p>
{loading ? ( {loading ? (
<p className="text-sm text-gray-500">Cargando Drive…</p> <p className="text-sm text-gray-500">Cargando Drive…</p>

View File

@@ -0,0 +1,60 @@
/**
* What clients may see in Biblioteca de Activos.
* Agency sees the full Drive tree + all portal uploads.
* Clients only see folders/files produced by Centro de Mando.
*/
/** Drive folder names created (or reserved) by the app under the client root. */
const APP_DRIVE_FOLDER_RES =
/^(Pipeline-creatividades_|Calendarios?[_\s-]?|Creatividades[_\s-]?|Ads[_\s-]?creatividades|Centro[_\s-]?de[_\s-]?Mando|4Ventures[_\s-]?Portal|Portal[_\s-]?app)/i;
/** Portal asset_files.folder prefixes written by the app (not free-form agency dumps). */
const APP_PORTAL_FOLDER_PREFIXES = [
'brandbook',
'creatividades',
'calendarios',
'calendario',
'pipeline',
'pipeline-creatividades',
'ads',
'meta',
'centro-de-mando',
'kpis',
] as const;
export function isAppGeneratedDriveFolderName(name: string): boolean {
const n = (name || '').trim();
if (!n) return false;
return APP_DRIVE_FOLDER_RES.test(n);
}
export function isAppGeneratedPortalFolder(folder: string | null | undefined): boolean {
const f = String(folder || '')
.trim()
.replace(/^\/+|\/+$/g, '')
.toLowerCase();
if (!f) return false;
return APP_PORTAL_FOLDER_PREFIXES.some(
(p) => f === p || f.startsWith(`${p}/`)
);
}
export function filterPortalAssetsForClient<T extends { folder?: string | null }>(
assets: T[]
): T[] {
return assets.filter((a) => isAppGeneratedPortalFolder(a.folder));
}
export function filterDriveEntriesForClientRoot<
T extends { name: string; isFolder?: boolean; mimeType?: string },
>(files: T[]): T[] {
return files.filter((f) => {
const isFolder =
f.isFolder || f.mimeType === 'application/vnd.google-apps.folder';
if (!isFolder) {
// Loose files at client root are agency/manual — hide from client
return false;
}
return isAppGeneratedDriveFolderName(f.name);
});
}