diff --git a/web-next/app/[locale]/transfer-character/page.tsx b/web-next/app/[locale]/transfer-character/page.tsx new file mode 100644 index 0000000..8a6fec7 --- /dev/null +++ b/web-next/app/[locale]/transfer-character/page.tsx @@ -0,0 +1,69 @@ +import type { Metadata } from 'next' +import { setRequestLocale } from 'next-intl/server' +import { redirect, Link } from '@/i18n/navigation' +import { getSession } from '@/lib/session' +import { getGameCharacters } from '@/lib/characters' +import { getTransferPrice } from '@/lib/prices' +import { PageShell } from '@/components/PageShell' +import { ServiceBox } from '@/components/ServiceBox' +import { TransferForm } from '@/components/TransferForm' + +export const dynamic = 'force-dynamic' + +export const metadata: Metadata = { title: 'Transferir personaje' } + +export default async function TransferCharacterPage({ 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 [chars, price] = await Promise.all([getGameCharacters(session.accountId!), getTransferPrice()]) + + return ( + + +
+

+ La herramienta Transferir personaje te permite mover un personaje a otra cuenta. +

+
+

Para personajes Caballeros de la Muerte:

+

- La cuenta destino debe tener aunque sea un personaje con nivel 55 o superior.

+

- La cuenta destino no puede tener un personaje Caballero de la Muerte.

+
+

Tu cuenta será bloqueada por 5s como medida de seguridad si el trámite es exitoso.

+
+

+ Al usar el botón TRANSFERIR PERSONAJE del personaje que hayas escogido, se transferirá desde tu cuenta a la + cuenta que hayas indicado. +

+

Cuando ingreses a la lista de personajes de la cuenta destino, verás al personaje transferido.

+
+

Si aún no tienes Token de seguridad, puedes solicitarlo aquí.

+

Si aún no tienes 2FA, puedes solicitarlo aquí.

+
+
+ NOTA +
+

Se requiere que el personaje esté desconectado.

+

+ Por favor, asegúrate de haber escogido al personaje correcto ya que esta acción no es reversible una vez + realizada. +

+
+
+ +
+
+
+

Requiere {price} (SumUp)

+
+ + ({ name: c.name, classCss: c.classCss }))} price={price} /> +
+
+ ) +} diff --git a/web-next/app/[locale]/transfer/page.tsx b/web-next/app/[locale]/transfer/page.tsx deleted file mode 100644 index b4883f3..0000000 --- a/web-next/app/[locale]/transfer/page.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { getTranslations, setRequestLocale } from 'next-intl/server' -import { redirect } from '@/i18n/navigation' -import { getSession } from '@/lib/session' -import { getGameCharacters } from '@/lib/characters' -import { getTransferPrice } from '@/lib/prices' -import { PageShell } from '@/components/PageShell' -import { ServiceBox } from '@/components/ServiceBox' -import { TransferForm } from '@/components/TransferForm' - -export const dynamic = 'force-dynamic' - -export default async function TransferPage({ params }: { params: Promise<{ locale: string }> }) { - const { locale } = await params - setRequestLocale(locale) - const t = await getTranslations('Paid') - const session = await getSession() - if (!session.bnetId) redirect({ href: '/login', locale }) - if (!session.username) redirect({ href: '/select-account', locale }) - - const [chars, price] = await Promise.all([getGameCharacters(session.accountId!), getTransferPrice()]) - - return ( - - - ({ name: c.name, classCss: c.classCss }))} price={price} /> - - - ) -} diff --git a/web-next/app/api/character/[service]/checkout/route.ts b/web-next/app/api/character/[service]/checkout/route.ts index 73473d9..ffa393f 100644 --- a/web-next/app/api/character/[service]/checkout/route.ts +++ b/web-next/app/api/character/[service]/checkout/route.ts @@ -27,9 +27,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ ser return Response.json({ success: false, error: 'invalidCharacter' }) } - // Comprobación previa al pago (condiciones del servicio, p.ej. cambio de facción). + // Comprobación previa al pago (condiciones del servicio, p.ej. cambio de facción / transferencia). if (cfg.precheck) { - const pc = await cfg.precheck(session.accountId, character) + const pc = await cfg.precheck({ accountId: session.accountId, email: session.bnetEmail || '', character, body }) if (!pc.ok) return Response.json({ success: false, error: 'ineligible', message: pc.message }) } diff --git a/web-next/components/AccountTools.tsx b/web-next/components/AccountTools.tsx index e704978..9c6b3ea 100644 --- a/web-next/components/AccountTools.tsx +++ b/web-next/components/AccountTools.tsx @@ -38,7 +38,7 @@ export function AccountTools({ isAdmin }: { isAdmin: boolean }) { { href: '/level-up-character', icon: 'level-up-icon', label: t('svcLevelUp'), desc: t('svcLevelUpDesc') }, { href: '/gold-character', icon: 'gold-icon', label: t('svcGold'), desc: t('svcGoldDesc') }, { href: '/quest-character', icon: 'quest-icon', label: t('svcQuest'), desc: t('svcQuestDesc') }, - { href: '/transfer', icon: 'tranfer-char-icon', label: t('svcTransfer'), desc: t('svcTransferDesc') }, + { href: '/transfer-character', icon: 'tranfer-char-icon', label: t('svcTransfer'), desc: t('svcTransferDesc') }, { href: '/restore-character', icon: 'restore-icon', label: t('svcRestoreChar'), desc: t('svcRestoreCharDesc') }, { href: '/restore-items', icon: 'prox-icon', label: t('svcRestoreItems'), desc: t('svcRestoreItemsDesc') }, { href: '/store', icon: 'store-icon', label: t('svcStoreItems'), desc: t('svcStoreItemsDesc') }, diff --git a/web-next/components/TransferForm.tsx b/web-next/components/TransferForm.tsx index d2e06bb..4a0636d 100644 --- a/web-next/components/TransferForm.tsx +++ b/web-next/components/TransferForm.tsx @@ -1,20 +1,58 @@ 'use client' import { useState } from 'react' -import { useTranslations } from 'next-intl' import { CharacterSelect, type CharOption } from '@/components/CharacterSelect' +/** Campo con ojo para mostrar/ocultar (contraseña / token). */ +function SecretInput({ + id, + value, + onChange, + placeholder, + maxLength, + toggleClass, +}: { + id: string + value: string + onChange: (v: string) => void + placeholder: string + maxLength: number + toggleClass: string +}) { + const [show, setShow] = useState(false) + return ( + <> + onChange(e.target.value)} + placeholder={placeholder} + required + />{' '} + setShow((s) => !s)} + /> + + ) +} + export function TransferForm({ characters, price }: { characters: CharOption[]; price: number }) { - const t = useTranslations('Services') - const tp = useTranslations('Paid') const [character, setCharacter] = useState('') const [destination, setDestination] = useState('') + const [password, setPassword] = useState('') + const [token, setToken] = useState('') const [busy, setBusy] = useState(false) const [error, setError] = useState(null) async function handleSubmit(e: React.FormEvent) { e.preventDefault() - if (busy || !character || !destination.trim()) return + if (busy || !character || !destination.trim() || !password || !token.trim()) return + if (!window.confirm(`¿Estás seguro de transferir el personaje "${character}" a la cuenta ${destination.trim()} por ${price} € (SumUp)?`)) return setBusy(true) setError(null) try { @@ -22,35 +60,45 @@ export function TransferForm({ characters, price }: { characters: CharOption[]; method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', - body: JSON.stringify({ character, destination_account: destination.trim() }), + body: JSON.stringify({ + character, + destination_account: destination.trim(), + password, + security_token: token.trim(), + provider: 'sumup', + }), }) - const data: { success?: boolean; url?: string } = await res.json() + const data: { success?: boolean; url?: string; message?: string } = await res.json() if (data.success && data.url) window.location.href = data.url else { - setError(t('genericError')) + setError(data.message || 'No se pudo iniciar la transferencia. Inténtalo de nuevo.') setBusy(false) } } catch { - setError(t('genericError')) + setError('Algo ha salido mal. Inténtalo más tarde.') setBusy(false) } } - if (characters.length === 0) return

