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 @@