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

This commit is contained in:
Isaac_Aracena
2026-06-26 16:20:20 -04:00
parent 4252e524f6
commit 0932ede688
35 changed files with 1059 additions and 285 deletions
+3 -7
View File
@@ -2,6 +2,7 @@ import React, { Component, ReactNode } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AuthProvider, useAuth } from "@/context/AuthContext";
import { LoginScreen } from "@/components/LoginScreen";
import { GLMLogo } from "@/components/GLMLogo";
import BoardPage from "./pages/BoardPage";
import { Toaster } from "@/components/ui/sonner";
@@ -62,13 +63,8 @@ function AuthGate() {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<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">
<svg className="w-4 h-4 text-primary-foreground" fill="none" viewBox="0 0 24 24">
<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 className="animate-pulse">
<GLMLogo showWordmark={false} />
</div>
<p className="text-sm text-muted-foreground">Cargando tablero</p>
</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 { useAuth } from "@/context/AuthContext";
import { GLMLogo } from "@/components/GLMLogo";
export function LoginScreen() {
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="w-full max-w-sm">
{/* Logo / marca */}
<div className="flex flex-col items-center mb-8 gap-3">
<div className="w-14 h-14 rounded-2xl bg-primary flex items-center justify-center shadow-lg">
<LayoutGrid className="w-7 h-7 text-primary-foreground" />
</div>
<div className="text-center leading-tight">
<h1 className="text-2xl font-bold tracking-tight">Tablero CDC</h1>
</div>
<div className="flex flex-col items-center mb-8 gap-5">
<GLMLogo size="lg" />
<h1 className="text-2xl font-bold tracking-tight text-[#4F758B]">Tablero CDC</h1>
</div>
{/* Card */}
@@ -43,7 +39,7 @@ export function LoginScreen() {
</div>
<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>
</div>
</div>
+1 -6
View File
@@ -63,11 +63,7 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
const [manualName, setManualName] = useState<string>("");
const [manualDescription, setManualDescription] = useState<string>("");
const [manualAmount, setManualAmount] = useState<string>("");
const {
catalog: tariffCatalog,
loading: tariffLoading,
error: tariffError,
} = useTariffCatalog();
const { catalog: tariffCatalog, loading: tariffLoading, error: tariffError } = useTariffCatalog();
const sectionOptions = TARIFF_SECTIONS.map((option) => option.label);
const selectedSectionLabel = TARIFF_SECTIONS.find((option) => option.id === section)?.label || "";
@@ -237,7 +233,6 @@ export function ProjectPricingPanel({ items, onChange, loading, error }: Props)
</div>
)}
{(loading || tariffLoading) && (
<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…"}
+36 -26
View File
@@ -9,19 +9,7 @@ import React, {
} from "react";
import type { User as SupabaseUser } from "@supabase/supabase-js";
import { supabase } from "@/lib/supabase";
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}`);
}
import { getActiveTableroCdcAccess, type TableroCdcUserAccess } from "@/lib/accessControl";
function getRedirectTo(): string {
if (typeof window === "undefined") return "/tablero-cdc/";
@@ -75,23 +63,44 @@ const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [access, setAccess] = useState<TableroCdcUserAccess | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const applyUser = useCallback(async (supabaseUser: SupabaseUser | null) => {
if (!supabaseUser) {
setUser(null);
setAccess(null);
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();
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;
}
setUser(normalizeSupabaseUser(supabaseUser));
setAccess(nextAccess);
setError(null);
}, []);
@@ -123,8 +132,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
});
const { data: listener } = supabase.auth.onAuthStateChange((_event, session) => {
void applyUser(session?.user ?? null);
setLoading(false);
setLoading(true);
void applyUser(session?.user ?? null).finally(() => {
if (mounted) setLoading(false);
});
});
return () => {
@@ -142,7 +153,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
redirectTo: getRedirectTo(),
queryParams: {
prompt: "select_account",
hd: ALLOWED_DOMAIN,
},
},
});
@@ -163,22 +173,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
setUser(null);
setAccess(null);
setError(null);
}, []);
const normalizedEmail = user?.email?.toLowerCase() || "";
const isGerardo = useMemo(() => normalizedEmail === GERARDO_EMAIL, [normalizedEmail]);
const isGerardo = useMemo(() => access?.role === "director_creativo", [access?.role]);
const canManageInternalPricing = useMemo(
() => INTERNAL_PRICING_ALLOWED_EMAILS.includes(normalizedEmail),
[normalizedEmail],
() => access?.canManageInternalPricing === true,
[access?.canManageInternalPricing],
);
const canDeleteProjects = useMemo(
() => DELETE_ALLOWED_EMAILS.includes(normalizedEmail),
[normalizedEmail],
() => access?.canDeleteProjects === true,
[access?.canDeleteProjects],
);
const canControlPricingSummary = useMemo(
() => PRICING_SUMMARY_CONTROLLER_EMAILS.includes(normalizedEmail),
[normalizedEmail],
() => access?.canControlPricingSummary === true,
[access?.canControlPricingSummary],
);
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) {
console.error("No se pudo leer la visibilidad del total tarifado:", err);
setError(
err instanceof Error
? err.message
: "No se pudo leer la visibilidad del total tarifado.",
err instanceof Error ? err.message : "No se pudo leer la visibilidad del total tarifado.",
);
return false;
} finally {
@@ -63,29 +61,26 @@ export function usePricingSummaryVisibility(enabled = true) {
}
}, [enabled]);
const setPublicVisibility = useCallback(
async (nextIsPublic: boolean) => {
setSaving(true);
setError(null);
const setPublicVisibility = useCallback(async (nextIsPublic: boolean) => {
setSaving(true);
setError(null);
try {
await savePricingSummaryPublicVisibility(nextIsPublic);
setIsPublic(nextIsPublic);
return nextIsPublic;
} catch (err) {
console.error("No se pudo actualizar la visibilidad del total tarifado:", err);
setError(
err instanceof Error
? err.message
: "No se pudo actualizar la visibilidad del total tarifado.",
);
throw err;
} finally {
setSaving(false);
}
},
[],
);
try {
await savePricingSummaryPublicVisibility(nextIsPublic);
setIsPublic(nextIsPublic);
return nextIsPublic;
} catch (err) {
console.error("No se pudo actualizar la visibilidad del total tarifado:", err);
setError(
err instanceof Error
? err.message
: "No se pudo actualizar la visibilidad del total tarifado.",
);
throw err;
} finally {
setSaving(false);
}
}, []);
useEffect(() => {
void refresh();
+7 -9
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
import { getCurrentTableroCdcAccess } from "@/lib/accessControl";
import type { ColorId, Status } from "@/data/lists";
export interface Project {
@@ -178,9 +179,6 @@ type DirectProjectSummaryRow = {
const DEFAULT_COLOR: ColorId = "blue";
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 = {
tab: "todos",
search: "",
@@ -478,18 +476,18 @@ async function getCurrentUserId(): Promise<string> {
}
async function currentUserIsGerardo(): Promise<boolean> {
const user = await getCurrentUser();
return user.email?.toLowerCase() === GERARDO_EMAIL;
const access = await getCurrentTableroCdcAccess();
return access.role === "director_creativo";
}
async function currentUserCanManageInternalPricing(): Promise<boolean> {
const user = await getCurrentUser();
return INTERNAL_PRICING_ALLOWED_EMAILS.includes(user.email?.toLowerCase() || "");
const access = await getCurrentTableroCdcAccess();
return access.canManageInternalPricing;
}
async function currentUserCanDeleteProjects(): Promise<boolean> {
const user = await getCurrentUser();
return DELETE_ALLOWED_EMAILS.includes(user.email?.toLowerCase() || "");
const access = await getCurrentTableroCdcAccess();
return access.canDeleteProjects;
}
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 { useAuth } from "@/context/AuthContext";
import { dedupeOptions } from "@/lib/optionUtils";
import { GLMLogo } from "@/components/GLMLogo";
/** Extract up-to-2-letter initials from a display name or email */
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">
<div className="max-w-[1400px] mx-auto px-6 py-3.5 flex items-center gap-4">
{/* Brand */}
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-xl bg-primary flex items-center justify-center">
<LayoutGrid className="w-4 h-4 text-primary-foreground" />
</div>
<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 className="flex items-center gap-4">
<GLMLogo size="sm" />
<div className="h-7 w-px bg-border" />
<h1 className="text-[15px] font-semibold tracking-tight text-[#4F758B]">Tablero CDC</h1>
</div>
{/* Search */}
+40 -31
View File
@@ -64,48 +64,57 @@
:root {
--radius: 0.875rem;
/* Google-like clean palette + soft purple accent */
--background: oklch(0.99 0.003 280);
--foreground: oklch(0.25 0.02 280);
/* GLM palette soft, clean and low-noise for the existing CDC UI */
--glm-blue: #4f758b;
--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-foreground: oklch(0.25 0.02 280);
--card-foreground: oklch(0.4091 0 89.9);
--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-foreground: oklch(0.99 0.005 280);
--primary: oklch(0.5427 0.055 234.6);
--primary-foreground: oklch(1 0 89.9);
--secondary: oklch(0.97 0.005 280);
--secondary-foreground: oklch(0.3 0.02 280);
--muted: oklch(0.965 0.004 280);
--muted-foreground: oklch(0.5 0.015 280);
--accent: oklch(0.96 0.02 290);
--accent-foreground: oklch(0.35 0.05 290);
--secondary: oklch(0.9702 0 89.9);
--secondary-foreground: oklch(0.4091 0 89.9);
--muted: oklch(0.9702 0 89.9);
--muted-foreground: oklch(0.6304 0.0501 232.9);
--accent: oklch(0.9633 0.0203 131.5);
--accent-foreground: oklch(0.4968 0.0663 196.6);
--destructive: oklch(0.6 0.22 27);
--destructive-foreground: oklch(0.99 0 0);
--border: oklch(0.925 0.005 280);
--input: oklch(0.93 0.005 280);
--ring: oklch(0.65 0.15 290);
--border: oklch(0.9 0.01 235);
--input: oklch(0.91 0.01 235);
--ring: oklch(0.7344 0.1772 137.9);
--chart-1: oklch(0.65 0.18 250);
--chart-2: oklch(0.65 0.18 150);
--chart-3: oklch(0.7 0.16 80);
--chart-4: oklch(0.6 0.2 20);
--chart-5: oklch(0.55 0.18 290);
--chart-1: oklch(0.5427 0.055 234.6);
--chart-2: oklch(0.7344 0.1772 137.9);
--chart-3: oklch(0.5778 0.0529 235.7);
--chart-4: oklch(0.7013 0.1996 43.7);
--chart-5: oklch(0.787 0.1232 161.2);
--sidebar: oklch(0.985 0.003 280);
--sidebar-foreground: oklch(0.25 0.02 280);
--sidebar-primary: oklch(0.55 0.18 290);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.96 0.02 290);
--sidebar-accent-foreground: oklch(0.35 0.05 290);
--sidebar-border: oklch(0.925 0.005 280);
--sidebar-ring: oklch(0.65 0.15 290);
--sidebar: oklch(0.985 0.004 131);
--sidebar-foreground: oklch(0.4091 0 89.9);
--sidebar-primary: oklch(0.5427 0.055 234.6);
--sidebar-primary-foreground: oklch(1 0 89.9);
--sidebar-accent: oklch(0.9633 0.0203 131.5);
--sidebar-accent-foreground: oklch(0.4968 0.0663 196.6);
--sidebar-border: oklch(0.9 0.01 235);
--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-hover: 0 4px 8px oklch(0.5 0.02 280 / 0.08), 0 12px 28px oklch(0.5 0.02 280 / 0.10);
--shadow-soft:
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 {