diff --git a/influence/app/controllers/authController.js b/influence/app/controllers/authController.js index 05f8c64..a9283f7 100644 --- a/influence/app/controllers/authController.js +++ b/influence/app/controllers/authController.js @@ -48,6 +48,24 @@ class AuthController { }); } + // Check if temp user has expired + const userType = user['User Type'] || user.UserType || user.userType || 'user'; + if (userType === 'temp') { + const expiration = user.ExpiresAt || user.expiresAt || user.Expiration || user.expiration; + if (expiration) { + const expirationDate = new Date(expiration); + const now = new Date(); + + if (now > expirationDate) { + console.warn(`Expired temp user attempted login: ${email}, expired: ${expiration}`); + return res.status(401).json({ + success: false, + error: 'Account has expired. Please contact an administrator.' + }); + } + } + } + // Update last login time try { // Debug: Log user object structure @@ -78,6 +96,7 @@ class AuthController { req.session.userEmail = user.Email || user.email; req.session.userName = user.Name || user.name; req.session.isAdmin = user.Admin || user.admin || false; + req.session.userType = userType; console.log('User logged in successfully:', { email: req.session.userEmail, @@ -100,7 +119,8 @@ class AuthController { id: req.session.userId, email: req.session.userEmail, name: req.session.userName, - isAdmin: req.session.isAdmin + isAdmin: req.session.isAdmin, + userType: req.session.userType } }); }); @@ -151,7 +171,8 @@ class AuthController { id: req.session.userId, email: req.session.userEmail, name: req.session.userName, - isAdmin: req.session.isAdmin + isAdmin: req.session.isAdmin, + userType: req.session.userType || 'user' } }); } else { diff --git a/influence/app/controllers/campaigns.js b/influence/app/controllers/campaigns.js index 06eb5d1..985b69c 100644 --- a/influence/app/controllers/campaigns.js +++ b/influence/app/controllers/campaigns.js @@ -3,6 +3,29 @@ const emailService = require('../services/email'); const representAPI = require('../services/represent-api'); const { generateSlug, validateSlug } = require('../utils/validators'); +const VALID_CAMPAIGN_STATUSES = ['draft', 'active', 'paused', 'archived']; + +const normalizeTargetLevels = (rawLevels) => { + if (Array.isArray(rawLevels)) { + return rawLevels; + } + + if (typeof rawLevels === 'string' && rawLevels.length > 0) { + return rawLevels.split(',').map(level => level.trim()).filter(Boolean); + } + + return []; +}; + +const normalizeStatus = (rawStatus, fallback = 'draft') => { + if (!rawStatus) { + return fallback; + } + + const status = String(rawStatus).toLowerCase(); + return VALID_CAMPAIGN_STATUSES.includes(status) ? status : fallback; +}; + // Helper function to cache representatives async function cacheRepresentatives(postalCode, representatives, representData) { try { @@ -40,6 +63,9 @@ class CampaignsController { if (id != null) { emailCount = await nocoDB.getCampaignEmailCount(id); } + + const rawTargetLevels = campaign['Target Government Levels'] || campaign.target_government_levels; + const normalizedTargetLevels = normalizeTargetLevels(rawTargetLevels); // Normalize campaign data structure for frontend return { @@ -50,22 +76,42 @@ class CampaignsController { email_subject: campaign['Email Subject'] || campaign.email_subject, email_body: campaign['Email Body'] || campaign.email_body, call_to_action: campaign['Call to Action'] || campaign.call_to_action, - status: campaign['Status'] || campaign.status, + status: normalizeStatus(campaign['Status'] || campaign.status), allow_smtp_email: campaign['Allow SMTP Email'] || campaign.allow_smtp_email, allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link, 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, - target_government_levels: campaign['Target Government Levels'] || campaign.target_government_levels, + target_government_levels: normalizedTargetLevels, created_at: campaign.CreatedAt || campaign.created_at, updated_at: campaign.UpdatedAt || campaign.updated_at, + created_by_user_id: campaign['Created By User ID'] || campaign.created_by_user_id || null, + created_by_user_email: campaign['Created By User Email'] || campaign.created_by_user_email || null, + created_by_user_name: campaign['Created By User Name'] || campaign.created_by_user_name || null, emailCount }; })); + let filteredCampaigns = campaignsWithCounts; + + if (req.user && !req.user.isAdmin) { + const sessionUserId = req.user.id != null ? String(req.user.id) : (req.session?.userId != null ? String(req.session.userId) : null); + const sessionUserEmail = req.user.email ? String(req.user.email).toLowerCase() : (req.session?.userEmail ? String(req.session.userEmail).toLowerCase() : null); + + filteredCampaigns = campaignsWithCounts.filter((campaign) => { + const createdById = campaign.created_by_user_id != null ? String(campaign.created_by_user_id) : null; + const createdByEmail = campaign.created_by_user_email ? String(campaign.created_by_user_email).toLowerCase() : null; + + return ( + (sessionUserId && createdById && createdById === sessionUserId) || + (sessionUserEmail && createdByEmail && createdByEmail === sessionUserEmail) + ); + }); + } + res.json({ success: true, - campaigns: campaignsWithCounts + campaigns: filteredCampaigns }); } catch (error) { console.error('Get campaigns error:', error); @@ -111,15 +157,18 @@ class CampaignsController { email_subject: campaign['Email Subject'] || campaign.email_subject, email_body: campaign['Email Body'] || campaign.email_body, call_to_action: campaign['Call to Action'] || campaign.call_to_action, - status: campaign['Status'] || campaign.status, + status: normalizeStatus(campaign['Status'] || campaign.status), allow_smtp_email: campaign['Allow SMTP Email'] || campaign.allow_smtp_email, allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link, 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, - target_government_levels: campaign['Target Government Levels'] || campaign.target_government_levels, + 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, + created_by_user_id: campaign['Created By User ID'] || campaign.created_by_user_id || null, + created_by_user_email: campaign['Created By User Email'] || campaign.created_by_user_email || null, + created_by_user_name: campaign['Created By User Name'] || campaign.created_by_user_name || null, emailCount } }); @@ -147,8 +196,8 @@ class CampaignsController { }); } - const campaignStatus = 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' @@ -180,11 +229,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, - target_government_levels: Array.isArray(campaign['Target Government Levels'] || campaign.target_government_levels) - ? (campaign['Target Government Levels'] || campaign.target_government_levels) - : (typeof (campaign['Target Government Levels'] || campaign.target_government_levels) === 'string' && (campaign['Target Government Levels'] || campaign.target_government_levels).length > 0 - ? (campaign['Target Government Levels'] || campaign.target_government_levels).split(',').map(s => s.trim()) - : []), + target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels), emailCount } }); @@ -217,7 +262,10 @@ class CampaignsController { target_government_levels = ['Federal', 'Provincial', 'Municipal'] } = req.body; - // Generate slug from title + const ownerUserId = req.user?.id ?? req.session?.userId ?? null; + const ownerEmail = req.user?.email ?? req.session?.userEmail ?? null; + const ownerName = req.user?.name ?? req.session?.userName ?? null; + const normalizedStatus = normalizeStatus(status, 'draft'); let slug = generateSlug(title); // Ensure slug is unique @@ -235,18 +283,18 @@ class CampaignsController { email_subject, email_body, call_to_action, - status, + status: normalizedStatus, allow_smtp_email, allow_mailto_link, collect_user_info, show_email_count, allow_email_editing, // NocoDB MultiSelect expects an array of values - target_government_levels: Array.isArray(target_government_levels) - ? target_government_levels - : (typeof target_government_levels === 'string' && target_government_levels.length > 0 - ? target_government_levels.split(',').map(s => s.trim()) - : []) + target_government_levels: normalizeTargetLevels(target_government_levels), + // Add user ownership data + created_by_user_id: ownerUserId, + created_by_user_email: ownerEmail, + created_by_user_name: ownerName }; const campaign = await nocoDB.createCampaign(campaignData); @@ -262,15 +310,18 @@ class CampaignsController { email_subject: campaign['Email Subject'] || campaign.email_subject, email_body: campaign['Email Body'] || campaign.email_body, call_to_action: campaign['Call to Action'] || campaign.call_to_action, - status: campaign['Status'] || campaign.status, + status: normalizeStatus(campaign['Status'] || campaign.status), allow_smtp_email: campaign['Allow SMTP Email'] || campaign.allow_smtp_email, allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link, 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, - target_government_levels: campaign['Target Government Levels'] || campaign.target_government_levels, + 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 + updated_at: campaign.UpdatedAt || campaign.updated_at, + created_by_user_id: campaign['Created By User ID'] || campaign.created_by_user_id || ownerUserId, + created_by_user_email: campaign['Created By User Email'] || campaign.created_by_user_email || ownerEmail, + created_by_user_name: campaign['Created By User Name'] || campaign.created_by_user_name || ownerName } }); } catch (error) { @@ -288,18 +339,45 @@ class CampaignsController { async updateCampaign(req, res, next) { try { const { id } = req.params; - const updates = req.body; + const updates = { ...req.body }; + + const existingCampaign = await nocoDB.getCampaignById(id); + if (!existingCampaign) { + return res.status(404).json({ + success: false, + error: 'Campaign not found' + }); + } + + const isAdmin = (req.user && req.user.isAdmin) || req.session?.isAdmin || false; + const sessionUserId = req.user?.id ?? req.session?.userId ?? null; + const sessionUserEmail = req.user?.email ?? req.session?.userEmail ?? null; + + if (!isAdmin) { + const createdById = existingCampaign['Created By User ID'] ?? existingCampaign.created_by_user_id ?? null; + const createdByEmail = existingCampaign['Created By User Email'] ?? existingCampaign.created_by_user_email ?? null; + + const ownsCampaign = ( + (createdById != null && sessionUserId != null && String(createdById) === String(sessionUserId)) || + (createdByEmail && sessionUserEmail && String(createdByEmail).toLowerCase() === String(sessionUserEmail).toLowerCase()) + ); + + if (!ownsCampaign) { + return res.status(403).json({ + success: false, + error: 'Access denied' + }); + } + } - // If title is being updated, regenerate slug if (updates.title) { let slug = generateSlug(updates.title); - - // Ensure slug is unique (but allow current campaign to keep its slug) - const existingCampaign = await nocoDB.getCampaignBySlug(slug); - const existingId = existingCampaign ? (existingCampaign.ID || existingCampaign.Id || existingCampaign.id) : null; - if (existingCampaign && String(existingId) !== String(id)) { + + const campaignWithSlug = await nocoDB.getCampaignBySlug(slug); + const existingId = campaignWithSlug ? (campaignWithSlug.ID || campaignWithSlug.Id || campaignWithSlug.id) : null; + if (campaignWithSlug && String(existingId) !== String(id)) { let counter = 1; - let originalSlug = slug; + const originalSlug = slug; while (await nocoDB.getCampaignBySlug(slug)) { slug = `${originalSlug}-${counter}`; counter++; @@ -308,20 +386,33 @@ class CampaignsController { updates.slug = slug; } - // Ensure target_government_levels remains an array for MultiSelect - if (updates.target_government_levels) { - updates.target_government_levels = Array.isArray(updates.target_government_levels) - ? updates.target_government_levels - : (typeof updates.target_government_levels === 'string' - ? updates.target_government_levels.split(',').map(s => s.trim()) - : []); + if (updates.target_government_levels !== undefined) { + updates.target_government_levels = normalizeTargetLevels(updates.target_government_levels); } - updates.updated_at = new Date().toISOString(); + if (updates.status !== undefined) { + const sanitizedStatus = normalizeStatus(updates.status, null); + if (!sanitizedStatus) { + return res.status(400).json({ + success: false, + error: 'Invalid campaign status' + }); + } + updates.status = sanitizedStatus; + } + + // Remove user ownership fields from updates to prevent unauthorized changes + delete updates.created_by_user_id; + delete updates.created_by_user_email; + delete updates.created_by_user_name; + // Remove auto-generated fields that NocoDB handles + delete updates.updated_at; + delete updates.UpdatedAt; + delete updates.created_at; + delete updates.CreatedAt; const campaign = await nocoDB.updateCampaign(id, updates); - // Normalize the updated campaign data res.json({ success: true, campaign: { @@ -332,14 +423,18 @@ class CampaignsController { email_subject: campaign['Email Subject'] || campaign.email_subject, email_body: campaign['Email Body'] || campaign.email_body, call_to_action: campaign['Call to Action'] || campaign.call_to_action, - status: campaign['Status'] || campaign.status, + status: normalizeStatus(campaign['Status'] || campaign.status), allow_smtp_email: campaign['Allow SMTP Email'] || campaign.allow_smtp_email, allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link, collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info, show_email_count: campaign['Show Email Count'] || campaign.show_email_count, - target_government_levels: campaign['Target Government Levels'] || campaign.target_government_levels, + allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing, + 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 + updated_at: campaign.UpdatedAt || campaign.updated_at, + created_by_user_id: campaign['Created By User ID'] || campaign.created_by_user_id || null, + created_by_user_email: campaign['Created By User Email'] || campaign.created_by_user_email || null, + created_by_user_name: campaign['Created By User Name'] || campaign.created_by_user_name || null } }); } catch (error) { @@ -393,17 +488,16 @@ class CampaignsController { } = req.body; // Get campaign - const campaign = await nocoDB.getCampaignBySlug(slug); + const campaign = await nocoDB.getCampaignBySlug(slug); if (!campaign) { return res.status(404).json({ success: false, error: 'Campaign not found' }); } - - - if ((campaign['Status'] || campaign.status) !== 'active') { + const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status); + if (campaignStatus !== 'active') { return res.status(403).json({ success: false, error: 'Campaign is not currently active' @@ -425,14 +519,14 @@ 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 - : (campaign['Email Subject'] || campaign.email_subject); - const message = (allowEmailEditing && customEmailBody) - ? customEmailBody - : (campaign['Email Body'] || campaign.email_body); + // 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 + : (campaign['Email Body'] || campaign.email_body); let emailResult = { success: true }; @@ -515,7 +609,7 @@ class CampaignsController { }); } - const campaignStatus = campaign['Status'] || campaign.status; + const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status); if (campaignStatus !== 'active') { return res.status(403).json({ success: false, @@ -570,7 +664,7 @@ class CampaignsController { }); } - const campaignStatus = campaign['Status'] || campaign.status; + const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status); if (campaignStatus !== 'active') { return res.status(403).json({ success: false, @@ -687,6 +781,36 @@ class CampaignsController { async getCampaignAnalytics(req, res, next) { try { const { id } = req.params; + + const campaign = await nocoDB.getCampaignById(id); + + if (!campaign) { + return res.status(404).json({ + success: false, + error: 'Campaign not found' + }); + } + + const sessionUserId = req.user?.id ?? req.session?.userId ?? null; + const sessionUserEmail = req.user?.email ?? req.session?.userEmail ?? null; + const isAdmin = (req.user && req.user.isAdmin) || req.session?.isAdmin || false; + + if (!isAdmin) { + const createdById = campaign['Created By User ID'] ?? campaign.created_by_user_id ?? null; + const createdByEmail = campaign['Created By User Email'] ?? campaign.created_by_user_email ?? null; + + const ownsCampaign = ( + (createdById != null && sessionUserId != null && String(createdById) === String(sessionUserId)) || + (createdByEmail && sessionUserEmail && String(createdByEmail).toLowerCase() === String(sessionUserEmail).toLowerCase()) + ); + + if (!ownsCampaign) { + return res.status(403).json({ + success: false, + error: 'Access denied' + }); + } + } const analytics = await nocoDB.getCampaignAnalytics(id); diff --git a/influence/app/controllers/usersController.js b/influence/app/controllers/usersController.js new file mode 100644 index 0000000..ba3fd03 --- /dev/null +++ b/influence/app/controllers/usersController.js @@ -0,0 +1,338 @@ +const nocodbService = require('../services/nocodb'); +const { sendLoginDetails } = require('../services/email'); +const { sanitizeUser, extractId } = require('../utils/helpers'); + +class UsersController { + async getAll(req, res) { + try { + console.log('UsersController.getAll called'); + console.log('Users table ID:', nocodbService.tableIds.users); + + if (!nocodbService.tableIds.users) { + console.error('Users table not configured in environment'); + return res.status(500).json({ + success: false, + error: 'Users table not configured. Please set NOCODB_TABLE_USERS in your environment variables.' + }); + } + + console.log('Fetching users from NocoDB...'); + const response = await nocodbService.getAll(nocodbService.tableIds.users, { + limit: 100 + }); + + const users = response.list || []; + console.log(`Retrieved ${users.length} users from database`); + + // Remove password field from response for security + const safeUsers = users.map(sanitizeUser); + + res.json({ + success: true, + users: safeUsers + }); + + } catch (error) { + console.error('Error fetching users:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch users: ' + error.message + }); + } + } + + async create(req, res) { + try { + const { email, password, name, phone, isAdmin, userType, expireDays } = req.body; + + if (!email || !password) { + return res.status(400).json({ + success: false, + error: 'Email and password are required' + }); + } + + if (!nocodbService.tableIds.users) { + return res.status(500).json({ + success: false, + error: 'Users table not configured' + }); + } + + // Check if user already exists + console.log(`Checking if user exists with email: ${email}`); + let existingUser = null; + try { + existingUser = await nocodbService.getUserByEmail(email); + console.log('Existing user check result:', existingUser ? 'User exists' : 'User does not exist'); + } catch (error) { + console.error('Error checking for existing user:', error.message); + // Continue with creation if check fails - NocoDB will handle the unique constraint + } + + if (existingUser) { + console.log('Existing user found:', { id: existingUser.ID || existingUser.Id || existingUser.id, email: existingUser.Email || existingUser.email }); + return res.status(400).json({ + success: false, + error: 'User with this email already exists' + }); + } + + // Calculate expiration date for temp users + let expiresAt = null; + if (userType === 'temp' && expireDays) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expireDays); + expiresAt = expirationDate.toISOString(); + } + + // Create new user - use the exact column titles from NocoDB schema + const userData = { + Email: email, + Name: name || '', + Password: password, + Phone: phone || '', + Admin: isAdmin === true, + 'User Type': userType || 'user', + ExpiresAt: expiresAt, + ExpireDays: userType === 'temp' ? expireDays : null, + 'Last Login': null + }; + + const response = await nocodbService.create( + nocodbService.tableIds.users, + userData + ); + + res.status(201).json({ + success: true, + message: 'User created successfully', + user: { + id: extractId(response), + email: email, + name: name, + phone: phone, + admin: isAdmin, + userType: userType, + expiresAt: expiresAt + } + }); + + } catch (error) { + console.error('Error creating user:', error); + + // Check if it's a unique constraint violation (email already exists) + if (error.response?.data?.code === '23505' || + error.response?.data?.message?.includes('already exists') || + error.message?.includes('already exists')) { + return res.status(400).json({ + success: false, + error: 'A user with this email address already exists' + }); + } + + res.status(500).json({ + success: false, + error: 'Failed to create user: ' + (error.message || 'Unknown error') + }); + } + } + + async delete(req, res) { + try { + const userId = req.params.id; + + if (!nocodbService.tableIds.users) { + return res.status(500).json({ + success: false, + error: 'Users table not configured' + }); + } + + // Don't allow admins to delete themselves + if (userId === req.session.userId) { + return res.status(400).json({ + success: false, + error: 'Cannot delete your own account' + }); + } + + await nocodbService.deleteUser(userId); + + res.json({ + success: true, + message: 'User deleted successfully' + }); + + } catch (error) { + console.error('Error deleting user:', error); + res.status(500).json({ + success: false, + error: 'Failed to delete user' + }); + } + } + + async sendLoginDetails(req, res) { + try { + const userId = req.params.id; + + if (!nocodbService.tableIds.users) { + return res.status(500).json({ + success: false, + error: 'Users table not configured' + }); + } + + // Get user data from database + const user = await nocodbService.getById( + nocodbService.tableIds.users, + userId + ); + + if (!user) { + return res.status(404).json({ + success: false, + error: 'User not found' + }); + } + + // Send login details email + await sendLoginDetails(user); + + res.json({ + success: true, + message: 'Login details sent successfully' + }); + + } catch (error) { + console.error('Error sending login details:', error); + res.status(500).json({ + success: false, + error: 'Failed to send login details' + }); + } + } + + async emailAllUsers(req, res) { + try { + const { subject, content } = req.body; + + if (!subject || !content) { + return res.status(400).json({ + success: false, + error: 'Subject and content are required' + }); + } + + if (!nocodbService.tableIds.users) { + return res.status(500).json({ + success: false, + error: 'Users table not configured' + }); + } + + // Get all users + const response = await nocodbService.getAll(nocodbService.tableIds.users, { + limit: 1000 + }); + + const users = response.list || []; + + if (users.length === 0) { + return res.status(400).json({ + success: false, + error: 'No users found to email' + }); + } + + // Import email service + const { sendEmail } = require('../services/email'); + const emailTemplates = require('../services/emailTemplates'); + + // Convert rich text content to plain text for the text version + const stripHtmlTags = (html) => { + return html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim(); + }; + + // Prepare base template variables + const baseTemplateVariables = { + APP_NAME: 'BNKops Influence - User Broadcast', + EMAIL_SUBJECT: subject, + EMAIL_CONTENT: content, + EMAIL_CONTENT_TEXT: stripHtmlTags(content), + SENDER_NAME: req.session.userName || req.session.userEmail || 'Administrator', + TIMESTAMP: new Date().toLocaleString() + }; + + // Send emails to all users + const emailResults = []; + const failedEmails = []; + + for (const user of users) { + try { + const userVariables = { + ...baseTemplateVariables, + USER_NAME: user.Name || user.name || user.Email || user.email || 'User', + USER_EMAIL: user.Email || user.email + }; + + const emailContent = await emailTemplates.render('user-broadcast', userVariables); + + await sendEmail({ + to: user.Email || user.email, + subject: subject, + text: emailContent.text, + html: emailContent.html + }); + + emailResults.push({ + email: user.Email || user.email, + name: user.Name || user.name || user.Email || user.email, + success: true + }); + + console.log(`Sent broadcast email to: ${user.Email || user.email}`); + } catch (emailError) { + console.error(`Failed to send broadcast email to ${user.Email || user.email}:`, emailError); + failedEmails.push({ + email: user.Email || user.email, + name: user.Name || user.name || user.Email || user.email, + error: emailError.message + }); + } + } + + const successCount = emailResults.length; + const failCount = failedEmails.length; + + if (successCount === 0) { + return res.status(500).json({ + success: false, + error: 'Failed to send any emails', + details: failedEmails + }); + } + + res.json({ + success: true, + message: `Sent email to ${successCount} user${successCount !== 1 ? 's' : ''}${failCount > 0 ? `, ${failCount} failed` : ''}`, + results: { + successful: emailResults, + failed: failedEmails, + total: users.length, + subject: subject + } + }); + + } catch (error) { + console.error('Error sending broadcast email:', error); + res.status(500).json({ + success: false, + error: 'Failed to send broadcast email' + }); + } + } +} + +module.exports = new UsersController(); \ No newline at end of file diff --git a/influence/app/middleware/auth.js b/influence/app/middleware/auth.js index 8641c54..a8c9534 100644 --- a/influence/app/middleware/auth.js +++ b/influence/app/middleware/auth.js @@ -1,17 +1,66 @@ const nocodbService = require('../services/nocodb'); +// Helper function to check if a temp user has expired +const checkTempUserExpiration = async (req, res) => { + if (req.session?.userType === 'temp' && req.session?.userEmail) { + try { + const user = await nocodbService.getUserByEmail(req.session.userEmail); + if (user) { + const expiration = user.ExpiresAt || user.expiresAt || user.Expiration || user.expiration; + if (expiration) { + const expirationDate = new Date(expiration); + const now = new Date(); + + if (now > expirationDate) { + console.warn(`Expired temp user session detected: ${req.session.userEmail}, expired: ${expiration}`); + + // Destroy the session + req.session.destroy((err) => { + if (err) { + console.error('Session destroy error:', err); + } + }); + + if (req.xhr || req.headers.accept?.indexOf('json') > -1) { + return res.status(401).json({ + success: false, + error: 'Account has expired. Please contact an administrator.', + expired: true + }); + } else { + return res.redirect('/login.html?expired=true'); + } + } + } + } + } catch (error) { + console.error('Error checking temp user expiration:', error.message); + // Don't fail the request on database errors, just log it + } + } + return null; // No expiration issue +}; + const requireAuth = async (req, res, next) => { - const isAuthenticated = (req.session && req.session.authenticated) || + const isAuthenticated = (req.session && req.session.authenticated) || (req.session && req.session.userId && req.session.userEmail); - + if (isAuthenticated) { + // Check if temp user has expired + const expirationResponse = await checkTempUserExpiration(req, res); + if (expirationResponse) { + return; // Response already sent by checkTempUserExpiration + } + // Set up req.user object for controllers that expect it req.user = { id: req.session.userId, email: req.session.userEmail, - isAdmin: req.session.isAdmin || false + isAdmin: req.session.isAdmin || false, + userType: req.session.userType || 'user', + name: req.session.userName || req.session.user_name || null }; - + next(); } else { console.warn('Unauthorized access attempt', { @@ -21,11 +70,11 @@ const requireAuth = async (req, res, next) => { method: req.method, timestamp: new Date().toISOString() }); - + if (req.xhr || req.headers.accept?.indexOf('json') > -1) { - res.status(401).json({ - success: false, - error: 'Authentication required' + res.status(401).json({ + success: false, + error: 'Authentication required' }); } else { res.redirect('/login.html'); @@ -34,17 +83,25 @@ const requireAuth = async (req, res, next) => { }; const requireAdmin = async (req, res, next) => { - const isAuthenticated = (req.session && req.session.authenticated) || + const isAuthenticated = (req.session && req.session.authenticated) || (req.session && req.session.userId && req.session.userEmail); - + if (isAuthenticated && req.session.isAdmin) { + // Check if temp user has expired + const expirationResponse = await checkTempUserExpiration(req, res); + if (expirationResponse) { + return; // Response already sent by checkTempUserExpiration + } + // Set up req.user object for controllers that expect it req.user = { id: req.session.userId, email: req.session.userEmail, - isAdmin: req.session.isAdmin || false + isAdmin: req.session.isAdmin || false, + userType: req.session.userType || 'user', + name: req.session.userName || req.session.user_name || null }; - + next(); } else { console.warn('Unauthorized admin access attempt', { @@ -53,11 +110,11 @@ const requireAdmin = async (req, res, next) => { user: req.session?.userEmail || 'anonymous', userAgent: req.get('User-Agent') }); - + if (req.xhr || req.headers.accept?.indexOf('json') > -1) { - res.status(403).json({ - success: false, - error: 'Admin access required' + res.status(403).json({ + success: false, + error: 'Admin access required' }); } else { res.redirect('/login.html'); @@ -65,7 +122,48 @@ const requireAdmin = async (req, res, next) => { } }; +const requireNonTemp = async (req, res, next) => { + const isAuthenticated = (req.session && req.session.authenticated) || + (req.session && req.session.userId && req.session.userEmail); + + if (isAuthenticated && req.session.userType !== 'temp') { + // Check if temp user has expired (shouldn't happen here, but for safety) + const expirationResponse = await checkTempUserExpiration(req, res); + if (expirationResponse) { + return; // Response already sent by checkTempUserExpiration + } + + // Set up req.user object for controllers that expect it + req.user = { + id: req.session.userId, + email: req.session.userEmail, + isAdmin: req.session.isAdmin || false, + userType: req.session.userType || 'user', + name: req.session.userName || req.session.user_name || null + }; + + next(); + } else { + console.warn('Temp user access denied', { + ip: req.ip, + path: req.path, + user: req.session?.userEmail || 'anonymous', + userType: req.session?.userType || 'unknown' + }); + + if (req.xhr || req.headers.accept?.indexOf('json') > -1) { + res.status(403).json({ + success: false, + error: 'Access denied for temporary users' + }); + } else { + res.redirect('/'); + } + } +}; + module.exports = { requireAuth, - requireAdmin + requireAdmin, + requireNonTemp }; \ No newline at end of file diff --git a/influence/app/public/admin.html b/influence/app/public/admin.html index d2ccae9..639ee79 100644 --- a/influence/app/public/admin.html +++ b/influence/app/public/admin.html @@ -467,6 +467,183 @@ transform: translateY(-1px); } + /* User Management Styles */ + .users-list { + display: grid; + gap: 1rem; + } + + .user-card { + border: 1px solid #ddd; + border-radius: 8px; + padding: 1.5rem; + background: white; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + } + + .user-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + } + + .user-info h4 { + margin: 0 0 0.25rem 0; + color: #2c3e50; + } + + .user-info p { + margin: 0; + color: #666; + font-size: 0.9rem; + } + + .user-badges { + display: flex; + gap: 0.5rem; + align-items: center; + } + + .user-badge { + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.8rem; + font-weight: bold; + text-transform: uppercase; + } + + .user-badge.admin { + background: #e74c3c; + color: white; + } + + .user-badge.user { + background: #3498db; + color: white; + } + + .user-badge.temp { + background: #f39c12; + color: white; + } + + .user-badge.expired { + background: #95a5a6; + color: white; + } + + .user-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 1rem; + } + + .btn-small { + padding: 0.5rem 1rem; + font-size: 0.8rem; + } + + /* Campaign Selector Dropdown Styles */ + .campaign-selector { + margin-bottom: 2rem; + background: #f8f9fa; + padding: 1.5rem; + border-radius: 12px; + border: 2px solid #e9ecef; + } + + .campaign-selector label { + display: block; + font-weight: 600; + color: #2c3e50; + margin-bottom: 0.75rem; + font-size: 1rem; + } + + .search-dropdown { + position: relative; + width: 100%; + } + + .search-dropdown input { + width: 100%; + padding: 0.75rem 2.5rem 0.75rem 0.75rem; + border: 2px solid #e0e6ed; + border-radius: 8px; + font-size: 0.95rem; + background: white; + transition: border-color 0.3s, box-shadow 0.3s; + } + + .search-dropdown input:focus { + outline: none; + border-color: #3498db; + box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); + } + + .dropdown-arrow { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + color: #666; + pointer-events: none; + font-size: 0.8rem; + } + + .dropdown-menu { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: white; + border: 2px solid #e0e6ed; + border-top: none; + border-radius: 0 0 8px 8px; + max-height: 200px; + overflow-y: auto; + z-index: 1000; + display: none; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + } + + .dropdown-menu.show { + display: block; + } + + .dropdown-item { + padding: 0.75rem; + cursor: pointer; + border-bottom: 1px solid #f1f3f4; + transition: background-color 0.2s; + } + + .dropdown-item:hover { + background: #f8f9fa; + } + + .dropdown-item.selected { + background: #e3f2fd; + color: #1976d2; + font-weight: 600; + } + + .dropdown-item:last-child { + border-bottom: none; + } + + .dropdown-item.no-results { + color: #666; + font-style: italic; + cursor: default; + } + + .dropdown-item.no-results:hover { + background: white; + } + @media (max-width: 768px) { .admin-nav { flex-direction: column; @@ -479,6 +656,16 @@ .campaign-actions { justify-content: center; } + + .user-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .user-actions { + justify-content: center; + } } @@ -496,6 +683,7 @@ + @@ -521,6 +709,19 @@

