Login de usuarios wip
Lo que ya sirve: - Ya se puede hacer login con email y contraseña - Se puede hacer logout con un botón en el header - La página te redirige a login si no hay sesion - La página te redirige a dashboard desde login si hay sesión Lo que falta: - Comprobar si se atrapan y manejan correctamente los errores por violación a RLS - Cambiar la BDD para asignar roles y permisos a usuarios - Comprobar si de manera defensiva se reestablecen los roles/permisos cuando el usuario intenta hacer algo que no está permitido
This commit is contained in:
1
.github/copilot-instructions.md
vendored
Normal file
1
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Al funcionar como agente, ignora los problemas de eslint del orden de imports
|
||||||
14
bun.lock
14
bun.lock
@@ -20,7 +20,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@stepperize/react": "^5.1.9",
|
"@stepperize/react": "^5.1.9",
|
||||||
"@supabase/supabase-js": "^2.90.1",
|
"@supabase/supabase-js": "^2.98.0",
|
||||||
"@tailwindcss/vite": "^4.0.6",
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
"@tanstack/react-devtools": "^0.7.0",
|
"@tanstack/react-devtools": "^0.7.0",
|
||||||
"@tanstack/react-query": "^5.66.5",
|
"@tanstack/react-query": "^5.66.5",
|
||||||
@@ -441,17 +441,17 @@
|
|||||||
|
|
||||||
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.7.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.53.1", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-zjTUwIsEfT+k9BmXwq1QEFYsb4afBlsI1AXFyWQBgggMzwBFOuu92pGrE5OFx90IOjNl+lUbQoTG7f8S0PkOdg=="],
|
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.7.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.53.1", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-zjTUwIsEfT+k9BmXwq1QEFYsb4afBlsI1AXFyWQBgggMzwBFOuu92pGrE5OFx90IOjNl+lUbQoTG7f8S0PkOdg=="],
|
||||||
|
|
||||||
"@supabase/auth-js": ["@supabase/auth-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-pC0Ek4xk4z6q7A/3+UuZ/eYgfFUUQTg3DhapzrAgJnFGDJDFDyGCj6v9nIz8+3jfLqSZ3QKGe6AoEodYjShghg=="],
|
"@supabase/auth-js": ["@supabase/auth-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-GBH361T0peHU91AQNzOlIrjUZw9TZbB9YDRiyFgk/3Kvr3/Z1NWUZ2athWTfHhwNNi8IrW00foyFxQD9IO/Trg=="],
|
||||||
|
|
||||||
"@supabase/functions-js": ["@supabase/functions-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-Ott2IcIXHGupaC0nX9WNEiJAX4OdlGRu9upkkURaQHbaLdz9JuCcHxlwTERgtgjMpikbIWHfMM1M9QTQFYABiA=="],
|
"@supabase/functions-js": ["@supabase/functions-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-N/xEyiNU5Org+d+PNCpv+TWniAXRzxIURxDYsS/m2I/sfAB/HcM9aM2Dmf5edj5oWb9GxID1OBaZ8NMmPXL+Lg=="],
|
||||||
|
|
||||||
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-uRKKQJBDnfi6XFNFPNMh9+u3HT2PCgp065PcMPmG7e0xGuqvLtN89QxO2/SZcGbw2y1+mNBz0yUs5KmyNqF2fA=="],
|
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-v6e9WeZuJijzUut8HyXu6gMqWFepIbaeaMIm1uKzei4yLg9bC9OtEW9O14LE/9ezqNbSAnSLO5GtOLFdm7Bpkg=="],
|
||||||
|
|
||||||
"@supabase/realtime-js": ["@supabase/realtime-js@2.93.1", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-2WaP/KVHPlQDjWM6qe4wOZz6zSRGaXw1lfXf4thbfvk3C3zPPKqXRyspyYnk3IhphyxSsJ2hQ/cXNOz48008tg=="],
|
"@supabase/realtime-js": ["@supabase/realtime-js@2.98.0", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-rOWt28uGyFipWOSd+n0WVMr9kUXiWaa7J4hvyLCIHjRFqWm1z9CaaKAoYyfYMC1Exn3WT8WePCgiVhlAtWC2yw=="],
|
||||||
|
|
||||||
"@supabase/storage-js": ["@supabase/storage-js@2.93.1", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-3KVwd4S1i1BVPL6KIywe5rnruNQXSkLyvrdiJmwnqwbCcDujQumARdGWBPesqCjOPKEU2M9ORWKAsn+2iLzquA=="],
|
"@supabase/storage-js": ["@supabase/storage-js@2.98.0", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-tzr2mG+v7ILSAZSfZMSL9OPyIH4z1ikgQ8EcQTKfMRz4EwmlFt3UnJaGzSOxyvF5b+fc9So7qdSUWTqGgeLokQ=="],
|
||||||
|
|
||||||
"@supabase/supabase-js": ["@supabase/supabase-js@2.93.1", "", { "dependencies": { "@supabase/auth-js": "2.93.1", "@supabase/functions-js": "2.93.1", "@supabase/postgrest-js": "2.93.1", "@supabase/realtime-js": "2.93.1", "@supabase/storage-js": "2.93.1" } }, "sha512-FJTgS5s0xEgRQ3u7gMuzGObwf3jA4O5Ki/DgCDXx94w1pihLM4/WG3XFa4BaCJYfuzLxLcv6zPPA5tDvBUjAUg=="],
|
"@supabase/supabase-js": ["@supabase/supabase-js@2.98.0", "", { "dependencies": { "@supabase/auth-js": "2.98.0", "@supabase/functions-js": "2.98.0", "@supabase/postgrest-js": "2.98.0", "@supabase/realtime-js": "2.98.0", "@supabase/storage-js": "2.98.0" } }, "sha512-Ohc97CtInLwZyiSASz7tT9/Abm/vqnIbO9REp+PivVUII8UZsuI3bngRQnYgJdFoOIwvaEII1fX1qy8x0CyNiw=="],
|
||||||
|
|
||||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils",
|
||||||
@@ -17,11 +18,11 @@
|
|||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide",
|
|
||||||
"registries": {
|
"registries": {
|
||||||
"@shadcn-studio": "https://shadcnstudio.com/r/{name}.json",
|
"@shadcn-studio": "https://shadcnstudio.com/r/{name}.json",
|
||||||
"@ss-components": "https://shadcnstudio.com/r/components/{name}.json",
|
"@ss-components": "https://shadcnstudio.com/r/components/{name}.json",
|
||||||
"@ss-blocks": "https://shadcnstudio.com/r/blocks/{name}.json",
|
"@ss-blocks": "https://shadcnstudio.com/r/blocks/{name}.json",
|
||||||
"@ss-themes": "https://shadcnstudio.com/r/themes/{name}.json"
|
"@ss-themes": "https://shadcnstudio.com/r/themes/{name}.json",
|
||||||
|
"@supabase": "https://supabase.com/ui/r/{name}.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@stepperize/react": "^5.1.9",
|
"@stepperize/react": "^5.1.9",
|
||||||
"@supabase/supabase-js": "^2.90.1",
|
"@supabase/supabase-js": "^2.98.0",
|
||||||
"@tailwindcss/vite": "^4.0.6",
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
"@tanstack/react-devtools": "^0.7.0",
|
"@tanstack/react-devtools": "^0.7.0",
|
||||||
"@tanstack/react-query": "^5.66.5",
|
"@tanstack/react-query": "^5.66.5",
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link, useNavigate } from '@tanstack/react-router'
|
||||||
import { Home, Menu, Network, X } from 'lucide-react'
|
import { Home, LogOut, Menu, Network, X } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { supabaseBrowser } from '@/data/supabase/client'
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await supabaseBrowser().auth.signOut()
|
||||||
|
} finally {
|
||||||
|
void navigate({ to: '/login', replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -21,6 +32,16 @@ export default function Header() {
|
|||||||
<img src="/lasalle-logo.svg" alt="La Salle Logo" className="h-10" />
|
<img src="/lasalle-logo.svg" alt="La Salle Logo" className="h-10" />
|
||||||
</Link>
|
</Link>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="ml-auto inline-flex items-center gap-2 rounded-lg p-2 transition-colors hover:bg-gray-700"
|
||||||
|
aria-label="Logout"
|
||||||
|
title="Logout"
|
||||||
|
>
|
||||||
|
<LogOut size={20} />
|
||||||
|
<span className="hidden sm:inline">Salir</span>
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
|
|||||||
@@ -1,18 +1,44 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
// import { supabase } from '@/lib/supabase'
|
|
||||||
import { LoginInput } from '../ui/LoginInput'
|
import { LoginInput } from '../ui/LoginInput'
|
||||||
import { SubmitButton } from '../ui/SubmitButton'
|
import { SubmitButton } from '../ui/SubmitButton'
|
||||||
|
|
||||||
|
import { throwIfError } from '@/data/api/_helpers'
|
||||||
|
import { qk } from '@/data/query/keys'
|
||||||
|
import { supabaseBrowser } from '@/data/supabase/client'
|
||||||
|
|
||||||
export function ExternalLoginForm() {
|
export function ExternalLoginForm() {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const navigate = useNavigate({ from: '/login' })
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
/* await supabase.auth.signInWithPassword({
|
setIsLoading(true)
|
||||||
email,
|
setError(null)
|
||||||
password,
|
|
||||||
})*/
|
try {
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
throwIfError(error)
|
||||||
|
|
||||||
|
qc.invalidateQueries({ queryKey: qk.session() })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.auth })
|
||||||
|
await navigate({ to: '/dashboard', replace: true })
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const anyErr = e as any
|
||||||
|
setError(anyErr?.message ?? 'No se pudo iniciar sesión')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -34,7 +60,11 @@ export function ExternalLoginForm() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={setPassword}
|
onChange={setPassword}
|
||||||
/>
|
/>
|
||||||
<SubmitButton />
|
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||||
|
<SubmitButton
|
||||||
|
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,45 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
// import { supabase } from '@/lib/supabase'
|
|
||||||
import { LoginInput } from '../ui/LoginInput'
|
import { LoginInput } from '../ui/LoginInput'
|
||||||
import { SubmitButton } from '../ui/SubmitButton'
|
import { SubmitButton } from '../ui/SubmitButton'
|
||||||
|
|
||||||
|
import { throwIfError } from '@/data/api/_helpers'
|
||||||
|
import { qk } from '@/data/query/keys'
|
||||||
|
import { supabaseBrowser } from '@/data/supabase/client'
|
||||||
|
|
||||||
export function InternalLoginForm() {
|
export function InternalLoginForm() {
|
||||||
const [clave, setClave] = useState('')
|
const [clave, setClave] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const navigate = useNavigate({ from: '/login' })
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
/* await supabase.auth.signInWithPassword({
|
setIsLoading(true)
|
||||||
email: `${clave}@ulsa.mx`,
|
setError(null)
|
||||||
password,
|
|
||||||
})*/
|
try {
|
||||||
|
const email = clave.includes('@') ? clave : `${clave}@ulsa.mx`
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
throwIfError(error)
|
||||||
|
|
||||||
|
qc.invalidateQueries({ queryKey: qk.session() })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.auth })
|
||||||
|
await navigate({ to: '/dashboard', replace: true })
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const anyErr = e as any
|
||||||
|
setError(anyErr?.message ?? 'No se pudo iniciar sesión')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -30,7 +57,11 @@ export function InternalLoginForm() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={setPassword}
|
onChange={setPassword}
|
||||||
/>
|
/>
|
||||||
<SubmitButton />
|
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||||
|
<SubmitButton
|
||||||
|
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
text?: string
|
text?: string
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubmitButton({ text = 'Iniciar sesión' }: Props) {
|
export function SubmitButton({ text = 'Iniciar sesión', disabled }: Props) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-[#7b0f1d] text-white py-2 rounded-lg
|
disabled={disabled}
|
||||||
font-semibold hover:opacity-90 transition"
|
className="w-full rounded-lg bg-[#7b0f1d] py-2 font-semibold text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,59 +1,145 @@
|
|||||||
import { useEffect } from "react";
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useEffect } from 'react'
|
||||||
import { supabaseBrowser } from "../supabase/client";
|
|
||||||
import { qk } from "../query/keys";
|
import { throwIfError } from '../api/_helpers'
|
||||||
import { throwIfError } from "../api/_helpers";
|
import { qk } from '../query/keys'
|
||||||
|
import { supabaseBrowser } from '../supabase/client'
|
||||||
|
|
||||||
export function useSession() {
|
export function useSession() {
|
||||||
const supabase = supabaseBrowser();
|
const supabase = supabaseBrowser()
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: qk.session(),
|
queryKey: qk.session(),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await supabase.auth.getSession();
|
const { data, error } = await supabase.auth.getSession()
|
||||||
throwIfError(error);
|
throwIfError(error)
|
||||||
return data.session ?? null;
|
return data.session ?? null
|
||||||
},
|
},
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { data } = supabase.auth.onAuthStateChange(() => {
|
const { data } = supabase.auth.onAuthStateChange(() => {
|
||||||
qc.invalidateQueries({ queryKey: qk.session() });
|
qc.invalidateQueries({ queryKey: qk.session() })
|
||||||
qc.invalidateQueries({ queryKey: qk.meProfile() });
|
qc.invalidateQueries({ queryKey: qk.meProfile() })
|
||||||
qc.invalidateQueries({ queryKey: qk.auth });
|
qc.invalidateQueries({ queryKey: qk.meAccess() })
|
||||||
});
|
qc.invalidateQueries({ queryKey: qk.auth })
|
||||||
|
})
|
||||||
|
|
||||||
return () => data.subscription.unsubscribe();
|
return () => data.subscription.unsubscribe()
|
||||||
}, [supabase, qc]);
|
}, [supabase, qc])
|
||||||
|
|
||||||
return query;
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMeProfile() {
|
export function useMeProfile() {
|
||||||
const supabase = supabaseBrowser();
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: qk.meProfile(),
|
queryKey: qk.meProfile(),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data: u, error: uErr } = await supabase.auth.getUser();
|
const { data: u, error: uErr } = await supabase.auth.getUser()
|
||||||
throwIfError(uErr);
|
throwIfError(uErr)
|
||||||
const userId = u.user?.id;
|
const userId = u.user?.id
|
||||||
if (!userId) return null;
|
if (!userId) return null
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("usuarios_app")
|
.from('usuarios_app')
|
||||||
.select("id,nombre_completo,email,externo,creado_en,actualizado_en")
|
.select('id,nombre_completo,email,externo,creado_en,actualizado_en')
|
||||||
.eq("id", userId)
|
.eq('id', userId)
|
||||||
.single();
|
.single()
|
||||||
|
|
||||||
// si aún no existe perfil en usuarios_app, permite null (tu seed/trigger puede crearlo)
|
// si aún no existe perfil en usuarios_app, permite null (tu seed/trigger puede crearlo)
|
||||||
if (error && (error as any).code === "PGRST116") return null;
|
if (error && (error as any).code === 'PGRST116') return null
|
||||||
|
|
||||||
throwIfError(error);
|
throwIfError(error)
|
||||||
return data ?? null;
|
return data ?? null
|
||||||
},
|
},
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MeAccessRole = {
|
||||||
|
assignmentId: string
|
||||||
|
rolId: string
|
||||||
|
clave: string
|
||||||
|
nombre: string
|
||||||
|
descripcion: string | null
|
||||||
|
facultadId: string | null
|
||||||
|
carreraId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MeAccess = {
|
||||||
|
userId: string
|
||||||
|
roles: Array<MeAccessRole>
|
||||||
|
permissions: Array<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database-first RBAC: obtiene roles del usuario desde tablas app (NO desde JWT).
|
||||||
|
*
|
||||||
|
* Nota: el esquema actual modela roles con `usuarios_roles` -> `roles`.
|
||||||
|
*/
|
||||||
|
export function useMeAccess() {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.meAccess(),
|
||||||
|
queryFn: async (): Promise<MeAccess | null> => {
|
||||||
|
const { data: u, error: uErr } = await supabase.auth.getUser()
|
||||||
|
throwIfError(uErr)
|
||||||
|
const userId = u.user?.id
|
||||||
|
if (!userId) return null
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('usuarios_roles')
|
||||||
|
.select(
|
||||||
|
'id,rol_id,facultad_id,carrera_id,roles(id,clave,nombre,descripcion)',
|
||||||
|
)
|
||||||
|
.eq('usuario_id', userId)
|
||||||
|
|
||||||
|
throwIfError(error)
|
||||||
|
|
||||||
|
const roles: Array<MeAccessRole> = (data ?? [])
|
||||||
|
.map((row: any) => {
|
||||||
|
const rol = row.roles
|
||||||
|
if (!rol) return null
|
||||||
|
return {
|
||||||
|
assignmentId: row.id,
|
||||||
|
rolId: rol.id,
|
||||||
|
clave: rol.clave,
|
||||||
|
nombre: rol.nombre,
|
||||||
|
descripcion: rol.descripcion ?? null,
|
||||||
|
facultadId: row.facultad_id ?? null,
|
||||||
|
carreraId: row.carrera_id ?? null,
|
||||||
|
} satisfies MeAccessRole
|
||||||
|
})
|
||||||
|
.filter(Boolean) as Array<MeAccessRole>
|
||||||
|
|
||||||
|
// Por ahora, los permisos granulares se derivan de claves de rol.
|
||||||
|
// Si luego existe una tabla `roles_permisos`, aquí se expande a permisos reales.
|
||||||
|
const permissions = Array.from(new Set(roles.map((r) => r.clave)))
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
roles,
|
||||||
|
permissions,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const session = useSession()
|
||||||
|
const meProfile = useMeProfile()
|
||||||
|
const meAccess = useMeAccess()
|
||||||
|
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
meProfile,
|
||||||
|
meAccess,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const qk = {
|
|||||||
auth: ['auth'] as const,
|
auth: ['auth'] as const,
|
||||||
session: () => ['auth', 'session'] as const,
|
session: () => ['auth', 'session'] as const,
|
||||||
meProfile: () => ['auth', 'meProfile'] as const,
|
meProfile: () => ['auth', 'meProfile'] as const,
|
||||||
|
meAccess: () => ['auth', 'meAccess'] as const,
|
||||||
|
|
||||||
facultades: () => ['meta', 'facultades'] as const,
|
facultades: () => ['meta', 'facultades'] as const,
|
||||||
carreras: (facultadId?: string | null) =>
|
carreras: (facultadId?: string | null) =>
|
||||||
|
|||||||
@@ -1,20 +1,57 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import {
|
||||||
|
MutationCache,
|
||||||
|
QueryCache,
|
||||||
|
QueryClient,
|
||||||
|
QueryClientProvider,
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { qk } from './keys'
|
||||||
|
|
||||||
|
import type React from 'react'
|
||||||
|
|
||||||
|
function isRlsViolationError(error: unknown): boolean {
|
||||||
|
const anyErr = error as any
|
||||||
|
const code = anyErr?.code
|
||||||
|
const status = anyErr?.status ?? anyErr?.response?.status
|
||||||
|
console.log('Checking RLS violation error:', { code, status })
|
||||||
|
// Supabase/PostgREST suele devolver 403 (Forbidden) o código PG 42501 (insufficient_privilege)
|
||||||
|
return status === 403 || code === '42501'
|
||||||
|
}
|
||||||
|
|
||||||
export function getContext() {
|
export function getContext() {
|
||||||
const queryClient = new QueryClient(
|
const queryClientRef: { current: QueryClient | null } = { current: null }
|
||||||
{
|
|
||||||
defaultOptions: {
|
const handleAuthzDesync = (error: unknown) => {
|
||||||
queries: {
|
if (!isRlsViolationError(error)) return
|
||||||
staleTime: 30_000,
|
// Forzar resincronización “database-first” del rol/permisos
|
||||||
refetchOnWindowFocus: false,
|
console.log('RLS violation detected, invalidating queries...')
|
||||||
retry: (failureCount) => failureCount < 2,
|
queryClientRef.current?.invalidateQueries({ queryKey: qk.meAccess() })
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
queryCache: new QueryCache({
|
||||||
|
onError: (error) => {
|
||||||
|
handleAuthzDesync(error)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mutationCache: new MutationCache({
|
||||||
|
onError: (error) => {
|
||||||
|
handleAuthzDesync(error)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: (failureCount) => failureCount < 2,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mutations: {
|
})
|
||||||
retry: 0,
|
|
||||||
},
|
queryClientRef.current = queryClient
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
queryClient,
|
queryClient,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import reportWebVitals from './reportWebVitals.ts'
|
|||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from './routeTree.gen'
|
||||||
|
|
||||||
import * as TanStackQueryProvider from '@/data/query/queryClient.tsx'
|
import * as TanStackQueryProvider from '@/data/query/queryClient.tsx'
|
||||||
|
import { supabaseBrowser } from '@/data/supabase/client'
|
||||||
|
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ const router = createRouter({
|
|||||||
routeTree,
|
routeTree,
|
||||||
context: {
|
context: {
|
||||||
...TanStackQueryProviderContext,
|
...TanStackQueryProviderContext,
|
||||||
|
supabase: supabaseBrowser(),
|
||||||
},
|
},
|
||||||
defaultPreload: 'intent',
|
defaultPreload: 'intent',
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
|
|||||||
@@ -1,22 +1,59 @@
|
|||||||
import { TanStackDevtools } from '@tanstack/react-devtools'
|
import { TanStackDevtools } from '@tanstack/react-devtools'
|
||||||
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
|
import {
|
||||||
|
Outlet,
|
||||||
|
createRootRouteWithContext,
|
||||||
|
redirect,
|
||||||
|
useNavigate,
|
||||||
|
useRouterState,
|
||||||
|
} from '@tanstack/react-router'
|
||||||
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
import Header from '../components/Header'
|
import Header from '../components/Header'
|
||||||
import TanStackQueryDevtools from '../integrations/tanstack-query/devtools'
|
import TanStackQueryDevtools from '../integrations/tanstack-query/devtools'
|
||||||
|
|
||||||
|
import type { Database } from '@/types/supabase'
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
import type { QueryClient } from '@tanstack/react-query'
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
import { NotFoundPage } from '@/components/ui/NotFoundPage'
|
import { NotFoundPage } from '@/components/ui/NotFoundPage'
|
||||||
|
import { throwIfError } from '@/data/api/_helpers'
|
||||||
|
import { useSession } from '@/data/hooks/useAuth'
|
||||||
|
import { qk } from '@/data/query/keys'
|
||||||
|
|
||||||
interface MyRouterContext {
|
interface MyRouterContext {
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
|
supabase: SupabaseClient<Database>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<MyRouterContext>()({
|
export const Route = createRootRouteWithContext<MyRouterContext>()({
|
||||||
|
beforeLoad: async ({ context, location }) => {
|
||||||
|
const pathname = location.pathname
|
||||||
|
const isLogin = pathname === '/login'
|
||||||
|
const isIndex = pathname === '/'
|
||||||
|
|
||||||
|
const session = await context.queryClient.ensureQueryData({
|
||||||
|
queryKey: qk.session(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await context.supabase.auth.getSession()
|
||||||
|
throwIfError(error)
|
||||||
|
return data.session ?? null
|
||||||
|
},
|
||||||
|
staleTime: Infinity,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!session && !isLogin) {
|
||||||
|
throw redirect({ to: '/login' })
|
||||||
|
}
|
||||||
|
if (session && (isLogin || isIndex)) {
|
||||||
|
throw redirect({ to: '/dashboard' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
component: () => (
|
component: () => (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<AuthSync />
|
||||||
|
<MaybeHeader />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<TanStackDevtools
|
<TanStackDevtools
|
||||||
config={{
|
config={{
|
||||||
@@ -60,3 +97,40 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function MaybeHeader() {
|
||||||
|
const pathname = useRouterState({
|
||||||
|
select: (s) => s.location.pathname,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (pathname === '/login') return null
|
||||||
|
return <Header />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthSync() {
|
||||||
|
const { data: session, isLoading } = useSession()
|
||||||
|
// Mantiene roles/permisos sincronizados con la BD (database-first)
|
||||||
|
// useMeAccess()
|
||||||
|
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const pathname = useRouterState({
|
||||||
|
select: (s) => s.location.pathname,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reaccionar a cambios de sesión (login/logout) sin depender solo de beforeLoad.
|
||||||
|
// Nota: beforeLoad sigue siendo la línea de defensa en navegación/refresh.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading) return
|
||||||
|
|
||||||
|
if (!session && pathname !== '/login') {
|
||||||
|
void navigate({ to: '/login', replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session && pathname === '/login') {
|
||||||
|
void navigate({ to: '/dashboard', replace: true })
|
||||||
|
}
|
||||||
|
}, [isLoading, session, pathname, navigate])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user