Audito dashboard and OPERATOR permissions

This commit is contained in:
2026-02-02 23:23:45 -06:00
parent b273003366
commit 9ab1beeef7
8 changed files with 314 additions and 122 deletions

View File

@@ -34,9 +34,19 @@ export interface AuthUser {
email: string; email: string;
name: string; name: string;
role: string; role: string;
projectId?: string | null;
avatar_url?: string; avatar_url?: string;
} }
export interface JwtPayload {
userId: string;
roleId: string;
roleName: string;
projectId?: string | null;
exp?: number;
iat?: number;
}
/** /**
* Login response combining tokens and user data * Login response combining tokens and user data
*/ */
@@ -329,3 +339,60 @@ export async function changePassword(
newPassword, newPassword,
}); });
} }
/**
* Get current user's project ID from JWT token
* @returns The project ID or null if not assigned
*/
export function getCurrentUserProjectId(): string | null {
const token = getAccessToken();
if (!token) return null;
try {
const payload = parseJwtPayload(token) as JwtPayload | null;
return payload?.projectId || null;
} catch {
return null;
}
}
/**
* Get current user's role name from JWT token
* @returns The role name or null
*/
export function getCurrentUserRole(): string | null {
const token = getAccessToken();
if (!token) return null;
try {
const payload = parseJwtPayload(token) as JwtPayload | null;
return payload?.roleName || null;
} catch {
return null;
}
}
/**
* Get current user's ID from JWT token
* @returns The user ID or null
*/
export function getCurrentUserId(): string | null {
const token = getAccessToken();
if (!token) return null;
try {
const payload = parseJwtPayload(token) as JwtPayload | null;
return payload?.userId || null;
} catch {
return null;
}
}
/**
* Check if current user is an admin
* @returns boolean indicating if user is admin
*/
export function isCurrentUserAdmin(): boolean {
const role = getCurrentUserRole();
return role?.toUpperCase() === 'ADMIN';
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useMemo } from "react";
import { import {
Home, Home,
Settings, Settings,
@@ -8,6 +8,7 @@ import {
People, People,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { Page } from "../../App"; import { Page } from "../../App";
import { getCurrentUserRole } from "../../api/auth";
interface SidebarProps { interface SidebarProps {
setPage: (page: Page) => void; setPage: (page: Page) => void;
@@ -19,6 +20,9 @@ export default function Sidebar({ setPage }: SidebarProps) {
const [pinned, setPinned] = useState(false); const [pinned, setPinned] = useState(false);
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const userRole = useMemo(() => getCurrentUserRole(), []);
const isOperator = userRole?.toUpperCase() === 'OPERATOR';
const isExpanded = pinned || hovered; const isExpanded = pinned || hovered;
return ( return (
@@ -115,56 +119,59 @@ export default function Sidebar({ setPage }: SidebarProps) {
</button> </button>
</li> </li>
<li> {!isOperator && (
<button <li>
onClick={() => setPage("auditoria")} <button
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10" onClick={() => setPage("auditoria")}
> className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
Auditoría >
</button> Auditoría
</li> </button>
</li>
)}
</ul> </ul>
)} )}
</li> </li>
{/* USERS MANAGEMENT */} {!isOperator && (
<li> <li>
<button <button
onClick={() => isExpanded && setUsersOpen(!usersOpen)} onClick={() => isExpanded && setUsersOpen(!usersOpen)}
className="flex items-center w-full px-2 py-2 rounded-md hover:bg-white/10 font-bold" className="flex items-center w-full px-2 py-2 rounded-md hover:bg-white/10 font-bold"
> >
<People className="w-5 h-5 shrink-0" /> <People className="w-5 h-5 shrink-0" />
{isExpanded && ( {isExpanded && (
<> <>
<span className="ml-3 flex-1 text-left"> <span className="ml-3 flex-1 text-left">
Users Management Users Management
</span> </span>
{usersOpen ? <ExpandLess /> : <ExpandMore />} {usersOpen ? <ExpandLess /> : <ExpandMore />}
</> </>
)}
</button>
{isExpanded && usersOpen && (
<ul className="mt-1 space-y-1 text-xs">
<li>
<button
onClick={() => setPage("users")}
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
>
Users
</button>
</li>
<li>
<button
onClick={() => setPage("roles")}
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
>
Roles
</button>
</li>
</ul>
)} )}
</button> </li>
)}
{isExpanded && usersOpen && (
<ul className="mt-1 space-y-1 text-xs">
<li>
<button
onClick={() => setPage("users")}
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
>
Users
</button>
</li>
<li>
<button
onClick={() => setPage("roles")}
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
>
Roles
</button>
</li>
</ul>
)}
</li>
</ul> </ul>
</div> </div>
</aside> </aside>

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
import { Bell, User, LogOut } from "lucide-react"; import { Bell, User, LogOut } from "lucide-react";
import NotificationDropdown from "../NotificationDropdown"; import NotificationDropdown from "../NotificationDropdown";
import { useNotifications } from "../../hooks/useNotifications"; import { useNotifications } from "../../hooks/useNotifications";
import ProjectBadge from "./common/ProjectBadge";
interface TopMenuProps { interface TopMenuProps {
page: string; page: string;
@@ -81,7 +82,7 @@ const TopMenu: React.FC<TopMenuProps> = ({
}} }}
> >
{/* IZQUIERDA */} {/* IZQUIERDA */}
<div className="flex items-center gap-2 text-sm font-medium opacity-90"> <div className="flex items-center gap-4 text-sm font-medium opacity-90">
{page !== "home" && ( {page !== "home" && (
<> <>
<span className="capitalize">{page}</span> <span className="capitalize">{page}</span>
@@ -93,6 +94,8 @@ const TopMenu: React.FC<TopMenuProps> = ({
)} )}
</> </>
)} )}
<ProjectBadge />
</div> </div>
{/* DERECHA */} {/* DERECHA */}

View File

@@ -0,0 +1,46 @@
import { useEffect, useState } from "react";
import { Building2 } from "lucide-react";
import { getCurrentUserProjectId, getCurrentUserRole } from "../../../api/auth";
import { fetchProject } from "../../../api/projects";
interface Project {
id: string;
name: string;
}
export default function ProjectBadge() {
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadProject = async () => {
const projectId = getCurrentUserProjectId();
const role = getCurrentUserRole();
if (role?.toUpperCase() !== 'ADMIN' && projectId) {
try {
const projectData = await fetchProject(projectId);
setProject(projectData);
} catch (err) {
console.error("Error loading user project:", err);
}
}
setLoading(false);
};
loadProject();
}, []);
if (loading || !project) {
return null;
}
return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 border border-blue-200 rounded-lg text-sm">
<Building2 size={16} className="text-blue-600" />
<span className="text-blue-900 font-medium">
Proyecto: <span className="font-semibold">{project.name}</span>
</span>
</div>
);
}