Create New Campaign

+ +
+ +
+ + + +
+
+
@@ -628,6 +829,19 @@ Sincerely,

Edit Campaign

+ +
+ +
+ + + +
+
+
@@ -720,6 +934,96 @@ Sincerely,
+ + +
+
+

User Management

+ +
+ + + +
+ +
+
+
+ + + + + + diff --git a/influence/app/public/dashboard.html b/influence/app/public/dashboard.html new file mode 100644 index 0000000..8ba8739 --- /dev/null +++ b/influence/app/public/dashboard.html @@ -0,0 +1,851 @@ + + + + + + User Dashboard - BNKops Influence + + + + + +
+
+

My Campaign Dashboard

+

Manage your influence campaigns

+
+
+ +
+ + + + + + + + + + +
+
+

My Campaigns

+
+ + + +
+ +
+
+ + +
+

Create New Campaign

+ + +
+ +
+ + + +
+
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
βš™οΈ Campaign Settings
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
πŸ›οΈ Target Government Levels
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+ + +
+

Edit Campaign

+ + +
+ +
+ + + +
+
+ +
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
βš™οΈ Campaign Settings
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
πŸ›οΈ Target Government Levels
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+ + +
+

Your Campaign Analytics

+ + + +
+
+
+

0

+

Total Campaigns

