UNPKG

@vizzly-testing/cli

Version:

Visual review platform for UI developers and designers

226 lines (213 loc) 6.66 kB
/** * Authentication Service for Vizzly CLI * Handles authentication flows with the Vizzly API */ import { AuthError, VizzlyError } from '../errors/vizzly-error.js'; import { getApiUrl } from '../utils/environment-config.js'; import { getPackageVersion } from '../utils/package-info.js'; import { saveAuthTokens, clearAuthTokens, getAuthTokens } from '../utils/global-config.js'; /** * AuthService class for CLI authentication */ export class AuthService { constructor(options = {}) { this.baseUrl = options.baseUrl || getApiUrl(); this.userAgent = `vizzly-cli/${getPackageVersion()} (auth)`; } /** * Make an unauthenticated API request * @param {string} endpoint - API endpoint * @param {Object} options - Fetch options * @returns {Promise<Object>} Response data */ async request(endpoint, options = {}) { let url = `${this.baseUrl}${endpoint}`; let headers = { 'User-Agent': this.userAgent, ...options.headers }; let response = await fetch(url, { ...options, headers }); if (!response.ok) { let errorText = ''; let errorData = null; try { let contentType = response.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { errorData = await response.json(); errorText = errorData.error || errorData.message || ''; } else { errorText = await response.text(); } } catch { errorText = response.statusText || ''; } if (response.status === 401) { throw new AuthError(errorText || 'Invalid credentials. Please check your email/username and password.'); } if (response.status === 429) { throw new VizzlyError('Too many login attempts. Please try again later.', 'RATE_LIMIT_ERROR'); } throw new VizzlyError(`Authentication request failed: ${response.status}${errorText ? ` - ${errorText}` : ''}`, 'AUTH_REQUEST_ERROR'); } return response.json(); } /** * Make an authenticated API request * @param {string} endpoint - API endpoint * @param {Object} options - Fetch options * @returns {Promise<Object>} Response data */ async authenticatedRequest(endpoint, options = {}) { let auth = await getAuthTokens(); if (!auth || !auth.accessToken) { throw new AuthError('No authentication token found. Please run "vizzly login" first.'); } let url = `${this.baseUrl}${endpoint}`; let headers = { 'User-Agent': this.userAgent, Authorization: `Bearer ${auth.accessToken}`, ...options.headers }; let response = await fetch(url, { ...options, headers }); if (!response.ok) { let errorText = ''; try { let contentType = response.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { let errorData = await response.json(); errorText = errorData.error || errorData.message || ''; } else { errorText = await response.text(); } } catch { errorText = response.statusText || ''; } if (response.status === 401) { throw new AuthError('Authentication token is invalid or expired. Please run "vizzly login" again.'); } throw new VizzlyError(`API request failed: ${response.status}${errorText ? ` - ${errorText}` : ''}`, 'API_REQUEST_ERROR'); } return response.json(); } /** * Initiate OAuth device flow * @returns {Promise<Object>} Device code, user code, verification URL */ async initiateDeviceFlow() { return this.request('/api/auth/cli/device/initiate', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); } /** * Poll for device authorization * @param {string} deviceCode - Device code from initiate * @returns {Promise<Object>} Token data or pending status */ async pollDeviceAuthorization(deviceCode) { return this.request('/api/auth/cli/device/poll', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ device_code: deviceCode }) }); } /** * Complete device flow and save tokens * @param {Object} tokenData - Token response from poll * @returns {Promise<Object>} Token data with user info */ async completeDeviceFlow(tokenData) { // Save tokens to global config await saveAuthTokens({ accessToken: tokenData.accessToken, refreshToken: tokenData.refreshToken, expiresAt: tokenData.expiresAt, user: tokenData.user }); return tokenData; } /** * Refresh access token using refresh token * @returns {Promise<Object>} New tokens */ async refresh() { let auth = await getAuthTokens(); if (!auth || !auth.refreshToken) { throw new AuthError('No refresh token found. Please run "vizzly login" first.'); } let response = await this.request('/api/auth/cli/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: auth.refreshToken }) }); // Update tokens in global config await saveAuthTokens({ accessToken: response.accessToken, refreshToken: response.refreshToken, expiresAt: response.expiresAt, user: auth.user // Keep existing user data }); return response; } /** * Logout and revoke tokens * @returns {Promise<void>} */ async logout() { let auth = await getAuthTokens(); if (auth && auth.refreshToken) { try { // Attempt to revoke tokens on server await this.request('/api/auth/cli/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: auth.refreshToken }) }); } catch (error) { // If server request fails, still clear local tokens console.warn('Warning: Failed to revoke tokens on server:', error.message); } } // Clear tokens from global config await clearAuthTokens(); } /** * Get current user information * @returns {Promise<Object>} User and organization data */ async whoami() { return this.authenticatedRequest('/api/auth/cli/whoami'); } /** * Check if user is authenticated * @returns {Promise<boolean>} True if authenticated */ async isAuthenticated() { try { await this.whoami(); return true; } catch { return false; } } }