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:
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -296,6 +308,8 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
|
||||
{err && <p className="text-xs text-red-400">{err}</p>}
|
||||
|
||||
{tab === 'portal' && (
|
||||
<>
|
||||
{isAgency ? (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-end">
|
||||
<div className="flex-1">
|
||||
@@ -342,8 +356,11 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) {
|
||||
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];
|
||||
if (top && !folder) setFolder(top);
|
||||
void uploadFiles(e.target.files);
|
||||
@@ -383,6 +400,14 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
|
||||
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>
|
||||
)}
|
||||
|
||||
{folders.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -481,6 +506,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
{isAgency ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(a.id)}
|
||||
@@ -489,6 +515,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
|
||||
>
|
||||
<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>
|
||||
|
||||
60
src/lib/assets-client-scope.ts
Normal file
60
src/lib/assets-client-scope.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user