From ceac24ffe7f45c01b92f430be4fa67a82df77c9b Mon Sep 17 00:00:00 2001 From: Jose Date: Mon, 10 Nov 2025 20:13:02 +0100 Subject: [PATCH] frontend finished --- backend/app/main.py | 14 + frontend/src/App.jsx | 350 +++++++++++++++++- frontend/src/api/auth.js | 26 ++ frontend/src/api/client.js | 11 + .../src/components/feedback/AlertMessage.jsx | 16 + frontend/src/components/forms/Enable2FA.jsx | 42 +++ frontend/src/components/forms/LoginForm.jsx | 54 +++ .../src/components/forms/RegisterForm.jsx | 72 ++++ frontend/src/components/forms/TotpForm.jsx | 48 +++ 9 files changed, 630 insertions(+), 3 deletions(-) create mode 100644 frontend/src/api/auth.js create mode 100644 frontend/src/api/client.js create mode 100644 frontend/src/components/feedback/AlertMessage.jsx create mode 100644 frontend/src/components/forms/Enable2FA.jsx create mode 100644 frontend/src/components/forms/LoginForm.jsx create mode 100644 frontend/src/components/forms/RegisterForm.jsx create mode 100644 frontend/src/components/forms/TotpForm.jsx diff --git a/backend/app/main.py b/backend/app/main.py index 998e07a..e8a1f9f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"]) \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 711b655..311f145 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( - <> - - +
+
+
+
+
+
+

Portal 2FA

+ clearFeedback() : undefined} + /> + + {step === 'login' && ( + <> +
    +
  • + +
  • +
  • + +
  • +
+ + {activeTab === 'login' ? ( + + ) : ( + + )} + + )} + + {step === 'totp' && ( + <> +

+ Usuario {authState.userName} autenticado en primer factor. Genera el codigo en tu app y verificalo para continuar. +

+ + + + )} + + {step === 'enable' && ( + <> + setStep('totp')} + onRefresh={handleEnableTwoFactor} + isLoading={loading} + /> + + + )} + + {step === 'dashboard' && ( +
+
+

Token de acceso

+ {authState.accessToken} +
+ + {tokenPayload && ( +
+

Payload decodificado

+
+                          {JSON.stringify(tokenPayload, null, 2)}
+                        
+
+ )} + +
+ + +
+ + {users.length > 0 && ( +
+ + + + + + + + + {users.map((user) => ( + + + + + ))} + +
IDUsuario
{user.user_id}{user.user_name}
+
+ )} +
+ )} + + {step !== 'login' && step !== 'dashboard' && ( + + )} +
+
+
+
+
+
) } diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js new file mode 100644 index 0000000..7fd89ac --- /dev/null +++ b/frontend/src/api/auth.js @@ -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 +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..28442e1 --- /dev/null +++ b/frontend/src/api/client.js @@ -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 diff --git a/frontend/src/components/feedback/AlertMessage.jsx b/frontend/src/components/feedback/AlertMessage.jsx new file mode 100644 index 0000000..f27be1b --- /dev/null +++ b/frontend/src/components/feedback/AlertMessage.jsx @@ -0,0 +1,16 @@ +const AlertMessage = ({ type = 'info', message, onClose }) => { + if (!message) { + return null + } + + return ( +
+ {message} + {onClose && ( +
+ ) +} + +export default AlertMessage diff --git a/frontend/src/components/forms/Enable2FA.jsx b/frontend/src/components/forms/Enable2FA.jsx new file mode 100644 index 0000000..161c9cd --- /dev/null +++ b/frontend/src/components/forms/Enable2FA.jsx @@ -0,0 +1,42 @@ +const Enable2FA = ({ secret, otpauthUrl, qrBase64, onBack, onRefresh, isLoading }) => { + return ( +
+

Escanea el codigo o introduce la clave manualmente en tu app TOTP.

+ {qrBase64 ? ( + Codigo QR 2FA + ) : ( +
+ No se pudo generar la imagen QR. Usa la clave manual. +
+ )} +
+ Clave TOTP: +
{secret}
+
+
+ URL OTPAuth: +
{otpauthUrl}
+
+
+ + +
+
+ ) +} + +export default Enable2FA diff --git a/frontend/src/components/forms/LoginForm.jsx b/frontend/src/components/forms/LoginForm.jsx new file mode 100644 index 0000000..720fbb7 --- /dev/null +++ b/frontend/src/components/forms/LoginForm.jsx @@ -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 ( +
+
+ + +
+
+ + +
+ +
+ ) +} + +export default LoginForm diff --git a/frontend/src/components/forms/RegisterForm.jsx b/frontend/src/components/forms/RegisterForm.jsx new file mode 100644 index 0000000..92171e7 --- /dev/null +++ b/frontend/src/components/forms/RegisterForm.jsx @@ -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 ( +
+
+ + +
+
+ + +
+
+ + + {localError &&
{localError}
} +
+ +
+ ) +} + +export default RegisterForm diff --git a/frontend/src/components/forms/TotpForm.jsx b/frontend/src/components/forms/TotpForm.jsx new file mode 100644 index 0000000..ffbc4bb --- /dev/null +++ b/frontend/src/components/forms/TotpForm.jsx @@ -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 ( +
+
+ + setTotpCode(event.target.value)} + placeholder="123456" + required + /> +
Introduce el codigo de tu app de autenticacion
+
+
+ {onBack && ( + + )} + +
+
+ ) +} + +export default TotpForm