+
+
+

0

+

Active Campaigns

+
+
+

0

+

Emails Sent

+
+
+

0

+

Users Reached

+
+
+
+
+ + +
+

Account Settings

+ + +
+
+ + + + + + \ No newline at end of file diff --git a/influence/app/public/index.html b/influence/app/public/index.html index 4229874..7438a19 100644 --- a/influence/app/public/index.html +++ b/influence/app/public/index.html @@ -197,10 +197,29 @@ crossorigin=""> + + + + \ No newline at end of file diff --git a/influence/app/public/js/admin.js b/influence/app/public/js/admin.js index d4db456..273932f 100644 --- a/influence/app/public/js/admin.js +++ b/influence/app/public/js/admin.js @@ -3,6 +3,7 @@ class AdminPanel { constructor() { this.currentCampaign = null; this.campaigns = []; + this.users = []; this.authManager = null; } @@ -47,9 +48,6 @@ class AdminPanel { } } - setupEventListeners() { - } - setupEventListeners() { // Tab navigation document.querySelectorAll('.nav-btn').forEach(btn => { @@ -68,6 +66,14 @@ class AdminPanel { this.handleUpdateCampaign(e); }); + document.getElementById('user-form').addEventListener('submit', (e) => { + this.handleCreateUser(e); + }); + + document.getElementById('email-form').addEventListener('submit', (e) => { + this.handleEmailAllUsers(e); + }); + // Cancel buttons - using event delegation for proper handling document.addEventListener('click', (e) => { if (e.target.matches('[data-action="cancel-create"]')) { @@ -76,8 +82,33 @@ class AdminPanel { if (e.target.matches('[data-action="cancel-edit"]')) { this.switchTab('campaigns'); } + if (e.target.matches('[data-action="create-user"]')) { + this.showUserModal(); + } + if (e.target.matches('[data-action="close-user-modal"]')) { + this.hideUserModal(); + } + if (e.target.matches('[data-action="close-email-modal"]')) { + this.hideEmailModal(); + } + if (e.target.matches('[data-action="delete-user"]')) { + this.deleteUser(e.target.dataset.userId); + } + if (e.target.matches('[data-action="send-login-details"]')) { + this.sendLoginDetails(e.target.dataset.userId); + } + if (e.target.matches('[data-action="email-all-users"]')) { + this.showEmailModal(); + } }); - this.loadCampaigns(); + + // User type change handler + const userTypeSelect = document.getElementById('user-type'); + if (userTypeSelect) { + userTypeSelect.addEventListener('change', (e) => { + this.handleUserTypeChange(e.target.value); + }); + } } setupFormInteractions() { @@ -111,6 +142,230 @@ class AdminPanel { this.handleSettingsChange(checkbox); }); }); + + // Setup campaign selector dropdowns + this.setupCampaignSelectors(); + } + + setupCampaignSelectors() { + // Setup Create Campaign Selector + const createSelector = document.getElementById('create-campaign-selector'); + const createDropdown = document.getElementById('create-dropdown-menu'); + if (createSelector && createDropdown) { + this.setupDropdown(createSelector, createDropdown, 'create'); + } + + // Setup Edit Campaign Selector + const editSelector = document.getElementById('edit-campaign-selector'); + const editDropdown = document.getElementById('edit-dropdown-menu'); + if (editSelector && editDropdown) { + this.setupDropdown(editSelector, editDropdown, 'edit'); + } + } + + setupDropdown(input, dropdown, type) { + // Show dropdown on focus + input.addEventListener('focus', () => { + this.populateDropdown(dropdown, type); + dropdown.classList.add('show'); + }); + + // Hide dropdown when clicking outside + document.addEventListener('click', (e) => { + if (!input.contains(e.target) && !dropdown.contains(e.target)) { + dropdown.classList.remove('show'); + } + }); + + // Filter campaigns on input + input.addEventListener('input', () => { + this.filterDropdown(input, dropdown, type); + }); + + // Handle dropdown item selection + dropdown.addEventListener('click', (e) => { + if (e.target.classList.contains('dropdown-item')) { + const campaignId = e.target.dataset.campaignId; + const campaignTitle = e.target.textContent; + + console.log('Dropdown item selected:', { campaignId, campaignTitle, type }); + + input.value = campaignTitle; + dropdown.classList.remove('show'); + + if (type === 'create' && campaignId !== 'new') { + console.log('Calling populateCreateFormFromCampaign with ID:', campaignId); + this.populateCreateFormFromCampaign(campaignId); + } else if (type === 'edit' && campaignId) { + this.loadCampaignForEdit(campaignId); + } else if (type === 'create' && campaignId === 'new') { + this.clearCreateForm(); + } + } + }); + } + + populateDropdown(dropdown, type) { + console.log('populateDropdown called:', { type, campaignsCount: this.campaigns?.length }); + + dropdown.innerHTML = ''; + + if (type === 'create') { + dropdown.innerHTML = ''; + } else { + dropdown.innerHTML = ''; + } + + if (this.campaigns && this.campaigns.length > 0) { + console.log('Adding campaigns to dropdown:', this.campaigns.map(c => ({ id: c.id, title: c.title }))); + + // Admin can edit all campaigns + this.campaigns.forEach(campaign => { + const item = document.createElement('div'); + item.className = 'dropdown-item'; + item.dataset.campaignId = campaign.id; + item.textContent = `${campaign.title} (${campaign.status})`; + dropdown.appendChild(item); + }); + } else { + console.log('No campaigns available for dropdown'); + const noResults = document.createElement('div'); + noResults.className = 'dropdown-item no-results'; + noResults.textContent = 'No campaigns found'; + dropdown.appendChild(noResults); + } + } + + filterDropdown(input, dropdown, type) { + const searchTerm = input.value.toLowerCase(); + + // Re-populate the dropdown to ensure we have the right campaigns + this.populateDropdown(dropdown, type); + + const items = dropdown.querySelectorAll('.dropdown-item:not(.no-results)'); + let hasVisibleItems = false; + + items.forEach(item => { + if (item.dataset.campaignId === 'new' || item.dataset.campaignId === '') { + // Always show default items + item.style.display = 'block'; + hasVisibleItems = true; + } else { + const text = item.textContent.toLowerCase(); + if (text.includes(searchTerm)) { + item.style.display = 'block'; + hasVisibleItems = true; + } else { + item.style.display = 'none'; + } + } + }); + + // Show/hide no results message + let noResultsItem = dropdown.querySelector('.no-results'); + if (!hasVisibleItems && searchTerm) { + if (!noResultsItem) { + noResultsItem = document.createElement('div'); + noResultsItem.className = 'dropdown-item no-results'; + dropdown.appendChild(noResultsItem); + } + noResultsItem.textContent = 'No campaigns found'; + noResultsItem.style.display = 'block'; + } else if (noResultsItem && searchTerm) { + noResultsItem.style.display = 'none'; + } + + dropdown.classList.add('show'); + } + + refreshDropdowns() { + // Refresh create dropdown if it exists + const createDropdown = document.getElementById('create-dropdown-menu'); + if (createDropdown) { + this.populateDropdown(createDropdown, 'create'); + } + + // Refresh edit dropdown if it exists + const editDropdown = document.getElementById('edit-dropdown-menu'); + if (editDropdown) { + this.populateDropdown(editDropdown, 'edit'); + } + } + + populateCreateFormFromCampaign(campaignId) { + console.log('populateCreateFormFromCampaign called with ID:', campaignId); + console.log('Available campaigns:', this.campaigns); + + const campaign = this.campaigns.find(c => String(c.id) === String(campaignId)); + console.log('Found campaign:', campaign); + + if (!campaign) { + console.error('Campaign not found for ID:', campaignId); + console.error('Available campaign IDs:', this.campaigns?.map(c => c.id)); + return; + } + + // Populate form fields with campaign data as template + document.getElementById('create-title').value = `Copy of ${campaign.title}`; + document.getElementById('create-description').value = campaign.description || ''; + document.getElementById('create-email-subject').value = campaign.email_subject || ''; + document.getElementById('create-email-body').value = campaign.email_body || ''; + document.getElementById('create-call-to-action').value = campaign.call_to_action || ''; + document.getElementById('create-status').value = 'draft'; // Always set to draft for new campaigns + + // Set checkboxes + document.getElementById('create-allow-smtp').checked = campaign.allow_smtp_email !== false; + document.getElementById('create-allow-mailto').checked = campaign.allow_mailto_link !== false; + document.getElementById('create-collect-info').checked = campaign.collect_user_info !== false; + document.getElementById('create-show-count').checked = campaign.show_email_count !== false; + document.getElementById('create-allow-editing').checked = campaign.allow_email_editing === true; + + // Set government levels + const targetLevels = campaign.target_government_levels || []; + document.querySelectorAll('input[name="target_government_levels"]').forEach(checkbox => { + checkbox.checked = targetLevels.includes(checkbox.value); + }); + + console.log('Form populated successfully with campaign:', campaign.title); + } + + clearCreateForm() { + // Clear all form fields + document.getElementById('create-title').value = ''; + document.getElementById('create-description').value = ''; + document.getElementById('create-email-subject').value = ''; + document.getElementById('create-email-body').value = ''; + document.getElementById('create-call-to-action').value = ''; + document.getElementById('create-status').value = 'draft'; + + // Reset checkboxes to defaults + document.getElementById('create-allow-smtp').checked = true; + document.getElementById('create-allow-mailto').checked = true; + document.getElementById('create-collect-info').checked = true; + document.getElementById('create-show-count').checked = true; + document.getElementById('create-allow-editing').checked = false; + + // Reset government levels to defaults + document.querySelectorAll('input[name="target_government_levels"]').forEach(checkbox => { + checkbox.checked = ['Federal', 'Provincial', 'Municipal'].includes(checkbox.value); + }); + } + + async loadCampaignForEdit(campaignId) { + try { + const response = await window.apiClient.get(`/admin/campaigns/${campaignId}`); + + if (response.success) { + this.currentCampaign = response.campaign; + this.populateEditForm(); + this.switchTab('edit'); + } else { + throw new Error(response.error || 'Failed to load campaign'); + } + } catch (error) { + console.error('Load campaign error:', error); + this.showMessage('Failed to load campaign: ' + error.message, 'error'); + } } switchTab(tabName) { @@ -139,8 +394,36 @@ class AdminPanel { // Special handling for different tabs if (tabName === 'campaigns') { this.loadCampaigns(); - } else if (tabName === 'edit' && this.currentCampaign) { - this.populateEditForm(); + } else if (tabName === 'create') { + // Ensure campaigns are loaded for template selection + if (!this.campaigns || this.campaigns.length === 0) { + this.loadCampaigns(); + } + } else if (tabName === 'edit') { + // Ensure campaigns are loaded for editing + if (!this.campaigns || this.campaigns.length === 0) { + this.loadCampaigns(); + } + if (this.currentCampaign) { + this.populateEditForm(); + } + } else if (tabName === 'users') { + this.loadUsers(); + } + + // Refresh dropdowns when switching to create or edit tabs + if (tabName === 'create' || tabName === 'edit') { + setTimeout(() => { + const createDropdown = document.getElementById('create-dropdown-menu'); + const editDropdown = document.getElementById('edit-dropdown-menu'); + + if (tabName === 'create' && createDropdown) { + this.populateDropdown(createDropdown, 'create'); + } + if (tabName === 'edit' && editDropdown) { + this.populateDropdown(editDropdown, 'edit'); + } + }, 100); } } @@ -157,6 +440,7 @@ class AdminPanel { if (response.success) { this.campaigns = response.campaigns; this.renderCampaignList(); + this.refreshDropdowns(); // Refresh dropdowns when campaigns are loaded } else { throw new Error(response.error || 'Failed to load campaigns'); } @@ -192,6 +476,8 @@ class AdminPanel {

Slug: /campaign/${campaign.slug}

Email Count: ${campaign.emailCount || 0}

Created: ${this.formatDate(campaign.created_at)}

+ ${campaign.created_by_user_name || campaign.created_by_user_email ? + `

Created By: ${this.escapeHtml(campaign.created_by_user_name || campaign.created_by_user_email)}

` : ''}
@@ -306,8 +592,14 @@ class AdminPanel { form.querySelector('[name="allow_email_editing"]').checked = campaign.allow_email_editing; // Government levels - const targetLevels = campaign.target_government_levels ? - campaign.target_government_levels.split(',').map(l => l.trim()) : []; + let targetLevels = []; + if (campaign.target_government_levels) { + if (Array.isArray(campaign.target_government_levels)) { + targetLevels = campaign.target_government_levels; + } else if (typeof campaign.target_government_levels === 'string') { + targetLevels = campaign.target_government_levels.split(',').map(l => l.trim()); + } + } form.querySelectorAll('[name="target_government_levels"]').forEach(checkbox => { checkbox.checked = targetLevels.includes(checkbox.value); @@ -491,7 +783,7 @@ class AdminPanel { formatDate(dateString) { if (!dateString) return 'N/A'; - + try { return new Date(dateString).toLocaleDateString('en-CA', { year: 'numeric', @@ -504,6 +796,216 @@ class AdminPanel { return dateString; } } + + // User Management Methods + async loadUsers() { + const loadingDiv = document.getElementById('users-loading'); + const listDiv = document.getElementById('users-list'); + + loadingDiv.classList.remove('hidden'); + listDiv.innerHTML = ''; + + try { + const response = await window.apiClient.get('/admin/users'); + + if (response.success) { + this.users = response.users; + this.renderUserList(); + } else { + throw new Error(response.error || 'Failed to load users'); + } + } catch (error) { + console.error('Load users error:', error); + this.showMessage('Failed to load users: ' + error.message, 'error'); + } finally { + loadingDiv.classList.add('hidden'); + } + } + + renderUserList() { + const listDiv = document.getElementById('users-list'); + + if (this.users.length === 0) { + listDiv.innerHTML = ` +
+

No users yet

+

Create your first user to get started.

+
+ `; + return; + } + + // Add email all users button at the top + listDiv.innerHTML = ` +
+ +
+ `; + + const userCards = this.users.map(user => { + const isExpired = user.userType === 'temp' && user.ExpiresAt && new Date(user.ExpiresAt) < new Date(); + const userTypeClass = isExpired ? 'expired' : (user.userType || 'user'); + + return ` +
+
+ +
+ + ${isExpired ? 'EXPIRED' : (user.Admin || user.admin ? 'ADMIN' : userTypeClass.toUpperCase())} + +
+
+ +
+ `; + }).join(''); + + listDiv.innerHTML += userCards; + } + + showUserModal() { + const modal = document.getElementById('user-modal'); + const form = document.getElementById('user-form'); + + form.reset(); + document.getElementById('user-modal-title').textContent = 'Add New User'; + modal.classList.remove('hidden'); + } + + hideUserModal() { + const modal = document.getElementById('user-modal'); + modal.classList.add('hidden'); + } + + showEmailModal() { + const modal = document.getElementById('email-modal'); + const form = document.getElementById('email-form'); + + form.reset(); + modal.classList.remove('hidden'); + } + + hideEmailModal() { + const modal = document.getElementById('email-modal'); + modal.classList.add('hidden'); + } + + handleUserTypeChange(userType) { + const tempOptions = document.getElementById('temp-user-options'); + if (tempOptions) { + tempOptions.style.display = userType === 'temp' ? 'block' : 'none'; + } + } + + async handleCreateUser(e) { + e.preventDefault(); + + const formData = new FormData(e.target); + const userData = { + email: formData.get('email'), + password: formData.get('password'), + name: formData.get('name'), + phone: formData.get('phone'), + userType: formData.get('userType'), + isAdmin: formData.get('isAdmin') === 'on' || formData.get('userType') === 'admin', + expireDays: formData.get('userType') === 'temp' ? parseInt(formData.get('expireDays')) : undefined + }; + + try { + const response = await window.apiClient.post('/admin/users', userData); + + if (response.success) { + this.showMessage('User created successfully!', 'success'); + this.hideUserModal(); + this.loadUsers(); + } else { + throw new Error(response.error || 'Failed to create user'); + } + } catch (error) { + console.error('Create user error:', error); + this.showMessage('Failed to create user: ' + error.message, 'error'); + } + } + + async deleteUser(userId) { + const user = this.users.find(u => (u.Id || u.id) == userId); + if (!user) return; + + if (!confirm(`Are you sure you want to delete the user "${user.Email || user.email}"? This action cannot be undone.`)) { + return; + } + + try { + const response = await window.apiClient.makeRequest(`/admin/users/${userId}`, { + method: 'DELETE' + }); + + if (response.success) { + this.showMessage('User deleted successfully!', 'success'); + this.loadUsers(); + } else { + throw new Error(response.error || 'Failed to delete user'); + } + } catch (error) { + console.error('Delete user error:', error); + this.showMessage('Failed to delete user: ' + error.message, 'error'); + } + } + + async sendLoginDetails(userId) { + try { + const response = await window.apiClient.post(`/admin/users/${userId}/send-login-details`); + + if (response.success) { + this.showMessage('Login details sent successfully!', 'success'); + } else { + throw new Error(response.error || 'Failed to send login details'); + } + } catch (error) { + console.error('Send login details error:', error); + this.showMessage('Failed to send login details: ' + error.message, 'error'); + } + } + + async handleEmailAllUsers(e) { + e.preventDefault(); + + const formData = new FormData(e.target); + const emailData = { + subject: formData.get('subject'), + content: formData.get('content') + }; + + try { + const response = await window.apiClient.post('/admin/users/email-all', emailData); + + if (response.success) { + this.showMessage(`Email sent successfully! ${response.results.successful.length} sent, ${response.results.failed.length} failed.`, 'success'); + this.hideEmailModal(); + } else { + throw new Error(response.error || 'Failed to send emails'); + } + } catch (error) { + console.error('Email all users error:', error); + this.showMessage('Failed to send emails: ' + error.message, 'error'); + } + } } // Initialize admin panel when DOM is loaded diff --git a/influence/app/public/js/auth.js b/influence/app/public/js/auth.js index ac31073..324f3c9 100644 --- a/influence/app/public/js/auth.js +++ b/influence/app/public/js/auth.js @@ -119,7 +119,20 @@ class AuthManager { } }); } - + + // Redirect to appropriate dashboard + redirectToDashboard() { + if (this.isAuthenticated && this.user) { + if (this.user.isAdmin) { + window.location.href = '/admin.html'; + } else { + window.location.href = '/dashboard.html'; + } + } else { + window.location.href = '/login.html'; + } + } + // Set up event listeners for auth-related actions setupAuthListeners() { // Global logout button diff --git a/influence/app/public/js/dashboard.js b/influence/app/public/js/dashboard.js new file mode 100644 index 0000000..a4acce6 --- /dev/null +++ b/influence/app/public/js/dashboard.js @@ -0,0 +1,1057 @@ +// User Dashboard JavaScript +class UserDashboard { + constructor() { + this.user = null; + this.campaigns = []; + this.analytics = {}; + this.authManager = null; + this.currentCampaign = null; // Track campaign being edited + } + + async init() { + // Check authentication first + if (typeof authManager !== 'undefined') { + this.authManager = authManager; + const isAuth = await this.authManager.checkSession(); + if (!isAuth) { + window.location.href = '/login.html'; + return; + } + this.user = this.authManager.user; + this.setupUserInterface(); + } else { + // Fallback if authManager not loaded + window.location.href = '/login.html'; + return; + } + + this.setupEventListeners(); + this.loadUserCampaigns(); + this.loadAnalytics(); + } + + setupUserInterface() { + if (!this.user) return; + + // Update user info display + const userNameEl = document.getElementById('user-name'); + const userEmailEl = document.getElementById('user-email'); + const userRoleBadge = document.getElementById('user-role-badge'); + + if (userNameEl) userNameEl.textContent = this.user.name || this.user.email; + if (userEmailEl) userEmailEl.textContent = this.user.email; + + if (userRoleBadge) { + const userType = this.user.userType || 'user'; + userRoleBadge.className = `user-badge ${userType}`; + userRoleBadge.textContent = userType === 'admin' ? 'Administrator' : + userType === 'temp' ? 'Temporary User' : 'User'; + } + + // Update account form + this.populateAccountForm(); + + // Show admin link if user is admin + const createBtn = document.getElementById('create-campaign-btn'); + if (createBtn && this.user.isAdmin) { + createBtn.textContent = 'Admin Panel'; + } else if (createBtn) { + createBtn.style.display = 'none'; + } + } + + populateAccountForm() { + if (!this.user) return; + + const nameInput = document.getElementById('account-name'); + const emailInput = document.getElementById('account-email'); + const phoneInput = document.getElementById('account-phone'); + const roleInput = document.getElementById('account-role'); + const expiresInput = document.getElementById('account-expires'); + const expirationGroup = document.getElementById('account-expiration'); + + if (nameInput) nameInput.value = this.user.name || ''; + if (emailInput) emailInput.value = this.user.email || ''; + if (phoneInput) phoneInput.value = this.user.phone || ''; + + if (roleInput) { + const roleText = this.user.isAdmin ? 'Administrator' : + this.user.userType === 'temp' ? 'Temporary User' : 'Standard User'; + roleInput.value = roleText; + } + + // Show expiration info for temp users + if (this.user.userType === 'temp' && expiresInput && expirationGroup) { + if (this.user.expiresAt) { + expiresInput.value = new Date(this.user.expiresAt).toLocaleDateString(); + expirationGroup.style.display = 'block'; + } + } + } + + setupEventListeners() { + // Tab navigation + document.querySelectorAll('.nav-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const tab = e.target.dataset.tab; + this.switchTab(tab); + }); + }); + + // Logout button + const logoutBtn = document.getElementById('logout-btn'); + if (logoutBtn) { + logoutBtn.addEventListener('click', () => { + this.authManager.logout(); + }); + } + + // Form submissions + const createForm = document.getElementById('create-campaign-form'); + if (createForm) { + createForm.addEventListener('submit', (e) => { + this.handleCreateCampaign(e); + }); + } + + const editForm = document.getElementById('edit-campaign-form'); + if (editForm) { + editForm.addEventListener('submit', (e) => { + this.handleUpdateCampaign(e); + }); + } + + // Campaign actions using event delegation + document.addEventListener('click', (e) => { + if (e.target.matches('[data-action="view-campaign"]')) { + this.viewCampaign(e.target.dataset.campaignSlug); + } + if (e.target.matches('[data-action="edit-campaign"]')) { + this.editCampaign(e.target.dataset.campaignId); + } + if (e.target.matches('[data-action="view-analytics"]')) { + this.viewCampaignAnalytics(e.target.dataset.campaignId); + } + if (e.target.matches('[data-action="cancel-create"]')) { + this.switchTab('campaigns'); + } + if (e.target.matches('[data-action="cancel-edit"]')) { + this.switchTab('campaigns'); + } + if (e.target.matches('[data-action="go-to-create"]')) { + this.switchTab('create'); + } + }); + + // Setup campaign selector dropdowns + this.setupCampaignSelectors(); + } + + setupCampaignSelectors() { + // Setup Create Campaign Selector + const createSelector = document.getElementById('create-campaign-selector'); + const createDropdown = document.getElementById('create-dropdown-menu'); + if (createSelector && createDropdown) { + this.setupDropdown(createSelector, createDropdown, 'create'); + } + + // Setup Edit Campaign Selector + const editSelector = document.getElementById('edit-campaign-selector'); + const editDropdown = document.getElementById('edit-dropdown-menu'); + if (editSelector && editDropdown) { + this.setupDropdown(editSelector, editDropdown, 'edit'); + } + } + + setupDropdown(input, dropdown, type) { + // Show dropdown on focus + input.addEventListener('focus', () => { + this.populateDropdown(dropdown, type); + dropdown.classList.add('show'); + }); + + // Hide dropdown when clicking outside + document.addEventListener('click', (e) => { + if (!input.contains(e.target) && !dropdown.contains(e.target)) { + dropdown.classList.remove('show'); + } + }); + + // Filter campaigns on input + input.addEventListener('input', () => { + this.filterDropdown(input, dropdown, type); + }); + + // Handle dropdown item selection + dropdown.addEventListener('click', (e) => { + if (e.target.classList.contains('dropdown-item')) { + const campaignId = e.target.dataset.campaignId; + const campaignTitle = e.target.textContent; + + input.value = campaignTitle; + dropdown.classList.remove('show'); + + if (type === 'create' && campaignId !== 'new') { + this.populateCreateFormFromCampaign(campaignId); + } else if (type === 'edit' && campaignId) { + this.loadCampaignForEdit(campaignId); + } else if (type === 'create' && campaignId === 'new') { + this.clearCreateForm(); + } + } + }); + } + + populateDropdown(dropdown, type) { + dropdown.innerHTML = ''; + + if (type === 'create') { + dropdown.innerHTML = ''; + } else { + dropdown.innerHTML = ''; + } + + if (this.campaigns && this.campaigns.length > 0) { + // Filter campaigns based on what user can do + const availableCampaigns = type === 'edit' + ? this.campaigns.filter(campaign => this.canEditCampaign(campaign)) + : this.campaigns; + + availableCampaigns.forEach(campaign => { + const item = document.createElement('div'); + item.className = 'dropdown-item'; + item.dataset.campaignId = campaign.id; + item.textContent = `${campaign.title} (${campaign.status})`; + dropdown.appendChild(item); + }); + + if (type === 'edit' && availableCampaigns.length === 0) { + const noResults = document.createElement('div'); + noResults.className = 'dropdown-item no-results'; + noResults.textContent = 'No editable campaigns found'; + dropdown.appendChild(noResults); + } + } else { + const noResults = document.createElement('div'); + noResults.className = 'dropdown-item no-results'; + noResults.textContent = 'No campaigns found'; + dropdown.appendChild(noResults); + } + } + + filterDropdown(input, dropdown, type) { + const searchTerm = input.value.toLowerCase(); + + // Re-populate the dropdown to ensure we have the right campaigns + this.populateDropdown(dropdown, type); + + const items = dropdown.querySelectorAll('.dropdown-item:not(.no-results)'); + let hasVisibleItems = false; + + items.forEach(item => { + if (item.dataset.campaignId === 'new' || item.dataset.campaignId === '') { + // Always show default items + item.style.display = 'block'; + hasVisibleItems = true; + } else { + const text = item.textContent.toLowerCase(); + if (text.includes(searchTerm)) { + item.style.display = 'block'; + hasVisibleItems = true; + } else { + item.style.display = 'none'; + } + } + }); + + // Show/hide no results message + let noResultsItem = dropdown.querySelector('.no-results'); + if (!hasVisibleItems && searchTerm) { + if (!noResultsItem) { + noResultsItem = document.createElement('div'); + noResultsItem.className = 'dropdown-item no-results'; + dropdown.appendChild(noResultsItem); + } + noResultsItem.textContent = 'No campaigns found'; + noResultsItem.style.display = 'block'; + } else if (noResultsItem && searchTerm) { + noResultsItem.style.display = 'none'; + } + + dropdown.classList.add('show'); + } + + populateCreateFormFromCampaign(campaignId) { + const campaign = this.campaigns.find(c => c.id === campaignId); + if (!campaign) return; + + // Populate form fields with campaign data as template + document.getElementById('create-title').value = `Copy of ${campaign.title}`; + document.getElementById('create-description').value = campaign.description || ''; + document.getElementById('create-email-subject').value = campaign.email_subject || ''; + document.getElementById('create-email-body').value = campaign.email_body || ''; + document.getElementById('create-call-to-action').value = campaign.call_to_action || ''; + + // Set checkboxes + document.getElementById('create-allow-smtp').checked = campaign.allow_smtp_email !== false; + document.getElementById('create-allow-mailto').checked = campaign.allow_mailto_link !== false; + document.getElementById('create-collect-info').checked = campaign.collect_user_info !== false; + document.getElementById('create-show-count').checked = campaign.show_email_count !== false; + document.getElementById('create-allow-editing').checked = campaign.allow_email_editing === true; + + // Set government levels + const targetLevels = campaign.target_government_levels || []; + document.querySelectorAll('input[name="target_government_levels"]').forEach(checkbox => { + checkbox.checked = targetLevels.includes(checkbox.value); + }); + } + + clearCreateForm() { + // Clear all form fields + document.getElementById('create-title').value = ''; + document.getElementById('create-description').value = ''; + document.getElementById('create-email-subject').value = ''; + document.getElementById('create-email-body').value = ''; + document.getElementById('create-call-to-action').value = ''; + + // Reset checkboxes to defaults + document.getElementById('create-allow-smtp').checked = true; + document.getElementById('create-allow-mailto').checked = true; + document.getElementById('create-collect-info').checked = true; + document.getElementById('create-show-count').checked = true; + document.getElementById('create-allow-editing').checked = false; + + // Reset government levels to defaults + document.querySelectorAll('input[name="target_government_levels"]').forEach(checkbox => { + checkbox.checked = ['Federal', 'Provincial', 'Municipal'].includes(checkbox.value); + }); + } + + async loadCampaignForEdit(campaignId) { + // Find campaign in already loaded campaigns array + const campaign = this.campaigns.find(c => String(c.id) === String(campaignId)); + + if (!campaign) { + this.showMessage('Campaign not found', 'error'); + return; + } + + if (!this.canEditCampaign(campaign)) { + this.showMessage('You do not have permission to edit this campaign', 'error'); + return; + } + + this.currentCampaign = campaign; + this.populateEditForm(); + this.switchTab('edit'); + } + + switchTab(tabName) { + // Hide all tabs + document.querySelectorAll('.tab-content').forEach(tab => { + tab.classList.remove('active'); + }); + + // Remove active class from nav buttons + document.querySelectorAll('.nav-btn').forEach(btn => { + btn.classList.remove('active'); + }); + + // Show selected tab + const targetTab = document.getElementById(`${tabName}-tab`); + if (targetTab) { + targetTab.classList.add('active'); + } + + // Update nav button + const targetNavBtn = document.querySelector(`[data-tab="${tabName}"]`); + if (targetNavBtn) { + targetNavBtn.classList.add('active'); + } + + // Load data for specific tabs + if (tabName === 'campaigns') { + this.loadUserCampaigns(); + } else if (tabName === 'analytics') { + this.loadAnalytics(); + } + } + + async loadUserCampaigns() { + const loadingDiv = document.getElementById('campaigns-loading'); + const listDiv = document.getElementById('campaigns-list'); + + if (loadingDiv) loadingDiv.classList.remove('hidden'); + if (listDiv) listDiv.innerHTML = ''; + + try { + const endpoint = this.user.isAdmin ? '/admin/campaigns' : '/campaigns'; + const response = await window.apiClient.get(endpoint); + + if (response.success) { + const campaigns = Array.isArray(response.campaigns) ? response.campaigns : []; + this.campaigns = campaigns + .map(campaign => this.normalizeCampaignFromApi(campaign)) + .filter(Boolean); + + this.renderCampaignList(); + } else { + throw new Error(response.error || 'Failed to load campaigns'); + } + } catch (error) { + console.error('Load campaigns error:', error); + this.showMessage('Failed to load campaigns: ' + error.message, 'error'); + } finally { + if (loadingDiv) loadingDiv.classList.add('hidden'); + } + } + + normalizeCampaignFromApi(campaign) { + if (!campaign) return null; + + const normalized = { + ...campaign, + id: campaign.id ?? campaign.ID ?? campaign.Id ?? null, + created_by_user_id: campaign.created_by_user_id ?? campaign['Created By User ID'] ?? null, + created_by_user_email: campaign.created_by_user_email ?? campaign['Created By User Email'] ?? null, + created_by_user_name: campaign.created_by_user_name ?? campaign['Created By User Name'] ?? null + }; + + // Ensure IDs are comparable as strings + if (normalized.created_by_user_id !== undefined && normalized.created_by_user_id !== null) { + normalized.created_by_user_id = String(normalized.created_by_user_id); + } + + if (typeof normalized.status === 'string') { + normalized.status = normalized.status.toLowerCase(); + } + + ['allow_smtp_email', 'allow_mailto_link', 'collect_user_info', 'show_email_count', 'allow_email_editing'].forEach(key => { + if (normalized[key] !== undefined) { + normalized[key] = normalized[key] === true || normalized[key] === 'true' || normalized[key] === 1 || normalized[key] === '1'; + } + }); + + // Normalize target government levels to an array + if (Array.isArray(normalized.target_government_levels)) { + normalized.target_government_levels = normalized.target_government_levels; + } else if (typeof normalized.target_government_levels === 'string' && normalized.target_government_levels.length > 0) { + normalized.target_government_levels = normalized.target_government_levels.split(',').map(level => level.trim()).filter(Boolean); + } else { + normalized.target_government_levels = []; + } + + return normalized; + } + + isCampaignOwner(campaign) { + if (!campaign || !this.user) return false; + + const userId = this.user.id != null ? String(this.user.id) : (this.user.userId != null ? String(this.user.userId) : null); + const userEmail = this.user.email ? String(this.user.email).toLowerCase() : null; + const campaignOwnerId = campaign.created_by_user_id != null ? String(campaign.created_by_user_id) : null; + const campaignOwnerEmail = campaign.created_by_user_email ? String(campaign.created_by_user_email).toLowerCase() : null; + + return ( + (userId && campaignOwnerId && userId === campaignOwnerId) || + (userEmail && campaignOwnerEmail && userEmail === campaignOwnerEmail) + ); + } + + canEditCampaign(campaign) { + if (this.user?.isAdmin) { + return true; + } + return this.isCampaignOwner(campaign); + } + + renderCampaignList() { + const listDiv = document.getElementById('campaigns-list'); + if (!listDiv) return; + + if (this.campaigns.length === 0) { + listDiv.innerHTML = ` +
+

No campaigns yet

+

You haven't created any campaigns yet.

+

+
+ `; + return; + } + + listDiv.innerHTML = this.campaigns.map(campaign => ` +
+
+

${this.escapeHtml(campaign.title)}

+ ${campaign.status} +
+ +
+

Slug: /campaign/${campaign.slug}

+

Emails Sent: ${campaign.emailCount || 0}

+

Created: ${this.formatDate(campaign.created_at)}

+ ${campaign.description ? `

Description: ${this.escapeHtml(campaign.description)}

` : ''} +
+ +
+ + ${this.canEditCampaign(campaign) ? ` + + ` : ''} + +
+
+ `).join(''); + } + + async loadAnalytics() { + const loadingDiv = document.getElementById('analytics-loading'); + + if (loadingDiv) loadingDiv.classList.remove('hidden'); + + try { + // Calculate analytics from campaigns data + const totalCampaigns = this.campaigns.length; + const activeCampaigns = this.campaigns.filter(c => c.status === 'active').length; + const totalEmails = this.campaigns.reduce((sum, c) => sum + (c.emailCount || 0), 0); + + // Update analytics display + const totalCampaignsEl = document.getElementById('total-campaigns'); + const activeCampaignsEl = document.getElementById('active-campaigns'); + const totalEmailsEl = document.getElementById('total-emails'); + const totalUsersReachedEl = document.getElementById('total-users-reached'); + + if (totalCampaignsEl) totalCampaignsEl.textContent = totalCampaigns; + if (activeCampaignsEl) activeCampaignsEl.textContent = activeCampaigns; + if (totalEmailsEl) totalEmailsEl.textContent = totalEmails; + if (totalUsersReachedEl) totalUsersReachedEl.textContent = Math.floor(totalEmails * 0.8); // Estimate + + } catch (error) { + console.error('Analytics error:', error); + this.showMessage('Failed to load analytics: ' + error.message, 'error'); + } finally { + if (loadingDiv) loadingDiv.classList.add('hidden'); + } + } + + viewCampaign(slug) { + window.open(`/campaign/${slug}`, '_blank'); + } + + editCampaign(campaignId) { + const campaign = this.campaigns.find(c => String(c.id) === String(campaignId)); + + if (!campaign) { + this.showMessage('Campaign not found', 'error'); + return; + } + + if (!this.canEditCampaign(campaign)) { + this.showMessage('You do not have permission to edit this campaign', 'error'); + return; + } + + this.currentCampaign = campaign; + this.switchTab('edit'); + this.populateEditForm(); + } + + async viewCampaignAnalytics(campaignId) { + try { + const endpoint = this.user.isAdmin + ? `/admin/campaigns/${campaignId}/analytics` + : `/campaigns/${campaignId}/analytics`; + + const response = await window.apiClient.get(endpoint); + + if (response.success) { + this.showAnalyticsModal(response.analytics); + } else { + throw new Error(response.error || 'Failed to load campaign analytics'); + } + } catch (error) { + console.error('Campaign analytics error:', error); + this.showMessage('Failed to load campaign analytics: ' + error.message, 'error'); + } + } + + showAnalyticsModal(analytics) { + // Create a simple analytics modal + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + + // Close modal handlers + modal.querySelector('.modal-close').addEventListener('click', () => { + document.body.removeChild(modal); + }); + + modal.addEventListener('click', (e) => { + if (e.target === modal) { + document.body.removeChild(modal); + } + }); + } + + openEditModal(campaign) { + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + const isAdmin = !!this.user?.isAdmin; + + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + + const form = modal.querySelector('#edit-campaign-form'); + this.populateEditForm(form, campaign); + + form.addEventListener('submit', (event) => this.handleEditCampaignSubmit(event, campaign.id, modal)); + + const closeModal = () => this.closeModal(modal); + + modal.querySelector('.modal-close').addEventListener('click', closeModal); + modal.addEventListener('click', (event) => { + if (event.target === modal) { + closeModal(); + } + }); + const cancelBtn = modal.querySelector('[data-action="cancel-modal"]'); + if (cancelBtn) { + cancelBtn.addEventListener('click', closeModal); + } + } + + populateEditForm(form, campaign) { + if (!form || !campaign) return; + + // Populate basic fields + form.querySelector('[name="title"]').value = campaign.title || ''; + form.querySelector('[name="description"]').value = campaign.description || ''; + form.querySelector('[name="email_subject"]').value = campaign.email_subject || ''; + form.querySelector('[name="email_body"]').value = campaign.email_body || ''; + form.querySelector('[name="call_to_action"]').value = campaign.call_to_action || ''; + + // Status select + const statusSelect = form.querySelector('[name="status"]'); + if (statusSelect) { + statusSelect.value = campaign.status || 'draft'; + } + + // Campaign settings checkboxes + form.querySelector('[name="allow_smtp_email"]').checked = !!campaign.allow_smtp_email; + form.querySelector('[name="allow_mailto_link"]').checked = !!campaign.allow_mailto_link; + form.querySelector('[name="collect_user_info"]').checked = !!campaign.collect_user_info; + form.querySelector('[name="show_email_count"]').checked = !!campaign.show_email_count; + form.querySelector('[name="allow_email_editing"]').checked = !!campaign.allow_email_editing; + + // Target government levels + const targetLevels = Array.isArray(campaign.target_government_levels) + ? campaign.target_government_levels + : (campaign.target_government_levels ? campaign.target_government_levels.split(',').map(l => l.trim()) : []); + + form.querySelectorAll('input[name="target_government_levels"]').forEach(checkbox => { + checkbox.checked = targetLevels.includes(checkbox.value); + }); + } + + // New method for edit tab form population + populateEditForm() { + if (!this.currentCampaign) return; + + const form = document.getElementById('edit-campaign-form'); + const campaign = this.currentCampaign; + + // Populate form fields + form.querySelector('[name="title"]').value = campaign.title || ''; + form.querySelector('[name="description"]').value = campaign.description || ''; + form.querySelector('[name="email_subject"]').value = campaign.email_subject || ''; + form.querySelector('[name="email_body"]').value = campaign.email_body || ''; + form.querySelector('[name="call_to_action"]').value = campaign.call_to_action || ''; + + // Status select + form.querySelector('[name="status"]').value = campaign.status || 'draft'; + + // Checkboxes + form.querySelector('[name="allow_smtp_email"]').checked = !!campaign.allow_smtp_email; + form.querySelector('[name="allow_mailto_link"]').checked = !!campaign.allow_mailto_link; + form.querySelector('[name="collect_user_info"]').checked = !!campaign.collect_user_info; + form.querySelector('[name="show_email_count"]').checked = !!campaign.show_email_count; + form.querySelector('[name="allow_email_editing"]').checked = !!campaign.allow_email_editing; + + // Government levels + const targetLevels = Array.isArray(campaign.target_government_levels) + ? campaign.target_government_levels + : (campaign.target_government_levels ? campaign.target_government_levels.split(',').map(l => l.trim()) : []); + + form.querySelectorAll('[name="target_government_levels"]').forEach(checkbox => { + checkbox.checked = targetLevels.includes(checkbox.value); + }); + } + + async handleUpdateCampaign(e) { + e.preventDefault(); + + if (!this.currentCampaign) return; + + const formData = new FormData(e.target); + const updates = { + title: formData.get('title'), + description: formData.get('description'), + email_subject: formData.get('email_subject'), + email_body: formData.get('email_body'), + call_to_action: formData.get('call_to_action'), + status: formData.get('status'), // Allow all users to change status for campaigns they own + allow_smtp_email: formData.get('allow_smtp_email') === 'on', + allow_mailto_link: formData.get('allow_mailto_link') === 'on', + collect_user_info: formData.get('collect_user_info') === 'on', + show_email_count: formData.get('show_email_count') === 'on', + allow_email_editing: formData.get('allow_email_editing') === 'on', + target_government_levels: Array.from(formData.getAll('target_government_levels')) + }; + + console.log('Updating campaign with data:', updates); + console.log('Current campaign:', this.currentCampaign); + console.log('User info:', this.user); + + try { + const endpoint = this.user?.isAdmin + ? `/admin/campaigns/${this.currentCampaign.id}` + : `/campaigns/${this.currentCampaign.id}`; + + console.log('Using endpoint:', endpoint); + + const response = await window.apiClient.makeRequest(endpoint, { + method: 'PUT', + body: JSON.stringify(updates) + }); + + console.log('Update response:', response); + + if (response.success) { + this.showMessage('Campaign updated successfully!', 'success'); + const updatedCampaign = this.normalizeCampaignFromApi(response.campaign); + this.updateCampaignInState(updatedCampaign); + this.renderCampaignList(); + this.loadAnalytics(); + this.switchTab('campaigns'); + } else { + throw new Error(response.error || 'Failed to update campaign'); + } + } catch (error) { + console.error('Update campaign error:', error); + const errorMsg = error?.data?.error || error.message || 'Failed to update campaign'; + this.showMessage('Failed to update campaign: ' + errorMsg, 'error'); + } + } + + async handleEditCampaignSubmit(event, campaignId, modal) { + event.preventDefault(); + + const form = event.target; + const formData = new FormData(form); + + const updates = { + title: formData.get('title'), + description: formData.get('description'), + email_subject: formData.get('email_subject'), + email_body: formData.get('email_body'), + call_to_action: formData.get('call_to_action'), + status: this.user?.isAdmin ? (formData.get('status') || 'draft') : 'draft', + allow_smtp_email: formData.get('allow_smtp_email') === 'on', + allow_mailto_link: formData.get('allow_mailto_link') === 'on', + collect_user_info: formData.get('collect_user_info') === 'on', + show_email_count: formData.get('show_email_count') === 'on', + allow_email_editing: formData.get('allow_email_editing') === 'on', + target_government_levels: Array.from(formData.getAll('target_government_levels')) + }; + + try { + const endpoint = this.user?.isAdmin + ? `/admin/campaigns/${campaignId}` + : `/campaigns/${campaignId}`; + + const response = await window.apiClient.makeRequest(endpoint, { + method: 'PUT', + body: JSON.stringify(updates) + }); + + if (response.success) { + this.closeModal(modal); + const updatedCampaign = this.normalizeCampaignFromApi(response.campaign); + this.updateCampaignInState(updatedCampaign); + this.renderCampaignList(); + this.loadAnalytics(); + this.showMessage('Campaign updated successfully!', 'success'); + } else { + throw new Error(response.error || 'Failed to update campaign'); + } + } catch (error) { + console.error('Update campaign error:', error); + const errorMsg = error?.data?.error || error.message || 'Failed to update campaign'; + this.showMessage('Failed to update campaign: ' + errorMsg, 'error'); + } + } + + updateCampaignInState(updatedCampaign) { + if (!updatedCampaign) return; + + const updatedId = updatedCampaign.id != null ? String(updatedCampaign.id) : null; + const index = this.campaigns.findIndex(c => String(c.id) === updatedId); + + if (index >= 0) { + this.campaigns[index] = this.normalizeCampaignFromApi({ + ...this.campaigns[index], + ...updatedCampaign + }); + } else { + this.campaigns.unshift(this.normalizeCampaignFromApi(updatedCampaign)); + } + } + + closeModal(modal) { + if (modal && modal.parentNode) { + modal.parentNode.removeChild(modal); + } + } + + showMessage(message, type = 'info') { + const container = document.getElementById('message-container'); + if (container) { + container.className = `message-${type}`; + container.textContent = message; + container.classList.remove('hidden'); + + setTimeout(() => { + container.classList.add('hidden'); + }, 5000); + } + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + formatDate(dateString) { + if (!dateString) return 'N/A'; + + try { + return new Date(dateString).toLocaleDateString('en-CA', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } catch (error) { + return dateString; + } + } + + // Campaign Creation Methods + async handleCreateCampaign(e) { + e.preventDefault(); + + const formData = new FormData(e.target); + const campaignData = { + title: formData.get('title'), + description: formData.get('description'), + email_subject: formData.get('email_subject'), + email_body: formData.get('email_body'), + call_to_action: formData.get('call_to_action'), + status: formData.get('status'), + allow_smtp_email: formData.get('allow_smtp_email') === 'on', + allow_mailto_link: formData.get('allow_mailto_link') === 'on', + collect_user_info: formData.get('collect_user_info') === 'on', + show_email_count: formData.get('show_email_count') === 'on', + allow_email_editing: formData.get('allow_email_editing') === 'on', + target_government_levels: Array.from(formData.getAll('target_government_levels')) + }; + + try { + if (!this.user.isAdmin) { + campaignData.status = 'draft'; + } + + const endpoint = this.user.isAdmin ? '/admin/campaigns' : '/campaigns'; + const response = await window.apiClient.post(endpoint, campaignData); + + if (response.success) { + this.showMessage('Campaign created successfully!', 'success'); + e.target.reset(); + this.switchTab('campaigns'); + // Reload campaigns to show the new one + this.loadUserCampaigns(); + } else { + throw new Error(response.error || 'Failed to create campaign'); + } + } catch (error) { + console.error('Create campaign error:', error); + this.showMessage('Failed to create campaign: ' + error.message, 'error'); + } + } +} + +// Initialize dashboard when DOM is loaded +document.addEventListener('DOMContentLoaded', async () => { + window.userDashboard = new UserDashboard(); + await window.userDashboard.init(); +}); \ No newline at end of file diff --git a/influence/app/public/js/login.js b/influence/app/public/js/login.js index 21a6eb9..1c76c56 100644 --- a/influence/app/public/js/login.js +++ b/influence/app/public/js/login.js @@ -30,8 +30,12 @@ document.addEventListener('DOMContentLoaded', function() { }); if (response.success) { - // Redirect to admin panel - window.location.href = '/admin.html'; + // Redirect based on user role + if (response.user && response.user.isAdmin) { + window.location.href = '/admin.html'; + } else { + window.location.href = '/dashboard.html'; + } } else { showError(response.error || 'Login failed'); } @@ -46,9 +50,13 @@ document.addEventListener('DOMContentLoaded', function() { async function checkSession() { try { const response = await apiClient.get('/auth/session'); - if (response.authenticated) { - // Already logged in, redirect to admin - window.location.href = '/admin.html'; + if (response.authenticated && response.user) { + // Already logged in, redirect based on user role + if (response.user.isAdmin) { + window.location.href = '/admin.html'; + } else { + window.location.href = '/dashboard.html'; + } } } catch (error) { // Not logged in, continue with login form diff --git a/influence/app/public/login.html b/influence/app/public/login.html index 57af425..1223ae3 100644 --- a/influence/app/public/login.html +++ b/influence/app/public/login.html @@ -147,8 +147,8 @@