frontend finished
This commit is contained in:
@@ -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"])
|
||||||
@@ -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'
|
||||||
|
|
||||||
const App = () => {
|
import { useMemo, useState } from 'react'
|
||||||
return (
|
|
||||||
<>
|
|
||||||
|
|
||||||
|
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
26
frontend/src/api/auth.js
Normal 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
|
||||||
|
}
|
||||||
11
frontend/src/api/client.js
Normal file
11
frontend/src/api/client.js
Normal 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
|
||||||
16
frontend/src/components/feedback/AlertMessage.jsx
Normal file
16
frontend/src/components/feedback/AlertMessage.jsx
Normal 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
|
||||||
42
frontend/src/components/forms/Enable2FA.jsx
Normal file
42
frontend/src/components/forms/Enable2FA.jsx
Normal 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
|
||||||
54
frontend/src/components/forms/LoginForm.jsx
Normal file
54
frontend/src/components/forms/LoginForm.jsx
Normal 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
|
||||||
72
frontend/src/components/forms/RegisterForm.jsx
Normal file
72
frontend/src/components/forms/RegisterForm.jsx
Normal 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
|
||||||
48
frontend/src/components/forms/TotpForm.jsx
Normal file
48
frontend/src/components/forms/TotpForm.jsx
Normal 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
|
||||||
Reference in New Issue
Block a user