🎨 Improve code readability by reformatting and updating function definitions and comments
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* useChallongeApiKey Composable
|
||||
* Manages Challonge API key storage in browser localStorage
|
||||
* Works on mobile, desktop, and tablets
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const STORAGE_KEY = 'challonge_api_key';
|
||||
const storedKey = ref(getStoredKey());
|
||||
|
||||
/**
|
||||
* Get API key from localStorage
|
||||
* @returns {string|null} Stored API key or null
|
||||
*/
|
||||
function getStoredKey() {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) || null;
|
||||
} catch (error) {
|
||||
console.warn('localStorage not available:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save API key to localStorage
|
||||
* @param {string} apiKey - The API key to store
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
function saveApiKey(apiKey) {
|
||||
try {
|
||||
if (!apiKey || typeof apiKey !== 'string') {
|
||||
throw new Error('Invalid API key format');
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, apiKey);
|
||||
storedKey.value = apiKey;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to save API key:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear API key from localStorage
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
function clearApiKey() {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
storedKey.value = null;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to clear API key:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get masked version of API key for display
|
||||
* Shows first 4 and last 4 characters
|
||||
* @returns {string|null} Masked key or null
|
||||
*/
|
||||
const maskedKey = computed(() => {
|
||||
if (!storedKey.value) return null;
|
||||
const key = storedKey.value;
|
||||
if (key.length < 8) return '••••••••';
|
||||
return `${key.slice(0, 4)}•••••••${key.slice(-4)}`;
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if API key is stored
|
||||
* @returns {boolean} True if key exists
|
||||
*/
|
||||
const isKeyStored = computed(() => !!storedKey.value);
|
||||
|
||||
/**
|
||||
* Get the full API key (use with caution)
|
||||
* @returns {string|null} Full API key or null
|
||||
*/
|
||||
function getApiKey() {
|
||||
return storedKey.value;
|
||||
}
|
||||
|
||||
export function useChallongeApiKey() {
|
||||
return {
|
||||
saveApiKey,
|
||||
clearApiKey,
|
||||
getApiKey,
|
||||
getStoredKey,
|
||||
storedKey: computed(() => storedKey.value),
|
||||
maskedKey,
|
||||
isKeyStored
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Challonge OAuth Composable
|
||||
*
|
||||
* Manages OAuth authentication flow and token storage for Challonge API v2.1
|
||||
*
|
||||
* Features:
|
||||
* - Authorization URL generation
|
||||
* - Token exchange and storage
|
||||
* - Automatic token refresh
|
||||
* - Secure token management
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const STORAGE_KEY = 'challonge_oauth_tokens';
|
||||
const CLIENT_ID = import.meta.env.VITE_CHALLONGE_CLIENT_ID;
|
||||
const REDIRECT_URI =
|
||||
import.meta.env.VITE_CHALLONGE_REDIRECT_URI ||
|
||||
`${window.location.origin}/oauth/callback`;
|
||||
|
||||
// Shared state across all instances
|
||||
const tokens = ref(null);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
// Load tokens from localStorage on module initialization
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
tokens.value = JSON.parse(stored);
|
||||
|
||||
// Check if token is expired
|
||||
if (tokens.value.expires_at && Date.now() >= tokens.value.expires_at) {
|
||||
console.log('🔄 Token expired, will need to refresh');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load OAuth tokens:', err);
|
||||
}
|
||||
|
||||
export function useChallongeOAuth() {
|
||||
const isAuthenticated = computed(() => {
|
||||
return !!tokens.value?.access_token;
|
||||
});
|
||||
|
||||
const isExpired = computed(() => {
|
||||
if (!tokens.value?.expires_at) return false;
|
||||
return Date.now() >= tokens.value.expires_at;
|
||||
});
|
||||
|
||||
const accessToken = computed(() => {
|
||||
return tokens.value?.access_token || null;
|
||||
});
|
||||
|
||||
/**
|
||||
* Generate authorization URL for OAuth flow
|
||||
* @param {string} scope - Requested scope (default: 'tournaments:read tournaments:write')
|
||||
* @param {string} state - Optional state parameter (will be generated if not provided)
|
||||
* @returns {Object} Object with authUrl and state
|
||||
*/
|
||||
function getAuthorizationUrl(
|
||||
scope = 'tournaments:read tournaments:write',
|
||||
state = null
|
||||
) {
|
||||
if (!CLIENT_ID) {
|
||||
throw new Error('VITE_CHALLONGE_CLIENT_ID not configured');
|
||||
}
|
||||
|
||||
// Generate state if not provided
|
||||
const oauthState = state || generateState();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: scope,
|
||||
state: oauthState
|
||||
});
|
||||
|
||||
return {
|
||||
authUrl: `https://api.challonge.com/oauth/authorize?${params.toString()}`,
|
||||
state: oauthState
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start OAuth authorization flow
|
||||
* @param {string} scope - Requested scope
|
||||
*/
|
||||
function login(scope) {
|
||||
try {
|
||||
// Generate auth URL and state
|
||||
const { authUrl, state } = getAuthorizationUrl(scope);
|
||||
|
||||
// Store state for CSRF protection
|
||||
sessionStorage.setItem('oauth_state', state);
|
||||
|
||||
console.log('🔐 Starting OAuth flow with state:', state);
|
||||
|
||||
// Redirect to Challonge authorization page
|
||||
window.location.href = authUrl;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
console.error('OAuth login error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access token
|
||||
* @param {string} code - Authorization code from callback
|
||||
* @param {string} state - State parameter for CSRF protection
|
||||
*/
|
||||
async function exchangeCode(code, state) {
|
||||
// Verify state parameter
|
||||
const storedState = sessionStorage.getItem('oauth_state');
|
||||
|
||||
console.log('🔐 OAuth callback verification:');
|
||||
console.log(' Received state:', state);
|
||||
console.log(' Stored state:', storedState);
|
||||
console.log(' Match:', state === storedState);
|
||||
|
||||
if (state !== storedState) {
|
||||
console.error(
|
||||
'❌ State mismatch! Possible CSRF attack or session issue.'
|
||||
);
|
||||
throw new Error('Invalid state parameter - possible CSRF attack');
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData.error_description ||
|
||||
errorData.error ||
|
||||
'Token exchange failed'
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Calculate expiration time
|
||||
const expiresAt = Date.now() + data.expires_in * 1000;
|
||||
|
||||
tokens.value = {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
token_type: data.token_type,
|
||||
expires_in: data.expires_in,
|
||||
expires_at: expiresAt,
|
||||
scope: data.scope,
|
||||
created_at: Date.now()
|
||||
};
|
||||
|
||||
// Store tokens
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens.value));
|
||||
sessionStorage.removeItem('oauth_state');
|
||||
|
||||
console.log('✅ OAuth authentication successful');
|
||||
return tokens.value;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
console.error('Token exchange error:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
async function refreshToken() {
|
||||
if (!tokens.value?.refresh_token) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/oauth/refresh', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refresh_token: tokens.value.refresh_token
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData.error_description ||
|
||||
errorData.error ||
|
||||
'Token refresh failed'
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Calculate expiration time
|
||||
const expiresAt = Date.now() + data.expires_in * 1000;
|
||||
|
||||
tokens.value = {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token || tokens.value.refresh_token, // Keep old if not provided
|
||||
token_type: data.token_type,
|
||||
expires_in: data.expires_in,
|
||||
expires_at: expiresAt,
|
||||
scope: data.scope,
|
||||
refreshed_at: Date.now()
|
||||
};
|
||||
|
||||
// Store updated tokens
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens.value));
|
||||
|
||||
console.log('✅ Token refreshed successfully');
|
||||
return tokens.value;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
console.error('Token refresh error:', err);
|
||||
|
||||
// If refresh fails, clear tokens and force re-authentication
|
||||
logout();
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get valid access token (refreshes if expired)
|
||||
*/
|
||||
async function getValidToken() {
|
||||
if (!tokens.value) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
// If token is expired or about to expire (within 5 minutes), refresh it
|
||||
const expiresIn = tokens.value.expires_at - Date.now();
|
||||
const fiveMinutes = 5 * 60 * 1000;
|
||||
|
||||
if (expiresIn < fiveMinutes) {
|
||||
console.log('🔄 Token expired or expiring soon, refreshing...');
|
||||
await refreshToken();
|
||||
}
|
||||
|
||||
return tokens.value.access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear tokens
|
||||
*/
|
||||
function logout() {
|
||||
tokens.value = null;
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
sessionStorage.removeItem('oauth_state');
|
||||
console.log('👋 Logged out');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random state for CSRF protection
|
||||
*/
|
||||
function generateState() {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
tokens: computed(() => tokens.value),
|
||||
isAuthenticated,
|
||||
isExpired,
|
||||
accessToken,
|
||||
loading: computed(() => loading.value),
|
||||
error: computed(() => error.value),
|
||||
|
||||
// Methods
|
||||
login,
|
||||
logout,
|
||||
exchangeCode,
|
||||
refreshToken,
|
||||
getValidToken,
|
||||
getAuthorizationUrl
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user