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

7
.vscode/launch.json vendored
View File

@@ -4,6 +4,13 @@
// 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": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
},
{ {
"type": "msedge", "type": "msedge",
"request": "launch", "request": "launch",

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,22 +39,23 @@ export function SupabaseAuthProvider({ children }: { children: React.ReactNode }
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
useEffect(() => { useEffect(() => {
// Carga inicial // Función para manejar la sesión
supabase.auth.getSession().then(async ({ data: { session } }) => { const handleSession = async (session: Session | null) => {
const u = session?.user ?? null const u = session?.user ?? null
setUser(u) setUser(u)
setIsAuthenticated(!!u) setIsAuthenticated(!!u)
setClaims(await buildClaims(session)) setClaims(await buildClaims(session))
setIsLoading(false) setIsLoading(false)
}
// Carga inicial
supabase.auth.getSession().then(({ data: { session } }) => {
handleSession(session)
}) })
// 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');
return null;
}
const u = session.user;
const app = (u.app_metadata ?? {}) as Partial<UserClaims> & { role?: Role }
const meta = (u.user_metadata ?? {}) as Partial<UserClaims>
// Mezcla segura: app_metadata > user_metadata (para campos de claims) try{
const base: Partial<UserClaims> = { const result = await supabase.rpc('obtener_claims_usuario', {
claims_admin: !!(app.claims_admin ?? (meta as any).claims_admin), p_user_id: u.id,
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, const data: UserClaims[] | null = result.data;
clave: (meta.clave as string) ?? '', const error = result.error;
nombre: (meta.nombre as string) ?? '',
apellidos: (meta.apellidos as string) ?? '', if (error) {
title: (meta.title as string) ?? '', console.error('Error al obtener la información:', error);
avatar: (meta.avatar as string) ?? null, throw new Error('Error al obtener la información del usuario');
} }
let facultad_color: string | null = null console.log(data);
if (base.facultad_id) { if (!data || data.length === 0) {
// Lee color desde public.facultades console.warn('No se encontró información para el usuario');
const { data, error } = await supabase return null;
.from('facultades')
.select('color')
.eq('id', base.facultad_id)
.maybeSingle()
if (!error && data) facultad_color = (data as any)?.color ?? null
} }
return { return data[0];
claims_admin: !!base.claims_admin, } catch (e) {
role: (base.role ?? 'lci') as Role, console.error('Error inesperado:', e);
clave: base.clave ?? '', return null;
nombre: base.nombre ?? '',
apellidos: base.apellidos ?? '',
title: base.title ?? '',
avatar: base.avatar ?? null,
facultad_id: (base.facultad_id as string | null) ?? null,
carrera_id: (base.carrera_id as string | null) ?? null,
facultad_color, // 🎨
} }
} }

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,