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 ? : }
+