feat: refactor authentication flow, implement user hooks, and add validation schemas for login/signup

This commit is contained in:
Jrodenas 2025-08-10 20:16:27 +02:00
parent e45772e2a9
commit a2ae7d5b5a
17 changed files with 247 additions and 224 deletions

View File

@ -6,13 +6,14 @@
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignoreUnknown": true,
"includes": [
"**/src/**/*",
"**/.vscode/**/*",
"**/index.html",
"**/vite.config.js",
"!**/src/routeTree.gen.ts"
"!**/src/routeTree.gen.ts",
"!**/node_modules/**/*"
]
},
"formatter": {

View File

@ -1,5 +1,5 @@
import { HeroUIProvider as HeroProvider } from "@heroui/react"
export const HeroUIProvider = ({ children }: { children: React.ReactNode }) => {
return <HeroProvider>{children}</HeroProvider>
return <HeroProvider validationBehavior="native">{children}</HeroProvider>
}

84
src/lib/db/user.ts Normal file
View File

@ -0,0 +1,84 @@
import { redirect } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import { getSupabaseServerClient } from "@/integrations/supabase/supabase"
import { loginFormSchema, signupFormSchema } from "../validation/user"
export const getUser = createServerFn().handler(async () => {
const supabase = getSupabaseServerClient()
const { data, error } = await supabase.auth.getUser()
if (error || !data.user) {
return {
error: true,
message: error?.message ?? "Unknown error"
}
}
return {
user: {
id: data.user.id,
email: data.user.email,
name: data.user.user_metadata.name || ""
},
error: false
}
})
export const loginUser = createServerFn({
method: "POST"
})
.validator(loginFormSchema)
.handler(async ({ data }) => {
const supabase = getSupabaseServerClient()
const login = await supabase.auth.signInWithPassword({
email: data.email,
password: data.password
})
if (login.error) {
return {
error: true,
message: login.error.message
}
}
return {
error: false
}
})
export const logoutUser = createServerFn().handler(async () => {
const supabase = getSupabaseServerClient()
const { error } = await supabase.auth.signOut()
if (error) {
return {
error: true,
message: error.message
}
}
throw redirect({
to: "/",
viewTransition: true,
replace: true
})
})
export const signupUser = createServerFn({ method: "POST" })
.validator(signupFormSchema)
.handler(async ({ data }) => {
const supabase = getSupabaseServerClient()
const { error } = await supabase.auth.signUp({
email: data.email,
password: data.password
})
if (error) {
return {
error: true,
message: error.message
}
}
throw redirect({
href: data.redirectUrl || "/"
})
})

View File

@ -15,7 +15,7 @@ export const useValidation = <T,>({
schema
}: {
formData: FormDataValidation
schema?: z.ZodSchema<T>
schema?: z.ZodType<T>
}) => {
const result =
schema?.safeParse(formData) ?? defaultSchema?.safeParse(formData)
@ -25,6 +25,7 @@ export const useValidation = <T,>({
}
if (!result.success) {
//FIXME: Flatten errors, new in zod v4
setErrors(result.error.flatten().fieldErrors as T)
return false
}

View File

@ -0,0 +1,54 @@
import { useMutation } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { toast } from "sonner"
import type z from "zod"
import { loginUser } from "@/lib/db/user"
import { loginFormSchema } from "@/lib/validation/user"
import { useValidation } from "../useValidation"
type TLoginForm = z.infer<typeof loginFormSchema>
export const useLogin = () => {
const navigate = useNavigate()
const { errors, validate } = useValidation({
defaultSchema: loginFormSchema
})
const loginMutation = useMutation({
mutationKey: ["login"],
mutationFn: async (data: TLoginForm) =>
loginUser({
data
}),
onMutate: () => {
toast.loading("Logging in...", { id: "login" })
},
onSuccess: () => {
toast.success("Login successful! Redirecting to posts..", { id: "login" })
navigate({
to: "/post"
})
},
onError: () => {
toast.error("Failed to log in.", { id: "login" })
}
})
const validateLogin = (formData: TLoginForm) => {
const isValid = validate({
formData
})
if (!isValid) {
toast.error("Error en el formulario.")
return false
}
loginMutation.mutate(formData)
}
return {
login: validateLogin,
isPending: loginMutation.isPending,
errors: errors
}
}

View File

@ -0,0 +1,44 @@
import { useMutation } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { toast } from "sonner"
import type z from "zod"
import { signupUser } from "@/lib/db/user"
import { signupFormSchema } from "@/lib/validation/user"
import { useValidation } from "../useValidation"
type TSignupForm = z.infer<typeof signupFormSchema>
export const useSignup = () => {
const navigate = useNavigate()
const { validate, errors } = useValidation({
defaultSchema: signupFormSchema
})
const signup = useMutation({
mutationKey: ["signup"],
mutationFn: async (data: TSignupForm) => signupUser({ data }),
onSuccess: () => {
toast.success("Signup successful! Redirecting to login...", {
id: "signup"
})
navigate({
to: "/login"
})
}
})
const validateSignup = (formData: TSignupForm) => {
const isValid = validate({ formData })
if (!isValid) {
toast.error("Signup failed. Please check your input.", {
id: "signup"
})
}
signup.mutate(formData)
}
return {
signup: validateSignup,
errors,
isPending: signup.isPending
}
}

View File

@ -1,59 +0,0 @@
import { useMutation } from "@tanstack/react-query"
import { getRouteApi } from "@tanstack/react-router"
import { createServerFn, useServerFn } from "@tanstack/react-start"
import { toast } from "sonner"
import { getSupabaseServerClient } from "@/integrations/supabase/supabase"
import { loginSchema } from "../schemas/login"
const apiRouter = getRouteApi("/login")
export const loginFn = createServerFn({ method: "POST" })
.validator(loginSchema)
.handler(async ({ data }) => {
const supabase = getSupabaseServerClient()
const response = await supabase.auth.signInWithPassword({
email: data.email,
password: data.password
})
console.log(response)
if (response.error) {
return {
error: true,
message: response.error.message
}
}
})
export const mutationLogin = () => {
const navigate = apiRouter.useNavigate()
const loginFunction = useServerFn(loginFn)
const mutation = useMutation({
mutationKey: ["login"],
mutationFn: async (data: { email: string; password: string }) => {
toast.loading("Logging in...", { id: "login" })
return loginFunction({
data: {
email: data.email,
password: data.password
}
})
},
onSuccess: (data) => {
if (data?.error) {
toast.error(data.message, { id: "login" })
return
}
toast.success("Login successful! Redirecting to posts...", {
id: "login"
})
navigate({
to: "/post"
})
},
onError: (error) => {
toast.error("Login failed. Please try again.", { id: "login" })
console.log("No se ha podido procesar el login", error)
}
})
return mutation
}

View File

@ -1,59 +0,0 @@
import { useMutation } from "@tanstack/react-query"
import { getRouteApi, redirect } from "@tanstack/react-router"
import { createServerFn, useServerFn } from "@tanstack/react-start"
import { toast } from "sonner"
import { getSupabaseServerClient } from "@/integrations/supabase/supabase"
import { signupSchema } from "../schemas/signup"
const apiRouter = getRouteApi("/signup")
export const signupFn = createServerFn({ method: "POST" })
.validator(signupSchema)
.handler(async ({ data }) => {
const supabase = getSupabaseServerClient()
const { error } = await supabase.auth.signUp({
email: data.email,
password: data.password
})
if (error) {
return {
error: true,
message: error.message
}
}
throw redirect({
href: data.redirectUrl || "/"
})
})
export const mutationSignup = () => {
const navigate = apiRouter.useNavigate()
const signup = useServerFn(signupFn)
const mutation = useMutation({
mutationKey: ["signup"],
mutationFn: async (data: {
email: string
password: string
redirectUrl?: string
}) => {
toast.loading("Signing up...", { id: "signup" })
return signup({
data: {
email: data.email,
password: data.password,
redirectUrl: data.redirectUrl
}
})
},
onSuccess: () => {
toast.success("Signup successful! Redirecting to login...", {
id: "signup"
})
navigate({
to: "/login"
})
}
})
return mutation
}

View File

@ -1,6 +0,0 @@
import z from "zod"
export const loginSchema = z.object({
email: z.email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters long")
})

View File

@ -1,7 +0,0 @@
import z from "zod"
export const signupSchema = z.object({
email: z.email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters long"),
redirectUrl: z.string().optional()
})

View File

@ -0,0 +1,12 @@
import z from "zod"
export const loginFormSchema = z.object({
email: z.string("Invalid email address"),
password: z.string().min(1, "Password must be at least 1 character long")
})
export const signupFormSchema = z.object({
email: z.string("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters long"),
redirectUrl: z.string().optional()
})

View File

@ -6,31 +6,18 @@ import {
Scripts
} from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
import { createServerFn } from "@tanstack/react-start"
import { HeroUIProvider } from "@/integrations/heroui/provider"
import { SonnerProvider } from "@/integrations/sonner/provider"
import { getSupabaseServerClient } from "@/integrations/supabase/supabase"
import { getUser } from "@/lib/db/user"
interface MyRouterContext {
queryClient: QueryClient
user: null
}
const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
const supabase = getSupabaseServerClient()
const { data, error: _error } = await supabase.auth.getUser()
if (!data.user?.email) {
return null
}
return {
email: data.user.email
}
})
export const Route = createRootRouteWithContext<MyRouterContext>()({
beforeLoad: async () => {
const user = await fetchUser()
const user = await getUser()
return {
user
}

View File

@ -1,6 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/_authed")({
beforeLoad: ({ context }) => {
console.log("contextw", context)
@ -10,9 +9,11 @@ export const Route = createFileRoute("/_authed")({
},
errorComponent: ({ error }) => {
if (error.message === "Not authenticated") {
;<p>
Not authenticated. Please <a href="/login">login</a>.
</p>
return (
<p>
Not authenticated. Please <a href="/login">login</a>.
</p>
)
}
throw error

View File

@ -1,13 +1,13 @@
import logo from "@assets/logo.svg"
import { Button } from "@heroui/react"
import { createFileRoute, getRouteApi } from "@tanstack/react-router"
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/")({
component: App
})
const apiRouter = getRouteApi("/")
function App() {
const navigate = apiRouter.useNavigate()
const navigate = Route.useNavigate()
return (
<div className="text-center">
<header className="min-h-screen flex flex-col items-center justify-center bg-[#282c34] text-white text-[calc(10px+2vmin)]">

View File

@ -1,42 +1,37 @@
import { Button, Form, Input } from "@heroui/react"
import { createFileRoute } from "@tanstack/react-router"
import { useValidation } from "@/lib/hooks/useValidation"
import { mutationLogin } from "@/lib/mutations/mutationLogin"
import { loginSchema } from "@/lib/schemas/login"
import type { FormEvent } from "react"
import { useLogin } from "@/lib/hooks/user/useLogin"
export const Route = createFileRoute("/login")({
component: LoginComp
})
function LoginComp() {
const loginMutation = mutationLogin()
const { errors, validate } = useValidation({
defaultSchema: loginSchema
})
const { errors, isPending, login } = useLogin()
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
login({
email: formData.get("email") as string,
password: formData.get("password") as string
})
}
return (
<div className="flex justify-center items-center flex-col h-screen">
<p className="font-semibold mb-3">Login</p>
<Form
validationErrors={errors}
className="grid gap-2 max-w-sm w-full"
onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const email = formData.get("email") as string
const password = formData.get("password") as string
if (
validate({
formData: { email, password }
})
)
loginMutation.mutate({ email, password })
}}
onSubmit={handleSubmit}
>
<Input name="email" label="Email" />
<Input name="password" type="password" label="Password" />
<Button type="submit" isLoading={loginMutation.isPending}>
<Button type="submit" isLoading={isPending}>
Entrar
</Button>
</Form>

View File

@ -1,26 +1,7 @@
import { createFileRoute, redirect } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import { toast } from "sonner"
import { getSupabaseServerClient } from "@/integrations/supabase/supabase"
const logoutFn = createServerFn().handler(async () => {
const supabase = getSupabaseServerClient()
const { error } = await supabase.auth.signOut()
if (error) {
toast.error("Logout failed. Please try again.")
return {
error: true,
message: error.message
}
}
throw redirect({
href: "/"
})
})
import { createFileRoute } from "@tanstack/react-router"
import { logoutUser } from "@/lib/db/user"
export const Route = createFileRoute("/logout")({
preload: false,
loader: () => logoutFn()
loader: () => logoutUser()
})

View File

@ -1,43 +1,37 @@
import { Button, Form, Input } from "@heroui/react"
import { createFileRoute } from "@tanstack/react-router"
import { useValidation } from "@/lib/hooks/useValidation"
import { mutationSignup } from "@/lib/mutations/mutationSignup"
import { signupSchema } from "@/lib/schemas/signup"
import type { FormEvent } from "react"
import { useSignup } from "@/lib/hooks/user/useSignup"
export const Route = createFileRoute("/signup")({
component: SignupComp
})
function SignupComp() {
const signupMutation = mutationSignup()
const { errors, validate } = useValidation({
defaultSchema: signupSchema
})
const { signup, errors, isPending } = useSignup()
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
signup({
email: formData.get("email") as string,
password: formData.get("password") as string
})
}
return (
<div className="flex justify-center items-center flex-col h-screen">
<p className="font-semibold mb-3">Signup</p>
<Form
validationErrors={errors}
className="grid gap-2 max-w-sm w-full"
onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const email = formData.get("email") as string
const password = formData.get("password") as string
if (
validate({
formData: { email, password }
})
)
signupMutation.mutate({
email,
password
})
}}
onSubmit={handleSubmit}
>
<Input name="email" type="email" label="Email" />
<Input name="password" type="password" label="Password" />
<Button type="submit" isLoading={signupMutation.isPending}>
<Button type="submit" isLoading={isPending}>
Enviar
</Button>
</Form>