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:
2026-07-12 21:26:30 +00:00
parent b2069df4c3
commit d0febb4855
7 changed files with 220 additions and 39 deletions
@@ -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>
</>
)
}
+16
View File
@@ -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>,
)
}
+16
View File
@@ -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>,
)
}