This commit is contained in:
2026-03-02 21:28:37 -06:00
parent 314a96f2c5
commit 59e50421d3
21 changed files with 877 additions and 72 deletions

1
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1 @@
Al funcionar como agente, ignora los problemas de eslint del orden de imports

View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "acad-ia-2",
@@ -20,7 +21,8 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@stepperize/react": "^5.1.9",
"@supabase/supabase-js": "^2.90.1",
"@supabase/ssr": "^0.9.0",
"@supabase/supabase-js": "^2.98.0",
"@tailwindcss/vite": "^4.0.6",
"@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-query": "^5.66.5",
@@ -441,17 +443,19 @@
"@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/ssr": ["@supabase/ssr@0.9.0", "", { "dependencies": { "cookie": "^1.0.2" }, "peerDependencies": { "@supabase/supabase-js": "^2.97.0" } }, "sha512-UFY6otYV3yqCgV+AyHj80vNkTvbf1Gas2LW4dpbQ4ap6p6v3eB2oaDfcI99jsuJzwVBCFU4BJI+oDYyhNk1z0Q=="],
"@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/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.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=="],
@@ -753,6 +757,8 @@
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],

View File

@@ -10,6 +10,7 @@
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@@ -17,11 +18,11 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide",
"registries": {
"@shadcn-studio": "https://shadcnstudio.com/r/{name}.json",
"@ss-components": "https://shadcnstudio.com/r/components/{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"
}
}

View File

@@ -33,7 +33,8 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@stepperize/react": "^5.1.9",
"@supabase/supabase-js": "^2.90.1",
"@supabase/ssr": "^0.9.0",
"@supabase/supabase-js": "^2.98.0",
"@tailwindcss/vite": "^4.0.6",
"@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-query": "^5.66.5",

View File

