// routes/_authenticated/usuarios.tsx 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" 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 { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox" import { toast } from "sonner" /* -------------------- Tipos -------------------- */ type AdminUser = { 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 = { root: ["usuarios"] as const, 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 AdminUser[] } const usersOptions = () => queryOptions({ queryKey: usersKeys.list(), queryFn: fetchUsers, staleTime: 60_000 }) /* -------------------- Ruta -------------------- */ export const Route = createFileRoute("/_authenticated/usuarios")({ component: RouteComponent, loader: async ({ context: { queryClient } }) => { await queryClient.ensureQueryData(usersOptions()) return null }, }) /* -------------------- Página -------------------- */ function RouteComponent() { const auth = useSupabaseAuth() const router = useRouter() const qc = useQueryClient() const { data } = useSuspenseQuery(usersOptions()) const [q, setQ] = useState("") const [editing, setEditing] = useState(null) const [form, setForm] = useState<{ role?: Role claims_admin?: boolean nombre?: string; apellidos?: string; title?: string; clave?: string; avatar?: string facultad_id?: string | null carrera_id?: string | null }>({}) const [createOpen, setCreateOpen] = useState(false) const [showPwd, setShowPwd] = useState(false) const [createForm, setCreateForm] = useState<{ email: string password: string role?: Role claims_admin?: boolean nombre?: string; apellidos?: string; title?: string; clave?: string; avatar?: string facultad_id?: string | null carrera_id?: string | null }>({ email: "", password: "" }) function genPassword() { const s = Array.from(crypto.getRandomValues(new Uint32Array(4))).map((n) => n.toString(36)).join("") return s.slice(0, 14) } /* ---------- Mutations ---------- */ const invalidateAll = async () => { await qc.invalidateQueries({ queryKey: usersKeys.root }) router.invalidate() } const upsertNombramiento = useMutation({ mutationFn: async (opts: { user_id: string puesto: "director_facultad" | "secretario_academico" | "jefe_carrera" facultad_id?: string | null carrera_id?: string | null }) => { // cierra vigentes if (opts.puesto === "jefe_carrera") { if (!opts.carrera_id) throw new Error("Selecciona carrera") await supabase .from("nombramientos") .update({ hasta: new Date().toISOString().slice(0, 10) }) .eq("puesto", "jefe_carrera") .eq("carrera_id", opts.carrera_id) .is("hasta", null) } else { if (!opts.facultad_id) throw new Error("Selecciona facultad") await supabase .from("nombramientos") .update({ hasta: new Date().toISOString().slice(0, 10) }) .eq("puesto", opts.puesto) .eq("facultad_id", opts.facultad_id) .is("hasta", null) } const { error } = await supabase.from("nombramientos").insert({ user_id: opts.user_id, puesto: opts.puesto, facultad_id: opts.facultad_id ?? null, carrera_id: opts.carrera_id ?? null, desde: new Date().toISOString().slice(0, 10), hasta: null, }) if (error) throw error }, onError: (e: any) => toast.error(e?.message || "Error al registrar nombramiento"), }) const toggleBan = useMutation({ mutationFn: async (u: AdminUser) => { 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) return !banned }, onSuccess: async (isBanned) => { toast.success(isBanned ? "Usuario baneado" : "Usuario desbaneado") await invalidateAll() }, onError: (e: any) => toast.error(e?.message || "Error al cambiar estado de baneo"), }) 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({ 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 }) } } }, onSuccess: async () => { toast.success("Usuario creado") setCreateOpen(false) setCreateForm({ email: "", password: "" }) await invalidateAll() }, onError: (e: any) => toast.error(e?.message || "No se pudo crear el usuario"), }) const saveUser = useMutation({ mutationFn: async ({ u, f }: { u: AdminUser; 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! }) } } }, onSuccess: async () => { toast.success("Cambios guardados") setEditing(null) await invalidateAll() }, onError: (e: any) => toast.error(e?.message || "No se pudo guardar"), }) if (!auth.claims?.claims_admin) { 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 label = role ? ROLE_META[role]?.label : "" return [u.email, u.user_metadata?.nombre, u.user_metadata?.apellidos, label] .filter(Boolean) .some((v) => String(v).toLowerCase().includes(t)) }) }, [q, data]) function openEdit(u: AdminUser) { 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, }) } function validateScopeForSave(): string | null { if (!editing) return "Sin usuario" if (form.role === "director_facultad" || form.role === "secretario_academico") { if (!form.facultad_id) return "Selecciona una facultad" } if (form.role === "jefe_carrera") { if (!form.facultad_id || !form.carrera_id) return "Selecciona facultad y carrera" } return null } return (
Usuarios
setQ(e.target.value)} className="w-full" />
{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() return (
{m.title ? `${m.title} ` : ""}{m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}
{roleCode && } {a.claims_admin ? ( Admin ) : ( Usuario )} {banned ? : } {banned ? "Baneado" : "Activo"}
{u.email ?? "—"} Creado: {new Date(u.created_at).toLocaleDateString()} Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}
) })} {!filtered.length &&
Sin usuarios
}
{/* Dialog de edición */} { if (!o) setEditing(null) }}> Editar usuario
setForm((s) => ({ ...s, nombre: e.target.value }))} />
setForm((s) => ({ ...s, apellidos: e.target.value }))} />
setForm((s) => ({ ...s, title: e.target.value }))} />
setForm((s) => ({ ...s, clave: e.target.value }))} />
setForm((s) => ({ ...s, avatar: e.target.value }))} />
{(form.role === "secretario_academico" || form.role === "director_facultad") && (
setForm((s) => ({ ...s, facultad_id: id, carrera_id: null }))} />

Este rol requiere Facultad.

)} {form.role === "jefe_carrera" && (
setForm((s) => ({ ...s, facultad_id: id, carrera_id: "" }))} />
setForm((s) => ({ ...s, carrera_id: id }))} disabled={!form.facultad_id} />
)}
{/* Modal: Nuevo usuario */} Nuevo usuario
setCreateForm((s) => ({ ...s, email: e.target.value }))} placeholder="usuario@lasalle.mx" />
setCreateForm((s) => ({ ...s, password: e.target.value }))} placeholder="Se generará si la dejas vacía" />

Pídeles cambiarla al iniciar sesión.

setCreateForm((s) => ({ ...s, nombre: e.target.value }))} />
setCreateForm((s) => ({ ...s, apellidos: e.target.value }))} />
setCreateForm((s) => ({ ...s, title: e.target.value }))} />
setCreateForm((s) => ({ ...s, clave: e.target.value }))} />
setCreateForm((s) => ({ ...s, avatar: e.target.value }))} />
{(createForm.role === "secretario_academico" || createForm.role === "director_facultad") && (
setCreateForm((s) => ({ ...s, facultad_id: id, carrera_id: null }))} />
)} {createForm.role === "jefe_carrera" && (
setCreateForm((s) => ({ ...s, facultad_id: id, carrera_id: "" }))} />
setCreateForm((s) => ({ ...s, carrera_id: id }))} disabled={!createForm.facultad_id} />
)}
) }