Fase 3 (islas): cambiar contraseña y token de seguridad en React/TSX
Ambas vistas ya devolvían JSON en POST y exigen sesión, así que sin cambio de backend: - ChangePasswordForm.tsx: 4 campos (actual/nueva/confirmar/token) con mostrar-ocultar; POST form-urlencoded + CSRF a change_password; mensaje HTML; si el backend responde redirect:true (logout por seguridad), redirige a login. - SecurityTokenForm.tsx: muestra la fecha del token y un botón que solicita uno nuevo; POST -> actualiza la fecha con token_date de la respuesta. - Plantillas: los <form> + scripts jQuery se sustituyen por las islas (#change-password-app / #security-token-app) con data-csrf/data-url. El texto informativo y los enlaces se quedan en Django. Verificado: check OK; ambas vistas siguen redirigiendo a login/index sin sesión. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
csrfToken: string
|
||||
loginUrl: string
|
||||
}
|
||||
|
||||
interface Field {
|
||||
key: 'cur' | 'new' | 'conf' | 'token'
|
||||
name: string
|
||||
placeholder: string
|
||||
maxLength: number
|
||||
}
|
||||
|
||||
const FIELDS: Field[] = [
|
||||
{ key: 'cur', name: 'cur-password', placeholder: 'Contraseña actual', maxLength: 16 },
|
||||
{ key: 'new', name: 'new-password', placeholder: 'Contraseña nueva', maxLength: 16 },
|
||||
{ key: 'conf', name: 'conf-new-password', placeholder: 'Confirmar contraseña nueva', maxLength: 16 },
|
||||
{ key: 'token', name: 'security-token', placeholder: 'Token de seguridad', maxLength: 6 },
|
||||
]
|
||||
|
||||
export function ChangePasswordForm({ url, csrfToken, loginUrl }: Props) {
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [shown, setShown] = useState<Record<string, boolean>>({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const [message, setMessage] = useState<{ ok: boolean; html: string } | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (busy || done) return
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
|
||||
const body = new URLSearchParams()
|
||||
for (const f of FIELDS) body.set(f.name, (values[f.key] || '').trim())
|
||||
body.set('csrfmiddlewaretoken', csrfToken)
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body.toString(),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data: { success?: boolean; message?: string; redirect?: boolean } = await resp.json()
|
||||
setMessage({ ok: !!data.success, html: data.message || '' })
|
||||
if (data.success && data.redirect) {
|
||||
setDone(true)
|
||||
setTimeout(() => {
|
||||
window.location.href = loginUrl
|
||||
}, 3000)
|
||||
} else {
|
||||
setBusy(false)
|
||||
}
|
||||
} catch {
|
||||
setMessage({ ok: false, html: '<span class="red-form-response">Error en el servidor. Inténtalo más tarde.</span>' })
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form id="nw-change-password-form" onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<table className="middle-center-table">
|
||||
<tbody>
|
||||
{FIELDS.map((f) => (
|
||||
<tr key={f.key}>
|
||||
<td>
|
||||
<input
|
||||
type={shown[f.key] ? 'text' : 'password'}
|
||||
maxLength={f.maxLength}
|
||||
name={f.name}
|
||||
id={f.name}
|
||||
placeholder={f.placeholder}
|
||||
value={values[f.key] || ''}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [f.key]: e.target.value }))}
|
||||
/>
|
||||
<span
|
||||
className={`far toggle-password ${shown[f.key] ? 'fa-eye-slash' : 'fa-eye'}`}
|
||||
onClick={() => setShown((s) => ({ ...s, [f.key]: !s[f.key] }))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td>
|
||||
<button type="submit" className="change-password-button" disabled={busy || done}>
|
||||
{done ? 'Hecho' : busy ? 'Cambiando…' : 'Cambiar contraseña'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
<hr />
|
||||
<div className="alert-message" id="change-password-response" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && <span dangerouslySetInnerHTML={{ __html: message.html }} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
csrfToken: string
|
||||
initialDate: string
|
||||
}
|
||||
|
||||
export function SecurityTokenForm({ url, csrfToken, initialDate }: Props) {
|
||||
const [date, setDate] = useState(initialDate)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<{ ok: boolean; html: string } | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (busy) return
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
|
||||
const body = new URLSearchParams()
|
||||
body.set('csrfmiddlewaretoken', csrfToken)
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body.toString(),
|
||||
})
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data: { success?: boolean; message?: string; token_date?: string } = await resp.json()
|
||||
setMessage({ ok: !!data.success, html: data.message || '' })
|
||||
if (data.success && data.token_date) setDate(data.token_date)
|
||||
} catch {
|
||||
setMessage({ ok: false, html: '<span class="red-form-response">Error en el servidor. Inténtalo más tarde.</span>' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>Fecha de solicitud de Token de seguridad:</p>
|
||||
<p><span id="token-date">{date}</span></p>
|
||||
<br />
|
||||
<form id="nw-sec-token-form" onSubmit={handleSubmit}>
|
||||
<button className="sec-token-button" type="submit" disabled={busy}>
|
||||
{busy ? 'Solicitando…' : 'Solicitar token'}
|
||||
</button>
|
||||
</form>
|
||||
<br />
|
||||
<hr />
|
||||
<div className="alert-message" id="sec-token-response" style={{ display: message ? 'block' : 'none' }}>
|
||||
{message && <span dangerouslySetInnerHTML={{ __html: message.html }} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ChangePasswordForm } from '../components/ChangePasswordForm'
|
||||
|
||||
const el = document.getElementById('change-password-app')
|
||||
if (el) {
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<ChangePasswordForm
|
||||
url={el.dataset.url || ''}
|
||||
csrfToken={el.dataset.csrf || ''}
|
||||
loginUrl={el.dataset.loginUrl || '/es/log-in/'}
|
||||
/>
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { SecurityTokenForm } from '../components/SecurityTokenForm'
|
||||
|
||||
const el = document.getElementById('security-token-app')
|
||||
if (el) {
|
||||
createRoot(el).render(
|
||||
<StrictMode>
|
||||
<SecurityTokenForm
|
||||
url={el.dataset.url || ''}
|
||||
csrfToken={el.dataset.csrf || ''}
|
||||
initialDate={el.dataset.tokenDate || 'Sin solicitar'}
|
||||
/>
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export default defineConfig({
|
||||
recover: resolve(__dirname, 'src/entries/recover.tsx'),
|
||||
reset_password: resolve(__dirname, 'src/entries/reset_password.tsx'),
|
||||
my_account: resolve(__dirname, 'src/entries/my_account.tsx'),
|
||||
change_password: resolve(__dirname, 'src/entries/change_password.tsx'),
|
||||
security_token: resolve(__dirname, 'src/entries/security_token.tsx'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -13,34 +13,14 @@
|
||||
<div class="centered">
|
||||
<br>
|
||||
<br>
|
||||
<form action="" method="POST" id="nw-change-password-form" accept-charset="utf-8">
|
||||
<table class="middle-center-table">
|
||||
<script
|
||||
src="{{ URL_PRINCIPAL }}/static/nw-themes/nw-ryu/nw-js-handlers/change_password_response.js">
|
||||
</script>
|
||||
<tbody><tr>
|
||||
<td><input type="password" maxlength="16" name="cur-password" id="cur-password" placeholder="Contraseña actual" autofocus="">
|
||||
<span class="far fa-eye toggle-cur-password"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><input type="password" maxlength="16" name="new-password" id="new-password" placeholder="Contraseña nueva">
|
||||
<span class="far fa-eye toggle-new-password"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><input type="password" maxlength="16" name="conf-new-password" id="conf-new-password" placeholder="Confirmar contraseña nueva">
|
||||
<span class="far fa-eye toggle-conf-new-password"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><input type="password" maxlength="6" name="security-token" id="security-token" placeholder="Token de seguridad">
|
||||
<span class="far fa-eye toggle-token"></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><button type="submit" name="changepassword" class="change-password-button" data-id="CambiarPassword">Cambiar contraseña</button></td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</form>
|
||||
<hr>
|
||||
<div class="alert-message" id="change-password-response"></div>
|
||||
{% load django_vite %}
|
||||
<!-- Isla React (Vite): cambio de contraseña. POST a la propia
|
||||
vista (JSON) -> re-deriva el verifier bnet. -->
|
||||
<div id="change-password-app"
|
||||
data-csrf="{{ csrf_token }}"
|
||||
data-url="{% url 'change_password' %}"
|
||||
data-login-url="{% url 'login' %}"></div>
|
||||
{% vite_asset 'src/entries/change_password.tsx' %}
|
||||
<br>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<div class="body-content">
|
||||
<div class="title-content"><h1>Token de seguridad</h1></div>
|
||||
<div class="box-content">
|
||||
<script src="{{ URL_PRINCIPAL }}/static/nw-themes/nw-ryu/nw-js-handlers/security_token_response.js"></script>
|
||||
<div class="title-box-content"><h2>Información</h2><a class="back-to-account" href="my-account">Regresar a mi cuenta</a><a class="back-to-account-responsive" href="my-account">Regresar</a></div>
|
||||
<div class="body-box-content justified">
|
||||
<br>
|
||||
@@ -24,16 +23,14 @@
|
||||
<div class="centered">
|
||||
<br>
|
||||
<br>
|
||||
<p>Fecha de solicitud de Token de seguridad:</p>
|
||||
<p><span id="token-date">{{ token_date }}</span></p>
|
||||
<br>
|
||||
<form id="nw-sec-token-form" method="POST">
|
||||
{% csrf_token %}
|
||||
<button class="sec-token-button" type="submit" name="securitytoken" data-id="Sectoken">Solicitar token</button>
|
||||
</form>
|
||||
<br>
|
||||
<hr>
|
||||
<div class="alert-message" id="sec-token-response"></div>
|
||||
{% load django_vite %}
|
||||
<!-- Isla React (Vite): solicitud del token de seguridad.
|
||||
POST a la propia vista (JSON), actualiza la fecha. -->
|
||||
<div id="security-token-app"
|
||||
data-csrf="{{ csrf_token }}"
|
||||
data-url="{% url 'security_token' %}"
|
||||
data-token-date="{{ token_date }}"></div>
|
||||
{% vite_asset 'src/entries/security_token.tsx' %}
|
||||
<br>
|
||||
<p><a href="recover">He olvidado el Token de seguridad</a></p>
|
||||
<br>
|
||||
|
||||
Reference in New Issue
Block a user