From b9803adeb9000d53dd6cb078fc7f5f55c2a1f5dd Mon Sep 17 00:00:00 2001 From: adevopg Date: Tue, 14 Jul 2026 10:06:49 +0000 Subject: [PATCH] =?UTF-8?q?web-next:=20redise=C3=B1o=20de=20security-token?= =?UTF-8?q?,=20recover,=20vote-points=20+=20rename-guild?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /security-token: diseño completo del original (info + advertencia + NOTA 7 días), fecha en formato HH:MM:SS DD-MM-YYYY, botón "Token enviado", enlace a /recover. - /recover: 4 opciones (contraseña por usuario, nombre de cuenta, token de seguridad y enlace de activación por correo); recuperación de token nueva; Turnstile por envío. - /vote-points: caja de info con las 7 secciones Q&A + panel "Sitios de votación" con tarjetas inline-div (imagen, PV, último voto), botón refrescar; abre el sitio y acredita PV al volver. lib/vote.ts + getVoteSitesForAccount. - /rename-guild: renombra una hermandad de la que la cuenta es Maestro por 1000 PD + token de seguridad; SOAP .guild rename con comillas; reembolsa los PD si SOAP falla. Co-Authored-By: Claude Opus 4.8 (1M context) --- web-next/app/[locale]/recover/RecoverForm.tsx | 70 +++++++-- web-next/app/[locale]/recover/page.tsx | 21 ++- web-next/app/[locale]/rename-guild/page.tsx | 67 ++++++++ .../security-token/SecurityTokenForm.tsx | 58 ++++--- web-next/app/[locale]/security-token/page.tsx | 16 ++ web-next/app/[locale]/vote-points/page.tsx | 35 ++++- web-next/app/api/auth/recover/route.ts | 8 +- web-next/app/api/guild/rename/route.ts | 25 +++ web-next/components/RenameGuildForm.tsx | 148 ++++++++++++++++++ web-next/components/VotePanel.tsx | 106 +++++++++---- web-next/lib/guild.ts | 103 ++++++++++++ web-next/lib/recover.ts | 104 +++++++++--- web-next/lib/vote.ts | 23 +++ web-next/messages/en.json | 55 +++++-- web-next/messages/es.json | 55 +++++-- 15 files changed, 778 insertions(+), 116 deletions(-) create mode 100644 web-next/app/[locale]/rename-guild/page.tsx create mode 100644 web-next/app/api/guild/rename/route.ts create mode 100644 web-next/components/RenameGuildForm.tsx create mode 100644 web-next/lib/guild.ts diff --git a/web-next/app/[locale]/recover/RecoverForm.tsx b/web-next/app/[locale]/recover/RecoverForm.tsx index de696d3..ae929f5 100644 --- a/web-next/app/[locale]/recover/RecoverForm.tsx +++ b/web-next/app/[locale]/recover/RecoverForm.tsx @@ -4,32 +4,64 @@ import { useState } from 'react' import { useTranslations } from 'next-intl' import { Turnstile } from '@/components/Turnstile' -const ERROR_KEYS = ['invalidEmail', 'captchaFailed'] as const +const ERROR_KEYS = ['invalidEmail', 'invalidUsername', 'captchaFailed'] as const +type RecoverType = 'password' | 'accountname' | 'securitytoken' | 'activation' export function RecoverForm() { const t = useTranslations('Recover') - const [type, setType] = useState('password') - const [email, setEmail] = useState('') + const [type, setType] = useState('password') + const [value, setValue] = useState('') const [captcha, setCaptcha] = useState('') + const [captchaKey, setCaptchaKey] = useState(0) // remonta el widget para pedir un token nuevo const [busy, setBusy] = useState(false) + const [sent, setSent] = useState(false) const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null) + const usesUsername = type === 'password' + + function changeType(newType: RecoverType) { + setType(newType) + setValue('') + setMessage(null) + setSent(false) + setCaptcha('') + setCaptchaKey((k) => k + 1) + } + + function resetCaptcha() { + setCaptcha('') + setCaptchaKey((k) => k + 1) + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault() - if (busy) return + if (busy || sent) return setBusy(true) setMessage(null) try { const res = await fetch('/api/auth/recover', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ type, email: email.trim(), turnstileToken: captcha }), + body: JSON.stringify({ type, value: value.trim(), turnstileToken: captcha }), }) const data: { success?: boolean; error?: string } = await res.json() - if (data.success) setMessage({ ok: true, text: t('success') }) - else { + if (data.success) { + setMessage({ ok: true, text: t('success') }) + setSent(true) + // Como el original: tras 5 s se restaura el botón y se limpia el formulario. + setTimeout(() => { + setSent(false) + setValue('') + setMessage(null) + resetCaptcha() + }, 5000) + } else { const key = (ERROR_KEYS as readonly string[]).includes(data.error ?? '') ? data.error! : 'genericError' setMessage({ ok: false, text: t(key) }) + setTimeout(() => { + setMessage(null) + resetCaptcha() + }, 5000) } } catch { setMessage({ ok: false, text: t('genericError') }) @@ -40,32 +72,39 @@ export function RecoverForm() { return ( <> +

{t('prompt')}

+
@@ -73,11 +112,10 @@ export function RecoverForm() {
- changeType(e.target.value as RecoverType)}> +
- setEmail(e.target.value)} /> + {usesUsername ? ( + setValue(e.target.value)} /> + ) : ( + setValue(e.target.value)} /> + )}
- +
-

