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 (
+
+ )
+}
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 (
+
+ )
+}
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,
+ }),
+ },
+ ),
+)