diff --git a/code/websites/pokedex.online/src/composables/useChallongeClientCredentials.js b/code/websites/pokedex.online/src/composables/useChallongeClientCredentials.js new file mode 100644 index 0000000..579afe6 --- /dev/null +++ b/code/websites/pokedex.online/src/composables/useChallongeClientCredentials.js @@ -0,0 +1,275 @@ +/** + * Challonge Client Credentials Flow Composable + * + * Manages client credentials OAuth flow for server-to-server authentication + * Used for APPLICATION scope access (application:manage) + * + * Features: + * - Client credentials token exchange + * - Automatic token refresh + * - Secure credential storage + * - Token expiration handling + * + * Usage: + * ```javascript + * import { useChallongeClientCredentials } from '@/composables/useChallongeClientCredentials' + * + * const { + * isAuthenticated, + * accessToken, + * authenticate, + * logout, + * saveCredentials + * } = useChallongeClientCredentials() + * + * // Save client credentials (one time) + * saveCredentials('your_client_id', 'your_client_secret') + * + * // Get access token (will auto-refresh if expired) + * await authenticate('application:manage tournaments:read tournaments:write') + * const token = accessToken.value + * ``` + */ + +import { ref, computed } from 'vue'; + +const CREDENTIALS_KEY = 'challonge_client_credentials'; +const TOKEN_KEY = 'challonge_client_token'; + +// Shared state across all instances +const credentials = ref(null); +const tokenData = ref(null); +const loading = ref(false); +const error = ref(null); + +// Load credentials and token from localStorage on module initialization +try { + const storedCreds = localStorage.getItem(CREDENTIALS_KEY); + if (storedCreds) { + credentials.value = JSON.parse(storedCreds); + } + + const storedToken = localStorage.getItem(TOKEN_KEY); + if (storedToken) { + tokenData.value = JSON.parse(storedToken); + + // Check if token is expired + if (tokenData.value.expires_at && Date.now() >= tokenData.value.expires_at) { + console.log('🔄 Client credentials token expired, will need to refresh'); + } + } +} catch (err) { + console.error('Failed to load client credentials:', err); +} + +export function useChallongeClientCredentials() { + const isAuthenticated = computed(() => { + return !!tokenData.value?.access_token && !isExpired.value; + }); + + const isExpired = computed(() => { + if (!tokenData.value?.expires_at) return true; + return Date.now() >= tokenData.value.expires_at; + }); + + const accessToken = computed(() => { + if (isExpired.value) return null; + return tokenData.value?.access_token || null; + }); + + const hasCredentials = computed(() => { + return !!(credentials.value?.client_id && credentials.value?.client_secret); + }); + + const maskedClientId = computed(() => { + if (!credentials.value?.client_id) return null; + const id = credentials.value.client_id; + if (id.length < 12) return id.slice(0, 4) + '••••'; + return id.slice(0, 6) + '•••••••' + id.slice(-4); + }); + + /** + * Save client credentials to localStorage + * @param {string} clientId - OAuth client ID + * @param {string} clientSecret - OAuth client secret + * @returns {boolean} Success status + */ + function saveCredentials(clientId, clientSecret) { + try { + if (!clientId || !clientSecret) { + throw new Error('Client ID and secret are required'); + } + + credentials.value = { + client_id: clientId, + client_secret: clientSecret, + saved_at: new Date().toISOString() + }; + + localStorage.setItem(CREDENTIALS_KEY, JSON.stringify(credentials.value)); + console.log('✅ Client credentials saved'); + return true; + } catch (err) { + error.value = err.message; + console.error('Failed to save credentials:', err); + return false; + } + } + + /** + * Clear stored credentials and token + * @returns {boolean} Success status + */ + function clearCredentials() { + try { + credentials.value = null; + tokenData.value = null; + localStorage.removeItem(CREDENTIALS_KEY); + localStorage.removeItem(TOKEN_KEY); + console.log('✅ Client credentials cleared'); + return true; + } catch (err) { + error.value = err.message; + console.error('Failed to clear credentials:', err); + return false; + } + } + + /** + * Authenticate using client credentials flow + * @param {string} scope - Requested scope (e.g., 'application:manage') + * @returns {Promise} Access token + */ + async function authenticate(scope = 'application:manage') { + if (!hasCredentials.value) { + throw new Error( + 'Client credentials not configured. Use saveCredentials() first.' + ); + } + + // Return existing token if still valid + if (isAuthenticated.value && !isExpired.value) { + console.log('✅ Using existing valid token'); + return accessToken.value; + } + + loading.value = true; + error.value = null; + + try { + console.log('🔐 Requesting client credentials token...'); + console.log(' Client ID:', maskedClientId.value); + console.log(' Scope:', scope); + + const response = await fetch('https://api.challonge.com/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: credentials.value.client_id, + client_secret: credentials.value.client_secret, + scope: scope + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData.error_description || + errorData.error || + `Token request failed: ${response.status}` + ); + } + + const data = await response.json(); + + // Store token with expiration + tokenData.value = { + access_token: data.access_token, + token_type: data.token_type, + scope: data.scope, + created_at: Date.now(), + expires_in: data.expires_in, + expires_at: Date.now() + data.expires_in * 1000 + }; + + // Save to localStorage + localStorage.setItem(TOKEN_KEY, JSON.stringify(tokenData.value)); + + console.log('✅ Client credentials token obtained'); + console.log(' Expires in:', data.expires_in, 'seconds'); + console.log(' Scope:', data.scope); + + return tokenData.value.access_token; + } catch (err) { + error.value = err.message; + console.error('❌ Client credentials authentication failed:', err); + throw err; + } finally { + loading.value = false; + } + } + + /** + * Force token refresh + * @param {string} scope - Requested scope + * @returns {Promise} New access token + */ + async function refresh(scope = 'application:manage') { + // Clear existing token + tokenData.value = null; + localStorage.removeItem(TOKEN_KEY); + + // Get new token + return authenticate(scope); + } + + /** + * Logout and clear token (keeps credentials) + */ + function logout() { + tokenData.value = null; + localStorage.removeItem(TOKEN_KEY); + console.log('✅ Logged out (credentials retained)'); + } + + /** + * Get token info for debugging + */ + const tokenInfo = computed(() => { + if (!tokenData.value) return null; + + const now = Date.now(); + const expiresAt = tokenData.value.expires_at; + const timeUntilExpiry = expiresAt ? expiresAt - now : 0; + + return { + hasToken: !!tokenData.value.access_token, + isExpired: isExpired.value, + scope: tokenData.value.scope, + expiresIn: Math.floor(timeUntilExpiry / 1000), + expiresAt: expiresAt ? new Date(expiresAt).toLocaleString() : null + }; + }); + + return { + // State + isAuthenticated, + isExpired, + accessToken, + hasCredentials, + maskedClientId, + loading, + error, + tokenInfo, + + // Actions + saveCredentials, + clearCredentials, + authenticate, + refresh, + logout + }; +}