Ahora se obtienen claims de las tablas en el esquema public, en vez de la información de sesion del usuario, que se obtiene de la tabla auth.users

En supabase.tsx se sustituyó la manera de obtener los claims del usuario, utilizando ahora un rpc de una función en supabase.
This commit is contained in:
2025-10-10 17:23:37 -06:00
parent c49c0bbc0a
commit f2b3010ac9
6 changed files with 70 additions and 79 deletions

23
.vscode/launch.json vendored
View File

@@ -4,12 +4,19 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"type": "msedge", "type": "chrome",
"request": "launch", "request": "launch",
"name": "Launch Edge against localhost", "name": "Launch Chrome against localhost",
"url": "http://localhost:3000", "url": "http://localhost:3000",
"webRoot": "${workspaceFolder}" "webRoot": "${workspaceFolder}"
} },
] {
"type": "msedge",
"request": "launch",
"name": "Launch Edge against localhost",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
}
]
} }

View File

@@ -15,13 +15,7 @@ export interface SupabaseAuthState {
isLoading: boolean isLoading: boolean
} }
type Role = type Role = string;
| 'lci'
| 'vicerrectoria'
| 'director_facultad' // 👈 NEW
| 'secretario_academico'
| 'jefe_carrera'
| 'planeacion'
type UserClaims = { type UserClaims = {
claims_admin: boolean claims_admin: boolean
@@ -45,24 +39,25 @@ export function SupabaseAuthProvider({ children }: { children: React.ReactNode }
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
useEffect(() => { useEffect(() => {
// Función para manejar la sesión
const handleSession = async (session: Session | null) => {
const u = session?.user ?? null
setUser(u)
setIsAuthenticated(!!u)
setClaims(await buildClaims(session))
setIsLoading(false)
}
// Carga inicial // Carga inicial
supabase.auth.getSession().then(async ({ data: { session } }) => { supabase.auth.getSession().then(({ data: { session } }) => {
const u = session?.user ?? null handleSession(session)
setUser(u)
setIsAuthenticated(!!u)
setClaims(await buildClaims(session))
setIsLoading(false)
}) })
// Suscripción a cambios de sesión // Suscripción a cambios de sesión
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (_event, session) => { const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
const u = session?.user ?? null handleSession(session)
setUser(u)
setIsAuthenticated(!!u)
setClaims(await buildClaims(session))
setIsLoading(false)
}) })
return () => subscription.unsubscribe() return () => subscription.unsubscribe()
}, []) }, [])
@@ -99,49 +94,38 @@ export function useSupabaseAuth() {
* Helpers * Helpers
* ===================== */ * ===================== */
// Unifica extracción de metadatos y resuelve facultad_color si hay facultad_id // Obtiene los claims del usuario desde la base de datos a partir de una función en la BDD
async function buildClaims(session: Session | null): Promise<UserClaims | null> { async function buildClaims(session: Session | null): Promise<UserClaims | null> {
const u = session?.user // Validar sesión
if (!u) return null if (!session || !session.user) {
console.warn('No session or user found');
const app = (u.app_metadata ?? {}) as Partial<UserClaims> & { role?: Role } return null;
const meta = (u.user_metadata ?? {}) as Partial<UserClaims>
// Mezcla segura: app_metadata > user_metadata (para campos de claims)
const base: Partial<UserClaims> = {
claims_admin: !!(app.claims_admin ?? (meta as any).claims_admin),
role: (app.role as Role | undefined) ?? ('lci' as Role),
facultad_id: app.facultad_id ?? meta.facultad_id ?? null,
carrera_id: app.carrera_id ?? meta.carrera_id ?? null,
clave: (meta.clave as string) ?? '',
nombre: (meta.nombre as string) ?? '',
apellidos: (meta.apellidos as string) ?? '',
title: (meta.title as string) ?? '',
avatar: (meta.avatar as string) ?? null,
} }
const u = session.user;
let facultad_color: string | null = null
if (base.facultad_id) {
// Lee color desde public.facultades
const { data, error } = await supabase
.from('facultades')
.select('color')
.eq('id', base.facultad_id)
.maybeSingle()
if (!error && data) facultad_color = (data as any)?.color ?? null try{
} const result = await supabase.rpc('obtener_claims_usuario', {
p_user_id: u.id,
});
const data: UserClaims[] | null = result.data;
const error = result.error;
return { if (error) {
claims_admin: !!base.claims_admin, console.error('Error al obtener la información:', error);
role: (base.role ?? 'lci') as Role, throw new Error('Error al obtener la información del usuario');
clave: base.clave ?? '', }
nombre: base.nombre ?? '',
apellidos: base.apellidos ?? '', console.log(data);
title: base.title ?? '', if (!data || data.length === 0) {
avatar: base.avatar ?? null, console.warn('No se encontró información para el usuario');
facultad_id: (base.facultad_id as string | null) ?? null, return null;
carrera_id: (base.carrera_id as string | null) ?? null, }
facultad_color, // 🎨
return data[0];
} catch (e) {
console.error('Error inesperado:', e);
return null;
} }
} }

View File

@@ -73,7 +73,6 @@ function useUserDisplay() {
avatar: claims?.avatar ?? null, avatar: claims?.avatar ?? null,
initials: getInitials([nombre, apellidos].filter(Boolean).join(" ")), initials: getInitials([nombre, apellidos].filter(Boolean).join(" ")),
role, role,
isAdmin: Boolean(claims?.claims_admin),
} }
} }
@@ -150,7 +149,7 @@ function Layout() {
function Sidebar({ onNavigate }: { onNavigate?: () => void }) { function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
const { claims } = useSupabaseAuth() const { claims } = useSupabaseAuth()
const isAdmin = Boolean(claims?.claims_admin) const isAdmin = claims?.role === 'lci' || claims?.role === 'vicerrectoria'
const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '') const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '')

