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 typing import Union
from fastapi import FastAPI from fastapi import FastAPI
from app.routes import users, auth from app.routes import users, auth
from fastapi.middleware.cors import CORSMiddleware
""" """
ENDPOINTS: ENDPOINTS:
@@ -11,6 +12,19 @@ from app.routes import users, auth
""" """
app = FastAPI(title="FastAPI + MariaDB + 2FA Example") 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 # Registramos las rutas
app.include_router(users.router, tags=["Users"]) app.include_router(users.router, tags=["Users"])
app.include_router(auth.router, tags=["Authentication"]) app.include_router(auth.router, tags=["Authentication"])

View File

@@ -1,11 +1,355 @@
import 'bootstrap/dist/css/bootstrap.min.css' import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap/dist/js/bootstrap.bundle.min.js' 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 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 ( 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