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>
@@ -297,92 +309,105 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
{tab === 'portal' && ( {tab === 'portal' && (
<> <>
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end"> {isAgency ? (
<div className="flex-1"> <>
<label className="text-[11px] uppercase text-gray-500"> <div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end">
Carpeta <div className="flex-1">
</label> <label className="text-[11px] uppercase text-gray-500">
<input Carpeta
className="mt-1 w-full rounded-lg bg-gray-900 border border-gray-200 px-3 py-2 text-sm text-gray-900" </label>
placeholder="ej. creatividades/meta o vacío = raíz" <input
value={folder} className="mt-1 w-full rounded-lg bg-gray-900 border border-gray-200 px-3 py-2 text-sm text-gray-900"
onChange={(e) => setFolder(e.target.value)} placeholder="ej. creatividades/meta o vacío = raíz"
list="asset-folders" value={folder}
/> onChange={(e) => setFolder(e.target.value)}
<datalist id="asset-folders"> list="asset-folders"
{folders.map((f) => ( />
<option key={f} value={f} /> <datalist id="asset-folders">
))} {folders.map((f) => (
</datalist> <option key={f} value={f} />
</div> ))}
<label className="inline-flex items-center justify-center gap-2 rounded-lg bg-brand-500/90 hover:bg-brand-500 px-4 py-2.5 text-sm font-medium text-white cursor-pointer"> </datalist>
{uploading ? ( </div>
<Loader2 className="w-4 h-4 animate-spin" /> <label className="inline-flex items-center justify-center gap-2 rounded-lg bg-brand-500/90 hover:bg-brand-500 px-4 py-2.5 text-sm font-medium text-white cursor-pointer">
) : ( {uploading ? (
<Upload className="w-4 h-4" /> <Loader2 className="w-4 h-4 animate-spin" />
)} ) : (
Subir archivos <Upload className="w-4 h-4" />
<input )}
type="file" Subir archivos
className="hidden" <input
multiple type="file"
onChange={(e) => { className="hidden"
if (e.target.files) void uploadFiles(e.target.files); multiple
e.target.value = ''; onChange={(e) => {
}} if (e.target.files) void uploadFiles(e.target.files);
/> e.target.value = '';
</label> }}
<label className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-600 hover:text-gray-900 cursor-pointer"> />
Subir carpeta </label>
<input <label className="inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 px-4 py-2.5 text-sm text-gray-600 hover:text-gray-900 cursor-pointer">
type="file" Subir carpeta
className="hidden" <input
multiple type="file"
{...{ webkitdirectory: '', directory: '' }} className="hidden"
onChange={(e) => { multiple
if (e.target.files?.length) { {...{ webkitdirectory: '', directory: '' }}
const rel = onChange={(e) => {
(e.target.files[0] as File & { webkitRelativePath?: string }) if (e.target.files?.length) {
.webkitRelativePath || ''; const rel =
const top = rel.split('/')[0]; (
if (top && !folder) setFolder(top); e.target.files[0] as File & {
void uploadFiles(e.target.files); webkitRelativePath?: string;
} }
e.target.value = ''; ).webkitRelativePath || '';
}} const top = rel.split('/')[0];
/> if (top && !folder) setFolder(top);
</label> void uploadFiles(e.target.files);
</div> }
e.target.value = '';
}}
/>
</label>
</div>
<div <div
onDragEnter={(e) => { onDragEnter={(e) => {
e.preventDefault(); e.preventDefault();
setDrag(true); setDrag(true);
}} }}
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
onDragLeave={() => setDrag(false)} onDragLeave={() => setDrag(false)}
onDrop={(e) => { onDrop={(e) => {
e.preventDefault(); e.preventDefault();
setDrag(false); setDrag(false);
if (e.dataTransfer.files?.length) { if (e.dataTransfer.files?.length) {
void uploadFiles(e.dataTransfer.files); void uploadFiles(e.dataTransfer.files);
} }
}} }}
className={`rounded-xl border border-dashed px-6 py-10 text-center transition-colors ${ className={`rounded-xl border border-dashed px-6 py-10 text-center transition-colors ${
drag drag
? 'border-brand-500 bg-brand-500/10' ? 'border-brand-500 bg-brand-500/10'
: 'border-gray-200 bg-white' : 'border-gray-200 bg-white'
}`} }`}
> >
<FolderOpen className="w-8 h-8 text-gray-600 mx-auto mb-2" /> <FolderOpen className="w-8 h-8 text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Arrastra aquí cualquier archivo (máx. 80 MB c/u) Arrastra aquí cualquier archivo (máx. 80 MB c/u)
</p>
<p className="text-[11px] text-gray-500 mt-1">
Se guarda en portal y se refleja en Drive si el cliente está
enlazado
</p>
</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> </p>
<p className="text-[11px] text-gray-500 mt-1"> )}
Se guarda en portal y se refleja en Drive si el cliente está
enlazado
</p>
</div>
{folders.length > 0 && ( {folders.length > 0 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -481,14 +506,16 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
> >
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</button> </button>
<button {isAgency ? (
type="button" <button
onClick={() => remove(a.id)} type="button"
className="p-2 rounded-lg text-gray-500 hover:text-red-400 hover:bg-red-500/10" onClick={() => remove(a.id)}
title="Eliminar" className="p-2 rounded-lg text-gray-500 hover:text-red-400 hover:bg-red-500/10"
> title="Eliminar"
<Trash2 className="w-4 h-4" /> >
</button> <Trash2 className="w-4 h-4" />
</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);
});
}