{t('noCharactersYet')}

+ if (characters.length === 0) return

No tienes personajes disponibles.

return (
-
- + +
- setDestination(e.target.value)} required /> + setDestination(e.target.value)} required />
-
-
+
{error && {error}}
diff --git a/web-next/lib/paid-services.ts b/web-next/lib/paid-services.ts index fc06e47..fdd311a 100644 --- a/web-next/lib/paid-services.ts +++ b/web-next/lib/paid-services.ts @@ -1,6 +1,7 @@ import { executeSoapCommand } from './soap' import { creditDPoints } from './dpoints' import { checkFactionChangeEligibility } from './change-faction' +import { checkTransferEligibility } from './transfer-character' import { getRenamePrice, getCustomizePrice, @@ -18,9 +19,16 @@ export interface PaidServiceConfig { productName: (character: string, meta: Meta) => string fulfill: (character: string, meta: Meta) => Promise extraFields: string[] // campos (además de character) que el form envía y se guardan en metadata - // Comprobación previa al pago (p.ej. condiciones de cambio de facción). Si `ok` - // es false, se bloquea el checkout y se muestra `message`. - precheck?: (accountId: number, character: string) => Promise<{ ok: boolean; message?: string }> + // Comprobación previa al pago (p.ej. condiciones de cambio de facción / transferencia). + // Si `ok` es false, se bloquea el checkout y se muestra `message`. + precheck?: (ctx: PrecheckCtx) => Promise<{ ok: boolean; message?: string }> +} + +export interface PrecheckCtx { + accountId: number + email: string + character: string + body: Record } async function soapFulfill(command: string): Promise { @@ -60,7 +68,7 @@ export const PAID_SERVICES: Record = { price: () => getChangeFactionPrice(), productName: (c) => `Cambiar facción: ${c}`, fulfill: (c) => soapFulfill(`.char changef ${c}`), - precheck: async (accountId, character) => { + precheck: async ({ accountId, character }) => { const r = await checkFactionChangeEligibility(accountId, character) return { ok: r.ok, message: r.ok ? undefined : `No se puede cambiar la facción: el personaje ${r.reasons.join('; ')}.` } }, @@ -83,6 +91,7 @@ export const PAID_SERVICES: Record = { productName: (c, m) => `Transferir ${c} a la cuenta ${m.destination_account}`, fulfill: (c, m) => soapFulfill(`.char changeaccount ${m.destination_account} ${c}`), extraFields: ['destination_account'], + precheck: ({ accountId, email, character, body }) => checkTransferEligibility(accountId, email, character, body), }, // Compra de PD (Adquirir PD): acredita puntos a la cuenta que hizo el pago. // El importe lo elige el usuario, por eso `price` lee metadata.amount. diff --git a/web-next/lib/transfer-character.ts b/web-next/lib/transfer-character.ts new file mode 100644 index 0000000..7f8466d --- /dev/null +++ b/web-next/lib/transfer-character.ts @@ -0,0 +1,81 @@ +import type { RowDataPacket } from 'mysql2' +import { db, DB } from './db' +import { authenticate } from './auth' +import { checkSecurityToken } from './security-token' +import { is2faEnabled } from './two-factor' + +/** + * Condiciones para transferir un personaje a otra cuenta (/transfer-character): + * personaje desconectado, contraseña correcta, token de seguridad, 2FA activo, + * cuenta destino válida, y —para Caballeros de la Muerte— la cuenta destino debe + * tener un personaje de nivel 55+ y no tener ya un DK. Bloqueo de 5 s tras una + * transferencia reciente. Se comprueba ANTES del pago (SumUp). + */ +export async function checkTransferEligibility( + accountId: number, + email: string, + character: string, + body: Record, +): Promise<{ ok: boolean; message?: string }> { + const destination = String(body.destination_account ?? '').trim() + const password = String(body.password ?? '') + const token = String(body.security_token ?? '').trim() + if (!destination || !password || !token) { + return { ok: false, message: 'Completa la cuenta destino, la contraseña y el token de seguridad.' } + } + + // Personaje: de la cuenta, desconectado, clase. + const [chRows] = await db(DB.characters).query( + 'SELECT class, online FROM characters WHERE name = ? AND account = ? LIMIT 1', + [character, accountId], + ) + const ch = chRows[0] + if (!ch) return { ok: false, message: 'El personaje no pertenece a tu cuenta.' } + if (ch.online === 1) return { ok: false, message: 'El personaje debe estar desconectado.' } + const isDK = Number(ch.class) === 6 + + // Contraseña + token de seguridad + 2FA activo. + if (!(await authenticate(email, password))) return { ok: false, message: 'La contraseña de tu cuenta es incorrecta.' } + if (!(await checkSecurityToken(accountId, token))) return { ok: false, message: 'El token de seguridad no es válido.' } + if (!(await is2faEnabled(accountId))) return { ok: false, message: 'Debes tener el 2FA activado en tu cuenta.' } + + // Cuenta destino (por nombre de cuenta). + let destId = 0 + try { + const [acc] = await db(DB.auth).query('SELECT id FROM account WHERE username = ? LIMIT 1', [destination]) + destId = acc[0] ? Number(acc[0].id) : 0 + } catch { + return { ok: false, message: 'No se pudo verificar la cuenta de destino.' } + } + if (!destId) return { ok: false, message: 'La cuenta de destino no existe.' } + if (destId === accountId) return { ok: false, message: 'La cuenta de destino no puede ser la tuya.' } + + // Condiciones de Caballero de la Muerte. + if (isDK) { + const [lvl] = await db(DB.characters).query( + 'SELECT 1 FROM characters WHERE account = ? AND level >= 55 LIMIT 1', + [destId], + ) + if (!lvl[0]) { + return { ok: false, message: 'La cuenta destino debe tener un personaje de nivel 55 o superior para recibir un Caballero de la Muerte.' } + } + const [dk] = await db(DB.characters).query( + 'SELECT 1 FROM characters WHERE account = ? AND class = 6 LIMIT 1', + [destId], + ) + if (dk[0]) return { ok: false, message: 'La cuenta destino ya tiene un Caballero de la Muerte.' } + } + + // Bloqueo de 5 s tras una transferencia reciente de esta cuenta. + try { + const [recent] = await db(DB.default).query( + "SELECT id FROM home_stripelog WHERE account_id = ? AND service = 'transfer' AND fulfilled = 1 AND timestamp > (NOW() - INTERVAL 5 SECOND) LIMIT 1", + [accountId], + ) + if (recent[0]) return { ok: false, message: 'Espera unos segundos antes de transferir otro personaje.' } + } catch { + /* ignore */ + } + + return { ok: true } +}