diff --git a/src/web/src/App.tsx b/src/web/src/App.tsx new file mode 100644 index 0000000..b4926b2 --- /dev/null +++ b/src/web/src/App.tsx @@ -0,0 +1,22 @@ +import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { AppRoutes } from './router' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: 1, staleTime: 1000 * 60 * 5 }, + mutations: { retry: 0 }, + }, +}) + +function App() { + return ( + + + + + + ) +} + +export default App diff --git a/src/web/src/api/axiosClient.ts b/src/web/src/api/axiosClient.ts new file mode 100644 index 0000000..bdadf6c --- /dev/null +++ b/src/web/src/api/axiosClient.ts @@ -0,0 +1,10 @@ +import axios from 'axios' + +const API_URL = import.meta.env['VITE_API_URL'] ?? 'http://localhost:5000' + +export const axiosClient = axios.create({ + baseURL: API_URL, + headers: { + 'Content-Type': 'application/json', + }, +}) diff --git a/src/web/src/features/auth/api/authApi.ts b/src/web/src/features/auth/api/authApi.ts new file mode 100644 index 0000000..81be6c1 --- /dev/null +++ b/src/web/src/features/auth/api/authApi.ts @@ -0,0 +1,21 @@ +import { axiosClient } from '../../../api/axiosClient' + +export interface LoginResponseDto { + accessToken: string + refreshToken: string + expiresIn: number + usuario: { + id: number + username: string + nombre: string + rol: string + } +} + +export async function login(username: string, password: string): Promise { + const response = await axiosClient.post('/api/v1/auth/login', { + username, + password, + }) + return response.data +} diff --git a/src/web/src/features/auth/components/LoginForm.tsx b/src/web/src/features/auth/components/LoginForm.tsx new file mode 100644 index 0000000..860b61c --- /dev/null +++ b/src/web/src/features/auth/components/LoginForm.tsx @@ -0,0 +1,66 @@ +import type { FormEvent } from 'react' + +interface LoginFormProps { + onSubmit: (username: string, password: string) => void + isLoading: boolean + error: string | null +} + +export function LoginForm({ onSubmit, isLoading, error }: LoginFormProps) { + function handleSubmit(e: FormEvent) { + e.preventDefault() + const form = e.currentTarget + const data = new FormData(form) + const username = data.get('username') as string + const password = data.get('password') as string + onSubmit(username, password) + } + + return ( +
+
+ + +
+ +
+ + +
+ + {error && ( +

+ {error} +

+ )} + + +
+ ) +} diff --git a/src/web/src/features/auth/hooks/useLogin.ts b/src/web/src/features/auth/hooks/useLogin.ts new file mode 100644 index 0000000..a637832 --- /dev/null +++ b/src/web/src/features/auth/hooks/useLogin.ts @@ -0,0 +1,29 @@ +import { useMutation } from '@tanstack/react-query' +import { login } from '../api/authApi' +import { useAuthStore } from '../../../stores/authStore' + +interface LoginVars { + username: string + password: string +} + +export function useLogin() { + const setAuth = useAuthStore((s) => s.setAuth) + + return useMutation({ + mutationFn: ({ username, password }: LoginVars) => login(username, password), + onSuccess: (data) => { + setAuth({ + user: { + id: data.usuario.id, + username: data.usuario.username, + nombre: data.usuario.nombre, + rol: data.usuario.rol, + }, + accessToken: data.accessToken, + refreshToken: data.refreshToken, + expiresIn: data.expiresIn, + }) + }, + }) +} diff --git a/src/web/src/features/auth/pages/LoginPage.tsx b/src/web/src/features/auth/pages/LoginPage.tsx new file mode 100644 index 0000000..08aa325 --- /dev/null +++ b/src/web/src/features/auth/pages/LoginPage.tsx @@ -0,0 +1,44 @@ +import { useNavigate } from 'react-router-dom' +import { useLogin } from '../hooks/useLogin' +import { LoginForm } from '../components/LoginForm' +import { isAxiosError } from 'axios' + +export function LoginPage() { + const navigate = useNavigate() + const { mutate, isPending, error } = useLogin() + + function handleSubmit(username: string, password: string) { + mutate( + { username, password }, + { + onSuccess: () => { + void navigate('/') + }, + }, + ) + } + + function resolveErrorMessage(err: unknown): string | null { + if (!err) return null + if (isAxiosError(err) && err.response?.data) { + const data = err.response.data as { error?: string } + return data.error ?? 'Error al iniciar sesión' + } + return 'Error al iniciar sesión' + } + + return ( +
+
+

+ SIG-CM2 +

+ +
+
+ ) +} diff --git a/src/web/src/index.css b/src/web/src/index.css new file mode 100644 index 0000000..b223f75 --- /dev/null +++ b/src/web/src/index.css @@ -0,0 +1,81 @@ +@import "tailwindcss"; + +@media (prefers-color-scheme: dark) { + :root { + --text: #9ca3af; + --text-h: #f3f4f6; + --bg: #16171d; + --border: #2e303a; + --code-bg: #1f2028; + --accent: #c084fc; + --accent-bg: rgba(192, 132, 252, 0.15); + --accent-border: rgba(192, 132, 252, 0.5); + --social-bg: rgba(47, 48, 58, 0.5); + --shadow: + rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; + } + + #social .button-icon { + filter: invert(1) brightness(2); + } +} + +#root { + width: 1126px; + max-width: 100%; + margin: 0 auto; + text-align: center; + border-inline: 1px solid var(--border); + min-height: 100svh; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +body { + margin: 0; +} + +h1, +h2 { + font-family: var(--heading); + font-weight: 500; + color: var(--text-h); +} + +h1 { + font-size: 56px; + letter-spacing: -1.68px; + margin: 32px 0; + @media (max-width: 1024px) { + font-size: 36px; + margin: 20px 0; + } +} +h2 { + font-size: 24px; + line-height: 118%; + letter-spacing: -0.24px; + margin: 0 0 8px; + @media (max-width: 1024px) { + font-size: 20px; + } +} +p { + margin: 0; +} + +code, +.counter { + font-family: var(--mono); + display: inline-flex; + border-radius: 4px; + color: var(--text-h); +} + +code { + font-size: 15px; + line-height: 135%; + padding: 4px 8px; + background: var(--code-bg); +} diff --git a/src/web/src/layouts/ProtectedLayout.tsx b/src/web/src/layouts/ProtectedLayout.tsx new file mode 100644 index 0000000..8d572ca --- /dev/null +++ b/src/web/src/layouts/ProtectedLayout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react' + +interface ProtectedLayoutProps { + children: ReactNode +} + +export function ProtectedLayout({ children }: ProtectedLayoutProps) { + return ( +
+ {children} +
+ ) +} diff --git a/src/web/src/layouts/PublicLayout.tsx b/src/web/src/layouts/PublicLayout.tsx new file mode 100644 index 0000000..0526116 --- /dev/null +++ b/src/web/src/layouts/PublicLayout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react' + +interface PublicLayoutProps { + children: ReactNode +} + +export function PublicLayout({ children }: PublicLayoutProps) { + return ( +
+ {children} +
+ ) +} diff --git a/src/web/src/main.tsx b/src/web/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/src/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/src/web/src/pages/HomePage.tsx b/src/web/src/pages/HomePage.tsx new file mode 100644 index 0000000..452ea85 --- /dev/null +++ b/src/web/src/pages/HomePage.tsx @@ -0,0 +1,8 @@ +export function HomePage() { + return ( +
+

Dashboard

+

Bienvenido al SIG-CM2.

+
+ ) +} diff --git a/src/web/src/router.tsx b/src/web/src/router.tsx new file mode 100644 index 0000000..9dfe380 --- /dev/null +++ b/src/web/src/router.tsx @@ -0,0 +1,50 @@ +import { Navigate, Route, Routes } from 'react-router-dom' +import { useAuthStore } from './stores/authStore' +import { LoginPage } from './features/auth/pages/LoginPage' +import { HomePage } from './pages/HomePage' +import { PublicLayout } from './layouts/PublicLayout' +import { ProtectedLayout } from './layouts/ProtectedLayout' + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const user = useAuthStore((s) => s.user) + if (!user) { + return + } + return <>{children} +} + +function PublicRoute({ children }: { children: React.ReactNode }) { + const user = useAuthStore((s) => s.user) + if (user) { + return + } + return <>{children} +} + +export function AppRoutes() { + return ( + + + + + + + } + /> + + + + + + } + /> + } /> + + ) +} diff --git a/src/web/src/stores/authStore.ts b/src/web/src/stores/authStore.ts new file mode 100644 index 0000000..99f3c2e --- /dev/null +++ b/src/web/src/stores/authStore.ts @@ -0,0 +1,53 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +export interface AuthUser { + id: number + username: string + nombre: string + rol: string +} + +interface SetAuthPayload { + user: AuthUser + accessToken: string + refreshToken: string + expiresIn: number +} + +interface AuthState { + user: AuthUser | null + accessToken: string | null + setAuth: (payload: SetAuthPayload) => void + logout: () => void +} + +export const useAuthStore = create()( + persist( + (set) => ({ + user: null, + accessToken: null, + + setAuth: (payload: SetAuthPayload) => { + set({ + user: payload.user, + accessToken: payload.accessToken, + }) + }, + + logout: () => { + set({ + user: null, + accessToken: null, + }) + }, + }), + { + name: 'auth-storage', + partialize: (state) => ({ + user: state.user, + accessToken: state.accessToken, + }), + }, + ), +)