frontend finished

This commit is contained in:
2025-11-10 20:13:02 +01:00
parent d2f3cad487
commit ceac24ffe7
9 changed files with 630 additions and 3 deletions

View File

@@ -1,6 +1,7 @@
from typing import Union
from fastapi import FastAPI
from app.routes import users, auth
from fastapi.middleware.cors import CORSMiddleware
"""
ENDPOINTS:
@@ -11,6 +12,19 @@ from app.routes import users, auth
"""
app = FastAPI(title="FastAPI + MariaDB + 2FA Example")
origins = [
"http://localhost:5173", # tu frontend (vite, react, etc.)
"http://127.0.0.1:5173",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins, # or ["*"] para permitir todo (solo dev)
allow_credentials=True,
allow_methods=["*"], # GET, POST, PUT, DELETE...
allow_headers=["*"], # Authorization, Content-Type...
)
# Registramos las rutas
app.include_router(users.router, tags=["Users"])
app.include_router(auth.router, tags=["Authentication"])

View File

@@ -1,11 +1,355 @@
import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap/dist/js/bootstrap.bundle.min.js'
import { useMemo, useState } from 'react'
import LoginForm from '@/components/forms/LoginForm.jsx'
import RegisterForm from '@/components/forms/RegisterForm.jsx'
import TotpForm from '@/components/forms/TotpForm.jsx'
import Enable2FA from '@/components/forms/Enable2FA.jsx'
import AlertMessage from '@/components/feedback/AlertMessage.jsx'
import {
enableTwoFactor,
listUsers,
login,
registerUser,
verifyTwoFactor
} from '@/api/auth.js'
const defaultAuthState = {
userName: '',
preAuthToken: '',
accessToken: '',
totpSecret: '',
otpauthUrl: '',
qrBase64: ''
}
const decodeBase64 = (value) => {
if (typeof globalThis.atob === 'function') {
return globalThis.atob(value)
}
if (typeof globalThis.Buffer !== 'undefined') {
return globalThis.Buffer.from(value, 'base64').toString('binary')
}
throw new Error('Decodificador base64 no disponible')
}
const decodeJwt = (token) => {
try {
const [, payload] = token.split('.')
if (!payload) {
return null
}
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(normalized.length + (4 - (normalized.length % 4 || 4)) % 4, '=')
const decoded = decodeBase64(padded)
return JSON.parse(decoded)
} catch (error) {
console.warn('No se pudo decodificar el token', error)
return null
}
}
const getErrorMessage = (error, fallback = 'Ha ocurrido un error inesperado') => {
if (error?.response?.data?.detail) {
return error.response.data.detail
}
if (error?.message) {
return error.message
}
return fallback
}
const App = () => {
const [step, setStep] = useState('login')
const [activeTab, setActiveTab] = useState('login')
const [authState, setAuthState] = useState(defaultAuthState)
const [feedback, setFeedback] = useState({ type: 'info', message: '' })
const [loading, setLoading] = useState(false)
const [users, setUsers] = useState([])
const resetFlow = () => {
setStep('login')
setActiveTab('login')
setAuthState(defaultAuthState)
setUsers([])
setFeedback({ type: 'info', message: '' })
}
const showFeedback = (type, message) => {
setFeedback({ type, message })
}
const clearFeedback = () => {
setFeedback((prev) => ({ ...prev, message: '' }))
}
const handleRegister = async (payload) => {
setLoading(true)
clearFeedback()
try {
const result = await registerUser(payload)
showFeedback('success', `Usuario ${result.user_name} creado correctamente`)
setActiveTab('login')
} catch (error) {
showFeedback('danger', getErrorMessage(error, 'No se pudo registrar el usuario'))
} finally {
setLoading(false)
}
}
const handleLogin = async (payload) => {
setLoading(true)
clearFeedback()
try {
const result = await login(payload)
setAuthState({
...defaultAuthState,
userName: payload.user_name,
preAuthToken: result.pre_auth_token
})
setStep('totp')
showFeedback('success', 'Paso 1 completado. Introduce el codigo TOTP para continuar')
} catch (error) {
showFeedback('danger', getErrorMessage(error, 'No se pudo iniciar sesion'))
} finally {
setLoading(false)
}
}
const handleEnableTwoFactor = async () => {
const token = authState.accessToken || authState.preAuthToken
if (!token) {
showFeedback('warning', 'Necesitas iniciar sesion primero')
return
}
setLoading(true)
clearFeedback()
try {
const result = await enableTwoFactor(token)
setAuthState((prev) => ({
...prev,
userName: result.user_name,
totpSecret: result.totp_secret,
otpauthUrl: result.otpauth_url,
qrBase64: result.qr_base64
}))
setStep('enable')
showFeedback('info', 'Escanea el QR y luego valida el codigo TOTP')
} catch (error) {
showFeedback('danger', getErrorMessage(error, 'No se pudo habilitar el 2FA'))
} finally {
setLoading(false)
}
}
const handleVerifyTwoFactor = async (code) => {
if (!authState.preAuthToken) {
showFeedback('warning', 'Primero inicia sesion con usuario y clave')
return
}
setLoading(true)
clearFeedback()
try {
const result = await verifyTwoFactor({
user_name: authState.userName,
pre_auth_token: authState.preAuthToken,
totp_code: code
})
setAuthState((prev) => ({
...prev,
accessToken: result.access_token
}))
setStep('dashboard')
showFeedback('success', result.message || 'Autenticacion completada')
} catch (error) {
showFeedback('danger', getErrorMessage(error, 'El codigo TOTP no es valido'))
} finally {
setLoading(false)
}
}
const handleFetchUsers = async () => {
setLoading(true)
clearFeedback()
setUsers([])
try {
const result = await listUsers()
setUsers(result)
showFeedback('info', 'Listado de usuarios actualizado')
} catch (error) {
if (error?.response?.status === 404) {
showFeedback('warning', error.response.data.detail || 'No hay usuarios registrados')
} else {
showFeedback('danger', getErrorMessage(error, 'No se pudo obtener la lista de usuarios'))
}
} finally {
setLoading(false)
}
}
const tokenPayload = useMemo(() => {
if (!authState.accessToken) {
return null
}
return decodeJwt(authState.accessToken)
}, [authState.accessToken])
return (
<>
</>
<main className="min-vh-100 d-flex align-items-center justify-content-center bg-dark-subtle">
<div className="container py-5">
<div className="row justify-content-center">
<div className="col-12 col-md-8 col-lg-6">
<div className="card shadow-lg bg-dark text-light border-0">
<div className="card-body p-4 p-md-5">
<h1 className="h3 text-center mb-4">Portal 2FA</h1>
<AlertMessage
type={feedback.type}
message={feedback.message}
onClose={feedback.message ? () => clearFeedback() : undefined}
/>
{step === 'login' && (
<>
<ul className="nav nav-pills mb-4" role="tablist">
<li className="nav-item" role="presentation">
<button
className={`nav-link ${activeTab === 'login' ? 'active' : ''}`}
type="button"
onClick={() => setActiveTab('login')}
>
Iniciar sesion
</button>
</li>
<li className="nav-item" role="presentation">
<button
className={`nav-link ${activeTab === 'register' ? 'active' : ''}`}
type="button"
onClick={() => setActiveTab('register')}
>
Registro
</button>
</li>
</ul>
{activeTab === 'login' ? (
<LoginForm onSubmit={handleLogin} isLoading={loading} />
) : (
<RegisterForm onSubmit={handleRegister} isLoading={loading} />
)}
</>
)}
{step === 'totp' && (
<>
<p className="mb-4">
Usuario <strong>{authState.userName}</strong> autenticado en primer factor. Genera el codigo en tu app y verificalo para continuar.
</p>
<TotpForm
onSubmit={handleVerifyTwoFactor}
isLoading={loading}
onBack={handleEnableTwoFactor}
/>
<button
type="button"
className="btn btn-link text-decoration-none mt-3 p-0"
onClick={resetFlow}
>
Volver a inicio
</button>
</>
)}
{step === 'enable' && (
<>
<Enable2FA
secret={authState.totpSecret}
otpauthUrl={authState.otpauthUrl}
qrBase64={authState.qrBase64}
onBack={() => setStep('totp')}
onRefresh={handleEnableTwoFactor}
isLoading={loading}
/>
<button
type="button"
className="btn btn-link text-decoration-none mt-3 p-0"
onClick={() => setStep('totp')}
>
Ya tengo el codigo, volver
</button>
</>
)}
{step === 'dashboard' && (
<div className="d-flex flex-column gap-3">
<div className="bg-dark-subtle rounded p-3">
<h2 className="h5">Token de acceso</h2>
<code className="small text-break">{authState.accessToken}</code>
</div>
{tokenPayload && (
<div className="bg-dark-subtle rounded p-3">
<h2 className="h5">Payload decodificado</h2>
<pre className="small text-break mb-0">
{JSON.stringify(tokenPayload, null, 2)}
</pre>
</div>
)}
<div className="d-flex gap-2">
<button type="button" className="btn btn-outline-primary" onClick={handleFetchUsers}>
Ver usuarios
</button>
<button type="button" className="btn btn-danger ms-auto" onClick={resetFlow}>
Cerrar sesion
</button>
</div>
{users.length > 0 && (
<div className="table-responsive">
<table className="table table-dark table-striped table-bordered align-middle mb-0">
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">Usuario</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.user_id}>
<td>{user.user_id}</td>
<td>{user.user_name}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{step !== 'login' && step !== 'dashboard' && (
<button
type="button"
className="btn btn-outline-light mt-4"
onClick={resetFlow}
>
Cancelar
</button>
)}
</div>
</div>
</div>
</div>
</div>
</main>
)
}

26
frontend/src/api/auth.js Normal file
View File

@@ -0,0 +1,26 @@
import client from './client'
export const registerUser = async (payload) => {
const { data } = await client.post('/users', payload)
return data
}
export const login = async (payload) => {
const { data } = await client.post('/login', payload)
return data
}
export const enableTwoFactor = async (token) => {
const { data } = await client.post('/enable-2fa', { token })
return data
}
export const verifyTwoFactor = async (payload) => {
const { data } = await client.post('/2fa', payload)
return data
}
export const listUsers = async () => {
const { data } = await client.get('/users')
return data
}

View File

@@ -0,0 +1,11 @@
import axios from 'axios'
const client = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000',
headers: {
'Content-Type': 'application/json'
},
timeout: 10000
})
export default client

View File

@@ -0,0 +1,16 @@
const AlertMessage = ({ type = 'info', message, onClose }) => {
if (!message) {
return null
}
return (
<div className={`alert alert-${type} d-flex justify-content-between align-items-center`} role="alert">
<span>{message}</span>
{onClose && (
<button type="button" className="btn-close" onClick={onClose} aria-label="Cerrar" />
)}
</div>
)
}
export default AlertMessage

View File

@@ -0,0 +1,42 @@
const Enable2FA = ({ secret, otpauthUrl, qrBase64, onBack, onRefresh, isLoading }) => {
return (
<div className="d-flex flex-column gap-3">
<p>Escanea el codigo o introduce la clave manualmente en tu app TOTP.</p>
{qrBase64 ? (
<img
src={`data:image/png;base64,${qrBase64}`}
alt="Codigo QR 2FA"
className="align-self-center border rounded"
style={{ width: '220px', height: '220px' }}
/>
) : (
<div className="alert alert-warning" role="alert">
No se pudo generar la imagen QR. Usa la clave manual.
</div>
)}
<div className="bg-dark-subtle rounded p-3">
<strong>Clave TOTP:</strong>
<div className="mt-2 text-break">{secret}</div>
</div>
<div className="bg-dark-subtle rounded p-3">
<strong>URL OTPAuth:</strong>
<div className="mt-2 text-break">{otpauthUrl}</div>
</div>
<div className="d-flex gap-2">
<button type="button" className="btn btn-outline-secondary" onClick={onBack}>
Volver a verificacion
</button>
<button
type="button"
className="btn btn-outline-primary ms-auto"
onClick={onRefresh}
disabled={isLoading}
>
{isLoading ? 'Generando...' : 'Generar de nuevo'}
</button>
</div>
</div>
)
}
export default Enable2FA

View File

@@ -0,0 +1,54 @@
import { useState } from 'react'
const LoginForm = ({ onSubmit, isLoading }) => {
const [formState, setFormState] = useState({
user_name: '',
password: ''
})
const handleChange = (event) => {
const { name, value } = event.target
setFormState((prev) => ({ ...prev, [name]: value }))
}
const handleSubmit = (event) => {
event.preventDefault()
onSubmit(formState)
}
return (
<form onSubmit={handleSubmit} className="d-flex flex-column gap-3">
<div>
<label htmlFor="user_name" className="form-label">Usuario</label>
<input
id="user_name"
name="user_name"
type="text"
className="form-control"
autoComplete="username"
value={formState.user_name}
onChange={handleChange}
required
/>
</div>
<div>
<label htmlFor="password" className="form-label">Clave</label>
<input
id="password"
name="password"
type="password"
className="form-control"
autoComplete="current-password"
value={formState.password}
onChange={handleChange}
required
/>
</div>
<button type="submit" className="btn btn-primary" disabled={isLoading}>
{isLoading ? 'Iniciando...' : 'Iniciar sesion'}
</button>
</form>
)
}
export default LoginForm

View File

@@ -0,0 +1,72 @@
import { useState } from 'react'
const RegisterForm = ({ onSubmit, isLoading }) => {
const [formState, setFormState] = useState({
user_name: '',
password: '',
confirmPassword: ''
})
const [localError, setLocalError] = useState('')
const handleChange = (event) => {
const { name, value } = event.target
setFormState((prev) => ({ ...prev, [name]: value }))
setLocalError('')
}
const handleSubmit = (event) => {
event.preventDefault()
if (formState.password !== formState.confirmPassword) {
setLocalError('Las claves no coinciden')
return
}
onSubmit({ user_name: formState.user_name, password: formState.password })
}
return (
<form onSubmit={handleSubmit} className="d-flex flex-column gap-3">
<div>
<label htmlFor="register-user" className="form-label">Usuario</label>
<input
id="register-user"
name="user_name"
type="text"
className="form-control"
value={formState.user_name}
onChange={handleChange}
required
/>
</div>
<div>
<label htmlFor="register-password" className="form-label">Clave</label>
<input
id="register-password"
name="password"
type="password"
className="form-control"
value={formState.password}
onChange={handleChange}
required
/>
</div>
<div>
<label htmlFor="register-confirm" className="form-label">Repetir clave</label>
<input
id="register-confirm"
name="confirmPassword"
type="password"
className="form-control"
value={formState.confirmPassword}
onChange={handleChange}
required
/>
{localError && <div className="form-text text-danger">{localError}</div>}
</div>
<button type="submit" className="btn btn-primary" disabled={isLoading}>
{isLoading ? 'Registrando...' : 'Crear cuenta'}
</button>
</form>
)
}
export default RegisterForm

View File

@@ -0,0 +1,48 @@
import { useState } from 'react'
const TotpForm = ({ onSubmit, isLoading, onBack }) => {
const [totpCode, setTotpCode] = useState('')
const handleSubmit = (event) => {
event.preventDefault()
onSubmit(totpCode.replace(/\s+/g, ''))
}
return (
<form onSubmit={handleSubmit} className="d-flex flex-column gap-3">
<div>
<label htmlFor="totpCode" className="form-label">Codigo TOTP</label>
<input
id="totpCode"
name="totpCode"
type="text"
pattern="[0-9]{6}"
inputMode="numeric"
className="form-control"
value={totpCode}
onChange={(event) => setTotpCode(event.target.value)}
placeholder="123456"
required
/>
<div className="form-text">Introduce el codigo de tu app de autenticacion</div>
</div>
<div className="d-flex gap-2">
{onBack && (
<button
type="button"
className="btn btn-outline-secondary"
onClick={onBack}
disabled={isLoading}
>
Configurar 2FA
</button>
)}
<button type="submit" className="btn btn-success ms-auto" disabled={isLoading}>
{isLoading ? 'Validando...' : 'Validar codigo'}
</button>
</div>
</form>
)
}
export default TotpForm