From 9a5b64b2c0b93ffccf1a06c46b3ca0294cd58587 Mon Sep 17 00:00:00 2001 From: Pandipipas <62224708+Pandipipas@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:33:44 +0100 Subject: [PATCH 1/6] feat(players): add Challonge v2.1 OAuth and import integration --- configschema.json | 17 + src/dashboard/scoreko-dev/views/Players.vue | 352 +++++++++++++ src/extension/challonge.ts | 525 ++++++++++++++++++++ src/extension/index.ts | 1 + src/types/schemas/configschema.d.ts | 12 + 5 files changed, 907 insertions(+) create mode 100644 src/extension/challonge.ts diff --git a/configschema.json b/configschema.json index 9488047..d0a093b 100644 --- a/configschema.json +++ b/configschema.json @@ -22,6 +22,23 @@ "minimum": 1, "maximum": 65535, "description": "Puerto local para callback OAuth" + }, + "challongeClientId": { + "type": "string", + "default": "", + "description": "Client ID de tu OAuth app de Challonge" + }, + "challongeClientSecret": { + "type": "string", + "default": "", + "description": "Client Secret de tu OAuth app de Challonge" + }, + "challongeOAuthPort": { + "type": "integer", + "default": 34921, + "minimum": 1, + "maximum": 65535, + "description": "Puerto local para callback OAuth de Challonge" } }, "required": [ diff --git a/src/dashboard/scoreko-dev/views/Players.vue b/src/dashboard/scoreko-dev/views/Players.vue index deec974..2839fda 100644 --- a/src/dashboard/scoreko-dev/views/Players.vue +++ b/src/dashboard/scoreko-dev/views/Players.vue @@ -30,7 +30,20 @@ interface StartGGImportedPlayer extends Player { id: string; } +interface ChallongeTournament { + id: string; + name: string; + slug: string; + startAt: number | null; + endAt: number | null; +} + +interface ChallongeImportedPlayer extends Player { + id: string; +} + const STARTGG_TOKEN_STORAGE_KEY = 'scoreko-dev.startgg-token'; +const CHALLONGE_TOKEN_STORAGE_KEY = 'scoreko-dev.challonge-token'; const STARTGG_TEMP_PLAYERS_STORAGE_KEY = 'scoreko-dev.startgg-temp-players'; const STARTGG_TEMP_FALLBACK_DURATION_SECONDS = 12 * 60 * 60; @@ -97,6 +110,7 @@ watch( ); const startGGToken = ref(localStorage.getItem(STARTGG_TOKEN_STORAGE_KEY) ?? ''); +const challongeToken = ref(localStorage.getItem(CHALLONGE_TOKEN_STORAGE_KEY) ?? ''); const recentTournaments = ref([]); const loadingTournaments = ref(false); const tournamentsError = ref(''); @@ -111,10 +125,24 @@ const temporaryStartGGPlayers = ref({}); let temporaryCleanupTimer: ReturnType | null = null; const oauthLoading = ref(false); +const challongeOauthLoading = ref(false); const isManualTokenDialogOpen = ref(false); const manualTokenDraft = ref(''); const oauthSessionId = ref(''); +const challongeOauthSessionId = ref(''); let oauthPollingTimer: ReturnType | null = null; +let challongeOauthPollingTimer: ReturnType | null = null; + +const challongeRecentTournaments = ref([]); +const challongeLoadingTournaments = ref(false); +const challongeTournamentsError = ref(''); +const selectedChallongeTournamentSlug = ref(''); +const challongeTournamentInput = ref(''); +const challongePlayers = ref([]); +const selectedChallongePlayerIds = ref([]); +const challongeImportDialogOpen = ref(false); +const loadingChallongeTournamentPlayers = ref(false); +const importingChallongeTournament = ref(null); interface OAuthSessionResponse { sessionId: string; @@ -131,6 +159,10 @@ watch(startGGToken, (value) => { localStorage.setItem(STARTGG_TOKEN_STORAGE_KEY, value); }); +watch(challongeToken, (value) => { + localStorage.setItem(CHALLONGE_TOKEN_STORAGE_KEY, value); +}); + const persistTemporaryStartGGPlayers = () => { localStorage.setItem(STARTGG_TEMP_PLAYERS_STORAGE_KEY, JSON.stringify(temporaryStartGGPlayers.value)); }; @@ -224,6 +256,13 @@ const clearOAuthPolling = () => { } }; +const clearChallongeOAuthPolling = () => { + if (challongeOauthPollingTimer) { + clearInterval(challongeOauthPollingTimer); + challongeOauthPollingTimer = null; + } +}; + const clearTemporaryCleanupTimer = () => { if (temporaryCleanupTimer) { clearInterval(temporaryCleanupTimer); @@ -285,6 +324,40 @@ const checkOAuthStatus = async () => { } }; +const checkChallongeOAuthStatus = async () => { + if (!challongeOauthSessionId.value) { + return; + } + + try { + const status = await sendNodeCGMessage('challonge:getOAuthSessionStatus', { + sessionId: challongeOauthSessionId.value, + }); + + if (status.status === 'completed' && status.token) { + challongeToken.value = status.token; + challongeOauthLoading.value = false; + clearChallongeOAuthPolling(); + challongeOauthSessionId.value = ''; + challongeTournamentsError.value = ''; + await loadChallongeRecentTournaments(); + return; + } + + if (status.status === 'error' || status.status === 'expired') { + challongeOauthLoading.value = false; + clearChallongeOAuthPolling(); + challongeOauthSessionId.value = ''; + challongeTournamentsError.value = status.error || 'Could not complete OAuth login with Challonge.'; + } + } catch (error) { + challongeOauthLoading.value = false; + clearChallongeOAuthPolling(); + challongeOauthSessionId.value = ''; + challongeTournamentsError.value = error instanceof Error ? error.message : 'Could not verify OAuth status.'; + } +}; + const connectWithStartGGOAuth = async () => { oauthLoading.value = true; tournamentsError.value = ''; @@ -304,6 +377,25 @@ const connectWithStartGGOAuth = async () => { } }; +const connectWithChallongeOAuth = async () => { + challongeOauthLoading.value = true; + challongeTournamentsError.value = ''; + clearChallongeOAuthPolling(); + + try { + const session = await sendNodeCGMessage('challonge:createOAuthSession', {}); + challongeOauthSessionId.value = session.sessionId; + window.open(session.authUrl, '_blank', 'noopener,noreferrer'); + + challongeOauthPollingTimer = setInterval(() => { + void checkChallongeOAuthStatus(); + }, 1500); + } catch (error) { + challongeOauthLoading.value = false; + challongeTournamentsError.value = error instanceof Error ? error.message : 'Could not start OAuth with Challonge.'; + } +}; + const openManualTokenDialog = () => { manualTokenDraft.value = startGGToken.value; isManualTokenDialogOpen.value = true; @@ -348,6 +440,119 @@ const loadRecentTournaments = async () => { } }; +const challongeTournamentOptions = computed(() => + challongeRecentTournaments.value.map((tournament) => ({ + label: tournament.name, + value: tournament.slug, + caption: tournament.slug, + })), +); +const filteredChallongeTournamentOptions = ref(challongeTournamentOptions.value); + +watch(challongeTournamentOptions, (value) => { + filteredChallongeTournamentOptions.value = value; + if (selectedChallongeTournamentSlug.value && !challongeRecentTournaments.value.some((item) => item.slug === selectedChallongeTournamentSlug.value)) { + selectedChallongeTournamentSlug.value = ''; + challongeTournamentInput.value = ''; + } +}); + +const selectedChallongeTournamentOption = computed(() => + challongeRecentTournaments.value.find((item) => item.slug === selectedChallongeTournamentSlug.value) ?? null, +); +const canImportSelectedChallongeTournament = computed(() => Boolean(selectedChallongeTournamentOption.value)); +const hasChallongeTokenConfigured = computed(() => Boolean(challongeToken.value.trim())); + +const filterChallongeTournaments = (value: string, update: (callback: () => void) => void) => { + update(() => { + const needle = value.toLowerCase().trim(); + if (!needle) { + filteredChallongeTournamentOptions.value = challongeTournamentOptions.value; + return; + } + + filteredChallongeTournamentOptions.value = challongeTournamentOptions.value.filter((option) => + option.label.toLowerCase().includes(needle) || option.caption.toLowerCase().includes(needle), + ); + }); +}; + +const loadChallongeRecentTournaments = async () => { + const token = challongeToken.value.trim(); + if (!token) { + challongeTournamentsError.value = 'Add your Challonge token to load tournaments.'; + challongeRecentTournaments.value = []; + return; + } + + challongeTournamentsError.value = ''; + challongeLoadingTournaments.value = true; + try { + const tournaments = await sendNodeCGMessage('challonge:fetchRecentTournaments', { + token, + }); + challongeRecentTournaments.value = tournaments; + if (!tournaments.length) { + challongeTournamentsError.value = 'There are no recent tournaments for this account.'; + } + } catch (error) { + challongeTournamentsError.value = error instanceof Error ? error.message : 'Could not load tournaments.'; + challongeRecentTournaments.value = []; + } finally { + challongeLoadingTournaments.value = false; + } +}; + +const openChallongeImportDialog = async (tournament: ChallongeTournament) => { + importingChallongeTournament.value = tournament; + challongeImportDialogOpen.value = true; + loadingChallongeTournamentPlayers.value = true; + selectedChallongePlayerIds.value = []; + selectedChallongeTournamentSlug.value = tournament.slug; + challongeTournamentInput.value = tournament.name; + challongePlayers.value = []; + + try { + const importedPlayers = await sendNodeCGMessage('challonge:fetchTournamentPlayers', { + token: challongeToken.value.trim(), + slug: tournament.slug, + }); + challongePlayers.value = importedPlayers; + selectedChallongePlayerIds.value = importedPlayers.map((player) => player.id); + } catch (error) { + const message = error instanceof Error ? error.message : 'Could not load players'; + window.alert(message); + challongeImportDialogOpen.value = false; + } finally { + loadingChallongeTournamentPlayers.value = false; + } +}; + +const openSelectedChallongeTournamentImportDialog = () => { + if (!selectedChallongeTournamentOption.value) { + return; + } + void openChallongeImportDialog(selectedChallongeTournamentOption.value); +}; + +const importSelectedChallongePlayers = () => { + const selectedPlayers = challongePlayers.value.filter((player) => + selectedChallongePlayerIds.value.includes(player.id), + ); + + selectedPlayers.forEach((player) => { + playersStore.upsertPlayer(player.id, { + gamertag: player.gamertag, + name: player.name, + team: player.team, + country: player.country, + twitter: player.twitter, + }); + }); + + challongeImportDialogOpen.value = false; +}; + const openStartGGImportDialog = async (tournament: StartGGTournament) => { importingTournament.value = tournament; isImportDialogOpen.value = true; @@ -490,10 +695,14 @@ onMounted(() => { if (startGGToken.value.trim()) { void loadRecentTournaments(); } + if (challongeToken.value.trim()) { + void loadChallongeRecentTournaments(); + } }); onBeforeUnmount(() => { clearOAuthPolling(); + clearChallongeOAuthPolling(); clearTemporaryCleanupTimer(); }); @@ -697,6 +906,104 @@ onBeforeUnmount(() => { + +
+ +
+ Challonge +
+
+ Connect with OAuth or paste your personal token to load your Challonge tournaments and import participants. +
+
+
+ + +
+
+
+ {{ challongeTournamentsError }} +
+
+ +
+ + + +
+
+ + Import players + +
+
+
+
@@ -795,6 +1102,51 @@ onBeforeUnmount(() => { + + + +
+ Import from {{ importingChallongeTournament?.name || 'Challonge' }} +
+
+ + +
+ + Loading participants... +
+
+ +
+
+ + + + + +
+
+ diff --git a/src/extension/challonge.ts b/src/extension/challonge.ts new file mode 100644 index 0000000..7984bf0 --- /dev/null +++ b/src/extension/challonge.ts @@ -0,0 +1,525 @@ +import { createServer, type Server, type ServerResponse } from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { nodecg } from './util/nodecg.js'; + +const CHALLONGE_API_BASE = 'https://api.challonge.com/v2.1'; +const CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT = 'https://api.challonge.com/oauth/authorize'; +const CHALLONGE_OAUTH_TOKEN_ENDPOINT = 'https://api.challonge.com/oauth/token'; +const CHALLONGE_OAUTH_CALLBACK_PATH = '/challonge/callback'; +const CHALLONGE_OAUTH_DEFAULT_PORT = 34921; +const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000; + +interface OAuthConfig { + clientId: string; + clientSecret: string; + callbackPort: number; +} + +interface OAuthSession { + sessionId: string; + state: string; + expiresAt: number; + status: 'pending' | 'completed' | 'error' | 'expired'; + token?: string; + error?: string; +} + +interface OAuthTokenResponse { + access_token?: string; + error?: string; + error_description?: string; + message?: string; +} + +interface RecentTournament { + id: string; + name: string; + slug: string; + startAt: number | null; + endAt: number | null; +} + +interface ImportedPlayer { + id: string; + gamertag: string; + name: string; + team: string; + country: string; + twitter: string; +} + +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 getNumberProp = (payload: Record, keys: string[]): number | null => { + for (const key of keys) { + const raw = payload[key]; + if (typeof raw === 'number' && Number.isFinite(raw)) { + return raw; + } + if (typeof raw === 'string') { + const parsed = Number(raw); + if (Number.isFinite(parsed)) { + return parsed; + } + } + } + return null; +}; + +const sendAck = (ack: unknown, error: string | null, response?: unknown) => { + if (typeof ack !== 'function') { + return; + } + ack(error, response); +}; + +const getOAuthConfig = (): OAuthConfig | null => { + const bundleConfig = nodecg.bundleConfig as unknown as Record; + const clientId = String(bundleConfig.challongeClientId || '').trim(); + const clientSecret = String(bundleConfig.challongeClientSecret || '').trim(); + const rawPort = Number(bundleConfig.challongeOAuthPort ?? CHALLONGE_OAUTH_DEFAULT_PORT); + const callbackPort = Number.isFinite(rawPort) && rawPort > 0 ? rawPort : CHALLONGE_OAUTH_DEFAULT_PORT; + + if (!clientId || !clientSecret) { + return null; + } + + return { + clientId, + clientSecret, + callbackPort, + }; +}; + +const getCallbackUrl = (callbackPort: number) => `http://127.0.0.1:${callbackPort}${CHALLONGE_OAUTH_CALLBACK_PATH}`; + +const updateOAuthSession = (sessionId: string, update: Partial) => { + const session = oauthSessions.get(sessionId); + if (!session) { + return; + } + + oauthSessions.set(sessionId, { + ...session, + ...update, + }); +}; + +const cleanupExpiredOAuthSessions = () => { + const now = Date.now(); + oauthSessions.forEach((session, sessionId) => { + if (session.expiresAt <= now && session.status === 'pending') { + updateOAuthSession(sessionId, { status: 'expired' }); + } + }); +}; + +const renderCallbackHtml = (title: string, message: string) => ` + + + + ${title} + + + +
+

${title}

+

${message}

+

You can close this tab and return to Scoreko.

+
+ +`; + +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 parseOAuthTokenPayload = async (response: Response): Promise => { + const rawBody = await response.text(); + try { + return JSON.parse(rawBody) as OAuthTokenResponse; + } catch { + return { message: rawBody }; + } +}; + +const exchangeOAuthCodeForToken = async ( + code: string, + redirectUri: string, + oauthConfig: OAuthConfig, +): Promise => { + const params = new URLSearchParams({ + grant_type: 'authorization_code', + code, + client_id: oauthConfig.clientId, + client_secret: oauthConfig.clientSecret, + redirect_uri: redirectUri, + }); + + const response = await fetch(CHALLONGE_OAUTH_TOKEN_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + + const payload = await parseOAuthTokenPayload(response); + + if (!response.ok) { + throw new Error(payload.error_description || payload.error || payload.message || `OAuth token request failed (${response.status})`); + } + + const token = String(payload.access_token || '').trim(); + if (!token) { + throw new Error(payload.error_description || payload.error || payload.message || 'OAuth token response did not include an access token'); + } + + return token; +}; + +const requestChallonge = async (path: string, token: string): Promise => { + const response = await fetch(`${CHALLONGE_API_BASE}${path}`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/vnd.api+json', + Authorization: `Bearer ${token}`, + }, + }); + + const rawBody = await response.text(); + let payload: unknown = null; + if (rawBody) { + try { + payload = JSON.parse(rawBody) as unknown; + } catch { + if (!response.ok) { + throw new Error(`Challonge responded with ${response.status} ${response.statusText}`.trim()); + } + } + } + + if (!response.ok) { + const maybeError = payload as { errors?: { detail?: string }; error?: string } | null; + throw new Error( + maybeError?.errors?.detail || maybeError?.error || `Challonge responded with ${response.status} ${response.statusText}`.trim(), + ); + } + + return payload; +}; + +const normalizeTournamentSlug = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) { + return ''; + } + return trimmed.replace(/^https?:\/\/[^/]+\//i, '').replace(/^tournaments\//i, '').replace(/^\/+/, ''); +}; + +const parseRecentTournaments = (payload: unknown): RecentTournament[] => { + const rows: RecentTournament[] = []; + + const push = (candidate: Record) => { + const attributes = (typeof candidate.attributes === 'object' && candidate.attributes !== null) + ? (candidate.attributes as Record) + : candidate; + + const id = String(candidate.id || attributes.id || attributes.tournament_id || '').trim(); + const name = String(attributes.name || attributes.full_name || '').trim(); + const slug = normalizeTournamentSlug(String(attributes.url || attributes.slug || attributes.identifier || id)); + + if (!id || !name || !slug) { + return; + } + + rows.push({ + id, + name, + slug, + startAt: getNumberProp(attributes, ['start_at', 'started_at', 'startAt']), + endAt: getNumberProp(attributes, ['completed_at', 'end_at', 'ended_at', 'endAt']), + }); + }; + + if (Array.isArray(payload)) { + payload.forEach((row) => { + const wrapper = row as Record; + const tournament = (typeof wrapper.tournament === 'object' && wrapper.tournament !== null) + ? (wrapper.tournament as Record) + : wrapper; + push(tournament); + }); + return rows; + } + + if (typeof payload === 'object' && payload !== null) { + const root = payload as Record; + const data = root.data; + if (Array.isArray(data)) { + data.forEach((row) => { + if (typeof row === 'object' && row !== null) { + push(row as Record); + } + }); + return rows; + } + } + + return rows; +}; + +const parseImportedPlayers = (payload: unknown): ImportedPlayer[] => { + const map = new Map(); + + const push = (candidate: Record) => { + const attributes = (typeof candidate.attributes === 'object' && candidate.attributes !== null) + ? (candidate.attributes as Record) + : candidate; + + const id = String(candidate.id || attributes.id || attributes.participant_id || '').trim(); + const gamertag = String( + attributes.display_name + || attributes.name + || attributes.username + || attributes.gamer_tag + || '', + ).trim(); + + if (!id || !gamertag) { + return; + } + + map.set(id, { + id, + gamertag, + name: gamertag, + team: String(attributes.group_player_ids || attributes.team_name || '').trim(), + country: '', + twitter: String(attributes.twitter_handle || attributes.twitter || '').trim(), + }); + }; + + if (Array.isArray(payload)) { + payload.forEach((row) => { + const wrapper = row as Record; + const participant = (typeof wrapper.participant === 'object' && wrapper.participant !== null) + ? (wrapper.participant as Record) + : wrapper; + push(participant); + }); + return Array.from(map.values()); + } + + if (typeof payload === 'object' && payload !== null) { + const root = payload as Record; + const data = root.data; + if (Array.isArray(data)) { + data.forEach((row) => { + if (typeof row === 'object' && row !== null) { + push(row as Record); + } + }); + } + } + + return Array.from(map.values()); +}; + +const ensureOAuthCallbackServer = async (oauthConfig: OAuthConfig) => { + if (oauthCallbackServer) { + return; + } + + const callbackUrl = getCallbackUrl(oauthConfig.callbackPort); + + const server = createServer((req, res) => { + if (!req.url) { + res.statusCode = 400; + res.end('Bad request'); + return; + } + + const requestUrl = new URL(req.url, callbackUrl); + if (requestUrl.pathname !== CHALLONGE_OAUTH_CALLBACK_PATH) { + res.statusCode = 404; + res.end('Not found'); + return; + } + + cleanupExpiredOAuthSessions(); + + const state = requestUrl.searchParams.get('state') || ''; + const code = requestUrl.searchParams.get('code') || ''; + const error = requestUrl.searchParams.get('error') || ''; + + const session = Array.from(oauthSessions.values()).find((candidate) => candidate.state === state); + if (!session) { + respondWithCallbackHtml(res, 400, 'Invalid OAuth', 'No active session was found for this authorization.'); + return; + } + + if (session.expiresAt <= Date.now()) { + updateOAuthSession(session.sessionId, { status: 'expired' }); + respondWithCallbackHtml(res, 400, 'Session expired', 'The OAuth session expired. Start the process again from Scoreko.'); + return; + } + + if (error) { + updateOAuthSession(session.sessionId, { status: 'error', error }); + respondWithCallbackHtml(res, 400, 'OAuth canceled', `Challonge returned this error: ${error}`); + return; + } + + if (!code) { + updateOAuthSession(session.sessionId, { + status: 'error', + error: 'Missing authorization code', + }); + respondWithCallbackHtml(res, 400, 'Incomplete OAuth', 'No authorization code was received.'); + return; + } + + void exchangeOAuthCodeForToken(code, callbackUrl, oauthConfig) + .then((token) => { + updateOAuthSession(session.sessionId, { status: 'completed', token, error: undefined }); + }) + .catch((exchangeError) => { + const message = exchangeError instanceof Error ? exchangeError.message : 'Failed to exchange authorization code'; + updateOAuthSession(session.sessionId, { status: 'error', error: message }); + }); + + respondWithCallbackHtml(res, 200, 'Authorization received', 'Your authorization was received. Finishing sign-in in the background...'); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(oauthConfig.callbackPort, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + + oauthCallbackServer = server; +}; + +nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => { + const oauthConfig = getOAuthConfig(); + if (!oauthConfig) { + sendAck(ack, 'OAuth is not configured in this installation (missing challongeClientId/challongeClientSecret). Use the Client ID and Client Secret from a Challonge OAuth app.'); + return; + } + + try { + await ensureOAuthCallbackServer(oauthConfig); + } catch (serverError) { + const message = serverError instanceof Error ? serverError.message : 'Could not start the local OAuth callback'; + sendAck(ack, message); + return; + } + + cleanupExpiredOAuthSessions(); + + const sessionId = randomUUID(); + const state = randomUUID(); + oauthSessions.set(sessionId, { + sessionId, + state, + expiresAt: Date.now() + CHALLONGE_OAUTH_SESSION_TTL_MS, + status: 'pending', + }); + + const params = new URLSearchParams({ + response_type: 'code', + client_id: oauthConfig.clientId, + redirect_uri: getCallbackUrl(oauthConfig.callbackPort), + state, + }); + + sendAck(ack, null, { + sessionId, + authUrl: `${CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`, + }); +}); + +nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => { + cleanupExpiredOAuthSessions(); + + const sessionId = getStringProp(payload, 'sessionId'); + if (!sessionId) { + sendAck(ack, 'Missing OAuth session id'); + return; + } + + const session = oauthSessions.get(sessionId); + if (!session) { + sendAck(ack, 'OAuth session not found'); + return; + } + + sendAck(ack, null, { + status: session.status, + token: session.status === 'completed' ? session.token : undefined, + error: session.status === 'error' ? session.error : undefined, + }); +}); + +nodecg.listenFor('challonge:fetchRecentTournaments', async (payload: unknown, ack) => { + const token = getStringProp(payload, 'token'); + + if (!token) { + sendAck(ack, 'Missing Challonge API token'); + return; + } + + try { + const raw = await requestChallonge('/tournaments.json', token); + const tournaments = parseRecentTournaments(raw) + .sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0)) + .slice(0, 20); + + sendAck(ack, null, tournaments); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error while loading tournaments'; + sendAck(ack, message); + } +}); + +nodecg.listenFor('challonge:fetchTournamentPlayers', async (payload: unknown, ack) => { + const token = getStringProp(payload, 'token'); + const slug = normalizeTournamentSlug(getStringProp(payload, 'slug')); + + if (!token) { + sendAck(ack, 'Missing Challonge API token'); + return; + } + + if (!slug) { + sendAck(ack, 'Missing tournament slug'); + return; + } + + try { + const raw = await requestChallonge(`/tournaments/${encodeURIComponent(slug)}/participants.json`, token); + const players = parseImportedPlayers(raw); + sendAck(ack, null, players); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error while importing players'; + sendAck(ack, message); + } +}); diff --git a/src/extension/index.ts b/src/extension/index.ts index f890cdf..d8ce32c 100644 --- a/src/extension/index.ts +++ b/src/extension/index.ts @@ -10,4 +10,5 @@ export default async (nodecg: NodeCGServerAPI) => { await import('./util/replicants.js'); // make sure replicants are set up await import('./example.js'); await import('./startgg.js'); + await import('./challonge.js'); }; diff --git a/src/types/schemas/configschema.d.ts b/src/types/schemas/configschema.d.ts index f5f456d..eb4b9ad 100644 --- a/src/types/schemas/configschema.d.ts +++ b/src/types/schemas/configschema.d.ts @@ -20,4 +20,16 @@ export interface Configschema { * Puerto local para callback OAuth */ startggOAuthPort?: number; + /** + * Client ID de tu OAuth app de Challonge + */ + challongeClientId?: string; + /** + * Client Secret de tu OAuth app de Challonge + */ + challongeClientSecret?: string; + /** + * Puerto local para callback OAuth de Challonge + */ + challongeOAuthPort?: number; } From c718f43eba63b89c1626890aa7d78037820a135b Mon Sep 17 00:00:00 2001 From: Pandipipas <62224708+Pandipipas@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:39:29 +0100 Subject: [PATCH 2/6] fix(players): improve Challonge auth UX and manual token flow --- src/dashboard/scoreko-dev/views/Players.vue | 88 ++++++++++++++++++++- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/src/dashboard/scoreko-dev/views/Players.vue b/src/dashboard/scoreko-dev/views/Players.vue index 2839fda..be6c0da 100644 --- a/src/dashboard/scoreko-dev/views/Players.vue +++ b/src/dashboard/scoreko-dev/views/Players.vue @@ -128,6 +128,8 @@ const oauthLoading = ref(false); const challongeOauthLoading = ref(false); const isManualTokenDialogOpen = ref(false); const manualTokenDraft = ref(''); +const isChallongeManualTokenDialogOpen = ref(false); +const challongeManualTokenDraft = ref(''); const oauthSessionId = ref(''); const challongeOauthSessionId = ref(''); let oauthPollingTimer: ReturnType | null = null; @@ -143,6 +145,7 @@ const selectedChallongePlayerIds = ref([]); const challongeImportDialogOpen = ref(false); const loadingChallongeTournamentPlayers = ref(false); const importingChallongeTournament = ref(null); +const hasValidatedChallongeToken = ref(false); interface OAuthSessionResponse { sessionId: string; @@ -161,6 +164,7 @@ watch(startGGToken, (value) => { watch(challongeToken, (value) => { localStorage.setItem(CHALLONGE_TOKEN_STORAGE_KEY, value); + hasValidatedChallongeToken.value = false; }); const persistTemporaryStartGGPlayers = () => { @@ -463,6 +467,8 @@ const selectedChallongeTournamentOption = computed(() => const canImportSelectedChallongeTournament = computed(() => Boolean(selectedChallongeTournamentOption.value)); const hasChallongeTokenConfigured = computed(() => Boolean(challongeToken.value.trim())); +const challongeConnectionLabel = computed(() => (hasValidatedChallongeToken.value ? 'Connected' : 'Token set')); + const filterChallongeTournaments = (value: string, update: (callback: () => void) => void) => { update(() => { const needle = value.toLowerCase().trim(); @@ -491,18 +497,41 @@ const loadChallongeRecentTournaments = async () => { const tournaments = await sendNodeCGMessage('challonge:fetchRecentTournaments', { token, }); + hasValidatedChallongeToken.value = true; challongeRecentTournaments.value = tournaments; if (!tournaments.length) { challongeTournamentsError.value = 'There are no recent tournaments for this account.'; } } catch (error) { - challongeTournamentsError.value = error instanceof Error ? error.message : 'Could not load tournaments.'; + hasValidatedChallongeToken.value = false; + const message = error instanceof Error ? error.message : 'Could not load tournaments.'; + challongeTournamentsError.value = message.includes('401') + ? 'Challonge rejected the token (401 Unauthorized). Verify OAuth callback/client credentials or paste a valid personal API token.' + : message; challongeRecentTournaments.value = []; } finally { challongeLoadingTournaments.value = false; } }; +const openChallongeManualTokenDialog = () => { + challongeManualTokenDraft.value = challongeToken.value; + isChallongeManualTokenDialogOpen.value = true; +}; + +const saveChallongeManualToken = () => { + challongeToken.value = challongeManualTokenDraft.value.trim(); + + if (!challongeToken.value) { + challongeRecentTournaments.value = []; + selectedChallongeTournamentSlug.value = ''; + challongeTournamentInput.value = ''; + challongeTournamentsError.value = ''; + } + + isChallongeManualTokenDialogOpen.value = false; +}; + const openChallongeImportDialog = async (tournament: ChallongeTournament) => { importingChallongeTournament.value = tournament; challongeImportDialogOpen.value = true; @@ -932,9 +961,19 @@ onBeforeUnmount(() => { + +
+
@@ -1147,6 +1186,49 @@ onBeforeUnmount(() => {
+ + + +
+ Personal Challonge API +
+
+ + +
+ If OAuth fails, paste a personal Challonge API token. +
+ +
+ + + + + + +
+
+ From 9b789d2c612357042c122706a3dee691f6a37290 Mon Sep 17 00:00:00 2001 From: Pandipipas <62224708+Pandipipas@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:44:18 +0100 Subject: [PATCH 3/6] fix(challonge): request OAuth scopes needed for tournament reads --- src/dashboard/scoreko-dev/views/Players.vue | 2 +- src/extension/challonge.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/dashboard/scoreko-dev/views/Players.vue b/src/dashboard/scoreko-dev/views/Players.vue index be6c0da..9b82b53 100644 --- a/src/dashboard/scoreko-dev/views/Players.vue +++ b/src/dashboard/scoreko-dev/views/Players.vue @@ -506,7 +506,7 @@ const loadChallongeRecentTournaments = async () => { hasValidatedChallongeToken.value = false; const message = error instanceof Error ? error.message : 'Could not load tournaments.'; challongeTournamentsError.value = message.includes('401') - ? 'Challonge rejected the token (401 Unauthorized). Verify OAuth callback/client credentials or paste a valid personal API token.' + ? 'Challonge rejected the token (401 Unauthorized). Re-connect OAuth so it grants scopes (me, tournaments:read, participants:read) or paste a valid personal API token.' : message; challongeRecentTournaments.value = []; } finally { diff --git a/src/extension/challonge.ts b/src/extension/challonge.ts index 7984bf0..30f8b9d 100644 --- a/src/extension/challonge.ts +++ b/src/extension/challonge.ts @@ -5,6 +5,15 @@ import { nodecg } from './util/nodecg.js'; const CHALLONGE_API_BASE = 'https://api.challonge.com/v2.1'; const CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT = 'https://api.challonge.com/oauth/authorize'; const CHALLONGE_OAUTH_TOKEN_ENDPOINT = 'https://api.challonge.com/oauth/token'; +const CHALLONGE_OAUTH_SCOPES = [ + 'me', + 'tournaments:read', + 'tournaments:write', + 'matches:read', + 'matches:write', + 'participants:read', + 'participants:write', +].join(' '); const CHALLONGE_OAUTH_CALLBACK_PATH = '/challonge/callback'; const CHALLONGE_OAUTH_DEFAULT_PORT = 34921; const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000; @@ -448,6 +457,7 @@ nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) response_type: 'code', client_id: oauthConfig.clientId, redirect_uri: getCallbackUrl(oauthConfig.callbackPort), + scope: CHALLONGE_OAUTH_SCOPES, state, }); From dd2d8b79ca83e7f8e375ad5fa2632b1b7ed30906 Mon Sep 17 00:00:00 2001 From: Pandipipas <62224708+Pandipipas@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:50:56 +0100 Subject: [PATCH 4/6] fix(challonge): send Authorization-Type headers for v2 oauth tokens --- src/extension/challonge.ts | 55 ++++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/src/extension/challonge.ts b/src/extension/challonge.ts index 30f8b9d..9ca4d6a 100644 --- a/src/extension/challonge.ts +++ b/src/extension/challonge.ts @@ -202,35 +202,62 @@ const exchangeOAuthCodeForToken = async ( return token; }; +const parseJsonResponse = async (response: Response): Promise => { + const rawBody = await response.text(); + if (!rawBody) { + return null; + } + + try { + return JSON.parse(rawBody) as unknown; + } catch { + return null; + } +}; + const requestChallonge = async (path: string, token: string): Promise => { - const response = await fetch(`${CHALLONGE_API_BASE}${path}`, { + const requestUrl = `${CHALLONGE_API_BASE}${path}`; + + const v2Response = await fetch(requestUrl, { headers: { Accept: 'application/json', 'Content-Type': 'application/vnd.api+json', + 'Authorization-Type': 'v2', Authorization: `Bearer ${token}`, }, }); - const rawBody = await response.text(); - let payload: unknown = null; - if (rawBody) { - try { - payload = JSON.parse(rawBody) as unknown; - } catch { - if (!response.ok) { - throw new Error(`Challonge responded with ${response.status} ${response.statusText}`.trim()); - } + const v2Payload = await parseJsonResponse(v2Response); + + if (v2Response.ok) { + return v2Payload; + } + + // Fallback for personal API keys pasted manually (v1 auth style). + if (v2Response.status === 401) { + const v1Response = await fetch(requestUrl, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/vnd.api+json', + 'Authorization-Type': 'v1', + Authorization: token, + }, + }); + + const v1Payload = await parseJsonResponse(v1Response); + if (v1Response.ok) { + return v1Payload; } } - if (!response.ok) { - const maybeError = payload as { errors?: { detail?: string }; error?: string } | null; + const maybeError = v2Payload as { errors?: { detail?: string }; error?: string } | null; + if (!v2Response.ok) { throw new Error( - maybeError?.errors?.detail || maybeError?.error || `Challonge responded with ${response.status} ${response.statusText}`.trim(), + maybeError?.errors?.detail || maybeError?.error || `Challonge responded with ${v2Response.status} ${v2Response.statusText}`.trim(), ); } - return payload; + return v2Payload; }; const normalizeTournamentSlug = (value: string): string => { From 77d4ec47b4d2cfc72a7227d205b4a61a373ba78f Mon Sep 17 00:00:00 2001 From: Pandipipas <62224708+Pandipipas@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:59:54 +0100 Subject: [PATCH 5/6] feat(players): add Challonge temporary imported-player expiration --- src/dashboard/scoreko-dev/views/Players.vue | 73 +++++++++++++++++++-- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/src/dashboard/scoreko-dev/views/Players.vue b/src/dashboard/scoreko-dev/views/Players.vue index 9b82b53..e34f784 100644 --- a/src/dashboard/scoreko-dev/views/Players.vue +++ b/src/dashboard/scoreko-dev/views/Players.vue @@ -45,6 +45,7 @@ interface ChallongeImportedPlayer extends Player { const STARTGG_TOKEN_STORAGE_KEY = 'scoreko-dev.startgg-token'; const CHALLONGE_TOKEN_STORAGE_KEY = 'scoreko-dev.challonge-token'; const STARTGG_TEMP_PLAYERS_STORAGE_KEY = 'scoreko-dev.startgg-temp-players'; +const CHALLONGE_TEMP_PLAYERS_STORAGE_KEY = 'scoreko-dev.challonge-temp-players'; const STARTGG_TEMP_FALLBACK_DURATION_SECONDS = 12 * 60 * 60; interface TemporaryStartGGPlayerMeta { @@ -53,6 +54,7 @@ interface TemporaryStartGGPlayerMeta { } type TemporaryStartGGPlayersMap = Record; +type TemporaryChallongePlayersMap = Record; const playersStore = usePlayersStore(); const rows = computed(() => playersStore.rows); @@ -122,6 +124,7 @@ const selectedStartGGPlayerIds = ref([]); const selectedTournamentSlug = ref(''); const tournamentInput = ref(''); const temporaryStartGGPlayers = ref({}); +const temporaryChallongePlayers = ref({}); let temporaryCleanupTimer: ReturnType | null = null; const oauthLoading = ref(false); @@ -171,6 +174,10 @@ const persistTemporaryStartGGPlayers = () => { localStorage.setItem(STARTGG_TEMP_PLAYERS_STORAGE_KEY, JSON.stringify(temporaryStartGGPlayers.value)); }; +const persistTemporaryChallongePlayers = () => { + localStorage.setItem(CHALLONGE_TEMP_PLAYERS_STORAGE_KEY, JSON.stringify(temporaryChallongePlayers.value)); +}; + const loadTemporaryStartGGPlayers = (): TemporaryStartGGPlayersMap => { try { const raw = localStorage.getItem(STARTGG_TEMP_PLAYERS_STORAGE_KEY); @@ -205,6 +212,40 @@ const loadTemporaryStartGGPlayers = (): TemporaryStartGGPlayersMap => { } }; +const loadTemporaryChallongePlayers = (): TemporaryChallongePlayersMap => { + try { + const raw = localStorage.getItem(CHALLONGE_TEMP_PLAYERS_STORAGE_KEY); + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== 'object' || parsed === null) { + return {}; + } + + const result: TemporaryChallongePlayersMap = {}; + Object.entries(parsed as Record).forEach(([playerId, value]) => { + if (!playerId || typeof value !== 'object' || value === null) { + return; + } + const candidate = value as Record; + const expiresAt = Number(candidate.expiresAt); + const tournamentSlug = String(candidate.tournamentSlug || '').trim(); + if (!Number.isFinite(expiresAt) || expiresAt <= 0 || !tournamentSlug) { + return; + } + result[playerId] = { + expiresAt, + tournamentSlug, + }; + }); + + return result; + } catch { + return {}; + } +}; + const tournamentOptions = computed(() => recentTournaments.value.map((tournament) => ({ label: tournament.name, @@ -276,22 +317,31 @@ const clearTemporaryCleanupTimer = () => { const cleanupExpiredTemporaryPlayers = () => { const now = Math.floor(Date.now() / 1000); - const expiredIds = Object.entries(temporaryStartGGPlayers.value) + const expiredStartGGIds = Object.entries(temporaryStartGGPlayers.value) .filter(([, meta]) => meta.expiresAt <= now) .map(([playerId]) => playerId); + const expiredChallongeIds = Object.entries(temporaryChallongePlayers.value) + .filter(([, meta]) => meta.expiresAt <= now) + .map(([playerId]) => playerId); + + const expiredIds = Array.from(new Set([...expiredStartGGIds, ...expiredChallongeIds])); if (!expiredIds.length) { return; } - const nextMeta = { ...temporaryStartGGPlayers.value }; + const nextStartGGMeta = { ...temporaryStartGGPlayers.value }; + const nextChallongeMeta = { ...temporaryChallongePlayers.value }; expiredIds.forEach((playerId) => { playersStore.removePlayer(playerId); - delete nextMeta[playerId]; + delete nextStartGGMeta[playerId]; + delete nextChallongeMeta[playerId]; }); - temporaryStartGGPlayers.value = nextMeta; + temporaryStartGGPlayers.value = nextStartGGMeta; + temporaryChallongePlayers.value = nextChallongeMeta; persistTemporaryStartGGPlayers(); + persistTemporaryChallongePlayers(); }; const checkOAuthStatus = async () => { @@ -569,6 +619,11 @@ const importSelectedChallongePlayers = () => { selectedChallongePlayerIds.value.includes(player.id), ); + const nextMeta = { ...temporaryChallongePlayers.value }; + const tournament = importingChallongeTournament.value; + const fallbackEndAt = (tournament?.startAt ?? Math.floor(Date.now() / 1000)) + STARTGG_TEMP_FALLBACK_DURATION_SECONDS; + const expiresAt = tournament?.endAt ?? fallbackEndAt; + selectedPlayers.forEach((player) => { playersStore.upsertPlayer(player.id, { gamertag: player.gamertag, @@ -577,8 +632,17 @@ const importSelectedChallongePlayers = () => { country: player.country, twitter: player.twitter, }); + + if (tournament) { + nextMeta[player.id] = { + expiresAt, + tournamentSlug: tournament.slug, + }; + } }); + temporaryChallongePlayers.value = nextMeta; + persistTemporaryChallongePlayers(); challongeImportDialogOpen.value = false; }; @@ -718,6 +782,7 @@ const handleImport = async (event: Event) => { onMounted(() => { temporaryStartGGPlayers.value = loadTemporaryStartGGPlayers(); + temporaryChallongePlayers.value = loadTemporaryChallongePlayers(); cleanupExpiredTemporaryPlayers(); temporaryCleanupTimer = setInterval(cleanupExpiredTemporaryPlayers, 60 * 1000); From 132045bb68148d33e8ae430330f8e92f2e2daed3 Mon Sep 17 00:00:00 2001 From: Pandipipas <62224708+Pandipipas@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:03:48 +0100 Subject: [PATCH 6/6] feat(players): stack Challonge card below StartGG and add logo icon --- src/dashboard/scoreko-dev/views/Players.vue | 433 ++++++++++---------- 1 file changed, 225 insertions(+), 208 deletions(-) diff --git a/src/dashboard/scoreko-dev/views/Players.vue b/src/dashboard/scoreko-dev/views/Players.vue index e34f784..1d95933 100644 --- a/src/dashboard/scoreko-dev/views/Players.vue +++ b/src/dashboard/scoreko-dev/views/Players.vue @@ -886,227 +886,232 @@ onBeforeUnmount(() => {
- -
- - StartGG -
-
- 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. -
-
-
- - -
-
- -
-
-
+ - {{ tournamentsError }} -
-
- -
- + + + + StartGG +
+
+ 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. +
+
+
+ + +
+
+ +
- - Import players - + {{ tournamentsError }}
-
-
-
+
+ +
+ + + +
+
+ + Import players + +
+
+
-
- -
- Challonge -
-
- Connect with OAuth or paste your personal token to load your Challonge tournaments and import participants. -
-
-
- - -
-
- -
-
-
- {{ challongeTournamentsError }} -
-
- -
- + Challonge - - + Challonge +
+
+ Connect with OAuth or paste your personal token to load your Challonge tournaments and import participants. +
+
+
+ + +
+
+ +
- - Import players - + {{ challongeTournamentsError }}
-
-
+
+ +
+ + + +
+
+ + Import players + +
+
+ +
@@ -1404,6 +1409,12 @@ onBeforeUnmount(() => { min-width: 320px; } +.players-integrations-stack { + display: flex; + flex-direction: column; + gap: 12px; +} + .players-dialog { min-width: 320px; @@ -1423,6 +1434,12 @@ onBeforeUnmount(() => { fill: #2e75ba; } +.challonge-heading__icon { + width: 20px; + height: 20px; + border-radius: 4px; +} + .startgg-tournament-row { gap: 4px; }