fix: Actualizar con branding de GLM y restringir acceso con Supabase.

This commit is contained in:
2026-06-26 16:20:20 -04:00
parent 4252e524f6
commit 0932ede688
35 changed files with 1059 additions and 285 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 436 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

+3 -12
View File
@@ -1,13 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs> <rect width="512" height="512" fill="transparent"/>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%"> <text x="256" y="310" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="142" font-weight="700" fill="#6B8294">G<tspan fill="#61B72A">L</tspan>M</text>
<stop offset="0%" stop-color="#3b82f6" />
<stop offset="100%" stop-color="#8b5cf6" />
</linearGradient>
</defs>
<rect width="100" height="100" rx="24" fill="url(#grad)" />
<rect x="25" y="25" width="20" height="20" rx="4" fill="#ffffff" />
<rect x="55" y="25" width="20" height="20" rx="4" fill="#ffffff" opacity="0.6" />
<rect x="25" y="55" width="20" height="20" rx="4" fill="#ffffff" opacity="0.6" />
<rect x="55" y="55" width="20" height="20" rx="4" fill="#ffffff" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 646 B

After

Width:  |  Height:  |  Size: 302 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

+8 -6
View File
@@ -3,12 +3,14 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tablero CDC</title> <title>Tablero CDC | GLM</title>
<meta name="description" content="Herramienta interna del Departamento de Diseño Creativo." /> <meta name="description" content="Herramienta interna de GomezLee Marketing para gestión de proyectos CDC." />
<meta name="author" content="CDC" /> <meta name="author" content="GomezLee Marketing" />
<link rel="icon" type="image/svg+xml" href="/tablero-cdc/favicon.svg" /> <link rel="icon" type="image/png" href="/tablero-cdc/glm-favicon.png?v=glm-logo-final-20260626" />
<script type="module" crossorigin src="/tablero-cdc/assets/index-BrJEDdGr.js"></script> <link rel="shortcut icon" type="image/png" href="/tablero-cdc/glm-favicon.png?v=glm-logo-final-20260626" />
<link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-CF6PF3ek.css"> <link rel="apple-touch-icon" href="/tablero-cdc/apple-touch-icon.png?v=glm-logo-final-20260626" />
<script type="module" crossorigin src="/tablero-cdc/assets/index-CYpFBqnj.js"></script>
<link rel="stylesheet" crossorigin href="/tablero-cdc/assets/index-DrD5IvZG.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+6 -4
View File
@@ -3,10 +3,12 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tablero CDC</title> <title>Tablero CDC | GLM</title>
<meta name="description" content="Herramienta interna del Departamento de Diseño Creativo." /> <meta name="description" content="Herramienta interna de GomezLee Marketing para gestión de proyectos CDC." />
<meta name="author" content="CDC" /> <meta name="author" content="GomezLee Marketing" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/png" href="%BASE_URL%glm-favicon.png?v=glm-logo-final-20260626" />
<link rel="shortcut icon" type="image/png" href="%BASE_URL%glm-favicon.png?v=glm-logo-final-20260626" />
<link rel="apple-touch-icon" href="%BASE_URL%apple-touch-icon.png?v=glm-logo-final-20260626" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

+3 -12
View File
@@ -1,13 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs> <rect width="512" height="512" fill="transparent"/>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%"> <text x="256" y="310" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="142" font-weight="700" fill="#6B8294">G<tspan fill="#61B72A">L</tspan>M</text>
<stop offset="0%" stop-color="#3b82f6" />
<stop offset="100%" stop-color="#8b5cf6" />
</linearGradient>
</defs>
<rect width="100" height="100" rx="24" fill="url(#grad)" />
<rect x="25" y="25" width="20" height="20" rx="4" fill="#ffffff" />
<rect x="55" y="25" width="20" height="20" rx="4" fill="#ffffff" opacity="0.6" />
<rect x="25" y="55" width="20" height="20" rx="4" fill="#ffffff" opacity="0.6" />
<rect x="55" y="55" width="20" height="20" rx="4" fill="#ffffff" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 646 B

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