@@ -1,18 +1,44 @@
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
// import { supabase } from '@/lib/supabase'
import { LoginInput } from '../ui/LoginInput'
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() {
const [email, setEmail] = 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 () => {
/* await supabase.auth.signInWithPassword({
email,
password,
})*/
setIsLoading(true)
setError(null)
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 (
@@ -34,7 +60,11 @@ export function ExternalLoginForm() {
value={password}
onChange={setPassword}
/>
<SubmitButton />
{error ? <p className="text-sm text-red-600">{error}</p> : null}
<SubmitButton
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
disabled={isLoading}
/>
</form>
)
}

View File

@@ -1,18 +1,45 @@
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
// import { supabase } from '@/lib/supabase'
import { LoginInput } from '../ui/LoginInput'
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() {
const [clave, setClave] = 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 () => {
/* await supabase.auth.signInWithPassword({
email: `${clave}@ulsa.mx`,
password,
})*/
setIsLoading(true)
setError(null)
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 (
@@ -30,7 +57,11 @@ export function InternalLoginForm() {
value={password}
onChange={setPassword}
/>
<SubmitButton />
{error ? <p className="text-sm text-red-600">{error}</p> : null}
<SubmitButton
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
disabled={isLoading}
/>
</form>
)
}

View File

@@ -0,0 +1,99 @@
'use client'
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { createClient } from '@/lib/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import Link from 'next/link'
export function ForgotPasswordForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
const [email, setEmail] = useState('')
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const handleForgotPassword = async (e: React.FormEvent) => {
e.preventDefault()
const supabase = createClient()
setIsLoading(true)
setError(null)
try {
// The url which will be included in the email. This URL needs to be configured in your redirect URLs in the Supabase dashboard at https://supabase.com/dashboard/project/_/auth/url-configuration
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/update-password`,
})
if (error) throw error
setSuccess(true)
} catch (error: unknown) {
setError(error instanceof Error ? error.message : 'An error occurred')
} finally {
setIsLoading(false)
}
}
return (
<div className={cn('flex flex-col gap-6', className)} {...props}>
{success ? (
<Card>
<CardHeader>
<CardTitle className="text-2xl">Check Your Email</CardTitle>
<CardDescription>Password reset instructions sent</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
If you registered using your email and password, you will receive a password reset
email.
</p>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle className="text-2xl">Reset Your Password</CardTitle>
<CardDescription>
Type in your email and we&apos;ll send you a link to reset your password
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleForgotPassword}>
<div className="flex flex-col gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Sending...' : 'Send reset email'}
</Button>
</div>
<div className="mt-4 text-center text-sm">
Already have an account?{' '}
<Link href="/auth/login" className="underline underline-offset-4">
Login
</Link>
</div>
</form>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,103 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { createClient } from '@/lib/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import Link from 'next/link'
export function LoginForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const router = useRouter()
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
const supabase = createClient()
setIsLoading(true)
setError(null)
try {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) throw error
// Update this route to redirect to an authenticated route. The user already has an active session.
router.push('/protected')
} catch (error: unknown) {
setError(error instanceof Error ? error.message : 'An error occurred')
} finally {
setIsLoading(false)
}
}
return (
<div className={cn('flex flex-col gap-6', className)} {...props}>
<Card>
<CardHeader>
<CardTitle className="text-2xl">Login</CardTitle>
<CardDescription>Enter your email below to login to your account</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin}>
<div className="flex flex-col gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<Link
href="/auth/forgot-password"
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</Link>
</div>
<Input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
</Button>
</div>
<div className="mt-4 text-center text-sm">
Don&apos;t have an account?{' '}
<Link href="/auth/sign-up" className="underline underline-offset-4">
Sign up
</Link>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,18 @@
'use client'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/client'
import { Button } from '@/components/ui/button'
export function LogoutButton() {
const router = useRouter()
const logout = async () => {
const supabase = createClient()
await supabase.auth.signOut()
router.push('/auth/login')
}
return <Button onClick={logout}>Logout</Button>
}

View File

@@ -0,0 +1,116 @@
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { createClient } from '@/lib/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import Link from 'next/link'
export function SignUpForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [repeatPassword, setRepeatPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const router = useRouter()
const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault()
const supabase = createClient()
setIsLoading(true)
setError(null)
if (password !== repeatPassword) {
setError('Passwords do not match')
setIsLoading(false)
return
}
try {
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/protected`,
},
})
if (error) throw error
router.push('/auth/sign-up-success')
} catch (error: unknown) {
setError(error instanceof Error ? error.message : 'An error occurred')
} finally {
setIsLoading(false)
}
}
return (
<div className={cn('flex flex-col gap-6', className)} {...props}>
<Card>
<CardHeader>
<CardTitle className="text-2xl">Sign up</CardTitle>
<CardDescription>Create a new account</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSignUp}>
<div className="flex flex-col gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
</div>
<Input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="repeat-password">Repeat Password</Label>
</div>
<Input
id="repeat-password"
type="password"
required
value={repeatPassword}
onChange={(e) => setRepeatPassword(e.target.value)}
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Creating an account...' : 'Sign up'}
</Button>
</div>
<div className="mt-4 text-center text-sm">
Already have an account?{' '}
<Link href="/auth/login" className="underline underline-offset-4">
Login
</Link>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,13 +1,14 @@
interface Props {
text?: string
disabled?: boolean
}
export function SubmitButton({ text = 'Iniciar sesión' }: Props) {
export function SubmitButton({ text = 'Iniciar sesión', disabled }: Props) {
return (
<button
type="submit"
className="w-full bg-[#7b0f1d] text-white py-2 rounded-lg
font-semibold hover:opacity-90 transition"
disabled={disabled}
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}
</button>

View File

@@ -0,0 +1,72 @@
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { createClient } from '@/lib/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
export function UpdatePasswordForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const router = useRouter()
const handleForgotPassword = async (e: React.FormEvent) => {
e.preventDefault()
const supabase = createClient()
setIsLoading(true)
setError(null)
try {
const { error } = await supabase.auth.updateUser({ password })
if (error) throw error
// Update this route to redirect to an authenticated route. The user already has an active session.
router.push('/protected')
} catch (error: unknown) {
setError(error instanceof Error ? error.message : 'An error occurred')
} finally {
setIsLoading(false)
}
}
return (
<div className={cn('flex flex-col gap-6', className)} {...props}>
<Card>
<CardHeader>
<CardTitle className="text-2xl">Reset Your Password</CardTitle>
<CardDescription>Please enter your new password below.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleForgotPassword}>
<div className="flex flex-col gap-6">
<div className="grid gap-2">
<Label htmlFor="password">New password</Label>
<Input
id="password"
type="password"
placeholder="New password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save new password'}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,59 +1,145 @@
import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { supabaseBrowser } from "../supabase/client";
import { qk } from "../query/keys";
import { throwIfError } from "../api/_helpers";
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { throwIfError } from '../api/_helpers'
import { qk } from '../query/keys'
import { supabaseBrowser } from '../supabase/client'
export function useSession() {
const supabase = supabaseBrowser();
const qc = useQueryClient();
const supabase = supabaseBrowser()
const qc = useQueryClient()
const query = useQuery({
queryKey: qk.session(),
queryFn: async () => {
const { data, error } = await supabase.auth.getSession();
throwIfError(error);
return data.session ?? null;
const { data, error } = await supabase.auth.getSession()
throwIfError(error)
return data.session ?? null
},
staleTime: Infinity,
});
})
useEffect(() => {
const { data } = supabase.auth.onAuthStateChange(() => {
qc.invalidateQueries({ queryKey: qk.session() });
qc.invalidateQueries({ queryKey: qk.meProfile() });
qc.invalidateQueries({ queryKey: qk.auth });
});
qc.invalidateQueries({ queryKey: qk.session() })
qc.invalidateQueries({ queryKey: qk.meProfile() })
qc.invalidateQueries({ queryKey: qk.meAccess() })
qc.invalidateQueries({ queryKey: qk.auth })
})
return () => data.subscription.unsubscribe();
}, [supabase, qc]);
return () => data.subscription.unsubscribe()
}, [supabase, qc])
return query;
return query
}
export function useMeProfile() {
const supabase = supabaseBrowser();
const supabase = supabaseBrowser()
return useQuery({
queryKey: qk.meProfile(),
queryFn: async () => {
const { data: u, error: uErr } = await supabase.auth.getUser();
throwIfError(uErr);
const userId = u.user?.id;
if (!userId) return 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_app")
.select("id,nombre_completo,email,externo,creado_en,actualizado_en")
.eq("id", userId)
.single();
.from('usuarios_app')
.select('id,nombre_completo,email,externo,creado_en,actualizado_en')
.eq('id', userId)
.single()
// 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);
return data ?? null;
throwIfError(error)
return data ?? null
},
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,
}
}

View File

@@ -2,6 +2,7 @@ export const qk = {
auth: ['auth'] as const,
session: () => ['auth', 'session'] as const,
meProfile: () => ['auth', 'meProfile'] as const,
meAccess: () => ['auth', 'meAccess'] as const,
facultades: () => ['meta', 'facultades'] as const,
carreras: (facultadId?: string | null) =>

View File

@@ -1,20 +1,56 @@
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
// Supabase/PostgREST suele devolver 403 (Forbidden) o código PG 42501 (insufficient_privilege)
return status === 403 || code === '42501'
}
export function getContext() {
const queryClient = new QueryClient(
{
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: (failureCount) => failureCount < 2,
const queryClientRef: { current: QueryClient | null } = { current: null }
const handleAuthzDesync = (error: unknown) => {
if (!isRlsViolationError(error)) return
// Forzar resincronización “database-first” del rol/permisos
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 {
queryClient,
}

8
src/lib/client.ts Normal file
View File

@@ -0,0 +1,8 @@
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
)
}

66
src/lib/middleware.ts Normal file
View File

@@ -0,0 +1,66 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
// With Fluid compute, don't put this client in a global environment
// variable. Always create a new one on each request.
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// Do not run code between createServerClient and
// supabase.auth.getClaims(). A simple mistake could make it very hard to debug
// issues with users being randomly logged out.
// IMPORTANT: If you remove getClaims() and you use server-side rendering
// with the Supabase client, your users may be randomly logged out.
const { data } = await supabase.auth.getClaims()
const user = data?.claims
if (
!user &&
!request.nextUrl.pathname.startsWith('/login') &&
!request.nextUrl.pathname.startsWith('/auth')
) {
// no user, potentially respond by redirecting the user to the login page
const url = request.nextUrl.clone()
url.pathname = '/auth/login'
return NextResponse.redirect(url)
}
// IMPORTANT: You *must* return the supabaseResponse object as it is.
// If you're creating a new response object with NextResponse.next() make sure to:
// 1. Pass the request in it, like so:
// const myNewResponse = NextResponse.next({ request })
// 2. Copy over the cookies, like so:
// myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())
// 3. Change the myNewResponse object to fit your needs, but avoid changing
// the cookies!
// 4. Finally:
// return myNewResponse
// If this is not done, you may be causing the browser and server to go out
// of sync and terminate the user's session prematurely!
return supabaseResponse
}

33
src/lib/server.ts Normal file
View File

@@ -0,0 +1,33 @@
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
/**
* If using Fluid compute: Don't put this client in a global variable. Always create a new client within each
* function when using it.
*/
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
)
}

