From ce2cd6b3971475eea34daf7dfa4bcd8c979f089c Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Mon, 20 Oct 2025 17:09:14 -0600 Subject: [PATCH] Eliminada dependencia de llave de servicio para el manejo de usuarios y eliminado el hard-code de los roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit supabase.tsx Se añadieron los roles al contexto de autenticación. Se modificó la interfaz de UserClaims que consiste en la información que se obtiene de los usuarios. Se obtienen los roles desde la base de datos. _authenticated.tsx Ya todos pueden ver el enlace a la página de facultades. login.tsx Se movió el enlace de '¿Olvidaste tu contraseña?' a después del input de la contraseña, para mejorar la usabilidad. usuarios.tsx - La obtención de los usuarios ahora se hace a con el cliente de llave anónima de supabase y se obtiene de tablas en el esquema public a través de una función de PostgreSQL. - La información de los roles se obtiene del contexto de autenticación para mostrarla en la página. - El RolePill se movió a dentro del componente para poder usar la información del contexto. - Se añadieron validaciones para poder crear un usuario. - Se muestra la información para editar los usuarios y se actualiza en la BDD con una función de PostgreSQL. --- src/auth/supabase.tsx | 47 +++- src/routes/_authenticated.tsx | 24 +- src/routes/_authenticated/usuarios.tsx | 346 +++++++++++++++---------- src/routes/login.tsx | 14 +- 4 files changed, 270 insertions(+), 161 deletions(-) diff --git a/src/auth/supabase.tsx b/src/auth/supabase.tsx index 4ba367d..4dd6fea 100644 --- a/src/auth/supabase.tsx +++ b/src/auth/supabase.tsx @@ -10,20 +10,29 @@ export interface SupabaseAuthState { isAuthenticated: boolean user: User | null claims: UserClaims | null + roles: RolCatalogo[] | null login: (email: string, password: string) => Promise logout: () => Promise isLoading: boolean } -type Role = string; +export interface RolCatalogo { + id: string + nombre: string + icono: string + nombre_clase: string + label: string +} -type UserClaims = { - claims_admin: boolean - clave: string +export type Role = string; + +export type UserClaims = { + id: string | null + clave?: string nombre: string apellidos: string - title: string - avatar: string | null + title?: string + avatar?: string | null carrera_id?: string | null facultad_id?: string | null facultad_color?: string | null // 🎨 NEW @@ -35,6 +44,7 @@ const SupabaseAuthContext = createContext(undefin export function SupabaseAuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null) const [claims, setClaims] = useState(null) + const [roles, setRoles] = useState(null) const [isAuthenticated, setIsAuthenticated] = useState(false) const [isLoading, setIsLoading] = useState(true) @@ -52,6 +62,11 @@ export function SupabaseAuthProvider({ children }: { children: React.ReactNode } supabase.auth.getSession().then(({ data: { session } }) => { handleSession(session) }) + + // Carga roles catálogo + fetchRoles().then(fetchedRoles => { + setRoles(fetchedRoles); + }); // Suscripción a cambios de sesión const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { @@ -75,7 +90,7 @@ export function SupabaseAuthProvider({ children }: { children: React.ReactNode } return ( {children} @@ -123,9 +138,25 @@ async function buildClaims(session: Session | null): Promise return null; } - return data[0]; + return { + ...data[0], + id: null + }; } catch (e) { console.error('Error inesperado:', e); return null; } } + +async function fetchRoles(): Promise { + const { data, error } = await supabase + .from("roles_catalogo") + .select("id, nombre, icono, nombre_clase, label"); + + if (error) { + console.error("Error al obtener los roles:", error.message); + return []; + } + + return data || []; +} diff --git a/src/routes/_authenticated.tsx b/src/routes/_authenticated.tsx index 0306b26..f004f36 100644 --- a/src/routes/_authenticated.tsx +++ b/src/routes/_authenticated.tsx @@ -188,18 +188,18 @@ function Sidebar({ onNavigate }: { onNavigate?: () => void }) { )} - {isAdmin && ( - - - Facultades - - )} + + + + Facultades + + diff --git a/src/routes/_authenticated/usuarios.tsx b/src/routes/_authenticated/usuarios.tsx index 4a377a8..4853278 100644 --- a/src/routes/_authenticated/usuarios.tsx +++ b/src/routes/_authenticated/usuarios.tsx @@ -3,6 +3,7 @@ import { createFileRoute, useRouter } from "@tanstack/react-router" import { useMemo, useState } from "react" import { useSuspenseQuery, queryOptions, useMutation, useQueryClient } from "@tanstack/react-query" import { supabase, useSupabaseAuth } from "@/auth/supabase" +import type { Role, UserClaims } from "@/auth/supabase" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" @@ -10,58 +11,19 @@ import { Badge } from "@/components/ui/badge" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" import { Label } from "@/components/ui/label" import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select" -import { - RefreshCcw, ShieldCheck, ShieldAlert, Pencil, Mail, - Cpu, Building2, ScrollText, GraduationCap, GanttChart, Plus, Eye, EyeOff, - Ban as BanIcon, Check -} from "lucide-react" -import { SupabaseClient } from "@supabase/supabase-js" +import * as Icons from "lucide-react" import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox" import { toast } from "sonner" + + /* -------------------- Tipos -------------------- */ -type User = { - id: string - email: string | null - created_at: string - last_sign_in_at: string | null - user_metadata: any - app_metadata: any - banned_until?: string | null -} + const SCOPED_ROLES = ["director_facultad", "secretario_academico", "jefe_carrera"] as const -const ROLES = [ - "lci", - "vicerrectoria", - "director_facultad", - "secretario_academico", - "jefe_carrera", - "planeacion", -] as const -export type Role = typeof ROLES[number] -const ROLE_META: Record>; className: string }> = { - lci: { label: "Laboratorio de Cómputo de Ingeniería", Icon: Cpu, className: "bg-neutral-900 text-white" }, - vicerrectoria: { label: "Vicerrectoría Académica", Icon: Building2, className: "bg-indigo-600 text-white" }, - director_facultad: { label: "Director(a) de Facultad", Icon: Building2, className: "bg-purple-600 text-white" }, - secretario_academico: { label: "Secretario Académico", Icon: ScrollText, className: "bg-emerald-600 text-white" }, - jefe_carrera: { label: "Jefe de Carrera", Icon: GraduationCap, className: "bg-orange-600 text-white" }, - planeacion: { label: "Planeación Curricular", Icon: GanttChart, className: "bg-sky-600 text-white" }, -} -function RolePill({ role }: { role: Role }) { - const meta = ROLE_META[role] - if (!meta) return null - const { Icon, className, label } = meta - return ( - - - {label} - - ) -} /* -------------------- Query Keys & Fetcher -------------------- */ const usersKeys = { @@ -69,13 +31,58 @@ const usersKeys = { list: () => [...usersKeys.root, "list"] as const, } -async function fetchUsers(): Promise { - // ⚠️ Dev only: service role en cliente - const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY) - const { data } = await admin.auth.admin.listUsers() - return (data?.users ?? []) as User[] +async function fetchUsers(): Promise { + try { + const { data: perfiles, error } = await supabase.from("perfiles").select("id"); + + if (error) { + console.error("Error al obtener usuarios:", error.message); + return []; // Devuelve un arreglo vacío en caso de error + } + + if (!perfiles || perfiles.length === 0) { + console.log("No se encontraron perfiles."); + return []; // Devuelve un arreglo vacío si no hay datos + } + + // Llama a `obtener_claims_usuario` para cada perfil + const usuarios = await Promise.all( + perfiles.map(async (perfil) => { + const { data: claims, error: rpcError } = await supabase.rpc("obtener_claims_usuario", { + p_user_id: perfil.id, // Pasa el ID del perfil como parámetro + }); + + console.log("Claims para perfil", perfil.id, claims[0]); + if (rpcError) { + console.error(`Error al obtener claims para el perfil ${perfil.id}:`, rpcError.message); + return null; // Devuelve null si hay un error + } + + return { + id: perfil.id, + role: claims[0]?.role, + title: claims[0]?.title, + facultad_id: claims[0]?.facultad_id, + carrera_id: claims[0]?.carrera_id, + facultad_color: claims[0]?.facultad_color, + clave: claims[0]?.clave, + nombre: claims[0]?.nombre, + apellidos: claims[0]?.apellidos, + avatar: claims[0]?.avatar, + }; + }) + ); + + // Filtra los resultados nulos (errores en las llamadas RPC) + return usuarios.filter((u) => u !== null) as UserClaims[]; + } catch (err) { + console.error("Error inesperado:", err); + return []; // Devuelve un arreglo vacío en caso de error inesperado + } } + + const usersOptions = () => queryOptions({ queryKey: usersKeys.list(), queryFn: fetchUsers, staleTime: 60_000 }) @@ -91,12 +98,37 @@ export const Route = createFileRoute("/_authenticated/usuarios")({ /* -------------------- Página -------------------- */ function RouteComponent() { const auth = useSupabaseAuth() + + if (auth.claims?.role !== "lci" && auth.claims?.role !== "vicerrectoria") { + return
No tienes permisos para administrar usuarios.
+ } + + const { ROLES, ROLE_META } = useMemo(() => { + if (!auth.roles) return { ROLES: [], ROLE_META: {} }; + + // Construir ROLES como un arreglo de strings + const rolesArray = auth.roles.map((role) => role.nombre); + + // Construir ROLE_META como un objeto basado en ROLES + const rolesMeta = auth.roles.reduce((acc, role) => { + acc[role.nombre] = { + id: role.id, + label: role.label, + Icon: (Icons as any)[role.icono] || Icons.Cpu, // Icono por defecto si no está definido + className: role.nombre_clase || "bg-gray-500 text-white", // Clase por defecto si no está definida + }; + return acc; + }, {} as Record>; className: string }>); + + return { ROLES: rolesArray, ROLE_META: rolesMeta }; + }, [auth.roles]); + const router = useRouter() const qc = useQueryClient() const { data } = useSuspenseQuery(usersOptions()) const [q, setQ] = useState("") - const [editing, setEditing] = useState(null) + const [editing, setEditing] = useState(null) const [form, setForm] = useState<{ role?: Role claims_admin?: boolean @@ -118,10 +150,47 @@ function RouteComponent() { }>({ email: "", password: "" }) function genPassword() { + /* + Supabase requiere que las contraseñas tengan las siguientes características: + - Mínimo de 6 caracteres + - Debe contener al menos una letra minúscula + - Debe contener al menos una letra mayúscula + - Debe contener al menos un número + - Debe contener al menos un carácter especial + Para garantizar la seguridad, generaremos contraseñas de 12 caracteres en vez del mínimo de 6 + */ + + // 1. Generar una permutación de los números de 1 al 12 con el método Fisher-Yates + + const positions = Array.from({ length: 12 }, (_, i) => i); + for (let i = positions.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [positions[i], positions[j]] = [positions[j], positions[i]]; + } + + // 2. Las correspondencias son las siguientes: + // - El primer número indica la posición de la letra minúscula + // - El segundo número indica la posición de la letra mayúscula + // - El tercer número indica la posición del número + // - El cuarto número indica la posición del carácter especial + // - En las demás posiciones puede haber cualquier caracter alfanumérico + const s = Array.from(crypto.getRandomValues(new Uint32Array(4))).map((n) => n.toString(36)).join("") return s.slice(0, 14) } + function RolePill({ role }: { role: Role }) { + const meta = ROLE_META[role] + if (!meta) return null + const { Icon, className, label } = meta + return ( + + + {label} + + ) + } + /* ---------- Mutations ---------- */ const invalidateAll = async () => { await qc.invalidateQueries({ queryKey: usersKeys.root }) @@ -167,11 +236,13 @@ function RouteComponent() { }) const toggleBan = useMutation({ - mutationFn: async (u: User) => { - const banned = !!u.banned_until && new Date(u.banned_until) > new Date() + mutationFn: async (u: UserClaims) => { + throw new Error("Funcionalidad de baneo no implementada aún.") + const banned = false // cuando se tenga acceso a ese campo + // const banned = !!u.banned_until && new Date(u.banned_until) > new Date() const payload = banned ? { banned_until: null } : { banned_until: new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString() } - const { error } = await supabase.auth.admin.updateUserById(u.id, payload as any) - if (error) throw new Error(error.message) + // const { error } = await supabase.auth.admin.updateUserById(u.id, payload as any) + // if (error) throw new Error(error.message) return !banned }, onSuccess: async (isBanned) => { @@ -183,39 +254,42 @@ function RouteComponent() { const createUser = useMutation({ mutationFn: async (payload: typeof createForm) => { - const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY) - const password = payload.password?.trim() || genPassword() - const { error, data } = await admin.auth.admin.createUser({ + // Validaciones previas + if (!payload.role) { + throw new Error("Selecciona un rol para el usuario."); + } + if ((payload.role === "secretario_academico" || payload.role === "director_facultad") && !payload.facultad_id) { + throw new Error("Selecciona una facultad para este rol."); + } + if (payload.role === "jefe_carrera" && (!payload.facultad_id || !payload.carrera_id)) { + throw new Error("Selecciona una facultad y una carrera para este rol."); + } + + const password = payload.password?.trim() + const { data, error } = await supabase.auth.signUp({ email: payload.email.trim(), password, - email_confirm: true, - user_metadata: { - nombre: payload.nombre ?? "", - apellidos: payload.apellidos ?? "", - title: payload.title ?? "", - clave: payload.clave ?? "", - avatar: payload.avatar ?? "", - }, - app_metadata: { - role: payload.role, - claims_admin: !!payload.claims_admin, - facultad_id: payload.facultad_id ?? null, - carrera_id: payload.carrera_id ?? null, - }, - }) - if (error) throw new Error(error.message) - const uid = data.user?.id - if (uid && payload.role && (SCOPED_ROLES as readonly string[]).includes(payload.role)) { - if (payload.role === "director_facultad") { - if (!payload.facultad_id) throw new Error("Selecciona facultad") - await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "director_facultad", facultad_id: payload.facultad_id }) - } else if (payload.role === "secretario_academico") { - if (!payload.facultad_id) throw new Error("Selecciona facultad") - await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "secretario_academico", facultad_id: payload.facultad_id }) - } else if (payload.role === "jefe_carrera") { - if (!payload.facultad_id || !payload.carrera_id) throw new Error("Selecciona facultad y carrera") - await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "jefe_carrera", facultad_id: payload.facultad_id, carrera_id: payload.carrera_id }) + options: { + data: { + nombre: payload.nombre ?? "", + apellidos: payload.apellidos ?? "", + title: payload.title ?? "", + clave: payload.clave ?? "", + avatar: payload.avatar ?? "", + role: payload.role, + role_id: payload.role ? ROLE_META[payload.role]?.id : null, + facultad_id: payload.facultad_id ?? null, + carrera_id: payload.carrera_id ?? null, + } } + }); + + if (error) throw new Error(error.message) + + const uid = data.user?.id + + if(!uid) { + throw new Error("No se pudo obtener el ID del usuario creado."); } }, onSuccess: async () => { @@ -228,19 +302,23 @@ function RouteComponent() { }) const saveUser = useMutation({ - mutationFn: async ({ u, f }: { u: User; f: typeof form }) => { - // 1) Actualiza metadatos (tu Edge Function; placeholder aquí) - // await fetch('/functions/update-user', { method: 'POST', body: JSON.stringify({ id: u.id, ...f }) }) - // Simula éxito: - // 2) Nombramiento si aplica - if (f.role && (SCOPED_ROLES as readonly string[]).includes(f.role)) { - if (f.role === "director_facultad") { - await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "director_facultad", facultad_id: f.facultad_id! }) - } else if (f.role === "secretario_academico") { - await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "secretario_academico", facultad_id: f.facultad_id! }) - } else if (f.role === "jefe_carrera") { - await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "jefe_carrera", facultad_id: f.facultad_id!, carrera_id: f.carrera_id! }) + mutationFn: async ({ u, f }: { u: UserClaims; f: typeof form }) => { + + const { error } = await supabase.rpc('actualizar_perfil_y_rol', { + datos: { + user_id: u.id, + rol_nombre: f.role, + titulo: f.title, + facultad_id: f.facultad_id, + carrera_id: f.carrera_id, + nombre: f.nombre, + apellidos: f.apellidos, + avatar: f.avatar, } + }); + + if (error) { + throw new Error(error.message); } }, onSuccess: async () => { @@ -251,34 +329,29 @@ function RouteComponent() { onError: (e: any) => toast.error(e?.message || "No se pudo guardar"), }) - if (auth.claims?.role !== "lci" && auth.claims?.role !== "vicerrectoria") { - return
No tienes permisos para administrar usuarios.
- } - const filtered = useMemo(() => { const t = q.trim().toLowerCase() if (!t) return data return data.filter((u) => { - const role: Role | undefined = u.app_metadata?.role + const role: Role | undefined = u.role const label = role ? ROLE_META[role]?.label : "" - return [u.email, u.user_metadata?.nombre, u.user_metadata?.apellidos, label] + return [u.nombre, u.apellidos, label] .filter(Boolean) .some((v) => String(v).toLowerCase().includes(t)) }) }, [q, data]) - function openEdit(u: User) { + function openEdit(u: UserClaims) { setEditing(u) setForm({ - role: u.app_metadata?.role, - claims_admin: !!u.app_metadata?.claims_admin, - nombre: u.user_metadata?.nombre ?? "", - apellidos: u.user_metadata?.apellidos ?? "", - title: u.user_metadata?.title ?? "", - clave: u.user_metadata?.clave ?? "", - avatar: u.user_metadata?.avatar ?? "", - facultad_id: u.app_metadata?.facultad_id ?? null, - carrera_id: u.app_metadata?.carrera_id ?? null, + role: u.role, + nombre: u.nombre ?? "", + apellidos: u.apellidos ?? "", + title: u.title ?? "", + clave: u.clave ?? "", + avatar: u.avatar ?? "", + facultad_id: u.facultad_id ?? null, + carrera_id: u.carrera_id ?? null, }) } @@ -301,10 +374,10 @@ function RouteComponent() {
setQ(e.target.value)} className="w-full" />
@@ -312,48 +385,48 @@ function RouteComponent() {
{filtered.map((u) => { - const m = u.user_metadata || {} - const a = u.app_metadata || {} - const roleCode: Role | undefined = a.role - const banned = !!(u as any).banned_until && new Date((u as any).banned_until).getTime() > Date.now() + const roleCode: Role | undefined = u.role + const banned = false // cuando se tenga acceso a ese campo + // const banned = !!(u as any).banned_until && new Date((u as any).banned_until).getTime() > Date.now() return (
- +
-
{m.title ? `${m.title} ` : ""}{m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}
+
{u.title ? `${u.title} ` : ""}{u.nombre ? `${u.nombre} ${u.apellidos ?? ""}` : /* (u.email ?? "—") */ "—"}
{roleCode && } - {a.claims_admin ? ( - Admin + {u.role === "lci" || u.role === "vicerrectoria" ? ( + Admin ) : ( - Usuario + Usuario )} - {banned ? : } {banned ? "Baneado" : "Activo"} + {banned ? : } {banned ? "Baneado" : "Activo"}
- {u.email ?? "—"} + {/* Cuando se tenga acceso a esta info, se mostrará + {u.email ?? "—"} Creado: {new Date(u.created_at).toLocaleDateString()} - Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"} + Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"} */}
- - + +
@@ -422,6 +495,7 @@ function RouteComponent() {
)} + {/* Probablemente ya no sea necesario
-
+ */} @@ -463,9 +537,9 @@ function RouteComponent() {
- setCreateForm((s) => ({ ...s, password: e.target.value }))} placeholder="Se generará si la dejas vacía" /> - - + setCreateForm((s) => ({ ...s, password: e.target.value }))} placeholder="abCD12&;" /> + {/* */} +

Pídeles cambiarla al iniciar sesión.

@@ -483,6 +557,7 @@ function RouteComponent() { onValueChange={(v) => { setCreateForm((s) => { const role = v as Role + console.log("Rol seleccionado: ", role, ROLE_META[role]); if (role === "jefe_carrera") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: s.carrera_id ?? "" } if (role === "secretario_academico" || role === "director_facultad") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: null } return { ...s, role, facultad_id: null, carrera_id: null } @@ -523,6 +598,7 @@ function RouteComponent() { )} + {/* Probablemente ya no sea necesario
-
+ */} - diff --git a/src/routes/login.tsx b/src/routes/login.tsx index f63c40f..99e114b 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -95,12 +95,6 @@ function LoginComponent() {
@@ -124,6 +118,14 @@ function LoginComponent() { {showPassword ? : }
+