+3 -7
View File
@@ -2,6 +2,7 @@ import React, { Component, ReactNode } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom"; import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AuthProvider, useAuth } from "@/context/AuthContext"; import { AuthProvider, useAuth } from "@/context/AuthContext";
import { LoginScreen } from "@/components/LoginScreen"; import { LoginScreen } from "@/components/LoginScreen";
import { GLMLogo } from "@/components/GLMLogo";
import BoardPage from "./pages/BoardPage"; import BoardPage from "./pages/BoardPage";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
@@ -62,13 +63,8 @@ function AuthGate() {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-background"> <div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-primary flex items-center justify-center animate-pulse"> <div className="animate-pulse">
<svg className="w-4 h-4 text-primary-foreground" fill="none" viewBox="0 0 24 24"> <GLMLogo showWordmark={false} />
<rect x="3" y="3" width="7" height="7" rx="1" fill="currentColor" />
<rect x="14" y="3" width="7" height="7" rx="1" fill="currentColor" opacity="0.5" />
<rect x="3" y="14" width="7" height="7" rx="1" fill="currentColor" opacity="0.5" />
<rect x="14" y="14" width="7" height="7" rx="1" fill="currentColor" />
</svg>
</div> </div>
<p className="text-sm text-muted-foreground">Cargando tablero</p> <p className="text-sm text-muted-foreground">Cargando tablero</p>
</div> </div>
+24
View File
@@ -0,0 +1,24 @@
import { cn } from "@/lib/utils";
type GLMLogoProps = {
size?: "sm" | "lg";
showWordmark?: boolean;
className?: string;
};
export function GLMLogo({ size = "sm", className }: GLMLogoProps) {
const imageSize = size === "lg" ? "h-auto w-[168px]" : "h-auto w-[104px]";
const logoNudge = size === "lg" ? "-translate-x-1" : "";
return (
<div className={cn("inline-flex items-center", className)} aria-label="GLM">
<img
src={`${import.meta.env.BASE_URL}glm-logo.png`}
alt="GLM"
className={cn(imageSize, logoNudge)}
loading="eager"
decoding="async"
/>
</div>
);
}
+5 -9
View File
@@ -1,6 +1,6 @@
import { LayoutGrid } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { GLMLogo } from "@/components/GLMLogo";
export function LoginScreen() { export function LoginScreen() {
const { loginWithGoogle, error, loading } = useAuth(); const { loginWithGoogle, error, loading } = useAuth();
@@ -9,13 +9,9 @@ export function LoginScreen() {
<div className="min-h-screen bg-background flex items-center justify-center px-4"> <div className="min-h-screen bg-background flex items-center justify-center px-4">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
{/* Logo / marca */} {/* Logo / marca */}
<div className="flex flex-col items-center mb-8 gap-3"> <div className="flex flex-col items-center mb-8 gap-5">
<div className="w-14 h-14 rounded-2xl bg-primary flex items-center justify-center shadow-lg"> <GLMLogo size="lg" />
<LayoutGrid className="w-7 h-7 text-primary-foreground" /> <h1 className="text-2xl font-bold tracking-tight text-[#4F758B]">Tablero CDC</h1>
</div>
<div className="text-center leading-tight">
<h1 className="text-2xl font-bold tracking-tight">Tablero CDC</h1>
</div>
</div> </div>
{/* Card */} {/* Card */}
@@ -43,7 +39,7 @@ export function LoginScreen() {
</div> </div>
<p className="text-center text-xs text-muted-foreground mt-6"> <p className="text-center text-xs text-muted-foreground mt-6">
Solo acceso para usuarios <span className="font-medium">@gomezleemarketing.com</span> Acceso restringido a usuarios autorizados
</p> </p>
</div> </div>
</div> </div>
+1 -6
View File
@@ -63,11 +63,7 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
const [manualName, setManualName] = useState<string>(""); const [manualName, setManualName] = useState<string>("");
const [manualDescription, setManualDescription] = useState<string>(""); const [manualDescription, setManualDescription] = useState<string>("");
const [manualAmount, setManualAmount] = useState<string>(""); const [manualAmount, setManualAmount] = useState<string>("");
const { const { catalog: tariffCatalog, loading: tariffLoading, error: tariffError } = useTariffCatalog();
catalog: tariffCatalog,
loading: tariffLoading,
error: tariffError,
} = useTariffCatalog();
const sectionOptions = TARIFF_SECTIONS.map((option) => option.label); const sectionOptions = TARIFF_SECTIONS.map((option) => option.label);
const selectedSectionLabel = TARIFF_SECTIONS.find((option) => option.id === section)?.label || ""; const selectedSectionLabel = TARIFF_SECTIONS.find((option) => option.id === section)?.label || "";
@@ -237,7 +233,6 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
</div> </div>
)} )}
{(loading || tariffLoading) && ( {(loading || tariffLoading) && (
<div className="mb-3 rounded-xl border border-border bg-card px-3 py-2 text-xs text-muted-foreground"> <div className="mb-3 rounded-xl border border-border bg-card px-3 py-2 text-xs text-muted-foreground">
{loading ? "Cargando costos guardados…" : "Cargando tarifario…"} {loading ? "Cargando costos guardados…" : "Cargando tarifario…"}
+36 -26
View File
@@ -9,19 +9,7 @@ import React, {
} from "react"; } from "react";
import type { User as SupabaseUser } from "@supabase/supabase-js"; import type { User as SupabaseUser } from "@supabase/supabase-js";
import { supabase } from "@/lib/supabase"; import { supabase } from "@/lib/supabase";
import { getActiveTableroCdcAccess, type TableroCdcUserAccess } from "@/lib/accessControl";
const ALLOWED_DOMAIN = "gomezleemarketing.com";
const GERARDO_EMAIL = "gmarrero@gomezleemarketing.com";
const JOSE_LEOPOLDO_EMAIL = "jgomez@gomezleemarketing.com";
const ISAAC_EMAIL = "iaracena@gomezleemarketing.com";
const INTERNAL_PRICING_ALLOWED_EMAILS = [GERARDO_EMAIL, ISAAC_EMAIL];
const PRICING_SUMMARY_CONTROLLER_EMAILS = [GERARDO_EMAIL, JOSE_LEOPOLDO_EMAIL, ISAAC_EMAIL];
const DELETE_ALLOWED_EMAILS = [GERARDO_EMAIL, "iaracena@gomezleemarketing.com"];
function isAllowedEmail(email: string | null | undefined): boolean {
if (!email) return false;
return email.toLowerCase().endsWith(`@${ALLOWED_DOMAIN}`);
}
function getRedirectTo(): string { function getRedirectTo(): string {
if (typeof window === "undefined") return "/tablero-cdc/"; if (typeof window === "undefined") return "/tablero-cdc/";
@@ -75,23 +63,44 @@ const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null); const [user, setUser] = useState<AuthUser | null>(null);
const [access, setAccess] = useState<TableroCdcUserAccess | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const applyUser = useCallback(async (supabaseUser: SupabaseUser | null) => { const applyUser = useCallback(async (supabaseUser: SupabaseUser | null) => {
if (!supabaseUser) { if (!supabaseUser) {
setUser(null); setUser(null);
setAccess(null);
return; return;
} }
if (!isAllowedEmail(supabaseUser.email)) { let nextAccess: TableroCdcUserAccess | null = null;
try {
nextAccess = await getActiveTableroCdcAccess(supabaseUser.email);
} catch (accessError) {
console.error("Error al validar acceso al Tablero CDC:", accessError);
await supabase.auth.signOut(); await supabase.auth.signOut();
setUser(null); setUser(null);
setError("Solo se permite acceso con correos corporativos de GomezLee Marketing."); setAccess(null);
setError(
"No se pudo validar tu acceso al Tablero CDC. Verifica que el SQL de accesos esté ejecutado o contacta a IT.",
);
return;
}
if (!nextAccess) {
await supabase.auth.signOut();
setUser(null);
setAccess(null);
setError(
"Tu correo no está autorizado para acceder al Tablero CDC. Solicita acceso a IT o al equipo CDC.",
);
return; return;
} }
setUser(normalizeSupabaseUser(supabaseUser)); setUser(normalizeSupabaseUser(supabaseUser));
setAccess(nextAccess);
setError(null); setError(null);
}, []); }, []);
@@ -123,8 +132,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}); });
const { data: listener } = supabase.auth.onAuthStateChange((_event, session) => { const { data: listener } = supabase.auth.onAuthStateChange((_event, session) => {
void applyUser(session?.user ?? null); setLoading(true);
setLoading(false); void applyUser(session?.user ?? null).finally(() => {
if (mounted) setLoading(false);
});
}); });
return () => { return () => {
@@ -142,7 +153,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
redirectTo: getRedirectTo(), redirectTo: getRedirectTo(),
queryParams: { queryParams: {
prompt: "select_account", prompt: "select_account",
hd: ALLOWED_DOMAIN,
}, },
}, },
}); });
@@ -163,22 +173,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
setUser(null); setUser(null);
setAccess(null);
setError(null); setError(null);
}, []); }, []);
const normalizedEmail = user?.email?.toLowerCase() || ""; const isGerardo = useMemo(() => access?.role === "director_creativo", [access?.role]);
const isGerardo = useMemo(() => normalizedEmail === GERARDO_EMAIL, [normalizedEmail]);
const canManageInternalPricing = useMemo( const canManageInternalPricing = useMemo(
() => INTERNAL_PRICING_ALLOWED_EMAILS.includes(normalizedEmail), () => access?.canManageInternalPricing === true,
[normalizedEmail], [access?.canManageInternalPricing],
); );
const canDeleteProjects = useMemo( const canDeleteProjects = useMemo(
() => DELETE_ALLOWED_EMAILS.includes(normalizedEmail), () => access?.canDeleteProjects === true,
[normalizedEmail], [access?.canDeleteProjects],
); );
const canControlPricingSummary = useMemo( const canControlPricingSummary = useMemo(
() => PRICING_SUMMARY_CONTROLLER_EMAILS.includes(normalizedEmail), () => access?.canControlPricingSummary === true,
[normalizedEmail], [access?.canControlPricingSummary],
); );
return ( return (
+80
View File
@@ -0,0 +1,80 @@
import { supabase } from "@/lib/supabase";
export type TableroCdcUserAccess = {
email: string;
fullName: string | null;
role: string;
isActive: boolean;
canDeleteProjects: boolean;
canManageInternalPricing: boolean;
canControlPricingSummary: boolean;
};
type AccessRow = {
email?: string | null;
full_name?: string | null;
role?: string | null;
is_active?: boolean | null;
can_delete_projects?: boolean | null;
can_manage_internal_pricing?: boolean | null;
can_control_pricing_summary?: boolean | null;
};
function normalizeEmail(email: string | null | undefined): string {
return String(email ?? "")
.trim()
.toLowerCase();
}
function normalizeAccessRow(row: AccessRow | null | undefined): TableroCdcUserAccess | null {
if (!row?.email || row.is_active !== true) return null;
return {
email: normalizeEmail(row.email),
fullName: row.full_name ?? null,
role: String(row.role || "user").trim() || "user",
isActive: true,
canDeleteProjects: row.can_delete_projects === true,
canManageInternalPricing: row.can_manage_internal_pricing === true,
canControlPricingSummary: row.can_control_pricing_summary === true,
};
}
export async function getActiveTableroCdcAccess(
email: string | null | undefined,
): Promise<TableroCdcUserAccess | null> {
const normalizedEmail = normalizeEmail(email);
if (!normalizedEmail) return null;
const { data, error } = await supabase
.from("tablero_cdc_allowed_users")
.select(
"email, full_name, role, is_active, can_delete_projects, can_manage_internal_pricing, can_control_pricing_summary",
)
.eq("email", normalizedEmail)
.eq("is_active", true)
.maybeSingle();
if (error) {
throw error;
}
return normalizeAccessRow(data as AccessRow | null);
}
export async function getCurrentTableroCdcAccess(): Promise<TableroCdcUserAccess> {
const { data, error } = await supabase.auth.getUser();
if (error || !data.user) {
throw new Error("No hay una sesión activa de Supabase.");
}
const access = await getActiveTableroCdcAccess(data.user.email);
if (!access) {
throw new Error("Tu correo no está autorizado para acceder al Tablero CDC.");
}
return access;
}
+20 -25
View File
@@ -53,9 +53,7 @@ export function usePricingSummaryVisibility(enabled = true) {
} catch (err) { } catch (err) {
console.error("No se pudo leer la visibilidad del total tarifado:", err); console.error("No se pudo leer la visibilidad del total tarifado:", err);
setError( setError(
err instanceof Error err instanceof Error ? err.message : "No se pudo leer la visibilidad del total tarifado.",
? err.message
: "No se pudo leer la visibilidad del total tarifado.",
); );
return false; return false;
} finally { } finally {
@@ -63,29 +61,26 @@ export function usePricingSummaryVisibility(enabled = true) {
} }
}, [enabled]); }, [enabled]);
const setPublicVisibility = useCallback( const setPublicVisibility = useCallback(async (nextIsPublic: boolean) => {
async (nextIsPublic: boolean) => { setSaving(true);
setSaving(true); setError(null);
setError(null);
try { try {
await savePricingSummaryPublicVisibility(nextIsPublic); await savePricingSummaryPublicVisibility(nextIsPublic);
setIsPublic(nextIsPublic); setIsPublic(nextIsPublic);
return nextIsPublic; return nextIsPublic;
} catch (err) { } catch (err) {
console.error("No se pudo actualizar la visibilidad del total tarifado:", err); console.error("No se pudo actualizar la visibilidad del total tarifado:", err);
setError( setError(
err instanceof Error err instanceof Error
? err.message ? err.message
: "No se pudo actualizar la visibilidad del total tarifado.", : "No se pudo actualizar la visibilidad del total tarifado.",
); );
throw err; throw err;
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, }, []);
[],
);
useEffect(() => { useEffect(() => {
void refresh(); void refresh();
+7 -9
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase"; import { supabase } from "@/lib/supabase";
import { getCurrentTableroCdcAccess } from "@/lib/accessControl";
import type { ColorId, Status } from "@/data/lists"; import type { ColorId, Status } from "@/data/lists";
export interface Project { export interface Project {
@@ -178,9 +179,6 @@ type DirectProjectSummaryRow = {
const DEFAULT_COLOR: ColorId = "blue"; const DEFAULT_COLOR: ColorId = "blue";
const OPEN_STATUS = "Activo"; const OPEN_STATUS = "Activo";
const GERARDO_EMAIL = "gmarrero@gomezleemarketing.com";
const INTERNAL_PRICING_ALLOWED_EMAILS = [GERARDO_EMAIL, "iaracena@gomezleemarketing.com"];
const DELETE_ALLOWED_EMAILS = [GERARDO_EMAIL, "iaracena@gomezleemarketing.com"];
const DEFAULT_PROJECT_PARAMS: ProjectQueryParams = { const DEFAULT_PROJECT_PARAMS: ProjectQueryParams = {
tab: "todos", tab: "todos",
search: "", search: "",
@@ -478,18 +476,18 @@ async function getCurrentUserId(): Promise<string> {
} }
async function currentUserIsGerardo(): Promise<boolean> { async function currentUserIsGerardo(): Promise<boolean> {
const user = await getCurrentUser(); const access = await getCurrentTableroCdcAccess();
return user.email?.toLowerCase() === GERARDO_EMAIL; return access.role === "director_creativo";
} }
async function currentUserCanManageInternalPricing(): Promise<boolean> { async function currentUserCanManageInternalPricing(): Promise<boolean> {
const user = await getCurrentUser(); const access = await getCurrentTableroCdcAccess();
return INTERNAL_PRICING_ALLOWED_EMAILS.includes(user.email?.toLowerCase() || ""); return access.canManageInternalPricing;
} }
async function currentUserCanDeleteProjects(): Promise<boolean> { async function currentUserCanDeleteProjects(): Promise<boolean> {
const user = await getCurrentUser(); const access = await getCurrentTableroCdcAccess();
return DELETE_ALLOWED_EMAILS.includes(user.email?.toLowerCase() || ""); return access.canDeleteProjects;
} }
function toProjectRow(project: Omit<Project, "id" | "createdAt"> | Partial<Project>) { function toProjectRow(project: Omit<Project, "id" | "createdAt"> | Partial<Project>) {
+5 -8
View File
@@ -33,6 +33,7 @@ import { usePricingSummaryVisibility } from "@/lib/pricingSummaryVisibility";
import { useAppLists } from "@/lib/appLists"; import { useAppLists } from "@/lib/appLists";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { dedupeOptions } from "@/lib/optionUtils"; import { dedupeOptions } from "@/lib/optionUtils";
import { GLMLogo } from "@/components/GLMLogo";
/** Extract up-to-2-letter initials from a display name or email */ /** Extract up-to-2-letter initials from a display name or email */
const PROJECTS_PER_PAGE = 24; const PROJECTS_PER_PAGE = 24;
@@ -221,14 +222,10 @@ export default function BoardPage() {
<header className="sticky top-0 z-30 bg-background/85 backdrop-blur-md border-b border-border"> <header className="sticky top-0 z-30 bg-background/85 backdrop-blur-md border-b border-border">
<div className="max-w-[1400px] mx-auto px-6 py-3.5 flex items-center gap-4"> <div className="max-w-[1400px] mx-auto px-6 py-3.5 flex items-center gap-4">
{/* Brand */} {/* Brand */}
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-4">
<div className="w-9 h-9 rounded-xl bg-primary flex items-center justify-center"> <GLMLogo size="sm" />
<LayoutGrid className="w-4 h-4 text-primary-foreground" /> <div className="h-7 w-px bg-border" />
</div> <h1 className="text-[15px] font-semibold tracking-tight text-[#4F758B]">Tablero CDC</h1>
<div className="leading-tight">
<h1 className="text-[15px] font-semibold tracking-tight">Tablero CDC</h1>
<p className="text-[11px] text-muted-foreground">Flujo de Proyectos</p>
</div>
</div> </div>
{/* Search */} {/* Search */}
+40 -31
View File
@@ -64,48 +64,57 @@
:root { :root {
--radius: 0.875rem; --radius: 0.875rem;
/* Google-like clean palette + soft purple accent */ /* GLM palette soft, clean and low-noise for the existing CDC UI */
--background: oklch(0.99 0.003 280); --glm-blue: #4f758b;
--foreground: oklch(0.25 0.02 280); --glm-blue-medium: #5b7f95;
--glm-steel: #6b8fa3;
--glm-teal: #2c6e6f;
--glm-green: #6cc24a;
--glm-green-soft: #eef6e8;
--background: oklch(0.99 0.004 131);
--foreground: oklch(0.4091 0 89.9);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.25 0.02 280); --card-foreground: oklch(0.4091 0 89.9);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.25 0.02 280); --popover-foreground: oklch(0.4091 0 89.9);
--primary: oklch(0.55 0.18 290); --primary: oklch(0.5427 0.055 234.6);
--primary-foreground: oklch(0.99 0.005 280); --primary-foreground: oklch(1 0 89.9);
--secondary: oklch(0.97 0.005 280); --secondary: oklch(0.9702 0 89.9);
--secondary-foreground: oklch(0.3 0.02 280); --secondary-foreground: oklch(0.4091 0 89.9);
--muted: oklch(0.965 0.004 280); --muted: oklch(0.9702 0 89.9);
--muted-foreground: oklch(0.5 0.015 280); --muted-foreground: oklch(0.6304 0.0501 232.9);
--accent: oklch(0.96 0.02 290); --accent: oklch(0.9633 0.0203 131.5);
--accent-foreground: oklch(0.35 0.05 290); --accent-foreground: oklch(0.4968 0.0663 196.6);
--destructive: oklch(0.6 0.22 27); --destructive: oklch(0.6 0.22 27);
--destructive-foreground: oklch(0.99 0 0); --destructive-foreground: oklch(0.99 0 0);
--border: oklch(0.925 0.005 280); --border: oklch(0.9 0.01 235);
--input: oklch(0.93 0.005 280); --input: oklch(0.91 0.01 235);
--ring: oklch(0.65 0.15 290); --ring: oklch(0.7344 0.1772 137.9);
--chart-1: oklch(0.65 0.18 250); --chart-1: oklch(0.5427 0.055 234.6);
--chart-2: oklch(0.65 0.18 150); --chart-2: oklch(0.7344 0.1772 137.9);
--chart-3: oklch(0.7 0.16 80); --chart-3: oklch(0.5778 0.0529 235.7);
--chart-4: oklch(0.6 0.2 20); --chart-4: oklch(0.7013 0.1996 43.7);
--chart-5: oklch(0.55 0.18 290); --chart-5: oklch(0.787 0.1232 161.2);
--sidebar: oklch(0.985 0.003 280); --sidebar: oklch(0.985 0.004 131);
--sidebar-foreground: oklch(0.25 0.02 280); --sidebar-foreground: oklch(0.4091 0 89.9);
--sidebar-primary: oklch(0.55 0.18 290); --sidebar-primary: oklch(0.5427 0.055 234.6);
--sidebar-primary-foreground: oklch(0.99 0 0); --sidebar-primary-foreground: oklch(1 0 89.9);
--sidebar-accent: oklch(0.96 0.02 290); --sidebar-accent: oklch(0.9633 0.0203 131.5);
--sidebar-accent-foreground: oklch(0.35 0.05 290); --sidebar-accent-foreground: oklch(0.4968 0.0663 196.6);
--sidebar-border: oklch(0.925 0.005 280); --sidebar-border: oklch(0.9 0.01 235);
--sidebar-ring: oklch(0.65 0.15 290); --sidebar-ring: oklch(0.7344 0.1772 137.9);
--shadow-soft: 0 1px 2px oklch(0.5 0.02 280 / 0.06), 0 4px 12px oklch(0.5 0.02 280 / 0.05); --shadow-soft:
--shadow-hover: 0 4px 8px oklch(0.5 0.02 280 / 0.08), 0 12px 28px oklch(0.5 0.02 280 / 0.10); 0 1px 2px oklch(0.5427 0.055 234.6 / 0.06), 0 4px 14px oklch(0.5427 0.055 234.6 / 0.06);
--shadow-hover:
0 4px 10px oklch(0.5427 0.055 234.6 / 0.1), 0 14px 30px oklch(0.5427 0.055 234.6 / 0.12);
} }
.dark { .dark {
+262 -42
View File
@@ -1,14 +1,243 @@
-- ========================================================= -- =========================================================
-- TABLERO CDC - Visibilidad del Total Tarifado -- TABLERO CDC - Acceso autorizado por Supabase
-- Ejecutar en Supabase SQL Editor antes de desplegar esta versión. -- Ejecutar en Supabase SQL Editor ANTES de desplegar el ZIP.
-- --
-- Objetivo: -- Objetivo:
-- - Por defecto, el Total Tarifado solo lo ven: -- - El acceso a la app ya no depende de una lista fija en código.
-- * gmarrero@gomezleemarketing.com -- - Los usuarios permitidos viven en public.tablero_cdc_allowed_users.
-- * jgomez@gomezleemarketing.com -- - Para agregar/quitar acceso luego, se actualiza esta tabla en Supabase.
-- * iaracena@gomezleemarketing.com -- - También centraliza permisos especiales usados por la app:
-- - Ellos pueden activar/desactivar un botón para mostrarlo temporalmente a todos. -- * can_manage_internal_pricing
-- - No expone montos internos por proyecto; solo habilita/oculta el resumen global/filtrado. -- * can_delete_projects
-- * can_control_pricing_summary
-- =========================================================
begin;
create table if not exists public.tablero_cdc_allowed_users (
email text primary key,
full_name text,
role text not null default 'user',
is_active boolean not null default true,
can_delete_projects boolean not null default false,
can_manage_internal_pricing boolean not null default false,
can_control_pricing_summary boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
comment on table public.tablero_cdc_allowed_users is
'Lista central de usuarios autorizados para entrar al Tablero CDC.';
comment on column public.tablero_cdc_allowed_users.role is
'Rol lógico de la app. director_creativo habilita comportamiento especial de Director Creativo.';
create or replace function public.set_tablero_cdc_allowed_users_updated_at()
returns trigger
language plpgsql
as $$
begin
new.email = lower(trim(new.email));
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists trg_tablero_cdc_allowed_users_updated_at on public.tablero_cdc_allowed_users;
create trigger trg_tablero_cdc_allowed_users_updated_at
before insert or update on public.tablero_cdc_allowed_users
for each row
execute function public.set_tablero_cdc_allowed_users_updated_at();
-- Usuarios autorizados iniciales.
-- Nota: Re-ejecutar este SQL mantiene estos usuarios activos y actualiza sus permisos base.
insert into public.tablero_cdc_allowed_users (
email,
full_name,
role,
is_active,
can_delete_projects,
can_manage_internal_pricing,
can_control_pricing_summary
)
values
(
'iaracena@gomezleemarketing.com',
'Isaac Daniel Aracena Toribio',
'admin_it',
true,
true,
true,
true
),
(
'jgomez@gomezleemarketing.com',
'José Leopoldo Gomez',
'leadership',
true,
false,
false,
true
),
(
'gmarrero@gomezleemarketing.com',
'Gerardo Marrero',
'director_creativo',
true,
true,
true,
true
),
(
'areyes@gomezleemarketing.com',
'Alicia Thaía Reyes',
'user',
true,
false,
false,
false
),
(
'boliva@gomezleemarketing.com',
'Bonnie Oliva',
'user',
true,
false,
false,
false
),
(
'obrisceno@gomezleemarketing.com',
'Oscar Brisceño',
'user',
true,
false,
false,
false
),
(
'lavila01@gomezlee.marketing',
'Lady Avila Faura',
'user',
true,
false,
false,
false
),
(
'rmarcano@gomezleemarketing.com',
'Ramon Marcano',
'user',
true,
false,
false,
false
),
(
'lmatos@gomezleemarketing.com',
'Luis Matos',
'it',
true,
false,
false,
false
),
(
'mgomez@gomezleemarketing.com',
'Máximo Gomez',
'leadership',
true,
false,
false,
false
)
on conflict (email)
do update set
full_name = excluded.full_name,
role = excluded.role,
is_active = excluded.is_active,
can_delete_projects = excluded.can_delete_projects,
can_manage_internal_pricing = excluded.can_manage_internal_pricing,
can_control_pricing_summary = excluded.can_control_pricing_summary,
updated_at = now();
alter table public.tablero_cdc_allowed_users enable row level security;
drop policy if exists "tablero_cdc_allowed_users_select_own_active" on public.tablero_cdc_allowed_users;
drop policy if exists "tablero_cdc_allowed_users_no_insert_from_app" on public.tablero_cdc_allowed_users;
drop policy if exists "tablero_cdc_allowed_users_no_update_from_app" on public.tablero_cdc_allowed_users;
drop policy if exists "tablero_cdc_allowed_users_no_delete_from_app" on public.tablero_cdc_allowed_users;
-- La app solo puede leer la fila activa del usuario autenticado.
-- La administración real se hace desde Supabase SQL Editor.
create policy "tablero_cdc_allowed_users_select_own_active"
on public.tablero_cdc_allowed_users
for select
to authenticated
using (
is_active = true
and lower(email) = lower(coalesce(auth.jwt() ->> 'email', ''))
);
create policy "tablero_cdc_allowed_users_no_insert_from_app"
on public.tablero_cdc_allowed_users
for insert
to authenticated
with check (false);
create policy "tablero_cdc_allowed_users_no_update_from_app"
on public.tablero_cdc_allowed_users
for update
to authenticated
using (false)
with check (false);
create policy "tablero_cdc_allowed_users_no_delete_from_app"
on public.tablero_cdc_allowed_users
for delete
to authenticated
using (false);
grant select on public.tablero_cdc_allowed_users to authenticated;
-- Funciones auxiliares para políticas/RPC.
create or replace function public.tablero_cdc_current_user_is_allowed()
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.tablero_cdc_allowed_users u
where u.email = lower(coalesce(auth.jwt() ->> 'email', ''))
and u.is_active = true
);
$$;
create or replace function public.tablero_cdc_current_user_can_control_pricing_summary()
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.tablero_cdc_allowed_users u
where u.email = lower(coalesce(auth.jwt() ->> 'email', ''))
and u.is_active = true
and u.can_control_pricing_summary = true
);
$$;
grant execute on function public.tablero_cdc_current_user_is_allowed() to authenticated;
grant execute on function public.tablero_cdc_current_user_can_control_pricing_summary() to authenticated;
-- =========================================================
-- Total Tarifado: configuración pública/privada usando tabla de accesos
-- ========================================================= -- =========================================================
create table if not exists public.tablero_cdc_app_settings ( create table if not exists public.tablero_cdc_app_settings (
@@ -25,15 +254,16 @@ on conflict (key) do nothing;
alter table public.tablero_cdc_app_settings enable row level security; alter table public.tablero_cdc_app_settings enable row level security;
drop policy if exists "tablero_cdc_app_settings_select_authenticated" on public.tablero_cdc_app_settings; drop policy if exists "tablero_cdc_app_settings_select_authenticated" on public.tablero_cdc_app_settings;
drop policy if exists "tablero_cdc_app_settings_select_allowed_users" on public.tablero_cdc_app_settings;
drop policy if exists "tablero_cdc_app_settings_insert_summary_controllers" on public.tablero_cdc_app_settings; drop policy if exists "tablero_cdc_app_settings_insert_summary_controllers" on public.tablero_cdc_app_settings;
drop policy if exists "tablero_cdc_app_settings_update_summary_controllers" on public.tablero_cdc_app_settings; drop policy if exists "tablero_cdc_app_settings_update_summary_controllers" on public.tablero_cdc_app_settings;
drop policy if exists "tablero_cdc_app_settings_delete_none" on public.tablero_cdc_app_settings; drop policy if exists "tablero_cdc_app_settings_delete_none" on public.tablero_cdc_app_settings;
create policy "tablero_cdc_app_settings_select_authenticated" create policy "tablero_cdc_app_settings_select_allowed_users"
on public.tablero_cdc_app_settings on public.tablero_cdc_app_settings
for select for select
to authenticated to authenticated
using (true); using (public.tablero_cdc_current_user_is_allowed());
create policy "tablero_cdc_app_settings_insert_summary_controllers" create policy "tablero_cdc_app_settings_insert_summary_controllers"
on public.tablero_cdc_app_settings on public.tablero_cdc_app_settings
@@ -41,11 +271,7 @@ create policy "tablero_cdc_app_settings_insert_summary_controllers"
to authenticated to authenticated
with check ( with check (
key = 'pricing_summary_public' key = 'pricing_summary_public'
and lower(coalesce(auth.jwt() ->> 'email', '')) in ( and public.tablero_cdc_current_user_can_control_pricing_summary()
'gmarrero@gomezleemarketing.com',
'jgomez@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
)
); );
create policy "tablero_cdc_app_settings_update_summary_controllers" create policy "tablero_cdc_app_settings_update_summary_controllers"
@@ -54,19 +280,11 @@ create policy "tablero_cdc_app_settings_update_summary_controllers"
to authenticated to authenticated
using ( using (
key = 'pricing_summary_public' key = 'pricing_summary_public'
and lower(coalesce(auth.jwt() ->> 'email', '')) in ( and public.tablero_cdc_current_user_can_control_pricing_summary()
'gmarrero@gomezleemarketing.com',
'jgomez@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
)
) )
with check ( with check (
key = 'pricing_summary_public' key = 'pricing_summary_public'
and lower(coalesce(auth.jwt() ->> 'email', '')) in ( and public.tablero_cdc_current_user_can_control_pricing_summary()
'gmarrero@gomezleemarketing.com',
'jgomez@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
)
); );
create policy "tablero_cdc_app_settings_delete_none" create policy "tablero_cdc_app_settings_delete_none"
@@ -93,6 +311,8 @@ before insert or update on public.tablero_cdc_app_settings
for each row for each row
execute function public.set_tablero_cdc_app_settings_updated_at(); execute function public.set_tablero_cdc_app_settings_updated_at();
grant select on public.tablero_cdc_app_settings to authenticated;
create or replace function public.tablero_cdc_set_pricing_summary_public(p_enabled boolean) create or replace function public.tablero_cdc_set_pricing_summary_public(p_enabled boolean)
returns jsonb returns jsonb
language plpgsql language plpgsql
@@ -100,14 +320,9 @@ security definer
set search_path = public set search_path = public
as $$ as $$
declare declare
v_email text := lower(coalesce(auth.jwt() ->> 'email', ''));
v_enabled boolean := coalesce(p_enabled, false); v_enabled boolean := coalesce(p_enabled, false);
begin begin
if v_email not in ( if not public.tablero_cdc_current_user_can_control_pricing_summary() then
'gmarrero@gomezleemarketing.com',
'jgomez@gomezleemarketing.com',
'iaracena@gomezleemarketing.com'
) then
raise exception 'No tienes permiso para cambiar la visibilidad del total tarifado.'; raise exception 'No tienes permiso para cambiar la visibilidad del total tarifado.';
end if; end if;
@@ -128,10 +343,11 @@ begin
end; end;
$$; $$;
grant select on public.tablero_cdc_app_settings to authenticated;
grant execute on function public.tablero_cdc_set_pricing_summary_public(boolean) to authenticated; grant execute on function public.tablero_cdc_set_pricing_summary_public(boolean) to authenticated;
-- Reemplaza la RPC de resumen para respetar visibilidad pública temporal. -- Reemplaza la RPC de resumen para respetar:
-- 1) que el usuario esté autorizado para la app;
-- 2) que sea controlador del Total Tarifado o que el total esté público.
create or replace function public.tablero_cdc_get_pricing_summary( create or replace function public.tablero_cdc_get_pricing_summary(
p_tab text default 'todos', p_tab text default 'todos',
p_search text default '', p_search text default '',
@@ -153,12 +369,11 @@ as $$
), ),
allowed as ( allowed as (
select select
lower(coalesce(auth.jwt() ->> 'email', '')) in ( public.tablero_cdc_current_user_is_allowed()
'gmarrero@gomezleemarketing.com', and (
'jgomez@gomezleemarketing.com', public.tablero_cdc_current_user_can_control_pricing_summary()
'iaracena@gomezleemarketing.com' or coalesce((select is_public from visibility), false)
) ) as can_view_pricing
or coalesce((select is_public from visibility), false) as can_view_pricing
), ),
params as ( params as (
select select
@@ -234,7 +449,6 @@ $$;
grant execute on function public.tablero_cdc_get_pricing_summary(text, text, text, text, text, text) to authenticated; grant execute on function public.tablero_cdc_get_pricing_summary(text, text, text, text, text, text) to authenticated;
-- Intenta habilitar realtime para que el cambio de visible/oculto se refleje sin recargar. -- Intenta habilitar realtime para que el cambio de visible/oculto se refleje sin recargar.
-- Si la publicación ya existe o la tabla ya está agregada, no hace nada.
do $$ do $$
begin begin
alter publication supabase_realtime add table public.tablero_cdc_app_settings; alter publication supabase_realtime add table public.tablero_cdc_app_settings;
@@ -243,6 +457,12 @@ exception
when undefined_object then null; when undefined_object then null;
end $$; end $$;
commit;
select select
'Visibilidad del Total Tarifado lista' as status, 'Acceso Tablero CDC listo' as status,
(select value from public.tablero_cdc_app_settings where key = 'pricing_summary_public') as pricing_summary_public; count(*) filter (where is_active) as usuarios_activos,
count(*) filter (where can_control_pricing_summary and is_active) as controlan_total_tarifado,
count(*) filter (where can_manage_internal_pricing and is_active) as ven_interno_cargado,
count(*) filter (where can_delete_projects and is_active) as pueden_eliminar
from public.tablero_cdc_allowed_users;
+468
View File
@@ -0,0 +1,468 @@
-- =========================================================
-- TABLERO CDC - Acceso autorizado por Supabase
-- Ejecutar en Supabase SQL Editor ANTES de desplegar el ZIP.
--
-- Objetivo:
-- - El acceso a la app ya no depende de una lista fija en código.
-- - Los usuarios permitidos viven en public.tablero_cdc_allowed_users.
-- - Para agregar/quitar acceso luego, se actualiza esta tabla en Supabase.
-- - También centraliza permisos especiales usados por la app:
-- * can_manage_internal_pricing
-- * can_delete_projects
-- * can_control_pricing_summary
-- =========================================================
begin;
create table if not exists public.tablero_cdc_allowed_users (
email text primary key,
full_name text,
role text not null default 'user',
is_active boolean not null default true,
can_delete_projects boolean not null default false,
can_manage_internal_pricing boolean not null default false,
can_control_pricing_summary boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
comment on table public.tablero_cdc_allowed_users is
'Lista central de usuarios autorizados para entrar al Tablero CDC.';
comment on column public.tablero_cdc_allowed_users.role is
'Rol lógico de la app. director_creativo habilita comportamiento especial de Director Creativo.';
create or replace function public.set_tablero_cdc_allowed_users_updated_at()
returns trigger
language plpgsql
as $$
begin
new.email = lower(trim(new.email));
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists trg_tablero_cdc_allowed_users_updated_at on public.tablero_cdc_allowed_users;
create trigger trg_tablero_cdc_allowed_users_updated_at
before insert or update on public.tablero_cdc_allowed_users
for each row
execute function public.set_tablero_cdc_allowed_users_updated_at();
-- Usuarios autorizados iniciales.
-- Nota: Re-ejecutar este SQL mantiene estos usuarios activos y actualiza sus permisos base.
insert into public.tablero_cdc_allowed_users (
email,
full_name,
role,
is_active,
can_delete_projects,
can_manage_internal_pricing,
can_control_pricing_summary
)
values
(
'iaracena@gomezleemarketing.com',
'Isaac Daniel Aracena Toribio',
'admin_it',
true,
true,
true,
true
),
(
'jgomez@gomezleemarketing.com',
'José Leopoldo Gomez',
'leadership',
true,
false,
false,
true
),
(
'gmarrero@gomezleemarketing.com',
'Gerardo Marrero',
'director_creativo',
true,
true,
true,
true
),
(
'areyes@gomezleemarketing.com',
'Alicia Thaía Reyes',
'user',
true,
false,
false,
false
),
(
'boliva@gomezleemarketing.com',
'Bonnie Oliva',
'user',
true,
false,
false,
false
),
(
'obrisceno@gomezleemarketing.com',
'Oscar Brisceño',
'user',
true,
false,
false,
false
),
(
'lavila01@gomezlee.marketing',
'Lady Avila Faura',
'user',
true,
false,
false,
false
),
(
'rmarcano@gomezleemarketing.com',
'Ramon Marcano',
'user',
true,
false,
false,
false
),
(
'lmatos@gomezleemarketing.com',
'Luis Matos',
'it',
true,
false,
false,
false
),
(
'mgomez@gomezleemarketing.com',
'Máximo Gomez',
'leadership',
true,
false,
false,
false
)
on conflict (email)
do update set
full_name = excluded.full_name,
role = excluded.role,
is_active = excluded.is_active,
can_delete_projects = excluded.can_delete_projects,
can_manage_internal_pricing = excluded.can_manage_internal_pricing,
can_control_pricing_summary = excluded.can_control_pricing_summary,
updated_at = now();
alter table public.tablero_cdc_allowed_users enable row level security;
drop policy if exists "tablero_cdc_allowed_users_select_own_active" on public.tablero_cdc_allowed_users;
drop policy if exists "tablero_cdc_allowed_users_no_insert_from_app" on public.tablero_cdc_allowed_users;
drop policy if exists "tablero_cdc_allowed_users_no_update_from_app" on public.tablero_cdc_allowed_users;
drop policy if exists "tablero_cdc_allowed_users_no_delete_from_app" on public.tablero_cdc_allowed_users;
-- La app solo puede leer la fila activa del usuario autenticado.
-- La administración real se hace desde Supabase SQL Editor.
create policy "tablero_cdc_allowed_users_select_own_active"
on public.tablero_cdc_allowed_users
for select
to authenticated
using (
is_active = true
and lower(email) = lower(coalesce(auth.jwt() ->> 'email', ''))
);
create policy "tablero_cdc_allowed_users_no_insert_from_app"
on public.tablero_cdc_allowed_users
for insert
to authenticated
with check (false);
create policy "tablero_cdc_allowed_users_no_update_from_app"
on public.tablero_cdc_allowed_users
for update
to authenticated
using (false)
with check (false);
create policy "tablero_cdc_allowed_users_no_delete_from_app"
on public.tablero_cdc_allowed_users
for delete
to authenticated
using (false);
grant select on public.tablero_cdc_allowed_users to authenticated;
-- Funciones auxiliares para políticas/RPC.
create or replace function public.tablero_cdc_current_user_is_allowed()
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.tablero_cdc_allowed_users u
where u.email = lower(coalesce(auth.jwt() ->> 'email', ''))
and u.is_active = true
);
$$;
create or replace function public.tablero_cdc_current_user_can_control_pricing_summary()
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.tablero_cdc_allowed_users u
where u.email = lower(coalesce(auth.jwt() ->> 'email', ''))
and u.is_active = true
and u.can_control_pricing_summary = true
);
$$;
grant execute on function public.tablero_cdc_current_user_is_allowed() to authenticated;
grant execute on function public.tablero_cdc_current_user_can_control_pricing_summary() to authenticated;
-- =========================================================
-- Total Tarifado: configuración pública/privada usando tabla de accesos
-- =========================================================
create table if not exists public.tablero_cdc_app_settings (
key text primary key,
value jsonb not null default '{}'::jsonb,
updated_by uuid references auth.users(id),
updated_at timestamptz not null default now()
);
insert into public.tablero_cdc_app_settings (key, value)
values ('pricing_summary_public', jsonb_build_object('enabled', false))
on conflict (key) do nothing;
alter table public.tablero_cdc_app_settings enable row level security;
drop policy if exists "tablero_cdc_app_settings_select_authenticated" on public.tablero_cdc_app_settings;
drop policy if exists "tablero_cdc_app_settings_select_allowed_users" on public.tablero_cdc_app_settings;
drop policy if exists "tablero_cdc_app_settings_insert_summary_controllers" on public.tablero_cdc_app_settings;
drop policy if exists "tablero_cdc_app_settings_update_summary_controllers" on public.tablero_cdc_app_settings;
drop policy if exists "tablero_cdc_app_settings_delete_none" on public.tablero_cdc_app_settings;
create policy "tablero_cdc_app_settings_select_allowed_users"
on public.tablero_cdc_app_settings
for select
to authenticated
using (public.tablero_cdc_current_user_is_allowed());
create policy "tablero_cdc_app_settings_insert_summary_controllers"
on public.tablero_cdc_app_settings
for insert
to authenticated
with check (
key = 'pricing_summary_public'
and public.tablero_cdc_current_user_can_control_pricing_summary()
);
create policy "tablero_cdc_app_settings_update_summary_controllers"
on public.tablero_cdc_app_settings
for update
to authenticated
using (
key = 'pricing_summary_public'
and public.tablero_cdc_current_user_can_control_pricing_summary()
)
with check (
key = 'pricing_summary_public'
and public.tablero_cdc_current_user_can_control_pricing_summary()
);
create policy "tablero_cdc_app_settings_delete_none"
on public.tablero_cdc_app_settings
for delete
to authenticated
using (false);
create or replace function public.set_tablero_cdc_app_settings_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
new.updated_by = auth.uid();
return new;
end;
$$;
drop trigger if exists trg_tablero_cdc_app_settings_updated_at on public.tablero_cdc_app_settings;
create trigger trg_tablero_cdc_app_settings_updated_at
before insert or update on public.tablero_cdc_app_settings
for each row
execute function public.set_tablero_cdc_app_settings_updated_at();
grant select on public.tablero_cdc_app_settings to authenticated;
create or replace function public.tablero_cdc_set_pricing_summary_public(p_enabled boolean)
returns jsonb
language plpgsql
security definer
set search_path = public
as $$
declare
v_enabled boolean := coalesce(p_enabled, false);
begin
if not public.tablero_cdc_current_user_can_control_pricing_summary() then
raise exception 'No tienes permiso para cambiar la visibilidad del total tarifado.';
end if;
insert into public.tablero_cdc_app_settings (key, value, updated_by, updated_at)
values (
'pricing_summary_public',
jsonb_build_object('enabled', v_enabled),
auth.uid(),
now()
)
on conflict (key)
do update set
value = excluded.value,
updated_by = auth.uid(),
updated_at = now();
return jsonb_build_object('enabled', v_enabled);
end;
$$;
grant execute on function public.tablero_cdc_set_pricing_summary_public(boolean) to authenticated;
-- Reemplaza la RPC de resumen para respetar:
-- 1) que el usuario esté autorizado para la app;
-- 2) que sea controlador del Total Tarifado o que el total esté público.
create or replace function public.tablero_cdc_get_pricing_summary(
p_tab text default 'todos',
p_search text default '',
p_country text default '',
p_brand text default '',
p_client text default '',
p_cm text default ''
)
returns jsonb
language sql
stable
security invoker
set search_path = public
as $$
with visibility as (
select coalesce((value ->> 'enabled')::boolean, false) as is_public
from public.tablero_cdc_app_settings
where key = 'pricing_summary_public'
),
allowed as (
select
public.tablero_cdc_current_user_is_allowed()
and (
public.tablero_cdc_current_user_can_control_pricing_summary()
or coalesce((select is_public from visibility), false)
) as can_view_pricing
),
params as (
select
lower(coalesce(nullif(trim(p_tab), ''), 'todos')) as tab,
trim(coalesce(p_search, '')) as search_text,
trim(coalesce(p_country, '')) as country_filter,
trim(coalesce(p_brand, '')) as brand_filter,
trim(coalesce(p_client, '')) as client_filter,
trim(coalesce(p_cm, '')) as cm_filter
),
base_filtered as (
select p.*
from public.tablero_cdc_projects p
cross join params prm
where
case
when prm.tab in ('abiertos', 'activos', 'active', 'open') then
coalesce(nullif(trim(p.status), ''), 'Activo') = 'Activo'
when prm.tab in ('cerrados', 'closed') then
coalesce(nullif(trim(p.status), ''), 'Activo') <> 'Activo'
else
true
end
and (
prm.search_text = ''
or coalesce(p.title, '') ilike '%' || prm.search_text || '%'
or coalesce(p.client, '') ilike '%' || prm.search_text || '%'
or coalesce(p.brand, '') ilike '%' || prm.search_text || '%'
or coalesce(p.country, '') ilike '%' || prm.search_text || '%'
or coalesce(p.requested_by, '') ilike '%' || prm.search_text || '%'
or coalesce(p.country_manager, '') ilike '%' || prm.search_text || '%'
or coalesce(p.description, '') ilike '%' || prm.search_text || '%'
)
),
filtered as (
select bf.*
from base_filtered bf
cross join params prm
where
(prm.country_filter = '' or coalesce(bf.country, '') ilike '%' || prm.country_filter || '%')
and (prm.brand_filter = '' or coalesce(bf.brand, '') ilike '%' || prm.brand_filter || '%')
and (prm.client_filter = '' or coalesce(bf.client, '') ilike '%' || prm.client_filter || '%')
and (prm.cm_filter = '' or coalesce(bf.country_manager, '') ilike '%' || prm.cm_filter || '%')
),
totals as (
select
count(*)::integer as project_count,
coalesce(
sum(
greatest(
coalesce(nullif(trim(internal_amount::text), '')::numeric, 0),
0
)
),
0
)::numeric as total_amount
from filtered
)
select case
when (select can_view_pricing from allowed) then
jsonb_build_object(
'total_amount', (select total_amount from totals),
'project_count', (select project_count from totals)
)
else
jsonb_build_object(
'total_amount', 0,
'project_count', 0
)
end;
$$;
grant execute on function public.tablero_cdc_get_pricing_summary(text, text, text, text, text, text) to authenticated;
-- Intenta habilitar realtime para que el cambio de visible/oculto se refleje sin recargar.
do $$
begin
alter publication supabase_realtime add table public.tablero_cdc_app_settings;
exception
when duplicate_object then null;
when undefined_object then null;
end $$;
commit;
select
'Acceso Tablero CDC listo' as status,
count(*) filter (where is_active) as usuarios_activos,
count(*) filter (where can_control_pricing_summary and is_active) as controlan_total_tarifado,
count(*) filter (where can_manage_internal_pricing and is_active) as ven_interno_cargado,
count(*) filter (where can_delete_projects and is_active) as pueden_eliminar
from public.tablero_cdc_allowed_users;