View File

@@ -6,6 +6,7 @@ import reportWebVitals from './reportWebVitals.ts'
import { routeTree } from './routeTree.gen'
import * as TanStackQueryProvider from '@/data/query/queryClient.tsx'
import { supabaseBrowser } from '@/data/supabase/client'
import './styles.css'
@@ -16,6 +17,7 @@ const router = createRouter({
routeTree,
context: {
...TanStackQueryProviderContext,
supabase: supabaseBrowser(),
},
defaultPreload: 'intent',
scrollRestoration: true,

21
src/middleware.ts Normal file
View File

@@ -0,0 +1,21 @@
import { type NextRequest } from 'next/server'
import { updateSession } from '@/middleware'
export async function middleware(request: NextRequest) {
return await updateSession(request)
}
export const config = {
matcher: [
/*
* Match all request paths except:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - images - .svg, .png, .jpg, .jpeg, .gif, .webp
* Feel free to modify this pattern to include more paths.
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}

View File

@@ -1,22 +1,58 @@
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 { useEffect } from 'react'
import Header from '../components/Header'
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 { NotFoundPage } from '@/components/ui/NotFoundPage'
import { throwIfError } from '@/data/api/_helpers'
import { useMeAccess, useSession } from '@/data/hooks/useAuth'
import { qk } from '@/data/query/keys'
interface MyRouterContext {
queryClient: QueryClient
supabase: SupabaseClient<Database>
}
export const Route = createRootRouteWithContext<MyRouterContext>()({
beforeLoad: async ({ context, location }) => {
const pathname = location.pathname
const isLogin = pathname === '/login'
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) {
throw redirect({ to: '/dashboard' })
}
},
component: () => (
<>
<Header />
<AuthSync />
<MaybeHeader />
<Outlet />
<TanStackDevtools
config={{
@@ -60,3 +96,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
}