diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..98ac94b --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +Al funcionar como agente, ignora los problemas de eslint del orden de imports diff --git a/bun.lock b/bun.lock index 7c9d598..772bbb8 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/components.json b/components.json index 5eeb81e..2230eb7 100644 --- a/components.json +++ b/components.json @@ -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" } } diff --git a/package.json b/package.json index 4c911fc..37eb02e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/auth/ExternalLoginForm.tsx b/src/components/auth/ExternalLoginForm.tsx index 4d4e830..0670df1 100644 --- a/src/components/auth/ExternalLoginForm.tsx +++ b/src/components/auth/ExternalLoginForm.tsx @@ -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(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} /> - + {error ?

{error}

: null} + ) } diff --git a/src/components/auth/InternalLoginForm.tsx b/src/components/auth/InternalLoginForm.tsx index 3881dd1..947c05e 100644 --- a/src/components/auth/InternalLoginForm.tsx +++ b/src/components/auth/InternalLoginForm.tsx @@ -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(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} /> - + {error ?

{error}

: null} + ) } diff --git a/src/components/forgot-password-form.tsx b/src/components/forgot-password-form.tsx new file mode 100644 index 0000000..a491a29 --- /dev/null +++ b/src/components/forgot-password-form.tsx @@ -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(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 ( +
+ {success ? ( + + + Check Your Email + Password reset instructions sent + + +

+ If you registered using your email and password, you will receive a password reset + email. +

+
+
+ ) : ( + + + Reset Your Password + + Type in your email and we'll send you a link to reset your password + + + +
+
+
+ + setEmail(e.target.value)} + /> +
+ {error &&

{error}

} + +
+
+ Already have an account?{' '} + + Login + +
+
+
+
+ )} +
+ ) +} diff --git a/src/components/login-form.tsx b/src/components/login-form.tsx new file mode 100644 index 0000000..17c100c --- /dev/null +++ b/src/components/login-form.tsx @@ -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(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 ( +
+ + + Login + Enter your email below to login to your account + + +
+
+
+ + setEmail(e.target.value)} + /> +
+
+
+ + + Forgot your password? + +
+ setPassword(e.target.value)} + /> +
+ {error &&

{error}

} + +
+
+ Don't have an account?{' '} + + Sign up + +
+
+
+
+
+ ) +} diff --git a/src/components/logout-button.tsx b/src/components/logout-button.tsx new file mode 100644 index 0000000..b396d36 --- /dev/null +++ b/src/components/logout-button.tsx @@ -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 +} diff --git a/src/components/sign-up-form.tsx b/src/components/sign-up-form.tsx new file mode 100644 index 0000000..aba8476 --- /dev/null +++ b/src/components/sign-up-form.tsx @@ -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(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 ( +
+ + + Sign up + Create a new account + + +
+
+
+ + setEmail(e.target.value)} + /> +
+
+
+ +
+ setPassword(e.target.value)} + /> +
+
+
+ +
+ setRepeatPassword(e.target.value)} + /> +
+ {error &&

{error}

} + +
+
+ Already have an account?{' '} + + Login + +
+
+
+
+
+ ) +} diff --git a/src/components/ui/SubmitButton.tsx b/src/components/ui/SubmitButton.tsx index 805b8b4..4baf535 100644 --- a/src/components/ui/SubmitButton.tsx +++ b/src/components/ui/SubmitButton.tsx @@ -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 ( diff --git a/src/components/update-password-form.tsx b/src/components/update-password-form.tsx new file mode 100644 index 0000000..ab62c11 --- /dev/null +++ b/src/components/update-password-form.tsx @@ -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(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 ( +
+ + + Reset Your Password + Please enter your new password below. + + +
+
+
+ + setPassword(e.target.value)} + /> +
+ {error &&

{error}

} + +
+
+
+
+
+ ) +} diff --git a/src/data/hooks/useAuth.ts b/src/data/hooks/useAuth.ts index 90e68cd..c2216c9 100644 --- a/src/data/hooks/useAuth.ts +++ b/src/data/hooks/useAuth.ts @@ -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 + permissions: Array +} + +/** + * 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 => { + 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 = (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 + + // 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, + } } diff --git a/src/data/query/keys.ts b/src/data/query/keys.ts index 9f50063..5070ae3 100644 --- a/src/data/query/keys.ts +++ b/src/data/query/keys.ts @@ -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) => diff --git a/src/data/query/queryClient.tsx b/src/data/query/queryClient.tsx index 3a8fd7e..934d784 100644 --- a/src/data/query/queryClient.tsx +++ b/src/data/query/queryClient.tsx @@ -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, } diff --git a/src/lib/client.ts b/src/lib/client.ts new file mode 100644 index 0000000..f5e7647 --- /dev/null +++ b/src/lib/client.ts @@ -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! + ) +} diff --git a/src/lib/middleware.ts b/src/lib/middleware.ts new file mode 100644 index 0000000..a022ea5 --- /dev/null +++ b/src/lib/middleware.ts @@ -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 +} diff --git a/src/lib/server.ts b/src/lib/server.ts new file mode 100644 index 0000000..31d476e --- /dev/null +++ b/src/lib/server.ts @@ -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. + } + }, + }, + } + ) +} diff --git a/src/main.tsx b/src/main.tsx index d9a2321..d80a7fb 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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, diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..a55880b --- /dev/null +++ b/src/middleware.ts @@ -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)$).*)', + ], +} diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 5ee62f5..df6b81a 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -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 } export const Route = createRootRouteWithContext()({ + 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: () => ( <> -
+ + ()({ ) }, }) + +function MaybeHeader() { + const pathname = useRouterState({ + select: (s) => s.location.pathname, + }) + + if (pathname === '/login') return null + return
+} + +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 +}