-
- {message && ( - {message.text} - )} +
+ {message && {message.text}}
+
) } diff --git a/web-next/app/[locale]/recover/page.tsx b/web-next/app/[locale]/recover/page.tsx index ab93741..65066bd 100644 --- a/web-next/app/[locale]/recover/page.tsx +++ b/web-next/app/[locale]/recover/page.tsx @@ -6,11 +6,30 @@ export default async function RecoverPage({ params }: { params: Promise<{ locale const { locale } = await params setRequestLocale(locale) const t = await getTranslations('Recover') + const sections = t.raw('infoSections') as { h: string; lines: string[] }[] + return (
-

{t('type')}

+

{t('infoTitle')}

+
+
+ {sections.map((s, i) => ( +
+ {s.h} + {s.lines.map((l, j) => ( +

{l}

+ ))} + {i < sections.length - 1 &&
} +
+ ))} +
+
+ +
+
+

{t('reqTitle')}

diff --git a/web-next/app/[locale]/rename-guild/page.tsx b/web-next/app/[locale]/rename-guild/page.tsx new file mode 100644 index 0000000..8dc6245 --- /dev/null +++ b/web-next/app/[locale]/rename-guild/page.tsx @@ -0,0 +1,67 @@ +import type { Metadata } from 'next' +import { setRequestLocale } from 'next-intl/server' +import { redirect } from '@/i18n/navigation' +import { getSession } from '@/lib/session' +import { getGuildMasterGuilds, GUILD_RENAME_PD } from '@/lib/guild' +import { PageShell } from '@/components/PageShell' +import { ServiceBox } from '@/components/ServiceBox' +import { RenameGuildForm } from '@/components/RenameGuildForm' + +export const dynamic = 'force-dynamic' + +export const metadata: Metadata = { title: 'Renombrar hermandad' } + +export default async function RenameGuildPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + setRequestLocale(locale) + + const session = await getSession() + if (!session.bnetId) redirect({ href: '/login', locale }) + if (!session.username) redirect({ href: '/select-account', locale }) + + const guilds = await getGuildMasterGuilds(session.accountId!) + + return ( + + +
+

+ La herramienta Renombrar hermandad te permite cambiar de nombre a una hermandad de la cual seas + Maestro de Hermandad. +

+
+

A la hora de escoger un nuevo nombre, ten en cuenta:

+

- La longitud máxima es de 24 caracteres.

+

- Los caracteres permitidos son A-Za-z y el espacio.

+
+

+ Al usar el botón RENOMBRAR HERMANDAD de la hermandad que hayas escogido, se cambiará su nombre al nuevo nombre + que hayas ingresado. +

+

+ El cambio de nombre de hermandad es instantáneo. Puede ser necesario que los miembros de la hermandad + conectados deban volver a conectar para finalmente ver el nuevo nombre. +

+
+
+ NOTA +
+

+ Por favor, asegúrate de haber elegido el nombre correcto ya que esta acción no es reversible una vez + realizada. +

+
+
+ +
+
+
+

Requiere {GUILD_RENAME_PD} PD

+
+
+ + +
+
+ ) +} diff --git a/web-next/app/[locale]/security-token/SecurityTokenForm.tsx b/web-next/app/[locale]/security-token/SecurityTokenForm.tsx index 486a76f..9a08903 100644 --- a/web-next/app/[locale]/security-token/SecurityTokenForm.tsx +++ b/web-next/app/[locale]/security-token/SecurityTokenForm.tsx @@ -1,19 +1,28 @@ 'use client' import { useState } from 'react' -import { useTranslations, useLocale } from 'next-intl' +import { useTranslations } from 'next-intl' +import { Link } from '@/i18n/navigation' const ERROR_KEYS = ['cooldown', 'noEmail'] as const +/** Formatea una fecha ISO como "HH:MM:SS DD-MM-YYYY" (igual que el diseño original). */ +function formatDate(iso: string): string { + const d = new Date(iso) + const p = (n: number) => String(n).padStart(2, '0') + return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())} ${p(d.getDate())}-${p(d.getMonth() + 1)}-${d.getFullYear()}` +} + export function SecurityTokenForm({ initialDate }: { initialDate: string | null }) { const t = useTranslations('SecurityToken') - const locale = useLocale() const [date, setDate] = useState(initialDate) const [busy, setBusy] = useState(false) + const [sent, setSent] = useState(false) const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null) - async function request() { - if (busy) return + async function request(e: React.FormEvent) { + e.preventDefault() + if (busy || sent) return setBusy(true) setMessage(null) try { @@ -21,36 +30,47 @@ export function SecurityTokenForm({ initialDate }: { initialDate: string | null const data: { success?: boolean; error?: string; tokenDate?: string } = await res.json() if (data.success) { setMessage({ ok: true, text: t('success') }) + setSent(true) if (data.tokenDate) setDate(data.tokenDate) } else { const key = (ERROR_KEYS as readonly string[]).includes(data.error ?? '') ? data.error! : 'genericError' setMessage({ ok: false, text: t(key) }) + // Como el original: el mensaje de error desaparece tras 5 s. + setTimeout(() => setMessage(null), 5000) } } catch { setMessage({ ok: false, text: t('genericError') }) + setTimeout(() => setMessage(null), 5000) } finally { setBusy(false) } } return ( - <> -

{t('info')}

+

-
-

- {t('lastRequest')}:{' '} - {date ? new Date(date).toLocaleString(locale) : t('never')} -

-
- -
-
- {message && {message.text}} -
+ +
+
+
+ {message && {message.text}}
- +
+

{t('forgot')}

+
+
) } diff --git a/web-next/app/[locale]/security-token/page.tsx b/web-next/app/[locale]/security-token/page.tsx index a955f43..70ec475 100644 --- a/web-next/app/[locale]/security-token/page.tsx +++ b/web-next/app/[locale]/security-token/page.tsx @@ -31,6 +31,22 @@ export default async function SecurityTokenPage({ params }: { params: Promise<{ return ( +
+

{t.rich('info1', { b: (c) => {c} })}

+

{t('info2')}

+
+

{t('info3')}

+

{t('info4')}

+
+

{t('warning')}

+
+
+ NOTA +
+

{t('note')}

+
+
+
diff --git a/web-next/app/[locale]/vote-points/page.tsx b/web-next/app/[locale]/vote-points/page.tsx index 2456d1b..81d8904 100644 --- a/web-next/app/[locale]/vote-points/page.tsx +++ b/web-next/app/[locale]/vote-points/page.tsx @@ -1,7 +1,9 @@ import { getTranslations, setRequestLocale } from 'next-intl/server' import { getSession } from '@/lib/session' -import { getVoteSites } from '@/lib/vote' +import { getRealmName } from '@/lib/realm' +import { getVoteSitesForAccount } from '@/lib/vote' import { PageShell } from '@/components/PageShell' +import { ServiceBox } from '@/components/ServiceBox' import { VotePanel } from '@/components/VotePanel' export const dynamic = 'force-dynamic' @@ -11,13 +13,36 @@ export default async function VotePointsPage({ params }: { params: Promise<{ loc setRequestLocale(locale) const t = await getTranslations('Vote') const session = await getSession() - const sites = await getVoteSites() + const [realm, sites] = await Promise.all([getRealmName(), getVoteSitesForAccount(session.accountId ?? 0)]) + const sub = (s: string) => s.replaceAll('{server}', realm) + const sections = t.raw('infoSections') as { h: string; lines: string[] }[] + return ( + +
+ {sections.map((s, i) => ( +
+ {sub(s.h)} + {s.lines.map((l, j) => ( +

{sub(l)}

+ ))} + {i < sections.length - 1 && ( + <> +
+
+
+ + )} +
+ ))} +
+
-
-

{t('info', { server: 'NovaWoW' })}

-
+
+

{t('sitesTitle')}

+
+
diff --git a/web-next/app/api/auth/recover/route.ts b/web-next/app/api/auth/recover/route.ts index 44ef7b9..005a69b 100644 --- a/web-next/app/api/auth/recover/route.ts +++ b/web-next/app/api/auth/recover/route.ts @@ -3,12 +3,14 @@ import { verifyTurnstile } from '@/lib/turnstile' export async function POST(request: Request) { let type = '' - let email = '' + let value = '' let turnstileToken = '' try { const body = await request.json() type = String(body.type ?? '') - email = String(body.email ?? '') + // `value` es el nombre de usuario (password) o el correo (resto). Se acepta + // `email` por compatibilidad. + value = String(body.value ?? body.email ?? '') turnstileToken = String(body.turnstileToken ?? '') } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) @@ -17,6 +19,6 @@ export async function POST(request: Request) { if (!(await verifyTurnstile(turnstileToken, ip))) { return Response.json({ success: false, error: 'captchaFailed' }) } - const result = await requestRecovery(type, email) + const result = await requestRecovery(type, value) return Response.json(result) } diff --git a/web-next/app/api/guild/rename/route.ts b/web-next/app/api/guild/rename/route.ts new file mode 100644 index 0000000..fd7e677 --- /dev/null +++ b/web-next/app/api/guild/rename/route.ts @@ -0,0 +1,25 @@ +import { getSession } from '@/lib/session' +import { renameGuild } from '@/lib/guild' + +/** Renombra una hermandad de la que la cuenta en sesión es Maestro (cuesta 1000 PD). */ +export async function POST(request: Request) { + const session = await getSession() + if (!session.accountId || !session.username) { + return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 }) + } + + let body: Record = {} + try { + body = await request.json() + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + + const result = await renameGuild({ + accountId: session.accountId, + guildId: Number(body.guildId), + newName: String(body.newName ?? ''), + token: String(body.token ?? '').trim(), + }) + return Response.json(result) +} diff --git a/web-next/components/RenameGuildForm.tsx b/web-next/components/RenameGuildForm.tsx new file mode 100644 index 0000000..8d30d3b --- /dev/null +++ b/web-next/components/RenameGuildForm.tsx @@ -0,0 +1,148 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from '@/i18n/navigation' +import type { GmGuild } from '@/lib/guild' + +function renameError(error?: string): string { + switch (error) { + case 'missingFields': + return 'Completa todos los campos.' + case 'invalidName': + return 'El nombre debe tener de 1 a 24 caracteres, solo letras (A-Z, a-z) y espacios.' + case 'invalidToken': + return 'El token de seguridad no es válido.' + case 'notGuildMaster': + return 'No eres Maestro de esa hermandad.' + case 'nameTaken': + return 'Ya existe una hermandad con ese nombre.' + case 'insufficientPoints': + return 'No tienes suficientes PD (se requieren 1000).' + case 'soapError': + return 'No se pudo aplicar el cambio (servidor de juego no disponible). Inténtalo más tarde.' + case 'notAuthenticated': + return 'Tu sesión ha expirado. Inicia sesión de nuevo.' + default: + return 'No se pudo renombrar la hermandad. Inténtalo de nuevo.' + } +} + +export function RenameGuildForm({ guilds }: { guilds: GmGuild[] }) { + const router = useRouter() + const [guildId, setGuildId] = useState('') + const [newName, setNewName] = useState('') + const [token, setToken] = useState('') + const [showToken, setShowToken] = useState(false) + const [busy, setBusy] = useState(false) + const [done, setDone] = useState(false) + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null) + + if (guilds.length === 0) { + return ( +
+
+ No hay personajes Maestros de hermandad +
+
+ ) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (busy || done) return + if (!guildId || !newName.trim() || !token.trim()) { + setMessage({ ok: false, text: renameError('missingFields') }) + return + } + if (!window.confirm('¿Estás seguro de renombrar la hermandad seleccionada?')) return + + setBusy(true) + setMessage(null) + try { + const res = await fetch('/api/guild/rename', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ guildId: Number(guildId), newName: newName.trim(), token: token.trim() }), + }) + const data: { success?: boolean; error?: string; newName?: string } = await res.json() + if (data.success) { + setDone(true) + setMessage({ ok: true, text: `¡Hermandad renombrada a "${data.newName}"! Puede que los miembros conectados deban reconectar para verlo.` }) + setNewName('') + setToken('') + setTimeout(() => router.refresh(), 3000) + } else { + setMessage({ ok: false, text: renameError(data.error) }) + } + } catch { + setMessage({ ok: false, text: renameError() }) + } finally { + setBusy(false) + } + } + + return ( +
+
+ + + + + + + + + + + + + + + +
+ +
+ setNewName(e.target.value)} + required + /> +
+ setToken(e.target.value)} + required + />{' '} + setShowToken((s) => !s)} + /> +
+ +
+
+
+
+ {message && {message.text}} +
+
+ ) +} diff --git a/web-next/components/VotePanel.tsx b/web-next/components/VotePanel.tsx index 28484f2..37804dd 100644 --- a/web-next/components/VotePanel.tsx +++ b/web-next/components/VotePanel.tsx @@ -1,16 +1,23 @@ 'use client' import { useState } from 'react' -import { useTranslations } from 'next-intl' -import type { VoteSite } from '@/lib/vote' +import { useTranslations, useLocale } from 'next-intl' +import { useRouter } from '@/i18n/navigation' +import type { VoteSiteStatus } from '@/lib/vote' -export function VotePanel({ sites, canVote }: { sites: VoteSite[]; canVote: boolean }) { +const REFRESH_ICON = '/nw-themes/nw-ryu/nw-images/nw-icons/refresh.webp' + +export function VotePanel({ sites, canVote }: { sites: VoteSiteStatus[]; canVote: boolean }) { const t = useTranslations('Vote') + const locale = useLocale() + const router = useRouter() const [busyId, setBusyId] = useState(null) + const [voted, setVoted] = useState>(new Set()) const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null) - async function vote(site: VoteSite) { - if (busyId !== null) return + async function vote(site: VoteSiteStatus) { + if (busyId !== null || voted.has(site.id)) return + // Abre el sitio de votos en otra pestaña (como el original) y acredita al volver. window.open(site.url, '_blank', 'noopener,noreferrer') setBusyId(site.id) setMessage(null) @@ -24,14 +31,18 @@ export function VotePanel({ sites, canVote }: { sites: VoteSite[]; canVote: bool const d: { success?: boolean; error?: string; points?: number; siteName?: string; hours?: number; minutes?: number } = await res.json() if (d.success) { + setVoted((v) => new Set(v).add(site.id)) setMessage({ ok: true, text: t('success', { site: d.siteName ?? site.name, points: d.points ?? 0 }) }) } else if (d.error === 'cooldown') { setMessage({ ok: false, text: t('cooldown', { hours: d.hours ?? 0, minutes: d.minutes ?? 0 }) }) + setTimeout(() => setMessage(null), 5000) } else { setMessage({ ok: false, text: t(d.error === 'siteNotFound' ? 'siteNotFound' : 'genericError') }) + setTimeout(() => setMessage(null), 5000) } } catch { setMessage({ ok: false, text: t('genericError') }) + setTimeout(() => setMessage(null), 5000) } finally { setBusyId(null) } @@ -40,33 +51,70 @@ export function VotePanel({ sites, canVote }: { sites: VoteSite[]; canVote: bool if (sites.length === 0) return

{t('noSites')}

return ( -
- {sites.map((s) => ( -
- {s.image_url && ( - // eslint-disable-next-line @next/next/no-img-element - {s.name} - )} -

{s.name}

-

- {s.points} {t('pv')} -

- {canVote ? ( - - ) : ( - - {t('vote')} - - )} -
- ))} + <> + +

{t('note')}

+
+ + {sites.map((s) => { + const isVoted = voted.has(s.id) + return ( +
+ + + + + + + + + + + + + + + + +
+ {s.image_url && ( + // eslint-disable-next-line @next/next/no-img-element + {s.name} + )} +
{s.name}

{t('pv')}: {s.points}
+ {s.lastVoteAt ? t('lastVote', { date: new Date(s.lastVoteAt).toLocaleString(locale) }) : t('neverVoted')} +

+ {canVote ? ( + + ) : ( + + {t('vote')} + + )} +
+
+ ) + })} +
-
+
{message && {message.text}}
{!canVote &&

{t('loginToVote')}

} -
+
+ ) } diff --git a/web-next/lib/guild.ts b/web-next/lib/guild.ts new file mode 100644 index 0000000..aa5e6fa --- /dev/null +++ b/web-next/lib/guild.ts @@ -0,0 +1,103 @@ +import type { RowDataPacket, ResultSetHeader } from 'mysql2' +import { db, DB } from './db' +import { checkSecurityToken } from './security-token' +import { executeSoapCommand } from './soap' + +/** + * Renombrar hermandad: el usuario elige una hermandad de la que sea Maestro + * (guild_member.rank = 0) y le pone un nombre nuevo. Cuesta 1000 PD y requiere + * el token de seguridad. El renombrado se aplica por SOAP (.guild rename). + */ + +export const GUILD_RENAME_PD = 1000 +/** Nombre de hermandad: A-Za-z y espacio, máx. 24 (regla del juego / diseño). */ +const NAME_RE = /^[A-Za-z ]{1,24}$/ + +export interface GmGuild { + guildid: number + name: string +} + +/** Hermandades de las que la cuenta tiene un personaje Maestro (rank 0). */ +export async function getGuildMasterGuilds(accountId: number): Promise { + if (!accountId) return [] + try { + const [rows] = await db(DB.characters).query( + `SELECT g.guildid, g.name + FROM guild g + JOIN guild_member gm ON g.guildid = gm.guildid + JOIN characters c ON c.guid = gm.guid + WHERE c.account = ? AND gm.rank = 0 + ORDER BY g.name`, + [accountId], + ) + return rows.map((r) => ({ guildid: Number(r.guildid), name: r.name })) + } catch { + return [] + } +} + +export interface Result { + success: boolean + error?: string + cost?: number + newName?: string +} + +export async function renameGuild(params: { + accountId: number + guildId: number + newName: string + token: string +}): Promise { + const { accountId, guildId, token } = params + const newName = (params.newName || '').trim() + + if (!accountId || !guildId || !newName || !token) return { success: false, error: 'missingFields' } + if (!NAME_RE.test(newName)) return { success: false, error: 'invalidName' } + if (!(await checkSecurityToken(accountId, token))) return { success: false, error: 'invalidToken' } + + // La hermandad debe ser una de las que la cuenta dirige (Maestro). + const guilds = await getGuildMasterGuilds(accountId) + const guild = guilds.find((g) => g.guildid === guildId) + if (!guild) return { success: false, error: 'notGuildMaster' } + const oldName = guild.name + if (/["\r\n]/.test(oldName)) return { success: false, error: 'genericError' } // nombre antiguo no seguro para SOAP + + // El nuevo nombre no puede coincidir con otra hermandad existente. + try { + const [dup] = await db(DB.characters).query( + 'SELECT guildid FROM guild WHERE name = ? AND guildid <> ? LIMIT 1', + [newName, guildId], + ) + if (dup[0]) return { success: false, error: 'nameTaken' } + } catch { + return { success: false, error: 'genericError' } + } + + // Cobra 1000 PD de forma atómica (condicional sobre el saldo). + let charged = false + try { + const [res] = await db(DB.default).query( + 'UPDATE home_api_points SET dp = dp - ? WHERE accountID = ? AND dp >= ?', + [GUILD_RENAME_PD, accountId, GUILD_RENAME_PD], + ) + charged = res.affectedRows === 1 + } catch { + return { success: false, error: 'genericError' } + } + if (!charged) return { success: false, error: 'insufficientPoints' } + + // Aplica el renombrado en el servidor por SOAP (convención del proyecto: + // respuesta no nula = aplicado, igual que soapFulfill en paid-services). Si el + // worldserver está caído, devuelve los PD cobrados. + const soap = await executeSoapCommand(`.guild rename "${oldName}" "${newName}"`) + if (soap === null) { + await db(DB.default) + .query('UPDATE home_api_points SET dp = dp + ? WHERE accountID = ?', [GUILD_RENAME_PD, accountId]) + .catch(() => {}) + return { success: false, error: 'soapError' } + } + + return { success: true, cost: GUILD_RENAME_PD, newName } +} diff --git a/web-next/lib/recover.ts b/web-next/lib/recover.ts index 03af418..6831330 100644 --- a/web-next/lib/recover.ts +++ b/web-next/lib/recover.ts @@ -19,35 +19,48 @@ function emailShell(title: string, bodyHtml: string): string {
` } -/** Recuperación por email (anti-enumeración: respuesta genérica siempre). */ -export async function requestRecovery(type: string, email: string): Promise { - email = (email || '').trim() - if (!email || !GMAIL_RE.test(email)) return { success: false, error: 'invalidEmail' } +/** + * Recuperación por email/usuario (anti-enumeración: respuesta genérica siempre). + * `value` es el nombre de usuario (type=password) o el correo (resto). + */ +export async function requestRecovery(type: string, value: string): Promise { + value = (value || '').trim() const site = process.env.SITE_URL || '' + // La contraseña se recupera por NOMBRE DE USUARIO (p.ej. 15#1): se resuelve el + // correo Battle.net asociado y se envía el enlace de restablecimiento. if (type === 'password') { + if (!value || value.length > 20) return { success: false, error: 'invalidUsername' } try { - const [rows] = await db(DB.auth).query( - 'SELECT id FROM battlenet_accounts WHERE email = ?', - [normalizeEmail(email)], + const [acc] = await db(DB.auth).query( + 'SELECT battlenet_account FROM account WHERE username = ? LIMIT 1', + [value], ) - if (rows[0]) { - const token = crypto.randomBytes(32).toString('hex') - await db(DB.default).query( - 'INSERT INTO home_passwordreset (email, token, created_at, used) VALUES (?, ?, NOW(), 0)', - [email, token], - ) - const link = `${site}/reset-password?token=${token}` - await sendMail( - email, - 'Restablecer contraseña - Nova WoW', - emailShell( - 'Restablecer contraseña', - `

Pulsa para elegir una nueva contraseña (válido 1 hora):

-

Restablecer

-

${link}

`, - ), + const bnetId = acc[0]?.battlenet_account + if (bnetId) { + const [bn] = await db(DB.auth).query( + 'SELECT email FROM battlenet_accounts WHERE id = ?', + [bnetId], ) + const email = bn[0]?.email + if (email) { + const token = crypto.randomBytes(32).toString('hex') + await db(DB.default).query( + 'INSERT INTO home_passwordreset (email, token, created_at, used) VALUES (?, ?, NOW(), 0)', + [email, token], + ) + const link = `${site}/reset-password?token=${token}` + await sendMail( + email, + 'Restablecer contraseña - Nova WoW', + emailShell( + 'Restablecer contraseña', + `

Pulsa para elegir una nueva contraseña (válido 1 hora):

+

Restablecer

+

${link}

`, + ), + ) + } } } catch { /* acore no disponible: respuesta genérica igualmente */ @@ -55,6 +68,51 @@ export async function requestRecovery(type: string, email: string): Promise( + 'SELECT id FROM battlenet_accounts WHERE email = ?', + [normalizeEmail(email)], + ) + if (bn[0]) { + const [games] = await db(DB.auth).query( + 'SELECT id, username FROM account WHERE battlenet_account = ? ORDER BY battlenet_index', + [bn[0].id], + ) + const ids = games.map((g) => g.id) + if (ids.length) { + const placeholders = ids.map(() => '?').join(',') + const [toks] = await db(DB.default).query( + `SELECT user_id, token FROM home_securitytoken WHERE user_id IN (${placeholders})`, + ids, + ) + if (toks.length) { + const nameById = new Map(games.map((g) => [g.id, g.username])) + const list = toks + .map((r) => `
  • ${nameById.get(r.user_id) ?? r.user_id}: ${r.token}
  • `) + .join('') + await sendMail( + email, + 'Tu Token de seguridad - Nova WoW', + emailShell( + 'Tu Token de seguridad', + `

    Token(s) de seguridad de las cuentas asociadas a ${email}:

      ${list}
    +

    Consérvalo en un lugar seguro y no lo compartas.

    `, + ), + ) + } + } + } + } catch { + /* ignore */ + } + return { success: true } + } + if (type === 'accountname') { try { const [acc] = await db(DB.auth).query( diff --git a/web-next/lib/vote.ts b/web-next/lib/vote.ts index 4957fde..f0f89dc 100644 --- a/web-next/lib/vote.ts +++ b/web-next/lib/vote.ts @@ -31,6 +31,29 @@ export async function getVoteSites(): Promise { } } +export interface VoteSiteStatus extends VoteSite { + lastVoteAt: string | null // ISO del último voto de la cuenta en este sitio, o null +} + +/** Sitios de votación con la fecha del último voto de la cuenta (para el recuadro). */ +export async function getVoteSitesForAccount(accountId: number): Promise { + const sites = await getVoteSites() + if (!accountId) return sites.map((s) => ({ ...s, lastVoteAt: null })) + try { + const [rows] = await db(DB.default).query( + 'SELECT vote_site_id, MAX(created_at) AS last FROM home_votelog WHERE account_id = ? GROUP BY vote_site_id', + [accountId], + ) + const map = new Map(rows.map((r) => [Number(r.vote_site_id), r.last as Date])) + return sites.map((s) => { + const last = map.get(s.id) + return { ...s, lastVoteAt: last ? new Date(last).toISOString() : null } + }) + } catch { + return sites.map((s) => ({ ...s, lastVoteAt: null })) + } +} + export async function registerVote(accountId: number, url: string): Promise { const [siteRows] = await db(DB.default).query( 'SELECT id, name, points FROM home_votesite WHERE url = ?', diff --git a/web-next/messages/en.json b/web-next/messages/en.json index 9535ee0..e4ddfdc 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -198,18 +198,31 @@ "title": "Account activation" }, "Recover": { - "title": "Recover account", + "title": "Recover account information", + "infoTitle": "Information", + "reqTitle": "Information request", + "prompt": "Choose the option matching the information you want to recover", "type": "What do you want to recover?", "typePassword": "Password", "typeAccountName": "Account name", + "typeSecurityToken": "Security token", "typeActivation": "Activation link", - "email": "Gmail email address", + "username": "Username", + "email": "Email address", "submit": "Request", - "sending": "Sending…", - "success": "If an account exists with that email, we have sent you the information.", + "sending": "Requesting data", + "sent": "Data sent", + "success": "If an account exists with those details, we have sent you the information.", "invalidEmail": "Enter a valid Gmail address.", + "invalidUsername": "Enter a valid username.", "genericError": "Something went wrong. Please try again later.", - "captchaFailed": "Anti-bot verification failed. Please retry." + "captchaFailed": "Anti-bot verification failed. Please retry.", + "infoSections": [ + { "h": "If you forgot the account password:", "lines": ["- Select the 'Password' option.", "- Enter your Username.", "- An email will be sent with a link to generate a new password."] }, + { "h": "If you forgot the account name:", "lines": ["- Select the 'Account name' option.", "- Enter your email.", "- An email will be sent with all accounts linked to that email."] }, + { "h": "If you forgot the Security token:", "lines": ["- Select the 'Security token' option.", "- Enter your email.", "- An email will be sent with the Security token."] }, + { "h": "If you didn't receive the activation link:", "lines": ["- Select the 'Activation link' option.", "- Enter your email.", "- An email will be sent with the activation link."] } + ] }, "Reset": { "title": "Reset password", @@ -289,11 +302,18 @@ }, "SecurityToken": { "title": "Security token", - "info": "The security token is a 6-character (case-sensitive) code required for important account actions. You can request a new one every 7 days.", - "lastRequest": "Request date", + "info1": "The Security token is a 6-digit (case-sensitive) code that adds security to certain options in your account panel on the website.", + "info2": "It will be requested for important account actions.", + "info3": "To get one, request it by clicking the button below.", + "info4": "When you click, a message will be sent to the account's email containing the Security Token.", + "warning": "It is important that you keep the Security token in a safe place and do not share it with other users.", + "note": "You can request a new Security token once every 7 days.", + "requestDateLabel": "Security token request date:", "never": "Not requested", "request": "Request token", - "requesting": "Requesting…", + "requesting": "Requesting token", + "sent": "Token sent", + "forgot": "I forgot the Security token", "success": "The security token has been sent to your email.", "cooldown": "You can only request a token every 7 days.", "noEmail": "Add an email to your account before requesting a token.", @@ -501,15 +521,30 @@ "Vote": { "title": "Vote for us", "info": "Vote for {server} on the sites below and earn VP. You can vote on each site every 12 hours.", + "sitesTitle": "Vote sites", + "note": "Note: some sites may take a few minutes to credit the vote.", + "refresh": "Refresh", "vote": "Vote", - "voting": "Voting…", + "voting": "Voting", + "voted": "Voted", "pv": "VP", + "lastVote": "Last vote: {date}", + "neverVoted": "You haven't voted here yet.", "success": "Thanks for voting on {site}! {points} VP credited.", "cooldown": "You must wait {hours}h {minutes}m to vote again on this site.", "siteNotFound": "Vote site not found.", "genericError": "Something went wrong. Please try again later.", "noSites": "No vote sites configured yet.", - "loginToVote": "Sign in to vote." + "loginToVote": "Sign in to vote.", + "infoSections": [ + { "h": "What is voting?", "lines": ["Voting is the act of giving the server a vote on a website that offers top lists.", "As a reward for voting for {server}, you receive VP."] }, + { "h": "What are VP?", "lines": ["We give our users the chance to earn levels with exclusive benefits in our online community. These benefits include access to special tools, exclusive content and significant improvements to the website experience. When voting, users receive VP, an internal recognition unit reflecting their commitment and progress on our platform. VP let you reach new levels and unlock exclusive rewards. VP are a unique way to value and reward the active participation of our users in the community."] }, + { "h": "How can I vote?", "lines": ["To vote for {server}, click the buttons below; each one redirects to the corresponding top-list website.", "Once there, give the server a vote and you'll receive your VP."] }, + { "h": "How many VP do I get per vote?", "lines": ["In the list below, each site shows how many VP it grants for a successful vote."] }, + { "h": "How often can I vote?", "lines": ["You can only vote on each site every 12 hours.", "The box shows the last time you voted on each site."] }, + { "h": "Why doesn't my vote count on the site?", "lines": ["We use a postback system, so each vote site sends us a response whenever a user votes for our server.", "Once the response is analyzed on our site, the corresponding VP are added as long as the user voted correctly.", "Each vote site also has its own restrictions, such as only allowing one vote per IP, so a vote may not count if several accounts vote from the same IP on the same day."] }, + { "h": "Questions or problems voting?", "lines": ["If after reading this guide you still have questions, the {server} team can assist you at any time. You can open a ticket asking for help to vote."] } + ] }, "Common": { "prev": "Previous", diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 36663c2..3954818 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -198,18 +198,31 @@ "title": "Activación de cuenta" }, "Recover": { - "title": "Recuperar cuenta", + "title": "Recuperar información de cuenta", + "infoTitle": "Información", + "reqTitle": "Solicitud de información", + "prompt": "Escoge la opción acorde a la información que quieres recuperar", "type": "¿Qué quieres recuperar?", "typePassword": "Contraseña", "typeAccountName": "Nombre de cuenta", + "typeSecurityToken": "Token de seguridad", "typeActivation": "Enlace de activación", - "email": "Correo electrónico de Gmail", + "username": "Nombre de usuario", + "email": "Correo electrónico", "submit": "Solicitar", - "sending": "Enviando…", - "success": "Si existe una cuenta con ese correo, te hemos enviado la información.", + "sending": "Solicitando datos", + "sent": "Datos enviados", + "success": "Si existe una cuenta con esos datos, te hemos enviado la información.", "invalidEmail": "Introduce un correo de Gmail válido.", + "invalidUsername": "Introduce un nombre de usuario válido.", "genericError": "Algo ha salido mal. Inténtalo más tarde.", - "captchaFailed": "Verificación anti-bots fallida. Reintenta." + "captchaFailed": "Verificación anti-bots fallida. Reintenta.", + "infoSections": [ + { "h": "Si has olvidado la contraseña de la cuenta:", "lines": ["- Selecciona la opción 'Contraseña'.", "- Escribe Nombre de usuario.", "- Un correo será enviado con un enlace para generar una contraseña nueva."] }, + { "h": "Si has olvidado el nombre de la cuenta:", "lines": ["- Selecciona la opción 'Nombre de cuenta'.", "- Escribe el correo electrónico.", "- Un correo será enviado con todas las cuentas ligadas al correo."] }, + { "h": "Si has olvidado el Token de seguridad:", "lines": ["- Selecciona la opción 'Token de seguridad'.", "- Escribe el correo electrónico.", "- Un correo será enviado con el Token de seguridad."] }, + { "h": "Si no has recibido el enlace de activación:", "lines": ["- Selecciona la opción 'Enlace de activación'.", "- Escribe el correo electrónico.", "- Un correo será enviado con el enlace de activación."] } + ] }, "Reset": { "title": "Restablecer contraseña", @@ -289,11 +302,18 @@ }, "SecurityToken": { "title": "Token de seguridad", - "info": "El token de seguridad es un código de 6 caracteres (sensible a mayúsculas) que se solicita para acciones importantes de tu cuenta. Puedes solicitar uno nuevo cada 7 días.", - "lastRequest": "Fecha de solicitud", + "info1": "El Token de seguridad es un código de 6 dígitos (sensible a mayúsculas y minúsculas), que añade seguridad a ciertas opciones en tu panel de cuenta en la página web.", + "info2": "El mismo será solicitado para acciones importantes de la cuenta.", + "info3": "Para obtenerlo, es necesario solicitarlo haciendo click en el botón debajo.", + "info4": "Al hacer click, un mensaje será enviado al correo de la cuenta conteniendo el Token de Seguridad.", + "warning": "Es importante que conserves el Token de seguridad en un lugar seguro y no lo compartas con otros usuarios.", + "note": "Puedes solicitar un nuevo Token de seguridad una vez cada 7 días.", + "requestDateLabel": "Fecha de solicitud de Token de seguridad:", "never": "Sin solicitar", "request": "Solicitar token", - "requesting": "Solicitando…", + "requesting": "Solicitando token", + "sent": "Token enviado", + "forgot": "He olvidado el Token de seguridad", "success": "Se ha enviado el token de seguridad a tu correo.", "cooldown": "Solo puedes solicitar un token cada 7 días.", "noEmail": "Añade un correo a tu cuenta antes de solicitar un token.", @@ -501,15 +521,30 @@ "Vote": { "title": "Votar por nosotros", "info": "Vota por {server} en las siguientes páginas y recibe PV. Puedes votar en cada sitio cada 12 horas.", + "sitesTitle": "Sitios de votación", + "note": "Nota: algunos sitios pueden demorar unos minutos en acreditar el voto.", + "refresh": "Refrescar", "vote": "Votar", - "voting": "Votando…", + "voting": "Votando", + "voted": "Votado", "pv": "PV", + "lastVote": "Último voto: {date}", + "neverVoted": "Aún no has votado aquí.", "success": "¡Gracias por votar en {site}! Se han acreditado {points} PV.", "cooldown": "Debes esperar {hours}h {minutes}m para volver a votar en este sitio.", "siteNotFound": "Sitio de votación no encontrado.", "genericError": "Algo ha salido mal. Inténtalo más tarde.", "noSites": "No hay sitios de votación configurados todavía.", - "loginToVote": "Inicia sesión para votar." + "loginToVote": "Inicia sesión para votar.", + "infoSections": [ + { "h": "¿Qué son las votaciones?", "lines": ["Una votación es la acción de darle un voto al servidor en una página web que ofrezca listas de tops.", "Como recompensa a realizar una votación por {server}, se otorgan PV."] }, + { "h": "¿Qué son los PV?", "lines": ["Brindamos a nuestros usuarios la oportunidad de obtener niveles con beneficios exclusivos en nuestra comunidad en línea. Estos beneficios incluyen acceso a herramientas especiales, contenido exclusivo y mejoras significativas en la experiencia de nuestro sitio web. Al realizar una votación, los usuarios reciben PV, una unidad de reconocimiento interna que refleja su compromiso y progreso en nuestra plataforma. Los PV permiten alcanzar nuevos niveles y desbloquear recompensas exclusivas. Destacamos que los PV son una forma única de valorar y premiar la participación activa de nuestros usuarios en la comunidad."] }, + { "h": "¿Cómo se puede votar?", "lines": ["Para votar por {server}, se debe hacer click en los botones que aparecen debajo, y cada uno de ellos redirigirá a la página web del top correspondiente.", "Una vez allí, deberás darle un voto al servidor y así recibirás tus PV."] }, + { "h": "¿Cuántos PV se reciben por cada voto?", "lines": ["En la lista inferior, cada sitio indica cuántos PV otorgará si se realiza una votación exitosa."] }, + { "h": "¿Cada cuánto se puede votar?", "lines": ["Sólo se puede votar por cada sitio cada 12 horas.", "El recuadro informa sobre la última vez que has votado en cada sitio."] }, + { "h": "¿Por qué el voto no cuenta en la web?", "lines": ["Usamos el sistema de postback por lo que cada sitio web de votos nos envía una respuesta cada vez que un usuario vota por nuestro servidor.", "Una vez que la respuesta es analizada en nuestra web, se sumarán los PV correspondientes siempre que el usuario haya votado correctamente.", "Cada sitio de votación tiene, además, sus propias restricciones como sólo permitir un voto por IP por lo cual puede suceder que el voto no cuente si se usan varias cuentas para votar en el mismo día."] }, + { "h": "¿Tienes dudas o problemas para votar?", "lines": ["Si a pesar de leer este instructivo aún tienes dudas, el equipo de {server} podrá asesorarte en cualquier momento. Puedes colocar un ticket solicitando ayuda para votar."] } + ] }, "Common": { "prev": "Anterior",