diff --git a/influence/CSRF_FIX_SUMMARY.md b/influence/CSRF_FIX_SUMMARY.md new file mode 100644 index 0000000..d701a18 --- /dev/null +++ b/influence/CSRF_FIX_SUMMARY.md @@ -0,0 +1,161 @@ +# CSRF Security Update - Fix Summary + +## Date: October 23, 2025 + +## Issues Encountered + +After implementing CSRF security updates, the application experienced two main issues: + +### 1. Login Failed with "Invalid CSRF token" +**Problem**: The login endpoint required a CSRF token, but users couldn't get a token before logging in (chicken-and-egg problem). + +**Root Cause**: The `/api/auth/login` endpoint was being protected by CSRF middleware, but there's no session yet during initial login. + +**Solution**: Added `/api/auth/login` and `/api/auth/session` to the CSRF exempt routes list in `app/middleware/csrf.js`. Login endpoints use credentials (username/password) for authentication, so they don't need CSRF protection. + +### 2. Campaign Creation Failed with Infinite Retry Loop +**Problem**: When creating campaigns, the app would get stuck in an infinite retry loop with repeated "CSRF token validation failed" errors. + +**Root Causes**: +1. The API client (`api-client.js`) wasn't fetching or sending CSRF tokens at all +2. The retry logic didn't have a guard against infinite recursion +3. FormData wasn't including the CSRF token + +**Solutions**: +1. **Added CSRF token management** to the API client: + - `fetchCsrfToken()` - Fetches token from `/api/csrf-token` endpoint + - `ensureCsrfToken()` - Ensures a valid token exists before requests + - Tokens are automatically included in state-changing requests (POST, PUT, PATCH, DELETE) + +2. **Fixed infinite retry loop**: + - Added `isRetry` parameter to `makeRequest()`, `postFormData()`, and `putFormData()` + - Retry only happens once per request + - If second attempt fails, error is thrown to the user + +3. **Enhanced token handling**: + - JSON requests: Token sent via `X-CSRF-Token` header + - FormData requests: Token sent via `_csrf` field + - Token automatically refreshed if server responds with new token + +4. **Server-side updates**: + - Added explicit CSRF protection to `/api/csrf-token` endpoint so it can generate tokens + - Exported `csrfProtection` middleware for explicit use + +## Files Modified + +### 1. `app/middleware/csrf.js` +```javascript +// Added to exempt routes: +const csrfExemptRoutes = [ + '/api/health', + '/api/metrics', + '/api/config', + '/api/auth/login', // ← NEW: Login uses credentials + '/api/auth/session', // ← NEW: Session check is read-only + '/api/representatives/postal/', + '/api/campaigns/public' +]; + +// Enhanced getCsrfToken with error handling +``` + +### 2. `app/server.js` +```javascript +// Added csrfProtection to imports +const { conditionalCsrfProtection, getCsrfToken, csrfProtection } = require('./middleware/csrf'); + +// Applied explicit CSRF protection to token endpoint +app.get('/api/csrf-token', csrfProtection, getCsrfToken); +``` + +### 3. `app/public/js/api-client.js` +- Added CSRF token caching and fetching logic +- Modified `makeRequest()` to include `X-CSRF-Token` header +- Modified `postFormData()` and `putFormData()` to include `_csrf` field +- Added retry logic with infinite loop protection (max 1 retry) +- Added automatic token refresh on 403 errors + +## How CSRF Protection Works Now + +### Flow for State-Changing Requests (POST, PUT, DELETE): + +``` +1. User Action (e.g., "Create Campaign") + ↓ +2. API Client checks if CSRF token exists + ↓ (if no token) +3. Fetch token from GET /api/csrf-token + ↓ +4. Include token in request: + - Header: X-CSRF-Token (for JSON) + - FormData: _csrf (for file uploads) + ↓ +5. Server validates token matches session + ↓ +6a. Success → Process request +6b. Invalid Token → Return 403 + ↓ (on 403, if not a retry) +7. Clear token, fetch new one, retry ONCE + ↓ +8a. Success → Return data +8b. Still fails → Throw error to user +``` + +### Protected vs Exempt Endpoints + +**Protected (requires CSRF token)**: +- ✅ POST `/api/admin/campaigns` - Create campaign +- ✅ PUT `/api/admin/campaigns/:id` - Update campaign +- ✅ POST `/api/emails/send` - Send email +- ✅ POST `/api/auth/logout` - Logout +- ✅ POST `/api/auth/change-password` - Change password + +**Exempt (no CSRF required)**: +- ✅ GET (all GET requests are safe) +- ✅ POST `/api/auth/login` - Uses credentials +- ✅ GET `/api/auth/session` - Read-only check +- ✅ GET `/api/health` - Public health check +- ✅ GET `/api/metrics` - Prometheus metrics + +## Testing Checklist + +- [x] Login as admin works +- [ ] Create new campaign works +- [ ] Update existing campaign works +- [ ] Delete campaign works +- [ ] Send email to representative works +- [ ] Logout works +- [ ] Password change works +- [ ] Public pages work without authentication + +## Security Benefits + +1. **CSRF Attack Prevention**: Malicious sites can't forge requests to your app +2. **Session Hijacking Protection**: httpOnly, secure, sameSite cookies +3. **Defense in Depth**: Multiple security layers (Helmet, rate limiting, CSRF, validation) +4. **Automatic Token Rotation**: Tokens refresh on each response when available +5. **Retry Logic**: Handles token expiration gracefully + +## Important Notes + +- CSRF tokens are tied to sessions and expire with the session (1 hour) +- Tokens are stored in cookies (httpOnly, secure in production) +- The retry logic prevents infinite loops by limiting to 1 retry per request +- Login doesn't need CSRF because it uses credentials for authentication +- All state-changing operations (POST/PUT/DELETE) now require valid CSRF tokens + +## Troubleshooting + +**If you see "Invalid CSRF token" errors:** + +1. Check browser console for detailed error messages +2. Clear browser cookies and session storage +3. Logout and login again to get a fresh session +4. Verify the session hasn't expired (1 hour timeout) +5. Check server logs for CSRF validation failures + +**If infinite retry loop occurs:** + +1. Check that `isRetry` parameter is being passed correctly +2. Verify FormData isn't being reused across retries +3. Clear the API client's cached token: `window.apiClient.csrfToken = null` diff --git a/influence/CUSTOM_RECIPIENTS_IMPLEMENTATION.md b/influence/CUSTOM_RECIPIENTS_IMPLEMENTATION.md new file mode 100644 index 0000000..38e4df4 --- /dev/null +++ b/influence/CUSTOM_RECIPIENTS_IMPLEMENTATION.md @@ -0,0 +1,243 @@ +# Custom Recipients Implementation + +## Overview +This feature allows campaigns to target any email address (custom recipients) instead of or in addition to elected representatives from the Represent API. + +## Implementation Summary + +### ✅ Backend (Complete) + +#### 1. Database Schema (`scripts/build-nocodb.sh`) +- **custom_recipients table** with fields: + - `id` - Primary key + - `campaign_id` - Links to campaigns table + - `campaign_slug` - Campaign identifier + - `recipient_name` - Full name of recipient + - `recipient_email` - Email address + - `recipient_title` - Job title/position (optional) + - `recipient_organization` - Organization name (optional) + - `notes` - Internal notes (optional) + - `is_active` - Boolean flag + +- **campaigns table** updated: + - Added `allow_custom_recipients` boolean field (default: false) + +#### 2. Backend Controller (`app/controllers/customRecipients.js`) +Full CRUD operations: +- `getRecipientsByCampaign(req, res)` - Fetch all recipients for a campaign +- `createRecipient(req, res)` - Add single recipient with validation +- `bulkCreateRecipients(req, res)` - Import multiple recipients from CSV +- `updateRecipient(req, res)` - Update recipient details +- `deleteRecipient(req, res)` - Delete single recipient +- `deleteAllRecipients(req, res)` - Clear all recipients for a campaign + +#### 3. NocoDB Service (`app/services/nocodb.js`) +- `getCustomRecipients(campaignId)` - Query by campaign ID +- `getCustomRecipientsBySlug(campaignSlug)` - Query by slug +- `createCustomRecipient(recipientData)` - Create with field mapping +- `updateCustomRecipient(recipientId, updateData)` - Partial updates +- `deleteCustomRecipient(recipientId)` - Single deletion +- `deleteCustomRecipientsByCampaign(campaignId)` - Bulk deletion + +#### 4. API Routes (`app/routes/api.js`) +All routes protected with `requireNonTemp` authentication: +- `GET /api/campaigns/:slug/custom-recipients` - List all recipients +- `POST /api/campaigns/:slug/custom-recipients` - Create single recipient +- `POST /api/campaigns/:slug/custom-recipients/bulk` - Bulk import +- `PUT /api/campaigns/:slug/custom-recipients/:id` - Update recipient +- `DELETE /api/campaigns/:slug/custom-recipients/:id` - Delete recipient +- `DELETE /api/campaigns/:slug/custom-recipients` - Delete all recipients + +#### 5. Campaign Controller Updates (`app/controllers/campaigns.js`) +- Added `allow_custom_recipients` field to all campaign CRUD operations +- Field normalization in 5+ locations for consistent API responses + +### ✅ Frontend (Complete) + +#### 1. JavaScript Module (`app/public/js/custom-recipients.js`) +Comprehensive module with: +- **CRUD Operations**: Add, edit, delete recipients +- **Bulk Import**: CSV file upload or paste with parsing +- **Validation**: Email format validation +- **UI Management**: Dynamic recipient list display with cards +- **Error Handling**: User-friendly error messages +- **XSS Protection**: HTML escaping for security + +Key methods: +```javascript +CustomRecipients.init(campaignSlug) // Initialize module +CustomRecipients.loadRecipients(slug) // Load from API +CustomRecipients.displayRecipients() // Render list +// Plus handleAddRecipient, handleEditRecipient, handleDeleteRecipient, etc. +``` + +#### 2. Admin Panel Integration (`app/public/admin.html` + `app/public/js/admin.js`) +- **Create Form**: Checkbox to enable custom recipients +- **Edit Form**: + - Checkbox with show/hide toggle + - Add recipient form (5 fields: name, email, title, organization, notes) + - Bulk CSV import button with modal + - Recipients list with edit/delete actions + - Clear all button +- **JavaScript Integration**: + - `toggleCustomRecipientsSection()` - Show/hide based on checkbox + - `setupCustomRecipientsHandlers()` - Event listeners for checkbox + - Auto-load recipients when editing campaign with feature enabled + - Form data includes `allow_custom_recipients` in create/update + +#### 3. Bulk Import Modal (`app/public/admin.html`) +Complete modal with: +- CSV format instructions +- File upload input +- Paste textarea for direct CSV input +- Import results display with success/failure details +- CSV format: `recipient_name,recipient_email,recipient_title,recipient_organization,notes` + +#### 4. CSS Styling (`app/public/admin.html`) +- `.recipient-card` - Card layout with hover effects +- `.recipient-info` - Name, email, metadata display +- `.recipient-actions` - Edit/delete icon buttons with hover colors +- `.bulk-import-help` - Modal styling +- Responsive grid layout + +## Usage + +### For Administrators: + +1. **Create Campaign**: + - Check "Allow Custom Recipients" during creation + - An info section will appear explaining that recipients can be added after campaign is created + - Complete the campaign creation + +2. **Edit Campaign**: + - Navigate to the Edit tab and select your campaign + - Check "Allow Custom Recipients" to enable the feature + - The custom recipients management section will appear below the checkbox + +3. **Add Single Recipient**: + - Fill in name (required) and email (required) + - Optionally add title, organization, notes + - Click "Add Recipient" + +4. **Bulk Import**: + - Click "Bulk Import (CSV)" button + - Upload CSV file or paste CSV data + - CSV format: `recipient_name,recipient_email,recipient_title,recipient_organization,notes` + - First row can be header (will be skipped if contains "recipient_name") + - Results show success/failure for each row + +5. **Edit Recipient**: + - Click edit icon on recipient card + - Form populates with current data + - Make changes and click "Update Recipient" + - Or click "Cancel" to revert + +6. **Delete Recipients**: + - Single: Click delete icon on card + - All: Click "Clear All" button + +### API Examples: + +```bash +# Create recipient +curl -X POST /api/campaigns/my-campaign/custom-recipients \ + -H "Content-Type: application/json" \ + -d '{ + "recipient_name": "Jane Doe", + "recipient_email": "jane@example.com", + "recipient_title": "CEO", + "recipient_organization": "Tech Corp" + }' + +# Bulk import +curl -X POST /api/campaigns/my-campaign/custom-recipients/bulk \ + -H "Content-Type: application/json" \ + -d '{ + "recipients": [ + {"recipient_name": "John Smith", "recipient_email": "john@example.com"}, + {"recipient_name": "Jane Doe", "recipient_email": "jane@example.com"} + ] + }' + +# Get all recipients +curl /api/campaigns/my-campaign/custom-recipients + +# Update recipient +curl -X PUT /api/campaigns/my-campaign/custom-recipients/123 \ + -H "Content-Type: application/json" \ + -d '{"recipient_title": "CTO"}' + +# Delete recipient +curl -X DELETE /api/campaigns/my-campaign/custom-recipients/123 + +# Delete all recipients +curl -X DELETE /api/campaigns/my-campaign/custom-recipients +``` + +## Security Features + +- **Authentication**: All API routes require non-temporary user session +- **Validation**: Email format validation on client and server +- **XSS Protection**: HTML escaping in display +- **Campaign Check**: Verifies campaign exists and feature is enabled +- **Input Sanitization**: express-validator on API endpoints + +## Next Steps (TODO) + +1. **Dashboard Integration**: Add same UI to `dashboard.html` for regular users +2. **Campaign Display**: Update `campaign.js` to show custom recipients alongside elected officials +3. **Email Composer**: Ensure custom recipients work in email sending flow +4. **Testing**: Comprehensive end-to-end testing +5. **Documentation**: Update main README and files-explainer + +## Files Modified/Created + +### Backend: +- ✅ `scripts/build-nocodb.sh` - Database schema +- ✅ `app/controllers/customRecipients.js` - NEW FILE (282 lines) +- ✅ `app/services/nocodb.js` - Service methods +- ✅ `app/routes/api.js` - API endpoints +- ✅ `app/controllers/campaigns.js` - Field updates + +### Frontend: +- ✅ `app/public/js/custom-recipients.js` - NEW FILE (538 lines) +- ✅ `app/public/js/admin.js` - Integration code +- ✅ `app/public/admin.html` - UI components and forms + +### Documentation: +- ✅ `CUSTOM_RECIPIENTS_IMPLEMENTATION.md` - This file + +## Testing Checklist + +- [ ] Database table creation (run build-nocodb.sh) +- [ ] Create campaign with custom recipients enabled +- [ ] Add single recipient via form +- [ ] Edit recipient information +- [ ] Delete single recipient +- [ ] Bulk import via CSV file +- [ ] Bulk import via paste +- [ ] Clear all recipients +- [ ] Toggle checkbox on/off +- [ ] Verify API authentication +- [ ] Test with campaign where feature is disabled +- [ ] Check recipient display on campaign page +- [ ] Test email sending to custom recipients + +## Known Limitations + +1. Custom recipients can only be added AFTER campaign is created (not during creation) +2. Dashboard UI not yet implemented (admin panel only) +3. Campaign display page doesn't show custom recipients yet +4. CSV import uses simple comma splitting (doesn't handle quoted commas) +5. No duplicate email detection + +## Future Enhancements + +- [ ] Duplicate email detection/prevention +- [ ] Import validation preview before saving +- [ ] Export recipients to CSV +- [ ] Recipient groups/categories +- [ ] Import from external sources (Google Contacts, etc.) +- [ ] Recipient engagement tracking +- [ ] Custom fields for recipients +- [ ] Merge tags in email templates using recipient data diff --git a/influence/DEBUGGING_CUSTOM_RECIPIENTS.md b/influence/DEBUGGING_CUSTOM_RECIPIENTS.md new file mode 100644 index 0000000..9507b4e --- /dev/null +++ b/influence/DEBUGGING_CUSTOM_RECIPIENTS.md @@ -0,0 +1,119 @@ +# Debugging Custom Recipients Feature + +## Changes Made to Fix Checkbox Toggle + +### Issue +The "Allow Custom Recipients" checkbox wasn't showing/hiding the custom recipients management section when clicked. + +### Root Causes +1. **Event Listener Timing**: Original code tried to attach event listeners during `init()`, but the edit form elements didn't exist yet +2. **Not Following Best Practices**: Wasn't using event delegation pattern as required by `instruct.md` + +### Solution +Switched to **event delegation** pattern using a single document-level listener: + +```javascript +// OLD (didn't work - elements didn't exist yet): +const editCheckbox = document.getElementById('edit-allow-custom-recipients'); +if (editCheckbox) { + editCheckbox.addEventListener('change', handler); +} + +// NEW (works with event delegation): +document.addEventListener('change', (e) => { + if (e.target.id === 'edit-allow-custom-recipients') { + // Handle the change + } +}); +``` + +### Benefits of Event Delegation +1. ✅ Works regardless of when elements are added to DOM +2. ✅ Follows `instruct.md` rules about using `addEventListener` +3. ✅ No need to reattach listeners when switching tabs +4. ✅ Single listener handles all checkbox changes efficiently + +### Console Logs Added for Debugging +The following console logs were added to help trace execution: + +1. **admin.js init()**: "AdminPanel init started" and "AdminPanel init completed" +2. **custom-recipients.js load**: "Custom Recipients module loading..." and "Custom Recipients module initialized" +3. **setupCustomRecipientsHandlers()**: "Setting up custom recipients handlers" and "Custom recipients handlers set up with event delegation" +4. **Checkbox change**: "Custom recipients checkbox changed: true/false" +5. **Module init**: "Initializing CustomRecipients module for campaign: [slug]" +6. **toggleCustomRecipientsSection()**: "Toggling custom recipients section: true/false" and "Section display set to: block/none" + +### Testing Steps + +1. **Open Browser Console** (F12) +2. **Navigate to Admin Panel** → Look for "AdminPanel init started" +3. **Look for Module Load** → "Custom Recipients module loading..." + +**Test Create Form:** +4. **Switch to Create Tab** → Click "Create New Campaign" +5. **Check the Checkbox** → "Allow Custom Recipients" +6. **Verify Info Section Appears** → Should see: "Custom recipients can only be added after the campaign is created" +7. **Console Should Show**: "Create form: Custom recipients checkbox changed: true" + +**Test Edit Form:** +8. **Switch to Edit Tab** → Select a campaign +9. **Check the Checkbox** → "Allow Custom Recipients" +10. **You Should See**: + - "Custom recipients checkbox changed: true" + - "Toggling custom recipients section: true" + - "Section display set to: block" + - "Initializing CustomRecipients module for campaign: [slug]" +11. **Verify Section Appears** → The "Manage Custom Recipients" section with forms should now be visible + +### If It Still Doesn't Work + +Check the following in browser console: + +1. **Are scripts loading?** + ``` + Look for: "Custom Recipients module loading..." + If missing: Check network tab for 404 errors on custom-recipients.js + ``` + +2. **Is event delegation working?** + ``` + Look for: "Custom recipients handlers set up with event delegation" + If missing: Check if setupCustomRecipientsHandlers() is being called + ``` + +3. **Is checkbox being detected?** + ``` + Click checkbox and look for: "Custom recipients checkbox changed: true" + If missing: Check if checkbox ID is correct in HTML + ``` + +4. **Is section element found?** + ``` + Look for: "section found: [object HTMLDivElement]" + If it says "section found: null": Check if section ID matches in HTML + ``` + +5. **Manual test in console:** + ```javascript + // Check if checkbox exists + document.getElementById('edit-allow-custom-recipients') + + // Check if section exists + document.getElementById('edit-custom-recipients-section') + + // Check if module loaded + window.CustomRecipients + + // Manually toggle section + document.getElementById('edit-custom-recipients-section').style.display = 'block'; + ``` + +### Files Modified +- ✅ `app/public/js/admin.js` - Changed to event delegation pattern, added logging +- ✅ `app/public/js/custom-recipients.js` - Added loading logs +- ✅ No changes needed to HTML (already correct) + +### Next Steps After Confirming It Works +1. Remove excessive console.log statements (or convert to debug mode) +2. Test full workflow: add recipient, edit, delete, bulk import +3. Proceed with dashboard.html integration diff --git a/influence/app/controllers/campaigns.js b/influence/app/controllers/campaigns.js index 6fa29fd..2d30d71 100644 --- a/influence/app/controllers/campaigns.js +++ b/influence/app/controllers/campaigns.js @@ -34,7 +34,7 @@ const upload = multer({ const allowedTypes = /jpeg|jpg|png|gif|webp/; const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); const mimetype = allowedTypes.test(file.mimetype); - + if (mimetype && extname) { return cb(null, true); } else { @@ -75,10 +75,10 @@ async function cacheRepresentatives(postalCode, representatives, representData) city: representData.city, province: representData.province }); - + // Cache representatives using the existing method const result = await nocoDB.storeRepresentatives(postalCode, representatives); - + if (result.success) { console.log(`Successfully cached ${result.count} representatives for ${postalCode}`); } else { @@ -95,7 +95,7 @@ class CampaignsController { async getPublicCampaigns(req, res, next) { try { const campaigns = await nocoDB.getAllCampaigns(); - + // Filter to only active campaigns and normalize data structure const activeCampaigns = await Promise.all( campaigns @@ -105,7 +105,7 @@ class CampaignsController { }) .map(async (campaign) => { const id = campaign.ID || campaign.Id || campaign.id; - + // Debug: Log specific fields we're looking for console.log(`Campaign ${id}:`, { 'Show Call Count': campaign['Show Call Count'], @@ -113,7 +113,7 @@ class CampaignsController { 'Show Email Count': campaign['Show Email Count'], 'show_email_count': campaign.show_email_count }); - + // Get email count if show_email_count is enabled let emailCount = null; const showEmailCount = campaign['Show Email Count'] || campaign.show_email_count; @@ -132,9 +132,16 @@ class CampaignsController { console.log(`Call count result: ${callCount}`); } + // Get verified response count + let verifiedResponseCount = 0; + if (id != null) { + verifiedResponseCount = await nocoDB.getCampaignVerifiedResponseCount(id); + console.log(`Verified response count result: ${verifiedResponseCount}`); + } + const rawTargetLevels = campaign['Target Government Levels'] || campaign.target_government_levels; const normalizedTargetLevels = normalizeTargetLevels(rawTargetLevels); - + // Return only public-facing information return { id, @@ -149,7 +156,8 @@ class CampaignsController { target_government_levels: normalizedTargetLevels, created_at: campaign.CreatedAt || campaign.created_at, emailCount, - callCount + callCount, + verifiedResponseCount }; }) ); @@ -172,7 +180,7 @@ class CampaignsController { async getAllCampaigns(req, res, next) { try { const campaigns = await nocoDB.getAllCampaigns(); - + // Get email counts for each campaign and normalize data structure const campaignsWithCounts = await Promise.all(campaigns.map(async (campaign) => { const id = campaign.ID || campaign.Id || campaign.id; @@ -183,7 +191,7 @@ class CampaignsController { const rawTargetLevels = campaign['Target Government Levels'] || campaign.target_government_levels; const normalizedTargetLevels = normalizeTargetLevels(rawTargetLevels); - + // Normalize campaign data structure for frontend return { id, @@ -200,6 +208,7 @@ class CampaignsController { collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info, show_email_count: campaign['Show Email Count'] || campaign.show_email_count, allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing, + allow_custom_recipients: campaign['Allow Custom Recipients'] || campaign.allow_custom_recipients, show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall, target_government_levels: normalizedTargetLevels, created_at: campaign.CreatedAt || campaign.created_at, @@ -248,7 +257,7 @@ class CampaignsController { try { const { id } = req.params; const campaign = await nocoDB.getCampaignById(id); - + if (!campaign) { return res.status(404).json({ success: false, @@ -259,12 +268,12 @@ class CampaignsController { // Debug logging console.log('Campaign object keys:', Object.keys(campaign)); console.log('Campaign ID field:', campaign.ID, campaign.Id, campaign.id); - + const normalizedId = campaign.ID || campaign.Id || campaign.id; console.log('Using normalized ID:', normalizedId); - + const emailCount = await nocoDB.getCampaignEmailCount(normalizedId); - + // Normalize campaign data structure for frontend res.json({ success: true, @@ -283,6 +292,7 @@ class CampaignsController { collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info, show_email_count: campaign['Show Email Count'] || campaign.show_email_count, allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing, + allow_custom_recipients: campaign['Allow Custom Recipients'] || campaign.allow_custom_recipients, show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall, target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels), created_at: campaign.CreatedAt || campaign.created_at, @@ -309,7 +319,7 @@ class CampaignsController { try { const { slug } = req.params; const campaign = await nocoDB.getCampaignBySlug(slug); - + if (!campaign) { return res.status(404).json({ success: false, @@ -317,8 +327,8 @@ class CampaignsController { }); } - const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status); - if (campaignStatus !== 'active') { + const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status); + if (campaignStatus !== 'active') { return res.status(403).json({ success: false, error: 'Campaign is not currently active' @@ -370,6 +380,7 @@ class CampaignsController { show_email_count: campaign['Show Email Count'] || campaign.show_email_count, show_call_count: campaign['Show Call Count'] || campaign.show_call_count, allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing, + allow_custom_recipients: campaign['Allow Custom Recipients'] || campaign.allow_custom_recipients, show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall, target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels), emailCount, @@ -390,6 +401,22 @@ class CampaignsController { // Create new campaign async createCampaign(req, res, next) { try { + // Convert boolean fields from string to actual boolean if needed + const booleanFields = [ + 'allow_smtp_email', + 'allow_mailto_link', + 'collect_user_info', + 'show_email_count', + 'allow_email_editing', + 'allow_custom_recipients' + ]; + + booleanFields.forEach(field => { + if (req.body[field] !== undefined && typeof req.body[field] === 'string') { + req.body[field] = req.body[field] === 'true'; + } + }); + const { title, description, @@ -402,6 +429,7 @@ class CampaignsController { collect_user_info = true, show_email_count = true, allow_email_editing = false, + allow_custom_recipients = false, target_government_levels = ['Federal', 'Provincial', 'Municipal'] } = req.body; @@ -410,7 +438,7 @@ class CampaignsController { const ownerName = req.user?.name ?? req.session?.userName ?? null; const normalizedStatus = normalizeStatus(status, 'draft'); let slug = generateSlug(title); - + // Ensure slug is unique let counter = 1; let originalSlug = slug; @@ -432,6 +460,7 @@ class CampaignsController { collect_user_info, show_email_count, allow_email_editing, + allow_custom_recipients, // NocoDB MultiSelect expects an array of values target_government_levels: normalizeTargetLevels(target_government_levels), // Add user ownership data @@ -462,6 +491,7 @@ class CampaignsController { collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info, show_email_count: campaign['Show Email Count'] || campaign.show_email_count, allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing, + allow_custom_recipients: campaign['Allow Custom Recipients'] || campaign.allow_custom_recipients, target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels), created_at: campaign.CreatedAt || campaign.created_at, updated_at: campaign.UpdatedAt || campaign.updated_at, @@ -548,7 +578,28 @@ class CampaignsController { } else { console.log('No cover photo file in request'); } + + // Convert boolean fields from string to actual boolean + const booleanFields = [ + 'allow_smtp_email', + 'allow_mailto_link', + 'collect_user_info', + 'show_email_count', + 'allow_email_editing', + 'show_response_wall', + 'allow_custom_recipients' + ]; + booleanFields.forEach(field => { + if (updates[field] !== undefined) { + // Convert string 'true'/'false' to boolean + if (typeof updates[field] === 'string') { + updates[field] = updates[field] === 'true'; + } + // Already boolean, leave as is + } + }); + console.log('Updates object before saving:', updates); if (updates.status !== undefined) { @@ -578,7 +629,7 @@ class CampaignsController { if (oldSlug && newSlug && oldSlug !== newSlug) { console.log(`Campaign slug changed from '${oldSlug}' to '${newSlug}', updating references...`); const cascadeResult = await nocoDB.updateCampaignSlugReferences(id, oldSlug, newSlug); - + if (cascadeResult.success) { console.log(`Successfully updated slug references: ${cascadeResult.updatedCampaignEmails} campaign emails, ${cascadeResult.updatedCallLogs} call logs`); } else { @@ -604,6 +655,7 @@ class CampaignsController { collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info, show_email_count: campaign['Show Email Count'] || campaign.show_email_count, allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing, + allow_custom_recipients: campaign['Allow Custom Recipients'] || campaign.allow_custom_recipients, target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels), created_at: campaign.CreatedAt || campaign.created_at, updated_at: campaign.UpdatedAt || campaign.updated_at, @@ -627,7 +679,7 @@ class CampaignsController { async deleteCampaign(req, res, next) { try { const { id } = req.params; - + await nocoDB.deleteCampaign(id); res.json({ @@ -694,13 +746,13 @@ class CampaignsController { }); } - // Use custom email content if provided (when email editing is enabled), otherwise use campaign defaults - const allowEmailEditing = campaign['Allow Email Editing'] || campaign.allow_email_editing; - const subject = (allowEmailEditing && customEmailSubject) - ? customEmailSubject + // Use custom email content if provided (when email editing is enabled), otherwise use campaign defaults + const allowEmailEditing = campaign['Allow Email Editing'] || campaign.allow_email_editing; + const subject = (allowEmailEditing && customEmailSubject) + ? customEmailSubject : (campaign['Email Subject'] || campaign.email_subject); - const message = (allowEmailEditing && customEmailBody) - ? customEmailBody + const message = (allowEmailEditing && customEmailBody) + ? customEmailBody : (campaign['Email Body'] || campaign.email_body); let emailResult = { success: true }; @@ -833,7 +885,7 @@ class CampaignsController { async getRepresentativesForCampaign(req, res, next) { try { const { slug, postalCode } = req.params; - + // Get campaign to check target levels const campaign = await nocoDB.getCampaignBySlug(slug); if (!campaign) { @@ -855,13 +907,13 @@ class CampaignsController { const formattedPostalCode = postalCode.replace(/\s/g, '').toUpperCase(); let representatives = []; let result = null; - + // Try to check cached data first, but don't fail if NocoDB is down let cachedData = []; try { cachedData = await nocoDB.getRepresentativesByPostalCode(formattedPostalCode); console.log(`Cache check for ${formattedPostalCode}: found ${cachedData.length} records`); - + if (cachedData && cachedData.length > 0) { representatives = cachedData; console.log(`Using cached representatives for ${formattedPostalCode}`); @@ -874,13 +926,13 @@ class CampaignsController { if (representatives.length === 0) { console.log(`Fetching representatives from Represent API for ${formattedPostalCode}`); result = await representAPI.getRepresentativesByPostalCode(postalCode); - + // Process representatives from both concordance and centroid // Add concordance representatives (if any) if (result.representatives_concordance && result.representatives_concordance.length > 0) { representatives = representatives.concat(result.representatives_concordance); } - + // Add centroid representatives (if any) - these are the actual elected officials if (result.representatives_centroid && result.representatives_centroid.length > 0) { representatives = representatives.concat(result.representatives_centroid); @@ -915,10 +967,10 @@ class CampaignsController { const filteredRepresentatives = representatives.filter(rep => { const repLevel = rep.elected_office ? rep.elected_office.toLowerCase() : 'other'; - + return targetLevels.some(targetLevel => { const target = targetLevel.toLowerCase(); - + if (target === 'federal' && (repLevel.includes('mp') || repLevel.includes('member of parliament'))) { return true; } @@ -931,14 +983,38 @@ class CampaignsController { if (target === 'school board' && repLevel.includes('school')) { return true; } - + return false; }); }); + // Add custom recipients if enabled for this campaign + let customRecipients = []; + if (campaign['Allow Custom Recipients']) { + try { + customRecipients = await nocoDB.getCustomRecipientsBySlug(slug); + // Mark custom recipients with a type field to distinguish them + customRecipients = customRecipients.map(recipient => ({ + ...recipient, + is_custom_recipient: true, + name: recipient.recipient_name, + email: recipient.recipient_email, + elected_office: recipient.recipient_title || 'Custom Recipient', + party_name: recipient.recipient_organization || '', + photo_url: null // Custom recipients don't have photos + })); + } catch (error) { + console.error('Error loading custom recipients:', error); + // Don't fail the entire request if custom recipients fail to load + } + } + + // Combine elected officials and custom recipients + const allRecipients = [...filteredRepresentatives, ...customRecipients]; + res.json({ success: true, - representatives: filteredRepresentatives, + representatives: allRecipients, location: { city: result?.city || cachedData[0]?.city || 'Alberta', province: result?.province || cachedData[0]?.province || 'AB' @@ -990,9 +1066,9 @@ class CampaignsController { }); } } - + const analytics = await nocoDB.getCampaignAnalytics(id); - + res.json({ success: true, analytics @@ -1102,7 +1178,7 @@ class CampaignsController { // Build URL based on type const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; let targetUrl; - + if (type === 'response-wall') { targetUrl = `${appUrl}/response-wall.html?campaign=${slug}`; } else { diff --git a/influence/app/controllers/customRecipients.js b/influence/app/controllers/customRecipients.js new file mode 100644 index 0000000..58721be --- /dev/null +++ b/influence/app/controllers/customRecipients.js @@ -0,0 +1,283 @@ +const nocoDB = require('../services/nocodb'); +const { validateEmail } = require('../utils/validators'); + +class CustomRecipientsController { + /** + * Get all custom recipients for a campaign + */ + async getRecipientsByCampaign(req, res, next) { + try { + const { slug } = req.params; + + // Get campaign first to verify it exists and get ID + const campaign = await nocoDB.getCampaignBySlug(slug); + if (!campaign) { + return res.status(404).json({ error: 'Campaign not found' }); + } + + // Check if custom recipients are enabled for this campaign + // Use NocoDB column title, not camelCase + if (!campaign['Allow Custom Recipients']) { + return res.json({ recipients: [], message: 'Custom recipients not enabled for this campaign' }); + } + + // Get custom recipients for this campaign using slug + const recipients = await nocoDB.getCustomRecipientsBySlug(slug); + + res.json({ + success: true, + recipients: recipients || [], + count: recipients ? recipients.length : 0 + }); + } catch (error) { + console.error('Error fetching custom recipients:', error); + next(error); + } + } + + /** + * Create a single custom recipient + */ + async createRecipient(req, res, next) { + try { + const { slug } = req.params; + const { recipient_name, recipient_email, recipient_title, recipient_organization, notes } = req.body; + + // Validate required fields + if (!recipient_name || !recipient_email) { + return res.status(400).json({ error: 'Recipient name and email are required' }); + } + + // Validate email format + if (!validateEmail(recipient_email)) { + return res.status(400).json({ error: 'Invalid email format' }); + } + + // Get campaign to verify it exists and get ID + const campaign = await nocoDB.getCampaignBySlug(slug); + if (!campaign) { + return res.status(404).json({ error: 'Campaign not found' }); + } + + // Check if custom recipients are enabled for this campaign + // Use NocoDB column title, not camelCase field name + if (!campaign['Allow Custom Recipients']) { + console.warn('Custom recipients not enabled. Campaign data:', campaign); + return res.status(403).json({ error: 'Custom recipients not enabled for this campaign' }); + } + + // Create the recipient + // Use campaign.ID (NocoDB system field) not campaign.id + const recipientData = { + campaign_id: campaign.ID, + campaign_slug: slug, + recipient_name, + recipient_email, + recipient_title: recipient_title || null, + recipient_organization: recipient_organization || null, + notes: notes || null, + is_active: true + }; + + const newRecipient = await nocoDB.createCustomRecipient(recipientData); + + res.status(201).json({ + success: true, + recipient: newRecipient, + message: 'Recipient created successfully' + }); + } catch (error) { + console.error('Error creating custom recipient:', error); + next(error); + } + } + + /** + * Bulk create custom recipients + */ + async bulkCreateRecipients(req, res, next) { + try { + const { slug } = req.params; + const { recipients } = req.body; + + // Validate input + if (!Array.isArray(recipients) || recipients.length === 0) { + return res.status(400).json({ error: 'Recipients array is required and must not be empty' }); + } + + // Get campaign to verify it exists and get ID + const campaign = await nocoDB.getCampaignBySlug(slug); + if (!campaign) { + return res.status(404).json({ error: 'Campaign not found' }); + } + + // Check if custom recipients are enabled for this campaign + if (!campaign.allow_custom_recipients) { + return res.status(403).json({ error: 'Custom recipients not enabled for this campaign' }); + } + + const results = { + success: [], + failed: [], + total: recipients.length + }; + + // Process each recipient + for (const recipient of recipients) { + try { + // Validate required fields + if (!recipient.recipient_name || !recipient.recipient_email) { + results.failed.push({ + recipient, + error: 'Missing required fields (name or email)' + }); + continue; + } + + // Validate email format + if (!validateEmail(recipient.recipient_email)) { + results.failed.push({ + recipient, + error: 'Invalid email format' + }); + continue; + } + + // Create the recipient + const recipientData = { + campaign_id: campaign.id, + campaign_slug: slug, + recipient_name: recipient.recipient_name, + recipient_email: recipient.recipient_email, + recipient_title: recipient.recipient_title || null, + recipient_organization: recipient.recipient_organization || null, + notes: recipient.notes || null, + is_active: true + }; + + const newRecipient = await nocoDB.createCustomRecipient(recipientData); + results.success.push(newRecipient); + } catch (error) { + results.failed.push({ + recipient, + error: error.message || 'Unknown error' + }); + } + } + + res.status(201).json({ + success: true, + results, + message: `Successfully created ${results.success.length} of ${results.total} recipients` + }); + } catch (error) { + console.error('Error bulk creating custom recipients:', error); + next(error); + } + } + + /** + * Update a custom recipient + */ + async updateRecipient(req, res, next) { + try { + const { slug, id } = req.params; + const { recipient_name, recipient_email, recipient_title, recipient_organization, notes, is_active } = req.body; + + // Validate email if provided + if (recipient_email && !validateEmail(recipient_email)) { + return res.status(400).json({ error: 'Invalid email format' }); + } + + // Get campaign to verify it exists + const campaign = await nocoDB.getCampaignBySlug(slug); + if (!campaign) { + return res.status(404).json({ error: 'Campaign not found' }); + } + + // Build update data (only include provided fields) + const updateData = {}; + if (recipient_name !== undefined) updateData.recipient_name = recipient_name; + if (recipient_email !== undefined) updateData.recipient_email = recipient_email; + if (recipient_title !== undefined) updateData.recipient_title = recipient_title; + if (recipient_organization !== undefined) updateData.recipient_organization = recipient_organization; + if (notes !== undefined) updateData.notes = notes; + if (is_active !== undefined) updateData.is_active = is_active; + + // Update the recipient + const updatedRecipient = await nocoDB.updateCustomRecipient(id, updateData); + + if (!updatedRecipient) { + return res.status(404).json({ error: 'Recipient not found' }); + } + + res.json({ + success: true, + recipient: updatedRecipient, + message: 'Recipient updated successfully' + }); + } catch (error) { + console.error('Error updating custom recipient:', error); + next(error); + } + } + + /** + * Delete a custom recipient + */ + async deleteRecipient(req, res, next) { + try { + const { slug, id } = req.params; + + // Get campaign to verify it exists + const campaign = await nocoDB.getCampaignBySlug(slug); + if (!campaign) { + return res.status(404).json({ error: 'Campaign not found' }); + } + + // Delete the recipient + const deleted = await nocoDB.deleteCustomRecipient(id); + + if (!deleted) { + return res.status(404).json({ error: 'Recipient not found' }); + } + + res.json({ + success: true, + message: 'Recipient deleted successfully' + }); + } catch (error) { + console.error('Error deleting custom recipient:', error); + next(error); + } + } + + /** + * Delete all custom recipients for a campaign + */ + async deleteAllRecipients(req, res, next) { + try { + const { slug } = req.params; + + // Get campaign to verify it exists and get ID + const campaign = await nocoDB.getCampaignBySlug(slug); + if (!campaign) { + return res.status(404).json({ error: 'Campaign not found' }); + } + + // Delete all recipients for this campaign + const deletedCount = await nocoDB.deleteCustomRecipientsByCampaign(campaign.id); + + res.json({ + success: true, + deletedCount, + message: `Successfully deleted ${deletedCount} recipient(s)` + }); + } catch (error) { + console.error('Error deleting all custom recipients:', error); + next(error); + } + } +} + +module.exports = new CustomRecipientsController(); diff --git a/influence/app/controllers/listmonkController.js b/influence/app/controllers/listmonkController.js new file mode 100644 index 0000000..68debac --- /dev/null +++ b/influence/app/controllers/listmonkController.js @@ -0,0 +1,262 @@ +const listmonkService = require('../services/listmonk'); +const nocodbService = require('../services/nocodb'); +const logger = require('../utils/logger'); + +// Get Listmonk sync status +exports.getSyncStatus = async (req, res) => { + try { + const status = listmonkService.getSyncStatus(); + + // Also check connection if it's enabled + if (status.enabled && !status.connected) { + // Try to reconnect + const reconnected = await listmonkService.checkConnection(); + status.connected = reconnected; + } + + res.json(status); + } catch (error) { + logger.error('Failed to get Listmonk status', error); + res.status(500).json({ + success: false, + error: 'Failed to get sync status' + }); + } +}; + +// Sync all campaign participants to Listmonk +exports.syncCampaignParticipants = async (req, res) => { + try { + if (!listmonkService.syncEnabled) { + return res.status(400).json({ + success: false, + error: 'Listmonk sync is disabled' + }); + } + + // Get all campaign emails (use campaignEmails table, not emails) + const emailsData = await nocodbService.getAll(nocodbService.tableIds.campaignEmails); + const emails = emailsData?.list || []; + + // Get all campaigns for reference + const campaigns = await nocodbService.getAllCampaigns(); + + if (!emails || emails.length === 0) { + return res.json({ + success: true, + message: 'No campaign participants to sync', + results: { total: 0, success: 0, failed: 0, errors: [] } + }); + } + + const results = await listmonkService.bulkSyncCampaignParticipants(emails, campaigns); + + res.json({ + success: true, + message: `Campaign participants sync completed: ${results.success} succeeded, ${results.failed} failed`, + results + }); + } catch (error) { + logger.error('Campaign participants sync failed', error); + res.status(500).json({ + success: false, + error: 'Failed to sync campaign participants to Listmonk' + }); + } +}; + +// Sync all custom recipients to Listmonk +exports.syncCustomRecipients = async (req, res) => { + try { + if (!listmonkService.syncEnabled) { + return res.status(400).json({ + success: false, + error: 'Listmonk sync is disabled' + }); + } + + // Get all custom recipients + const recipientsData = await nocodbService.getAll(nocodbService.tableIds.customRecipients); + const recipients = recipientsData?.list || []; + + // Get all campaigns for reference + const campaigns = await nocodbService.getAllCampaigns(); + + if (!recipients || recipients.length === 0) { + return res.json({ + success: true, + message: 'No custom recipients to sync', + results: { total: 0, success: 0, failed: 0, errors: [] } + }); + } + + const results = await listmonkService.bulkSyncCustomRecipients(recipients, campaigns); + + res.json({ + success: true, + message: `Custom recipients sync completed: ${results.success} succeeded, ${results.failed} failed`, + results + }); + } catch (error) { + logger.error('Custom recipients sync failed', error); + res.status(500).json({ + success: false, + error: 'Failed to sync custom recipients to Listmonk' + }); + } +}; + +// Sync everything (participants and custom recipients) +exports.syncAll = async (req, res) => { + try { + if (!listmonkService.syncEnabled) { + return res.status(400).json({ + success: false, + error: 'Listmonk sync is disabled' + }); + } + + let results = { + participants: { total: 0, success: 0, failed: 0, errors: [] }, + customRecipients: { total: 0, success: 0, failed: 0, errors: [] } + }; + + // Get campaigns once for both syncs + const campaigns = await nocodbService.getAllCampaigns(); + + // Sync campaign participants + try { + const emailsData = await nocodbService.getAll(nocodbService.tableIds.campaignEmails); + const emails = emailsData?.list || []; + if (emails && emails.length > 0) { + results.participants = await listmonkService.bulkSyncCampaignParticipants(emails, campaigns); + } + } catch (error) { + logger.error('Failed to sync campaign participants during full sync', error); + results.participants.errors.push({ error: error.message }); + } + + // Sync custom recipients + try { + const recipientsData = await nocodbService.getAll(nocodbService.tableIds.customRecipients); + const recipients = recipientsData?.list || []; + if (recipients && recipients.length > 0) { + results.customRecipients = await listmonkService.bulkSyncCustomRecipients(recipients, campaigns); + } + } catch (error) { + logger.error('Failed to sync custom recipients during full sync', error); + results.customRecipients.errors.push({ error: error.message }); + } + + const totalSuccess = results.participants.success + results.customRecipients.success; + const totalFailed = results.participants.failed + results.customRecipients.failed; + + res.json({ + success: true, + message: `Complete sync finished: ${totalSuccess} succeeded, ${totalFailed} failed`, + results + }); + } catch (error) { + logger.error('Complete sync failed', error); + res.status(500).json({ + success: false, + error: 'Failed to perform complete sync' + }); + } +}; + +// Get Listmonk list statistics +exports.getListStats = async (req, res) => { + try { + if (!listmonkService.syncEnabled) { + return res.json({ + success: false, + error: 'Listmonk sync is disabled', + stats: null + }); + } + + const stats = await listmonkService.getListStats(); + + // Convert stats object to array format for frontend + let statsArray = []; + if (stats && typeof stats === 'object') { + statsArray = Object.entries(stats).map(([key, list]) => ({ + id: key, + name: list.name, + subscriberCount: list.subscriber_count || 0, + description: `Email list for ${key}` + })); + } + + res.json({ + success: true, + stats: statsArray + }); + } catch (error) { + logger.error('Failed to get Listmonk list stats', error); + res.status(500).json({ + success: false, + error: 'Failed to get list statistics' + }); + } +}; + +// Test Listmonk connection +exports.testConnection = async (req, res) => { + try { + const connected = await listmonkService.checkConnection(); + + if (connected) { + res.json({ + success: true, + message: 'Listmonk connection successful', + connected: true + }); + } else { + res.json({ + success: false, + message: listmonkService.lastError || 'Connection failed', + connected: false + }); + } + } catch (error) { + logger.error('Failed to test Listmonk connection', error); + res.status(500).json({ + success: false, + error: 'Failed to test connection' + }); + } +}; + +// Reinitialize Listmonk lists +exports.reinitializeLists = async (req, res) => { + try { + if (!listmonkService.syncEnabled) { + return res.status(400).json({ + success: false, + error: 'Listmonk sync is disabled' + }); + } + + const initialized = await listmonkService.initializeLists(); + + if (initialized) { + res.json({ + success: true, + message: 'Listmonk lists reinitialized successfully' + }); + } else { + res.json({ + success: false, + message: listmonkService.lastError || 'Failed to initialize lists' + }); + } + } catch (error) { + logger.error('Failed to reinitialize Listmonk lists', error); + res.status(500).json({ + success: false, + error: 'Failed to reinitialize lists' + }); + } +}; diff --git a/influence/app/middleware/csrf.js b/influence/app/middleware/csrf.js index abeb03f..65082d7 100644 --- a/influence/app/middleware/csrf.js +++ b/influence/app/middleware/csrf.js @@ -61,6 +61,7 @@ const csrfExemptRoutes = [ '/api/metrics', '/api/config', '/api/auth/login', // Login uses credentials for authentication + '/api/auth/logout', // Logout is an authentication action '/api/auth/session', // Session check is read-only '/api/representatives/postal/', // Read-only operation '/api/campaigns/public' // Public read operations @@ -77,12 +78,30 @@ const conditionalCsrfProtection = (req, res, next) => { return next(); } + // Log CSRF validation attempt for debugging + console.log('=== CSRF VALIDATION ==='); + console.log('Method:', req.method); + console.log('Path:', req.path); + console.log('Body Token:', req.body?._csrf ? 'YES' : 'NO'); + console.log('Header Token:', req.headers['x-csrf-token'] ? 'YES' : 'NO'); + console.log('CSRF Cookie:', req.cookies['_csrf'] ? 'YES' : 'NO'); + console.log('Session ID:', req.session?.id || 'NO_SESSION'); + console.log('======================='); + // Apply CSRF protection for state-changing operations csrfProtection(req, res, (err) => { if (err) { - return csrfErrorHandler(err, req, res, next); + console.log('=== CSRF ERROR ==='); + console.log('Error Message:', err.message); + console.log('Error Code:', err.code); + console.log('Path:', req.path); + console.log('=================='); + logger.warn('CSRF token validation failed'); + csrfErrorHandler(err, req, res, next); + } else { + logger.info('CSRF validation passed for:', req.path); + next(); } - injectCsrfToken(req, res, next); }); }; @@ -90,9 +109,28 @@ const conditionalCsrfProtection = (req, res, next) => { * Helper to get CSRF token for client-side use */ const getCsrfToken = (req, res) => { - res.json({ - csrfToken: req.csrfToken() - }); + try { + // Generate a CSRF token if one doesn't exist + const token = req.csrfToken(); + console.log('=== CSRF TOKEN GENERATION ==='); + console.log('Token Length:', token?.length || 0); + console.log('Has Token:', !!token); + console.log('Session ID:', req.session?.id || 'NO_SESSION'); + console.log('Cookie will be set:', !!req.cookies); + console.log('============================='); + res.json({ + csrfToken: token + }); + } catch (error) { + console.log('=== CSRF TOKEN ERROR ==='); + console.log('Error:', error.message); + console.log('Stack:', error.stack); + console.log('========================'); + logger.error('Failed to generate CSRF token', { error: error.message, stack: error.stack }); + res.status(500).json({ + error: 'Failed to generate CSRF token' + }); + } }; module.exports = { diff --git a/influence/app/public/admin.html b/influence/app/public/admin.html index 3508fc1..c94d446 100644 --- a/influence/app/public/admin.html +++ b/influence/app/public/admin.html @@ -762,6 +762,97 @@ opacity: 0.8; } + /* Custom Recipients Styles */ + .recipient-card { + background: white; + border: 1px solid #ddd; + border-radius: 8px; + padding: 1rem; + margin-bottom: 0.75rem; + display: flex; + justify-content: space-between; + align-items: flex-start; + transition: box-shadow 0.2s; + } + + .recipient-card:hover { + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + } + + .recipient-info { + flex: 1; + } + + .recipient-info h5 { + margin: 0 0 0.25rem 0; + color: #2c3e50; + font-size: 1rem; + } + + .recipient-info .recipient-email { + color: #3498db; + font-size: 0.9rem; + margin-bottom: 0.25rem; + } + + .recipient-info .recipient-meta { + color: #666; + font-size: 0.85rem; + margin: 0.25rem 0; + } + + .recipient-actions { + display: flex; + gap: 0.5rem; + margin-left: 1rem; + } + + .btn-icon { + padding: 0.5rem; + background: none; + border: 1px solid #ddd; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + transition: all 0.2s; + } + + .btn-icon:hover { + background: #f8f9fa; + transform: scale(1.1); + } + + .btn-icon.edit:hover { + border-color: #3498db; + color: #3498db; + } + + .btn-icon.delete:hover { + border-color: #e74c3c; + color: #e74c3c; + } + + /* Bulk Import Modal */ + .bulk-import-help { + background: #e3f2fd; + padding: 1rem; + border-radius: 8px; + margin-bottom: 1rem; + font-size: 0.9rem; + } + + .bulk-import-help h4 { + margin: 0 0 0.5rem 0; + color: #1976d2; + } + + .bulk-import-help code { + background: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-family: monospace; + } + @media (max-width: 768px) { .admin-nav { flex-direction: column; @@ -803,6 +894,7 @@ + @@ -918,6 +1010,10 @@ Sincerely, +
Sync campaign participants and custom recipients to Listmonk email lists for targeted email campaigns.
+ + + + ++ Note: Reinitializing lists will recreate all email list structures. Use only if lists are corrupted or missing. +
+Loading statistics...
+