feat: ficha por cliente en portafolio (conexiones, accesos, logo, módulos)
Cada tenant tiene una ficha con plataformas, invitaciones por email, logo de marca y toggles de módulos visibles en el portal cliente. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
132
src/app/api/admin/tenants/logo/route.ts
Normal file
132
src/app/api/admin/tenants/logo/route.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import {
|
||||
ASSETS_BUCKET,
|
||||
ensureAssetsBucket,
|
||||
sanitizeFileName,
|
||||
serviceSupabase,
|
||||
} from '@/lib/assets';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const MAX_LOGO = 2 * 1024 * 1024; // 2 MB
|
||||
const ALLOWED = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
]);
|
||||
|
||||
async function requireAgency() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
|
||||
}
|
||||
if (!isAgencyRole(userRole(user))) {
|
||||
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
|
||||
}
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return {
|
||||
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
|
||||
};
|
||||
}
|
||||
return { headers };
|
||||
}
|
||||
|
||||
/** POST multipart: tenantId + file — upload logo and set tenants.logo_url */
|
||||
export async function POST(req: NextRequest) {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
const { headers } = gate as {
|
||||
headers: { apikey: string; Authorization: string };
|
||||
};
|
||||
|
||||
const form = await req.formData();
|
||||
const tenantId = String(form.get('tenantId') || '').trim();
|
||||
const file = form.get('file');
|
||||
if (!tenantId || !(file instanceof File)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'tenantId y file requeridos' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (file.size > MAX_LOGO) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Logo máximo 2 MB' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const mime = file.type || 'application/octet-stream';
|
||||
if (!ALLOWED.has(mime)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Usa PNG, JPG, WEBP o SVG' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const bucketOk = await ensureAssetsBucket();
|
||||
if (!bucketOk.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: bucketOk.error || 'bucket' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const sb = serviceSupabase();
|
||||
if (!sb) {
|
||||
return NextResponse.json({ error: 'no service role' }, { status: 503 });
|
||||
}
|
||||
|
||||
const safe = sanitizeFileName(file.name || 'logo.png');
|
||||
const path = `${tenantId}/branding/${randomUUID()}_${safe}`;
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
const { error: upErr } = await sb.storage
|
||||
.from(ASSETS_BUCKET)
|
||||
.upload(path, buf, { contentType: mime, upsert: true });
|
||||
if (upErr) {
|
||||
return NextResponse.json({ error: upErr.message }, { status: 500 });
|
||||
}
|
||||
|
||||
// Store storage path; served via /api/branding/logo
|
||||
const logoUrl = `storage:${path}`;
|
||||
const patch = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify({ logo_url: logoUrl }),
|
||||
}
|
||||
);
|
||||
if (!patch.ok) {
|
||||
const msg = await patch.text();
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: msg.includes('logo_url')
|
||||
? 'Aplicar sql/tenant_ficha.sql'
|
||||
: msg.slice(0, 300),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
logoUrl,
|
||||
serveUrl: `/api/branding/logo?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import type { TenantSettings } from '@/lib/tenant-modules';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -29,7 +30,74 @@ async function requireAgency() {
|
||||
return { headers };
|
||||
}
|
||||
|
||||
/** PATCH — follow/unfollow client or set notes. */
|
||||
/** GET ?tenantId= — full ficha payload */
|
||||
export async function GET(req: NextRequest) {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
const { headers } = gate as {
|
||||
headers: { apikey: string; Authorization: string };
|
||||
};
|
||||
|
||||
const tenantId = req.nextUrl.searchParams.get('tenantId')?.trim() || '';
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ error: 'tenantId requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const base = supabaseUrl();
|
||||
const [tRes, iRes] = await Promise.all([
|
||||
fetch(
|
||||
`${base}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=id,name,slug,logo_url,settings,agency_followed,agency_notes,created_at`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
fetch(
|
||||
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&select=provider,status,enabled,external_account_id,label,last_sync_at,error_message,meta`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
]);
|
||||
|
||||
if (!tRes.ok) {
|
||||
const msg = await tRes.text();
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: msg.includes('logo_url') || msg.includes('settings')
|
||||
? 'Aplicar sql/tenant_ficha.sql'
|
||||
: msg.slice(0, 300),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const tenants = await tRes.json();
|
||||
const tenant = tenants[0];
|
||||
if (!tenant) {
|
||||
return NextResponse.json({ error: 'Cliente no encontrado' }, { status: 404 });
|
||||
}
|
||||
|
||||
let integrations: unknown[] = [];
|
||||
let integWarning: string | null = null;
|
||||
if (iRes.ok) {
|
||||
integrations = await iRes.json();
|
||||
} else {
|
||||
integWarning = (await iRes.text()).slice(0, 200);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
tenant: {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
logoUrl: tenant.logo_url || null,
|
||||
settings: (tenant.settings || {}) as TenantSettings,
|
||||
followed: tenant.agency_followed !== false,
|
||||
notes: tenant.agency_notes || '',
|
||||
createdAt: tenant.created_at,
|
||||
},
|
||||
integrations,
|
||||
warning: integWarning,
|
||||
});
|
||||
}
|
||||
|
||||
/** PATCH — follow/notes + ficha fields (name, logoUrl, settings). */
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
@@ -51,9 +119,22 @@ export async function PATCH(req: NextRequest) {
|
||||
if (typeof body.notes === 'string') {
|
||||
patch.agency_notes = body.notes.trim() || null;
|
||||
}
|
||||
if (typeof body.name === 'string' && body.name.trim()) {
|
||||
patch.name = body.name.trim();
|
||||
}
|
||||
if (typeof body.logoUrl === 'string') {
|
||||
patch.logo_url = body.logoUrl.trim() || null;
|
||||
}
|
||||
if (body.logoUrl === null) {
|
||||
patch.logo_url = null;
|
||||
}
|
||||
if (body.settings && typeof body.settings === 'object') {
|
||||
patch.settings = body.settings as TenantSettings;
|
||||
}
|
||||
|
||||
if (!Object.keys(patch).length) {
|
||||
return NextResponse.json(
|
||||
{ error: 'followed o notes requeridos' },
|
||||
{ error: 'Nada que actualizar' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -74,9 +155,12 @@ export async function PATCH(req: NextRequest) {
|
||||
const msg = await r.text();
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: msg.includes('agency_followed')
|
||||
? 'Aplicar sql/agency_control.sql'
|
||||
: msg.slice(0, 300),
|
||||
error:
|
||||
msg.includes('logo_url') || msg.includes('settings')
|
||||
? 'Aplicar sql/tenant_ficha.sql en Supabase'
|
||||
: msg.includes('agency_followed')
|
||||
? 'Aplicar sql/agency_control.sql'
|
||||
: msg.slice(0, 300),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
|
||||
257
src/app/api/admin/tenants/users/route.ts
Normal file
257
src/app/api/admin/tenants/users/route.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { createClient as createServiceClient } from '@supabase/supabase-js';
|
||||
import {
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
async function requireAgency() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
|
||||
}
|
||||
if (!isAgencyRole(userRole(user))) {
|
||||
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
|
||||
}
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim();
|
||||
if (!key) {
|
||||
return {
|
||||
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
|
||||
};
|
||||
}
|
||||
const admin = createServiceClient(supabaseUrl(), key, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
return { admin, headers: serviceHeaders()! };
|
||||
}
|
||||
|
||||
function tenantOf(u: {
|
||||
app_metadata?: Record<string, unknown>;
|
||||
user_metadata?: Record<string, unknown>;
|
||||
}): string | null {
|
||||
return (
|
||||
(u.app_metadata?.tenant_id as string) ||
|
||||
(u.user_metadata?.tenant_id as string) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/** GET ?tenantId= — users with access to this client portal. */
|
||||
export async function GET(req: NextRequest) {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
const { admin } = gate as Awaited<ReturnType<typeof requireAgency>> & {
|
||||
admin: ReturnType<typeof createServiceClient>;
|
||||
};
|
||||
|
||||
const tenantId = req.nextUrl.searchParams.get('tenantId')?.trim() || '';
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ error: 'tenantId requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const users: {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
lastSignIn: string | null;
|
||||
}[] = [];
|
||||
|
||||
let page = 1;
|
||||
for (;;) {
|
||||
const { data, error } = await admin.auth.admin.listUsers({
|
||||
page,
|
||||
perPage: 200,
|
||||
});
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
for (const u of data.users) {
|
||||
if (tenantOf(u) !== tenantId) continue;
|
||||
users.push({
|
||||
id: u.id,
|
||||
email: u.email || '',
|
||||
role:
|
||||
(u.app_metadata?.role as string) ||
|
||||
(u.user_metadata?.role as string) ||
|
||||
'client',
|
||||
createdAt: u.created_at,
|
||||
lastSignIn: u.last_sign_in_at || null,
|
||||
});
|
||||
}
|
||||
if (data.users.length < 200) break;
|
||||
page += 1;
|
||||
if (page > 20) break;
|
||||
}
|
||||
|
||||
users.sort((a, b) => a.email.localeCompare(b.email));
|
||||
return NextResponse.json({ users });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST — invite email to client portal view.
|
||||
* Body: { tenantId, email, companyName? }
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
const { admin, headers } = gate as Awaited<
|
||||
ReturnType<typeof requireAgency>
|
||||
> & {
|
||||
admin: ReturnType<typeof createServiceClient>;
|
||||
headers: { apikey: string; Authorization: string };
|
||||
};
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const tenantId =
|
||||
typeof body.tenantId === 'string' ? body.tenantId.trim() : '';
|
||||
const email =
|
||||
typeof body.email === 'string' ? body.email.toLowerCase().trim() : '';
|
||||
if (!tenantId || !email || !email.includes('@')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'tenantId y email válidos requeridos' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let company =
|
||||
typeof body.companyName === 'string' ? body.companyName.trim() : '';
|
||||
if (!company) {
|
||||
const tr = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=name`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (tr.ok) {
|
||||
const rows = await tr.json();
|
||||
company = rows[0]?.name || 'Cliente';
|
||||
} else {
|
||||
company = 'Cliente';
|
||||
}
|
||||
}
|
||||
|
||||
const site =
|
||||
process.env.NEXT_PUBLIC_SITE_URL?.trim() || 'https://app.4ventures.es';
|
||||
|
||||
const { data: invited, error: inviteErr } =
|
||||
await admin.auth.admin.inviteUserByEmail(email, {
|
||||
redirectTo: `${site}/login`,
|
||||
data: {
|
||||
tenant_id: tenantId,
|
||||
role: 'client',
|
||||
company,
|
||||
},
|
||||
});
|
||||
|
||||
if (inviteErr) {
|
||||
// Fallback: create user with temp password if invite not configured
|
||||
if (
|
||||
inviteErr.message.toLowerCase().includes('sending') ||
|
||||
inviteErr.message.toLowerCase().includes('smtp') ||
|
||||
inviteErr.message.toLowerCase().includes('email')
|
||||
) {
|
||||
const temp =
|
||||
typeof body.password === 'string' && body.password.length >= 8
|
||||
? body.password
|
||||
: `4V-${Math.random().toString(36).slice(2, 10)}!aA1`;
|
||||
const { data: created, error: createErr } =
|
||||
await admin.auth.admin.createUser({
|
||||
email,
|
||||
password: temp,
|
||||
email_confirm: true,
|
||||
user_metadata: {
|
||||
tenant_id: tenantId,
|
||||
role: 'client',
|
||||
company,
|
||||
},
|
||||
app_metadata: {
|
||||
tenant_id: tenantId,
|
||||
role: 'client',
|
||||
company,
|
||||
},
|
||||
});
|
||||
if (createErr) {
|
||||
return NextResponse.json(
|
||||
{ error: createErr.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: created.user?.id,
|
||||
email,
|
||||
role: 'client',
|
||||
},
|
||||
invited: false,
|
||||
temporaryPassword: temp,
|
||||
hint: 'SMTP no configurado: se creó usuario con contraseña temporal. Compártela al cliente.',
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ error: inviteErr.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const uid = invited.user?.id;
|
||||
if (uid) {
|
||||
await admin.auth.admin.updateUserById(uid, {
|
||||
app_metadata: {
|
||||
tenant_id: tenantId,
|
||||
role: 'client',
|
||||
company,
|
||||
},
|
||||
user_metadata: {
|
||||
tenant_id: tenantId,
|
||||
role: 'client',
|
||||
company,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
user: { id: uid, email, role: 'client' },
|
||||
invited: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** DELETE — remove portal access (delete auth user). Body: { tenantId, userId } */
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
const { admin } = gate as Awaited<ReturnType<typeof requireAgency>> & {
|
||||
admin: ReturnType<typeof createServiceClient>;
|
||||
};
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const tenantId =
|
||||
typeof body.tenantId === 'string' ? body.tenantId.trim() : '';
|
||||
const userId = typeof body.userId === 'string' ? body.userId.trim() : '';
|
||||
if (!tenantId || !userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'tenantId y userId requeridos' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await admin.auth.admin.getUserById(userId);
|
||||
if (error || !data.user) {
|
||||
return NextResponse.json({ error: 'Usuario no encontrado' }, { status: 404 });
|
||||
}
|
||||
if (tenantOf(data.user) !== tenantId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'El usuario no pertenece a este cliente' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const del = await admin.auth.admin.deleteUser(userId);
|
||||
if (del.error) {
|
||||
return NextResponse.json({ error: del.error.message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
97
src/app/api/branding/logo/route.ts
Normal file
97
src/app/api/branding/logo/route.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
homeTenantId,
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
VIEW_TENANT_COOKIE,
|
||||
} from '@/lib/agency';
|
||||
import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* GET ?tenantId= — stream client logo (agency any tenant; client only own).
|
||||
* logo_url may be https://… or storage:{path}.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
const cookieStore = await cookies();
|
||||
const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null;
|
||||
const paramId = req.nextUrl.searchParams.get('tenantId')?.trim() || '';
|
||||
const ownId = homeTenantId(user);
|
||||
|
||||
let tenantId = paramId || viewId || ownId || '';
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ error: 'sin tenant' }, { status: 400 });
|
||||
}
|
||||
if (!agency && tenantId !== ownId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return NextResponse.json({ error: 'no service role' }, { status: 503 });
|
||||
}
|
||||
|
||||
const tr = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(tenantId)}&select=logo_url`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!tr.ok) {
|
||||
return NextResponse.json({ error: 'tenant' }, { status: 404 });
|
||||
}
|
||||
const rows = await tr.json();
|
||||
const logoUrl: string | null = rows[0]?.logo_url || null;
|
||||
if (!logoUrl) {
|
||||
return NextResponse.json({ error: 'sin logo' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (logoUrl.startsWith('http://') || logoUrl.startsWith('https://')) {
|
||||
return NextResponse.redirect(logoUrl);
|
||||
}
|
||||
|
||||
const path = logoUrl.startsWith('storage:')
|
||||
? logoUrl.slice('storage:'.length)
|
||||
: logoUrl;
|
||||
|
||||
const sb = serviceSupabase();
|
||||
if (!sb) {
|
||||
return NextResponse.json({ error: 'no service role' }, { status: 503 });
|
||||
}
|
||||
const { data, error } = await sb.storage.from(ASSETS_BUCKET).download(path);
|
||||
if (error || !data) {
|
||||
return NextResponse.json(
|
||||
{ error: error?.message || 'not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
const buf = Buffer.from(await data.arrayBuffer());
|
||||
const ext = path.split('.').pop()?.toLowerCase();
|
||||
const type =
|
||||
ext === 'png'
|
||||
? 'image/png'
|
||||
: ext === 'webp'
|
||||
? 'image/webp'
|
||||
: ext === 'svg'
|
||||
? 'image/svg+xml'
|
||||
: 'image/jpeg';
|
||||
return new NextResponse(buf, {
|
||||
headers: {
|
||||
'Content-Type': type,
|
||||
'Cache-Control': 'private, max-age=300',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import ClientIntegrationsPanel from '@/components/ClientIntegrationsPanel';
|
||||
import ClientFicha from '@/components/ClientFicha';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import {
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function ClienteIntegrationsPage({
|
||||
export default async function ClienteFichaPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -37,5 +37,5 @@ export default async function ClienteIntegrationsPage({
|
||||
}
|
||||
}
|
||||
|
||||
return <ClientIntegrationsPanel tenantId={id} tenantName={name} />;
|
||||
return <ClientFicha tenantId={id} initialName={name} />;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,21 @@ import React from 'react';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { logoutAction } from '@/app/auth/actions';
|
||||
import { cookies } from 'next/headers';
|
||||
import { VIEW_TENANT_NAME_COOKIE } from '@/lib/agency';
|
||||
import {
|
||||
VIEW_TENANT_COOKIE,
|
||||
VIEW_TENANT_NAME_COOKIE,
|
||||
homeTenantId,
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import DashboardShell from '@/components/DashboardShell';
|
||||
import {
|
||||
DashboardModuleId,
|
||||
TenantSettings,
|
||||
resolveTenantModules,
|
||||
} from '@/lib/tenant-modules';
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
@@ -15,13 +28,56 @@ export default async function DashboardLayout({
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
const role =
|
||||
user?.user_metadata?.role || user?.app_metadata?.role || 'client';
|
||||
const company = user?.user_metadata?.company || '4Ventures Central';
|
||||
const role = userRole(user);
|
||||
const company =
|
||||
(user?.user_metadata?.company as string) ||
|
||||
(user?.app_metadata?.company as string) ||
|
||||
'4Ventures Central';
|
||||
const email = user?.email || 'usuario@4ventures.es';
|
||||
const isAgency = role === 'admin' || role === 'team';
|
||||
const isAgency = isAgencyRole(role);
|
||||
const cookieStore = await cookies();
|
||||
const viewName = cookieStore.get(VIEW_TENANT_NAME_COOKIE)?.value;
|
||||
const viewId = cookieStore.get(VIEW_TENANT_COOKIE)?.value || null;
|
||||
const ownId = homeTenantId(user);
|
||||
|
||||
let enabledModules: Record<DashboardModuleId, boolean> | null = null;
|
||||
let clientLogoUrl: string | null = null;
|
||||
let brandLabel = company;
|
||||
|
||||
// Client users (and agency zoomed into a client for branding): load ficha settings
|
||||
const brandingTenantId = isAgency ? viewId : ownId;
|
||||
if (brandingTenantId) {
|
||||
const headers = serviceHeaders();
|
||||
if (headers) {
|
||||
const [tRes, iRes] = await Promise.all([
|
||||
fetch(
|
||||
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(brandingTenantId)}&select=name,logo_url,settings`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
fetch(
|
||||
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(brandingTenantId)}&select=provider,status,enabled`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
]);
|
||||
if (tRes.ok) {
|
||||
const rows = await tRes.json();
|
||||
const t = rows[0];
|
||||
if (t) {
|
||||
brandLabel = t.name || brandLabel;
|
||||
if (t.logo_url) {
|
||||
clientLogoUrl = `/api/branding/logo?tenantId=${encodeURIComponent(brandingTenantId)}`;
|
||||
}
|
||||
const integrations = iRes.ok ? await iRes.json() : [];
|
||||
if (!isAgency) {
|
||||
enabledModules = resolveTenantModules(
|
||||
(t.settings || {}) as TenantSettings,
|
||||
integrations || []
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
@@ -30,6 +86,11 @@ export default async function DashboardLayout({
|
||||
role={role}
|
||||
isAgency={isAgency}
|
||||
viewName={viewName}
|
||||
enabledModules={enabledModules}
|
||||
clientLogoUrl={
|
||||
isAgency && viewId ? clientLogoUrl : !isAgency ? clientLogoUrl : null
|
||||
}
|
||||
brandLabel={brandLabel}
|
||||
logoutAction={logoutAction}
|
||||
>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user