diff --git a/src/dashboard/scoreko-dev/views/Players.vue b/src/dashboard/scoreko-dev/views/Players.vue index f6c6614..deec974 100644 --- a/src/dashboard/scoreko-dev/views/Players.vue +++ b/src/dashboard/scoreko-dev/views/Players.vue @@ -275,13 +275,13 @@ const checkOAuthStatus = async () => { oauthLoading.value = false; clearOAuthPolling(); oauthSessionId.value = ''; - tournamentsError.value = status.error || 'No se pudo completar el login OAuth con start.gg.'; + tournamentsError.value = status.error || 'Could not complete OAuth login with start.gg.'; } } catch (error) { oauthLoading.value = false; clearOAuthPolling(); oauthSessionId.value = ''; - tournamentsError.value = error instanceof Error ? error.message : 'No se pudo verificar el estado OAuth.'; + tournamentsError.value = error instanceof Error ? error.message : 'Could not verify OAuth status.'; } }; @@ -300,7 +300,7 @@ const connectWithStartGGOAuth = async () => { }, 1500); } catch (error) { oauthLoading.value = false; - tournamentsError.value = error instanceof Error ? error.message : 'No se pudo iniciar OAuth con start.gg.'; + tournamentsError.value = error instanceof Error ? error.message : 'Could not start OAuth with start.gg.'; } }; @@ -325,7 +325,7 @@ const saveManualToken = () => { const loadRecentTournaments = async () => { const token = startGGToken.value.trim(); if (!token) { - tournamentsError.value = 'Añade tu token de start.gg para cargar torneos.'; + tournamentsError.value = 'Add your start.gg token to load tournaments.'; recentTournaments.value = []; return; } @@ -338,10 +338,10 @@ const loadRecentTournaments = async () => { }); recentTournaments.value = tournaments; if (!tournaments.length) { - tournamentsError.value = 'No hay torneos recientes para esta cuenta.'; + tournamentsError.value = 'There are no recent tournaments for this account.'; } } catch (error) { - tournamentsError.value = error instanceof Error ? error.message : 'No se pudieron cargar torneos.'; + tournamentsError.value = error instanceof Error ? error.message : 'Could not load tournaments.'; recentTournaments.value = []; } finally { loadingTournaments.value = false; @@ -365,7 +365,7 @@ const openStartGGImportDialog = async (tournament: StartGGTournament) => { startGGPlayers.value = importedPlayers; selectedStartGGPlayerIds.value = importedPlayers.map((player) => player.id); } catch (error) { - const message = error instanceof Error ? error.message : 'No se pudieron cargar jugadores'; + const message = error instanceof Error ? error.message : 'Could not load players'; window.alert(message); isImportDialogOpen.value = false; } finally { @@ -599,7 +599,7 @@ onBeforeUnmount(() => { StartGG
- Conecta por OAuth (recomendado) o pega tu token personal para cargar tus torneos creados o donde eres admin. Si aparece "Client authentication failed", revisa que en config uses el Client ID/Secret de un OAuth App de start.gg. + Connect via OAuth (recommended) or paste your personal token to load tournaments you created or administrate. If you see "Client authentication failed", verify your config uses the Client ID/Secret from a start.gg OAuth App.
@@ -607,7 +607,7 @@ onBeforeUnmount(() => { v-if="!hasStartGGTokenConfigured" color="primary" icon="login" - label="Conectar con start.gg" + label="Connect with start.gg" :loading="oauthLoading" @click="connectWithStartGGOAuth" /> @@ -616,7 +616,7 @@ onBeforeUnmount(() => { outline color="positive" icon="check_circle" - label="Conectado" + label="Connected" class="startgg-connected-btn" @click="openManualTokenDialog" /> @@ -626,7 +626,7 @@ onBeforeUnmount(() => { outline color="white" icon="vpn_key" - label="Usar API personal" + label="Use personal API" @click="openManualTokenDialog" />
@@ -663,7 +663,7 @@ onBeforeUnmount(() => { input-debounce="0" clearable dense - label="Torneo" + label="Tournament" class="players-underlined-field" @filter="filterTournaments" > @@ -686,9 +686,13 @@ onBeforeUnmount(() => { + > + Import players +
@@ -700,24 +704,24 @@ onBeforeUnmount(() => {
- API personal de start.gg + Personal start.gg API
- Si OAuth falla, puedes crear tu token personal manualmente con estos pasos: + If OAuth fails, you can create your personal token manually with these steps:
    -
  1. Ir a https://start.gg/admin/profile/developer
  2. -
  3. Iniciar sesión con tu cuenta
  4. -
  5. De los 3 access tokens, clicar en Third Party
  6. -
  7. Crear uno nuevo y cubrir la descripción con el nombre que quieras
  8. -
  9. Copiar el token generado y pegarlo en Scoreko
  10. +
  11. Go to https://start.gg/admin/profile/developer
  12. +
  13. Sign in with your account
  14. +
  15. From the 3 access tokens, click Third Party
  16. +
  17. Create a new one and fill the description with any name you want
  18. +
  19. Copy the generated token and paste it into Scoreko
{ @@ -750,7 +754,7 @@ onBeforeUnmount(() => {
- Importar desde {{ importingTournament?.name || 'start.gg' }} + Import from {{ importingTournament?.name || 'start.gg' }}
@@ -760,7 +764,7 @@ onBeforeUnmount(() => { class="row items-center q-gutter-sm" > - Cargando inscritos... + Loading participants...
{ diff --git a/src/extension/startgg.ts b/src/extension/startgg.ts index 1a06786..6c3282d 100644 --- a/src/extension/startgg.ts +++ b/src/extension/startgg.ts @@ -1,4 +1,4 @@ -import { createServer, type Server } from 'node:http'; +import { createServer, type Server, type ServerResponse } from 'node:http'; import { randomUUID } from 'node:crypto'; import { getData, type CountryRecord } from 'country-list'; import { nodecg } from './util/nodecg.js'; @@ -64,6 +64,27 @@ interface OAuthTokenResponse { const oauthSessions = new Map(); let oauthCallbackServer: Server | null = null; +const getStringProp = (payload: unknown, key: string): string => { + if (typeof payload !== 'object' || payload === null || !(key in payload)) { + return ''; + } + + const value = (payload as Record)[key]; + return typeof value === 'string' ? value.trim() : String(value || '').trim(); +}; + +const updateOAuthSession = (sessionId: string, update: Partial) => { + const session = oauthSessions.get(sessionId); + if (!session) { + return; + } + + oauthSessions.set(sessionId, { + ...session, + ...update, + }); +}; + const requestStartGG = async (query: string, variables: Record, token: string): Promise => { const response = await fetch(STARTGG_ENDPOINT, { method: 'POST', @@ -75,10 +96,16 @@ const requestStartGG = async (query: string, variables: Record; + try { + payload = (await response.json()) as StartGGGraphQLResponse; + } catch { + throw new Error('Invalid JSON response from start.gg'); } - const payload = (await response.json()) as StartGGGraphQLResponse; if (payload.errors?.length) { throw new Error(payload.errors[0]?.message || 'Unknown start.gg error'); } @@ -137,16 +164,19 @@ const getCallbackUrl = (callbackPort: number) => `http://127.0.0.1:${callbackPor const cleanupExpiredOAuthSessions = () => { const now = Date.now(); - oauthSessions.forEach((session) => { + oauthSessions.forEach((session, sessionId) => { if (session.expiresAt <= now && session.status === 'pending') { - oauthSessions.set(session.sessionId, { - ...session, - status: 'expired', - }); + updateOAuthSession(sessionId, { status: 'expired' }); } }); }; +const respondWithCallbackHtml = (res: ServerResponse, statusCode: number, title: string, message: string) => { + res.statusCode = statusCode; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(renderCallbackHtml(title, message)); +}; + const renderCallbackHtml = (title: string, message: string) => ` @@ -163,7 +193,7 @@ const renderCallbackHtml = (title: string, message: string) => `

${title}

${message}

-

Puedes cerrar esta pestaña y volver a Scoreko.

+

You can close this tab and return to Scoreko.

`; @@ -252,68 +282,41 @@ const ensureOAuthCallbackServer = async (oauthConfig: OAuthConfig) => { const session = Array.from(oauthSessions.values()).find((candidate) => candidate.state === state); if (!session) { - res.statusCode = 400; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(renderCallbackHtml('OAuth inválido', 'No se encontró una sesión activa para esta autorización.')); + respondWithCallbackHtml(res, 400, 'Invalid OAuth', 'No active session was found for this authorization.'); return; } if (session.expiresAt <= Date.now()) { - oauthSessions.set(session.sessionId, { - ...session, - status: 'expired', - }); - res.statusCode = 400; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(renderCallbackHtml('Sesión expirada', 'La sesión OAuth expiró. Vuelve a iniciar el proceso desde Scoreko.')); + updateOAuthSession(session.sessionId, { status: 'expired' }); + respondWithCallbackHtml(res, 400, 'Session expired', 'The OAuth session expired. Start the process again from Scoreko.'); return; } if (error) { - oauthSessions.set(session.sessionId, { - ...session, - status: 'error', - error, - }); - res.statusCode = 400; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(renderCallbackHtml('OAuth cancelado', `start.gg devolvió el error: ${error}`)); + updateOAuthSession(session.sessionId, { status: 'error', error }); + respondWithCallbackHtml(res, 400, 'OAuth canceled', `start.gg returned this error: ${error}`); return; } if (!code) { - oauthSessions.set(session.sessionId, { - ...session, + updateOAuthSession(session.sessionId, { status: 'error', error: 'Missing authorization code', }); - res.statusCode = 400; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(renderCallbackHtml('OAuth incompleto', 'No se recibió un código de autorización.')); + respondWithCallbackHtml(res, 400, 'Incomplete OAuth', 'No authorization code was received.'); return; } void exchangeOAuthCodeForToken(code, callbackUrl, oauthConfig) .then((token) => { - oauthSessions.set(session.sessionId, { - ...session, - status: 'completed', - token, - error: undefined, - }); + updateOAuthSession(session.sessionId, { status: 'completed', token, error: undefined }); }) .catch((exchangeError) => { const message = exchangeError instanceof Error ? exchangeError.message : 'Failed to exchange authorization code'; - oauthSessions.set(session.sessionId, { - ...session, - status: 'error', - error: message, - }); + updateOAuthSession(session.sessionId, { status: 'error', error: message }); }); - res.statusCode = 200; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.end(renderCallbackHtml('Autorización recibida', 'Se recibió tu autorización. Finalizando el login en segundo plano...')); + respondWithCallbackHtml(res, 200, 'Authorization received', 'Your authorization was received. Finishing sign-in in the background...'); }); await new Promise((resolve, reject) => { @@ -330,14 +333,14 @@ const ensureOAuthCallbackServer = async (oauthConfig: OAuthConfig) => { nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => { const oauthConfig = getOAuthConfig(); if (!oauthConfig) { - sendAck(ack, 'OAuth no está configurado en esta instalación (faltan startggClientId/startggClientSecret). Usa el Client ID y Client Secret del OAuth app de start.gg.'); + sendAck(ack, 'OAuth is not configured in this installation (missing startggClientId/startggClientSecret). Use the Client ID and Client Secret from a start.gg OAuth app.'); return; } try { await ensureOAuthCallbackServer(oauthConfig); } catch (serverError) { - const message = serverError instanceof Error ? serverError.message : 'No se pudo iniciar el callback OAuth local'; + const message = serverError instanceof Error ? serverError.message : 'Could not start the local OAuth callback'; sendAck(ack, message); return; } @@ -371,9 +374,7 @@ nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => { cleanupExpiredOAuthSessions(); - const sessionId = typeof payload === 'object' && payload !== null && 'sessionId' in payload - ? String((payload as { sessionId?: string }).sessionId || '').trim() - : ''; + const sessionId = getStringProp(payload, 'sessionId'); if (!sessionId) { sendAck(ack, 'Missing OAuth session id'); @@ -394,9 +395,7 @@ nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => { }); nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) => { - const token = typeof payload === 'object' && payload !== null && 'token' in payload - ? String((payload as { token?: string }).token || '').trim() - : ''; + const token = getStringProp(payload, 'token'); if (!token) { sendAck(ack, 'Missing start.gg API token'); @@ -443,12 +442,8 @@ nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) }); nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) => { - const candidate = typeof payload === 'object' && payload !== null ? payload as { - token?: string; - slug?: string; - } : {}; - const token = String(candidate.token || '').trim(); - const slug = String(candidate.slug || '').trim(); + const token = getStringProp(payload, 'token'); + const slug = getStringProp(payload, 'slug'); if (!token) { sendAck(ack, 'Missing start.gg API token'); @@ -485,7 +480,7 @@ nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) try { let currentPage = 1; let totalPages = 1; - const playersMap: Record = {}; + const playersMap = new Map(); while (currentPage <= totalPages) { const data = await requestStartGG<{ @@ -514,7 +509,8 @@ nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) throw new Error('Tournament not found'); } - totalPages = Math.max(data.tournament.participants.pageInfo.totalPages || 1, 1); + const apiTotalPages = Number(data.tournament.participants.pageInfo.totalPages); + totalPages = Number.isFinite(apiTotalPages) ? Math.max(apiTotalPages, 1) : 1; data.tournament.participants.nodes.forEach((participant) => { const playerId = String(participant.id); @@ -523,20 +519,20 @@ nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) return; } const country = resolveCountryCodeFromStartGG(participant.user?.location?.country); - playersMap[playerId] = { + playersMap.set(playerId, { id: playerId, gamertag, name: gamertag, team: (participant.prefix || '').trim(), country, twitter: '', - }; + }); }); currentPage += 1; } - sendAck(ack, null, Object.values(playersMap)); + sendAck(ack, null, Array.from(playersMap.values())); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error while importing players'; sendAck(ack, message);