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 { 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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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