frontend finished
This commit is contained in:
@@ -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"])
|
||||
@@ -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
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