View File

@@ -175,7 +175,7 @@ function RouteComponent() {
const name = auth.user?.user_metadata?.nombre || auth.user?.email?.split('@')[0] || '¡Hola!' const name = auth.user?.user_metadata?.nombre || auth.user?.email?.split('@')[0] || '¡Hola!'
const isAdmin = !!auth.claims?.claims_admin const isAdmin = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
const role = auth.claims?.role as 'lci' | 'vicerrectoria' | 'secretario_academico' | 'jefe_carrera' | 'planeacion' | undefined const role = auth.claims?.role as 'lci' | 'vicerrectoria' | 'secretario_academico' | 'jefe_carrera' | 'planeacion' | undefined
const navigate = useNavigate({ from: Route.fullPath }) const navigate = useNavigate({ from: Route.fullPath })

View File

@@ -45,6 +45,7 @@ export const Route = createFileRoute("/_authenticated/planes")({
`) `)
.order("fecha_creacion", { ascending: false }) .order("fecha_creacion", { ascending: false })
.limit(100) .limit(100)
console.log({ data, error })
if (error) throw new Error(error.message) if (error) throw new Error(error.message)
return (data ?? []) as PlanRow[] return (data ?? []) as PlanRow[]
}, },

View File

@@ -20,7 +20,7 @@ import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedenci
import { toast } from "sonner" import { toast } from "sonner"
/* -------------------- Tipos -------------------- */ /* -------------------- Tipos -------------------- */
type AdminUser = { type User = {
id: string id: string
email: string | null email: string | null
created_at: string created_at: string
@@ -69,11 +69,11 @@ const usersKeys = {
list: () => [...usersKeys.root, "list"] as const, list: () => [...usersKeys.root, "list"] as const,
} }
async function fetchUsers(): Promise<AdminUser[]> { async function fetchUsers(): Promise<User[]> {
// ⚠️ Dev only: service role en cliente // ⚠️ 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 admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY)
const { data } = await admin.auth.admin.listUsers() const { data } = await admin.auth.admin.listUsers()
return (data?.users ?? []) as AdminUser[] return (data?.users ?? []) as User[]
} }
const usersOptions = () => const usersOptions = () =>
@@ -96,7 +96,7 @@ function RouteComponent() {
const { data } = useSuspenseQuery(usersOptions()) const { data } = useSuspenseQuery(usersOptions())
const [q, setQ] = useState("") const [q, setQ] = useState("")
const [editing, setEditing] = useState<AdminUser | null>(null) const [editing, setEditing] = useState<User | null>(null)
const [form, setForm] = useState<{ const [form, setForm] = useState<{
role?: Role role?: Role
claims_admin?: boolean claims_admin?: boolean
@@ -167,7 +167,7 @@ function RouteComponent() {
}) })
const toggleBan = useMutation({ const toggleBan = useMutation({
mutationFn: async (u: AdminUser) => { mutationFn: async (u: User) => {
const banned = !!u.banned_until && new Date(u.banned_until) > new Date() 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 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) const { error } = await supabase.auth.admin.updateUserById(u.id, payload as any)
@@ -228,7 +228,7 @@ function RouteComponent() {
}) })
const saveUser = useMutation({ const saveUser = useMutation({
mutationFn: async ({ u, f }: { u: AdminUser; f: typeof form }) => { mutationFn: async ({ u, f }: { u: User; f: typeof form }) => {
// 1) Actualiza metadatos (tu Edge Function; placeholder aquí) // 1) Actualiza metadatos (tu Edge Function; placeholder aquí)
// await fetch('/functions/update-user', { method: 'POST', body: JSON.stringify({ id: u.id, ...f }) }) // await fetch('/functions/update-user', { method: 'POST', body: JSON.stringify({ id: u.id, ...f }) })
// Simula éxito: // Simula éxito:
@@ -251,7 +251,7 @@ function RouteComponent() {
onError: (e: any) => toast.error(e?.message || "No se pudo guardar"), onError: (e: any) => toast.error(e?.message || "No se pudo guardar"),
}) })
if (!auth.claims?.claims_admin) { 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> return <div className="p-6 text-sm text-red-600">No tienes permisos para administrar usuarios.</div>
} }
@@ -267,7 +267,7 @@ function RouteComponent() {
}) })
}, [q, data]) }, [q, data])
function openEdit(u: AdminUser) { function openEdit(u: User) {
setEditing(u) setEditing(u)
setForm({ setForm({
role: u.app_metadata?.role, role: u.app_metadata?.role,