UDT-001: Login (scaffolding + JWT RS256 end-to-end) #1
22
src/web/src/App.tsx
Normal file
22
src/web/src/App.tsx
Normal 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
|
||||||
10
src/web/src/api/axiosClient.ts
Normal file
10
src/web/src/api/axiosClient.ts
Normal 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',
|
||||||
|
},
|
||||||
|
})
|
||||||
21
src/web/src/features/auth/api/authApi.ts
Normal file
21
src/web/src/features/auth/api/authApi.ts
Normal 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
|
||||||
|
}
|
||||||
66
src/web/src/features/auth/components/LoginForm.tsx
Normal file
66
src/web/src/features/auth/components/LoginForm.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
src/web/src/features/auth/hooks/useLogin.ts
Normal file
29
src/web/src/features/auth/hooks/useLogin.ts
Normal 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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
44
src/web/src/features/auth/pages/LoginPage.tsx
Normal file
44
src/web/src/features/auth/pages/LoginPage.tsx
Normal 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
81
src/web/src/index.css
Normal 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);
|
||||||
|
}
|
||||||
13
src/web/src/layouts/ProtectedLayout.tsx
Normal file
13
src/web/src/layouts/ProtectedLayout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/web/src/layouts/PublicLayout.tsx
Normal file
13
src/web/src/layouts/PublicLayout.tsx
Normal 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
10
src/web/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
8
src/web/src/pages/HomePage.tsx
Normal file
8
src/web/src/pages/HomePage.tsx
Normal 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
50
src/web/src/router.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
src/web/src/stores/authStore.ts
Normal file
53
src/web/src/stores/authStore.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user