|
|
|
|
@@ -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<Role, { label: string; Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; 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 (
|
|
|
|
|
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] sm:text-[11px] ${className} max-w-[160px] sm:max-w-none truncate`} title={label}>
|
|
|
|
|
<Icon className="h-3 w-3 shrink-0" />
|
|
|
|
|
<span className="truncate">{label}</span>
|
|
|
|
|
</span>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* -------------------- Query Keys & Fetcher -------------------- */
|
|
|
|
|
const usersKeys = {
|
|
|
|
|
@@ -69,13 +31,58 @@ const usersKeys = {
|
|
|
|
|
list: () => [...usersKeys.root, "list"] as const,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchUsers(): Promise<User[]> {
|
|
|
|
|
// ⚠️ 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<UserClaims[]> {
|
|
|
|
|
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 <div className="p-6 text-sm text-red-600">No tienes permisos para administrar usuarios.</div>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<string, { id: string; label: string; Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; 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<User | null>(null)
|
|
|
|
|
const [editing, setEditing] = useState<UserClaims | null>(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 (
|
|
|
|
|
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] sm:text-[11px] ${className} max-w-[160px] sm:max-w-none truncate`} title={label}>
|
|
|
|
|
<Icon className="h-3 w-3 shrink-0" />
|
|
|
|
|
<span className="truncate">{label}</span>
|
|
|
|
|
</span>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------- 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,40 +254,43 @@ 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: {
|
|
|
|
|
options: {
|
|
|
|
|
data: {
|
|
|
|
|
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,
|
|
|
|
|
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 && 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 })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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 () => {
|
|
|
|
|
toast.success("Usuario creado")
|
|
|
|
|
@@ -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 <div className="p-6 text-sm text-red-600">No tienes permisos para administrar usuarios.</div>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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() {
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Input placeholder="Buscar por nombre, email o rol…" value={q} onChange={(e) => setQ(e.target.value)} className="w-full" />
|
|
|
|
|
<Button variant="outline" size="icon" title="Recargar" onClick={invalidateAll}>
|
|
|
|
|
<RefreshCcw className="w-4 h-4" />
|
|
|
|
|
<Icons.RefreshCcw className="w-4 h-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button onClick={() => setCreateOpen(true)} className="whitespace-nowrap">
|
|
|
|
|
<Plus className="w-4 h-4 mr-1" /> Nuevo usuario
|
|
|
|
|
<Icons.Plus className="w-4 h-4 mr-1" /> Nuevo usuario
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
@@ -312,48 +385,48 @@ function RouteComponent() {
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="grid gap-3">
|
|
|
|
|
{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 (
|
|
|
|
|
<div key={u.id} className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-3 sm:p-4 hover:shadow-sm transition">
|
|
|
|
|
<div className="flex items-start gap-3 sm:gap-4">
|
|
|
|
|
<img src={m.avatar || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(m.nombre || u.email || "U")}`} alt="" className="h-10 w-10 rounded-full object-cover" />
|
|
|
|
|
<img src={u.avatar || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(u.nombre || /* u.email || */ "U")}`} alt="" className="h-10 w-10 rounded-full object-cover" />
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<div className="flex items-start justify-between gap-2">
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<div className="font-medium truncate">{m.title ? `${m.title} ` : ""}{m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}</div>
|
|
|
|
|
<div className="font-medium truncate">{u.title ? `${u.title} ` : ""}{u.nombre ? `${u.nombre} ${u.apellidos ?? ""}` : /* (u.email ?? "—") */ "—"}</div>
|
|
|
|
|
<div className="mt-1 flex flex-wrap items-center gap-1.5 sm:gap-2">
|
|
|
|
|
{roleCode && <RolePill role={roleCode} />}
|
|
|
|
|
{a.claims_admin ? (
|
|
|
|
|
<Badge className="gap-1" variant="secondary"><ShieldCheck className="w-3 h-3" /> Admin</Badge>
|
|
|
|
|
{u.role === "lci" || u.role === "vicerrectoria" ? (
|
|
|
|
|
<Badge className="gap-1" variant="secondary"><Icons.ShieldCheck className="w-3 h-3" /> Admin</Badge>
|
|
|
|
|
) : (
|
|
|
|
|
<Badge className="gap-1" variant="outline"><ShieldAlert className="w-3 h-3" /> Usuario</Badge>
|
|
|
|
|
<Badge className="gap-1" variant="outline"><Icons.ShieldAlert className="w-3 h-3" /> Usuario</Badge>
|
|
|
|
|
)}
|
|
|
|
|
<Badge variant={banned ? ("destructive" as any) : "secondary"} className="gap-1">
|
|
|
|
|
{banned ? <BanIcon className="w-3 h-3" /> : <Check className="w-3 h-3" />} {banned ? "Baneado" : "Activo"}
|
|
|
|
|
{banned ? <Icons.BanIcon className="w-3 h-3" /> : <Icons.Check className="w-3 h-3" />} {banned ? "Baneado" : "Activo"}
|
|
|
|
|
</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<Button variant="outline" size="sm" onClick={() => toggleBan.mutate(u)} title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} className="hidden sm:inline-flex">
|
|
|
|
|
<BanIcon className="w-4 h-4 mr-1" /> {banned ? "Restaurar acceso" : "Inhabilitar la cuenta"}
|
|
|
|
|
<Icons.BanIcon className="w-4 h-4 mr-1" /> {banned ? "Restaurar acceso" : "Inhabilitar la cuenta"}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}>
|
|
|
|
|
<Pencil className="w-4 h-4 mr-1" /> Editar
|
|
|
|
|
<Icons.Pencil className="w-4 h-4 mr-1" /> Editar
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] sm:text-xs text-neutral-600">
|
|
|
|
|
<span className="inline-flex items-center gap-1"><Mail className="w-3 h-3" /> {u.email ?? "—"}</span>
|
|
|
|
|
{/* Cuando se tenga acceso a esta info, se mostrará
|
|
|
|
|
<span className="inline-flex items-center gap-1"><Icons.Mail className="w-3 h-3" /> {u.email ?? "—"}</span>
|
|
|
|
|
<span className="hidden xs:inline">Creado: {new Date(u.created_at).toLocaleDateString()}</span>
|
|
|
|
|
<span className="hidden md:inline">Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}</span>
|
|
|
|
|
<span className="hidden md:inline">Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}</span> */}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="sm:hidden self-start shrink-0 flex gap-1">
|
|
|
|
|
<Button variant="outline" size="icon" onClick={() => toggleBan.mutate(u)} aria-label="Ban/Unban"><BanIcon className="w-4 h-4" /></Button>
|
|
|
|
|
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><Pencil className="w-4 h-4" /></Button>
|
|
|
|
|
<Button variant="outline" size="icon" onClick={() => toggleBan.mutate(u)} aria-label="Ban/Unban"><Icons.BanIcon className="w-4 h-4" /></Button>
|
|
|
|
|
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><Icons.Pencil className="w-4 h-4" /></Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
@@ -422,6 +495,7 @@ function RouteComponent() {
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Probablemente ya no sea necesario
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Label>Permisos</Label>
|
|
|
|
|
<Select value={String(!!form.claims_admin)} onValueChange={(v) => setForm((s) => ({ ...s, claims_admin: v === 'true' }))}>
|
|
|
|
|
@@ -431,7 +505,7 @@ function RouteComponent() {
|
|
|
|
|
<SelectItem value="false">Usuario</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</div> */}
|
|
|
|
|
</div>
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
|
|
|
|
|
@@ -463,9 +537,9 @@ function RouteComponent() {
|
|
|
|
|
<div className="space-y-1 md:col-span-2">
|
|
|
|
|
<Label>Contraseña temporal</Label>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<Input type={showPwd ? "text" : "password"} value={createForm.password} onChange={(e) => setCreateForm((s) => ({ ...s, password: e.target.value }))} placeholder="Se generará si la dejas vacía" />
|
|
|
|
|
<Button type="button" variant="outline" onClick={() => setCreateForm((s) => ({ ...s, password: genPassword() }))}>Generar</Button>
|
|
|
|
|
<Button type="button" variant="outline" onClick={() => setShowPwd((v) => !v)} aria-label="Mostrar u ocultar">{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}</Button>
|
|
|
|
|
<Input type={showPwd ? "text" : "password"} value={createForm.password} onChange={(e) => setCreateForm((s) => ({ ...s, password: e.target.value }))} placeholder="abCD12&;" />
|
|
|
|
|
{/* <Button type="button" variant="outline" onClick={() => setCreateForm((s) => ({ ...s, password: genPassword() }))}>Generar</Button> */}
|
|
|
|
|
<Button type="button" variant="outline" onClick={() => setShowPwd((v) => !v)} aria-label="Mostrar u ocultar">{showPwd ? <Icons.EyeOff className="w-4 h-4" /> : <Icons.Eye className="w-4 h-4" />}</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-[11px] text-neutral-500">Pídeles cambiarla al iniciar sesión.</p>
|
|
|
|
|
</div>
|
|
|
|
|
@@ -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() {
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Probablemente ya no sea necesario
|
|
|
|
|
<div className="space-y-1 md:col-span-2">
|
|
|
|
|
<Label>Permisos</Label>
|
|
|
|
|
<Select value={String(!!createForm.claims_admin)} onValueChange={(v) => setCreateForm((s) => ({ ...s, claims_admin: v === "true" }))}>
|
|
|
|
|
@@ -532,12 +608,12 @@ function RouteComponent() {
|
|
|
|
|
<SelectItem value="false">Usuario</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</div> */}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
|
|
|
|
<Button onClick={() => createUser.mutate(createForm)} disabled={!createForm.email || createUser.isPending}>
|
|
|
|
|
<Button onClick={() => createUser.mutate(createForm)} disabled={!createForm.email || !createForm.password || createUser.isPending}>
|
|
|
|
|
{createUser.isPending ? "Creando…" : "Crear usuario"}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
|