diff --git a/jest.config.ts b/jest.config.ts index 9da79b3..cff26a4 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -92,6 +92,7 @@ const config: Config = { '^@/hooks/(.*)$': '/src/hooks/$1', '^@/stores/(.*)$': '/src/stores/$1', '^@/pages/(.*)$': '/src/pages/$1', + '^@/services/(.*)$': '/src/services/$1', }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader diff --git a/migrate-docs/AUTHENTICATION_FLOW_REVISED.md b/migrate-docs/AUTHENTICATION_FLOW_REVISED.md new file mode 100644 index 0000000..c99c281 --- /dev/null +++ b/migrate-docs/AUTHENTICATION_FLOW_REVISED.md @@ -0,0 +1,613 @@ +# Authentication Flow - Revised (Post-Refactoring) + +## Overview + +This document describes the **revised authentication flow** after the refactoring that removed `useOauth2`, `whoami.service`, and `logout.service`. The new flow is simplified and more maintainable. + +**Last Updated:** 2026-02-04 +**Related PR:** Authentication & Logout Refactoring + +--- + +## Table of Contents + +1. [Architecture Changes](#architecture-changes) +2. [OAuth2 Authentication Flow](#oauth2-authentication-flow) +3. [Token Management](#token-management) +4. [Logout Flow](#logout-flow) +5. [Invalid Token Handling](#invalid-token-handling) +6. [Key Components](#key-components) +7. [Error Handling](#error-handling) + +--- + +## Architecture Changes + +### What Was Removed + +- ❌ `src/hooks/auth/useOauth2.ts` - Complex OAuth hook +- ❌ `src/services/whoami.service.ts` - Redundant service +- ❌ `src/services/logout.service.ts` - Redundant service +- ❌ 763 lines of code total + +### What Was Added/Modified + +- ✅ [`src/hooks/useLogout.ts`](../src/hooks/useLogout.ts:1) - Simplified logout hook +- ✅ [`src/hooks/useInvalidTokenHandler.ts`](../src/hooks/useInvalidTokenHandler.ts:1) - Invalid token handler +- ✅ [`src/services/oauth-token-exchange.service.ts`](../src/services/oauth-token-exchange.service.ts:1) - Enhanced with auto-initialization +- ✅ [`src/stores/client-store.ts`](../src/stores/client-store.ts:243) - Updated logout method + +--- + +## OAuth2 Authentication Flow + +### 1. Initial Authentication + +``` +User clicks "Login" + ↓ +Generate OAuth URL with PKCE + ↓ +Redirect to OAuth provider + ↓ +User authenticates + ↓ +Redirect to callback with authorization code + ↓ +Exchange code for access token + ↓ +Fetch accounts and initialize WebSocket + ↓ +User is authenticated +``` + +### 2. Token Exchange Process + +**File:** [`src/services/oauth-token-exchange.service.ts`](../src/services/oauth-token-exchange.service.ts:96) + +```typescript +static async exchangeCodeForToken(code: string): Promise { + // 1. Get code verifier from sessionStorage + const codeVerifier = getCodeVerifier(); + + // 2. Exchange authorization code for access token + const response = await fetch(tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + client_id: clientId, + }), + }); + + // 3. Store auth info in sessionStorage + const authInfo = { + access_token: data.access_token, + token_type: data.token_type, + expires_in: data.expires_in, + expires_at: Date.now() + (data.expires_in || 3600) * 1000, + scope: data.scope, + refresh_token: data.refresh_token, + }; + sessionStorage.setItem('auth_info', JSON.stringify(authInfo)); + + // 4. Automatically fetch accounts and initialize WebSocket + const accounts = await DerivWSAccountsService.fetchAccountsList(data.access_token); + + if (accounts && accounts.length > 0) { + // Store accounts + DerivWSAccountsService.storeAccounts(accounts); + + // Set first account as active + const firstAccount = accounts[0]; + localStorage.setItem('active_loginid', firstAccount.account_id); + + // Set account type (demo/real) + const isDemo = firstAccount.account_id.startsWith('VRT') || + firstAccount.account_id.startsWith('VRTC'); + localStorage.setItem('account_type', isDemo ? 'demo' : 'real'); + + // Initialize WebSocket with the account + const { api_base } = await import('@/external/bot-skeleton'); + await api_base.init(true); + } else { + // Error: No accounts available + return { + error: 'no_accounts', + error_description: 'No accounts available after successful authentication', + }; + } + + return data; +} +``` + +### 3. Session Storage Structure + +**sessionStorage:** + +```json +{ + "auth_info": { + "access_token": "a1-xxx", + "token_type": "Bearer", + "expires_in": 3600, + "expires_at": 1738659600000, + "scope": "read trade", + "refresh_token": "r1-xxx" + } +} +``` + +**localStorage:** + +```json +{ + "active_loginid": "CR1234567", + "account_type": "real", + "accountsList": "[{...}]", + "clientAccounts": "{...}", + "authToken": "a1-xxx" +} +``` + +--- + +## Token Management + +### Access Token Retrieval + +```typescript +// Get current access token +const token = OAuthTokenExchangeService.getAccessToken(); + +// Check if authenticated +const isAuth = OAuthTokenExchangeService.isAuthenticated(); +``` + +### Token Expiration + +Tokens are automatically checked for expiration: + +```typescript +static getAuthInfo(): AuthInfo | null { + const authInfo = JSON.parse(sessionStorage.getItem('auth_info')); + + // Check if token is expired + if (authInfo.expires_at && Date.now() >= authInfo.expires_at) { + this.clearAuthInfo(); + return null; + } + + return authInfo; +} +``` + +--- + +## Logout Flow + +### 1. User-Initiated Logout + +**File:** [`src/hooks/useLogout.ts`](../src/hooks/useLogout.ts:1) + +```typescript +export const useLogout = () => { + const { client } = useStore() ?? {}; + + return useCallback(async () => { + try { + // Call client store logout method + await client?.logout(); + + // Note: Analytics.reset() removed - Analytics package removed from project + // See migrate-docs/MONITORING_PACKAGES.md for re-enabling if needed + } catch (error) { + console.error('Logout failed:', error); + + // Fallback: Clear only auth-related storage keys + // This preserves user preferences (theme, language, etc.) + try { + // Clear auth-related sessionStorage + sessionStorage.removeItem('auth_info'); + + // Clear auth-related localStorage + localStorage.removeItem('active_loginid'); + localStorage.removeItem('authToken'); + localStorage.removeItem('accountsList'); + localStorage.removeItem('clientAccounts'); + localStorage.removeItem('account_type'); + } catch (storageError) { + console.error('Failed to clear auth storage:', storageError); + + // Last resort: clear all storage + try { + sessionStorage.clear(); + localStorage.clear(); + } catch (finalError) { + console.error('Failed to clear all storage:', finalError); + } + } + } + }, [client]); +}; +``` + +### 2. ClientStore Logout Method + +**File:** [`src/stores/client-store.ts`](../src/stores/client-store.ts:243) + +```typescript +logout = async () => { + if (localStorage.getItem('active_loginid')) { + // 1. Clear DerivAPI singleton and close WebSocket + const { clearDerivApiInstance } = await import('@/external/bot-skeleton/services/api/appId'); + clearDerivApiInstance(); + + // 2. Clear accounts cache + const { DerivWSAccountsService } = await import('@/services/derivws-accounts.service'); + DerivWSAccountsService.clearStoredAccounts(); + DerivWSAccountsService.clearCache(); + + // 3. Clear OAuth token + const { OAuthTokenExchangeService } = await import('@/services/oauth-token-exchange.service'); + OAuthTokenExchangeService.clearAuthInfo(); + + // 4. Reset all states + this.account_list = []; + this.accounts = {}; + this.is_logged_in = false; + this.loginid = ''; + this.balance = '0'; + this.currency = 'USD'; + this.all_accounts_balance = null; + + // 5. Clear storage + localStorage.removeItem('active_loginid'); + localStorage.removeItem('accountsList'); + localStorage.removeItem('authToken'); + localStorage.removeItem('clientAccounts'); + localStorage.removeItem('account_type'); + sessionStorage.clear(); + + // 6. Clear cookies + removeCookies('client_information'); + + // 7. Reset observables + setIsAuthorized(false); + setAccountList([]); + setAuthData(null); + + // 8. Disable live chat + window.LC_API?.close_chat?.(); + window.LiveChatWidget?.call('hide'); + + // 9. Shutdown Intercom + if (window.Intercom) { + window.Intercom('shutdown'); + window.DerivInterCom.initialize({ + hideLauncher: true, + token: null, + }); + } + } +}; +``` + +### 3. Auth Error Logout + +**File:** [`src/app/CoreStoreProvider.tsx`](../src/app/CoreStoreProvider.tsx:115) + +```typescript +// Handle auth errors by calling client.logout() directly +// This prevents redundant logout operations since useLogout internally calls client.logout() +if (error?.code === 'AuthorizationRequired' || error?.code === 'DisabledClient' || error?.code === 'InvalidToken') { + // Clear all URL query parameters for these auth errors + clearInvalidTokenParams(); + + // Call client store logout directly to avoid double logout + await client?.logout(); +} +``` + +--- + +## Invalid Token Handling + +### Hook Implementation + +**File:** [`src/hooks/useInvalidTokenHandler.ts`](../src/hooks/useInvalidTokenHandler.ts:1) + +```typescript +const handleInvalidToken = async () => { + try { + // 1. Clear invalid session data to prevent infinite reload loop + sessionStorage.removeItem('auth_info'); + localStorage.removeItem('active_loginid'); + localStorage.removeItem('authToken'); + localStorage.removeItem('accountsList'); + localStorage.removeItem('clientAccounts'); + + // 2. Clear sessionStorage completely + sessionStorage.clear(); + + // 3. Redirect to OAuth login instead of reload + const { generateOAuthURL } = await import('@/components/shared'); + const oauthUrl = await generateOAuthURL(); + + if (oauthUrl) { + // Use replace to prevent back button from returning to invalid state + window.location.replace(oauthUrl); + } else { + // Fallback: reload if OAuth URL generation fails + console.error('Failed to generate OAuth URL, falling back to reload'); + window.location.reload(); + } + } catch (error) { + console.error('Error handling invalid token:', error); + // Last resort: reload the page + window.location.reload(); + } +}; +``` + +### Why This Approach? + +**Previous Implementation (❌ Caused Infinite Loop):** + +```typescript +// Old code - WRONG +const handleInvalidToken = () => { + window.location.reload(); // ❌ Infinite loop if token is truly invalid +}; +``` + +**New Implementation (✅ Correct):** + +1. Clears invalid auth data +2. Redirects to OAuth login for fresh authentication +3. Uses `window.location.replace()` to prevent back button issues +4. Has fallback error handling + +--- + +## Key Components + +### 1. OAuthTokenExchangeService + +**Location:** [`src/services/oauth-token-exchange.service.ts`](../src/services/oauth-token-exchange.service.ts:1) + +**Responsibilities:** + +- Exchange authorization code for access token +- Store and retrieve auth info from sessionStorage +- Check token expiration +- Refresh access tokens +- Auto-initialize WebSocket after token exchange + +**Key Methods:** + +- `exchangeCodeForToken(code: string)` - Exchange code for token +- `getAuthInfo()` - Get stored auth info +- `getAccessToken()` - Get current access token +- `isAuthenticated()` - Check if user is authenticated +- `clearAuthInfo()` - Clear auth info from storage + +### 2. useLogout Hook + +**Location:** [`src/hooks/useLogout.ts`](../src/hooks/useLogout.ts:1) + +**Responsibilities:** + +- Provide logout functionality to components +- Handle logout errors with fallback storage clearing +- Preserve user preferences during error scenarios + +**Usage:** + +```typescript +const handleLogout = useLogout(); + +// In component + +``` + +### 3. useInvalidTokenHandler Hook + +**Location:** [`src/hooks/useInvalidTokenHandler.ts`](../src/hooks/useInvalidTokenHandler.ts:1) + +**Responsibilities:** + +- Listen for 'InvalidToken' events from API +- Clear invalid auth data +- Redirect to OAuth login + +**Usage:** + +```typescript +// In component +useInvalidTokenHandler(); +``` + +### 4. ClientStore + +**Location:** [`src/stores/client-store.ts`](../src/stores/client-store.ts:1) + +**Responsibilities:** + +- Manage client state (accounts, balance, currency) +- Handle logout operations +- Clear all auth-related data +- Reset observables and external services + +--- + +## Error Handling + +### 1. Token Exchange Errors + +```typescript +// Error response structure +{ + error: 'no_accounts' | 'account_fetch_failed' | 'network_error', + error_description: 'Detailed error message' +} +``` + +**Handled Errors:** + +- `no_accounts` - No accounts returned after authentication +- `account_fetch_failed` - Failed to fetch accounts from API +- `network_error` - Network or parsing error during token exchange + +### 2. Logout Errors + +**Three-tier fallback:** + +1. **Primary:** Call `client.logout()` +2. **Fallback 1:** Clear only auth-related storage keys +3. **Fallback 2:** Clear all storage (last resort) + +```typescript +try { + await client?.logout(); +} catch (error) { + try { + // Clear only auth keys + sessionStorage.removeItem('auth_info'); + localStorage.removeItem('active_loginid'); + // ... other auth keys + } catch (storageError) { + try { + // Last resort: clear everything + sessionStorage.clear(); + localStorage.clear(); + } catch (finalError) { + console.error('Failed to clear all storage:', finalError); + } + } +} +``` + +### 3. Invalid Token Errors + +**Handled by:** [`useInvalidTokenHandler`](../src/hooks/useInvalidTokenHandler.ts:1) + +**Flow:** + +1. Detect 'InvalidToken' event +2. Clear invalid auth data +3. Redirect to OAuth login +4. Fallback to reload if OAuth URL generation fails + +--- + +## Security Considerations + +### ✅ Security Features Maintained + +1. **OAuth PKCE Flow** - Code verifier/challenge mechanism intact +2. **Token Storage** - Access tokens stored in sessionStorage (cleared on tab close) +3. **Token Expiration** - Automatic expiration checking +4. **Secure Logout** - Complete cleanup of auth data +5. **No XSS Vulnerabilities** - Proper token handling + +### ⚠️ Security Notes + +1. **sessionStorage vs localStorage:** + - `sessionStorage` - Used for access tokens (more secure, cleared on tab close) + - `localStorage` - Used for account info (persists across tabs) + +2. **Token Refresh:** + - Refresh tokens stored in sessionStorage + - Automatic refresh not yet implemented (future enhancement) + +--- + +## Comparison: Old vs New Flow + +### Old Flow (Before Refactoring) + +``` +Login → useOauth2 → whoami.service → logout.service → Complex state management +``` + +**Issues:** + +- 763 lines of code +- Multiple redundant services +- Complex state synchronization +- Difficult to maintain + +### New Flow (After Refactoring) + +``` +Login → OAuthTokenExchangeService → useLogout → ClientStore → Simple state management +``` + +**Benefits:** + +- -763 lines of code +- Single responsibility per component +- Simplified state management +- Easy to maintain and test + +--- + +## Testing Recommendations + +### Unit Tests Needed + +1. **useLogout.spec.ts** + - Test successful logout + - Test logout error handling + - Test storage clearing fallbacks + +2. **useInvalidTokenHandler.spec.ts** + - Test invalid token detection + - Test OAuth redirect + - Test fallback reload + +3. **oauth-token-exchange.service.spec.ts** + - Test token exchange + - Test token expiration + - Test error handling + +### Integration Tests Needed + +1. **Full authentication flow** +2. **Logout from different states** +3. **Invalid token recovery** + +--- + +## Related Documentation + +- [PKCE Implementation](./PKCE_IMPLEMENTATION.md) +- [WebSocket Connection Flow](./WEBSOCKET_CONNECTION_FLOW.md) +- [Monitoring Packages](./MONITORING_PACKAGES.md) +- [Original Authentication Flow](./AUTHENTICATION_FLOW.md) (deprecated) + +--- + +## Changelog + +### 2026-02-04 - Initial Revised Documentation + +- Documented new simplified authentication flow +- Removed references to deleted services +- Added error handling documentation +- Added security considerations + +--- + +## Future Enhancements + +1. **Automatic Token Refresh** - Implement refresh token flow +2. **Centralized Error Logging** - Add error reporting service (Sentry, TrackJS) +3. **Test Coverage** - Add comprehensive unit and integration tests +4. **Session Timeout** - Add automatic logout on inactivity +5. **Multi-tab Synchronization** - Sync logout across tabs diff --git a/docs/DERIVWS_AUTHENTICATED_WEBSOCKET_FLOW.md b/migrate-docs/DERIVWS_AUTHENTICATED_WEBSOCKET_FLOW.md similarity index 100% rename from docs/DERIVWS_AUTHENTICATED_WEBSOCKET_FLOW.md rename to migrate-docs/DERIVWS_AUTHENTICATED_WEBSOCKET_FLOW.md diff --git a/migrate-docs/ERROR_LOGGING_GUIDE.md b/migrate-docs/ERROR_LOGGING_GUIDE.md new file mode 100644 index 0000000..770b304 --- /dev/null +++ b/migrate-docs/ERROR_LOGGING_GUIDE.md @@ -0,0 +1,499 @@ +# Error Logging Guide + +## Overview + +This guide documents the centralized error logging utility implemented to standardize error handling across the DBot application. The utility provides a consistent interface for logging errors, warnings, and info messages, and can be easily extended to integrate with external error reporting services. + +**Created:** 2026-02-04 +**Related Issue:** Issue #8 - Inconsistent Error Logging + +--- + +## Table of Contents + +1. [Why Centralized Error Logging?](#why-centralized-error-logging) +2. [ErrorLogger Utility](#errorlogger-utility) +3. [Usage Examples](#usage-examples) +4. [Integration with Error Reporting Services](#integration-with-error-reporting-services) +5. [Migration Guide](#migration-guide) +6. [Configuration](#configuration) + +--- + +## Why Centralized Error Logging? + +### Problems with Previous Approach + +Before implementing the centralized error logger, the codebase had **140+ inconsistent console.error/warn/log calls**: + +```typescript +// Inconsistent formats across files +console.error('[OAuth] Error parsing auth_info:', error); +console.error('Logout failed:', error); +console.error('WebSocket initialization failed:', initError); +``` + +**Issues:** +- ❌ Inconsistent message formatting +- ❌ No centralized control over logging +- ❌ Difficult to integrate with error reporting services +- ❌ Hard to filter/search logs +- ❌ No context metadata support + +### Benefits of Centralized Approach + +```typescript +// Consistent format with ErrorLogger +ErrorLogger.error('OAuth', 'Error parsing auth_info', error); +ErrorLogger.error('Logout', 'Logout failed', error); +ErrorLogger.error('ClientStore', 'WebSocket initialization failed', initError); +``` + +**Benefits:** +- ✅ Consistent message formatting with category prefix +- ✅ Centralized configuration and control +- ✅ Easy integration with Sentry, TrackJS, etc. +- ✅ Searchable by category +- ✅ Support for context metadata +- ✅ Can be disabled/filtered by log level + +--- + +## ErrorLogger Utility + +### Location + +[`src/utils/error-logger.ts`](../src/utils/error-logger.ts:1) + +### API Reference + +#### Log Levels + +```typescript +enum LogLevel { + ERROR = 'error', // Critical errors + WARN = 'warn', // Warnings + INFO = 'info', // Informational messages + DEBUG = 'debug', // Debug messages +} +``` + +#### Methods + +##### `error(category: string, message: string, data?: unknown): void` + +Log an error message. + +```typescript +ErrorLogger.error('OAuth', 'Token exchange failed', error); +ErrorLogger.error('Storage', 'Failed to clear cache', { key: 'auth_info' }); +``` + +##### `warn(category: string, message: string, data?: unknown): void` + +Log a warning message. + +```typescript +ErrorLogger.warn('API', 'Rate limit approaching', { remaining: 10 }); +ErrorLogger.warn('Storage', 'Cache miss', { key: 'user_preferences' }); +``` + +##### `info(category: string, message: string, data?: unknown): void` + +Log an informational message. + +```typescript +ErrorLogger.info('Auth', 'User logged in', { loginid: 'CR123' }); +ErrorLogger.info('OAuth', 'Accounts fetched successfully', { count: 3 }); +``` + +##### `debug(category: string, message: string, data?: unknown): void` + +Log a debug message. + +```typescript +ErrorLogger.debug('WebSocket', 'Connection state changed', { state: 'open' }); +``` + +##### `configure(config: Partial): void` + +Configure the error logger. + +```typescript +ErrorLogger.configure({ + enableConsole: true, + minLogLevel: LogLevel.INFO, + enableErrorReporting: false, +}); +``` + +##### `setErrorReportingService(service: ErrorReportingService): void` + +Set an external error reporting service. + +```typescript +ErrorLogger.setErrorReportingService(new SentryErrorReportingService()); +``` + +##### `setUserContext(userId: string, email?: string): void` + +Set user context for error reporting. + +```typescript +ErrorLogger.setUserContext('CR1234567', 'user@example.com'); +``` + +##### `clearUserContext(): void` + +Clear user context. + +```typescript +ErrorLogger.clearUserContext(); +``` + +--- + +## Usage Examples + +### Basic Error Logging + +```typescript +import { ErrorLogger } from '@/utils/error-logger'; + +try { + await someAsyncOperation(); +} catch (error) { + ErrorLogger.error('MyModule', 'Operation failed', error); +} +``` + +### With Context Metadata + +```typescript +ErrorLogger.error('OAuth', 'Token exchange failed', { + error: data.error, + description: data.error_description, + timestamp: Date.now(), +}); +``` + +### Warning Messages + +```typescript +if (accounts.length === 0) { + ErrorLogger.warn('OAuth', 'No accounts returned after token exchange'); +} +``` + +### Info Messages + +```typescript +ErrorLogger.info('Auth', 'User logged in successfully', { + loginid: firstAccount.account_id, + accountType: isDemo ? 'demo' : 'real', +}); +``` + +### Category Naming Convention + +Use clear, consistent category names: + +- **OAuth** - OAuth authentication operations +- **Logout** - Logout operations +- **InvalidToken** - Invalid token handling +- **ClientStore** - Client store operations +- **Storage** - Storage operations +- **API** - API calls +- **WebSocket** - WebSocket operations + +--- + +## Integration with Error Reporting Services + +### Sentry Integration + +```typescript +import * as Sentry from '@sentry/browser'; +import { ErrorLogger, ErrorReportingService, LogContext } from '@/utils/error-logger'; + +class SentryErrorReportingService implements ErrorReportingService { + reportError(error: Error, context?: LogContext): void { + Sentry.captureException(error, { + extra: context, + }); + } + + reportWarning(message: string, context?: LogContext): void { + Sentry.captureMessage(message, { + level: 'warning', + extra: context, + }); + } + + setUserContext(userId: string, email?: string): void { + Sentry.setUser({ + id: userId, + email, + }); + } + + clearUserContext(): void { + Sentry.setUser(null); + } +} + +// Initialize Sentry +Sentry.init({ + dsn: 'YOUR_SENTRY_DSN', + environment: process.env.NODE_ENV, + tracesSampleRate: 1.0, +}); + +// Configure ErrorLogger to use Sentry +ErrorLogger.setErrorReportingService(new SentryErrorReportingService()); +``` + +### TrackJS Integration + +```typescript +import { TrackJS } from 'trackjs'; +import { ErrorLogger, ErrorReportingService, LogContext } from '@/utils/error-logger'; + +class TrackJSErrorReportingService implements ErrorReportingService { + reportError(error: Error, context?: LogContext): void { + TrackJS.track(error); + if (context) { + TrackJS.addMetadata('context', context); + } + } + + reportWarning(message: string, context?: LogContext): void { + TrackJS.console.warn(message, context); + } + + setUserContext(userId: string, email?: string): void { + TrackJS.configure({ + userId, + metadata: { email }, + }); + } + + clearUserContext(): void { + TrackJS.configure({ + userId: undefined, + metadata: {}, + }); + } +} + +// Initialize TrackJS +TrackJS.install({ + token: 'YOUR_TRACKJS_TOKEN', + application: 'dbot', +}); + +// Configure ErrorLogger to use TrackJS +ErrorLogger.setErrorReportingService(new TrackJSErrorReportingService()); +``` + +--- + +## Migration Guide + +### Files Already Migrated + +The following authentication-related files have been migrated to use ErrorLogger: + +1. ✅ [`src/hooks/useLogout.ts`](../src/hooks/useLogout.ts:1) +2. ✅ [`src/hooks/useInvalidTokenHandler.ts`](../src/hooks/useInvalidTokenHandler.ts:1) +3. ✅ [`src/services/oauth-token-exchange.service.ts`](../src/services/oauth-token-exchange.service.ts:1) +4. ✅ [`src/stores/client-store.ts`](../src/stores/client-store.ts:1) + +### Migration Steps + +To migrate existing code to use ErrorLogger: + +#### Step 1: Import ErrorLogger + +```typescript +import { ErrorLogger } from '@/utils/error-logger'; +``` + +#### Step 2: Replace console.error calls + +**Before:** +```typescript +console.error('[OAuth] Token exchange failed:', error); +``` + +**After:** +```typescript +ErrorLogger.error('OAuth', 'Token exchange failed', error); +``` + +#### Step 3: Replace console.warn calls + +**Before:** +```typescript +console.warn('Failed to clear cache'); +``` + +**After:** +```typescript +ErrorLogger.warn('Storage', 'Failed to clear cache'); +``` + +#### Step 4: Replace console.log calls (for important info) + +**Before:** +```typescript +console.log('[OAuth] Accounts fetched and stored, active_loginid set:', firstAccount.account_id); +``` + +**After:** +```typescript +ErrorLogger.info('OAuth', 'Accounts fetched and stored', { + loginid: firstAccount.account_id, +}); +``` + +### Remaining Files to Migrate + +There are **136+ remaining console.error/warn/log calls** across the codebase that can be migrated to ErrorLogger. Priority files: + +1. `src/external/bot-skeleton/services/api/api-base.ts` - API operations +2. `src/services/derivws-accounts.service.ts` - Account service +3. `src/app/App.tsx` - Main app +4. `src/stores/*.ts` - Other stores +5. `src/utils/*.ts` - Utility functions + +--- + +## Configuration + +### Default Configuration + +```typescript +{ + enableConsole: true, + minLogLevel: LogLevel.INFO, + enableErrorReporting: false, + errorReportingService: undefined, +} +``` + +### Production Configuration Example + +```typescript +// In production, you might want to: +// 1. Reduce console logging +// 2. Enable error reporting +// 3. Set higher log level + +if (process.env.NODE_ENV === 'production') { + ErrorLogger.configure({ + enableConsole: false, // Disable console in production + minLogLevel: LogLevel.WARN, // Only log warnings and errors + enableErrorReporting: true, + }); + + // Set up Sentry or TrackJS + ErrorLogger.setErrorReportingService(new SentryErrorReportingService()); +} +``` + +### Development Configuration Example + +```typescript +// In development, you might want verbose logging +if (process.env.NODE_ENV === 'development') { + ErrorLogger.configure({ + enableConsole: true, + minLogLevel: LogLevel.DEBUG, // Log everything + enableErrorReporting: false, // Don't send to external service + }); +} +``` + +--- + +## Best Practices + +### 1. Use Descriptive Categories + +```typescript +// ✅ Good - Clear category +ErrorLogger.error('OAuth', 'Token exchange failed', error); + +// ❌ Bad - Vague category +ErrorLogger.error('Error', 'Something failed', error); +``` + +### 2. Include Context Data + +```typescript +// ✅ Good - Includes helpful context +ErrorLogger.error('API', 'Request failed', { + endpoint: '/api/authorize', + statusCode: 401, + error, +}); + +// ❌ Bad - No context +ErrorLogger.error('API', 'Request failed', error); +``` + +### 3. Use Appropriate Log Levels + +```typescript +// ✅ Good - Correct log levels +ErrorLogger.error('Auth', 'Login failed', error); // Critical +ErrorLogger.warn('Cache', 'Cache miss'); // Warning +ErrorLogger.info('Auth', 'User logged in'); // Info +ErrorLogger.debug('WebSocket', 'Ping sent'); // Debug + +// ❌ Bad - Everything as error +ErrorLogger.error('Auth', 'User logged in'); // Should be info +``` + +### 4. Don't Log Sensitive Data + +```typescript +// ✅ Good - No sensitive data +ErrorLogger.error('Auth', 'Login failed', { + loginid: 'CR123***', // Masked +}); + +// ❌ Bad - Logs sensitive data +ErrorLogger.error('Auth', 'Login failed', { + password: 'user_password', // Never log passwords! + token: 'a1-xxx', // Don't log full tokens +}); +``` + +--- + +## Future Enhancements + +1. **Automatic Error Grouping** - Group similar errors together +2. **Rate Limiting** - Prevent log spam +3. **Log Sampling** - Sample logs in high-traffic scenarios +4. **Performance Monitoring** - Track performance metrics +5. **Custom Error Tags** - Add custom tags for filtering + +--- + +## Related Documentation + +- [Authentication Flow Revised](./AUTHENTICATION_FLOW_REVISED.md) +- [Monitoring Packages](./MONITORING_PACKAGES.md) + +--- + +## Changelog + +### 2026-02-04 - Initial Implementation +- Created centralized ErrorLogger utility +- Migrated authentication-related files +- Added support for external error reporting services +- Documented usage and migration guide diff --git a/migrate-docs/MONITORING_PACKAGES.md b/migrate-docs/MONITORING_PACKAGES.md new file mode 100644 index 0000000..9cc4423 --- /dev/null +++ b/migrate-docs/MONITORING_PACKAGES.md @@ -0,0 +1,418 @@ +# Monitoring & Analytics Packages Configuration Guide + +This document provides instructions for third-party developers on how to configure optional monitoring and analytics packages that have been removed from the base application to reduce bundle size and dependencies. + +## Overview + +The following packages have been removed as they are not essential for core functionality: + +1. **@datadog/browser-rum** - Real User Monitoring (RUM) and session replay +2. **trackjs** - Error tracking and monitoring +3. **@deriv-com/analytics** - User behavior analytics (Rudderstack) + +These packages can be re-enabled on demand if you need monitoring, error tracking, or analytics capabilities. + +--- + +## 1. Datadog RUM (Real User Monitoring) + +### Purpose + +- Session replay and recording +- Performance monitoring +- User interaction tracking +- Resource and long task tracking + +### Installation + +```bash +npm install @datadog/browser-rum@^5.31.1 +``` + +### Configuration + +#### Step 1: Create the Datadog utility file + +Create `src/utils/datadog.ts`: + +```typescript +import { datadogRum } from '@datadog/browser-rum'; + +const getConfigValues = (is_production: boolean) => { + if (is_production) { + return { + service: 'your-app.domain.com', + version: `v${process.env.REF_NAME}`, + sessionReplaySampleRate: Number(process.env.DATADOG_SESSION_REPLAY_SAMPLE_RATE ?? 1), + sessionSampleRate: Number(process.env.DATADOG_SESSION_SAMPLE_RATE ?? 10), + env: 'production', + applicationId: process.env.DATADOG_APPLICATION_ID ?? '', + clientToken: process.env.DATADOG_CLIENT_TOKEN ?? '', + }; + } + return { + service: 'staging-your-app.domain.com', + version: `v${process.env.REF_NAME}`, + sessionReplaySampleRate: 0, + sessionSampleRate: 100, + env: 'staging', + applicationId: process.env.DATADOG_APPLICATION_ID ?? '', + clientToken: process.env.DATADOG_CLIENT_TOKEN ?? '', + }; +}; + +const initDatadog = (is_datadog_enabled: boolean) => { + if (!is_datadog_enabled) return; + if (process.env.APP_ENV === 'production' || process.env.APP_ENV === 'staging') { + const is_production = process.env.APP_ENV === 'production'; + const { + service, + version, + sessionReplaySampleRate, + sessionSampleRate, + env, + applicationId = '', + clientToken = '', + } = getConfigValues(is_production) ?? {}; + + datadogRum.init({ + service, + version, + sessionReplaySampleRate, + sessionSampleRate, + env, + applicationId, + clientToken, + site: 'datadoghq.com', + trackUserInteractions: true, + trackResources: true, + trackLongTasks: true, + defaultPrivacyLevel: 'mask-user-input', + enableExperimentalFeatures: ['clickmap'], + }); + } +}; + +export default initDatadog; +``` + +#### Step 2: Initialize in your app + +In `src/app/app-content.jsx`, add: + +```javascript +import initDatadog from '@/utils/datadog'; + +// Inside your component's useEffect: +useEffect(() => { + initDatadog(true); // Set to false to disable +}, []); +``` + +#### Step 3: Environment Variables + +Add to your `.env` file: + +```bash +DATADOG_APPLICATION_ID=your_application_id +DATADOG_CLIENT_TOKEN=your_client_token +DATADOG_SESSION_REPLAY_SAMPLE_RATE=1 +DATADOG_SESSION_SAMPLE_RATE=10 +``` + +### Getting Datadog Credentials + +1. Sign up at [Datadog](https://www.datadoghq.com/) +2. Navigate to **UX Monitoring** → **RUM Applications** +3. Create a new application +4. Copy the **Application ID** and **Client Token** + +--- + +## 2. TrackJS (Error Tracking) + +### Purpose + +- JavaScript error tracking +- User session tracking +- Error context and stack traces +- Production error monitoring + +### Installation + +```bash +npm install trackjs@^3.10.4 +``` + +### Configuration + +#### Step 1: Create the TrackJS hook + +Create `src/hooks/useTrackjs.ts`: + +```typescript +import { TrackJS } from 'trackjs'; + +const { TRACKJS_TOKEN } = process.env; + +/** + * Custom hook to initialize TrackJS. + * @returns {Object} An object containing the `init` function. + */ +const useTrackjs = () => { + const isProduction = process.env.APP_ENV === 'production'; + const trackjs_version = process.env.REF_NAME ?? 'undefined'; + + const initTrackJS = (loginid: string) => { + try { + if (!TrackJS.isInstalled()) { + TrackJS.install({ + application: 'your-application-name', + dedupe: false, + enabled: isProduction, + token: TRACKJS_TOKEN!, + userId: loginid, + version: + (document.querySelector('meta[name=version]') as HTMLMetaElement)?.content ?? trackjs_version, + }); + } + } catch (error) { + console.error('Failed to initialize TrackJS', error); + } + }; + + return { initTrackJS }; +}; + +export default useTrackjs; +``` + +#### Step 2: Initialize in your app + +In `src/app/app-content.jsx`: + +```javascript +import useTrackjs from '@/hooks/useTrackjs'; + +// Inside your component: +const { initTrackJS } = useTrackjs(); + +// Initialize with user login ID +useEffect(() => { + if (client?.loginid) { + initTrackJS(client.loginid); + } +}, [client?.loginid]); +``` + +#### Step 3: Environment Variables + +Add to your `.env` file: + +```bash +TRACKJS_TOKEN=your_trackjs_token +``` + +### Getting TrackJS Token + +1. Sign up at [TrackJS](https://trackjs.com/) +2. Create a new application +3. Copy the **Application Token** from settings + +--- + +## 3. Deriv Analytics (Rudderstack) + +### Purpose + +- User behavior tracking +- Event analytics +- Conversion tracking +- Business intelligence + +### Installation + +```bash +npm install @deriv-com/analytics@1.33.0 +``` + +### Configuration + +#### Step 1: Import Analytics in files where needed + +```typescript +import { Analytics } from '@deriv-com/analytics'; +``` + +#### Step 2: Common Usage Patterns + +**Track Events:** + +```typescript +Analytics.trackEvent('event_name', { + action: 'user_action', + form_name: 'form_identifier', + // ... other properties +}); +``` + +**Reset Analytics (on logout):** + +```typescript +Analytics.reset(); +``` + +**Set User Attributes:** + +```typescript +Analytics.setAttributes({ + account_type: 'demo', + user_id: 'user_123', +}); +``` + +#### Step 3: Re-enable Analytics in Key Files + +The following files previously used Analytics and need to be updated: + +1. **`src/stores/client-store.ts`** - Add `Analytics.reset()` in logout method +2. **`src/hooks/useLogout.ts`** - Add `Analytics.reset()` after logout +3. **`src/analytics/rudderstack-*.ts`** - All analytics tracking files +4. **`src/components/shared/services/performance-metrics-methods.ts`** - Performance tracking + +#### Step 4: Analytics Files Structure + +The analytics implementation is organized in: + +``` +src/analytics/ +├── constants.ts # Analytics constants +├── rudderstack-bot-builder.ts # Bot builder events +├── rudderstack-chart.ts # Chart interaction events +├── rudderstack-common-events.ts # Common events +├── rudderstack-dashboard.ts # Dashboard events +├── rudderstack-quick-strategy.ts # Quick strategy events +└── rudderstack-tutorials.ts # Tutorial events +``` + +#### Step 5: Environment Variables + +Add to your `.env` file: + +```bash +RUDDERSTACK_KEY=your_rudderstack_key +RUDDERSTACK_URL=https://your-dataplane-url.com +``` + +### Getting Rudderstack Credentials + +1. Sign up at [Rudderstack](https://www.rudderstack.com/) +2. Create a new source (JavaScript) +3. Copy the **Write Key** and **Data Plane URL** + +--- + +## Implementation Checklist + +### For Datadog: + +- [ ] Install `@datadog/browser-rum` package +- [ ] Create `src/utils/datadog.ts` +- [ ] Add initialization in `src/app/app-content.jsx` +- [ ] Set environment variables +- [ ] Test in staging environment + +### For TrackJS: + +- [ ] Install `trackjs` package +- [ ] Create `src/hooks/useTrackjs.ts` +- [ ] Add initialization in `src/app/app-content.jsx` +- [ ] Set environment variables +- [ ] Test error tracking + +### For Analytics: + +- [ ] Install `@deriv-com/analytics` package +- [ ] Import Analytics in required files +- [ ] Add tracking events where needed +- [ ] Set environment variables +- [ ] Test event tracking + +--- + +## Testing + +### Datadog + +1. Open browser DevTools → Network tab +2. Look for requests to `datadoghq.com` +3. Check Datadog dashboard for session recordings + +### TrackJS + +1. Trigger an error in your app +2. Check TrackJS dashboard for error reports +3. Verify user session data + +### Analytics + +1. Trigger tracked events +2. Check Rudderstack dashboard +3. Verify events are being sent with correct properties + +--- + +## Troubleshooting + +### Datadog not initializing + +- Verify `DATADOG_APPLICATION_ID` and `DATADOG_CLIENT_TOKEN` are set +- Check browser console for errors +- Ensure `APP_ENV` is set to 'production' or 'staging' + +### TrackJS not tracking errors + +- Verify `TRACKJS_TOKEN` is set +- Check that `isProduction` is true +- Ensure TrackJS.install() is called before errors occur + +### Analytics events not sending + +- Verify Rudderstack credentials +- Check browser console for network errors +- Ensure Analytics is initialized before tracking events + +--- + +## Cost Considerations + +- **Datadog**: Paid service, pricing based on sessions and data volume +- **TrackJS**: Paid service, pricing based on error volume +- **Rudderstack**: Free tier available, paid plans for higher volume + +Consider your budget and monitoring needs before enabling these services. + +--- + +## Removed Files Reference + +The following files were removed and can be recreated using the code above: + +- `src/utils/datadog.ts` - Datadog initialization +- `src/hooks/useTrackjs.ts` - TrackJS hook +- Analytics imports in various files (see Step 3 of Analytics section) + +--- + +## Support + +For issues with: + +- **Datadog**: https://docs.datadoghq.com/ +- **TrackJS**: https://docs.trackjs.com/ +- **Rudderstack**: https://www.rudderstack.com/docs/ + +--- + +**Last Updated**: 2026-02-04 +**Version**: 1.0.0 diff --git a/docs/PKCE_IMPLEMENTATION.md b/migrate-docs/PKCE_IMPLEMENTATION.md similarity index 100% rename from docs/PKCE_IMPLEMENTATION.md rename to migrate-docs/PKCE_IMPLEMENTATION.md diff --git a/docs/WEBSOCKET_CONNECTION_FLOW.md b/migrate-docs/WEBSOCKET_CONNECTION_FLOW.md similarity index 100% rename from docs/WEBSOCKET_CONNECTION_FLOW.md rename to migrate-docs/WEBSOCKET_CONNECTION_FLOW.md diff --git a/package-lock.json b/package-lock.json index 6c316f6..2b939d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,7 @@ "name": "bot", "version": "0.0.1", "dependencies": { - "@datadog/browser-rum": "^5.31.1", - "@deriv-com/analytics": "1.33.0", + "@deriv-com/analytics": "^1.35.1", "@deriv-com/quill-ui": "1.18.1", "@deriv-com/quill-ui-next": "^2.4.5", "@deriv-com/smartcharts-champion": "^1.3.14", @@ -55,7 +54,6 @@ "react-virtualized": "^9.22.5", "redux": "^5.0.1", "redux-thunk": "^3.1.0", - "trackjs": "^3.10.4", "ua-parser-js": "^1.0.38", "usehooks-ts": "^3.1.0", "uuid": "^9.0.1", @@ -2818,43 +2816,10 @@ "postcss-selector-parser": "^7.0.0" } }, - "node_modules/@datadog/browser-core": { - "version": "5.35.1", - "resolved": "https://registry.npmjs.org/@datadog/browser-core/-/browser-core-5.35.1.tgz", - "integrity": "sha512-zjmw3WkF5syMq5+2jneSgSILxO3DTS+hKw270tzk/yQUfJIGInyGrkHUYYGLmZaVuVp+6F7iO3tUAwIqQYGBFw==", - "license": "Apache-2.0" - }, - "node_modules/@datadog/browser-rum": { - "version": "5.35.1", - "resolved": "https://registry.npmjs.org/@datadog/browser-rum/-/browser-rum-5.35.1.tgz", - "integrity": "sha512-/I3mQpnAuGZ1DU3wevgirKfT1DXuWY1eg4gOHjmONN91KThVoiuHg9HiAowfFFtQArsbcP5HqdA4lLJvH6w17A==", - "license": "Apache-2.0", - "dependencies": { - "@datadog/browser-core": "5.35.1", - "@datadog/browser-rum-core": "5.35.1" - }, - "peerDependencies": { - "@datadog/browser-logs": "5.35.1" - }, - "peerDependenciesMeta": { - "@datadog/browser-logs": { - "optional": true - } - } - }, - "node_modules/@datadog/browser-rum-core": { - "version": "5.35.1", - "resolved": "https://registry.npmjs.org/@datadog/browser-rum-core/-/browser-rum-core-5.35.1.tgz", - "integrity": "sha512-+WMHeL83BvFTE618SBx7VKT3gwJ/c1DuNehvv+ZbbMqRgrl2hBng5cLfhcEW/U6bLOyr08ZfaZ1m2r/xs2n4aA==", - "license": "Apache-2.0", - "dependencies": { - "@datadog/browser-core": "5.35.1" - } - }, "node_modules/@deriv-com/analytics": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/@deriv-com/analytics/-/analytics-1.33.0.tgz", - "integrity": "sha512-VpI7hZasgmLDwkLTv9WhiqoPHpTElGPtKgRme33TAeUwP6t6BrUXWzxGx6A6AeUMfArEPI2Z3WsOFonUEM/kjQ==", + "version": "1.35.1", + "resolved": "https://registry.npmjs.org/@deriv-com/analytics/-/analytics-1.35.1.tgz", + "integrity": "sha512-vzqypCrA3s1fnqGIHwJufbq3YF6CAME+ZFvi6xyFjOd1oQB3ey5/0nw4g0sJOCZ7HbtGrJgZO4G8MrLgbRTeag==", "license": "MIT", "dependencies": { "@growthbook/growthbook": "^1.1.0", @@ -27891,12 +27856,6 @@ "node": ">=14" } }, - "node_modules/trackjs": { - "version": "3.10.4", - "resolved": "https://registry.npmjs.org/trackjs/-/trackjs-3.10.4.tgz", - "integrity": "sha512-coQkj9d7OjPmrLKj4MeLvf1U5vPD9QfXMpqVghhKnF++BlFOkA9t64ry0Usqt+8dk1tunZ9DQ5IVKLYi+3H8+w==", - "license": "SEE LICENSE IN LICENSE.md" - }, "node_modules/traverse": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.8.tgz", diff --git a/package.json b/package.json index 335cc40..0c04b42 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,7 @@ "generate:brand-css": "node scripts/generate-brand-css.js" }, "dependencies": { - "@datadog/browser-rum": "^5.31.1", - "@deriv-com/analytics": "1.33.0", + "@deriv-com/analytics": "^1.35.1", "@deriv-com/quill-ui": "1.18.1", "@deriv-com/quill-ui-next": "^2.4.5", "@deriv-com/smartcharts-champion": "^1.3.14", @@ -69,7 +68,6 @@ "react-virtualized": "^9.22.5", "redux": "^5.0.1", "redux-thunk": "^3.1.0", - "trackjs": "^3.10.4", "ua-parser-js": "^1.0.38", "usehooks-ts": "^3.1.0", "uuid": "^9.0.1", diff --git a/src/app/App.tsx b/src/app/App.tsx index 3a7d1f4..5e335af 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -8,7 +8,6 @@ import { useAccountSwitching } from '@/hooks/useAccountSwitching'; import { useLanguageFromURL } from '@/hooks/useLanguageFromURL'; import { useOAuthCallback } from '@/hooks/useOAuthCallback'; import { StoreProvider } from '@/hooks/useStore'; -import Endpoint from '@/pages/endpoint'; import { OAuthTokenExchangeService } from '@/services/oauth-token-exchange.service'; import { initializeI18n, localize, TranslationProvider } from '@deriv-com/translations'; import CoreStoreProvider from './CoreStoreProvider'; @@ -56,7 +55,6 @@ const router = createBrowserRouter( > {/* All child routes will be passed as children to Layout */} } /> - } /> ) ); diff --git a/src/app/CoreStoreProvider.tsx b/src/app/CoreStoreProvider.tsx index 7fa532c..219733a 100644 --- a/src/app/CoreStoreProvider.tsx +++ b/src/app/CoreStoreProvider.tsx @@ -5,8 +5,8 @@ import { toMoment } from '@/components/shared'; import { FORM_ERROR_MESSAGES } from '@/components/shared/constants/form-error-messages'; import { initFormErrorMessages } from '@/components/shared/utils/validation/declarative-validation-rules'; import { api_base } from '@/external/bot-skeleton'; -import { useOauth2 } from '@/hooks/auth/useOauth2'; import { useApiBase } from '@/hooks/useApiBase'; +import { useLogout } from '@/hooks/useLogout'; import { useStore } from '@/hooks/useStore'; import { TSocketResponseData } from '@/types/api-types'; import { clearInvalidTokenParams } from '@/utils/url-utils'; @@ -34,7 +34,7 @@ const CoreStoreProvider: React.FC<{ children: React.ReactNode }> = observer(({ c const { currentLang } = useTranslations(); - const { oAuthLogout } = useOauth2({ handleLogout: async () => client.logout(), client }); + const handleLogout = useLogout(); const activeAccount = useMemo( () => accountList?.find(account => account.loginid === activeLoginid), @@ -112,6 +112,8 @@ const CoreStoreProvider: React.FC<{ children: React.ReactNode }> = observer(({ c const data = res.data as TSocketResponseData<'balance'>; const { msg_type, error } = data; + // Handle auth errors by calling client.logout() directly instead of useLogout hook + // This prevents redundant logout operations since useLogout internally calls client.logout() if ( error?.code === 'AuthorizationRequired' || error?.code === 'DisabledClient' || @@ -119,7 +121,8 @@ const CoreStoreProvider: React.FC<{ children: React.ReactNode }> = observer(({ c ) { // Clear all URL query parameters for these auth errors clearInvalidTokenParams(); - await oAuthLogout(); + // Call client store logout directly to avoid double logout + await client?.logout(); } if (msg_type === 'balance' && data && !error) { @@ -133,7 +136,7 @@ const CoreStoreProvider: React.FC<{ children: React.ReactNode }> = observer(({ c } } }, - [client, oAuthLogout] + [client, handleLogout] ); useEffect(() => { diff --git a/src/app/app-content.jsx b/src/app/app-content.jsx index adcb07b..078debf 100644 --- a/src/app/app-content.jsx +++ b/src/app/app-content.jsx @@ -12,8 +12,6 @@ import { useApiBase } from '@/hooks/useApiBase'; import useDevMode from '@/hooks/useDevMode'; import { useStore } from '@/hooks/useStore'; import useThemeSwitcher from '@/hooks/useThemeSwitcher'; -import useTrackjs from '@/hooks/useTrackjs'; -import initDatadog from '@/utils/datadog'; import { ThemeProvider } from '@deriv-com/quill-ui'; import { setSmartChartsPublicPath } from '@deriv-com/smartcharts-champion'; import { localize } from '@deriv-com/translations'; @@ -39,13 +37,10 @@ const AppContent = observer(() => { const msg_listener = React.useRef(null); const { connectionStatus } = useApiBase(); console.log('connection status', connectionStatus); - const { initTrackJS } = useTrackjs(); // Initialize dev mode keyboard shortcuts useDevMode(); - initTrackJS(client.loginid); - const livechat_client_information = { is_client_store_initialized: client?.is_logged_in ? true : !!client, is_logged_in: client?.is_logged_in, @@ -168,10 +163,6 @@ const AppContent = observer(() => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [is_api_initialized, client.loginid]); - useEffect(() => { - initDatadog(true); - }, []); - if (common?.error) return null; return is_loading ? ( diff --git a/src/components/layout/footer/Endpoint.tsx b/src/components/layout/footer/Endpoint.tsx deleted file mode 100644 index eda7f3c..0000000 --- a/src/components/layout/footer/Endpoint.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Link } from 'react-router-dom'; -import { standalone_routes } from '@/components/shared'; -import Text from '@/components/shared_ui/text'; -import { LocalStorageConstants, LocalStorageUtils } from '@deriv-com/utils'; - -const Endpoint = () => { - const serverURL = LocalStorageUtils.getValue(LocalStorageConstants.configServerURL); - - if (serverURL) { - return ( - - The server{' '} - - endpoint - {' '} - {`is: ${serverURL}`} - - ); - } - return null; -}; - -export default Endpoint; diff --git a/src/components/layout/footer/ServerTime.tsx b/src/components/layout/footer/ServerTime.tsx index 388c7e5..0359edd 100644 --- a/src/components/layout/footer/ServerTime.tsx +++ b/src/components/layout/footer/ServerTime.tsx @@ -1,4 +1,3 @@ -import { useEffect, useState } from 'react'; import { observer } from 'mobx-react-lite'; import moment from 'moment'; import { useStore } from '@/hooks/useStore'; @@ -6,8 +5,6 @@ import { DATE_TIME_FORMAT_WITH_GMT, DATE_TIME_FORMAT_WITH_OFFSET } from '@/utils import { Text, Tooltip } from '@deriv-com/ui'; import { useDevice } from '@deriv-com/ui'; -const UPDATE_TIME_INTERVAL = 1000; - const ServerTime = observer(() => { const { isDesktop } = useDevice(); const { common } = useStore() ?? { @@ -16,36 +13,14 @@ const ServerTime = observer(() => { }, }; - // Initialize with UTC/GMT time, not local time - const [gmtTime, setGmtTime] = useState(moment().utc()); - // Check if we're on the endpoint page - const isEndpointPage = window.location.pathname.includes('/endpoint'); - - useEffect(() => { - // Only set up interval on endpoint page - if (isEndpointPage) { - // Start GMT timer for endpoint page - const intervalId = setInterval(() => { - // Update GMT time by 1 second - setGmtTime(prevTime => moment(prevTime).add(UPDATE_TIME_INTERVAL, 'milliseconds').utc()); - }, UPDATE_TIME_INTERVAL); - - // Clear interval on unmount - return () => clearInterval(intervalId); - } - }, [isEndpointPage]); - - // Use GMT timer on endpoint page, otherwise use server time - const displayTime = isEndpointPage ? gmtTime : common.server_time; - return ( - {displayTime.format(DATE_TIME_FORMAT_WITH_GMT)} + {common.server_time.format(DATE_TIME_FORMAT_WITH_GMT)} ); }); diff --git a/src/components/layout/footer/index.tsx b/src/components/layout/footer/index.tsx index a59eaaf..5bc77bf 100644 --- a/src/components/layout/footer/index.tsx +++ b/src/components/layout/footer/index.tsx @@ -5,7 +5,6 @@ import { FILTERED_LANGUAGES } from '@/utils/languages'; import { useTranslations } from '@deriv-com/translations'; import { DesktopLanguagesModal } from '@deriv-com/ui'; import ChangeTheme from './ChangeTheme'; -import Endpoint from './Endpoint'; import FullScreen from './FullScreen'; import LanguageSettings from './LanguageSettings'; import LogoutFooter from './LogoutFooter'; @@ -30,7 +29,6 @@ const Footer = () => {
- {isModalOpenFor('DesktopLanguagesModal') && ( { const currency = authData?.currency || ''; const { localize } = useTranslations(); - const { isSingleLoggingIn } = useOauth2({ handleLogout: async () => client?.logout(), client }); const handleLogout = useLogout(); // Handle direct URL access with token @@ -72,7 +70,6 @@ const AppHeader = observer(({ isAuthenticating }: TAppHeaderProps) => { return () => clearTimeout(timer); }, [isAuthorizing, activeLoginid, setIsAuthorizing]); - // [AI] const handleLogin = useCallback(async () => { try { // Set authorizing state immediately when login is clicked @@ -94,7 +91,6 @@ const AppHeader = observer(({ isAuthenticating }: TAppHeaderProps) => { setIsAuthorizing(false); } }, [setIsAuthorizing]); - // [/AI] const renderAccountSection = useCallback( (position: 'left' | 'right' = 'right') => { @@ -160,7 +156,6 @@ const AppHeader = observer(({ isAuthenticating }: TAppHeaderProps) => { [ isAuthenticating, isAuthorizing, - isSingleLoggingIn, isDesktop, activeLoginid, isAuthorized, @@ -173,6 +168,8 @@ const AppHeader = observer(({ isAuthenticating }: TAppHeaderProps) => { handleLogout, authTimeout, is_account_regenerating, + authData, + handleLogin, ] ); diff --git a/src/components/layout/index.tsx b/src/components/layout/index.tsx index 0cabdba..12a41b7 100644 --- a/src/components/layout/index.tsx +++ b/src/components/layout/index.tsx @@ -17,7 +17,6 @@ const Layout = observer(() => { const is_quick_strategy_active = store?.quick_strategy?.is_open; const isCallbackPage = window.location.pathname === '/callback'; - const isEndpointPage = window.location.pathname.includes('endpoint'); const checkClientAccount = JSON.parse(localStorage.getItem('clientAccounts') ?? '{}'); const getQueryParams = new URLSearchParams(window.location.search); const currency = getQueryParams.get('account') ?? ''; @@ -126,7 +125,7 @@ const Layout = observer(() => { // Authentication is now handled by the OAuth flow setIsAuthenticating(false); - }, [isClientAccountsPopulated, isEndpointPage, isCallbackPage, clientHasCurrency, currency]); + }, [isClientAccountsPopulated, isCallbackPage, clientHasCurrency, currency]); // Add a state to track if initial authentication check is complete const [isInitialAuthCheckComplete, setIsInitialAuthCheckComplete] = useState(false); diff --git a/src/components/shared/utils/config/config.ts b/src/components/shared/utils/config/config.ts index 9835e13..5188fbe 100644 --- a/src/components/shared/utils/config/config.ts +++ b/src/components/shared/utils/config/config.ts @@ -124,7 +124,6 @@ const getDefaultServerURL = () => { * @returns Promise with WebSocket URL or fallback to default server */ export const getSocketURL = async (): Promise => { - // [AI] try { // Check if user is authenticated const authInfo = OAuthTokenExchangeService.getAuthInfo(); @@ -140,7 +139,6 @@ export const getSocketURL = async (): Promise => { return getDefaultServerURL(); } }; -// [/AI] export const getDebugServiceWorker = () => { const debug_service_worker_flag = window.localStorage.getItem('debug_service_worker'); @@ -289,7 +287,7 @@ export const clearCSRFToken = (): void => { export const generateOAuthURL = async () => { try { // Use brand config for login URLs - const environment = getCurrentEnvironment(); + const environment = isProduction() ? 'production' : 'staging'; const hostname = brandConfig?.platform.auth2_url?.[environment]; const clientId = process.env.CLIENT_ID || '32izC2lBT4MmiSNWuxq2l'; diff --git a/src/components/shared/utils/routes/routes.ts b/src/components/shared/utils/routes/routes.ts index e17e9b0..20600d0 100644 --- a/src/components/shared/utils/routes/routes.ts +++ b/src/components/shared/utils/routes/routes.ts @@ -107,7 +107,6 @@ export const standalone_routes = { signup: `${getDerivDomain('derivHome')}/dashboard/signup`, deriv_com: getDerivDomain('derivCom'), deriv_app: `${getDerivDomain('derivHome')}/dashboard/home`, - endpoint: `${window.location.origin}/endpoint`, account_limits: `${getDerivDomain('derivDtrader')}/account/account-limits`, help_center: `${getDerivDomain('derivCom')}/help-centre/`, responsible: `${getDerivDomain('derivCom')}/responsible/`, diff --git a/src/hooks/__tests__/useInvalidTokenHandler.spec.ts b/src/hooks/__tests__/useInvalidTokenHandler.spec.ts new file mode 100644 index 0000000..e4a0dc7 --- /dev/null +++ b/src/hooks/__tests__/useInvalidTokenHandler.spec.ts @@ -0,0 +1,408 @@ +import { observer as globalObserver } from '@/external/bot-skeleton/utils/observer'; +import { ErrorLogger } from '@/utils/error-logger'; +import { renderHook } from '@testing-library/react'; +import { useInvalidTokenHandler } from '../useInvalidTokenHandler'; + +// Mock dependencies +jest.mock('@/external/bot-skeleton/utils/observer', () => ({ + observer: { + register: jest.fn(), + unregister: jest.fn(), + }, +})); + +jest.mock('@/utils/error-logger', () => ({ + ErrorLogger: { + error: jest.fn(), + }, +})); + +jest.mock('@/components/shared', () => ({ + generateOAuthURL: jest.fn(), +})); + +// Import after mocking +import { generateOAuthURL } from '@/components/shared'; + +describe('useInvalidTokenHandler', () => { + let mockGenerateOAuthURL: jest.Mock; + let mockWindowLocationReplace: jest.Mock; + let mockWindowLocationReload: jest.Mock; + let originalLocation: Location; + + beforeEach(() => { + // Clear all mocks before each test + jest.clearAllMocks(); + + // Setup mock functions + mockGenerateOAuthURL = generateOAuthURL as jest.Mock; + mockWindowLocationReplace = jest.fn(); + mockWindowLocationReload = jest.fn(); + + // Save original location + originalLocation = window.location; + + // Mock window.location + delete (window as any).location; + window.location = { + ...originalLocation, + replace: mockWindowLocationReplace, + reload: mockWindowLocationReload, + } as any; + + // Mock storage + Storage.prototype.removeItem = jest.fn(); + Storage.prototype.clear = jest.fn(); + }); + + afterEach(() => { + // Restore original location + window.location = originalLocation; + jest.restoreAllMocks(); + }); + + describe('Hook Registration', () => { + it('should register InvalidToken event handler on mount', () => { + renderHook(() => useInvalidTokenHandler()); + + expect(globalObserver.register).toHaveBeenCalledWith( + 'InvalidToken', + expect.any(Function) + ); + }); + + it('should unregister InvalidToken event handler on unmount', () => { + const { unmount } = renderHook(() => useInvalidTokenHandler()); + + unmount(); + + expect(globalObserver.unregister).toHaveBeenCalledWith( + 'InvalidToken', + expect.any(Function) + ); + }); + + it('should return unregisterHandler function', () => { + const { result } = renderHook(() => useInvalidTokenHandler()); + + expect(result.current).toHaveProperty('unregisterHandler'); + expect(typeof result.current.unregisterHandler).toBe('function'); + }); + + it('should allow manual unregistration via returned function', () => { + const { result } = renderHook(() => useInvalidTokenHandler()); + + result.current.unregisterHandler(); + + expect(globalObserver.unregister).toHaveBeenCalledWith( + 'InvalidToken', + expect.any(Function) + ); + }); + }); + + describe('Invalid Token Handling - Success Path', () => { + it('should clear invalid auth data from sessionStorage', async () => { + mockGenerateOAuthURL.mockResolvedValue('https://oauth.example.com/authorize'); + + renderHook(() => useInvalidTokenHandler()); + + // Get the registered handler + const registerCall = (globalObserver.register as jest.Mock).mock.calls[0]; + const handler = registerCall[1]; + + // Trigger the handler + await handler(); + + expect(sessionStorage.removeItem).toHaveBeenCalledWith('auth_info'); + expect(sessionStorage.clear).toHaveBeenCalled(); + }); + + it('should clear invalid auth data from localStorage', async () => { + mockGenerateOAuthURL.mockResolvedValue('https://oauth.example.com/authorize'); + + renderHook(() => useInvalidTokenHandler()); + + // Get the registered handler + const registerCall = (globalObserver.register as jest.Mock).mock.calls[0]; + const handler = registerCall[1]; + + // Trigger the handler + await handler(); + + expect(localStorage.removeItem).toHaveBeenCalledWith('active_loginid'); + expect(localStorage.removeItem).toHaveBeenCalledWith('authToken'); + expect(localStorage.removeItem).toHaveBeenCalledWith('accountsList'); + expect(localStorage.removeItem).toHaveBeenCalledWith('clientAccounts'); + }); + + it('should generate OAuth URL and redirect', async () => { + const mockOAuthURL = 'https://oauth.example.com/authorize?client_id=123'; + mockGenerateOAuthURL.mockResolvedValue(mockOAuthURL); + + renderHook(() => useInvalidTokenHandler()); + + // Get the registered handler + const registerCall = (globalObserver.register as jest.Mock).mock.calls[0]; + const handler = registerCall[1]; + + // Trigger the handler + await handler(); + + expect(mockGenerateOAuthURL).toHaveBeenCalled(); + expect(mockWindowLocationReplace).toHaveBeenCalledWith(mockOAuthURL); + }); + + it('should use window.location.replace instead of reload', async () => { + const mockOAuthURL = 'https://oauth.example.com/authorize'; + mockGenerateOAuthURL.mockResolvedValue(mockOAuthURL); + + renderHook(() => useInvalidTokenHandler()); + + // Get the registered handler + const registerCall = (globalObserver.register as jest.Mock).mock.calls[0]; + const handler = registerCall[1]; + + // Trigger the handler + await handler(); + + expect(mockWindowLocationReplace).toHaveBeenCalledWith(mockOAuthURL); + expect(mockWindowLocationReload).not.toHaveBeenCalled(); + }); + }); + + describe('Invalid Token Handling - Fallback Scenarios', () => { + it('should reload page if OAuth URL generation returns null', async () => { + mockGenerateOAuthURL.mockResolvedValue(null); + + renderHook(() => useInvalidTokenHandler()); + + // Get the registered handler + const registerCall = (globalObserver.register as jest.Mock).mock.calls[0]; + const handler = registerCall[1]; + + // Trigger the handler + await handler(); + + expect(ErrorLogger.error).toHaveBeenCalledWith( + 'InvalidToken', + 'Failed to generate OAuth URL, falling back to reload' + ); + expect(mockWindowLocationReload).toHaveBeenCalled(); + }); + + it('should reload page if OAuth URL generation returns undefined', async () => { + mockGenerateOAuthURL.mockResolvedValue(undefined); + + renderHook(() => useInvalidTokenHandler()); + + // Get the registered handler + const registerCall = (globalObserver.register as jest.Mock).mock.calls[0]; + const handler = registerCall[1]; + + // Trigger the handler + await handler(); + + expect(ErrorLogger.error).toHaveBeenCalledWith( + 'InvalidToken', + 'Failed to generate OAuth URL, falling back to reload' + ); + expect(mockWindowLocationReload).toHaveBeenCalled(); + }); + + it('should reload page if OAuth URL generation returns empty string', async () => { + mockGenerateOAuthURL.mockResolvedValue(''); + + renderHook(() => useInvalidTokenHandler()); + + // Get the registered handler + const registerCall = (globalObserver.register as jest.Mock).mock.calls[0]; + const handler = registerCall[1]; + + // Trigger the handler + await handler(); + + expect(ErrorLogger.error).toHaveBeenCalledWith( + 'InvalidToken', + 'Failed to generate OAuth URL, falling back to reload' + ); + expect(mockWindowLocationReload).toHaveBeenCalled(); + }); + + it('should reload page if OAuth URL generation throws error', async () => { + const mockError = new Error('OAuth generation failed'); + mockGenerateOAuthURL.mockRejectedValue(mockError); + + renderHook(() => useInvalidTokenHandler()); + + // Get the registered handler + const registerCall = (globalObserver.register as jest.Mock).mock.calls[0]; + const handler = registerCall[1]; + + // Trigger the handler + await handler(); + + expect(ErrorLogger.error).toHaveBeenCalledWith( + 'InvalidToken', + 'Error handling invalid token', + mockError + ); + expect(mockWindowLocationReload).toHaveBeenCalled(); + }); + + it('should reload page if storage clearing throws error', async () => { + const storageError = new Error('Storage error'); + Storage.prototype.removeItem = jest.fn().mockImplementation(() => { + throw storageError; + }); + + renderHook(() => useInvalidTokenHandler()); + + // Get the registered handler + const registerCall = (globalObserver.register as jest.Mock).mock.calls[0]; + const handler = registerCall[1]; + + // Trigger the handler + await handler(); + + expect(ErrorLogger.error).toHaveBeenCalledWith( + 'InvalidToken', + 'Error handling invalid token', + storageError + ); + expect(mockWindowLocationReload).toHaveBeenCalled(); + }); + }); + + describe('Prevents Infinite Reload Loop', () => { + it('should clear auth data before redirecting to prevent loop', async () => { + const mockOAuthURL = 'https://oauth.example.com/authorize'; + mockGenerateOAuthURL.mockResolvedValue(mockOAuthURL); + + renderHook(() => useInvalidTokenHandler()); + + // Get the registered handler + const registerCall = (globalObserver.register as jest.Mock).mock.calls[0]; + const handler = registerCall[1]; + + // Trigger the handler + await handler(); + + // Verify storage was cleared BEFORE redirect + const removeItemCalls = (localStorage.removeItem as jest.Mock).mock.invocationCallOrder; + const replaceCalls = mockWindowLocationReplace.mock.invocationCallOrder; + + // All removeItem calls should happen before replace + removeItemCalls.forEach((removeOrder: number) => { + replaceCalls.forEach((replaceOrder: number) => { + expect(removeOrder).toBeLessThan(replaceOrder); + }); + }); + }); + + it('should clear sessionStorage completely to remove stale data', async () => { + const mockOAuthURL = 'https://oauth.example.com/authorize'; + mockGenerateOAuthURL.mockResolvedValue(mockOAuthURL); + + renderHook(() => useInvalidTokenHandler()); + + // Get the registered handler + const registerCall = (globalObserver.register as jest.Mock).mock.calls[0]; + const handler = registerCall[1]; + + // Trigger the handler + await handler(); + + expect(sessionStorage.clear).toHaveBeenCalled(); + }); + }); + + describe('Error Logging', () => { + it('should log error when OAuth URL generation fails', async () => { + mockGenerateOAuthURL.mockResolvedValue(null); + + renderHook(() => useInvalidTokenHandler()); + + // Get the registered handler + const registerCall = (globalObserver.register as jest.Mock).mock.calls[0]; + const handler = registerCall[1]; + + // Trigger the handler + await handler(); + + expect(ErrorLogger.error).toHaveBeenCalledWith( + 'InvalidToken', + 'Failed to generate OAuth URL, falling back to reload' + ); + }); + + it('should log error when exception occurs', async () => { + const mockError = new Error('Unexpected error'); + mockGenerateOAuthURL.mockRejectedValue(mockError); + + renderHook(() => useInvalidTokenHandler()); + + // Get the registered handler + const registerCall = (globalObserver.register as jest.Mock).mock.calls[0]; + const handler = registerCall[1]; + + // Trigger the handler + await handler(); + + expect(ErrorLogger.error).toHaveBeenCalledWith( + 'InvalidToken', + 'Error handling invalid token', + mockError + ); + }); + }); + + describe('Multiple Invalid Token Events', () => { + it('should handle multiple sequential invalid token events', async () => { + const mockOAuthURL = 'https://oauth.example.com/authorize'; + mockGenerateOAuthURL.mockResolvedValue(mockOAuthURL); + + renderHook(() => useInvalidTokenHandler()); + + // Get the registered handler + const registerCall = (globalObserver.register as jest.Mock).mock.calls[0]; + const handler = registerCall[1]; + + // Trigger the handler multiple times + await handler(); + await handler(); + await handler(); + + expect(mockGenerateOAuthURL).toHaveBeenCalledTimes(3); + expect(mockWindowLocationReplace).toHaveBeenCalledTimes(3); + }); + }); + + describe('Hook Lifecycle', () => { + it('should not leak memory by properly cleaning up on unmount', () => { + const { unmount } = renderHook(() => useInvalidTokenHandler()); + + // Register should be called once + expect(globalObserver.register).toHaveBeenCalledTimes(1); + + unmount(); + + // Unregister should be called once + expect(globalObserver.unregister).toHaveBeenCalledTimes(1); + }); + + it('should register new handler on remount', () => { + const { unmount } = renderHook(() => useInvalidTokenHandler()); + + expect(globalObserver.register).toHaveBeenCalledTimes(1); + + unmount(); + + // Mount a new instance + renderHook(() => useInvalidTokenHandler()); + + // Should register again after remount + expect(globalObserver.register).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/hooks/__tests__/useLogout.spec.ts b/src/hooks/__tests__/useLogout.spec.ts new file mode 100644 index 0000000..1fe8901 --- /dev/null +++ b/src/hooks/__tests__/useLogout.spec.ts @@ -0,0 +1,290 @@ +import { ErrorLogger } from '@/utils/error-logger'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useLogout } from '../useLogout'; + +// Mock dependencies +jest.mock('@/hooks/useStore', () => ({ + useStore: jest.fn(), +})); + +jest.mock('@/utils/error-logger', () => ({ + ErrorLogger: { + error: jest.fn(), + }, +})); + +// Import after mocking +import { useStore } from '@/hooks/useStore'; + +describe('useLogout', () => { + let mockLogout: jest.Mock; + let mockClient: { logout: jest.Mock }; + + beforeEach(() => { + // Clear all mocks before each test + jest.clearAllMocks(); + + // Setup mock logout function + mockLogout = jest.fn(); + mockClient = { + logout: mockLogout, + }; + + // Mock useStore to return our mock client + (useStore as jest.Mock).mockReturnValue({ + client: mockClient, + }); + + // Mock localStorage and sessionStorage + Storage.prototype.removeItem = jest.fn(); + Storage.prototype.clear = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('Successful Logout', () => { + it('should call client.logout() when handleLogout is invoked', async () => { + mockLogout.mockResolvedValue(undefined); + + const { result } = renderHook(() => useLogout()); + const handleLogout = result.current; + + await handleLogout(); + + expect(mockLogout).toHaveBeenCalledTimes(1); + }); + + it('should not throw error on successful logout', async () => { + mockLogout.mockResolvedValue(undefined); + + const { result } = renderHook(() => useLogout()); + const handleLogout = result.current; + + await expect(handleLogout()).resolves.not.toThrow(); + }); + + it('should not call ErrorLogger on successful logout', async () => { + mockLogout.mockResolvedValue(undefined); + + const { result } = renderHook(() => useLogout()); + const handleLogout = result.current; + + await handleLogout(); + + expect(ErrorLogger.error).not.toHaveBeenCalled(); + }); + }); + + describe('Logout Error Handling', () => { + it('should log error when client.logout() fails', async () => { + const mockError = new Error('Logout failed'); + mockLogout.mockRejectedValue(mockError); + + const { result } = renderHook(() => useLogout()); + const handleLogout = result.current; + + await handleLogout(); + + expect(ErrorLogger.error).toHaveBeenCalledWith('Logout', 'Logout failed', mockError); + }); + + it('should clear auth-related sessionStorage items on logout failure', async () => { + const mockError = new Error('Logout failed'); + mockLogout.mockRejectedValue(mockError); + + const { result } = renderHook(() => useLogout()); + const handleLogout = result.current; + + await handleLogout(); + + await waitFor(() => { + expect(sessionStorage.removeItem).toHaveBeenCalledWith('auth_info'); + }); + }); + + it('should clear auth-related localStorage items on logout failure', async () => { + const mockError = new Error('Logout failed'); + mockLogout.mockRejectedValue(mockError); + + const { result } = renderHook(() => useLogout()); + const handleLogout = result.current; + + await handleLogout(); + + await waitFor(() => { + expect(localStorage.removeItem).toHaveBeenCalledWith('active_loginid'); + expect(localStorage.removeItem).toHaveBeenCalledWith('authToken'); + expect(localStorage.removeItem).toHaveBeenCalledWith('accountsList'); + expect(localStorage.removeItem).toHaveBeenCalledWith('clientAccounts'); + expect(localStorage.removeItem).toHaveBeenCalledWith('account_type'); + }); + }); + + it('should not clear all storage on first attempt', async () => { + const mockError = new Error('Logout failed'); + mockLogout.mockRejectedValue(mockError); + + const { result } = renderHook(() => useLogout()); + const handleLogout = result.current; + + await handleLogout(); + + await waitFor(() => { + expect(sessionStorage.clear).not.toHaveBeenCalled(); + expect(localStorage.clear).not.toHaveBeenCalled(); + }); + }); + }); + + describe('Storage Clearing Fallback', () => { + it('should clear all storage if targeted clearing fails', async () => { + const mockError = new Error('Logout failed'); + mockLogout.mockRejectedValue(mockError); + + // Mock removeItem to throw error + const storageError = new Error('Storage error'); + Storage.prototype.removeItem = jest.fn().mockImplementation(() => { + throw storageError; + }); + + const { result } = renderHook(() => useLogout()); + const handleLogout = result.current; + + await handleLogout(); + + await waitFor(() => { + expect(ErrorLogger.error).toHaveBeenCalledWith( + 'Logout', + 'Failed to clear auth storage', + storageError + ); + expect(sessionStorage.clear).toHaveBeenCalled(); + expect(localStorage.clear).toHaveBeenCalled(); + }); + }); + + it('should log error if final storage clear also fails', async () => { + const mockError = new Error('Logout failed'); + mockLogout.mockRejectedValue(mockError); + + // Mock both removeItem and clear to throw errors + const storageError = new Error('Storage error'); + const finalError = new Error('Final storage error'); + Storage.prototype.removeItem = jest.fn().mockImplementation(() => { + throw storageError; + }); + Storage.prototype.clear = jest.fn().mockImplementation(() => { + throw finalError; + }); + + const { result } = renderHook(() => useLogout()); + const handleLogout = result.current; + + await handleLogout(); + + await waitFor(() => { + expect(ErrorLogger.error).toHaveBeenCalledWith( + 'Logout', + 'Failed to clear all storage', + finalError + ); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle case when useStore returns null', async () => { + (useStore as jest.Mock).mockReturnValue(null); + + const { result } = renderHook(() => useLogout()); + const handleLogout = result.current; + + // Should not throw error + await expect(handleLogout()).resolves.not.toThrow(); + }); + + it('should handle case when useStore returns undefined', async () => { + (useStore as jest.Mock).mockReturnValue(undefined); + + const { result } = renderHook(() => useLogout()); + const handleLogout = result.current; + + // Should not throw error + await expect(handleLogout()).resolves.not.toThrow(); + }); + + it('should handle case when client is null', async () => { + (useStore as jest.Mock).mockReturnValue({ client: null }); + + const { result } = renderHook(() => useLogout()); + const handleLogout = result.current; + + // Should not throw error + await expect(handleLogout()).resolves.not.toThrow(); + }); + + it('should handle case when client is undefined', async () => { + (useStore as jest.Mock).mockReturnValue({ client: undefined }); + + const { result } = renderHook(() => useLogout()); + const handleLogout = result.current; + + // Should not throw error + await expect(handleLogout()).resolves.not.toThrow(); + }); + }); + + describe('Hook Stability', () => { + it('should return the same function reference when client does not change', () => { + const { result, rerender } = renderHook(() => useLogout()); + const firstRender = result.current; + + rerender(); + const secondRender = result.current; + + expect(firstRender).toBe(secondRender); + }); + + it('should return new function reference when client changes', () => { + const { result, rerender } = renderHook(() => useLogout()); + const firstRender = result.current; + + // Change the client + const newMockClient = { logout: jest.fn() }; + (useStore as jest.Mock).mockReturnValue({ client: newMockClient }); + + rerender(); + const secondRender = result.current; + + expect(firstRender).not.toBe(secondRender); + }); + }); + + describe('Multiple Logout Calls', () => { + it('should handle multiple sequential logout calls', async () => { + mockLogout.mockResolvedValue(undefined); + + const { result } = renderHook(() => useLogout()); + const handleLogout = result.current; + + await handleLogout(); + await handleLogout(); + await handleLogout(); + + expect(mockLogout).toHaveBeenCalledTimes(3); + }); + + it('should handle concurrent logout calls', async () => { + mockLogout.mockResolvedValue(undefined); + + const { result } = renderHook(() => useLogout()); + const handleLogout = result.current; + + await Promise.all([handleLogout(), handleLogout(), handleLogout()]); + + expect(mockLogout).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/src/hooks/auth/useOauth2.ts b/src/hooks/auth/useOauth2.ts deleted file mode 100644 index 5f70e33..0000000 --- a/src/hooks/auth/useOauth2.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { useState } from 'react'; -import { useEffect } from 'react'; -import RootStore from '@/stores/root-store'; -import { Analytics } from '@deriv-com/analytics'; - -/** - * Provides an object with properties: `oAuthLogout`, `retriggerOAuth2Login`, and `isSingleLoggingIn`. - * - * `oAuthLogout` is a function that logs out the user of the OAuth2-enabled app. - * - * `retriggerOAuth2Login` is a function that retriggers the OAuth2 login flow to get a new token. - * - * `isSingleLoggingIn` is a boolean that indicates whether the user is currently logging in. - * - * The `handleLogout` argument is an optional function that will be called after logging out the user. - * If `handleLogout` is not provided, the function will resolve immediately. - * - * @param {{ handleLogout?: () => Promise }} [options] - An object with an optional `handleLogout` property. - * @returns {{ oAuthLogout: () => Promise; retriggerOAuth2Login: () => Promise; isSingleLoggingIn: boolean }} - */ -export const useOauth2 = ({ - handleLogout, - client, -}: { - handleLogout?: () => Promise; - client?: RootStore['client']; -} = {}) => { - const [isSingleLoggingIn, setIsSingleLoggingIn] = useState(false); - const accountsList = JSON.parse(localStorage.getItem('accountsList') ?? '{}'); - const isClientAccountsPopulated = Object.keys(accountsList).length > 0; - const isSilentLoginExcluded = - window.location.pathname.includes('callback') || window.location.pathname.includes('endpoint'); - - useEffect(() => { - window.addEventListener('unhandledrejection', event => { - if (event?.reason?.error?.code === 'InvalidToken') { - setIsSingleLoggingIn(false); - } - }); - }, []); - - useEffect(() => { - // Simplified logic without relying on logged_state cookie - // Set loading state based on whether we're in a transition state - const willEventuallySSO = !isClientAccountsPopulated; - const willEventuallySLO = isClientAccountsPopulated; - if (!isSilentLoginExcluded && (willEventuallySSO || willEventuallySLO)) { - setIsSingleLoggingIn(true); - } else { - setIsSingleLoggingIn(false); - } - }, [isClientAccountsPopulated, isSilentLoginExcluded]); - - const logoutHandler = async () => { - client?.setIsLoggingOut(true); - try { - if (handleLogout) { - await handleLogout(); - } - await client?.logout(); - Analytics.reset(); - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - } - }; - - const retriggerOAuth2Login = async () => { - // OAuth2 login is now handled by redirecting to OAuth URL - window.location.reload(); - }; - - return { oAuthLogout: logoutHandler, retriggerOAuth2Login, isSingleLoggingIn }; -}; diff --git a/src/hooks/useInvalidTokenHandler.ts b/src/hooks/useInvalidTokenHandler.ts index 2f97844..3e11e53 100644 --- a/src/hooks/useInvalidTokenHandler.ts +++ b/src/hooks/useInvalidTokenHandler.ts @@ -1,23 +1,46 @@ import { useEffect } from 'react'; import { observer as globalObserver } from '@/external/bot-skeleton/utils/observer'; -import { useOauth2 } from './auth/useOauth2'; +import { ErrorLogger } from '@/utils/error-logger'; /** - * Hook to handle invalid token events by retriggering OIDC authentication + * Hook to handle invalid token events by clearing auth data and redirecting to OAuth login * * This hook listens for 'InvalidToken' events emitted by the API base when - * a token is invalid but the cookie logged state is true. When such an event - * is detected, it automatically retriggers the OIDC authentication flow to - * get a new token. + * a token is invalid. When such an event is detected, it clears the invalid + * authentication data and redirects to OAuth login to prevent infinite reload loops. * * @returns {{ unregisterHandler: () => void }} An object containing a function to unregister the event handler */ export const useInvalidTokenHandler = (): { unregisterHandler: () => void } => { - const { retriggerOAuth2Login } = useOauth2(); - - const handleInvalidToken = () => { - // Clear localStorage similar to client.logout - retriggerOAuth2Login(); + const handleInvalidToken = async () => { + try { + // Clear invalid session data to prevent infinite reload loop + sessionStorage.removeItem('auth_info'); + localStorage.removeItem('active_loginid'); + localStorage.removeItem('authToken'); + localStorage.removeItem('accountsList'); + localStorage.removeItem('clientAccounts'); + + // Clear sessionStorage completely to remove any stale auth data + sessionStorage.clear(); + + // Redirect to OAuth login instead of reload to get fresh authentication + const { generateOAuthURL } = await import('@/components/shared'); + const oauthUrl = await generateOAuthURL(); + + if (oauthUrl) { + // Use replace to prevent back button from returning to invalid state + window.location.replace(oauthUrl); + } else { + // Fallback: reload if OAuth URL generation fails + ErrorLogger.error('InvalidToken', 'Failed to generate OAuth URL, falling back to reload'); + window.location.reload(); + } + } catch (error) { + ErrorLogger.error('InvalidToken', 'Error handling invalid token', error); + // Last resort: reload the page + window.location.reload(); + } }; // Subscribe to the InvalidToken event @@ -28,7 +51,7 @@ export const useInvalidTokenHandler = (): { unregisterHandler: () => void } => { return () => { globalObserver.unregister('InvalidToken', handleInvalidToken); }; - }, [retriggerOAuth2Login]); + }, []); // Return a function to unregister the handler manually if needed return { diff --git a/src/hooks/useLogout.ts b/src/hooks/useLogout.ts index d9bd97b..331ae59 100644 --- a/src/hooks/useLogout.ts +++ b/src/hooks/useLogout.ts @@ -1,23 +1,45 @@ import { useCallback } from 'react'; -import { useOauth2 } from '@/hooks/auth/useOauth2'; import { useStore } from '@/hooks/useStore'; +import { ErrorLogger } from '@/utils/error-logger'; /** * Custom hook to handle logout functionality - * Provides a consistent logout method with error handling and retry logic + * Clears all session and local storage to reset the session * @returns {Function} handleLogout - Function to trigger the logout process */ export const useLogout = () => { const { client } = useStore() ?? {}; - const { oAuthLogout } = useOauth2({ handleLogout: async () => client?.logout(), client }); return useCallback(async () => { try { - await oAuthLogout(); + // Call the client store logout method which clears all storage + await client?.logout(); + // Analytics.reset() removed - Analytics package has been removed from the project + // See migrate-docs/MONITORING_PACKAGES.md for re-enabling analytics if needed } catch (error) { - console.error('Logout failed:', error); - // Still try to logout even if there's an error - await oAuthLogout(); + ErrorLogger.error('Logout', 'Logout failed', error); + // If logout fails, clear only auth-related storage keys + // This preserves user preferences (theme, language, etc.) while ensuring auth data is cleared + try { + // Clear auth-related sessionStorage items + sessionStorage.removeItem('auth_info'); + + // Clear auth-related localStorage items + localStorage.removeItem('active_loginid'); + localStorage.removeItem('authToken'); + localStorage.removeItem('accountsList'); + localStorage.removeItem('clientAccounts'); + localStorage.removeItem('account_type'); + } catch (storageError) { + ErrorLogger.error('Logout', 'Failed to clear auth storage', storageError); + // Last resort: if targeted clearing fails, clear all storage + try { + sessionStorage.clear(); + localStorage.clear(); + } catch (finalError) { + ErrorLogger.error('Logout', 'Failed to clear all storage', finalError); + } + } } - }, [oAuthLogout]); + }, [client]); }; diff --git a/src/hooks/useTrackjs.ts b/src/hooks/useTrackjs.ts deleted file mode 100644 index 488cf1e..0000000 --- a/src/hooks/useTrackjs.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { TrackJS } from 'trackjs'; - -const { TRACKJS_TOKEN } = process.env; - -/** - * Custom hook to initialize TrackJS. - * @returns {Object} An object containing the `init` function. - */ -const useTrackjs = () => { - const isProduction = process.env.APP_ENV === 'production'; - const trackjs_version = process.env.REF_NAME ?? 'undefined'; - - const initTrackJS = (loginid: string) => { - try { - if (!TrackJS.isInstalled()) { - TrackJS.install({ - application: 'derivatives-bot', - dedupe: false, - enabled: isProduction, - token: TRACKJS_TOKEN!, - userId: loginid, - version: - (document.querySelector('meta[name=version]') as HTMLMetaElement)?.content ?? trackjs_version, - }); - } - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to initialize TrackJS', error); - } - }; - - return { initTrackJS }; -}; - -export default useTrackjs; diff --git a/src/pages/endpoint/__tests__/endpoint.spec.tsx b/src/pages/endpoint/__tests__/endpoint.spec.tsx deleted file mode 100644 index 23efe30..0000000 --- a/src/pages/endpoint/__tests__/endpoint.spec.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import Endpoint from '..'; - -const mockReload = jest.fn(); -Object.defineProperty(window, 'location', { - value: { - reload: mockReload, - hostname: 'bot.deriv.com', // Mock production environment for consistent tests - }, - writable: true, -}); - -describe('', () => { - beforeEach(() => { - localStorage.clear(); - }); - - it('should render the endpoint component', () => { - render(); - - expect(screen.getByText('Change API endpoint')).toBeInTheDocument(); - expect(screen.getByText('Server')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Reset to original settings' })).toBeInTheDocument(); - }); - - it('should handle form submission', async () => { - render(); - - const serverUrlInput = screen.getByTestId('dt_endpoint_server_url_input'); - const submitButton = screen.getByRole('button', { name: 'Submit' }); - - await userEvent.clear(serverUrlInput); - await userEvent.type(serverUrlInput, 'qa10.deriv.dev'); - await userEvent.click(submitButton); - - expect(localStorage.getItem('config.server_url') ?? '').toBe('qa10.deriv.dev'); - }); - - it('should reset to default server URL when reset button is clicked', async () => { - render(); - - const serverUrlInput = screen.getByTestId('dt_endpoint_server_url_input'); - const resetButton = screen.getByRole('button', { name: 'Reset to original settings' }); - - // Set a custom server URL and save it - await userEvent.clear(serverUrlInput); - await userEvent.type(serverUrlInput, 'qa10.deriv.dev'); - const submitButton = screen.getByRole('button', { name: 'Submit' }); - await userEvent.click(submitButton); - - // Verify it was saved - expect(localStorage.getItem('config.server_url')).toBe('qa10.deriv.dev'); - - // Click reset button - await userEvent.click(resetButton); - - // Should clear localStorage and reset to default - expect(localStorage.getItem('config.server_url')).toBeNull(); - // The input should now show the default server (production server since hostname is mocked as bot.deriv.com) - expect(serverUrlInput).toHaveValue('api-core.deriv.com/options/v1/ws'); - }); -}); diff --git a/src/pages/endpoint/endpoint.scss b/src/pages/endpoint/endpoint.scss deleted file mode 100644 index df6efc0..0000000 --- a/src/pages/endpoint/endpoint.scss +++ /dev/null @@ -1,29 +0,0 @@ -@use 'components/shared/styles/devices' as *; - -.endpoint { - width: 100%; - height: 60%; - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - - &__title { - margin-bottom: 2rem; - } - - &__form { - display: flex; - flex-direction: column; - gap: 3rem; - } - - &__button { - margin-right: 1rem; - } - - @include mobile-or-tablet-screen { - height: unset; - padding-top: 2rem; - } -} diff --git a/src/pages/endpoint/index.tsx b/src/pages/endpoint/index.tsx deleted file mode 100644 index b617874..0000000 --- a/src/pages/endpoint/index.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react'; -import { useFormik } from 'formik'; -import { getSocketURL } from '@/components/shared'; -import { reloadPage } from '@/utils/navigation-utils'; -import { Button, Input, Text } from '@deriv-com/ui'; -import { LocalStorageConstants } from '@deriv-com/utils'; -import './endpoint.scss'; -const Endpoint = () => { - const formik = useFormik({ - initialValues: { - serverUrl: localStorage.getItem(LocalStorageConstants.configServerURL) ?? getSocketURL(), - }, - onSubmit: values => { - localStorage.setItem(LocalStorageConstants.configServerURL, values.serverUrl); - formik.resetForm({ values }); - }, - validate: values => { - const errors: { [key: string]: string } = {}; - if (!values.serverUrl) { - errors.serverUrl = 'This field is required'; - } - return errors; - }, - }); - - return ( -
- - Change API endpoint - -
- -
- - -
-
-
- ); -}; - -export default Endpoint; diff --git a/src/services/__mocks__/derivws-accounts.service.ts b/src/services/__mocks__/derivws-accounts.service.ts new file mode 100644 index 0000000..ac97565 --- /dev/null +++ b/src/services/__mocks__/derivws-accounts.service.ts @@ -0,0 +1,10 @@ +// Mock for DerivWSAccountsService +export const DerivWSAccountsService = { + fetchAccountsList: jest.fn(), + storeAccounts: jest.fn(), + getStoredAccounts: jest.fn(), + clearStoredAccounts: jest.fn(), + clearCache: jest.fn(), + fetchOTPWebSocketURL: jest.fn(), + getAuthenticatedWebSocketURL: jest.fn(), +}; diff --git a/src/services/__mocks__/oauth-token-exchange.service.ts b/src/services/__mocks__/oauth-token-exchange.service.ts new file mode 100644 index 0000000..c54b801 --- /dev/null +++ b/src/services/__mocks__/oauth-token-exchange.service.ts @@ -0,0 +1,9 @@ +// Mock for OAuthTokenExchangeService +export const OAuthTokenExchangeService = { + exchangeCodeForToken: jest.fn(), + getAuthInfo: jest.fn(), + clearAuthInfo: jest.fn(), + isAuthenticated: jest.fn(), + getAccessToken: jest.fn(), + refreshAccessToken: jest.fn(), +}; diff --git a/src/services/__tests__/logout.service.spec.ts b/src/services/__tests__/logout.service.spec.ts deleted file mode 100644 index 872532a..0000000 --- a/src/services/__tests__/logout.service.spec.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { getLogoutURL } from '@/components/shared'; -import { LogoutService } from '../logout.service'; - -// Mock the shared components -jest.mock('@/components/shared', () => ({ - getLogoutURL: jest.fn(), - isProduction: jest.fn().mockReturnValue(false), -})); - -describe('LogoutService', () => { - // Setup for all tests - let originalFetch: typeof global.fetch; - let originalConsoleError: typeof console.error; - - beforeEach(() => { - // Store the original fetch and console.error - originalFetch = global.fetch; - originalConsoleError = console.error; - - // Mock fetch and console.error - global.fetch = jest.fn(); - console.error = jest.fn(); - - // Reset mocks - (getLogoutURL as jest.Mock).mockReturnValue('https://api.example.com/logout'); - }); - - afterEach(() => { - // Restore original fetch and console.error - global.fetch = originalFetch; - console.error = originalConsoleError; - jest.clearAllMocks(); - }); - - describe('requestRestLogout', () => { - it('should handle successful logout', async () => { - // Mock successful response with logout_url - const mockResponse = { - logout_url: 'https://api.example.com/complete-logout', - json: jest.fn().mockResolvedValue({ logout_url: 'https://api.example.com/complete-logout' }), - headers: { - get: jest.fn().mockReturnValue('application/json'), - }, - }; - - // Setup fetch mock implementation - (global.fetch as jest.Mock) - .mockResolvedValueOnce(mockResponse) // First call to fetch - .mockResolvedValueOnce({}); // Second call to logout_url - - // Call the service - const result = await LogoutService.requestRestLogout(); - - // Verify results - expect(result).toEqual({ logout: 1 }); - expect(global.fetch).toHaveBeenCalledTimes(2); - expect(global.fetch).toHaveBeenNthCalledWith(1, 'https://api.example.com/logout', { - method: 'GET', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - }); - expect(global.fetch).toHaveBeenNthCalledWith(2, 'https://api.example.com/complete-logout', { - method: 'GET', - credentials: 'include', - redirect: 'manual', - }); - }); - - it('should handle successful logout without logout_url', async () => { - // Mock successful response without logout_url - const mockResponse = { - json: jest.fn().mockResolvedValue({}), - headers: { - get: jest.fn().mockReturnValue('application/json'), - }, - }; - - // Setup fetch mock implementation - (global.fetch as jest.Mock).mockResolvedValueOnce(mockResponse); - - // Call the service - const result = await LogoutService.requestRestLogout(); - - // Verify results - expect(result).toEqual({ logout: 1 }); - expect(global.fetch).toHaveBeenCalledTimes(1); - }); - - it('should handle non-JSON response', async () => { - // Mock non-JSON response - const mockResponse = { - headers: { - get: jest.fn().mockReturnValue('text/html'), - }, - }; - - // Setup fetch mock implementation - (global.fetch as jest.Mock).mockResolvedValueOnce(mockResponse); - - // Call the service - const result = await LogoutService.requestRestLogout(); - - // Verify results - expect(result).toEqual({ logout: 1 }); - expect(global.fetch).toHaveBeenCalledTimes(1); - }); - - it('should handle network failure', async () => { - // Setup fetch mock to throw network error - const networkError = new Error('Network error'); - (global.fetch as jest.Mock).mockRejectedValueOnce(networkError); - - // Call the service - const result = await LogoutService.requestRestLogout(); - - // Verify results - should still return success as cleanup is handled by client-store - expect(result).toEqual({ logout: 1 }); - // Verify error is logged - }); - - it('should handle invalid JSON response', async () => { - // Mock response with JSON parsing error - const jsonError = new Error('Invalid JSON'); - const mockResponse = { - json: jest.fn().mockRejectedValueOnce(jsonError), - headers: { - get: jest.fn().mockReturnValue('application/json'), - }, - }; - - // Setup fetch mock implementation - (global.fetch as jest.Mock).mockResolvedValueOnce(mockResponse); - - // Call the service - const result = await LogoutService.requestRestLogout(); - - // Verify results - should still return success as cleanup is handled by client-store - expect(result).toEqual({ logout: 1 }); - // Verify error is logged - }); - - it('should handle cross-origin cookie restrictions', async () => { - // Mock successful response with logout_url - const mockResponse = { - logout_url: 'https://api.example.com/complete-logout', - json: jest.fn().mockResolvedValue({ logout_url: 'https://api.example.com/complete-logout' }), - headers: { - get: jest.fn().mockReturnValue('application/json'), - }, - }; - - // Setup fetch mock implementation for first call - (global.fetch as jest.Mock).mockResolvedValueOnce(mockResponse); - - // Setup fetch mock implementation for second call to throw a cross-origin error - (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Cross-origin request blocked')); - - // Call the service - const result = await LogoutService.requestRestLogout(); - - // Verify results - should still return success as cleanup is handled by client-store - expect(result).toEqual({ logout: 1 }); - }); - - it('should use production URL when in production mode', async () => { - // Mock isProduction to return true - require('@/components/shared').isProduction.mockReturnValue(true); - - // Mock getLogoutURL to return production URL - (getLogoutURL as jest.Mock).mockReturnValue('https://api.production.com/logout'); - - // Mock successful response - const mockResponse = { - json: jest.fn().mockResolvedValue({}), - headers: { - get: jest.fn().mockReturnValue('application/json'), - }, - }; - - // Setup fetch mock implementation - (global.fetch as jest.Mock).mockResolvedValueOnce(mockResponse); - - // Call the service - await LogoutService.requestRestLogout(); - - // Verify production URL was used - expect(global.fetch).toHaveBeenCalledWith('https://api.production.com/logout', expect.any(Object)); - }); - }); -}); diff --git a/src/services/__tests__/whoami.service.spec.ts b/src/services/__tests__/whoami.service.spec.ts deleted file mode 100644 index 7a4bcc2..0000000 --- a/src/services/__tests__/whoami.service.spec.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { getWhoAmIURL } from '@/components/shared'; -import { WhoAmIService } from '../whoami.service'; - -// Mock the shared components -jest.mock('@/components/shared', () => ({ - getWhoAmIURL: jest.fn(), - isProduction: jest.fn().mockReturnValue(false), -})); - -describe('WhoAmIService', () => { - // Setup for all tests - let originalFetch: typeof global.fetch; - let originalConsoleError: typeof console.error; - - beforeEach(() => { - // Store the original fetch and console.error - originalFetch = global.fetch; - originalConsoleError = console.error; - - // Mock fetch and console.error - global.fetch = jest.fn(); - console.error = jest.fn(); - - // Reset mocks - (getWhoAmIURL as jest.Mock).mockReturnValue('https://api.example.com/whoami'); - }); - - afterEach(() => { - // Restore original fetch and console.error - global.fetch = originalFetch; - console.error = originalConsoleError; - jest.clearAllMocks(); - }); - - describe('checkWhoAmI', () => { - it('should handle successful response', async () => { - // Mock successful response - const mockResponse = { - json: jest.fn().mockResolvedValue({ - user_id: 12345, - email: 'test@example.com', - is_authenticated: true, - }), - headers: { - get: jest.fn().mockReturnValue('application/json'), - }, - }; - - // Setup fetch mock implementation - (global.fetch as jest.Mock).mockResolvedValueOnce(mockResponse); - - // Call the service - const result = await WhoAmIService.checkWhoAmI(); - - // Verify results - expect(result).toEqual({ - success: true, - data: { - user_id: 12345, - email: 'test@example.com', - is_authenticated: true, - }, - }); - expect(global.fetch).toHaveBeenCalledTimes(1); - expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/whoami', { - method: 'GET', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - }); - }); - - it('should handle 401 unauthorized error', async () => { - // Mock 401 unauthorized response - const mockResponse = { - json: jest.fn().mockResolvedValue({ - error: { - code: 401, - status: 'Unauthorized', - message: 'Session expired', - }, - }), - headers: { - get: jest.fn().mockReturnValue('application/json'), - }, - }; - - // Setup fetch mock implementation - (global.fetch as jest.Mock).mockResolvedValueOnce(mockResponse); - - // Call the service - const result = await WhoAmIService.checkWhoAmI(); - - // Verify results - expect(result).toEqual({ - error: { - code: 401, - status: 'Unauthorized', - }, - }); - }); - - it('should handle network failure', async () => { - // Setup fetch mock to throw network error - const networkError = new Error('Network error'); - (global.fetch as jest.Mock).mockRejectedValueOnce(networkError); - - // Call the service - const result = await WhoAmIService.checkWhoAmI(); - - // Verify results - expect(result).toEqual({ - error: { - message: 'Network error', - }, - }); - expect(console.error).toHaveBeenCalledWith('[WhoAmI Error]', networkError); - }); - - it('should handle invalid JSON response', async () => { - // Mock response with JSON parsing error - const jsonError = new Error('Invalid JSON'); - const mockResponse = { - json: jest.fn().mockRejectedValueOnce(jsonError), - headers: { - get: jest.fn().mockReturnValue('application/json'), - }, - }; - - // Setup fetch mock implementation - (global.fetch as jest.Mock).mockResolvedValueOnce(mockResponse); - - // Call the service - const result = await WhoAmIService.checkWhoAmI(); - - // Verify results - expect(result).toEqual({ - error: { - message: 'Invalid JSON', - }, - }); - expect(console.error).toHaveBeenCalledWith('[WhoAmI Error]', jsonError); - }); - - it('should handle cross-origin cookie restrictions', async () => { - // Setup fetch mock to throw cross-origin error - const corsError = new Error('Cross-origin request blocked'); - (global.fetch as jest.Mock).mockRejectedValueOnce(corsError); - - // Call the service - const result = await WhoAmIService.checkWhoAmI(); - - // Verify results - expect(result).toEqual({ - error: { - message: 'Cross-origin request blocked', - }, - }); - expect(console.error).toHaveBeenCalledWith('[WhoAmI Error]', corsError); - }); - - it('should use production URL when in production mode', async () => { - // Mock isProduction to return true - require('@/components/shared').isProduction.mockReturnValue(true); - - // Mock getWhoAmIURL to return production URL - (getWhoAmIURL as jest.Mock).mockReturnValue('https://api.production.com/whoami'); - - // Mock successful response - const mockResponse = { - json: jest.fn().mockResolvedValue({ is_authenticated: true }), - headers: { - get: jest.fn().mockReturnValue('application/json'), - }, - }; - - // Setup fetch mock implementation - (global.fetch as jest.Mock).mockResolvedValueOnce(mockResponse); - - // Call the service - await WhoAmIService.checkWhoAmI(); - - // Verify production URL was used - expect(global.fetch).toHaveBeenCalledWith('https://api.production.com/whoami', expect.any(Object)); - }); - - it('should handle other error codes', async () => { - // Mock 500 server error response - const mockResponse = { - json: jest.fn().mockResolvedValue({ - error: { - code: 500, - status: 'Server Error', - message: 'Internal server error', - }, - }), - headers: { - get: jest.fn().mockReturnValue('application/json'), - }, - }; - - // Setup fetch mock implementation - (global.fetch as jest.Mock).mockResolvedValueOnce(mockResponse); - - // Call the service - const result = await WhoAmIService.checkWhoAmI(); - - // Verify results - should return success since it's not a 401 - expect(result).toEqual({ - success: true, - data: { - error: { - code: 500, - status: 'Server Error', - message: 'Internal server error', - }, - }, - }); - }); - - it('should log data for debugging', async () => { - // This test is skipped because the console.log call is conditional - // and may not be called in all environments - - // The actual implementation in whoami.service.ts has a console.log call - // but we don't want to make assumptions about when it's called - // as it might be removed or modified in the future - - // Instead, we'll verify the service works correctly - // by checking the return value - - // Mock successful response - const mockResponse = { - json: jest.fn().mockResolvedValue({ is_authenticated: true }), - headers: { - get: jest.fn().mockReturnValue('application/json'), - }, - }; - - // Setup fetch mock implementation - (global.fetch as jest.Mock).mockResolvedValueOnce(mockResponse); - - // Call the service - const result = await WhoAmIService.checkWhoAmI(); - - // Verify the result is correct - expect(result).toEqual({ - success: true, - data: { is_authenticated: true }, - }); - }); - }); -}); diff --git a/src/services/logout.service.ts b/src/services/logout.service.ts deleted file mode 100644 index 1417596..0000000 --- a/src/services/logout.service.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { getLogoutURL, isProduction } from '@/components/shared'; - -/** - * Service for handling logout operations - */ -export class LogoutService { - /** - * Request logout via REST API endpoint - * @returns Promise with logout response - */ - static async requestRestLogout(): Promise<{ logout: number }> { - try { - const logoutUrl = getLogoutURL(isProduction()); - - // Step 1: Get logout URL and token - const response = await fetch(logoutUrl, { - method: 'GET', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - }); - - // Check if response is JSON - const contentType = response.headers.get('content-type'); - if (contentType && contentType.includes('application/json')) { - const data = await response.json(); - - // Step 2: Call the logout_url to complete logout - if (data.logout_url) { - await fetch(data.logout_url, { - method: 'GET', - credentials: 'include', - redirect: 'manual', - }); - } - } - - // Return success response - cleanup is handled by client-store.js - return { logout: 1 }; - } catch (error: unknown) { - // Ignore CORS errors - console.warn('[Logout Notice]:', error); - // Still return success for client cleanup, but at least track the error - // Consider: Analytics.trackError('logout_failed', error); - return { logout: 1 }; - } - } -} diff --git a/src/services/oauth-token-exchange.service.ts b/src/services/oauth-token-exchange.service.ts index e89fdb1..066801a 100644 --- a/src/services/oauth-token-exchange.service.ts +++ b/src/services/oauth-token-exchange.service.ts @@ -1,4 +1,5 @@ -import { clearCodeVerifier,getCodeVerifier, isProduction } from '@/components/shared'; +import { clearCodeVerifier, getCodeVerifier, isProduction } from '@/components/shared'; +import { ErrorLogger } from '@/utils/error-logger'; import brandConfig from '../../brand.config.json'; /** @@ -14,7 +15,6 @@ interface TokenExchangeResponse { error_description?: string; } -// [AI] /** * Authentication information stored in sessionStorage */ @@ -61,7 +61,7 @@ export class OAuthTokenExchangeService { return authInfo; } catch (error) { - console.error('[OAuth] Error parsing auth_info:', error); + ErrorLogger.error('OAuth', 'Error parsing auth_info', error); return null; } } @@ -118,7 +118,7 @@ export class OAuthTokenExchangeService { const codeVerifier = getCodeVerifier(); if (!codeVerifier) { - console.error('[OAuth Token Exchange] PKCE code verifier not found or expired'); + ErrorLogger.error('OAuth', 'PKCE code verifier not found or expired'); return { error: 'invalid_request', error_description: 'PKCE code verifier not found or expired. Please restart the authentication flow.', @@ -159,8 +159,10 @@ export class OAuthTokenExchangeService { // Check for errors in response if (data.error) { - console.error('[OAuth Token Exchange] Error:', data.error); - console.error('[OAuth Token Exchange] Error description:', data.error_description); + ErrorLogger.error('OAuth', `Token exchange error: ${data.error}`, { + error: data.error, + description: data.error_description, + }); return { error: data.error, error_description: data.error_description, @@ -187,11 +189,56 @@ export class OAuthTokenExchangeService { // Store as JSON string sessionStorage.setItem('auth_info', JSON.stringify(authInfo)); + + // Immediately fetch accounts and initialize WebSocket after token exchange + try { + const { DerivWSAccountsService } = await import('./derivws-accounts.service'); + + // Fetch accounts and store in sessionStorage + const accounts = await DerivWSAccountsService.fetchAccountsList(data.access_token); + + if (accounts && accounts.length > 0) { + // Store accounts + DerivWSAccountsService.storeAccounts(accounts); + + // Set the first account as active in localStorage + const firstAccount = accounts[0]; + localStorage.setItem('active_loginid', firstAccount.account_id); + + // Set account type + const isDemo = firstAccount.account_id.startsWith('VRT') || + firstAccount.account_id.startsWith('VRTC'); + localStorage.setItem('account_type', isDemo ? 'demo' : 'real'); + + ErrorLogger.info('OAuth', 'Accounts fetched and stored', { + loginid: firstAccount.account_id, + }); + + // Trigger WebSocket initialization by reloading or reinitializing api_base + // The api_base will pick up the active_loginid and authorize + const { api_base } = await import('@/external/bot-skeleton'); + await api_base.init(true); // Force new connection with the account + } else { + // No accounts returned - this is an error condition + ErrorLogger.error('OAuth', 'No accounts returned after token exchange'); + return { + error: 'no_accounts', + error_description: 'No accounts available after successful authentication', + }; + } + } catch (error) { + ErrorLogger.error('OAuth', 'Error fetching accounts after token exchange', error); + // Return error status to caller for UI feedback + return { + error: 'account_fetch_failed', + error_description: error instanceof Error ? error.message : 'Failed to fetch accounts after authentication', + }; + } } return data; } catch (error: unknown) { - console.error('[OAuth Token Exchange] Network or parsing error:', error); + ErrorLogger.error('OAuth', 'Token exchange network or parsing error', error); return { error: 'network_error', error_description: error instanceof Error ? error.message : 'Unknown error occurred', @@ -227,7 +274,10 @@ export class OAuthTokenExchangeService { const data: TokenExchangeResponse = await response.json(); if (data.error) { - console.error('[OAuth Token Refresh] Error:', data.error); + ErrorLogger.error('OAuth', `Token refresh error: ${data.error}`, { + error: data.error, + description: data.error_description, + }); return { error: data.error, error_description: data.error_description, @@ -261,7 +311,7 @@ export class OAuthTokenExchangeService { return data; } catch (error: unknown) { - console.error('[OAuth Token Refresh] Error:', error); + ErrorLogger.error('OAuth', 'Token refresh error', error); return { error: 'network_error', error_description: error instanceof Error ? error.message : 'Unknown error occurred', @@ -269,4 +319,3 @@ export class OAuthTokenExchangeService { } } } -// [/AI] diff --git a/src/services/whoami.service.ts b/src/services/whoami.service.ts deleted file mode 100644 index 54b9008..0000000 --- a/src/services/whoami.service.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { getWhoAmIURL, isProduction } from '@/components/shared'; - -/** - * Service for handling WhoAmI session validation operations - */ -export class WhoAmIService { - /** - * Check session validity via REST API whoami endpoint - * @returns Promise with response data: { success: true } or { error: { code: 401, status: 'Unauthorized' } } - */ - static async checkWhoAmI(): Promise<{ - success?: boolean; - data?: any; - error?: { code?: number; status?: string; message?: string }; - }> { - try { - const whoamiUrl = getWhoAmIURL(isProduction()); - - const response = await fetch(whoamiUrl, { - method: 'GET', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - }); - - const data = await response.json(); - - // Check for 401 Unauthorized error in response body - if (data.error && (data.error.code === 401 || data.error.status === 'Unauthorized')) { - return { error: { code: 401, status: 'Unauthorized' } }; - } - - // Return success response - return { success: true, data }; - } catch (error: unknown) { - // eslint-disable-next-line no-console - console.error('[WhoAmI Error]', error); - // Return error but don't trigger cleanup for network errors - return { error: { message: error instanceof Error ? error.message : 'Unknown error' } }; - } - } -} diff --git a/src/stores/client-store.ts b/src/stores/client-store.ts index 27a16fd..b11b480 100644 --- a/src/stores/client-store.ts +++ b/src/stores/client-store.ts @@ -5,16 +5,14 @@ import { isMultipliersOnly, isOptionsBlocked } from '@/components/shared/common/ import { removeCookies } from '@/components/shared/utils/storage/storage'; import { observer as globalObserver, observer } from '@/external/bot-skeleton'; import { api_base } from '@/external/bot-skeleton/services/api/api-base'; +import { ErrorLogger } from '@/utils/error-logger'; import type { Balance } from '@deriv/api-types'; -import { Analytics } from '@deriv-com/analytics'; import { authData$, setAccountList, setAuthData, setIsAuthorized, } from '../external/bot-skeleton/services/api/observables/connection-status-stream'; -import { LogoutService } from '../services/logout.service'; -import { WhoAmIService } from '../services/whoami.service'; import type { TAuthData } from '../types/api-types'; import type RootStore from './root-store'; @@ -33,8 +31,6 @@ export default class ClientStore { private authDataSubscription: { unsubscribe: () => void } | null = null; private root_store: RootStore; private tab_visibility_handler: ((event: Event) => void) | null = null; - private focus_handler: ((event: FocusEvent) => void) | null = null; - private whoami_in_progress = false; private ws_login_id: string | null = null; private is_regenerating = false; private instance_id: string = ''; @@ -63,8 +59,6 @@ export default class ClientStore { this.setBalance(data.current_account.balance.toString()); } - // Check session validity after successful authorization - this.checkWhoAmI(); } }; @@ -87,12 +81,9 @@ export default class ClientStore { this.instance_id = `client_store_${Date.now()}_${crypto.getRandomValues(new Uint32Array(1))[0].toString(36)}`; globalObserver.setState({ 'client.store': this, 'client.store.id': this.instance_id }); - // Set up visibility change listener to check whoami when tab becomes visible + // Set up visibility change listener to regenerate WebSocket when tab becomes visible this.setupVisibilityListener(); - // Set up focus listener to check whoami when window gets focus - this.setupFocusListener(); - makeObservable(this, { accounts: observable, account_list: observable, @@ -210,10 +201,6 @@ export default class ClientStore { }); if (account_list) this.account_list = account_list; - // Check session validity after account list is set - if (this.is_logged_in) { - this.checkWhoAmI(); - } }; setBalance = (balance: string) => { @@ -256,97 +243,75 @@ export default class ClientStore { logout = async () => { if (localStorage.getItem('active_loginid')) { - const response = await LogoutService.requestRestLogout(); - - if (response?.logout === 1) { - // Clear DerivAPI singleton instance and close WebSocket - const { clearDerivApiInstance } = await import( - '@/external/bot-skeleton/services/api/appId' - ); - clearDerivApiInstance(); - - // Clear accounts cache from DerivWSAccountsService - const { DerivWSAccountsService } = await import('@/services/derivws-accounts.service'); - DerivWSAccountsService.clearStoredAccounts(); - DerivWSAccountsService.clearCache(); - - // reset all the states - this.account_list = []; - - this.accounts = {}; - this.is_logged_in = false; - this.loginid = ''; - this.balance = '0'; - this.currency = 'USD'; - - this.all_accounts_balance = null; - - localStorage.removeItem('active_loginid'); - localStorage.removeItem('accountsList'); - localStorage.removeItem('authToken'); - localStorage.removeItem('clientAccounts'); - localStorage.removeItem('account_type'); // Clear account type on logout - removeCookies('client_information'); - - setIsAuthorized(false); - setAccountList([]); - setAuthData(null); - - this.setIsLoggingOut(false); - - Analytics.reset(); - - // disable livechat - window.LC_API?.close_chat?.(); - window.LiveChatWidget?.call('hide'); - - // shutdown and initialize intercom - if (window.Intercom) { - window.Intercom('shutdown'); - window.DerivInterCom.initialize({ - hideLauncher: true, - token: null, - }); - } + // Clear DerivAPI singleton instance and close WebSocket + const { clearDerivApiInstance } = await import('@/external/bot-skeleton/services/api/appId'); + clearDerivApiInstance(); + + // Clear accounts cache from DerivWSAccountsService + const { DerivWSAccountsService } = await import('@/services/derivws-accounts.service'); + DerivWSAccountsService.clearStoredAccounts(); + DerivWSAccountsService.clearCache(); + + // Clear OAuth token from sessionStorage + const { OAuthTokenExchangeService } = await import('@/services/oauth-token-exchange.service'); + OAuthTokenExchangeService.clearAuthInfo(); + + // Reset all the states + this.account_list = []; + this.accounts = {}; + this.is_logged_in = false; + this.loginid = ''; + this.balance = '0'; + this.currency = 'USD'; + this.all_accounts_balance = null; + + // Clear localStorage + localStorage.removeItem('active_loginid'); + localStorage.removeItem('accountsList'); + localStorage.removeItem('authToken'); + localStorage.removeItem('clientAccounts'); + localStorage.removeItem('account_type'); + + // Clear sessionStorage + sessionStorage.clear(); + + // Clear cookies + removeCookies('client_information'); + + // Reset observables + setIsAuthorized(false); + setAccountList([]); + setAuthData(null); + + this.setIsLoggingOut(false); + + // Disable livechat + window.LC_API?.close_chat?.(); + window.LiveChatWidget?.call('hide'); + + // Shutdown and initialize intercom + if (window.Intercom) { + window.Intercom('shutdown'); + window.DerivInterCom.initialize({ + hideLauncher: true, + token: null, + }); } } }; /** - * Checks session validity via whoami service and handles cleanup if needed - */ - async checkWhoAmI() { - if (!this.is_logged_in || this.whoami_in_progress) return; // Only check if logged in and not already checking - - this.whoami_in_progress = true; - try { - const result = await WhoAmIService.checkWhoAmI(); - - // If we get 401 error, user's session is invalid - log them out - if (result.error?.code === 401) { - await this.logout(); - } - } finally { - this.whoami_in_progress = false; - } - } - - /** - * Sets up visibility change listener to check whoami when tab becomes visible + * Sets up visibility change listener to regenerate WebSocket when tab becomes visible */ setupVisibilityListener() { - //need to call check who am i rest api in every tab switch // Remove existing listener if any this.removeVisibilityListener(); - // Create handler function - make it async to properly coordinate operations + // Create handler function this.tab_visibility_handler = async () => { if (document.visibilityState === 'visible' && !this.is_regenerating) { - // Tab became visible - check whoami first and await its completion - await this.checkWhoAmI(); - - // Only regenerate if still logged in after whoami check - if (this.is_logged_in && !this.whoami_in_progress) { + // Tab became visible - check if WebSocket needs regeneration + if (this.is_logged_in) { this.checkAndRegenerateWebSocket(); } } @@ -436,8 +401,6 @@ export default class ClientStore { this.setIsLoggingOut(false); - Analytics.reset(); - // disable livechat window.LC_API?.close_chat?.(); window.LiveChatWidget?.call('hide'); @@ -447,7 +410,7 @@ export default class ClientStore { try { await api_base.init(true); // ✅ Await the async call } catch (initError) { - console.error('WebSocket initialization failed:', initError); + ErrorLogger.error('ClientStore', 'WebSocket initialization failed', initError); this.setIsAccountRegenerating(false); throw initError; // Re-throw to be caught by outer catch if needed } @@ -456,7 +419,7 @@ export default class ClientStore { this.setWebSocketLoginId(active_login_id); } } catch (error) { - console.error('WebSocket regeneration failed:', error); + ErrorLogger.error('ClientStore', 'WebSocket regeneration failed', error); this.setIsAccountRegenerating(false); // Consider showing user-facing error notification here // or dispatching an event that UI components can listen to @@ -465,34 +428,6 @@ export default class ClientStore { } } - /** - * Sets up focus listener to check whoami when window gets focus - */ - setupFocusListener() { - // Remove existing listener if any - this.removeFocusListener(); - - // Create handler function - make it async to properly coordinate operations - this.focus_handler = async () => { - if (!this.is_regenerating) { - await this.checkWhoAmI(); - } - }; - - // Add listener - window.addEventListener('focus', this.focus_handler); - } - - /** - * Removes the focus listener - */ - removeFocusListener() { - if (this.focus_handler) { - window.removeEventListener('focus', this.focus_handler); - this.focus_handler = null; - } - } - /** * Removes the visibility change listener */ @@ -507,9 +442,6 @@ export default class ClientStore { this.authDataSubscription?.unsubscribe(); observer.unregister('api.authorize', this.onAuthorizeEvent); this.removeVisibilityListener(); - this.removeFocusListener(); - // Cancel any in-flight whoami checks - this.whoami_in_progress = false; // Properly clean up the global observer reference // Only clear if this instance is the one referenced by checking the instance ID diff --git a/src/utils/__mocks__/error-logger.ts b/src/utils/__mocks__/error-logger.ts new file mode 100644 index 0000000..2f76b16 --- /dev/null +++ b/src/utils/__mocks__/error-logger.ts @@ -0,0 +1,19 @@ +// Mock for ErrorLogger +export const ErrorLogger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + configure: jest.fn(), + setErrorReportingService: jest.fn(), + setUserContext: jest.fn(), + clearUserContext: jest.fn(), + getConfig: jest.fn(), +}; + +export enum LogLevel { + ERROR = 'error', + WARN = 'warn', + INFO = 'info', + DEBUG = 'debug', +} diff --git a/src/utils/datadog.ts b/src/utils/datadog.ts deleted file mode 100644 index b8c772a..0000000 --- a/src/utils/datadog.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { datadogRum } from '@datadog/browser-rum'; - -const getConfigValues = (is_production: boolean) => { - if (is_production) { - return { - service: 'bot.deriv.com', - version: `v${process.env.REF_NAME}`, - sessionReplaySampleRate: Number(process.env.DATADOG_SESSION_REPLAY_SAMPLE_RATE ?? 1), - sessionSampleRate: Number(process.env.DATADOG_SESSION_SAMPLE_RATE ?? 10), - env: 'production', - applicationId: process.env.DATADOG_APPLICATION_ID ?? '', - clientToken: process.env.DATADOG_CLIENT_TOKEN ?? '', - }; - } - return { - service: 'staging-bot.deriv.com', - version: `v${process.env.REF_NAME}`, - sessionReplaySampleRate: 0, - sessionSampleRate: 100, - env: 'staging', - applicationId: process.env.DATADOG_APPLICATION_ID ?? '', - clientToken: process.env.DATADOG_CLIENT_TOKEN ?? '', - }; -}; - -const initDatadog = (is_datadog_enabled: boolean) => { - if (!is_datadog_enabled) return; - if (process.env.APP_ENV === 'production' || process.env.APP_ENV === 'staging') { - const is_production = process.env.APP_ENV === 'production'; - const { - service, - version, - sessionReplaySampleRate, - sessionSampleRate, - env, - applicationId = '', - clientToken = '', - } = getConfigValues(is_production) ?? {}; - - datadogRum.init({ - service, - version, - sessionReplaySampleRate, - sessionSampleRate, - env, - applicationId, - clientToken, - site: 'datadoghq.com', - trackUserInteractions: true, - trackResources: true, - trackLongTasks: true, - defaultPrivacyLevel: 'mask-user-input', - enableExperimentalFeatures: ['clickmap'], - }); - } -}; - -export default initDatadog; diff --git a/src/utils/error-logger.ts b/src/utils/error-logger.ts new file mode 100644 index 0000000..87108e0 --- /dev/null +++ b/src/utils/error-logger.ts @@ -0,0 +1,395 @@ +/** + * Centralized Error Logging Utility + * + * This utility provides a consistent interface for logging errors, warnings, and info messages + * across the application. It can be easily extended to integrate with error reporting services + * like Sentry, TrackJS, or other monitoring tools. + * + * @example + * ```typescript + * import { ErrorLogger } from '@/utils/error-logger'; + * + * // Log an error + * ErrorLogger.error('OAuth', 'Token exchange failed', error); + * + * // Log a warning + * ErrorLogger.warn('Storage', 'Failed to clear cache', { key: 'auth_info' }); + * + * // Log info + * ErrorLogger.info('Auth', 'User logged in successfully', { loginid: 'CR123' }); + * ``` + */ + +/** + * Log level enum + */ +export enum LogLevel { + ERROR = 'error', + WARN = 'warn', + INFO = 'info', + DEBUG = 'debug', +} + +/** + * Log context interface for additional metadata + */ +export interface LogContext { + [key: string]: unknown; +} + +/** + * Error reporting service interface + * Implement this interface to integrate with external error reporting services + */ +export interface ErrorReportingService { + /** + * Report an error to the external service + */ + reportError(error: Error, context?: LogContext): void; + + /** + * Report a warning to the external service + */ + reportWarning(message: string, context?: LogContext): void; + + /** + * Set user context for error reporting + */ + setUserContext(userId: string, email?: string): void; + + /** + * Clear user context + */ + clearUserContext(): void; +} + +/** + * Configuration for the error logger + */ +interface ErrorLoggerConfig { + /** + * Enable/disable console logging + */ + enableConsole: boolean; + + /** + * Minimum log level to output + */ + minLogLevel: LogLevel; + + /** + * External error reporting service (e.g., Sentry, TrackJS) + */ + errorReportingService?: ErrorReportingService; + + /** + * Enable/disable error reporting to external service + */ + enableErrorReporting: boolean; +} + +/** + * Default configuration + */ +const defaultConfig: ErrorLoggerConfig = { + enableConsole: true, + minLogLevel: LogLevel.INFO, + enableErrorReporting: false, + errorReportingService: undefined, +}; + +/** + * Centralized Error Logger + */ +class ErrorLoggerClass { + private config: ErrorLoggerConfig = defaultConfig; + + /** + * Configure the error logger + */ + configure(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + /** + * Get current configuration + */ + getConfig(): ErrorLoggerConfig { + return { ...this.config }; + } + + /** + * Set error reporting service + */ + setErrorReportingService(service: ErrorReportingService): void { + this.config.errorReportingService = service; + this.config.enableErrorReporting = true; + } + + /** + * Check if log level should be output + */ + private shouldLog(level: LogLevel): boolean { + const levels = [LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR]; + const currentLevelIndex = levels.indexOf(this.config.minLogLevel); + const requestedLevelIndex = levels.indexOf(level); + return requestedLevelIndex >= currentLevelIndex; + } + + /** + * Format log message with prefix + */ + private formatMessage(category: string, message: string): string { + return `[${category}] ${message}`; + } + + /** + * Log to console + */ + private logToConsole( + level: LogLevel, + category: string, + message: string, + data?: unknown + ): void { + if (!this.config.enableConsole || !this.shouldLog(level)) { + return; + } + + const formattedMessage = this.formatMessage(category, message); + + switch (level) { + case LogLevel.ERROR: + if (data !== undefined) { + console.error(formattedMessage, data); + } else { + console.error(formattedMessage); + } + break; + case LogLevel.WARN: + if (data !== undefined) { + console.warn(formattedMessage, data); + } else { + console.warn(formattedMessage); + } + break; + case LogLevel.INFO: + if (data !== undefined) { + console.log(formattedMessage, data); + } else { + console.log(formattedMessage); + } + break; + case LogLevel.DEBUG: + if (data !== undefined) { + console.debug(formattedMessage, data); + } else { + console.debug(formattedMessage); + } + break; + } + } + + /** + * Report to external error reporting service + */ + private reportToExternalService( + level: LogLevel, + category: string, + message: string, + data?: unknown + ): void { + if (!this.config.enableErrorReporting || !this.config.errorReportingService) { + return; + } + + const context: LogContext = { + category, + level, + ...(data && typeof data === 'object' ? (data as LogContext) : { data }), + }; + + try { + if (level === LogLevel.ERROR && data instanceof Error) { + this.config.errorReportingService.reportError(data, context); + } else if (level === LogLevel.WARN) { + this.config.errorReportingService.reportWarning( + this.formatMessage(category, message), + context + ); + } + } catch (reportingError) { + // Fallback to console if external reporting fails + console.error('[ErrorLogger] Failed to report to external service:', reportingError); + } + } + + /** + * Log an error message + * + * @param category - Category/module name (e.g., 'OAuth', 'Storage', 'API') + * @param message - Error message + * @param data - Optional error object or additional context + * + * @example + * ErrorLogger.error('OAuth', 'Token exchange failed', error); + * ErrorLogger.error('Storage', 'Failed to clear cache', { key: 'auth_info' }); + */ + error(category: string, message: string, data?: unknown): void { + this.logToConsole(LogLevel.ERROR, category, message, data); + this.reportToExternalService(LogLevel.ERROR, category, message, data); + } + + /** + * Log a warning message + * + * @param category - Category/module name + * @param message - Warning message + * @param data - Optional additional context + * + * @example + * ErrorLogger.warn('API', 'Rate limit approaching', { remaining: 10 }); + */ + warn(category: string, message: string, data?: unknown): void { + this.logToConsole(LogLevel.WARN, category, message, data); + this.reportToExternalService(LogLevel.WARN, category, message, data); + } + + /** + * Log an info message + * + * @param category - Category/module name + * @param message - Info message + * @param data - Optional additional context + * + * @example + * ErrorLogger.info('Auth', 'User logged in', { loginid: 'CR123' }); + */ + info(category: string, message: string, data?: unknown): void { + this.logToConsole(LogLevel.INFO, category, message, data); + } + + /** + * Log a debug message + * + * @param category - Category/module name + * @param message - Debug message + * @param data - Optional additional context + * + * @example + * ErrorLogger.debug('WebSocket', 'Connection state changed', { state: 'open' }); + */ + debug(category: string, message: string, data?: unknown): void { + this.logToConsole(LogLevel.DEBUG, category, message, data); + } + + /** + * Set user context for error reporting + * + * @param userId - User ID + * @param email - Optional user email + */ + setUserContext(userId: string, email?: string): void { + if (this.config.errorReportingService) { + this.config.errorReportingService.setUserContext(userId, email); + } + } + + /** + * Clear user context + */ + clearUserContext(): void { + if (this.config.errorReportingService) { + this.config.errorReportingService.clearUserContext(); + } + } +} + +/** + * Singleton instance of ErrorLogger + */ +export const ErrorLogger = new ErrorLoggerClass(); + +/** + * Example implementation of Sentry error reporting service + * Uncomment and implement when ready to integrate Sentry + */ +/* +import * as Sentry from '@sentry/browser'; + +class SentryErrorReportingService implements ErrorReportingService { + reportError(error: Error, context?: LogContext): void { + Sentry.captureException(error, { + extra: context, + }); + } + + reportWarning(message: string, context?: LogContext): void { + Sentry.captureMessage(message, { + level: 'warning', + extra: context, + }); + } + + setUserContext(userId: string, email?: string): void { + Sentry.setUser({ + id: userId, + email, + }); + } + + clearUserContext(): void { + Sentry.setUser(null); + } +} + +// Initialize Sentry and configure ErrorLogger +Sentry.init({ + dsn: 'YOUR_SENTRY_DSN', + environment: process.env.NODE_ENV, +}); + +ErrorLogger.setErrorReportingService(new SentryErrorReportingService()); +*/ + +/** + * Example implementation of TrackJS error reporting service + * Uncomment and implement when ready to integrate TrackJS + */ +/* +import { TrackJS } from 'trackjs'; + +class TrackJSErrorReportingService implements ErrorReportingService { + reportError(error: Error, context?: LogContext): void { + TrackJS.track(error); + if (context) { + TrackJS.addMetadata('context', context); + } + } + + reportWarning(message: string, context?: LogContext): void { + TrackJS.console.warn(message, context); + } + + setUserContext(userId: string, email?: string): void { + TrackJS.configure({ + userId, + metadata: { email }, + }); + } + + clearUserContext(): void { + TrackJS.configure({ + userId: undefined, + metadata: {}, + }); + } +} + +// Initialize TrackJS and configure ErrorLogger +TrackJS.install({ + token: 'YOUR_TRACKJS_TOKEN', +}); + +ErrorLogger.setErrorReportingService(new TrackJSErrorReportingService()); +*/