feat(udt-001): frontend auth UI (Zustand store, TanStack Query, LoginPage, router)

This commit is contained in:
2026-04-13 21:36:32 -03:00
parent 5f6ebccb54
commit a692576bc3
13 changed files with 420 additions and 0 deletions

22
src/web/src/App.tsx Normal file
View File

@@ -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 (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</QueryClientProvider>
)
}
export default App

View File

@@ -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',
},
})

View File

@@ -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<LoginResponseDto> {
const response = await axiosClient.post<LoginResponseDto>('/api/v1/auth/login', {
username,
password,
})
return response.data
}

View File

@@ -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<HTMLFormElement>) {
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 (
<form onSubmit={handleSubmit} className="flex flex-col gap-4 w-full max-w-sm">
<div className="flex flex-col gap-1">
<label htmlFor="username" className="text-sm font-medium text-gray-700">
Usuario
</label>
<input
id="username"
name="username"
type="text"
required
autoComplete="username"
disabled={isLoading}
className="rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="password" className="text-sm font-medium text-gray-700">
Contraseña
</label>
<input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
disabled={isLoading}
className="rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
{error && (
<p role="alert" className="text-sm text-red-600">
{error}
</p>
)}
<button
type="submit"
disabled={isLoading}
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Ingresando...' : 'Ingresar'}
</button>
</form>
)
}

View File

@@ -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,
})
},
})
}

View File

@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="rounded-lg bg-white p-8 shadow-md w-full max-w-sm">
<h1 className="mb-6 text-center text-2xl font-semibold text-gray-900">
SIG-CM2
</h1>
<LoginForm
onSubmit={handleSubmit}
isLoading={isPending}
error={resolveErrorMessage(error)}
/>
</div>
</div>
)
}

81
src/web/src/index.css Normal file
View File

@@ -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);
}

View File

@@ -0,0 +1,13 @@
import type { ReactNode } from 'react'
interface ProtectedLayoutProps {
children: ReactNode
}
export function ProtectedLayout({ children }: ProtectedLayoutProps) {
return (
<div className="min-h-screen bg-white">
{children}
</div>
)
}

View File

@@ -0,0 +1,13 @@
import type { ReactNode } from 'react'
interface PublicLayoutProps {
children: ReactNode
}
export function PublicLayout({ children }: PublicLayoutProps) {
return (
<div className="min-h-screen bg-gray-50">
{children}
</div>
)
}

10
src/web/src/main.tsx Normal file
View File

@@ -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(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,8 @@
export function HomePage() {
return (
<div className="p-8">
<h1 className="text-2xl font-semibold text-gray-900">Dashboard</h1>
<p className="mt-2 text-gray-600">Bienvenido al SIG-CM2.</p>
</div>
)
}

50
src/web/src/router.tsx Normal file
View File

@@ -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 <Navigate to="/login" replace />
}
return <>{children}</>
}
function PublicRoute({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user)
if (user) {
return <Navigate to="/" replace />
}
return <>{children}</>
}
export function AppRoutes() {
return (
<Routes>
<Route
path="/login"
element={
<PublicRoute>
<PublicLayout>
<LoginPage />
</PublicLayout>
</PublicRoute>
}
/>
<Route
path="/"
element={
<ProtectedRoute>
<ProtectedLayout>
<HomePage />
</ProtectedLayout>
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}

View File

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