View File

@@ -10,6 +10,8 @@ import {
CartesianGrid, CartesianGrid,
} from "recharts"; } from "recharts";
import { fetchMeters, type Meter } from "../api/meters"; import { fetchMeters, type Meter } from "../api/meters";
import { getAuditLogs, type AuditLog } from "../api/audit";
import { getCurrentUserRole } from "../api/auth";
import type { Page } from "../App"; import type { Page } from "../App";
import grhWatermark from "../assets/images/grhWatermark.png"; import grhWatermark from "../assets/images/grhWatermark.png";
@@ -46,6 +48,10 @@ export default function Home({
setPage: (page: Page) => void; setPage: (page: Page) => void;
navigateToMetersWithProject: (projectName: string) => void; navigateToMetersWithProject: (projectName: string) => void;
}) { }) {
const userRole = useMemo(() => getCurrentUserRole(), []);
const isOperator = userRole?.toUpperCase() === 'OPERATOR';
/* ================= ORGANISMS (MOCK) ================= */ /* ================= ORGANISMS (MOCK) ================= */
const organismsData: Organism[] = [ const organismsData: Organism[] = [
@@ -111,6 +117,29 @@ export default function Home({
return "CESPT TIJUANA"; return "CESPT TIJUANA";
}; };
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [loadingAuditLogs, setLoadingAuditLogs] = useState(false);
const loadAuditLogs = async () => {
setLoadingAuditLogs(true);
try {
const response = await getAuditLogs({ limit: 10, page: 1 });
setAuditLogs(response.data);
} catch (err) {
console.error("Error loading audit logs:", err);
setAuditLogs([]);
} finally {
setLoadingAuditLogs(false);
}
};
useEffect(() => {
if (!isOperator) {
loadAuditLogs();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filteredMeters = useMemo( const filteredMeters = useMemo(
() => meters.filter((m) => getOrganismFromMeter(m) === selectedOrganism), () => meters.filter((m) => getOrganismFromMeter(m) === selectedOrganism),
[meters, selectedOrganism] [meters, selectedOrganism]
@@ -146,46 +175,76 @@ export default function Home({
return organismsData.filter((o) => o.name.toLowerCase().includes(q)); return organismsData.filter((o) => o.name.toLowerCase().includes(q));
}, [organismQuery]); }, [organismQuery]);
/* ================= MOCK ALERTS / HISTORY ================= */
const alerts: AlertItem[] = [ const alerts: AlertItem[] = [
{ company: "Empresa A", type: "Fuga", time: "Hace 2 horas" }, { company: "Empresa A", type: "Fuga", time: "Hace 2 horas" },
{ company: "Empresa C", type: "Consumo alto", time: "Hace 5 horas" }, { company: "Empresa C", type: "Consumo alto", time: "Hace 5 horas" },
{ company: "Empresa B", type: "Inactividad", time: "Hace 8 horas" }, { company: "Empresa B", type: "Inactividad", time: "Hace 8 horas" },
]; ];
const history: HistoryItem[] = [ const formatAuditAction = (action: string): string => {
{ const actionMap: Record<string, string> = {
user: "GRH", CREATE: "creó",
action: "Creó un nuevo medidor", UPDATE: "actualizó",
target: "SN001", DELETE: "eliminó",
time: "Hace 5 minutos", READ: "consultó",
}, LOGIN: "inició sesión",
{ LOGOUT: "cerró sesión",
user: "CESPT", EXPORT: "exportó",
action: "Actualizó concentrador", BULK_UPLOAD: "cargó masivamente",
target: "Planta 1", STATUS_CHANGE: "cambió estado de",
time: "Hace 20 minutos", PERMISSION_CHANGE: "cambió permisos de",
}, };
{ return actionMap[action] || action.toLowerCase();
user: "GRH", };
action: "Eliminó un usuario",
target: "Juan Pérez", const formatTableName = (tableName: string): string => {
time: "Hace 1 hora", const tableMap: Record<string, string> = {
}, meters: "medidor",
{ concentrators: "concentrador",
user: "CESPT", projects: "proyecto",
action: "Creó un payload", users: "usuario",
target: "Payload 12", roles: "rol",
time: "Hace 2 horas", gateways: "gateway",
}, devices: "dispositivo",
{ readings: "lectura",
user: "GRH", webhooks: "webhook",
action: "Actualizó medidor", };
target: "SN002", return tableMap[tableName] || tableName;
time: "Hace 3 horas", };
},
]; const formatRelativeTime = (dateString: string): string => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "Hace un momento";
if (diffMins < 60) return `Hace ${diffMins} minuto${diffMins > 1 ? "s" : ""}`;
if (diffHours < 24) return `Hace ${diffHours} hora${diffHours > 1 ? "s" : ""}`;
if (diffDays < 7) return `Hace ${diffDays} día${diffDays > 1 ? "s" : ""}`;
return date.toLocaleDateString();
};
const formatAuditLog = (log: AuditLog): HistoryItem => {
const action = formatAuditAction(log.action);
const target = formatTableName(log.table_name);
const recordInfo = log.description || log.record_id || target;
return {
user: log.user_name || log.user_email || "Sistema",
action: action,
target: recordInfo,
time: formatRelativeTime(log.created_at),
};
};
const history: HistoryItem[] = useMemo(
() => auditLogs.map(formatAuditLog),
// eslint-disable-next-line react-hooks/exhaustive-deps
[auditLogs]
);
/* ================= KPIs (Optional) ================= */ /* ================= KPIs (Optional) ================= */
@@ -458,24 +517,35 @@ export default function Home({
</div> </div>
</div> </div>
{/* Historial */} {!isOperator && (
<div className="bg-white rounded-xl shadow p-6"> <div className="bg-white rounded-xl shadow p-6">
<h2 className="text-lg font-semibold mb-4">Historial Reciente</h2> <h2 className="text-lg font-semibold mb-4">Historial Reciente de Auditoría</h2>
<ul className="divide-y divide-gray-200 max-h-60 overflow-y-auto"> {loadingAuditLogs ? (
{history.map((h, i) => ( <div className="flex items-center justify-center py-8">
<li key={i} className="py-2 flex items-start gap-3"> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="text-gray-400 mt-1"></span> </div>
<div className="flex-1"> ) : history.length === 0 ? (
<p className="text-sm text-gray-700"> <p className="text-sm text-gray-500 text-center py-8">
<span className="font-semibold">{h.user}</span> {h.action}{" "} No hay registros de auditoría disponibles
<span className="font-medium">{h.target}</span> </p>
</p> ) : (
<p className="text-xs text-gray-400">{h.time}</p> <ul className="divide-y divide-gray-200 max-h-60 overflow-y-auto">
</div> {history.map((h, i) => (
</li> <li key={i} className="py-2 flex items-start gap-3">
))} <span className="text-gray-400 mt-1"></span>
</ul> <div className="flex-1">
</div> <p className="text-sm text-gray-700">
<span className="font-semibold">{h.user}</span> {h.action}{" "}
<span className="font-medium">{h.target}</span>
</p>
<p className="text-xs text-gray-400">{h.time}</p>
</div>
</li>
))}
</ul>
)}
</div>
)}
{/* Últimas alertas */} {/* Últimas alertas */}
<div className="bg-white rounded-xl shadow p-6"> <div className="bg-white rounded-xl shadow p-6">

View File

@@ -28,18 +28,8 @@ export type ProjectCard = {
status: ProjectStatus; status: ProjectStatus;
}; };
type User = {
role: "SUPER_ADMIN" | "USER";
project?: string;
};
export default function ConcentratorsPage() { export default function ConcentratorsPage() {
const currentUser: User = { const c = useConcentrators();
role: "SUPER_ADMIN",
project: "CESPT",
};
const c = useConcentrators(currentUser);
const [typesMenuOpen, setTypesMenuOpen] = useState(false); const [typesMenuOpen, setTypesMenuOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");

View File

@@ -6,14 +6,14 @@ import {
} from "../../api/concentrators"; } from "../../api/concentrators";
import { fetchProjects, type Project } from "../../api/projects"; import { fetchProjects, type Project } from "../../api/projects";
import { fetchMeterTypes, type MeterType } from "../../api/meterTypes"; import { fetchMeterTypes, type MeterType } from "../../api/meterTypes";
import { getCurrentUserRole, getCurrentUserProjectId } from "../../api/auth";
import type { ProjectCard, SampleView } from "./ConcentratorsPage"; import type { ProjectCard, SampleView } from "./ConcentratorsPage";
type User = { export function useConcentrators() {
role: "SUPER_ADMIN" | "USER";
project?: string;
};
export function useConcentrators(currentUser: User) { const userRole = getCurrentUserRole();
const userProjectId = getCurrentUserProjectId();
const isAdmin = userRole?.toUpperCase() === 'ADMIN';
const [sampleView, setSampleView] = useState<SampleView>("GENERAL"); const [sampleView, setSampleView] = useState<SampleView>("GENERAL");
const [loadingProjects, setLoadingProjects] = useState(true); const [loadingProjects, setLoadingProjects] = useState(true);
@@ -51,12 +51,12 @@ export function useConcentrators(currentUser: User) {
const visibleProjects = useMemo( const visibleProjects = useMemo(
() => () =>
currentUser.role === "SUPER_ADMIN" isAdmin
? allProjects ? allProjects
: currentUser.project : userProjectId
? [currentUser.project] ? [userProjectId]
: [], : [],
[allProjects, currentUser.role, currentUser.project] [allProjects, isAdmin, userProjectId]
); );
const loadMeterTypes = async () => { const loadMeterTypes = async () => {
@@ -82,8 +82,8 @@ export function useConcentrators(currentUser: User) {
setSelectedProject((prev) => { setSelectedProject((prev) => {
if (prev) return prev; if (prev) return prev;
if (currentUser.role !== "SUPER_ADMIN" && currentUser.project) { if (!isAdmin && userProjectId) {
return currentUser.project; return userProjectId;
} }
return projectIds[0] ?? ""; return projectIds[0] ?? "";
}); });

View File

@@ -1,12 +1,16 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { fetchMeters, type Meter } from "../../api/meters"; import { fetchMeters, type Meter } from "../../api/meters";
import { fetchProjects } from "../../api/projects"; import { fetchProjects } from "../../api/projects";
import { getCurrentUserRole, getCurrentUserProjectId } from "../../api/auth";
type UseMetersArgs = { type UseMetersArgs = {
initialProject?: string; initialProject?: string;
}; };
export function useMeters({ initialProject }: UseMetersArgs) { export function useMeters({ initialProject }: UseMetersArgs) {
const userRole = getCurrentUserRole();
const userProjectId = getCurrentUserProjectId();
const isAdmin = userRole?.toUpperCase() === 'ADMIN';
const [allProjects, setAllProjects] = useState<string[]>([]); const [allProjects, setAllProjects] = useState<string[]>([]);
const [loadingProjects, setLoadingProjects] = useState(true); const [loadingProjects, setLoadingProjects] = useState(true);
@@ -26,6 +30,12 @@ export function useMeters({ initialProject }: UseMetersArgs) {
setSelectedProject((prev) => { setSelectedProject((prev) => {
if (prev) return prev; if (prev) return prev;
if (initialProject) return initialProject; if (initialProject) return initialProject;
if (!isAdmin && userProjectId) {
const userProject = projects.find(p => p.id === userProjectId);
if (userProject) return userProject.name;
}
return projectNames[0] ?? ""; return projectNames[0] ?? "";
}); });
} catch (error) { } catch (error) {
@@ -62,7 +72,6 @@ export function useMeters({ initialProject }: UseMetersArgs) {
if (initialProject) setSelectedProject(initialProject); if (initialProject) setSelectedProject(initialProject);
}, [initialProject]); }, [initialProject]);
// filter by project
useEffect(() => { useEffect(() => {
if (!selectedProject) { if (!selectedProject) {
setFilteredMeters([]); setFilteredMeters([]);