diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..0ce6b13 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_API_URL=http://localhost:4000/api diff --git a/apps/web/app/(auth)/layout.tsx b/apps/web/app/(auth)/layout.tsx new file mode 100644 index 0000000..c19e053 --- /dev/null +++ b/apps/web/app/(auth)/layout.tsx @@ -0,0 +1,11 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
{children}
+
+ ); +} diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx new file mode 100644 index 0000000..2ef4133 --- /dev/null +++ b/apps/web/app/(auth)/login/page.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { login } from '@/lib/api/auth'; +import { useAuthStore } from '@/stores/auth-store'; + +export default function LoginPage() { + const router = useRouter(); + const { setUser, setTokens } = useAuthStore(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setIsLoading(true); + setError(''); + + const formData = new FormData(e.currentTarget); + const email = formData.get('email') as string; + const password = formData.get('password') as string; + + try { + const response = await login({ email, password }); + setTokens(response.accessToken, response.refreshToken); + setUser(response.user); + router.push('/dashboard'); + } catch (err: any) { + setError(err.response?.data?.message || 'Error al iniciar sesión'); + } finally { + setIsLoading(false); + } + } + + return ( + + + Iniciar Sesión + + Ingresa tus credenciales para acceder a tu cuenta + + +
+ + {error && ( +
+ {error} +
+ )} +
+ + +
+
+ + +
+
+ + +

+ ¿No tienes cuenta?{' '} + + Regístrate + +

+
+
+
+ ); +} diff --git a/apps/web/app/(auth)/register/page.tsx b/apps/web/app/(auth)/register/page.tsx new file mode 100644 index 0000000..bd4d1fe --- /dev/null +++ b/apps/web/app/(auth)/register/page.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { register } from '@/lib/api/auth'; +import { useAuthStore } from '@/stores/auth-store'; + +export default function RegisterPage() { + const router = useRouter(); + const { setUser, setTokens } = useAuthStore(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setIsLoading(true); + setError(''); + + const formData = new FormData(e.currentTarget); + + try { + const response = await register({ + empresa: { + nombre: formData.get('empresaNombre') as string, + rfc: formData.get('empresaRfc') as string, + }, + usuario: { + nombre: formData.get('nombre') as string, + email: formData.get('email') as string, + password: formData.get('password') as string, + }, + }); + setTokens(response.accessToken, response.refreshToken); + setUser(response.user); + router.push('/dashboard'); + } catch (err: any) { + setError(err.response?.data?.message || 'Error al registrarse'); + } finally { + setIsLoading(false); + } + } + + return ( + + + Crear Cuenta + + Registra tu empresa y comienza tu prueba gratuita + + +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + +
+ +
+ + + + +
+
+ + +

+ ¿Ya tienes cuenta?{' '} + + Inicia sesión + +

+
+
+
+ ); +} diff --git a/apps/web/lib/api/auth.ts b/apps/web/lib/api/auth.ts new file mode 100644 index 0000000..d23084a --- /dev/null +++ b/apps/web/lib/api/auth.ts @@ -0,0 +1,22 @@ +import { apiClient } from './client'; +import type { LoginRequest, RegisterRequest, LoginResponse } from '@horux/shared'; + +export async function login(data: LoginRequest): Promise { + const response = await apiClient.post('/auth/login', data); + return response.data; +} + +export async function register(data: RegisterRequest): Promise { + const response = await apiClient.post('/auth/register', data); + return response.data; +} + +export async function logout(): Promise { + const refreshToken = localStorage.getItem('refreshToken'); + await apiClient.post('/auth/logout', { refreshToken }); +} + +export async function getMe(): Promise { + const response = await apiClient.get('/auth/me'); + return response.data.user; +} diff --git a/apps/web/lib/api/client.ts b/apps/web/lib/api/client.ts new file mode 100644 index 0000000..f2ffea0 --- /dev/null +++ b/apps/web/lib/api/client.ts @@ -0,0 +1,52 @@ +import axios from 'axios'; + +export const apiClient = axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api', + headers: { + 'Content-Type': 'application/json', + }, +}); + +apiClient.interceptors.request.use((config) => { + if (typeof window !== 'undefined') { + const token = localStorage.getItem('accessToken'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + } + return config; +}); + +apiClient.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + const refreshToken = localStorage.getItem('refreshToken'); + if (refreshToken) { + const response = await axios.post( + `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api'}/auth/refresh`, + { refreshToken } + ); + + const { accessToken, refreshToken: newRefreshToken } = response.data; + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', newRefreshToken); + + originalRequest.headers.Authorization = `Bearer ${accessToken}`; + return apiClient(originalRequest); + } + } catch { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + window.location.href = '/login'; + } + } + + return Promise.reject(error); + } +); diff --git a/apps/web/stores/auth-store.ts b/apps/web/stores/auth-store.ts new file mode 100644 index 0000000..f648284 --- /dev/null +++ b/apps/web/stores/auth-store.ts @@ -0,0 +1,34 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { UserInfo } from '@horux/shared'; + +interface AuthState { + user: UserInfo | null; + isAuthenticated: boolean; + setUser: (user: UserInfo | null) => void; + setTokens: (accessToken: string, refreshToken: string) => void; + logout: () => void; +} + +export const useAuthStore = create()( + persist( + (set) => ({ + user: null, + isAuthenticated: false, + setUser: (user) => set({ user, isAuthenticated: !!user }), + setTokens: (accessToken, refreshToken) => { + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken); + }, + logout: () => { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + set({ user: null, isAuthenticated: false }); + }, + }), + { + name: 'horux-auth', + partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }), + } + ) +);