sales force integrated
This commit is contained in:
parent
536a72ff4a
commit
af8cc5d52d
314
SALESFORCE_BACKEND_CALLBACK_UPDATE.md
Normal file
314
SALESFORCE_BACKEND_CALLBACK_UPDATE.md
Normal file
@ -0,0 +1,314 @@
|
||||
# 🔄 Salesforce OAuth - Backend Callback Flow Update
|
||||
|
||||
## What Changed?
|
||||
|
||||
The Salesforce OAuth implementation has been updated to use a **backend callback flow** instead of a mobile deep-link scheme. This means the backend server handles the OAuth callback and token exchange directly.
|
||||
|
||||
---
|
||||
|
||||
## Previous Flow vs New Flow
|
||||
|
||||
### ❌ Previous Flow (Deep Link)
|
||||
```
|
||||
Salesforce → Mobile App (deep link) → Send code to backend → Backend exchanges token
|
||||
```
|
||||
- **Redirect URI**: `centralizedreportingsystem://oauth/salesforce/callback`
|
||||
- **Problem**: Custom scheme requires complex deep linking configuration
|
||||
- **Risk**: Authorization code exposed to mobile app
|
||||
|
||||
### ✅ New Flow (Backend Callback)
|
||||
```
|
||||
Salesforce → Backend Server → Exchange token → Success page → Mobile app detects success
|
||||
```
|
||||
- **Redirect URI**: `https://YOUR_BACKEND_URL/api/v1/users/oauth/callback?user_uuid=USER_ID&service_name=salesforce`
|
||||
- **Benefits**:
|
||||
- Backend handles token exchange securely
|
||||
- No need for deep linking configuration
|
||||
- Authorization code never exposed to mobile app
|
||||
- Standard OAuth 2.0 web flow
|
||||
|
||||
---
|
||||
|
||||
## Code Changes
|
||||
|
||||
### SalesforceAuth.tsx
|
||||
|
||||
#### 1. Updated Configuration
|
||||
```typescript
|
||||
// OLD
|
||||
const SALESFORCE_CONFIG = {
|
||||
CLIENT_ID: 'YOUR_CLIENT_ID',
|
||||
REDIRECT_URI: 'centralizedreportingsystem://oauth/salesforce/callback',
|
||||
AUTH_BASE_URL: 'https://login.salesforce.com',
|
||||
};
|
||||
|
||||
// NEW
|
||||
const SALESFORCE_CONFIG = {
|
||||
CLIENT_ID: 'YOUR_CLIENT_ID',
|
||||
BACKEND_BASE_URL: 'https://d5285bf63993.ngrok-free.app',
|
||||
CALLBACK_PATH: '/api/v1/users/oauth/callback',
|
||||
AUTH_BASE_URL: 'https://login.salesforce.com',
|
||||
};
|
||||
```
|
||||
|
||||
#### 2. Updated OAuth URL Builder
|
||||
```typescript
|
||||
// OLD
|
||||
const buildSalesforceAuthUrl = (): string => {
|
||||
const redirectUri = 'centralizedreportingsystem://oauth/salesforce/callback';
|
||||
// ...
|
||||
};
|
||||
|
||||
// NEW
|
||||
const buildSalesforceAuthUrl = (userUuid: string): string => {
|
||||
// Build redirect URI with query parameters for backend
|
||||
const redirectUri = `${BACKEND_BASE_URL}${CALLBACK_PATH}?user_uuid=${userUuid}&service_name=salesforce`;
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
#### 3. New Detection Functions
|
||||
```typescript
|
||||
// Check if URL is the backend callback
|
||||
const isBackendCallbackUri = (url: string): boolean => {
|
||||
return url.includes('/api/v1/users/oauth/callback');
|
||||
};
|
||||
|
||||
// Check for success
|
||||
const isCallbackSuccess = (url: string): boolean => {
|
||||
const status = getQueryParamFromUrl(url, 'status');
|
||||
return status === 'success';
|
||||
};
|
||||
|
||||
// Check for error
|
||||
const isCallbackError = (url: string): boolean => {
|
||||
const status = getQueryParamFromUrl(url, 'status');
|
||||
return status === 'error' || status === 'failure';
|
||||
};
|
||||
```
|
||||
|
||||
#### 4. Simplified Navigation Handler
|
||||
```typescript
|
||||
// OLD - Mobile app handled token exchange
|
||||
handleNavigationStateChange = (navState) => {
|
||||
if (isRedirectUri(url) && authCode) {
|
||||
handleAuthorizationCode(authCode); // Send code to backend
|
||||
}
|
||||
};
|
||||
|
||||
// NEW - Backend handles token exchange
|
||||
handleNavigationStateChange = (navState) => {
|
||||
if (isBackendCallbackUri(url)) {
|
||||
if (isCallbackSuccess(url)) {
|
||||
handleBackendSuccess(); // Just close modal
|
||||
}
|
||||
if (isCallbackError(url)) {
|
||||
handleBackendError(url); // Show error
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation Required
|
||||
|
||||
### Endpoint: `GET /api/v1/users/oauth/callback`
|
||||
|
||||
Your backend **must** implement this endpoint to handle the OAuth callback from Salesforce.
|
||||
|
||||
#### Query Parameters (Received from Salesforce):
|
||||
```typescript
|
||||
{
|
||||
user_uuid: string; // User ID (passed in redirect_uri)
|
||||
service_name: string; // 'salesforce' (passed in redirect_uri)
|
||||
code: string; // Authorization code from Salesforce
|
||||
}
|
||||
```
|
||||
|
||||
#### Backend Responsibilities:
|
||||
|
||||
1. **Receive the callback** from Salesforce
|
||||
2. **Extract** query parameters: `user_uuid`, `service_name`, `code`
|
||||
3. **Exchange** authorization code for tokens with Salesforce:
|
||||
```http
|
||||
POST https://login.salesforce.com/services/oauth2/token
|
||||
grant_type=authorization_code
|
||||
code={CODE}
|
||||
client_id={CLIENT_ID}
|
||||
client_secret={CLIENT_SECRET}
|
||||
redirect_uri={CALLBACK_URL_WITH_PARAMS}
|
||||
```
|
||||
4. **Store** tokens (encrypted) in database
|
||||
5. **Return** success/error HTML page:
|
||||
- Success: Redirect to `?status=success`
|
||||
- Error: Redirect to `?status=error&message=ERROR_MESSAGE`
|
||||
|
||||
#### Example Response (Success):
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Success</title></head>
|
||||
<body>
|
||||
<h1>✓ Authentication Successful!</h1>
|
||||
<p>Your Salesforce account has been connected.</p>
|
||||
<script>
|
||||
// Mobile app will detect this URL
|
||||
window.location.search = '?status=success';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
See [SALESFORCE_BACKEND_CALLBACK_FLOW.md](src/modules/integrations/screens/SALESFORCE_BACKEND_CALLBACK_FLOW.md) for complete backend implementation guide.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Steps
|
||||
|
||||
### 1. Update Salesforce Connected App
|
||||
|
||||
In your Salesforce Connected App settings:
|
||||
|
||||
**Old Callback URL**:
|
||||
```
|
||||
centralizedreportingsystem://oauth/salesforce/callback
|
||||
```
|
||||
|
||||
**New Callback URL**:
|
||||
```
|
||||
https://YOUR_BACKEND_URL/api/v1/users/oauth/callback
|
||||
```
|
||||
|
||||
> Note: Don't include query parameters in the Salesforce Connected App config. The app adds them dynamically.
|
||||
|
||||
### 2. Update Mobile App (Already Done ✅)
|
||||
|
||||
The `SalesforceAuth.tsx` component has been updated with:
|
||||
- ✅ New backend callback URL configuration
|
||||
- ✅ Detection logic for backend callback
|
||||
- ✅ Success/error handling
|
||||
- ✅ User UUID passed to backend
|
||||
|
||||
### 3. Configure Backend
|
||||
|
||||
Update your environment variables:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
SALESFORCE_CLIENT_ID=3MVG9GBhY6wQjl2sueQtv2NXMm3EuWtEvOQoeKRAzYcgs2...
|
||||
SALESFORCE_CLIENT_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
BACKEND_URL=https://d5285bf63993.ngrok-free.app
|
||||
ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
||||
```
|
||||
|
||||
### 4. Implement Backend Endpoint
|
||||
|
||||
Create the `/api/v1/users/oauth/callback` endpoint following the guide in [SALESFORCE_BACKEND_CALLBACK_FLOW.md](src/modules/integrations/screens/SALESFORCE_BACKEND_CALLBACK_FLOW.md).
|
||||
|
||||
---
|
||||
|
||||
## Testing the Updated Flow
|
||||
|
||||
### Test Checklist:
|
||||
|
||||
1. **Start Backend Server**
|
||||
- Ensure backend is running
|
||||
- Verify callback endpoint is accessible
|
||||
- For local testing, use ngrok: `ngrok http 4000`
|
||||
|
||||
2. **Update Configuration**
|
||||
- Update `BACKEND_BASE_URL` in `SalesforceAuth.tsx`
|
||||
- Update Salesforce Connected App callback URL
|
||||
- Restart mobile app
|
||||
|
||||
3. **Test Authentication**
|
||||
- Open app → CRM & Sales → Salesforce
|
||||
- Complete Salesforce login
|
||||
- **Expected**: Redirect to backend URL
|
||||
- **Backend logs should show**: "Received callback", "Token exchange successful", "Tokens stored"
|
||||
- **Mobile app should show**: Success message, then close modal
|
||||
|
||||
4. **Verify Token Storage**
|
||||
- Check database for stored tokens
|
||||
- Verify tokens are encrypted
|
||||
- Verify associated with correct user_id
|
||||
|
||||
5. **Test Re-authentication**
|
||||
- Tap "Re-auth" button
|
||||
- Complete flow again
|
||||
- Verify tokens are updated in database
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
### Check Logs
|
||||
|
||||
#### Mobile App (React Native)
|
||||
```
|
||||
[SalesforceAuth] Built OAuth URL: https://login.salesforce.com/...
|
||||
[SalesforceAuth] Redirect URI: https://YOUR_BACKEND/callback?user_uuid=...
|
||||
[SalesforceAuth] Backend callback detected
|
||||
[SalesforceAuth] Backend callback indicates success
|
||||
```
|
||||
|
||||
#### Backend Server
|
||||
```
|
||||
[OAuth Callback] Received: { user_uuid: '...', service_name: 'salesforce', hasCode: true }
|
||||
[OAuth Callback] Token exchange successful
|
||||
[OAuth Callback] Tokens stored successfully
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| Backend not reachable | Use ngrok for local testing: `ngrok http 4000` |
|
||||
| Callback URL mismatch | Update Salesforce Connected App with exact backend URL |
|
||||
| Token exchange fails | Verify CLIENT_SECRET is correct in backend |
|
||||
| Mobile app doesn't detect success | Ensure backend returns HTML with `?status=success` |
|
||||
|
||||
---
|
||||
|
||||
## Benefits of Backend Callback Flow
|
||||
|
||||
✅ **More Secure**: Authorization code never exposed to mobile app
|
||||
✅ **Simpler Configuration**: No deep linking setup needed
|
||||
✅ **Standard OAuth**: Follows standard web OAuth 2.0 flow
|
||||
✅ **Backend Control**: Full control over token exchange and storage
|
||||
✅ **Better Error Handling**: Backend can provide detailed error messages
|
||||
✅ **Token Refresh**: Backend can implement automatic token refresh
|
||||
|
||||
---
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
- [x] Update `SalesforceAuth.tsx` configuration
|
||||
- [x] Update OAuth URL builder to include user_uuid
|
||||
- [x] Add backend callback detection logic
|
||||
- [x] Simplify navigation handler
|
||||
- [ ] **Implement backend `/oauth/callback` endpoint**
|
||||
- [ ] **Update Salesforce Connected App callback URL**
|
||||
- [ ] **Configure backend environment variables**
|
||||
- [ ] **Test end-to-end flow**
|
||||
- [ ] **Verify token storage in database**
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Implement Backend Endpoint**: Follow the guide in [SALESFORCE_BACKEND_CALLBACK_FLOW.md](src/modules/integrations/screens/SALESFORCE_BACKEND_CALLBACK_FLOW.md)
|
||||
|
||||
2. **Update Salesforce Config**: Change callback URL in Connected App settings
|
||||
|
||||
3. **Test Thoroughly**: Test authentication flow with both success and error scenarios
|
||||
|
||||
4. **Deploy**: Deploy backend changes and update mobile app configuration
|
||||
|
||||
---
|
||||
|
||||
**Updated**: October 2025
|
||||
**Status**: ✅ Mobile App Updated - Backend Implementation Required
|
||||
**Version**: 2.0.0
|
||||
|
||||
380
SALESFORCE_INTEGRATION_SUMMARY.md
Normal file
380
SALESFORCE_INTEGRATION_SUMMARY.md
Normal file
@ -0,0 +1,380 @@
|
||||
# 🎉 Salesforce Authentication Integration - Implementation Summary
|
||||
|
||||
## Overview
|
||||
Successfully implemented Salesforce OAuth authentication for the Centralized Reporting System, enabling users to authenticate with Salesforce through the CRM & Sales Integration category.
|
||||
|
||||
---
|
||||
|
||||
## ✅ What Was Implemented
|
||||
|
||||
### 1. **SalesforceAuth Component** (`src/modules/integrations/screens/SalesforceAuth.tsx`)
|
||||
- Full OAuth 2.0 authentication flow using WebView
|
||||
- Mobile-optimized with `display=touch` parameter
|
||||
- Authorization code capture and exchange
|
||||
- Comprehensive error handling
|
||||
- Loading states with processing modal
|
||||
- Deep linking support for OAuth callback
|
||||
- Similar architecture to ZohoAuth for consistency
|
||||
|
||||
### 2. **Updated IntegrationCategoryScreen** (`src/modules/integrations/screens/IntegrationCategoryScreen.tsx`)
|
||||
- Added Salesforce authentication support
|
||||
- Added `checkSalesforceToken()` function to verify existing tokens
|
||||
- Updated `handleReAuthenticate()` to support both Zoho and Salesforce
|
||||
- Added Salesforce Auth modal integration
|
||||
- Enhanced authentication logic to distinguish between services
|
||||
- Added re-authentication button for Salesforce
|
||||
|
||||
### 3. **Deep Linking Configuration**
|
||||
- **iOS**: Updated `Info.plist` with `CFBundleURLTypes` for custom URL scheme
|
||||
- **Android**: Already configured in `AndroidManifest.xml` (existing deep linking support)
|
||||
- URL Scheme: `centralizedreportingsystem://oauth/salesforce/callback`
|
||||
|
||||
### 4. **Comprehensive Setup Guide** (`src/modules/integrations/screens/SALESFORCE_SETUP.md`)
|
||||
- Step-by-step Salesforce Connected App creation
|
||||
- OAuth configuration instructions
|
||||
- Mobile app configuration guide
|
||||
- Backend implementation guidance
|
||||
- Troubleshooting section
|
||||
- Security best practices
|
||||
- Testing procedures
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuration Required
|
||||
|
||||
### Step 1: Create Salesforce Connected App
|
||||
|
||||
1. **Access Salesforce Setup**
|
||||
- Log in to Salesforce (Production or Sandbox)
|
||||
- Click gear icon (⚙️) → Setup
|
||||
|
||||
2. **Create Connected App**
|
||||
- Navigate to App Manager
|
||||
- Click "New Connected App"
|
||||
- Fill in basic information:
|
||||
- **Name**: `Centralized Reporting System`
|
||||
- **Contact Email**: Your email
|
||||
|
||||
3. **Enable OAuth Settings**
|
||||
- Check "Enable OAuth Settings"
|
||||
- **Callback URL**: `centralizedreportingsystem://oauth/salesforce/callback`
|
||||
- **Select OAuth Scopes**:
|
||||
- Access the identity URL service (id, profile, email, address, phone)
|
||||
- Access unique user identifiers (openid)
|
||||
- Perform requests at any time (refresh_token, offline_access)
|
||||
- Manage user data via APIs (api)
|
||||
|
||||
4. **Save and Retrieve Credentials**
|
||||
- Wait 2-10 minutes for processing
|
||||
- Navigate back to App Manager → View your app
|
||||
- Copy the **Consumer Key** (Client ID)
|
||||
- Reveal and copy the **Consumer Secret** (Client Secret)
|
||||
|
||||
### Step 2: Update Application Configuration
|
||||
|
||||
Open `src/modules/integrations/screens/SalesforceAuth.tsx` and update:
|
||||
|
||||
```typescript
|
||||
const SALESFORCE_CONFIG = {
|
||||
// ⚠️ REPLACE with your Consumer Key from Salesforce
|
||||
CLIENT_ID: 'YOUR_CONSUMER_KEY_HERE',
|
||||
|
||||
// This must match exactly what you configured in Salesforce
|
||||
REDIRECT_URI: 'centralizedreportingsystem://oauth/salesforce/callback',
|
||||
|
||||
// Production: https://login.salesforce.com
|
||||
// Sandbox: https://test.salesforce.com
|
||||
AUTH_BASE_URL: 'https://login.salesforce.com',
|
||||
|
||||
RESPONSE_TYPE: 'code',
|
||||
DISPLAY: 'touch',
|
||||
};
|
||||
```
|
||||
|
||||
### Step 3: Backend Token Exchange
|
||||
|
||||
Your backend needs to implement the token exchange endpoint:
|
||||
|
||||
**Endpoint**: `POST /api/v1/integrations/manage-token`
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
{
|
||||
"authorization_code": "aPrxXXXXXXXXX",
|
||||
"id": "user-uuid",
|
||||
"service_name": "salesforce",
|
||||
"access_token": "user-session-token"
|
||||
}
|
||||
```
|
||||
|
||||
**Backend Should**:
|
||||
1. Receive the authorization code
|
||||
2. Exchange it with Salesforce for an access token:
|
||||
```http
|
||||
POST https://login.salesforce.com/services/oauth2/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=authorization_code
|
||||
&code={AUTH_CODE}
|
||||
&client_id={CLIENT_ID}
|
||||
&client_secret={CLIENT_SECRET}
|
||||
&redirect_uri={REDIRECT_URI}
|
||||
```
|
||||
3. Store tokens securely (encrypted)
|
||||
4. Return success/failure response
|
||||
|
||||
**Salesforce Response**:
|
||||
```json
|
||||
{
|
||||
"access_token": "00D...ABC",
|
||||
"refresh_token": "5Aep...XYZ",
|
||||
"instance_url": "https://yourinstance.salesforce.com",
|
||||
"id": "https://login.salesforce.com/id/00D.../005...",
|
||||
"token_type": "Bearer",
|
||||
"issued_at": "1699999999999"
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Optional - Data Sync Endpoint
|
||||
|
||||
If you want to sync Salesforce data, implement:
|
||||
|
||||
**Endpoint**: `POST /api/v1/integrations/salesforce/sync/schedule`
|
||||
|
||||
This is called automatically after successful authentication to initiate data synchronization.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 How to Use
|
||||
|
||||
### For End Users:
|
||||
|
||||
1. **Navigate to Integration**
|
||||
- Open app → Integrations tab
|
||||
- Tap "CRM & Sales Integration"
|
||||
|
||||
2. **Select Salesforce**
|
||||
- Tap on "Salesforce" service
|
||||
- If not authenticated, Salesforce login screen will appear
|
||||
|
||||
3. **Authenticate**
|
||||
- Enter Salesforce credentials
|
||||
- Complete 2FA if enabled
|
||||
- Approve OAuth permissions
|
||||
|
||||
4. **Success**
|
||||
- Authorization code is captured
|
||||
- Backend exchanges code for tokens
|
||||
- Processing modal shows progress
|
||||
- Modal closes automatically on success
|
||||
|
||||
5. **Re-authenticate (Optional)**
|
||||
- Tap the "Re-auth" button next to Salesforce
|
||||
- Useful for changing organizations or refreshing permissions
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ File Structure
|
||||
|
||||
```
|
||||
src/modules/integrations/screens/
|
||||
├── SalesforceAuth.tsx # New: Salesforce OAuth component
|
||||
├── ZohoAuth.tsx # Existing: Zoho OAuth component
|
||||
├── IntegrationCategoryScreen.tsx # Updated: Added Salesforce support
|
||||
├── SALESFORCE_SETUP.md # New: Setup documentation
|
||||
└── ...
|
||||
|
||||
ios/CentralizedReportingSystem/
|
||||
└── Info.plist # Updated: Added CFBundleURLTypes
|
||||
|
||||
android/app/src/main/
|
||||
└── AndroidManifest.xml # Already configured (no changes needed)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Considerations
|
||||
|
||||
1. **Never Commit Credentials**
|
||||
- Store Client ID and Secret in environment variables
|
||||
- Use `.env` files (don't commit to Git)
|
||||
- Example:
|
||||
```bash
|
||||
# .env
|
||||
SALESFORCE_CLIENT_ID=your_consumer_key
|
||||
SALESFORCE_CLIENT_SECRET=your_consumer_secret
|
||||
```
|
||||
|
||||
2. **Backend Token Storage**
|
||||
- Encrypt tokens before storing
|
||||
- Use secure database encryption
|
||||
- Implement token rotation
|
||||
|
||||
3. **HTTPS Only**
|
||||
- Always use `https://` for Salesforce APIs
|
||||
- Never downgrade to HTTP in production
|
||||
|
||||
4. **Minimal Scopes**
|
||||
- Request only necessary OAuth scopes
|
||||
- Avoid `full` access unless required
|
||||
|
||||
5. **State Parameter** (Future Enhancement)
|
||||
- Consider adding CSRF protection with state parameter
|
||||
- Validate state on callback
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Checklist
|
||||
|
||||
- [ ] Create Salesforce Connected App
|
||||
- [ ] Update CLIENT_ID in SalesforceAuth.tsx
|
||||
- [ ] Backend token exchange endpoint is ready
|
||||
- [ ] Build and run the app (both iOS and Android)
|
||||
- [ ] Navigate to CRM & Sales Integration
|
||||
- [ ] Tap Salesforce service
|
||||
- [ ] Complete OAuth flow
|
||||
- [ ] Verify authorization code is captured
|
||||
- [ ] Check backend logs for successful token exchange
|
||||
- [ ] Test re-authentication flow
|
||||
- [ ] Test with Production Salesforce org
|
||||
- [ ] Test with Sandbox Salesforce org (if applicable)
|
||||
- [ ] Verify error handling (wrong credentials, network failure)
|
||||
- [ ] Test deep linking callback on both platforms
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Common Issues & Solutions
|
||||
|
||||
### Issue 1: "redirect_uri_mismatch"
|
||||
**Solution**: Ensure the redirect URI in code exactly matches Salesforce Connected App configuration.
|
||||
|
||||
### Issue 2: "invalid_client_id"
|
||||
**Solution**: Wait 2-10 minutes after creating Connected App, or verify the Client ID is correct.
|
||||
|
||||
### Issue 3: WebView Doesn't Load
|
||||
**Solution**: Check internet connection, verify AUTH_BASE_URL is correct.
|
||||
|
||||
### Issue 4: App Doesn't Capture Code
|
||||
**Solution**: Verify deep linking is configured correctly in Info.plist and AndroidManifest.xml.
|
||||
|
||||
### Issue 5: Backend Token Exchange Fails
|
||||
**Solution**: Verify backend endpoint, check Consumer Secret, ensure authorization code hasn't expired (15 min limit).
|
||||
|
||||
---
|
||||
|
||||
## 📊 Architecture Flow
|
||||
|
||||
```
|
||||
User Taps Salesforce
|
||||
↓
|
||||
Check for Existing Token (checkSalesforceToken)
|
||||
↓
|
||||
[No Token?]
|
||||
↓
|
||||
Open SalesforceAuth Modal
|
||||
↓
|
||||
Display Salesforce Login (WebView)
|
||||
↓
|
||||
User Authenticates
|
||||
↓
|
||||
Salesforce Redirects to: centralizedreportingsystem://oauth/salesforce/callback?code=...
|
||||
↓
|
||||
App Captures Authorization Code
|
||||
↓
|
||||
Send Code to Backend (manageToken API)
|
||||
↓
|
||||
Backend Exchanges Code for Tokens (Salesforce API)
|
||||
↓
|
||||
Backend Stores Tokens (Encrypted)
|
||||
↓
|
||||
[Optional] Schedule Data Sync
|
||||
↓
|
||||
Success! Close Modal
|
||||
↓
|
||||
User is Authenticated
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Next Steps
|
||||
|
||||
### Immediate (Required):
|
||||
1. ✅ Create Salesforce Connected App
|
||||
2. ✅ Update `CLIENT_ID` in `SalesforceAuth.tsx`
|
||||
3. ✅ Implement backend token exchange endpoint
|
||||
4. ✅ Test the integration end-to-end
|
||||
|
||||
### Future Enhancements:
|
||||
- [ ] Add token refresh logic
|
||||
- [ ] Implement data synchronization from Salesforce
|
||||
- [ ] Add Salesforce dashboard screens
|
||||
- [ ] Support multiple Salesforce orgs per user
|
||||
- [ ] Add offline token management
|
||||
- [ ] Implement webhook support for real-time updates
|
||||
- [ ] Add analytics for Salesforce data
|
||||
- [ ] Create Salesforce-specific widgets
|
||||
|
||||
---
|
||||
|
||||
## 📚 Resources
|
||||
|
||||
### Documentation Files:
|
||||
- **Setup Guide**: `src/modules/integrations/screens/SALESFORCE_SETUP.md`
|
||||
- **Component**: `src/modules/integrations/screens/SalesforceAuth.tsx`
|
||||
- **Integration Screen**: `src/modules/integrations/screens/IntegrationCategoryScreen.tsx`
|
||||
|
||||
### External Resources:
|
||||
- [Salesforce Connected Apps](https://help.salesforce.com/s/articleView?id=sf.connected_app_overview.htm)
|
||||
- [Salesforce OAuth 2.0](https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_flow.htm)
|
||||
- [React Native WebView](https://github.com/react-native-webview/react-native-webview)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Key Features
|
||||
|
||||
✅ Full OAuth 2.0 authentication flow
|
||||
✅ Mobile-optimized login experience
|
||||
✅ Automatic authorization code capture
|
||||
✅ Backend token exchange integration
|
||||
✅ Re-authentication support
|
||||
✅ Comprehensive error handling
|
||||
✅ Loading and processing states
|
||||
✅ Deep linking configuration
|
||||
✅ Production and Sandbox support
|
||||
✅ Consistent with existing Zoho integration
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Support
|
||||
|
||||
If you encounter issues:
|
||||
1. Check the `SALESFORCE_SETUP.md` guide
|
||||
2. Review console logs for error messages
|
||||
3. Verify Salesforce Connected App configuration
|
||||
4. Check backend logs for token exchange errors
|
||||
5. Test with both Production and Sandbox environments
|
||||
|
||||
---
|
||||
|
||||
**Implementation Date**: October 2025
|
||||
**Status**: ✅ Complete - Pending Configuration
|
||||
**Version**: 1.0.0
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes for Developers
|
||||
|
||||
- The Salesforce authentication follows the same pattern as Zoho authentication for consistency
|
||||
- Authorization codes expire in 15 minutes - ensure timely backend exchange
|
||||
- The `manageToken` API endpoint is reused for both Zoho and Salesforce (differentiated by `service_name` parameter)
|
||||
- Deep linking is already configured for the app; Salesforce uses the same scheme
|
||||
- The component includes comprehensive logging for debugging
|
||||
- Error handling includes automatic retry functionality
|
||||
- The processing modal provides user feedback during async operations
|
||||
|
||||
---
|
||||
|
||||
**Ready to Deploy!** 🚀
|
||||
|
||||
Once you've completed the configuration steps above, the Salesforce integration will be fully functional. Users will be able to authenticate with their Salesforce accounts directly from the CRM & Sales Integration category.
|
||||
|
||||
@ -56,5 +56,16 @@
|
||||
<string>Roboto-SemiBold.ttf</string>
|
||||
<string>Roboto-Thin.ttf</string>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>centralizedreportingsystem</string>
|
||||
</array>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.centralizedreportingsystem</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -1,16 +1,30 @@
|
||||
import React from 'react';
|
||||
import { createStackNavigator } from '@react-navigation/stack';
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { RootState } from '@/store/store';
|
||||
import CrmDashboardScreen from '@/modules/crm/zoho/screens/CrmDashboardScreen';
|
||||
import ZohoCrmDataScreen from '@/modules/crm/zoho/screens/ZohoCrmDataScreen';
|
||||
import SalesforceCrmDashboardScreen from '@/modules/crm/salesforce/screens/SalesforceCrmDashboardScreen';
|
||||
import SalesforceDataScreen from '@/modules/crm/salesforce/screens/SalesforceDataScreen';
|
||||
|
||||
const Stack = createStackNavigator();
|
||||
|
||||
const CrmNavigator = () => (
|
||||
<Stack.Navigator>
|
||||
<Stack.Screen name="CrmDashboard" component={CrmDashboardScreen} options={{headerShown:false}} />
|
||||
<Stack.Screen name="ZohoCrmData" component={ZohoCrmDataScreen} options={{headerShown:false}} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
const CrmNavigator = () => {
|
||||
// Get the selected service from Redux to determine initial screen
|
||||
const selectedService = useSelector((state: RootState) => state.integrations.selectedService);
|
||||
|
||||
// Determine initial route based on selected CRM service
|
||||
const initialRouteName = selectedService === 'salesforce' ? 'SalesforceDashboard' : 'CrmDashboard';
|
||||
|
||||
return (
|
||||
<Stack.Navigator initialRouteName={initialRouteName}>
|
||||
<Stack.Screen name="CrmDashboard" component={CrmDashboardScreen} options={{headerShown:false}} />
|
||||
<Stack.Screen name="ZohoCrmData" component={ZohoCrmDataScreen} options={{headerShown:false}} />
|
||||
<Stack.Screen name="SalesforceDashboard" component={SalesforceCrmDashboardScreen} options={{headerShown:false}} />
|
||||
<Stack.Screen name="SalesforceData" component={SalesforceDataScreen} options={{headerShown:false}} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
export default CrmNavigator;
|
||||
|
||||
|
||||
265
src/modules/crm/salesforce/README.md
Normal file
265
src/modules/crm/salesforce/README.md
Normal file
@ -0,0 +1,265 @@
|
||||
# Salesforce CRM Module
|
||||
|
||||
## Overview
|
||||
This module provides a complete Salesforce CRM integration for the Centralized Reporting System, including dashboard visualizations, data management, and comprehensive analytics.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
salesforce/
|
||||
├── components/
|
||||
│ └── SalesforceDataCards.tsx # Card components for each data type
|
||||
├── screens/
|
||||
│ ├── SalesforceCrmDashboardScreen.tsx # Dashboard with KPIs and charts
|
||||
│ └── SalesforceDataScreen.tsx # Tabbed data view with all CRM data
|
||||
├── services/
|
||||
│ └── salesforceCrmAPI.ts # API service for Salesforce data
|
||||
├── store/
|
||||
│ ├── salesforceCrmSlice.ts # Redux slice with state management
|
||||
│ └── selectors.ts # Redux selectors
|
||||
├── types/
|
||||
│ └── SalesforceCrmTypes.ts # TypeScript type definitions
|
||||
└── index.ts # Module exports
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The module integrates with the following Salesforce CRM endpoints:
|
||||
|
||||
- **Events**: `/api/v1/n8n/salesforce/crm/events`
|
||||
- **Leads**: `/api/v1/n8n/salesforce/crm/leads`
|
||||
- **Tasks**: `/api/v1/n8n/salesforce/crm/tasks`
|
||||
- **Accounts**: `/api/v1/n8n/salesforce/crm/accounts`
|
||||
- **Opportunities**: `/api/v1/n8n/salesforce/crm/opportunities`
|
||||
|
||||
## Features
|
||||
|
||||
### Dashboard Screen (`SalesforceCrmDashboardScreen`)
|
||||
- **Hero Stats**: Large cards showing Leads and Opportunities totals
|
||||
- **Pipeline Overview**: Total pipeline value, average deal size, and open deals
|
||||
- **Module Counts**: Compact grid showing Events, Tasks, and Accounts counts
|
||||
- **Lead Status Distribution**: Pie chart showing New, Working, and Qualified leads
|
||||
- **Task Status**: Stacked bar chart showing Open, Completed, and High Priority tasks
|
||||
- **Key Metrics**: Upcoming Events and Won Deals summary
|
||||
- **Pull-to-refresh** functionality
|
||||
- **Real-time data updates**
|
||||
|
||||
### Data Screen (`SalesforceDataScreen`)
|
||||
- **Tabbed Interface**: Switch between Events, Leads, Tasks, Accounts, and Opportunities
|
||||
- **Data Cards**: Rich card display for each data type with relevant information
|
||||
- **Infinite Scrolling**: Load more data as you scroll
|
||||
- **Count Badges**: Show total count for each tab
|
||||
- **Pull-to-refresh** functionality
|
||||
- **Error handling and loading states**
|
||||
|
||||
### Data Types
|
||||
|
||||
#### Event
|
||||
- Subject, Location, Description
|
||||
- Start/End DateTime
|
||||
- All Day Event indicator
|
||||
- Created date
|
||||
|
||||
#### Lead
|
||||
- Name (First + Last)
|
||||
- Company, Email, Phone
|
||||
- Status (New, Working, Nurturing, Qualified, Unqualified)
|
||||
- Title, Lead Source
|
||||
- Created date
|
||||
|
||||
#### Task
|
||||
- Subject, Description
|
||||
- Status (Open, In Progress, Completed, Waiting, Deferred)
|
||||
- Priority (High, Normal, Low)
|
||||
- Activity Date
|
||||
- Created/Modified dates
|
||||
|
||||
#### Account
|
||||
- Name, Industry, Type
|
||||
- Phone, Website
|
||||
- Billing Address (City, State, Country)
|
||||
- Annual Revenue
|
||||
- Created date
|
||||
|
||||
#### Opportunity
|
||||
- Name, Stage, Type
|
||||
- Amount, Probability
|
||||
- Close Date, Forecast Category
|
||||
- Created/Modified dates
|
||||
|
||||
## Redux State Management
|
||||
|
||||
### State Structure
|
||||
```typescript
|
||||
{
|
||||
events: SalesforceEvent[];
|
||||
leads: SalesforceLead[];
|
||||
tasks: SalesforceTask[];
|
||||
accounts: SalesforceAccount[];
|
||||
opportunities: SalesforceOpportunity[];
|
||||
loading: { /* loading states for each type */ };
|
||||
errors: { /* error states for each type */ };
|
||||
pagination: { /* pagination info for each type */ };
|
||||
counts: { /* total counts from API */ };
|
||||
lastUpdated: { /* last updated timestamps */ };
|
||||
}
|
||||
```
|
||||
|
||||
### Actions
|
||||
- `fetchAllSalesforceCrmData()` - Fetch all data types in parallel
|
||||
- `fetchSalesforceEvents()` - Fetch events with pagination
|
||||
- `fetchSalesforceLeads()` - Fetch leads with pagination
|
||||
- `fetchSalesforceTasks()` - Fetch tasks with pagination
|
||||
- `fetchSalesforceAccounts()` - Fetch accounts with pagination
|
||||
- `fetchSalesforceOpportunities()` - Fetch opportunities with pagination
|
||||
- `fetchSalesforceCrmCounts()` - Fetch total counts for all types
|
||||
- `resetEventsPagination()` - Reset events pagination
|
||||
- `resetLeadsPagination()` - Reset leads pagination
|
||||
- `resetTasksPagination()` - Reset tasks pagination
|
||||
- `resetAccountsPagination()` - Reset accounts pagination
|
||||
- `resetOpportunitiesPagination()` - Reset opportunities pagination
|
||||
- `clearData()` - Clear all data
|
||||
- `clearErrors()` - Clear all errors
|
||||
|
||||
### Selectors
|
||||
- `selectSalesforceEvents` - Get events array
|
||||
- `selectSalesforceLeads` - Get leads array
|
||||
- `selectSalesforceTasks` - Get tasks array
|
||||
- `selectSalesforceAccounts` - Get accounts array
|
||||
- `selectSalesforceOpportunities` - Get opportunities array
|
||||
- `selectSalesforceCrmLoading` - Get loading states
|
||||
- `selectSalesforceCrmErrors` - Get error states
|
||||
- `selectSalesforceCrmCounts` - Get total counts
|
||||
- `selectDashboardData` - Get computed dashboard data with summary
|
||||
- `selectIsAnyLoading` - Check if any data is loading
|
||||
- `selectHasAnyError` - Check if any errors exist
|
||||
|
||||
## Navigation
|
||||
|
||||
The module is integrated into the CRM navigator:
|
||||
|
||||
```typescript
|
||||
<Stack.Screen
|
||||
name="SalesforceDashboard"
|
||||
component={SalesforceCrmDashboardScreen}
|
||||
options={{headerShown:false}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="SalesforceData"
|
||||
component={SalesforceDataScreen}
|
||||
options={{headerShown:false}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Navigate to Salesforce Dashboard
|
||||
```typescript
|
||||
navigation.navigate('SalesforceDashboard');
|
||||
```
|
||||
|
||||
### Navigate to Salesforce Data Screen
|
||||
```typescript
|
||||
navigation.navigate('SalesforceData');
|
||||
```
|
||||
|
||||
### Fetch Salesforce Data
|
||||
```typescript
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { fetchAllSalesforceCrmData } from '@/modules/crm/salesforce';
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Fetch all data
|
||||
await dispatch(fetchAllSalesforceCrmData()).unwrap();
|
||||
|
||||
// Fetch specific data type with pagination
|
||||
await dispatch(fetchSalesforceLeads({ page: 1, limit: 20 })).unwrap();
|
||||
```
|
||||
|
||||
### Access Salesforce Data
|
||||
```typescript
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectSalesforceLeads, selectDashboardData } from '@/modules/crm/salesforce';
|
||||
|
||||
// Get leads
|
||||
const leads = useSelector(selectSalesforceLeads);
|
||||
|
||||
// Get dashboard data with computed summaries
|
||||
const dashboardData = useSelector(selectDashboardData);
|
||||
```
|
||||
|
||||
## API Response Format
|
||||
|
||||
All endpoints return responses in the following format:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Salesforce crm {resource} data fetched successfully",
|
||||
"data": {
|
||||
"success": true,
|
||||
"data": [ /* array of records */ ],
|
||||
"count": 56,
|
||||
"metadata": {
|
||||
"totalSize": 56,
|
||||
"done": true,
|
||||
"nextRecordsUrl": null
|
||||
}
|
||||
},
|
||||
"timestamp": "2025-10-09T06:40:31.428Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
The module follows the application's design system:
|
||||
- Uses theme colors from `useTheme()` hook
|
||||
- Consistent spacing and typography
|
||||
- Responsive layouts
|
||||
- Material Community Icons
|
||||
- Smooth animations and transitions
|
||||
|
||||
## Data Persistence
|
||||
|
||||
The Salesforce CRM state is persisted using Redux Persist:
|
||||
- State is saved to AsyncStorage
|
||||
- Survives app restarts
|
||||
- Automatically rehydrated on app launch
|
||||
|
||||
## Error Handling
|
||||
|
||||
- All API calls wrapped in try-catch blocks
|
||||
- Error states stored in Redux
|
||||
- User-friendly error messages via Toast notifications
|
||||
- Retry functionality on error screens
|
||||
- Loading states for all async operations
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
- **Parallel Data Fetching**: All data types fetched simultaneously
|
||||
- **Infinite Scrolling**: Load data in chunks to reduce initial load time
|
||||
- **Memoized Selectors**: Prevent unnecessary re-renders
|
||||
- **Redux Persist**: Cache data locally to reduce API calls
|
||||
- **Pull-to-refresh**: Manual refresh when needed
|
||||
- **Optimized Re-renders**: Use React.memo where appropriate
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Advanced filtering options
|
||||
- Search functionality
|
||||
- Sort options for each data type
|
||||
- Export data to CSV/Excel
|
||||
- Detailed view screens for each record
|
||||
- Create/Edit/Delete operations
|
||||
- Real-time sync with Salesforce
|
||||
- Offline mode support
|
||||
- Analytics and reporting
|
||||
- Custom dashboard widgets
|
||||
|
||||
## Related Modules
|
||||
|
||||
- **Zoho CRM** (`src/modules/crm/zoho/`) - Similar structure for Zoho integration
|
||||
- **CRM Navigator** (`src/modules/crm/navigation/CrmNavigator.tsx`) - Navigation configuration
|
||||
- **Root Store** (`src/store/store.ts`) - Redux store configuration
|
||||
|
||||
453
src/modules/crm/salesforce/components/SalesforceDataCards.tsx
Normal file
453
src/modules/crm/salesforce/components/SalesforceDataCards.tsx
Normal file
@ -0,0 +1,453 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||
import { useTheme } from '@/shared/styles/useTheme';
|
||||
import type { SalesforceEvent, SalesforceLead, SalesforceTask, SalesforceAccount, SalesforceOpportunity } from '../types/SalesforceCrmTypes';
|
||||
|
||||
interface BaseCardProps {
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
interface EventCardProps extends BaseCardProps {
|
||||
event: SalesforceEvent;
|
||||
}
|
||||
|
||||
interface LeadCardProps extends BaseCardProps {
|
||||
lead: SalesforceLead;
|
||||
}
|
||||
|
||||
interface TaskCardProps extends BaseCardProps {
|
||||
task: SalesforceTask;
|
||||
}
|
||||
|
||||
interface AccountCardProps extends BaseCardProps {
|
||||
account: SalesforceAccount;
|
||||
}
|
||||
|
||||
interface OpportunityCardProps extends BaseCardProps {
|
||||
opportunity: SalesforceOpportunity;
|
||||
}
|
||||
|
||||
// Helper function to get status-based colors
|
||||
const getStatusColor = (status: string, colors: any) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'new':
|
||||
case 'open':
|
||||
case 'prospecting':
|
||||
return '#3AA0FF';
|
||||
case 'working':
|
||||
case 'in progress':
|
||||
case 'qualification':
|
||||
case 'needs analysis':
|
||||
return '#F59E0B';
|
||||
case 'qualified':
|
||||
case 'completed':
|
||||
case 'closed won':
|
||||
return '#22C55E';
|
||||
case 'unqualified':
|
||||
case 'closed lost':
|
||||
return '#EF4444';
|
||||
default:
|
||||
return colors.textLight;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get priority-based colors
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority.toLowerCase()) {
|
||||
case 'high':
|
||||
return '#EF4444';
|
||||
case 'normal':
|
||||
return '#F59E0B';
|
||||
case 'low':
|
||||
return '#10B981';
|
||||
default:
|
||||
return '#6B7280';
|
||||
}
|
||||
};
|
||||
|
||||
export const EventCard: React.FC<EventCardProps> = ({ event, onPress }) => {
|
||||
const { colors, fonts, shadows } = useTheme();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border, ...shadows.medium }]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.cardTitleRow}>
|
||||
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.bold }]} numberOfLines={2}>
|
||||
{event.Subject}
|
||||
</Text>
|
||||
{event.IsAllDayEvent && (
|
||||
<View style={[styles.statusBadge, { backgroundColor: colors.primary }]}>
|
||||
<Text style={[styles.statusText, { color: colors.surface, fontFamily: fonts.medium }]}>
|
||||
All Day
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{event.Location && (
|
||||
<Text style={[styles.cardSubtitle, { color: colors.textLight, fontFamily: fonts.regular }]} numberOfLines={1}>
|
||||
{event.Location}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.cardContent}>
|
||||
{event.StartDateTime && (
|
||||
<View style={styles.infoRow}>
|
||||
<Icon name="calendar-start" size={16} color={colors.textLight} />
|
||||
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||
Start: {new Date(event.StartDateTime).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{event.EndDateTime && (
|
||||
<View style={styles.infoRow}>
|
||||
<Icon name="calendar-end" size={16} color={colors.textLight} />
|
||||
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||
End: {new Date(event.EndDateTime).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{event.Description && (
|
||||
<View style={styles.infoRow}>
|
||||
<Icon name="text" size={16} color={colors.textLight} />
|
||||
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]} numberOfLines={2}>
|
||||
{event.Description}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.cardFooter}>
|
||||
<Text style={[styles.dateText, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||
Created: {new Date(event.CreatedDate).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export const LeadCard: React.FC<LeadCardProps> = ({ lead, onPress }) => {
|
||||
const { colors, fonts, shadows } = useTheme();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border, ...shadows.medium }]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.cardTitleRow}>
|
||||
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.bold }]} numberOfLines={1}>
|
||||
{lead.FirstName} {lead.LastName}
|
||||
</Text>
|
||||
<View style={[styles.statusBadge, { backgroundColor: getStatusColor(lead.Status, colors) }]}>
|
||||
<Text style={[styles.statusText, { color: colors.surface, fontFamily: fonts.medium }]}>
|
||||
{lead.Status}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.cardSubtitle, { color: colors.textLight, fontFamily: fonts.regular }]} numberOfLines={1}>
|
||||
{lead.Company}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.cardContent}>
|
||||
<View style={styles.infoRow}>
|
||||
<Icon name="email-outline" size={16} color={colors.textLight} />
|
||||
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]} numberOfLines={1}>
|
||||
{lead.Email}
|
||||
</Text>
|
||||
</View>
|
||||
{lead.Phone && (
|
||||
<View style={styles.infoRow}>
|
||||
<Icon name="phone-outline" size={16} color={colors.textLight} />
|
||||
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||
{lead.Phone}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{lead.Title && (
|
||||
<View style={styles.infoRow}>
|
||||
<Icon name="briefcase-outline" size={16} color={colors.textLight} />
|
||||
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||
{lead.Title}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{lead.LeadSource && (
|
||||
<View style={styles.infoRow}>
|
||||
<Icon name="source-branch" size={16} color={colors.textLight} />
|
||||
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||
Source: {lead.LeadSource}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.cardFooter}>
|
||||
<Text style={[styles.dateText, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||
Created: {lead.CreatedDate ? new Date(lead.CreatedDate).toLocaleDateString() : 'N/A'}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export const TaskCard: React.FC<TaskCardProps> = ({ task, onPress }) => {
|
||||
const { colors, fonts, shadows } = useTheme();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border, ...shadows.medium }]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.cardTitleRow}>
|
||||
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.bold }]} numberOfLines={2}>
|
||||
{task.Subject}
|
||||
</Text>
|
||||
<View style={[styles.statusBadge, { backgroundColor: getStatusColor(task.Status, colors) }]}>
|
||||
<Text style={[styles.statusText, { color: colors.surface, fontFamily: fonts.medium }]}>
|
||||
{task.Status}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.cardContent}>
|
||||
{task.Description && (
|
||||
<Text style={[styles.descriptionText, { color: colors.text, fontFamily: fonts.regular }]} numberOfLines={2}>
|
||||
{task.Description}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.infoRow}>
|
||||
<Icon name="flag-outline" size={16} color={getPriorityColor(task.Priority)} />
|
||||
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||
Priority: {task.Priority}
|
||||
</Text>
|
||||
</View>
|
||||
{task.ActivityDate && (
|
||||
<View style={styles.infoRow}>
|
||||
<Icon name="calendar-outline" size={16} color={colors.textLight} />
|
||||
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||
Due: {new Date(task.ActivityDate).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.cardFooter}>
|
||||
<Text style={[styles.dateText, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||
Created: {new Date(task.CreatedDate).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccountCard: React.FC<AccountCardProps> = ({ account, onPress }) => {
|
||||
const { colors, fonts, shadows } = useTheme();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border, ...shadows.medium }]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.cardTitleRow}>
|
||||
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.bold }]} numberOfLines={1}>
|
||||
{account.Name}
|
||||
</Text>
|
||||
{account.Type && (
|
||||
<View style={[styles.statusBadge, { backgroundColor: colors.primary }]}>
|
||||
<Text style={[styles.statusText, { color: colors.surface, fontFamily: fonts.medium }]}>
|
||||
{account.Type}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{account.Industry && (
|
||||
<Text style={[styles.cardSubtitle, { color: colors.textLight, fontFamily: fonts.regular }]} numberOfLines={1}>
|
||||
{account.Industry}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.cardContent}>
|
||||
{account.Phone && (
|
||||
<View style={styles.infoRow}>
|
||||
<Icon name="phone-outline" size={16} color={colors.textLight} />
|
||||
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||
{account.Phone}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{account.Website && (
|
||||
<View style={styles.infoRow}>
|
||||
<Icon name="web" size={16} color={colors.textLight} />
|
||||
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]} numberOfLines={1}>
|
||||
{account.Website}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{(account.BillingCity || account.BillingState) && (
|
||||
<View style={styles.infoRow}>
|
||||
<Icon name="map-marker-outline" size={16} color={colors.textLight} />
|
||||
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||
{[account.BillingCity, account.BillingState, account.BillingCountry].filter(Boolean).join(', ')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{account.AnnualRevenue && (
|
||||
<View style={styles.infoRow}>
|
||||
<Icon name="currency-usd" size={16} color={colors.primary} />
|
||||
<Text style={[styles.infoText, { color: colors.primary, fontFamily: fonts.bold }]}>
|
||||
Revenue: ${account.AnnualRevenue.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.cardFooter}>
|
||||
<Text style={[styles.dateText, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||
Created: {new Date(account.CreatedDate).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
export const OpportunityCard: React.FC<OpportunityCardProps> = ({ opportunity, onPress }) => {
|
||||
const { colors, fonts, shadows } = useTheme();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border, ...shadows.medium }]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.cardTitleRow}>
|
||||
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.bold }]} numberOfLines={1}>
|
||||
{opportunity.Name}
|
||||
</Text>
|
||||
<View style={[styles.statusBadge, { backgroundColor: getStatusColor(opportunity.StageName, colors) }]}>
|
||||
<Text style={[styles.statusText, { color: colors.surface, fontFamily: fonts.medium }]}>
|
||||
{opportunity.StageName}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{opportunity.Type && (
|
||||
<Text style={[styles.cardSubtitle, { color: colors.textLight, fontFamily: fonts.regular }]} numberOfLines={1}>
|
||||
{opportunity.Type}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.cardContent}>
|
||||
<View style={styles.infoRow}>
|
||||
<Icon name="currency-usd" size={16} color={colors.primary} />
|
||||
<Text style={[styles.infoText, { color: colors.primary, fontFamily: fonts.bold }]}>
|
||||
${opportunity.Amount?.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.infoRow}>
|
||||
<Icon name="trending-up" size={16} color={colors.textLight} />
|
||||
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||
Probability: {opportunity.Probability}%
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.infoRow}>
|
||||
<Icon name="calendar-outline" size={16} color={colors.textLight} />
|
||||
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||
Close date: {new Date(opportunity.CloseDate).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
{opportunity.ForecastCategory && (
|
||||
<View style={styles.infoRow}>
|
||||
<Icon name="chart-line" size={16} color={colors.textLight} />
|
||||
<Text style={[styles.infoText, { color: colors.text, fontFamily: fonts.regular }]}>
|
||||
Forecast: {opportunity.ForecastCategory}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.cardFooter}>
|
||||
<Text style={[styles.dateText, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||
Created: {new Date(opportunity.CreatedDate).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
marginBottom: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
cardHeader: {
|
||||
padding: 16,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
cardTitleRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 4,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
cardSubtitle: {
|
||||
fontSize: 14,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 12,
|
||||
},
|
||||
cardContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
infoText: {
|
||||
marginLeft: 8,
|
||||
fontSize: 14,
|
||||
flex: 1,
|
||||
},
|
||||
descriptionText: {
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
lineHeight: 20,
|
||||
},
|
||||
cardFooter: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#F8F9FA',
|
||||
},
|
||||
dateText: {
|
||||
fontSize: 12,
|
||||
},
|
||||
});
|
||||
|
||||
8
src/modules/crm/salesforce/index.ts
Normal file
8
src/modules/crm/salesforce/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
// Salesforce CRM Module Exports
|
||||
export { default as SalesforceCrmDashboardScreen } from './screens/SalesforceCrmDashboardScreen';
|
||||
export { default as SalesforceDataScreen } from './screens/SalesforceDataScreen';
|
||||
export { default as salesforceCrmSlice } from './store/salesforceCrmSlice';
|
||||
export { selectDashboardData, selectIsAnyLoading, selectHasAnyError, selectSalesforceCrmCounts } from './store/selectors';
|
||||
export * from './types/SalesforceCrmTypes';
|
||||
export { salesforceCrmAPI } from './services/salesforceCrmAPI';
|
||||
|
||||
@ -0,0 +1,468 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, RefreshControl, ScrollView, ActivityIndicator } from 'react-native';
|
||||
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useTheme } from '@/shared/styles/useTheme';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { fetchAllSalesforceCrmData, fetchSalesforceCrmCounts } from '../store/salesforceCrmSlice';
|
||||
import {
|
||||
selectDashboardData,
|
||||
selectIsAnyLoading,
|
||||
selectHasAnyError
|
||||
} from '../store/selectors';
|
||||
import type { AppDispatch } from '@/store/store';
|
||||
import { PieChart, DonutChart, StackedBarChart } from '@/shared/components/charts';
|
||||
|
||||
const SalesforceCrmDashboardScreen: React.FC = () => {
|
||||
const { colors, fonts } = useTheme();
|
||||
const navigation = useNavigation();
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
|
||||
// Redux selectors
|
||||
const dashboardData = useSelector(selectDashboardData);
|
||||
const isLoading = useSelector(selectIsAnyLoading);
|
||||
const hasError = useSelector(selectHasAnyError);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Fetch data on component mount
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [dispatch]);
|
||||
|
||||
// Fetch all Salesforce CRM data
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
await dispatch(fetchAllSalesforceCrmData()).unwrap();
|
||||
await dispatch(fetchSalesforceCrmCounts()).unwrap();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Salesforce CRM data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle refresh
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await fetchData();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={[styles.container, { backgroundColor: colors.background }]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>Salesforce CRM</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.dataButton, { backgroundColor: colors.primary }]}
|
||||
onPress={() => navigation.navigate('SalesforceData' as never)}
|
||||
>
|
||||
<Icon name="database" size={20} color={colors.surface} />
|
||||
<Text style={[styles.dataButtonText, { color: colors.surface, fontFamily: fonts.medium }]}>
|
||||
View Data
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && !dashboardData.events.length && (
|
||||
<View style={[styles.loadingCard, { borderColor: colors.primary, backgroundColor: colors.surface }]}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: colors.primary, fontFamily: fonts.medium }]}>
|
||||
Loading Salesforce CRM data...
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{hasError && !dashboardData.events.length && (
|
||||
<View style={[styles.errorCard, { borderColor: colors.error, backgroundColor: colors.surface }]}>
|
||||
<Icon name="alert-circle" size={20} color={colors.error} />
|
||||
<Text style={[styles.errorText, { color: colors.error, fontFamily: fonts.medium }]}>
|
||||
Failed to load data. Pull to refresh.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Hero Stats - Large Cards */}
|
||||
<View style={styles.heroStatsContainer}>
|
||||
<View style={[styles.heroCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<View style={styles.heroCardHeader}>
|
||||
<Icon name="account-heart" size={24} color="#3B82F6" />
|
||||
<Text style={[styles.heroCardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Leads</Text>
|
||||
</View>
|
||||
<Text style={[styles.heroCardValue, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||
{dashboardData.counts?.leads || dashboardData.summary.totalLeads}
|
||||
</Text>
|
||||
<Text style={[styles.heroCardSubtitle, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||
{dashboardData.summary.newLeads} New · {dashboardData.summary.qualifiedLeads} Qualified
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.heroCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<View style={styles.heroCardHeader}>
|
||||
<Icon name="handshake" size={24} color="#10B981" />
|
||||
<Text style={[styles.heroCardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Opportunities</Text>
|
||||
</View>
|
||||
<Text style={[styles.heroCardValue, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||
{dashboardData.counts?.opportunities || dashboardData.summary.totalOpportunities}
|
||||
</Text>
|
||||
<Text style={[styles.heroCardSubtitle, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||
{dashboardData.summary.openOpportunities} Open · {dashboardData.summary.wonOpportunities} Won
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Pipeline Value Card */}
|
||||
<View style={[styles.pipelineCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<View style={styles.pipelineHeader}>
|
||||
<Icon name="trending-up" size={28} color="#10B981" />
|
||||
<Text style={[styles.pipelineTitle, { color: colors.text, fontFamily: fonts.bold }]}>Pipeline Overview</Text>
|
||||
</View>
|
||||
<View style={styles.pipelineStats}>
|
||||
<View style={styles.pipelineItem}>
|
||||
<Text style={[styles.pipelineLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>Total Pipeline</Text>
|
||||
<Text style={[styles.pipelineValue, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||
${(dashboardData.summary.totalPipelineValue / 1000).toFixed(1)}K
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.pipelineItem}>
|
||||
<Text style={[styles.pipelineLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>Avg Deal Size</Text>
|
||||
<Text style={[styles.pipelineValue, { color: colors.primary, fontFamily: fonts.bold }]}>
|
||||
${(dashboardData.summary.averageDealSize / 1000).toFixed(1)}K
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.pipelineItem}>
|
||||
<Text style={[styles.pipelineLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>Open Deals</Text>
|
||||
<Text style={[styles.pipelineValue, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||
{dashboardData.summary.openOpportunities}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Module Counts - Compact Grid */}
|
||||
<View style={styles.compactGrid}>
|
||||
<View style={[styles.compactCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<Icon name="calendar" size={20} color="#8B5CF6" />
|
||||
<Text style={[styles.compactValue, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||
{dashboardData.counts?.events || dashboardData.summary.totalEvents}
|
||||
</Text>
|
||||
<Text style={[styles.compactLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>Events</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.compactCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<Icon name="check-circle" size={20} color="#10B981" />
|
||||
<Text style={[styles.compactValue, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||
{dashboardData.counts?.tasks || dashboardData.summary.totalTasks}
|
||||
</Text>
|
||||
<Text style={[styles.compactLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>Tasks</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.compactCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<Icon name="domain" size={20} color="#F59E0B" />
|
||||
<Text style={[styles.compactValue, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||
{dashboardData.counts?.accounts || dashboardData.summary.totalAccounts}
|
||||
</Text>
|
||||
<Text style={[styles.compactLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>Accounts</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Lead Status Distribution */}
|
||||
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Lead Status Distribution</Text>
|
||||
|
||||
<View style={styles.chartContainer}>
|
||||
<PieChart
|
||||
data={[
|
||||
{ label: 'New', value: dashboardData.summary.newLeads, color: '#3AA0FF' },
|
||||
{ label: 'Working', value: dashboardData.summary.workingLeads, color: '#F59E0B' },
|
||||
{ label: 'Qualified', value: dashboardData.summary.qualifiedLeads, color: '#22C55E' },
|
||||
].filter(item => item.value > 0)}
|
||||
colors={colors}
|
||||
fonts={fonts}
|
||||
size={140}
|
||||
/>
|
||||
|
||||
{/* Legend */}
|
||||
<View style={styles.pieLegend}>
|
||||
{[
|
||||
{ label: 'New', value: dashboardData.summary.newLeads, color: '#3AA0FF' },
|
||||
{ label: 'Working', value: dashboardData.summary.workingLeads, color: '#F59E0B' },
|
||||
{ label: 'Qualified', value: dashboardData.summary.qualifiedLeads, color: '#22C55E' },
|
||||
].filter(item => item.value > 0).map((item) => (
|
||||
<View key={item.label} style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: item.color }]} />
|
||||
<Text style={[styles.legendText, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||
{item.label} ({item.value})
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Task Status */}
|
||||
<View style={[styles.card, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||||
<Text style={[styles.cardTitle, { color: colors.text, fontFamily: fonts.medium }]}>Task Status</Text>
|
||||
|
||||
<View style={styles.chartContainer}>
|
||||
<StackedBarChart
|
||||
data={[
|
||||
{ label: 'Open', value: dashboardData.summary.openTasks, color: '#3AA0FF' },
|
||||
{ label: 'Completed', value: dashboardData.summary.completedTasks, color: '#22C55E' },
|
||||
{ label: 'High Priority', value: dashboardData.summary.highPriorityTasks, color: '#EF4444' },
|
||||
].filter(item => item.value > 0)}
|
||||
colors={colors}
|
||||
fonts={fonts}
|
||||
height={120}
|
||||
/>
|
||||
|
||||
{/* Legend */}
|
||||
<View style={styles.barLegend}>
|
||||
{[
|
||||
{ label: 'Open', value: dashboardData.summary.openTasks, color: '#3AA0FF' },
|
||||
{ label: 'Completed', value: dashboardData.summary.completedTasks, color: '#22C55E' },
|
||||
{ label: 'High Priority', value: dashboardData.summary.highPriorityTasks, color: '#EF4444' },
|
||||
].filter(item => item.value > 0).map((item) => (
|
||||
<View key={item.label} style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: item.color }]} />
|
||||
<Text style={[styles.legendText, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||
{item.label} ({item.value})
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<View style={styles.metricsRow}>
|
||||
<View style={[styles.metricCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<Icon name="calendar-clock" size={24} color="#F59E0B" />
|
||||
<Text style={[styles.metricValue, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||
{dashboardData.summary.upcomingEvents}
|
||||
</Text>
|
||||
<Text style={[styles.metricLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>Upcoming Events</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.metricCard, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||
<Icon name="trophy" size={24} color="#22C55E" />
|
||||
<Text style={[styles.metricValue, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||
{dashboardData.summary.wonOpportunities}
|
||||
</Text>
|
||||
<Text style={[styles.metricLabel, { color: colors.textLight, fontFamily: fonts.regular }]}>Won Deals</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
},
|
||||
dataButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
},
|
||||
dataButtonText: {
|
||||
marginLeft: 6,
|
||||
fontSize: 14,
|
||||
},
|
||||
loadingCard: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
errorCard: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorText: {
|
||||
marginLeft: 8,
|
||||
fontSize: 14,
|
||||
flex: 1,
|
||||
},
|
||||
heroStatsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
},
|
||||
heroCard: {
|
||||
flex: 1,
|
||||
marginHorizontal: 4,
|
||||
padding: 20,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
heroCardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
heroCardTitle: {
|
||||
fontSize: 16,
|
||||
marginLeft: 8,
|
||||
},
|
||||
heroCardValue: {
|
||||
fontSize: 32,
|
||||
marginBottom: 4,
|
||||
},
|
||||
heroCardSubtitle: {
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
pipelineCard: {
|
||||
padding: 20,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
marginBottom: 16,
|
||||
},
|
||||
pipelineHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
pipelineTitle: {
|
||||
fontSize: 20,
|
||||
marginLeft: 12,
|
||||
},
|
||||
pipelineStats: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
pipelineItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
pipelineLabel: {
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
pipelineValue: {
|
||||
fontSize: 18,
|
||||
},
|
||||
compactGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
},
|
||||
compactCard: {
|
||||
width: '30%',
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
compactValue: {
|
||||
fontSize: 18,
|
||||
marginVertical: 4,
|
||||
},
|
||||
compactLabel: {
|
||||
fontSize: 10,
|
||||
textAlign: 'center',
|
||||
},
|
||||
card: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
chartContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 10,
|
||||
},
|
||||
pieLegend: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
marginTop: 12,
|
||||
gap: 8,
|
||||
},
|
||||
barLegend: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
marginTop: 8,
|
||||
gap: 6,
|
||||
},
|
||||
legendItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
marginTop: 6,
|
||||
},
|
||||
legendDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginRight: 6,
|
||||
},
|
||||
legendText: {
|
||||
fontSize: 12,
|
||||
},
|
||||
metricsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
},
|
||||
metricCard: {
|
||||
flex: 1,
|
||||
marginHorizontal: 4,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
metricValue: {
|
||||
fontSize: 20,
|
||||
marginVertical: 8,
|
||||
},
|
||||
metricLabel: {
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default SalesforceCrmDashboardScreen;
|
||||
|
||||
473
src/modules/crm/salesforce/screens/SalesforceDataScreen.tsx
Normal file
473
src/modules/crm/salesforce/screens/SalesforceDataScreen.tsx
Normal file
@ -0,0 +1,473 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import type { AppDispatch } from '@/store/store';
|
||||
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||
import { LoadingSpinner, ErrorState } from '@/shared/components/ui';
|
||||
import { useTheme } from '@/shared/styles/useTheme';
|
||||
import { showError, showSuccess, showInfo } from '@/shared/utils/Toast';
|
||||
import { EventCard, LeadCard, TaskCard, AccountCard, OpportunityCard } from '../components/SalesforceDataCards';
|
||||
import {
|
||||
selectSalesforceEvents,
|
||||
selectSalesforceLeads,
|
||||
selectSalesforceTasks,
|
||||
selectSalesforceAccounts,
|
||||
selectSalesforceOpportunities,
|
||||
selectSalesforceCrmLoading,
|
||||
selectSalesforceCrmErrors,
|
||||
selectEventsPagination,
|
||||
selectLeadsPagination,
|
||||
selectTasksPagination,
|
||||
selectAccountsPagination,
|
||||
selectOpportunitiesPagination,
|
||||
selectSalesforceCrmCounts
|
||||
} from '../store/selectors';
|
||||
import {
|
||||
fetchAllSalesforceCrmData,
|
||||
fetchSalesforceEvents,
|
||||
fetchSalesforceLeads,
|
||||
fetchSalesforceTasks,
|
||||
fetchSalesforceAccounts,
|
||||
fetchSalesforceOpportunities,
|
||||
fetchSalesforceCrmCounts,
|
||||
resetEventsPagination,
|
||||
resetLeadsPagination,
|
||||
resetTasksPagination,
|
||||
resetAccountsPagination,
|
||||
resetOpportunitiesPagination
|
||||
} from '../store/salesforceCrmSlice';
|
||||
|
||||
const SalesforceDataScreen: React.FC = () => {
|
||||
const { colors, fonts } = useTheme();
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const [selectedTab, setSelectedTab] = useState<'events' | 'leads' | 'tasks' | 'accounts' | 'opportunities'>('leads');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Redux selectors
|
||||
const events = useSelector(selectSalesforceEvents);
|
||||
const leads = useSelector(selectSalesforceLeads);
|
||||
const tasks = useSelector(selectSalesforceTasks);
|
||||
const accounts = useSelector(selectSalesforceAccounts);
|
||||
const opportunities = useSelector(selectSalesforceOpportunities);
|
||||
const loading = useSelector(selectSalesforceCrmLoading);
|
||||
const errors = useSelector(selectSalesforceCrmErrors);
|
||||
|
||||
// Pagination selectors
|
||||
const eventsPagination = useSelector(selectEventsPagination);
|
||||
const leadsPagination = useSelector(selectLeadsPagination);
|
||||
const tasksPagination = useSelector(selectTasksPagination);
|
||||
const accountsPagination = useSelector(selectAccountsPagination);
|
||||
const opportunitiesPagination = useSelector(selectOpportunitiesPagination);
|
||||
const counts = useSelector(selectSalesforceCrmCounts);
|
||||
|
||||
// Fetch Salesforce CRM data
|
||||
const fetchSalesforceData = useCallback(async (showRefresh = false) => {
|
||||
try {
|
||||
if (showRefresh) {
|
||||
setRefreshing(true);
|
||||
}
|
||||
|
||||
// Dispatch Redux action to fetch all Salesforce CRM data
|
||||
await dispatch(fetchAllSalesforceCrmData()).unwrap();
|
||||
|
||||
if (showRefresh) {
|
||||
showSuccess('Salesforce data refreshed successfully');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = 'Failed to fetch Salesforce data';
|
||||
showError(errorMessage);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
// Load more data for infinite scrolling
|
||||
const loadMoreData = useCallback(async (dataType: string) => {
|
||||
try {
|
||||
let pagination;
|
||||
let fetchAction;
|
||||
|
||||
switch (dataType) {
|
||||
case 'events':
|
||||
pagination = eventsPagination;
|
||||
fetchAction = fetchSalesforceEvents;
|
||||
break;
|
||||
case 'leads':
|
||||
pagination = leadsPagination;
|
||||
fetchAction = fetchSalesforceLeads;
|
||||
break;
|
||||
case 'tasks':
|
||||
pagination = tasksPagination;
|
||||
fetchAction = fetchSalesforceTasks;
|
||||
break;
|
||||
case 'accounts':
|
||||
pagination = accountsPagination;
|
||||
fetchAction = fetchSalesforceAccounts;
|
||||
break;
|
||||
case 'opportunities':
|
||||
pagination = opportunitiesPagination;
|
||||
fetchAction = fetchSalesforceOpportunities;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there are more records and not currently loading
|
||||
if (!pagination.moreRecords || loading[dataType as keyof typeof loading]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch next page
|
||||
await (dispatch(fetchAction({
|
||||
page: pagination.page + 1,
|
||||
limit: 20,
|
||||
append: true
|
||||
}) as any)).unwrap();
|
||||
|
||||
} catch (err) {
|
||||
showError(`Failed to load more ${dataType}`);
|
||||
}
|
||||
}, [dispatch, eventsPagination, leadsPagination, tasksPagination, accountsPagination, opportunitiesPagination, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSalesforceData();
|
||||
// Fetch counts in parallel
|
||||
dispatch(fetchSalesforceCrmCounts());
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
// Reset pagination for all data types before refreshing
|
||||
dispatch(resetEventsPagination());
|
||||
dispatch(resetLeadsPagination());
|
||||
dispatch(resetTasksPagination());
|
||||
dispatch(resetAccountsPagination());
|
||||
dispatch(resetOpportunitiesPagination());
|
||||
|
||||
// Then fetch fresh data and counts
|
||||
fetchSalesforceData(true);
|
||||
dispatch(fetchSalesforceCrmCounts());
|
||||
}, [fetchSalesforceData, dispatch]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
fetchSalesforceData();
|
||||
}, [fetchSalesforceData]);
|
||||
|
||||
const handleCardPress = useCallback((item: any, type: string) => {
|
||||
showInfo(`Viewing ${type}: ${item.Name || item.Subject || `${item.FirstName} ${item.LastName}`}`);
|
||||
}, []);
|
||||
|
||||
// Render loading footer for infinite scroll
|
||||
const renderFooter = useCallback((dataType: string) => {
|
||||
const isLoadingMore = loading[dataType as keyof typeof loading];
|
||||
if (!isLoadingMore) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.footerLoader}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
<Text style={[styles.footerText, { color: colors.textLight }]}>
|
||||
Loading more...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}, [loading, colors]);
|
||||
|
||||
// Get current loading state and error
|
||||
const isLoading = loading.events || loading.leads || loading.tasks || loading.accounts || loading.opportunities;
|
||||
const hasError = errors.events || errors.leads || errors.tasks || errors.accounts || errors.opportunities;
|
||||
|
||||
// Tab configuration with counts from API
|
||||
const tabs = [
|
||||
{
|
||||
key: 'events',
|
||||
label: 'Events',
|
||||
icon: 'calendar',
|
||||
count: counts?.events || events.length
|
||||
},
|
||||
{
|
||||
key: 'leads',
|
||||
label: 'Leads',
|
||||
icon: 'account-heart',
|
||||
count: counts?.leads || leads.length
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
label: 'Tasks',
|
||||
icon: 'check-circle',
|
||||
count: counts?.tasks || tasks.length
|
||||
},
|
||||
{
|
||||
key: 'accounts',
|
||||
label: 'Accounts',
|
||||
icon: 'domain',
|
||||
count: counts?.accounts || accounts.length
|
||||
},
|
||||
{
|
||||
key: 'opportunities',
|
||||
label: 'Opportunities',
|
||||
icon: 'handshake',
|
||||
count: counts?.opportunities || opportunities.length
|
||||
},
|
||||
] as const;
|
||||
|
||||
const renderTabContent = useCallback(() => {
|
||||
const commonFlatListProps = {
|
||||
numColumns: 1,
|
||||
showsVerticalScrollIndicator: false,
|
||||
contentContainerStyle: styles.listContainer,
|
||||
refreshControl: (
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||
),
|
||||
};
|
||||
|
||||
switch (selectedTab) {
|
||||
case 'events':
|
||||
return (
|
||||
<FlatList
|
||||
data={events}
|
||||
renderItem={({ item }) => (
|
||||
<EventCard
|
||||
event={item}
|
||||
onPress={() => handleCardPress(item, 'Event')}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(item) => `event-${item.Id}`}
|
||||
onEndReached={() => loadMoreData('events')}
|
||||
onEndReachedThreshold={0.1}
|
||||
ListFooterComponent={() => renderFooter('events')}
|
||||
{...commonFlatListProps}
|
||||
/>
|
||||
);
|
||||
case 'leads':
|
||||
return (
|
||||
<FlatList
|
||||
data={leads}
|
||||
renderItem={({ item }) => (
|
||||
<LeadCard
|
||||
lead={item}
|
||||
onPress={() => handleCardPress(item, 'Lead')}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(item) => `lead-${item.Id}`}
|
||||
onEndReached={() => loadMoreData('leads')}
|
||||
onEndReachedThreshold={0.1}
|
||||
ListFooterComponent={() => renderFooter('leads')}
|
||||
{...commonFlatListProps}
|
||||
/>
|
||||
);
|
||||
case 'tasks':
|
||||
return (
|
||||
<FlatList
|
||||
data={tasks}
|
||||
renderItem={({ item }) => (
|
||||
<TaskCard
|
||||
task={item}
|
||||
onPress={() => handleCardPress(item, 'Task')}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(item) => `task-${item.Id}`}
|
||||
onEndReached={() => loadMoreData('tasks')}
|
||||
onEndReachedThreshold={0.1}
|
||||
ListFooterComponent={() => renderFooter('tasks')}
|
||||
{...commonFlatListProps}
|
||||
/>
|
||||
);
|
||||
case 'accounts':
|
||||
return (
|
||||
<FlatList
|
||||
data={accounts}
|
||||
renderItem={({ item }) => (
|
||||
<AccountCard
|
||||
account={item}
|
||||
onPress={() => handleCardPress(item, 'Account')}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(item) => `account-${item.Id}`}
|
||||
onEndReached={() => loadMoreData('accounts')}
|
||||
onEndReachedThreshold={0.1}
|
||||
ListFooterComponent={() => renderFooter('accounts')}
|
||||
{...commonFlatListProps}
|
||||
/>
|
||||
);
|
||||
case 'opportunities':
|
||||
return (
|
||||
<FlatList
|
||||
data={opportunities}
|
||||
renderItem={({ item }) => (
|
||||
<OpportunityCard
|
||||
opportunity={item}
|
||||
onPress={() => handleCardPress(item, 'Opportunity')}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(item) => `opportunity-${item.Id}`}
|
||||
onEndReached={() => loadMoreData('opportunities')}
|
||||
onEndReachedThreshold={0.1}
|
||||
ListFooterComponent={() => renderFooter('opportunities')}
|
||||
{...commonFlatListProps}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [selectedTab, events, leads, tasks, accounts, opportunities, handleCardPress, loadMoreData, renderFooter, refreshing, handleRefresh]);
|
||||
|
||||
// Conditional returns after all hooks
|
||||
if (isLoading && !leads.length) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (hasError && !leads.length) {
|
||||
return <ErrorState onRetry={handleRetry} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
{/* Fixed Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||
Salesforce CRM Data
|
||||
</Text>
|
||||
<TouchableOpacity onPress={handleRefresh} disabled={refreshing}>
|
||||
<Icon name="refresh" size={24} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Fixed Tabs */}
|
||||
<View style={styles.tabsContainer}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View style={styles.tabs}>
|
||||
{tabs.map((tab) => (
|
||||
<TouchableOpacity
|
||||
key={tab.key}
|
||||
style={[
|
||||
styles.tab,
|
||||
selectedTab === tab.key && { backgroundColor: colors.primary },
|
||||
]}
|
||||
onPress={() => setSelectedTab(tab.key)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Icon
|
||||
name={tab.icon}
|
||||
size={20}
|
||||
color={selectedTab === tab.key ? colors.surface : colors.textLight}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
{
|
||||
color: selectedTab === tab.key ? colors.surface : colors.textLight,
|
||||
fontFamily: fonts.medium,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.countBadge,
|
||||
{ backgroundColor: selectedTab === tab.key ? colors.surface : colors.primary },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.countText,
|
||||
{
|
||||
color: selectedTab === tab.key ? colors.primary : colors.surface,
|
||||
fontFamily: fonts.bold,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{tab.count}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<View style={styles.content}>
|
||||
{renderTabContent()}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
},
|
||||
tabsContainer: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E5E7EB',
|
||||
},
|
||||
tabs: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
tab: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
marginRight: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#F1F5F9',
|
||||
},
|
||||
tabText: {
|
||||
marginLeft: 6,
|
||||
fontSize: 14,
|
||||
},
|
||||
countBadge: {
|
||||
marginLeft: 6,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 10,
|
||||
minWidth: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
countText: {
|
||||
fontSize: 12,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
listContainer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
footerLoader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
},
|
||||
footerText: {
|
||||
marginLeft: 8,
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
|
||||
export default SalesforceDataScreen;
|
||||
|
||||
65
src/modules/crm/salesforce/services/salesforceCrmAPI.ts
Normal file
65
src/modules/crm/salesforce/services/salesforceCrmAPI.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import http from '@/services/http';
|
||||
import type {
|
||||
SalesforceEvent,
|
||||
SalesforceLead,
|
||||
SalesforceTask,
|
||||
SalesforceAccount,
|
||||
SalesforceOpportunity,
|
||||
SalesforceSearchParams,
|
||||
SalesforceApiResponse
|
||||
} from '../types/SalesforceCrmTypes';
|
||||
|
||||
// Available Salesforce CRM resource types
|
||||
export type SalesforceResourceType = 'events' | 'leads' | 'tasks' | 'accounts' | 'opportunities';
|
||||
|
||||
// Base API endpoint for Salesforce CRM
|
||||
const SALESFORCE_CRM_BASE_URL = '/api/v1/n8n/salesforce/crm';
|
||||
|
||||
export const salesforceCrmAPI = {
|
||||
// Generic method to get Salesforce CRM data by resource type
|
||||
getSalesforceCrmData: <T = any>(
|
||||
resource: SalesforceResourceType,
|
||||
params?: SalesforceSearchParams
|
||||
) => {
|
||||
const queryParams = {
|
||||
page: params?.page || 1,
|
||||
limit: params?.limit || 20,
|
||||
...params
|
||||
};
|
||||
|
||||
return http.get<SalesforceApiResponse<T>>(`${SALESFORCE_CRM_BASE_URL}/${resource}`, queryParams);
|
||||
},
|
||||
|
||||
// Specific resource methods for type safety
|
||||
getEvents: (params?: SalesforceSearchParams) =>
|
||||
salesforceCrmAPI.getSalesforceCrmData<SalesforceEvent>('events', params),
|
||||
|
||||
getLeads: (params?: SalesforceSearchParams) =>
|
||||
salesforceCrmAPI.getSalesforceCrmData<SalesforceLead>('leads', params),
|
||||
|
||||
getTasks: (params?: SalesforceSearchParams) =>
|
||||
salesforceCrmAPI.getSalesforceCrmData<SalesforceTask>('tasks', params),
|
||||
|
||||
getAccounts: (params?: SalesforceSearchParams) =>
|
||||
salesforceCrmAPI.getSalesforceCrmData<SalesforceAccount>('accounts', params),
|
||||
|
||||
getOpportunities: (params?: SalesforceSearchParams) =>
|
||||
salesforceCrmAPI.getSalesforceCrmData<SalesforceOpportunity>('opportunities', params),
|
||||
|
||||
// Get counts for all Salesforce CRM modules
|
||||
getSalesforceCrmCounts: () => {
|
||||
return http.get<{
|
||||
status: string;
|
||||
message: string;
|
||||
data: {
|
||||
events: { count: number; success: boolean };
|
||||
leads: { count: number; success: boolean };
|
||||
tasks: { count: number; success: boolean };
|
||||
accounts: { count: number; success: boolean };
|
||||
opportunities: { count: number; success: boolean };
|
||||
};
|
||||
timestamp: string;
|
||||
}>(`${SALESFORCE_CRM_BASE_URL}/counts`);
|
||||
},
|
||||
};
|
||||
|
||||
517
src/modules/crm/salesforce/store/salesforceCrmSlice.ts
Normal file
517
src/modules/crm/salesforce/store/salesforceCrmSlice.ts
Normal file
@ -0,0 +1,517 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { salesforceCrmAPI } from '../services/salesforceCrmAPI';
|
||||
import type {
|
||||
SalesforceEvent,
|
||||
SalesforceLead,
|
||||
SalesforceTask,
|
||||
SalesforceAccount,
|
||||
SalesforceOpportunity,
|
||||
SalesforceStats,
|
||||
SalesforceSearchParams
|
||||
} from '../types/SalesforceCrmTypes';
|
||||
|
||||
// State interface
|
||||
export interface SalesforceCrmState {
|
||||
// Data
|
||||
events: SalesforceEvent[];
|
||||
leads: SalesforceLead[];
|
||||
tasks: SalesforceTask[];
|
||||
accounts: SalesforceAccount[];
|
||||
opportunities: SalesforceOpportunity[];
|
||||
|
||||
// Loading states
|
||||
loading: {
|
||||
events: boolean;
|
||||
leads: boolean;
|
||||
tasks: boolean;
|
||||
accounts: boolean;
|
||||
opportunities: boolean;
|
||||
stats: boolean;
|
||||
};
|
||||
|
||||
// Error states
|
||||
errors: {
|
||||
events: string | null;
|
||||
leads: string | null;
|
||||
tasks: string | null;
|
||||
accounts: string | null;
|
||||
opportunities: string | null;
|
||||
stats: string | null;
|
||||
};
|
||||
|
||||
// Pagination
|
||||
pagination: {
|
||||
events: { page: number; count: number; moreRecords: boolean };
|
||||
leads: { page: number; count: number; moreRecords: boolean };
|
||||
tasks: { page: number; count: number; moreRecords: boolean };
|
||||
accounts: { page: number; count: number; moreRecords: boolean };
|
||||
opportunities: { page: number; count: number; moreRecords: boolean };
|
||||
};
|
||||
|
||||
// Statistics
|
||||
stats: SalesforceStats | null;
|
||||
|
||||
// Module counts
|
||||
counts: {
|
||||
events: number;
|
||||
leads: number;
|
||||
tasks: number;
|
||||
accounts: number;
|
||||
opportunities: number;
|
||||
} | null;
|
||||
|
||||
// Last updated timestamps
|
||||
lastUpdated: {
|
||||
events: string | null;
|
||||
leads: string | null;
|
||||
tasks: string | null;
|
||||
accounts: string | null;
|
||||
opportunities: string | null;
|
||||
stats: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
// Initial state
|
||||
const initialState: SalesforceCrmState = {
|
||||
events: [],
|
||||
leads: [],
|
||||
tasks: [],
|
||||
accounts: [],
|
||||
opportunities: [],
|
||||
loading: {
|
||||
events: false,
|
||||
leads: false,
|
||||
tasks: false,
|
||||
accounts: false,
|
||||
opportunities: false,
|
||||
stats: false,
|
||||
},
|
||||
errors: {
|
||||
events: null,
|
||||
leads: null,
|
||||
tasks: null,
|
||||
accounts: null,
|
||||
opportunities: null,
|
||||
stats: null,
|
||||
},
|
||||
pagination: {
|
||||
events: { page: 1, count: 0, moreRecords: false },
|
||||
leads: { page: 1, count: 0, moreRecords: false },
|
||||
tasks: { page: 1, count: 0, moreRecords: false },
|
||||
accounts: { page: 1, count: 0, moreRecords: false },
|
||||
opportunities: { page: 1, count: 0, moreRecords: false },
|
||||
},
|
||||
stats: null,
|
||||
counts: null,
|
||||
lastUpdated: {
|
||||
events: null,
|
||||
leads: null,
|
||||
tasks: null,
|
||||
accounts: null,
|
||||
opportunities: null,
|
||||
stats: null,
|
||||
},
|
||||
};
|
||||
|
||||
// Async thunks for infinite scrolling
|
||||
export const fetchSalesforceEvents = createAsyncThunk(
|
||||
'salesforceCrm/fetchEvents',
|
||||
async (params?: SalesforceSearchParams & { append?: boolean }) => {
|
||||
const response = await salesforceCrmAPI.getEvents(params);
|
||||
return {
|
||||
data: response.data?.data?.data || [],
|
||||
count: response.data?.data?.count || 0,
|
||||
moreRecords: !response.data?.data?.metadata?.done,
|
||||
append: params?.append || false
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchSalesforceLeads = createAsyncThunk(
|
||||
'salesforceCrm/fetchLeads',
|
||||
async (params?: SalesforceSearchParams & { append?: boolean }) => {
|
||||
const response = await salesforceCrmAPI.getLeads(params);
|
||||
return {
|
||||
data: response.data?.data?.data || [],
|
||||
count: response.data?.data?.count || 0,
|
||||
moreRecords: !response.data?.data?.metadata?.done,
|
||||
append: params?.append || false
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchSalesforceTasks = createAsyncThunk(
|
||||
'salesforceCrm/fetchTasks',
|
||||
async (params?: SalesforceSearchParams & { append?: boolean }) => {
|
||||
const response = await salesforceCrmAPI.getTasks(params);
|
||||
return {
|
||||
data: response.data?.data?.data || [],
|
||||
count: response.data?.data?.count || 0,
|
||||
moreRecords: !response.data?.data?.metadata?.done,
|
||||
append: params?.append || false
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchSalesforceAccounts = createAsyncThunk(
|
||||
'salesforceCrm/fetchAccounts',
|
||||
async (params?: SalesforceSearchParams & { append?: boolean }) => {
|
||||
const response = await salesforceCrmAPI.getAccounts(params);
|
||||
return {
|
||||
data: response.data?.data?.data || [],
|
||||
count: response.data?.data?.count || 0,
|
||||
moreRecords: !response.data?.data?.metadata?.done,
|
||||
append: params?.append || false
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const fetchSalesforceOpportunities = createAsyncThunk(
|
||||
'salesforceCrm/fetchOpportunities',
|
||||
async (params?: SalesforceSearchParams & { append?: boolean }) => {
|
||||
const response = await salesforceCrmAPI.getOpportunities(params);
|
||||
return {
|
||||
data: response.data?.data?.data || [],
|
||||
count: response.data?.data?.count || 0,
|
||||
moreRecords: !response.data?.data?.metadata?.done,
|
||||
append: params?.append || false
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Fetch Salesforce CRM counts
|
||||
export const fetchSalesforceCrmCounts = createAsyncThunk(
|
||||
'salesforceCrm/fetchCounts',
|
||||
async () => {
|
||||
const response = await salesforceCrmAPI.getSalesforceCrmCounts();
|
||||
return response.data || {
|
||||
events: { count: 0, success: false },
|
||||
leads: { count: 0, success: false },
|
||||
tasks: { count: 0, success: false },
|
||||
accounts: { count: 0, success: false },
|
||||
opportunities: { count: 0, success: false },
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Fetch all Salesforce CRM data
|
||||
export const fetchAllSalesforceCrmData = createAsyncThunk(
|
||||
'salesforceCrm/fetchAllData',
|
||||
async (params?: SalesforceSearchParams) => {
|
||||
const [
|
||||
eventsResponse,
|
||||
leadsResponse,
|
||||
tasksResponse,
|
||||
accountsResponse,
|
||||
opportunitiesResponse
|
||||
] = await Promise.all([
|
||||
salesforceCrmAPI.getEvents(params),
|
||||
salesforceCrmAPI.getLeads(params),
|
||||
salesforceCrmAPI.getTasks(params),
|
||||
salesforceCrmAPI.getAccounts(params),
|
||||
salesforceCrmAPI.getOpportunities(params),
|
||||
]);
|
||||
|
||||
return {
|
||||
events: {
|
||||
data: eventsResponse.data?.data?.data || [],
|
||||
count: eventsResponse.data?.data?.count || 0,
|
||||
moreRecords: !eventsResponse.data?.data?.metadata?.done
|
||||
},
|
||||
leads: {
|
||||
data: leadsResponse.data?.data?.data || [],
|
||||
count: leadsResponse.data?.data?.count || 0,
|
||||
moreRecords: !leadsResponse.data?.data?.metadata?.done
|
||||
},
|
||||
tasks: {
|
||||
data: tasksResponse.data?.data?.data || [],
|
||||
count: tasksResponse.data?.data?.count || 0,
|
||||
moreRecords: !tasksResponse.data?.data?.metadata?.done
|
||||
},
|
||||
accounts: {
|
||||
data: accountsResponse.data?.data?.data || [],
|
||||
count: accountsResponse.data?.data?.count || 0,
|
||||
moreRecords: !accountsResponse.data?.data?.metadata?.done
|
||||
},
|
||||
opportunities: {
|
||||
data: opportunitiesResponse.data?.data?.data || [],
|
||||
count: opportunitiesResponse.data?.data?.count || 0,
|
||||
moreRecords: !opportunitiesResponse.data?.data?.metadata?.done
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Slice
|
||||
const salesforceCrmSlice = createSlice({
|
||||
name: 'salesforceCrm',
|
||||
initialState,
|
||||
reducers: {
|
||||
clearErrors: (state) => {
|
||||
state.errors = {
|
||||
events: null,
|
||||
leads: null,
|
||||
tasks: null,
|
||||
accounts: null,
|
||||
opportunities: null,
|
||||
stats: null,
|
||||
};
|
||||
},
|
||||
clearData: (state) => {
|
||||
state.events = [];
|
||||
state.leads = [];
|
||||
state.tasks = [];
|
||||
state.accounts = [];
|
||||
state.opportunities = [];
|
||||
state.stats = null;
|
||||
},
|
||||
// Reset pagination for specific data types
|
||||
resetEventsPagination: (state) => {
|
||||
state.pagination.events = { page: 1, count: 0, moreRecords: false };
|
||||
state.events = [];
|
||||
},
|
||||
resetLeadsPagination: (state) => {
|
||||
state.pagination.leads = { page: 1, count: 0, moreRecords: false };
|
||||
state.leads = [];
|
||||
},
|
||||
resetTasksPagination: (state) => {
|
||||
state.pagination.tasks = { page: 1, count: 0, moreRecords: false };
|
||||
state.tasks = [];
|
||||
},
|
||||
resetAccountsPagination: (state) => {
|
||||
state.pagination.accounts = { page: 1, count: 0, moreRecords: false };
|
||||
state.accounts = [];
|
||||
},
|
||||
resetOpportunitiesPagination: (state) => {
|
||||
state.pagination.opportunities = { page: 1, count: 0, moreRecords: false };
|
||||
state.opportunities = [];
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
// Fetch events
|
||||
builder
|
||||
.addCase(fetchSalesforceEvents.pending, (state) => {
|
||||
state.loading.events = true;
|
||||
state.errors.events = null;
|
||||
})
|
||||
.addCase(fetchSalesforceEvents.fulfilled, (state, action) => {
|
||||
state.loading.events = false;
|
||||
const { data, count, moreRecords, append } = action.payload;
|
||||
|
||||
if (append) {
|
||||
// Append new data for infinite scrolling
|
||||
state.events = [...state.events, ...data];
|
||||
} else {
|
||||
// Replace data for initial load or refresh
|
||||
state.events = data;
|
||||
}
|
||||
|
||||
state.pagination.events = {
|
||||
page: append ? state.pagination.events.page + 1 : 1,
|
||||
count,
|
||||
moreRecords
|
||||
};
|
||||
state.lastUpdated.events = new Date().toISOString();
|
||||
})
|
||||
.addCase(fetchSalesforceEvents.rejected, (state, action) => {
|
||||
state.loading.events = false;
|
||||
state.errors.events = action.error.message || 'Failed to fetch events';
|
||||
})
|
||||
|
||||
// Fetch leads
|
||||
.addCase(fetchSalesforceLeads.pending, (state) => {
|
||||
state.loading.leads = true;
|
||||
state.errors.leads = null;
|
||||
})
|
||||
.addCase(fetchSalesforceLeads.fulfilled, (state, action) => {
|
||||
state.loading.leads = false;
|
||||
const { data, count, moreRecords, append } = action.payload;
|
||||
|
||||
if (append) {
|
||||
state.leads = [...state.leads, ...data];
|
||||
} else {
|
||||
state.leads = data;
|
||||
}
|
||||
|
||||
state.pagination.leads = {
|
||||
page: append ? state.pagination.leads.page + 1 : 1,
|
||||
count,
|
||||
moreRecords
|
||||
};
|
||||
state.lastUpdated.leads = new Date().toISOString();
|
||||
})
|
||||
.addCase(fetchSalesforceLeads.rejected, (state, action) => {
|
||||
state.loading.leads = false;
|
||||
state.errors.leads = action.error.message || 'Failed to fetch leads';
|
||||
})
|
||||
|
||||
// Fetch tasks
|
||||
.addCase(fetchSalesforceTasks.pending, (state) => {
|
||||
state.loading.tasks = true;
|
||||
state.errors.tasks = null;
|
||||
})
|
||||
.addCase(fetchSalesforceTasks.fulfilled, (state, action) => {
|
||||
state.loading.tasks = false;
|
||||
const { data, count, moreRecords, append } = action.payload;
|
||||
|
||||
if (append) {
|
||||
state.tasks = [...state.tasks, ...data];
|
||||
} else {
|
||||
state.tasks = data;
|
||||
}
|
||||
|
||||
state.pagination.tasks = {
|
||||
page: append ? state.pagination.tasks.page + 1 : 1,
|
||||
count,
|
||||
moreRecords
|
||||
};
|
||||
state.lastUpdated.tasks = new Date().toISOString();
|
||||
})
|
||||
.addCase(fetchSalesforceTasks.rejected, (state, action) => {
|
||||
state.loading.tasks = false;
|
||||
state.errors.tasks = action.error.message || 'Failed to fetch tasks';
|
||||
})
|
||||
|
||||
// Fetch accounts
|
||||
.addCase(fetchSalesforceAccounts.pending, (state) => {
|
||||
state.loading.accounts = true;
|
||||
state.errors.accounts = null;
|
||||
})
|
||||
.addCase(fetchSalesforceAccounts.fulfilled, (state, action) => {
|
||||
state.loading.accounts = false;
|
||||
const { data, count, moreRecords, append } = action.payload;
|
||||
|
||||
if (append) {
|
||||
state.accounts = [...state.accounts, ...data];
|
||||
} else {
|
||||
state.accounts = data;
|
||||
}
|
||||
|
||||
state.pagination.accounts = {
|
||||
page: append ? state.pagination.accounts.page + 1 : 1,
|
||||
count,
|
||||
moreRecords
|
||||
};
|
||||
state.lastUpdated.accounts = new Date().toISOString();
|
||||
})
|
||||
.addCase(fetchSalesforceAccounts.rejected, (state, action) => {
|
||||
state.loading.accounts = false;
|
||||
state.errors.accounts = action.error.message || 'Failed to fetch accounts';
|
||||
})
|
||||
|
||||
// Fetch opportunities
|
||||
.addCase(fetchSalesforceOpportunities.pending, (state) => {
|
||||
state.loading.opportunities = true;
|
||||
state.errors.opportunities = null;
|
||||
})
|
||||
.addCase(fetchSalesforceOpportunities.fulfilled, (state, action) => {
|
||||
state.loading.opportunities = false;
|
||||
const { data, count, moreRecords, append } = action.payload;
|
||||
|
||||
if (append) {
|
||||
state.opportunities = [...state.opportunities, ...data];
|
||||
} else {
|
||||
state.opportunities = data;
|
||||
}
|
||||
|
||||
state.pagination.opportunities = {
|
||||
page: append ? state.pagination.opportunities.page + 1 : 1,
|
||||
count,
|
||||
moreRecords
|
||||
};
|
||||
state.lastUpdated.opportunities = new Date().toISOString();
|
||||
})
|
||||
.addCase(fetchSalesforceOpportunities.rejected, (state, action) => {
|
||||
state.loading.opportunities = false;
|
||||
state.errors.opportunities = action.error.message || 'Failed to fetch opportunities';
|
||||
})
|
||||
|
||||
// Fetch all Salesforce CRM data
|
||||
.addCase(fetchAllSalesforceCrmData.pending, (state) => {
|
||||
state.loading.events = true;
|
||||
state.loading.leads = true;
|
||||
state.loading.tasks = true;
|
||||
state.loading.accounts = true;
|
||||
state.loading.opportunities = true;
|
||||
state.errors.events = null;
|
||||
state.errors.leads = null;
|
||||
state.errors.tasks = null;
|
||||
state.errors.accounts = null;
|
||||
state.errors.opportunities = null;
|
||||
})
|
||||
.addCase(fetchAllSalesforceCrmData.fulfilled, (state, action) => {
|
||||
const { events, leads, tasks, accounts, opportunities } = action.payload;
|
||||
|
||||
state.loading.events = false;
|
||||
state.loading.leads = false;
|
||||
state.loading.tasks = false;
|
||||
state.loading.accounts = false;
|
||||
state.loading.opportunities = false;
|
||||
|
||||
state.events = events.data;
|
||||
state.leads = leads.data;
|
||||
state.tasks = tasks.data;
|
||||
state.accounts = accounts.data;
|
||||
state.opportunities = opportunities.data;
|
||||
|
||||
state.pagination.events = { page: 1, count: events.count, moreRecords: events.moreRecords };
|
||||
state.pagination.leads = { page: 1, count: leads.count, moreRecords: leads.moreRecords };
|
||||
state.pagination.tasks = { page: 1, count: tasks.count, moreRecords: tasks.moreRecords };
|
||||
state.pagination.accounts = { page: 1, count: accounts.count, moreRecords: accounts.moreRecords };
|
||||
state.pagination.opportunities = { page: 1, count: opportunities.count, moreRecords: opportunities.moreRecords };
|
||||
|
||||
const now = new Date().toISOString();
|
||||
state.lastUpdated.events = now;
|
||||
state.lastUpdated.leads = now;
|
||||
state.lastUpdated.tasks = now;
|
||||
state.lastUpdated.accounts = now;
|
||||
state.lastUpdated.opportunities = now;
|
||||
})
|
||||
.addCase(fetchAllSalesforceCrmData.rejected, (state, action) => {
|
||||
state.loading.events = false;
|
||||
state.loading.leads = false;
|
||||
state.loading.tasks = false;
|
||||
state.loading.accounts = false;
|
||||
state.loading.opportunities = false;
|
||||
|
||||
const errorMessage = action.error.message || 'Failed to fetch Salesforce CRM data';
|
||||
state.errors.events = errorMessage;
|
||||
state.errors.leads = errorMessage;
|
||||
state.errors.tasks = errorMessage;
|
||||
state.errors.accounts = errorMessage;
|
||||
state.errors.opportunities = errorMessage;
|
||||
})
|
||||
|
||||
// Fetch Salesforce CRM counts
|
||||
.addCase(fetchSalesforceCrmCounts.pending, (state) => {
|
||||
// No loading state needed for counts as it's fetched in background
|
||||
})
|
||||
.addCase(fetchSalesforceCrmCounts.fulfilled, (state, action) => {
|
||||
const data = action.payload as any;
|
||||
state.counts = {
|
||||
events: data.events?.count || 0,
|
||||
leads: data.leads?.count || 0,
|
||||
tasks: data.tasks?.count || 0,
|
||||
accounts: data.accounts?.count || 0,
|
||||
opportunities: data.opportunities?.count || 0,
|
||||
};
|
||||
})
|
||||
.addCase(fetchSalesforceCrmCounts.rejected, (state) => {
|
||||
// Keep existing counts on error
|
||||
console.warn('Failed to fetch Salesforce CRM counts');
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
clearErrors,
|
||||
clearData,
|
||||
resetEventsPagination,
|
||||
resetLeadsPagination,
|
||||
resetTasksPagination,
|
||||
resetAccountsPagination,
|
||||
resetOpportunitiesPagination,
|
||||
} = salesforceCrmSlice.actions;
|
||||
|
||||
export default salesforceCrmSlice.reducer;
|
||||
|
||||
150
src/modules/crm/salesforce/store/selectors.ts
Normal file
150
src/modules/crm/salesforce/store/selectors.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { RootState } from '@/store/store';
|
||||
import type { SalesforceCrmState } from './salesforceCrmSlice';
|
||||
|
||||
// Base selector
|
||||
const selectSalesforceCrmState = (state: RootState): SalesforceCrmState =>
|
||||
state.salesforceCrm as SalesforceCrmState;
|
||||
|
||||
// Data selectors
|
||||
export const selectSalesforceEvents = createSelector(
|
||||
[selectSalesforceCrmState],
|
||||
(state) => state.events
|
||||
);
|
||||
|
||||
export const selectSalesforceLeads = createSelector(
|
||||
[selectSalesforceCrmState],
|
||||
(state) => state.leads
|
||||
);
|
||||
|
||||
export const selectSalesforceTasks = createSelector(
|
||||
[selectSalesforceCrmState],
|
||||
(state) => state.tasks
|
||||
);
|
||||
|
||||
export const selectSalesforceAccounts = createSelector(
|
||||
[selectSalesforceCrmState],
|
||||
(state) => state.accounts
|
||||
);
|
||||
|
||||
export const selectSalesforceOpportunities = createSelector(
|
||||
[selectSalesforceCrmState],
|
||||
(state) => state.opportunities
|
||||
);
|
||||
|
||||
// Loading selectors
|
||||
export const selectSalesforceCrmLoading = createSelector(
|
||||
[selectSalesforceCrmState],
|
||||
(state) => state.loading
|
||||
);
|
||||
|
||||
export const selectIsAnyLoading = createSelector(
|
||||
[selectSalesforceCrmLoading],
|
||||
(loading) => Object.values(loading).some(isLoading => isLoading)
|
||||
);
|
||||
|
||||
// Error selectors
|
||||
export const selectSalesforceCrmErrors = createSelector(
|
||||
[selectSalesforceCrmState],
|
||||
(state) => state.errors
|
||||
);
|
||||
|
||||
export const selectHasAnyError = createSelector(
|
||||
[selectSalesforceCrmErrors],
|
||||
(errors) => Object.values(errors).some(error => error !== null)
|
||||
);
|
||||
|
||||
// Pagination selectors
|
||||
export const selectEventsPagination = createSelector(
|
||||
[selectSalesforceCrmState],
|
||||
(state) => state.pagination.events
|
||||
);
|
||||
|
||||
export const selectLeadsPagination = createSelector(
|
||||
[selectSalesforceCrmState],
|
||||
(state) => state.pagination.leads
|
||||
);
|
||||
|
||||
export const selectTasksPagination = createSelector(
|
||||
[selectSalesforceCrmState],
|
||||
(state) => state.pagination.tasks
|
||||
);
|
||||
|
||||
export const selectAccountsPagination = createSelector(
|
||||
[selectSalesforceCrmState],
|
||||
(state) => state.pagination.accounts
|
||||
);
|
||||
|
||||
export const selectOpportunitiesPagination = createSelector(
|
||||
[selectSalesforceCrmState],
|
||||
(state) => state.pagination.opportunities
|
||||
);
|
||||
|
||||
// Count selectors
|
||||
export const selectSalesforceCrmCounts = createSelector(
|
||||
[selectSalesforceCrmState],
|
||||
(state) => state.counts
|
||||
);
|
||||
|
||||
// Stats selectors
|
||||
export const selectSalesforceCrmStats = createSelector(
|
||||
[selectSalesforceCrmState],
|
||||
(state) => state.stats
|
||||
);
|
||||
|
||||
// Dashboard data selector - combines all data for dashboard view
|
||||
export const selectDashboardData = createSelector(
|
||||
[
|
||||
selectSalesforceEvents,
|
||||
selectSalesforceLeads,
|
||||
selectSalesforceTasks,
|
||||
selectSalesforceAccounts,
|
||||
selectSalesforceOpportunities,
|
||||
selectSalesforceCrmCounts,
|
||||
],
|
||||
(events, leads, tasks, accounts, opportunities, counts) => ({
|
||||
events,
|
||||
leads,
|
||||
tasks,
|
||||
accounts,
|
||||
opportunities,
|
||||
counts,
|
||||
summary: {
|
||||
totalEvents: events.length,
|
||||
totalLeads: leads.length,
|
||||
totalTasks: tasks.length,
|
||||
totalAccounts: accounts.length,
|
||||
totalOpportunities: opportunities.length,
|
||||
|
||||
// Event stats
|
||||
upcomingEvents: events.filter(e => e.StartDateTime && new Date(e.StartDateTime) > new Date()).length,
|
||||
|
||||
// Lead stats
|
||||
newLeads: leads.filter(l => l.Status === 'New').length,
|
||||
workingLeads: leads.filter(l => l.Status === 'Working').length,
|
||||
qualifiedLeads: leads.filter(l => l.Status === 'Qualified').length,
|
||||
|
||||
// Task stats
|
||||
openTasks: tasks.filter(t => t.Status === 'Open').length,
|
||||
completedTasks: tasks.filter(t => t.Status === 'Completed').length,
|
||||
highPriorityTasks: tasks.filter(t => t.Priority === 'High').length,
|
||||
|
||||
// Opportunity stats
|
||||
openOpportunities: opportunities.filter(o => !o.IsClosed).length,
|
||||
totalPipelineValue: opportunities
|
||||
.filter(o => !o.IsClosed)
|
||||
.reduce((sum, o) => sum + (o.Amount || 0), 0),
|
||||
averageDealSize: opportunities.length > 0
|
||||
? opportunities.reduce((sum, o) => sum + (o.Amount || 0), 0) / opportunities.length
|
||||
: 0,
|
||||
wonOpportunities: opportunities.filter(o => o.IsWon).length,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Last updated selector
|
||||
export const selectLastUpdated = createSelector(
|
||||
[selectSalesforceCrmState],
|
||||
(state) => state.lastUpdated
|
||||
);
|
||||
|
||||
273
src/modules/crm/salesforce/types/SalesforceCrmTypes.ts
Normal file
273
src/modules/crm/salesforce/types/SalesforceCrmTypes.ts
Normal file
@ -0,0 +1,273 @@
|
||||
// Salesforce CRM Data Types for Integration
|
||||
|
||||
export interface SalesforceEvent {
|
||||
Id: string;
|
||||
Subject: string;
|
||||
StartDateTime: string | null;
|
||||
EndDateTime: string | null;
|
||||
Location: string;
|
||||
Description: string | null;
|
||||
OwnerId: string;
|
||||
WhatId: string | null;
|
||||
WhoId: string | null;
|
||||
IsAllDayEvent: boolean;
|
||||
CreatedDate: string;
|
||||
attributes: {
|
||||
type: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SalesforceLead {
|
||||
Id: string;
|
||||
FirstName: string;
|
||||
LastName: string;
|
||||
Company: string;
|
||||
Email: string;
|
||||
Status: LeadStatus;
|
||||
Phone?: string;
|
||||
Title?: string;
|
||||
Street?: string;
|
||||
City?: string;
|
||||
State?: string;
|
||||
Country?: string;
|
||||
PostalCode?: string;
|
||||
Website?: string;
|
||||
Industry?: string;
|
||||
NumberOfEmployees?: number;
|
||||
AnnualRevenue?: number;
|
||||
OwnerId?: string;
|
||||
LeadSource?: string;
|
||||
Description?: string;
|
||||
CreatedDate?: string;
|
||||
LastModifiedDate?: string;
|
||||
attributes: {
|
||||
type: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SalesforceTask {
|
||||
Id: string;
|
||||
Subject: string;
|
||||
Status: TaskStatus;
|
||||
Priority: TaskPriority;
|
||||
ActivityDate: string | null;
|
||||
WhatId: string | null;
|
||||
WhoId: string | null;
|
||||
OwnerId: string;
|
||||
Description: string | null;
|
||||
CreatedDate: string;
|
||||
LastModifiedDate: string;
|
||||
IsClosed?: boolean;
|
||||
IsHighPriority?: boolean;
|
||||
attributes: {
|
||||
type: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SalesforceAccount {
|
||||
Id: string;
|
||||
Name: string;
|
||||
Industry: string | null;
|
||||
Type: string | null;
|
||||
Phone: string | null;
|
||||
BillingCity: string | null;
|
||||
BillingState: string | null;
|
||||
BillingCountry: string | null;
|
||||
BillingStreet?: string | null;
|
||||
BillingPostalCode?: string | null;
|
||||
ShippingCity?: string | null;
|
||||
ShippingState?: string | null;
|
||||
ShippingCountry?: string | null;
|
||||
ShippingStreet?: string | null;
|
||||
ShippingPostalCode?: string | null;
|
||||
Website: string | null;
|
||||
OwnerId: string;
|
||||
CreatedDate: string;
|
||||
LastModifiedDate?: string;
|
||||
Description?: string | null;
|
||||
NumberOfEmployees?: number;
|
||||
AnnualRevenue?: number;
|
||||
attributes: {
|
||||
type: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SalesforceOpportunity {
|
||||
Id: string;
|
||||
Name: string;
|
||||
StageName: OpportunityStage;
|
||||
Amount: number;
|
||||
CloseDate: string;
|
||||
AccountId: string | null;
|
||||
Type: string | null;
|
||||
Probability: number;
|
||||
ForecastCategory: string;
|
||||
OwnerId: string;
|
||||
CreatedDate: string;
|
||||
LastModifiedDate: string;
|
||||
Description?: string | null;
|
||||
LeadSource?: string | null;
|
||||
NextStep?: string | null;
|
||||
IsClosed?: boolean;
|
||||
IsWon?: boolean;
|
||||
ExpectedRevenue?: number;
|
||||
attributes: {
|
||||
type: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Enums and Union Types
|
||||
export type LeadStatus = 'New' | 'Working' | 'Nurturing' | 'Qualified' | 'Unqualified';
|
||||
export type TaskStatus = 'Open' | 'In Progress' | 'Completed' | 'Waiting' | 'Deferred';
|
||||
export type TaskPriority = 'High' | 'Normal' | 'Low';
|
||||
export type OpportunityStage =
|
||||
| 'Prospecting'
|
||||
| 'Qualification'
|
||||
| 'Needs Analysis'
|
||||
| 'Value Proposition'
|
||||
| 'Identify Decision Makers'
|
||||
| 'Perception Analysis'
|
||||
| 'Proposal/Price Quote'
|
||||
| 'Negotiation/Review'
|
||||
| 'Closed Won'
|
||||
| 'Closed Lost';
|
||||
|
||||
// Combined Salesforce CRM Data Interface
|
||||
export interface SalesforceCrmData {
|
||||
events: SalesforceEvent[];
|
||||
leads: SalesforceLead[];
|
||||
tasks: SalesforceTask[];
|
||||
accounts: SalesforceAccount[];
|
||||
opportunities: SalesforceOpportunity[];
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface SalesforceApiResponse<T> {
|
||||
status: 'success' | 'error';
|
||||
message: string;
|
||||
data?: {
|
||||
success: boolean;
|
||||
data: T[];
|
||||
count: number;
|
||||
metadata: {
|
||||
totalSize: number;
|
||||
done: boolean;
|
||||
nextRecordsUrl: string | null;
|
||||
};
|
||||
};
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface SalesforcePaginatedResponse<T> {
|
||||
data: T[];
|
||||
info: {
|
||||
count: number;
|
||||
moreRecords: boolean;
|
||||
page: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Filter and Search Types
|
||||
export interface SalesforceFilters {
|
||||
events?: {
|
||||
dateRange?: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
};
|
||||
leads?: {
|
||||
status?: LeadStatus[];
|
||||
source?: string[];
|
||||
assignedTo?: string[];
|
||||
dateRange?: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
};
|
||||
tasks?: {
|
||||
status?: TaskStatus[];
|
||||
priority?: TaskPriority[];
|
||||
assignedTo?: string[];
|
||||
dueDateRange?: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
};
|
||||
accounts?: {
|
||||
industry?: string[];
|
||||
type?: string[];
|
||||
assignedTo?: string[];
|
||||
};
|
||||
opportunities?: {
|
||||
stage?: OpportunityStage[];
|
||||
owner?: string[];
|
||||
amountRange?: {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
closeDateRange?: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface SalesforceSearchParams {
|
||||
query?: string;
|
||||
type?: 'events' | 'leads' | 'tasks' | 'accounts' | 'opportunities' | 'all';
|
||||
filters?: SalesforceFilters;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// Statistics and Analytics Types
|
||||
export interface SalesforceStats {
|
||||
events: {
|
||||
total: number;
|
||||
upcoming: number;
|
||||
completed: number;
|
||||
byLocation: Record<string, number>;
|
||||
};
|
||||
leads: {
|
||||
total: number;
|
||||
new: number;
|
||||
working: number;
|
||||
qualified: number;
|
||||
byStatus: Record<LeadStatus, number>;
|
||||
bySource: Record<string, number>;
|
||||
};
|
||||
tasks: {
|
||||
total: number;
|
||||
open: number;
|
||||
completed: number;
|
||||
overdue: number;
|
||||
byStatus: Record<TaskStatus, number>;
|
||||
byPriority: Record<TaskPriority, number>;
|
||||
};
|
||||
accounts: {
|
||||
total: number;
|
||||
byIndustry: Record<string, number>;
|
||||
byType: Record<string, number>;
|
||||
totalRevenue: number;
|
||||
};
|
||||
opportunities: {
|
||||
total: number;
|
||||
open: number;
|
||||
won: number;
|
||||
lost: number;
|
||||
totalValue: number;
|
||||
averageDealSize: number;
|
||||
byStage: Record<OpportunityStage, number>;
|
||||
pipelineValue: number;
|
||||
winRate: number;
|
||||
};
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import { useDispatch } from 'react-redux';
|
||||
import { setSelectedService } from '@/modules/integrations/store/integrationsSlice';
|
||||
import type { AppDispatch } from '@/store/store';
|
||||
import ZohoAuth from './ZohoAuth';
|
||||
import SalesforceAuth from './SalesforceAuth';
|
||||
import { Modal } from 'react-native';
|
||||
import httpClient from '@/services/http';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
@ -61,6 +62,7 @@ const IntegrationCategoryScreen: React.FC<Props> = ({ route }) => {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const navigation = useNavigation();
|
||||
const [showZohoAuth, setShowZohoAuth] = React.useState(false);
|
||||
const [showSalesforceAuth, setShowSalesforceAuth] = React.useState(false);
|
||||
const [pendingService, setPendingService] = React.useState<string | null>(null);
|
||||
const [isCheckingToken, setIsCheckingToken] = React.useState(false);
|
||||
const [authenticatedServices, setAuthenticatedServices] = React.useState<Set<string>>(new Set());
|
||||
@ -102,11 +104,50 @@ const IntegrationCategoryScreen: React.FC<Props> = ({ route }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Check for existing Salesforce token
|
||||
const checkSalesforceToken = async (serviceKey: string, forceReauth: boolean = false) => {
|
||||
try {
|
||||
setIsCheckingToken(true);
|
||||
|
||||
if (forceReauth) {
|
||||
// Force re-authentication by showing auth modal
|
||||
setPendingService(serviceKey);
|
||||
setShowSalesforceAuth(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await httpClient.get('/api/v1/users/decrypt-token?service_name=salesforce');
|
||||
const responseData = response.data as any;
|
||||
|
||||
if (responseData.status === 'success' && responseData.data?.accessToken) {
|
||||
// Token exists and is valid, mark as authenticated
|
||||
// CrmNavigator will automatically show SalesforceDashboard based on selectedService
|
||||
console.log('Salesforce token found, setting selected service');
|
||||
setAuthenticatedServices(prev => new Set([...prev, serviceKey]));
|
||||
dispatch(setSelectedService(serviceKey));
|
||||
} else {
|
||||
// No valid token, show auth modal
|
||||
setPendingService(serviceKey);
|
||||
setShowSalesforceAuth(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('No valid Salesforce token found, showing auth modal');
|
||||
// Token doesn't exist or is invalid, show auth modal
|
||||
setPendingService(serviceKey);
|
||||
setShowSalesforceAuth(true);
|
||||
} finally {
|
||||
setIsCheckingToken(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle re-authentication
|
||||
const handleReAuthenticate = (serviceKey: string) => {
|
||||
const isSalesforce = serviceKey === 'salesforce';
|
||||
const serviceTitle = isSalesforce ? 'Salesforce' : 'Zoho';
|
||||
|
||||
Alert.alert(
|
||||
'Re-authenticate',
|
||||
'This will allow you to change your organization or re-authorize access. Continue?',
|
||||
`This will allow you to change your ${serviceTitle} organization or re-authorize access. Continue?`,
|
||||
[
|
||||
{
|
||||
text: 'Cancel',
|
||||
@ -114,7 +155,13 @@ const IntegrationCategoryScreen: React.FC<Props> = ({ route }) => {
|
||||
},
|
||||
{
|
||||
text: 'Re-authenticate',
|
||||
onPress: () => checkZohoToken(serviceKey, true),
|
||||
onPress: () => {
|
||||
if (isSalesforce) {
|
||||
checkSalesforceToken(serviceKey, true);
|
||||
} else {
|
||||
checkZohoToken(serviceKey, true);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
@ -129,6 +176,8 @@ const IntegrationCategoryScreen: React.FC<Props> = ({ route }) => {
|
||||
ItemSeparatorComponent={() => <View style={[styles.sep, { backgroundColor: colors.border }]} />}
|
||||
renderItem={({ item }) => {
|
||||
const requiresZohoAuth = item.key === 'zohoProjects' || item.key === 'zohoPeople' || item.key === 'zohoBooks' || item.key === 'zohoCRM';
|
||||
const requiresSalesforceAuth = item.key === 'salesforce';
|
||||
const requiresAuth = requiresZohoAuth || requiresSalesforceAuth;
|
||||
|
||||
return (
|
||||
<View style={styles.serviceItem}>
|
||||
@ -142,8 +191,10 @@ const IntegrationCategoryScreen: React.FC<Props> = ({ route }) => {
|
||||
|
||||
if (requiresZohoAuth) {
|
||||
checkZohoToken(item.key);
|
||||
} else if (requiresSalesforceAuth) {
|
||||
checkSalesforceToken(item.key);
|
||||
} else {
|
||||
// For non-Zoho services, navigate to Coming Soon screen
|
||||
// For other services, navigate to Coming Soon screen
|
||||
navigation.navigate('ComingSoon' as never);
|
||||
}
|
||||
}}
|
||||
@ -161,8 +212,8 @@ const IntegrationCategoryScreen: React.FC<Props> = ({ route }) => {
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Re-authentication button for Zoho services - always visible */}
|
||||
{requiresZohoAuth && (
|
||||
{/* Re-authentication button for services that require auth */}
|
||||
{requiresAuth && (
|
||||
<TouchableOpacity
|
||||
style={[styles.reauthButton, { backgroundColor: colors.background, borderColor: colors.border }]}
|
||||
onPress={() => handleReAuthenticate(item.key)}
|
||||
@ -190,7 +241,7 @@ const IntegrationCategoryScreen: React.FC<Props> = ({ route }) => {
|
||||
<ZohoAuth
|
||||
serviceKey={pendingService as any}
|
||||
onAuthSuccess={(authData) => {
|
||||
console.log('auth data i got',authData)
|
||||
console.log('Zoho auth data received:', authData);
|
||||
setShowZohoAuth(false);
|
||||
if (pendingService) {
|
||||
// Mark service as authenticated
|
||||
@ -209,6 +260,37 @@ const IntegrationCategoryScreen: React.FC<Props> = ({ route }) => {
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Salesforce Auth Modal */}
|
||||
<Modal
|
||||
visible={showSalesforceAuth}
|
||||
animationType="slide"
|
||||
presentationStyle="fullScreen"
|
||||
onRequestClose={() => setShowSalesforceAuth(false)}
|
||||
>
|
||||
<SalesforceAuth
|
||||
onAuthSuccess={(authData) => {
|
||||
console.log('Salesforce auth data received:', authData);
|
||||
setShowSalesforceAuth(false);
|
||||
if (pendingService) {
|
||||
// Mark service as authenticated
|
||||
// CrmNavigator will automatically show SalesforceDashboard based on selectedService
|
||||
setAuthenticatedServices(prev => new Set([...prev, pendingService]));
|
||||
dispatch(setSelectedService(pendingService));
|
||||
setPendingService(null);
|
||||
}
|
||||
}}
|
||||
onAuthError={(error) => {
|
||||
console.error('Salesforce auth error:', error);
|
||||
setShowSalesforceAuth(false);
|
||||
setPendingService(null);
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowSalesforceAuth(false);
|
||||
setPendingService(null);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,600 @@
|
||||
# Salesforce OAuth - Backend Callback Flow
|
||||
|
||||
## Overview
|
||||
This document explains the backend-handled OAuth callback flow for Salesforce authentication, where the backend receives the authorization code directly from Salesforce and handles the token exchange.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Flow
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Mobile App │
|
||||
│ (SalesforceAuth)│
|
||||
└────────┬────────┘
|
||||
│ 1. User taps Salesforce
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Build OAuth URL with backend callback: │
|
||||
│ https://login.salesforce.com/services/oauth2/authorize │
|
||||
│ ?client_id=YOUR_CLIENT_ID │
|
||||
│ &redirect_uri=YOUR_BACKEND_URL/callback │
|
||||
│ ?user_uuid=USER_ID&service_name=salesforce │
|
||||
│ &response_type=code │
|
||||
│ &scope=api refresh_token offline_access... │
|
||||
└────────────────┬────────────────────────────────────────┘
|
||||
│ 2. Open in WebView
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ Salesforce │
|
||||
│ Login Page │
|
||||
└────────┬───────┘
|
||||
│ 3. User authenticates
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────┐
|
||||
│ Salesforce redirects to backend callback: │
|
||||
│ https://YOUR_BACKEND/callback │
|
||||
│ ?user_uuid=USER_ID │
|
||||
│ &service_name=salesforce │
|
||||
│ &code=AUTHORIZATION_CODE │
|
||||
└────────┬───────────────────────────────────┘
|
||||
│ 4. Backend receives callback
|
||||
▼
|
||||
┌────────────────────────────────────────────┐
|
||||
│ Your Backend Server │
|
||||
│ │
|
||||
│ • Extract: authorization_code, user_uuid, │
|
||||
│ service_name from query params │
|
||||
│ │
|
||||
│ • Exchange code for tokens with Salesforce:│
|
||||
│ POST /services/oauth2/token │
|
||||
│ grant_type=authorization_code │
|
||||
│ code=CODE │
|
||||
│ client_id=YOUR_CLIENT_ID │
|
||||
│ client_secret=YOUR_CLIENT_SECRET │
|
||||
│ redirect_uri=YOUR_CALLBACK_URL │
|
||||
│ │
|
||||
│ • Store tokens (encrypted) in database │
|
||||
│ associated with user_uuid │
|
||||
│ │
|
||||
│ • Return HTML page or redirect with │
|
||||
│ status indicator │
|
||||
└────────┬───────────────────────────────────┘
|
||||
│ 5. Backend responds
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────┐
|
||||
│ Backend returns success/error page: │
|
||||
│ │
|
||||
│ Success: ?status=success │
|
||||
│ OR ?success=true │
|
||||
│ │
|
||||
│ Error: ?status=error │
|
||||
│ &message=Error+description │
|
||||
└────────┬───────────────────────────────────┘
|
||||
│ 6. WebView detects callback response
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ Mobile App │
|
||||
│ • Detect backend callback URL │
|
||||
│ • Check for status=success or error │
|
||||
│ • Show success/error to user │
|
||||
│ • Close auth modal │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
### Endpoint: `GET /api/v1/users/oauth/callback`
|
||||
|
||||
#### Query Parameters (Received from Salesforce)
|
||||
```typescript
|
||||
{
|
||||
user_uuid: string; // User ID passed in redirect_uri
|
||||
service_name: string; // 'salesforce' passed in redirect_uri
|
||||
code: string; // Authorization code from Salesforce
|
||||
state?: string; // Optional CSRF token
|
||||
}
|
||||
```
|
||||
|
||||
#### Implementation Steps
|
||||
|
||||
##### 1. Extract Query Parameters
|
||||
```javascript
|
||||
const { code: authorization_code, user_uuid, service_name } = req.query;
|
||||
|
||||
// Validate required parameters
|
||||
if (!authorization_code || !user_uuid || !service_name) {
|
||||
return res.redirect(`/oauth/error?message=Missing required parameters`);
|
||||
}
|
||||
|
||||
// Verify service_name is 'salesforce'
|
||||
if (service_name !== 'salesforce') {
|
||||
return res.redirect(`/oauth/error?message=Invalid service name`);
|
||||
}
|
||||
```
|
||||
|
||||
##### 2. Exchange Authorization Code for Tokens
|
||||
```javascript
|
||||
const axios = require('axios');
|
||||
|
||||
try {
|
||||
const tokenResponse = await axios.post(
|
||||
'https://login.salesforce.com/services/oauth2/token',
|
||||
null,
|
||||
{
|
||||
params: {
|
||||
grant_type: 'authorization_code',
|
||||
code: authorization_code,
|
||||
client_id: process.env.SALESFORCE_CLIENT_ID,
|
||||
client_secret: process.env.SALESFORCE_CLIENT_SECRET,
|
||||
redirect_uri: `${process.env.BACKEND_URL}/api/v1/users/oauth/callback?user_uuid=${user_uuid}&service_name=salesforce`
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
access_token,
|
||||
refresh_token,
|
||||
instance_url,
|
||||
id,
|
||||
issued_at,
|
||||
signature
|
||||
} = tokenResponse.data;
|
||||
|
||||
// Store tokens...
|
||||
|
||||
} catch (error) {
|
||||
console.error('Token exchange failed:', error);
|
||||
return res.redirect(`/oauth/error?message=${encodeURIComponent(error.message)}`);
|
||||
}
|
||||
```
|
||||
|
||||
##### 3. Store Tokens Securely
|
||||
```javascript
|
||||
// Encrypt tokens before storing
|
||||
const encryptedAccessToken = encrypt(access_token);
|
||||
const encryptedRefreshToken = encrypt(refresh_token);
|
||||
|
||||
await db.integrations.upsert({
|
||||
where: {
|
||||
user_id: user_uuid,
|
||||
service_name: 'salesforce'
|
||||
},
|
||||
update: {
|
||||
access_token: encryptedAccessToken,
|
||||
refresh_token: encryptedRefreshToken,
|
||||
instance_url: instance_url,
|
||||
token_issued_at: new Date(parseInt(issued_at)),
|
||||
updated_at: new Date()
|
||||
},
|
||||
create: {
|
||||
user_id: user_uuid,
|
||||
service_name: 'salesforce',
|
||||
access_token: encryptedAccessToken,
|
||||
refresh_token: encryptedRefreshToken,
|
||||
instance_url: instance_url,
|
||||
token_issued_at: new Date(parseInt(issued_at)),
|
||||
created_at: new Date()
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
##### 4. Return Success Response
|
||||
```javascript
|
||||
// Option 1: Redirect to success page with status
|
||||
res.redirect('/oauth/success?status=success&service=salesforce');
|
||||
|
||||
// Option 2: Return HTML page that mobile app can detect
|
||||
res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authentication Successful</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
}
|
||||
.success-icon {
|
||||
font-size: 64px;
|
||||
color: #10b981;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h1 {
|
||||
color: #1f2937;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
p {
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="success-icon">✓</div>
|
||||
<h1>Authentication Successful!</h1>
|
||||
<p>Your Salesforce account has been connected.</p>
|
||||
<p>You can close this window now.</p>
|
||||
</div>
|
||||
<script>
|
||||
// Mobile app will detect this URL and close the WebView
|
||||
window.location.search = '?status=success';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
```
|
||||
|
||||
##### 5. Handle Errors
|
||||
```javascript
|
||||
// If token exchange or storage fails
|
||||
res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authentication Failed</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
}
|
||||
.error-icon {
|
||||
font-size: 64px;
|
||||
color: #ef4444;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h1 {
|
||||
color: #1f2937;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
p {
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="error-icon">✗</div>
|
||||
<h1>Authentication Failed</h1>
|
||||
<p>${errorMessage || 'An error occurred during authentication.'}</p>
|
||||
<p>Please try again.</p>
|
||||
</div>
|
||||
<script>
|
||||
window.location.search = '?status=error&message=${encodeURIComponent(errorMessage)}';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mobile App Detection Logic
|
||||
|
||||
The mobile app (SalesforceAuth component) detects the backend callback using these helper functions:
|
||||
|
||||
### Detection Functions
|
||||
```typescript
|
||||
// Check if URL is the backend callback URI
|
||||
const isBackendCallbackUri = (url: string): boolean => {
|
||||
return url.includes('/api/v1/users/oauth/callback');
|
||||
};
|
||||
|
||||
// Check if backend callback indicates success
|
||||
const isCallbackSuccess = (url: string): boolean => {
|
||||
const status = getQueryParamFromUrl(url, 'status');
|
||||
const success = getQueryParamFromUrl(url, 'success');
|
||||
return status === 'success' || success === 'true';
|
||||
};
|
||||
|
||||
// Check if backend callback indicates error
|
||||
const isCallbackError = (url: string): boolean => {
|
||||
const status = getQueryParamFromUrl(url, 'status');
|
||||
const error = getQueryParamFromUrl(url, 'error');
|
||||
return status === 'error' || status === 'failure' || !!error;
|
||||
};
|
||||
```
|
||||
|
||||
### Navigation State Handler
|
||||
```typescript
|
||||
handleNavigationStateChange = (navState) => {
|
||||
const { url, loading } = navState;
|
||||
|
||||
// Check if this is the backend callback URL
|
||||
if (isBackendCallbackUri(url)) {
|
||||
console.log('Backend callback detected');
|
||||
|
||||
// Show processing modal
|
||||
setState({ processing: true, processingStep: 'Processing...' });
|
||||
|
||||
// Check for success
|
||||
if (isCallbackSuccess(url)) {
|
||||
handleBackendSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for error
|
||||
if (isCallbackError(url)) {
|
||||
const errorMessage = getQueryParamFromUrl(url, 'message') || 'Authentication failed';
|
||||
handleBackendError(errorMessage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete Backend Example (Node.js/Express)
|
||||
|
||||
```javascript
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Encryption utilities
|
||||
function encrypt(text) {
|
||||
const algorithm = 'aes-256-gcm';
|
||||
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
|
||||
const iv = crypto.randomBytes(16);
|
||||
|
||||
const cipher = crypto.createCipheriv(algorithm, key, iv);
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
||||
}
|
||||
|
||||
// OAuth Callback Endpoint
|
||||
router.get('/api/v1/users/oauth/callback', async (req, res) => {
|
||||
const { code: authorization_code, user_uuid, service_name } = req.query;
|
||||
|
||||
console.log('[OAuth Callback] Received:', { user_uuid, service_name, hasCode: !!authorization_code });
|
||||
|
||||
// Validate required parameters
|
||||
if (!authorization_code || !user_uuid || !service_name) {
|
||||
return res.send(errorPage('Missing required parameters'));
|
||||
}
|
||||
|
||||
if (service_name !== 'salesforce') {
|
||||
return res.send(errorPage('Invalid service name'));
|
||||
}
|
||||
|
||||
try {
|
||||
// Exchange authorization code for tokens
|
||||
const tokenResponse = await axios.post(
|
||||
'https://login.salesforce.com/services/oauth2/token',
|
||||
null,
|
||||
{
|
||||
params: {
|
||||
grant_type: 'authorization_code',
|
||||
code: authorization_code,
|
||||
client_id: process.env.SALESFORCE_CLIENT_ID,
|
||||
client_secret: process.env.SALESFORCE_CLIENT_SECRET,
|
||||
redirect_uri: `${process.env.BACKEND_URL}/api/v1/users/oauth/callback?user_uuid=${user_uuid}&service_name=salesforce`
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
access_token,
|
||||
refresh_token,
|
||||
instance_url,
|
||||
id,
|
||||
issued_at
|
||||
} = tokenResponse.data;
|
||||
|
||||
console.log('[OAuth Callback] Token exchange successful');
|
||||
|
||||
// Encrypt and store tokens
|
||||
const encryptedAccessToken = encrypt(access_token);
|
||||
const encryptedRefreshToken = encrypt(refresh_token);
|
||||
|
||||
await db.integrations.upsert({
|
||||
where: {
|
||||
user_id: user_uuid,
|
||||
service_name: 'salesforce'
|
||||
},
|
||||
update: {
|
||||
access_token: encryptedAccessToken,
|
||||
refresh_token: encryptedRefreshToken,
|
||||
instance_url: instance_url,
|
||||
token_issued_at: new Date(parseInt(issued_at)),
|
||||
updated_at: new Date()
|
||||
},
|
||||
create: {
|
||||
user_id: user_uuid,
|
||||
service_name: 'salesforce',
|
||||
access_token: encryptedAccessToken,
|
||||
refresh_token: encryptedRefreshToken,
|
||||
instance_url: instance_url,
|
||||
token_issued_at: new Date(parseInt(issued_at)),
|
||||
created_at: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[OAuth Callback] Tokens stored successfully');
|
||||
|
||||
// Return success page
|
||||
return res.send(successPage());
|
||||
|
||||
} catch (error) {
|
||||
console.error('[OAuth Callback] Error:', error);
|
||||
return res.send(errorPage(error.message));
|
||||
}
|
||||
});
|
||||
|
||||
// Success page HTML
|
||||
function successPage() {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Success</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
|
||||
.container { background: white; padding: 40px; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); display: inline-block; }
|
||||
.icon { font-size: 64px; color: #10b981; margin-bottom: 20px; }
|
||||
h1 { color: #1f2937; }
|
||||
p { color: #6b7280; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">✓</div>
|
||||
<h1>Authentication Successful!</h1>
|
||||
<p>Your Salesforce account has been connected.</p>
|
||||
</div>
|
||||
<script>
|
||||
setTimeout(() => { window.location.search = '?status=success'; }, 500);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
// Error page HTML
|
||||
function errorPage(message) {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Error</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background: linear-gradient(135deg, #f87171 0%, #ef4444 100%); }
|
||||
.container { background: white; padding: 40px; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); display: inline-block; }
|
||||
.icon { font-size: 64px; color: #ef4444; margin-bottom: 20px; }
|
||||
h1 { color: #1f2937; }
|
||||
p { color: #6b7280; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">✗</div>
|
||||
<h1>Authentication Failed</h1>
|
||||
<p>${message || 'An error occurred.'}</p>
|
||||
</div>
|
||||
<script>
|
||||
setTimeout(() => { window.location.search = '?status=error&message=${encodeURIComponent(message)}'; }, 500);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Checklist
|
||||
|
||||
### Salesforce Connected App
|
||||
- ✅ Callback URL: `https://YOUR_BACKEND_URL/api/v1/users/oauth/callback`
|
||||
- ✅ OAuth Scopes: api, refresh_token, offline_access, profile, email, address, phone
|
||||
|
||||
### Mobile App (SalesforceAuth.tsx)
|
||||
- ✅ `BACKEND_BASE_URL`: Your backend domain
|
||||
- ✅ `CALLBACK_PATH`: `/api/v1/users/oauth/callback`
|
||||
- ✅ `CLIENT_ID`: Salesforce Consumer Key
|
||||
|
||||
### Backend Environment Variables
|
||||
- ✅ `SALESFORCE_CLIENT_ID`: Consumer Key from Salesforce
|
||||
- ✅ `SALESFORCE_CLIENT_SECRET`: Consumer Secret from Salesforce
|
||||
- ✅ `BACKEND_URL`: Your public backend URL
|
||||
- ✅ `ENCRYPTION_KEY`: 32-byte hex string for token encryption
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
1. **Test OAuth Flow**:
|
||||
- Open app → CRM & Sales → Salesforce
|
||||
- Complete Salesforce login
|
||||
- Verify redirect to backend callback
|
||||
- Check backend logs for token exchange
|
||||
- Verify success page appears
|
||||
- Confirm modal closes automatically
|
||||
|
||||
2. **Test Error Handling**:
|
||||
- Simulate invalid credentials
|
||||
- Simulate network failure
|
||||
- Verify error page displays
|
||||
- Confirm retry functionality works
|
||||
|
||||
3. **Verify Token Storage**:
|
||||
- Check database for encrypted tokens
|
||||
- Verify user_id association
|
||||
- Confirm refresh_token is stored
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **HTTPS Only**: Always use HTTPS for callback URLs
|
||||
2. **Token Encryption**: Encrypt tokens before storing in database
|
||||
3. **Client Secret**: Never expose client secret to mobile app
|
||||
4. **State Parameter**: Add CSRF protection using state parameter
|
||||
5. **Token Expiration**: Implement token refresh logic
|
||||
6. **Access Control**: Validate user_uuid belongs to authenticated session
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| Callback URL mismatch | Ensure Salesforce Connected App callback URL exactly matches backend URL with query params |
|
||||
| Token exchange fails | Check client_id and client_secret are correct |
|
||||
| Authorization code expired | Codes expire in 15 minutes - ensure timely exchange |
|
||||
| Backend not reachable | Verify backend URL is publicly accessible (use ngrok for local testing) |
|
||||
| WebView doesn't detect success | Ensure backend returns HTML with `?status=success` in URL |
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: October 2025
|
||||
**Version**: 2.0.0 (Backend Callback Flow)
|
||||
|
||||
452
src/modules/integrations/screens/SALESFORCE_SETUP.md
Normal file
452
src/modules/integrations/screens/SALESFORCE_SETUP.md
Normal file
@ -0,0 +1,452 @@
|
||||
# Salesforce OAuth Integration Setup Guide
|
||||
|
||||
This guide will help you configure Salesforce OAuth authentication for your Centralized Reporting System application.
|
||||
|
||||
## Table of Contents
|
||||
1. [Prerequisites](#prerequisites)
|
||||
2. [Create a Salesforce Connected App](#create-a-salesforce-connected-app)
|
||||
3. [Configure OAuth Settings](#configure-oauth-settings)
|
||||
4. [Update Application Configuration](#update-application-configuration)
|
||||
5. [Test the Integration](#test-the-integration)
|
||||
6. [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have:
|
||||
- A Salesforce account (Production or Sandbox)
|
||||
- Administrator access to Salesforce
|
||||
- Your backend API ready to handle OAuth token exchange
|
||||
- React Native app with WebView capability
|
||||
|
||||
---
|
||||
|
||||
## Create a Salesforce Connected App
|
||||
|
||||
### Step 1: Access Setup
|
||||
1. Log in to your Salesforce account
|
||||
2. Click the **gear icon** (⚙️) in the top right corner
|
||||
3. Select **Setup** from the dropdown menu
|
||||
|
||||
### Step 2: Navigate to App Manager
|
||||
1. In the Quick Find box, type **"App Manager"**
|
||||
2. Click **App Manager** under Platform Tools > Apps
|
||||
3. Click **New Connected App** button
|
||||
|
||||
### Step 3: Basic Information
|
||||
Fill in the following required fields:
|
||||
- **Connected App Name**: `Centralized Reporting System` (or your preferred name)
|
||||
- **API Name**: `Centralized_Reporting_System` (auto-populated)
|
||||
- **Contact Email**: Your email address
|
||||
|
||||
### Step 4: API (Enable OAuth Settings)
|
||||
1. Check the box **"Enable OAuth Settings"**
|
||||
2. **Callback URL**: Enter your redirect URI
|
||||
```
|
||||
centralizedreportingsystem://oauth/salesforce/callback
|
||||
```
|
||||
> Note: You can add multiple callback URLs for different environments (development, staging, production)
|
||||
|
||||
3. **Selected OAuth Scopes**: Select the following scopes and move them to "Selected OAuth Scopes":
|
||||
- Access the identity URL service (id, profile, email, address, phone)
|
||||
- Access unique user identifiers (openid)
|
||||
- Perform requests at any time (refresh_token, offline_access)
|
||||
- Manage user data via APIs (api)
|
||||
- Full access to all data (full) - Optional, use with caution
|
||||
|
||||
4. **Additional OAuth Settings**:
|
||||
- ✅ **Enable for Device Flow**: Check this if you want to support device authentication
|
||||
- ✅ **Require Secret for Web Server Flow**: Recommended for security
|
||||
- ✅ **Require Secret for Refresh Token Flow**: Recommended for security
|
||||
- ✅ **Enable Authorization Code and Credentials Flow**: Check this
|
||||
|
||||
5. Click **Save** button
|
||||
|
||||
### Step 5: Wait for Salesforce Processing
|
||||
After saving, Salesforce will process your app (usually 2-10 minutes).
|
||||
|
||||
### Step 6: Retrieve OAuth Credentials
|
||||
1. Once processing is complete, navigate back to **App Manager**
|
||||
2. Find your Connected App and click the dropdown arrow (▼)
|
||||
3. Select **View**
|
||||
4. In the "API (Enable OAuth Settings)" section, you'll see:
|
||||
- **Consumer Key** (Client ID)
|
||||
- **Consumer Secret** (Client Secret) - Click "Click to reveal"
|
||||
|
||||
> ⚠️ **Important**: Keep these credentials secure! Never commit them to public repositories.
|
||||
|
||||
---
|
||||
|
||||
## Configure OAuth Settings
|
||||
|
||||
### Environment-Specific Endpoints
|
||||
|
||||
#### Production
|
||||
```
|
||||
Auth Base URL: https://login.salesforce.com
|
||||
```
|
||||
|
||||
#### Sandbox
|
||||
```
|
||||
Auth Base URL: https://test.salesforce.com
|
||||
```
|
||||
|
||||
### OAuth Scopes Explained
|
||||
|
||||
| Scope | Description |
|
||||
|-------|-------------|
|
||||
| `api` | Access to all standard Salesforce REST APIs |
|
||||
| `refresh_token` | Ability to refresh the access token |
|
||||
| `offline_access` | Perform requests even when the user is offline |
|
||||
| `id` | Access to the unique identifier for the user |
|
||||
| `profile` | Access to user's profile information |
|
||||
| `email` | Access to user's email address |
|
||||
| `address` | Access to user's address |
|
||||
| `phone` | Access to user's phone number |
|
||||
| `openid` | OpenID Connect authentication |
|
||||
|
||||
---
|
||||
|
||||
## Update Application Configuration
|
||||
|
||||
### Step 1: Update SalesforceAuth.tsx
|
||||
|
||||
Open `src/modules/integrations/screens/SalesforceAuth.tsx` and update the following:
|
||||
|
||||
```typescript
|
||||
const SALESFORCE_CONFIG = {
|
||||
// Replace with your Consumer Key (Client ID) from Salesforce
|
||||
CLIENT_ID: '3MVG9XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
|
||||
|
||||
// This must match exactly what you configured in Salesforce Connected App
|
||||
REDIRECT_URI: 'centralizedreportingsystem://oauth/salesforce/callback',
|
||||
|
||||
// Use login.salesforce.com for production
|
||||
// Use test.salesforce.com for sandbox
|
||||
AUTH_BASE_URL: 'https://login.salesforce.com',
|
||||
|
||||
RESPONSE_TYPE: 'code',
|
||||
DISPLAY: 'touch', // Optimized for mobile
|
||||
};
|
||||
```
|
||||
|
||||
### Step 2: Configure Deep Linking (Android)
|
||||
|
||||
#### Android - Update AndroidManifest.xml
|
||||
Add the intent filter to handle the OAuth callback:
|
||||
|
||||
```xml
|
||||
<!-- android/app/src/main/AndroidManifest.xml -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/app_name"
|
||||
android:launchMode="singleTask">
|
||||
|
||||
<!-- Existing intent filters -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Salesforce OAuth Callback -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="centralizedreportingsystem"
|
||||
android:host="oauth"
|
||||
android:pathPrefix="/salesforce/callback" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
```
|
||||
|
||||
### Step 3: Configure Deep Linking (iOS)
|
||||
|
||||
#### iOS - Update Info.plist
|
||||
Add URL types for the OAuth callback:
|
||||
|
||||
```xml
|
||||
<!-- ios/CentralizedReportingSystem/Info.plist -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>centralizedreportingsystem</string>
|
||||
</array>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.yourcompany.centralizedreportingsystem</string>
|
||||
</dict>
|
||||
</array>
|
||||
```
|
||||
|
||||
### Step 4: Backend Token Exchange Endpoint
|
||||
|
||||
Your backend should have an endpoint to exchange the authorization code for an access token:
|
||||
|
||||
```typescript
|
||||
// Example backend endpoint
|
||||
POST /api/v1/integrations/manage-token
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"authorization_code": "aPrxXXXXXXXXXXXXXXXXXXXXXXXXX",
|
||||
"id": "user-uuid",
|
||||
"service_name": "salesforce",
|
||||
"access_token": "user-session-token"
|
||||
}
|
||||
```
|
||||
|
||||
The backend should:
|
||||
1. Receive the authorization code
|
||||
2. Exchange it with Salesforce for an access token
|
||||
3. Store the tokens securely (encrypted)
|
||||
4. Return success/failure response
|
||||
|
||||
#### Salesforce Token Exchange Request
|
||||
|
||||
Your backend should make this request to Salesforce:
|
||||
|
||||
```http
|
||||
POST https://login.salesforce.com/services/oauth2/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=authorization_code
|
||||
&code={AUTHORIZATION_CODE}
|
||||
&client_id={YOUR_CLIENT_ID}
|
||||
&client_secret={YOUR_CLIENT_SECRET}
|
||||
&redirect_uri={YOUR_REDIRECT_URI}
|
||||
```
|
||||
|
||||
#### Expected Salesforce Response
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "00D...ABC",
|
||||
"refresh_token": "5Aep...XYZ",
|
||||
"signature": "signature-value",
|
||||
"scope": "api refresh_token offline_access id profile email",
|
||||
"id_token": "eyJ...",
|
||||
"instance_url": "https://yourinstance.salesforce.com",
|
||||
"id": "https://login.salesforce.com/id/00D.../005...",
|
||||
"token_type": "Bearer",
|
||||
"issued_at": "1699999999999"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test the Integration
|
||||
|
||||
### Testing Steps
|
||||
|
||||
1. **Launch the App**
|
||||
- Open your React Native application
|
||||
- Navigate to **Integrations** > **CRM & Sales Integration**
|
||||
|
||||
2. **Select Salesforce**
|
||||
- Tap on the **Salesforce** option
|
||||
- The Salesforce authentication WebView should open
|
||||
|
||||
3. **Authenticate**
|
||||
- Enter your Salesforce username and password
|
||||
- If using Two-Factor Authentication, complete the verification
|
||||
- Review and approve the OAuth permissions
|
||||
|
||||
4. **Verify Success**
|
||||
- The app should capture the authorization code
|
||||
- Backend should exchange it for tokens
|
||||
- You should see a success message
|
||||
- The auth modal should close automatically
|
||||
|
||||
5. **Check Data Sync** (if implemented)
|
||||
- Navigate to the Salesforce dashboard
|
||||
- Verify that data is being synced properly
|
||||
|
||||
### Testing with Different Salesforce Orgs
|
||||
|
||||
To test with different Salesforce organizations:
|
||||
1. Tap the **Re-auth** button next to Salesforce
|
||||
2. Confirm the re-authentication
|
||||
3. Log in with a different Salesforce account
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues and Solutions
|
||||
|
||||
#### 1. **"redirect_uri_mismatch" Error**
|
||||
|
||||
**Problem**: The redirect URI doesn't match what's configured in Salesforce.
|
||||
|
||||
**Solution**:
|
||||
- Ensure the redirect URI in `SalesforceAuth.tsx` exactly matches what's in the Salesforce Connected App
|
||||
- Check for trailing slashes, capitalization, and special characters
|
||||
- Both values must be identical: `centralizedreportingsystem://oauth/salesforce/callback`
|
||||
|
||||
---
|
||||
|
||||
#### 2. **"invalid_client_id" Error**
|
||||
|
||||
**Problem**: The Client ID is incorrect or the Connected App is not yet active.
|
||||
|
||||
**Solution**:
|
||||
- Wait 2-10 minutes after creating the Connected App for Salesforce to process it
|
||||
- Double-check the Consumer Key (Client ID) from Salesforce matches your config
|
||||
- Ensure there are no extra spaces or characters
|
||||
|
||||
---
|
||||
|
||||
#### 3. **WebView Doesn't Load**
|
||||
|
||||
**Problem**: The Salesforce login page doesn't appear.
|
||||
|
||||
**Solution**:
|
||||
- Check your internet connection
|
||||
- Verify the `AUTH_BASE_URL` is correct (login.salesforce.com or test.salesforce.com)
|
||||
- Check console logs for error messages
|
||||
- Ensure WebView has internet permission (Android)
|
||||
|
||||
---
|
||||
|
||||
#### 4. **App Doesn't Capture the Code**
|
||||
|
||||
**Problem**: After authentication, the app doesn't proceed.
|
||||
|
||||
**Solution**:
|
||||
- Verify deep linking is configured correctly for both iOS and Android
|
||||
- Check that the redirect URI scheme matches your app's URL scheme
|
||||
- Test the deep link manually using ADB (Android) or xcrun (iOS)
|
||||
- Check console logs for navigation state changes
|
||||
|
||||
---
|
||||
|
||||
#### 5. **"access_denied" Error**
|
||||
|
||||
**Problem**: User denied the OAuth permissions.
|
||||
|
||||
**Solution**:
|
||||
- User must approve all requested permissions
|
||||
- If permissions are too broad, consider reducing the requested scopes
|
||||
- Check if the Connected App is enabled for the user's profile
|
||||
|
||||
---
|
||||
|
||||
#### 6. **Token Exchange Fails**
|
||||
|
||||
**Problem**: Backend returns an error when exchanging the authorization code.
|
||||
|
||||
**Solution**:
|
||||
- Verify backend endpoint is working: `/api/v1/integrations/manage-token`
|
||||
- Check that the backend has the correct Consumer Secret
|
||||
- Authorization codes expire after 15 minutes - ensure timely exchange
|
||||
- Check backend logs for detailed error messages
|
||||
- Verify the authorization code is being sent correctly
|
||||
|
||||
---
|
||||
|
||||
#### 7. **Sandbox vs Production Confusion**
|
||||
|
||||
**Problem**: Using the wrong Salesforce environment.
|
||||
|
||||
**Solution**:
|
||||
- **Production**: Use `https://login.salesforce.com`
|
||||
- **Sandbox**: Use `https://test.salesforce.com`
|
||||
- Create separate Connected Apps for each environment
|
||||
- Update the config based on your current environment
|
||||
|
||||
---
|
||||
|
||||
### Debug Logging
|
||||
|
||||
Enable comprehensive logging to troubleshoot issues:
|
||||
|
||||
```typescript
|
||||
// In SalesforceAuth.tsx, check console logs for:
|
||||
console.log('[SalesforceAuth] Authorization code received:', authCode);
|
||||
console.log('[SalesforceAuth] Response from manageToken:', response);
|
||||
console.log('[SalesforceAuth] nav change url:', url, 'loading:', loading);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Never Store Credentials in Code**
|
||||
- Use environment variables or secure config management
|
||||
- Never commit `.env` files with credentials to version control
|
||||
|
||||
2. **Use HTTPS Only**
|
||||
- Always use `https://` for Salesforce endpoints
|
||||
- Never use `http://` in production
|
||||
|
||||
3. **Implement Token Refresh**
|
||||
- Store refresh tokens securely
|
||||
- Implement automatic token refresh on expiration
|
||||
- Handle refresh token failures gracefully
|
||||
|
||||
4. **Encrypt Stored Tokens**
|
||||
- Use encryption for tokens stored on device
|
||||
- Use secure storage mechanisms (Keychain, KeyStore)
|
||||
|
||||
5. **Validate State Parameter**
|
||||
- Include CSRF protection using state parameter
|
||||
- Verify state on callback to prevent CSRF attacks
|
||||
|
||||
6. **Limit OAuth Scopes**
|
||||
- Request only the minimum scopes needed
|
||||
- Avoid using `full` scope unless absolutely necessary
|
||||
|
||||
7. **Monitor Token Usage**
|
||||
- Log token usage and refresh patterns
|
||||
- Set up alerts for unusual authentication patterns
|
||||
|
||||
---
|
||||
|
||||
## Additional Resources
|
||||
|
||||
### Salesforce Documentation
|
||||
- [Connected Apps Overview](https://help.salesforce.com/s/articleView?id=sf.connected_app_overview.htm)
|
||||
- [OAuth 2.0 Authorization Flow](https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_flow.htm)
|
||||
- [OAuth Scopes](https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_tokens_scopes.htm)
|
||||
|
||||
### React Native Resources
|
||||
- [React Native WebView](https://github.com/react-native-webview/react-native-webview)
|
||||
- [Deep Linking in React Native](https://reactnative.dev/docs/linking)
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues not covered in this guide:
|
||||
|
||||
1. Check Salesforce Setup Audit Trail for any configuration errors
|
||||
2. Review React Native console logs
|
||||
3. Check backend API logs for token exchange errors
|
||||
4. Verify network requests in the debugger
|
||||
5. Test with both Production and Sandbox environments
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### Version 1.0.0
|
||||
- Initial Salesforce OAuth integration
|
||||
- Support for mobile OAuth flow
|
||||
- Backend token exchange implementation
|
||||
- Data sync scheduling
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This integration follows your application's license terms.
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: October 2025
|
||||
**Maintained By**: Your Development Team
|
||||
|
||||
689
src/modules/integrations/screens/SalesforceAuth.tsx
Normal file
689
src/modules/integrations/screens/SalesforceAuth.tsx
Normal file
@ -0,0 +1,689 @@
|
||||
import React, { useState, useRef, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
TouchableOpacity,
|
||||
SafeAreaView,
|
||||
} from 'react-native';
|
||||
import { WebView } from 'react-native-webview';
|
||||
import Icon from 'react-native-vector-icons/MaterialIcons';
|
||||
import { useTheme } from '@/shared/styles/useTheme';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { manageToken } from '../services/integrationAPI';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from '@/store/store';
|
||||
import http from '@/services/http';
|
||||
|
||||
// Types
|
||||
interface SalesforceAuthProps {
|
||||
onAuthSuccess?: (authData: SalesforceAuthData) => void;
|
||||
onAuthError?: (error: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
interface SalesforceAuthData {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
instanceUrl?: string;
|
||||
id?: string;
|
||||
issuedAt?: string;
|
||||
signature?: string;
|
||||
tokenType?: string;
|
||||
}
|
||||
|
||||
interface SalesforceAuthState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
currentUrl: string;
|
||||
processing: boolean;
|
||||
processingStep: string;
|
||||
}
|
||||
|
||||
// Salesforce OAuth Configuration
|
||||
const SALESFORCE_CONFIG = {
|
||||
// Replace with your actual Salesforce Connected App credentials
|
||||
CLIENT_ID: '3MVG9GBhY6wQjl2sueQtv2NXMm3EuWtEvOQoeKRAzYcgs2.AWhkCPFitVFPYyUkiLRRdIww2fpr48_Inokd3F',
|
||||
// Backend callback URL that will handle the OAuth callback
|
||||
BACKEND_BASE_URL: 'https://512acb53a4a4.ngrok-free.app',
|
||||
CALLBACK_PATH: '/api/v1/users/oauth/callback',
|
||||
// Use login.salesforce.com for production, test.salesforce.com for sandbox
|
||||
AUTH_BASE_URL: 'https://test.salesforce.com',
|
||||
RESPONSE_TYPE: 'code',
|
||||
DISPLAY: 'touch', // Optimized for mobile
|
||||
};
|
||||
|
||||
// Salesforce OAuth scopes - request access to various Salesforce APIs
|
||||
const SALESFORCE_SCOPES = [
|
||||
'api', // Access to REST API
|
||||
'refresh_token', // Ability to use refresh token
|
||||
'offline_access', // Perform requests at any time
|
||||
// 'id', // Access to unique identifier
|
||||
'profile', // Access to profile information
|
||||
'email', // Access to email address
|
||||
'address', // Access to address information
|
||||
'phone', // Access to phone number
|
||||
// 'openid', // OpenID Connect
|
||||
].join(' ');
|
||||
|
||||
// Build Salesforce OAuth URL with user-specific callback
|
||||
const buildSalesforceAuthUrl = (userUuid: string): string => {
|
||||
const baseUrl = `${SALESFORCE_CONFIG.AUTH_BASE_URL}/services/oauth2/authorize`;
|
||||
|
||||
// Build the redirect URI with query parameters that backend expects
|
||||
const redirectUri = `${SALESFORCE_CONFIG.BACKEND_BASE_URL}${SALESFORCE_CONFIG.CALLBACK_PATH}?user_uuid=${encodeURIComponent(userUuid)}&service_name=salesforce`;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: SALESFORCE_CONFIG.CLIENT_ID,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: SALESFORCE_CONFIG.RESPONSE_TYPE,
|
||||
display: SALESFORCE_CONFIG.DISPLAY,
|
||||
scope: SALESFORCE_SCOPES,
|
||||
// Add state parameter for security (CSRF protection)
|
||||
state: Math.random().toString(36).substring(7),
|
||||
});
|
||||
|
||||
console.log('[SalesforceAuth] Built OAuth URL:', `${baseUrl}?${params.toString()}`);
|
||||
console.log('[SalesforceAuth] Redirect URI:', redirectUri);
|
||||
return `${baseUrl}?${params.toString()}`;
|
||||
};
|
||||
|
||||
// Safe query param parser for React Native
|
||||
const getQueryParamFromUrl = (url: string, key: string): string | null => {
|
||||
try {
|
||||
const queryIndex = url.indexOf('?');
|
||||
if (queryIndex === -1) return null;
|
||||
const query = url.substring(queryIndex + 1);
|
||||
const pairs = query.split('&');
|
||||
for (let i = 0; i < pairs.length; i += 1) {
|
||||
const [rawK, rawV] = pairs[i].split('=');
|
||||
const k = decodeURIComponent(rawK || '');
|
||||
if (k === key) {
|
||||
return decodeURIComponent(rawV || '');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse all query params from URL into a plain object
|
||||
const getAllQueryParamsFromUrl = (url: string): Record<string, any> => {
|
||||
const result: Record<string, any> = {};
|
||||
try {
|
||||
const queryIndex = url.indexOf('?');
|
||||
if (queryIndex === -1) return result;
|
||||
const query = url.substring(queryIndex + 1);
|
||||
const pairs = query.split('&');
|
||||
for (let i = 0; i < pairs.length; i += 1) {
|
||||
const [rawK, rawV] = pairs[i].split('=');
|
||||
const k = decodeURIComponent(rawK || '');
|
||||
const v = decodeURIComponent(rawV || '');
|
||||
if (!k) continue;
|
||||
// Coerce numeric values when appropriate
|
||||
const numeric = /^-?\d+$/.test(v) ? Number(v) : v;
|
||||
result[k] = numeric;
|
||||
}
|
||||
} catch (e) {
|
||||
// swallow errors, return best-effort result
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Extract authorization code from URL
|
||||
const extractAuthCode = (url: string): string | null => {
|
||||
return getQueryParamFromUrl(url, 'code');
|
||||
};
|
||||
|
||||
// Check if URL is the backend callback URI
|
||||
const isBackendCallbackUri = (url: string): boolean => {
|
||||
return url.includes(SALESFORCE_CONFIG.CALLBACK_PATH);
|
||||
};
|
||||
|
||||
// Check if backend callback indicates success
|
||||
const isCallbackSuccess = (url: string): boolean => {
|
||||
const status = getQueryParamFromUrl(url, 'status');
|
||||
const success = getQueryParamFromUrl(url, 'success');
|
||||
return status === 'success' || success === 'true';
|
||||
};
|
||||
|
||||
// Check if backend callback indicates error
|
||||
const isCallbackError = (url: string): boolean => {
|
||||
const status = getQueryParamFromUrl(url, 'status');
|
||||
const error = getQueryParamFromUrl(url, 'error');
|
||||
return status === 'error' || status === 'failure' || !!error;
|
||||
};
|
||||
|
||||
const SalesforceAuth: React.FC<SalesforceAuthProps> = ({
|
||||
onAuthSuccess,
|
||||
onAuthError,
|
||||
onClose,
|
||||
}) => {
|
||||
const { colors, spacing, fonts, shadows } = useTheme();
|
||||
const webViewRef = useRef<WebView>(null);
|
||||
const navigation = useNavigation<any>();
|
||||
const { user, accessToken } = useSelector((state: RootState) => state.auth);
|
||||
|
||||
const [state, setState] = useState<SalesforceAuthState>({
|
||||
loading: true,
|
||||
error: null,
|
||||
currentUrl: buildSalesforceAuthUrl(user?.uuid || ''),
|
||||
processing: false,
|
||||
processingStep: '',
|
||||
});
|
||||
|
||||
// Handle authorization code from callback URL
|
||||
const handleAuthorizationCode = useCallback(async (authCode: string) => {
|
||||
console.log('[SalesforceAuth] Authorization code captured:', authCode);
|
||||
console.log('[SalesforceAuth] Manually hitting callback URL with query params');
|
||||
|
||||
// Show processing modal
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
processing: true,
|
||||
processingStep: 'Connecting to Salesforce and storing credentials...'
|
||||
}));
|
||||
|
||||
try {
|
||||
// Manually hit the callback URL endpoint with query parameters
|
||||
// This mimics what Salesforce would have sent directly to the backend
|
||||
const response = await http.get('/api/v1/users/oauth/callback', {
|
||||
authorization_code: authCode,
|
||||
user_uuid: user?.uuid || '',
|
||||
service_name: 'salesforce'
|
||||
});
|
||||
|
||||
console.log('[SalesforceAuth] Response from callback endpoint:', response);
|
||||
|
||||
// Check if response status is success
|
||||
if (response?.data && typeof response.data === 'object' && 'status' in response.data && response.data.status === 'success') {
|
||||
console.log('[SalesforceAuth] Token exchange successful');
|
||||
|
||||
// Update processing step
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
processingStep: 'Salesforce authentication successful!'
|
||||
}));
|
||||
|
||||
// Wait a moment to show success message
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// Hide processing modal and call success callback
|
||||
setState(prev => ({ ...prev, processing: false, processingStep: '' }));
|
||||
|
||||
// Call success callback
|
||||
onAuthSuccess?.({
|
||||
accessToken: authCode,
|
||||
tokenType: 'authorization_code',
|
||||
});
|
||||
} else {
|
||||
const errorMessage = response?.data && typeof response.data === 'object' && 'message' in response.data
|
||||
? String(response.data.message)
|
||||
: 'Failed to authenticate with Salesforce';
|
||||
console.warn('[SalesforceAuth] Token exchange failed:', errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SalesforceAuth] Error during token exchange:', error);
|
||||
|
||||
// Hide processing modal
|
||||
setState(prev => ({ ...prev, processing: false, processingStep: '' }));
|
||||
|
||||
// Call error callback
|
||||
const errorMessage = error instanceof Error ? error.message : 'Authentication failed';
|
||||
onAuthError?.(errorMessage);
|
||||
|
||||
// Show error alert
|
||||
Alert.alert(
|
||||
'Authentication Error',
|
||||
errorMessage,
|
||||
[
|
||||
{
|
||||
text: 'Retry',
|
||||
onPress: () => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: null,
|
||||
currentUrl: buildSalesforceAuthUrl(user?.uuid || '')
|
||||
}));
|
||||
webViewRef.current?.reload();
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Cancel',
|
||||
onPress: onClose,
|
||||
style: 'cancel',
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}, [onAuthSuccess, onAuthError, onClose, user?.uuid]);
|
||||
|
||||
// Handle backend callback error
|
||||
const handleBackendError = useCallback((url: string) => {
|
||||
const errorMessage = getQueryParamFromUrl(url, 'message') ||
|
||||
getQueryParamFromUrl(url, 'error_description') ||
|
||||
'Failed to authenticate with Salesforce';
|
||||
|
||||
console.error('[SalesforceAuth] Backend callback error:', errorMessage);
|
||||
|
||||
// Hide processing modal
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
processing: false,
|
||||
processingStep: '',
|
||||
error: errorMessage
|
||||
}));
|
||||
|
||||
// Call error callback
|
||||
onAuthError?.(errorMessage);
|
||||
|
||||
// Show error alert
|
||||
Alert.alert(
|
||||
'Authentication Error',
|
||||
errorMessage,
|
||||
[
|
||||
{
|
||||
text: 'Retry',
|
||||
onPress: () => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: null,
|
||||
currentUrl: buildSalesforceAuthUrl(user?.uuid || '')
|
||||
}));
|
||||
webViewRef.current?.reload();
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Cancel',
|
||||
onPress: onClose,
|
||||
style: 'cancel',
|
||||
},
|
||||
]
|
||||
);
|
||||
}, [onAuthError, onClose, user?.uuid]);
|
||||
|
||||
// Handle WebView navigation state changes
|
||||
const handleNavigationStateChange = useCallback((navState: any) => {
|
||||
const { url, loading } = navState;
|
||||
console.log('[SalesforceAuth] nav change url:', url, 'loading:', loading);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading,
|
||||
currentUrl: url,
|
||||
}));
|
||||
|
||||
// Check if this is the backend callback URL (after Salesforce redirects)
|
||||
if (isBackendCallbackUri(url)) {
|
||||
console.log('[SalesforceAuth] Backend callback detected');
|
||||
|
||||
// Extract authorization code from the URL
|
||||
const authCode = extractAuthCode(url);
|
||||
console.log('[SalesforceAuth] Authorization code in URL:', authCode);
|
||||
|
||||
// If we have an authorization code, capture it and send to backend
|
||||
if (authCode && !state.processing) {
|
||||
console.log('[SalesforceAuth] Capturing authorization code and sending to backend');
|
||||
handleAuthorizationCode(authCode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for success or error status (if backend already processed and redirected)
|
||||
if (isCallbackSuccess(url)) {
|
||||
console.log('[SalesforceAuth] Backend callback indicates success (already processed)');
|
||||
// Just close the modal - backend already handled everything
|
||||
setState(prev => ({ ...prev, processing: false }));
|
||||
onAuthSuccess?.({
|
||||
accessToken: 'token_exchanged_by_backend',
|
||||
tokenType: 'Bearer',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCallbackError(url)) {
|
||||
console.log('[SalesforceAuth] Backend callback indicates error');
|
||||
handleBackendError(url);
|
||||
return;
|
||||
}
|
||||
|
||||
// If no code and no status, show processing
|
||||
if (!state.processing) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
processing: true,
|
||||
processingStep: 'Processing Salesforce authentication...'
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Salesforce OAuth errors (before redirect to backend)
|
||||
const oauthError = getQueryParamFromUrl(url, 'error');
|
||||
if (oauthError) {
|
||||
const errorDescription = getQueryParamFromUrl(url, 'error_description') || oauthError;
|
||||
console.error('[SalesforceAuth] OAuth error:', errorDescription);
|
||||
handleAuthError(errorDescription);
|
||||
return;
|
||||
}
|
||||
}, [state.processing, handleAuthorizationCode, handleBackendError, onAuthSuccess]);
|
||||
|
||||
// Allow WebView to load all URLs (backend callback is HTTP, not custom scheme)
|
||||
const handleShouldStartLoadWithRequest = useCallback((request: any) => {
|
||||
const { url } = request || {};
|
||||
if (!url) return true;
|
||||
|
||||
// Log for debugging
|
||||
console.log('[SalesforceAuth] onShouldStart load:', url);
|
||||
|
||||
// Allow all HTTP/HTTPS URLs (backend callback will be handled in handleNavigationStateChange)
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
// Handle authentication error
|
||||
const handleAuthError = useCallback((error: string) => {
|
||||
setState(prev => ({ ...prev, error, loading: false }));
|
||||
onAuthError?.(error);
|
||||
|
||||
Alert.alert(
|
||||
'Salesforce Authentication Error',
|
||||
error,
|
||||
[
|
||||
{
|
||||
text: 'Retry',
|
||||
onPress: () => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: null,
|
||||
currentUrl: buildSalesforceAuthUrl(user?.uuid || '')
|
||||
}));
|
||||
webViewRef.current?.reload();
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Cancel',
|
||||
onPress: onClose,
|
||||
style: 'cancel',
|
||||
},
|
||||
]
|
||||
);
|
||||
}, [onAuthError, onClose, user?.uuid]);
|
||||
|
||||
// Handle WebView error
|
||||
const handleWebViewError = useCallback((syntheticEvent: any) => {
|
||||
const { nativeEvent } = syntheticEvent;
|
||||
console.error('WebView error:', nativeEvent);
|
||||
handleAuthError('Failed to load Salesforce login page. Please check your internet connection.');
|
||||
}, [handleAuthError]);
|
||||
|
||||
const handleLoadStart = useCallback((e: any) => {
|
||||
console.log('[SalesforceAuth] load start:', e?.nativeEvent?.url);
|
||||
}, []);
|
||||
|
||||
const handleLoadEnd = useCallback((e: any) => {
|
||||
console.log('[SalesforceAuth] load end:', e?.nativeEvent?.url);
|
||||
}, []);
|
||||
|
||||
// Handle close button press
|
||||
const handleClose = useCallback(() => {
|
||||
onClose?.();
|
||||
}, [onClose]);
|
||||
|
||||
// Handle reload
|
||||
const handleReload = useCallback(() => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: null,
|
||||
currentUrl: buildSalesforceAuthUrl(user?.uuid || '')
|
||||
}));
|
||||
webViewRef.current?.reload();
|
||||
}, [user?.uuid]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
{/* Header */}
|
||||
<View style={[styles.header, { backgroundColor: colors.surface, ...shadows.light }]}>
|
||||
<View style={styles.headerContent}>
|
||||
<Icon name="cloud" size={24} color="#00A1E0" />
|
||||
<Text style={[styles.headerTitle, { color: colors.text, fontFamily: fonts.medium }]}>
|
||||
Salesforce Authentication
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.closeButton, { backgroundColor: colors.background }]}
|
||||
onPress={handleClose}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Icon name="close" size={20} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Error State */}
|
||||
{state.error && (
|
||||
<View style={[styles.errorContainer, { backgroundColor: colors.surface }]}>
|
||||
<Icon name="error-outline" size={48} color={colors.error} />
|
||||
<Text style={[styles.errorTitle, { color: colors.text, fontFamily: fonts.medium }]}>
|
||||
Authentication Failed
|
||||
</Text>
|
||||
<Text style={[styles.errorMessage, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||
{state.error}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.retryButton, { backgroundColor: '#00A1E0' }]}
|
||||
onPress={handleReload}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[styles.retryButtonText, { color: colors.surface, fontFamily: fonts.medium }]}>
|
||||
Try Again
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Processing Modal */}
|
||||
{state.processing && (
|
||||
<View style={[styles.processingOverlay, { backgroundColor: 'rgba(0, 0, 0, 0.7)' }]}>
|
||||
<View style={[styles.processingModal, { backgroundColor: colors.surface }]}>
|
||||
<View style={styles.processingIconContainer}>
|
||||
<ActivityIndicator size="large" color="#00A1E0" />
|
||||
</View>
|
||||
<Text style={[styles.processingTitle, { color: colors.text, fontFamily: fonts.bold }]}>
|
||||
Setting Up Salesforce
|
||||
</Text>
|
||||
<Text style={[styles.processingStep, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||
{state.processingStep}
|
||||
</Text>
|
||||
<View style={styles.processingDots}>
|
||||
<View style={[styles.dot, { backgroundColor: '#00A1E0' }]} />
|
||||
<View style={[styles.dot, { backgroundColor: '#00A1E0' }]} />
|
||||
<View style={[styles.dot, { backgroundColor: '#00A1E0' }]} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{state.loading && !state.error && (
|
||||
<View style={[styles.loadingOverlay, { backgroundColor: colors.background }]}>
|
||||
<ActivityIndicator size="large" color="#00A1E0" />
|
||||
<Text style={[styles.loadingText, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||
Loading Salesforce Login...
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* WebView */}
|
||||
{!state.error && !state.processing && (
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={{ uri: state.currentUrl }}
|
||||
style={styles.webView}
|
||||
onNavigationStateChange={handleNavigationStateChange}
|
||||
onLoadStart={handleLoadStart}
|
||||
onLoadEnd={handleLoadEnd}
|
||||
onError={handleWebViewError}
|
||||
onHttpError={handleWebViewError}
|
||||
startInLoadingState={true}
|
||||
javaScriptEnabled={true}
|
||||
domStorageEnabled={true}
|
||||
sharedCookiesEnabled={true}
|
||||
thirdPartyCookiesEnabled={true}
|
||||
mixedContentMode="compatibility"
|
||||
originWhitelist={["*"]}
|
||||
allowsInlineMediaPlayback={true}
|
||||
mediaPlaybackRequiresUserAction={false}
|
||||
onShouldStartLoadWithRequest={handleShouldStartLoadWithRequest}
|
||||
setSupportMultipleWindows={false}
|
||||
javaScriptCanOpenWindowsAutomatically={true}
|
||||
renderLoading={() => (
|
||||
<View style={[styles.webViewLoading, { backgroundColor: colors.background }]}>
|
||||
<ActivityIndicator size="large" color="#00A1E0" />
|
||||
<Text style={[styles.webViewLoadingText, { color: colors.textLight, fontFamily: fonts.regular }]}>
|
||||
Loading...
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E2E8F0',
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
marginLeft: 8,
|
||||
},
|
||||
closeButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: 20,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
errorMessage: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
marginBottom: 24,
|
||||
},
|
||||
retryButton: {
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
},
|
||||
retryButtonText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
loadingOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 1000,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
marginTop: 16,
|
||||
},
|
||||
webView: {
|
||||
flex: 1,
|
||||
},
|
||||
webViewLoading: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
webViewLoadingText: {
|
||||
fontSize: 14,
|
||||
marginTop: 8,
|
||||
},
|
||||
processingOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 2000,
|
||||
},
|
||||
processingModal: {
|
||||
borderRadius: 16,
|
||||
padding: 32,
|
||||
alignItems: 'center',
|
||||
minWidth: 280,
|
||||
maxWidth: 320,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
processingIconContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
processingTitle: {
|
||||
fontSize: 20,
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
processingStep: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
processingDots: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
});
|
||||
|
||||
export default SalesforceAuth;
|
||||
|
||||
@ -8,9 +8,9 @@ import { clearSelectedService } from '@/modules/integrations/store/integrationsS
|
||||
let pendingRequest: any = null;
|
||||
|
||||
const http = create({
|
||||
// baseURL: 'http://192.168.1.20:4000',
|
||||
baseURL: 'http://160.187.167.216',
|
||||
// baseURL: 'https://angry-gifts-shave.loca.lt',
|
||||
// baseURL: 'http://192.168.1.17:4000',
|
||||
// baseURL: 'http://160.187.167.216',
|
||||
baseURL: 'https://512acb53a4a4.ngrok-free.app',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ import zohoProjectsSlice from '@/modules/zohoProjects/store/zohoProjectsSlice';
|
||||
import profileSlice from '@/modules/profile/store/profileSlice';
|
||||
import integrationsSlice from '@/modules/integrations/store/integrationsSlice';
|
||||
import crmSlice from '@/modules/crm/zoho/store/crmSlice';
|
||||
import salesforceCrmSlice from '@/modules/crm/salesforce/store/salesforceCrmSlice';
|
||||
import zohoBooksSlice from '@/modules/finance/zoho/store/zohoBooksSlice';
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
@ -19,6 +20,7 @@ const rootReducer = combineReducers({
|
||||
profile: profileSlice.reducer,
|
||||
integrations: integrationsSlice.reducer,
|
||||
crm: crmSlice,
|
||||
salesforceCrm: salesforceCrmSlice,
|
||||
zohoBooks: zohoBooksSlice,
|
||||
ui: uiSlice.reducer,
|
||||
});
|
||||
@ -26,7 +28,7 @@ const rootReducer = combineReducers({
|
||||
const persistConfig = {
|
||||
key: 'root',
|
||||
storage: AsyncStorage,
|
||||
whitelist: ['auth', 'zohoPeople', 'zohoProjects', 'profile', 'integrations', 'crm', 'zohoBooks'],
|
||||
whitelist: ['auth', 'zohoPeople', 'zohoProjects', 'profile', 'integrations', 'crm', 'salesforceCrm', 'zohoBooks'],
|
||||
blacklist: ['ui'],
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user