fa7897c195
Renombrado completo de los prefijos heredados: 19 carpetas, 11 ficheros y 318 referencias en 35 ficheros de código, más las clases y las rutas url() de dentro del CSS del tema. Se usa git mv para conservar el historial. También fuera del código, que un sed no ve: - BD: votesite.image_url (4 filas) apuntaba a /nw-themes/... - Ficheros con la marca vieja en el NOMBRE: novawow-maintenance.webp -> nightspire-maintenance.webp (lo usa la página de mantenimiento) y store_novawow_response.js. ⚠ Alias en Caddy /nw-themes/* -> /ns-themes/*: los correos ENVIADOS antes del rebranding llevan esas rutas escritas y están en las bandejas de los usuarios. Sin el alias, sus imágenes se romperían. Verificado que las rutas viejas siguen sirviendo 200. Nota: ns-js/ y ns-js-handlers/ son CÓDIGO MUERTO (manejadores jQuery del portal Django, que ya se borró). Se midió en el navegador: la web no pide ni un solo JS del tema. Se renombran igualmente por consistencia, pero son candidatos a borrarse. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
297 lines
9.6 KiB
TypeScript
297 lines
9.6 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { useTranslations } from 'next-intl'
|
|
|
|
const LOGOS = '/ns-themes/ns-ryu/ns-images/ns-logos'
|
|
|
|
type Msg = { success: boolean; text: string } | null
|
|
|
|
async function post(action: string, extra: Record<string, string>): Promise<{ success: boolean; message: string; qrCodeUrl?: string; secretKey?: string }> {
|
|
const res = await fetch('/api/account/2fa', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ action, ...extra }),
|
|
})
|
|
return res.json()
|
|
}
|
|
|
|
/** Ojo para mostrar/ocultar un campo de contraseña/token. */
|
|
function EyeToggle({ shown, onToggle }: { shown: boolean; onToggle: () => void }) {
|
|
const t = useTranslations('UI')
|
|
return (
|
|
<span
|
|
className={`far ${shown ? 'fa-eye-slash' : 'fa-eye'} toggle-password`}
|
|
onClick={onToggle}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={shown ? t('twofa.eyeHide') : t('twofa.eyeShow')}
|
|
></span>
|
|
)
|
|
}
|
|
|
|
export function TwoFactorLogin({ enabled }: { enabled: boolean }) {
|
|
return (
|
|
<div className="centered">
|
|
<br />
|
|
<br />
|
|
{enabled ? <DisableForm /> : <ActivateFlow />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------ Activar + QR ------------------------------- */
|
|
function ActivateFlow() {
|
|
const t = useTranslations('UI')
|
|
const [password, setPassword] = useState('')
|
|
const [showPassword, setShowPassword] = useState(false)
|
|
const [token, setToken] = useState('')
|
|
const [showToken, setShowToken] = useState(false)
|
|
const [busy, setBusy] = useState(false)
|
|
const [msg, setMsg] = useState<Msg>(null)
|
|
const [qr, setQr] = useState<{ qrCodeUrl: string; secretKey: string } | null>(null)
|
|
const [done, setDone] = useState(false)
|
|
|
|
// verificación
|
|
const [code, setCode] = useState('')
|
|
const [vBusy, setVBusy] = useState(false)
|
|
const [vMsg, setVMsg] = useState<Msg>(null)
|
|
const [copied, setCopied] = useState(false)
|
|
|
|
async function activate(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (busy) return
|
|
setBusy(true)
|
|
setMsg(null)
|
|
try {
|
|
const d = await post('activate', { password, securityToken: token })
|
|
setMsg({ success: !!d.success, text: d.message })
|
|
if (d.success && d.qrCodeUrl && d.secretKey) setQr({ qrCodeUrl: d.qrCodeUrl, secretKey: d.secretKey })
|
|
} catch {
|
|
setMsg({ success: false, text: t('twofa.errGeneric') })
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
async function verify(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (vBusy) return
|
|
setVBusy(true)
|
|
setVMsg(null)
|
|
try {
|
|
const d = await post('verify', { code })
|
|
setVMsg({ success: !!d.success, text: d.message })
|
|
if (d.success) setDone(true)
|
|
} catch {
|
|
setVMsg({ success: false, text: t('twofa.errGeneric') })
|
|
} finally {
|
|
setVBusy(false)
|
|
}
|
|
}
|
|
|
|
function copySecret() {
|
|
if (!qr) return
|
|
navigator.clipboard?.writeText(qr.secretKey).then(() => {
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 1500)
|
|
})
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<form noValidate onSubmit={activate} id="ns-2fa-form">
|
|
<table className="middle-center-table">
|
|
<tbody>
|
|
<tr>
|
|
<td>
|
|
<input
|
|
type={showPassword ? 'text' : 'password'}
|
|
maxLength={16}
|
|
name="password"
|
|
id="password"
|
|
placeholder={t('twofa.passwordPlaceholder')}
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
disabled={!!qr}
|
|
/>
|
|
<EyeToggle shown={showPassword} onToggle={() => setShowPassword((s) => !s)} />
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<input
|
|
type={showToken ? 'text' : 'password'}
|
|
maxLength={6}
|
|
name="security-token"
|
|
id="security-token"
|
|
placeholder={t('twofa.tokenPlaceholder')}
|
|
value={token}
|
|
onChange={(e) => setToken(e.target.value)}
|
|
disabled={!!qr}
|
|
/>
|
|
<EyeToggle shown={showToken} onToggle={() => setShowToken((s) => !s)} />
|
|
</td>
|
|
</tr>
|
|
{!qr && (
|
|
<tr>
|
|
<td>
|
|
<button type="submit" className="activate-2fa-button" disabled={busy}>
|
|
{busy ? t('twofa.activating') : t('twofa.activate')}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</form>
|
|
<hr />
|
|
{msg && (
|
|
<div className={`alert-message ${msg.success ? 'green-info' : 'red-info'}`} style={{ display: 'block' }}>
|
|
{msg.text}
|
|
</div>
|
|
)}
|
|
|
|
{qr && (
|
|
<div id="qr-code-container">
|
|
<p>{t('twofa.step1')}</p>
|
|
<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" target="_blank" rel="noopener noreferrer" title="Google Authenticator (Android)">
|
|
<img src={`${LOGOS}/google-play.png`} alt="Google Play" className="google-play-logo" />
|
|
</a>
|
|
<a href="https://apps.apple.com/app/google-authenticator/id388497605" target="_blank" rel="noopener noreferrer" title="Google Authenticator (iOS)">
|
|
<img src={`${LOGOS}/app-store.png`} alt="App Store" className="apple-store-logo" />
|
|
</a>
|
|
<br />
|
|
<br />
|
|
<p>{t('twofa.step2')}</p>
|
|
<img id="qr-code" src={qr.qrCodeUrl} alt="QR Code" />
|
|
<br />
|
|
<br />
|
|
<p>{t('twofa.copyKeyNote')}</p>
|
|
<div className="copy-secret-container">
|
|
<input type="text" id="secret-key" className="centered" value={qr.secretKey} readOnly />
|
|
<span
|
|
className={`fa ${copied ? 'fa-check' : 'fa-copy'} copy-btn second-brown`}
|
|
title={copied ? t('twofa.copied') : t('twofa.copyKey')}
|
|
onClick={copySecret}
|
|
role="button"
|
|
tabIndex={0}
|
|
></span>
|
|
</div>
|
|
<br />
|
|
<br />
|
|
<p>{t('twofa.step3')}</p>
|
|
<form noValidate onSubmit={verify} id="ns-2fa-verify-form" autoComplete="off">
|
|
<table className="middle-center-table">
|
|
<tbody>
|
|
<tr>
|
|
<td>
|
|
<input
|
|
type="text"
|
|
maxLength={6}
|
|
name="authenticator-code"
|
|
id="authenticator-code"
|
|
placeholder={t('twofa.codePlaceholder')}
|
|
required
|
|
className="centered"
|
|
inputMode="numeric"
|
|
pattern="\d{6}"
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value)}
|
|
disabled={done}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<button type="submit" className="verify-2fa-button" disabled={vBusy || done}>
|
|
{done ? t('twofa.verified') : vBusy ? t('twofa.verifying') : t('twofa.verifyCode')}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</form>
|
|
<hr />
|
|
{vMsg && (
|
|
<div className={`alert-message ${vMsg.success ? 'green-info' : 'red-info'}`} style={{ display: 'block' }}>
|
|
{vMsg.text}
|
|
</div>
|
|
)}
|
|
<br />
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------- Desactivar -------------------------------- */
|
|
function DisableForm() {
|
|
const t = useTranslations('UI')
|
|
const [code, setCode] = useState('')
|
|
const [busy, setBusy] = useState(false)
|
|
const [msg, setMsg] = useState<Msg>(null)
|
|
const [done, setDone] = useState(false)
|
|
|
|
async function disable(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (busy) return
|
|
setBusy(true)
|
|
setMsg(null)
|
|
try {
|
|
const d = await post('disable', { code })
|
|
setMsg({ success: !!d.success, text: d.message })
|
|
if (d.success) setDone(true)
|
|
} catch {
|
|
setMsg({ success: false, text: t('twofa.errGeneric') })
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<p className="green-info"><i className="fas fa-shield-alt"></i> {t('twofa.enabledNotice')}</p>
|
|
<p>{t('twofa.disableInstruction')}</p>
|
|
<br />
|
|
<form noValidate onSubmit={disable} id="ns-2fa-disable-form">
|
|
<table className="middle-center-table">
|
|
<tbody>
|
|
<tr>
|
|
<td>
|
|
<input
|
|
type="text"
|
|
maxLength={6}
|
|
name="authenticator-code"
|
|
placeholder={t('twofa.codePlaceholder')}
|
|
required
|
|
className="centered"
|
|
inputMode="numeric"
|
|
pattern="\d{6}"
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value)}
|
|
disabled={done}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<button type="submit" className="deactivate-2fa-button" disabled={busy || done}>
|
|
{done ? t('twofa.disabled') : busy ? t('twofa.disabling') : t('twofa.disable')}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</form>
|
|
<hr />
|
|
{msg && (
|
|
<div className={`alert-message ${msg.success ? 'green-info' : 'red-info'}`} style={{ display: 'block' }}>
|
|
{msg.text}
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|