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 { isAgencyRole, userRole } from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import {
filterDriveEntriesForClientRoot,
isAppGeneratedDriveFolderName,
} from '@/lib/assets-client-scope';
import {
findClientesRootFolder,
getDriveDownloadUrl,
@@ -15,7 +19,33 @@ export const dynamic = 'force-dynamic';
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) {
const supabase = await createClient();
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();
if (downloadId) {
@@ -52,6 +82,15 @@ export async function GET(req: NextRequest) {
{ 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);
return NextResponse.json({ url });
} catch (e) {
@@ -67,6 +106,8 @@ export async function GET(req: NextRequest) {
return NextResponse.json({
files: [],
linked: false,
agency,
clientScope: agency ? 'full' : 'app_generated',
warning:
'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();
let folderId = link.folderId;
let appRootIds: string[] | undefined;
if (requested && requested !== link.folderId) {
const allowed = await isUnderDriveFolder(requested, link.folderId);
@@ -102,13 +144,32 @@ export async function GET(req: NextRequest) {
{ 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;
}
try {
const currentMeta = await getDriveFileMeta(folderId);
const files = await listFilesInFolder(folderId);
const mapped = files
let mapped = files
.map((f) => ({
id: f.id,
name: f.name,
@@ -124,19 +185,34 @@ export async function GET(req: NextRequest) {
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({
linked: true,
agency,
clientScope: agency ? 'full' : 'app_generated',
rootFolderId: link.folderId,
rootFolderName: link.folderName,
folderId,
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,
warning: scopeWarning,
});
} catch (e) {
return NextResponse.json({
linked: true,
agency,
clientScope: agency ? 'full' : 'app_generated',
rootFolderId: link.folderId,
rootFolderName: link.folderName,
folderId,

View File

@@ -15,6 +15,10 @@ import {
sanitizeFolder,
serviceSupabase,
} from '@/lib/assets';
import {
filterPortalAssetsForClient,
isAppGeneratedPortalFolder,
} from '@/lib/assets-client-scope';
import {
ensureChildFolder,
getTenantDriveFolderId,
@@ -64,12 +68,18 @@ export async function GET() {
const msg = await r.text();
return NextResponse.json({
assets: [],
agency: true,
clientScope: 'full',
warning: msg.includes('does not exist')
? 'Aplicar sql/asset_files.sql'
: 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();
@@ -81,19 +91,27 @@ export async function GET() {
if (error) {
return NextResponse.json({
assets: [],
agency: false,
clientScope: 'app_generated',
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 */
export async function POST(req: NextRequest) {
const g = await gate();
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 };
ctx: { tenantId: string };
agency: boolean;
};
const bucketOk = await ensureAssetsBucket();
@@ -115,6 +133,15 @@ export async function POST(req: NextRequest) {
const form = await req.formData();
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);
if (!files.length) {
@@ -234,10 +261,18 @@ export async function POST(req: NextRequest) {
export async function DELETE(req: NextRequest) {
const g = await gate();
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 };
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();
if (!id) {
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 { redirect } from 'next/navigation';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency';
import AssetsLibrary from '@/components/AssetsLibrary';
import { EmptyState, PageHeader } from '@/components/ui';
@@ -15,6 +16,7 @@ export default async function ActivosPage() {
if (!user) redirect('/login');
const ctx = await resolveActiveTenant(user);
const agency = isAgencyRole(userRole(user));
if (ctx.portfolioWide) {
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';
}
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 [assets, setAssets] = useState<Asset[]>([]);
const [driveFiles, setDriveFiles] = useState<DriveFile[]>([]);
@@ -257,8 +263,14 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
<PageHeader
eyebrow="Contenido"
title="Activos"
description={`Réplica exacta de Drive · carpeta del cliente «${tenantLabel}» (bajo Clientes). Solo ese árbol; sin mezclar otros clientes.`}
actions={<SyncNowButton forceModule="activos" />}
description={
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">
@@ -272,7 +284,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
}`}
>
<HardDrive className="w-3.5 h-3.5" />
Google Drive
{isAgency ? 'Google Drive' : 'Generados por la app (Drive)'}
{driveFolder ? ` · ${driveFolder}` : ''}
</button>
<button
@@ -284,7 +296,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
: 'border-gray-200 text-gray-500'
}`}
>
Subidas portal
{isAgency ? 'Subidas portal' : 'Archivos del portal'}
</button>
</div>
@@ -297,92 +309,105 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
{tab === 'portal' && (
<>
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end">
<div className="flex-1">
<label className="text-[11px] uppercase text-gray-500">
Carpeta
</label>
<input
className="mt-1 w-full rounded-lg bg-gray-900 border border-gray-200 px-3 py-2 text-sm text-gray-900"
placeholder="ej. creatividades/meta o vacío = raíz"
value={folder}
onChange={(e) => setFolder(e.target.value)}
list="asset-folders"
/>
<datalist id="asset-folders">
{folders.map((f) => (
<option key={f} value={f} />
))}
</datalist>
</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">
{uploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Upload className="w-4 h-4" />
)}
Subir archivos
<input
type="file"
className="hidden"
multiple
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
<input
type="file"
className="hidden"
multiple
{...{ webkitdirectory: '', directory: '' }}
onChange={(e) => {
if (e.target.files?.length) {
const rel =
(e.target.files[0] as File & { webkitRelativePath?: string })
.webkitRelativePath || '';
const top = rel.split('/')[0];
if (top && !folder) setFolder(top);
void uploadFiles(e.target.files);
}
e.target.value = '';
}}
/>
</label>
</div>
{isAgency ? (
<>
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end">
<div className="flex-1">
<label className="text-[11px] uppercase text-gray-500">
Carpeta
</label>
<input
className="mt-1 w-full rounded-lg bg-gray-900 border border-gray-200 px-3 py-2 text-sm text-gray-900"
placeholder="ej. creatividades/meta o vacío = raíz"
value={folder}
onChange={(e) => setFolder(e.target.value)}
list="asset-folders"
/>
<datalist id="asset-folders">
{folders.map((f) => (
<option key={f} value={f} />
))}
</datalist>
</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">
{uploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Upload className="w-4 h-4" />
)}
Subir archivos
<input
type="file"
className="hidden"
multiple
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
<input
type="file"
className="hidden"
multiple
{...{ webkitdirectory: '', directory: '' }}
onChange={(e) => {
if (e.target.files?.length) {
const rel =
(
e.target.files[0] as File & {
webkitRelativePath?: string;
}
).webkitRelativePath || '';
const top = rel.split('/')[0];
if (top && !folder) setFolder(top);
void uploadFiles(e.target.files);
}
e.target.value = '';
}}
/>
</label>
</div>
<div
onDragEnter={(e) => {
e.preventDefault();
setDrag(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => setDrag(false)}
onDrop={(e) => {
e.preventDefault();
setDrag(false);
if (e.dataTransfer.files?.length) {
void uploadFiles(e.dataTransfer.files);
}
}}
className={`rounded-xl border border-dashed px-6 py-10 text-center transition-colors ${
drag
? 'border-brand-500 bg-brand-500/10'
: 'border-gray-200 bg-white'
}`}
>
<FolderOpen className="w-8 h-8 text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-600">
Arrastra aquí cualquier archivo (máx. 80 MB c/u)
<div
onDragEnter={(e) => {
e.preventDefault();
setDrag(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => setDrag(false)}
onDrop={(e) => {
e.preventDefault();
setDrag(false);
if (e.dataTransfer.files?.length) {
void uploadFiles(e.dataTransfer.files);
}
}}
className={`rounded-xl border border-dashed px-6 py-10 text-center transition-colors ${
drag
? 'border-brand-500 bg-brand-500/10'
: 'border-gray-200 bg-white'
}`}
>
<FolderOpen className="w-8 h-8 text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-600">
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 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 && (
<div className="flex flex-wrap gap-2">
@@ -481,14 +506,16 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
>
<Download className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => remove(a.id)}
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>
{isAgency ? (
<button
type="button"
onClick={() => remove(a.id)}
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>
) : null}
</li>
))}
</ul>
@@ -518,7 +545,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
</button>
</span>
))}
{driveOpenUrl && (
{isAgency && driveOpenUrl && (
<a
href={driveOpenUrl}
target="_blank"
@@ -530,8 +557,9 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
)}
</div>
<p className="text-[11px] text-gray-500">
Misma carpeta enlazada en Agencia (Clientes → {driveFolder || '…'}).
Embed iframe no aplica a carpetas privadas; listamos el contenido vía API.
{isAgency
? `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>
{loading ? (
<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);
});
}