Whole new user interface and user system
This commit is contained in:
parent
9aaefd149e
commit
dfe244f821
@ -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
|
// Update last login time
|
||||||
try {
|
try {
|
||||||
// Debug: Log user object structure
|
// Debug: Log user object structure
|
||||||
@ -78,6 +96,7 @@ class AuthController {
|
|||||||
req.session.userEmail = user.Email || user.email;
|
req.session.userEmail = user.Email || user.email;
|
||||||
req.session.userName = user.Name || user.name;
|
req.session.userName = user.Name || user.name;
|
||||||
req.session.isAdmin = user.Admin || user.admin || false;
|
req.session.isAdmin = user.Admin || user.admin || false;
|
||||||
|
req.session.userType = userType;
|
||||||
|
|
||||||
console.log('User logged in successfully:', {
|
console.log('User logged in successfully:', {
|
||||||
email: req.session.userEmail,
|
email: req.session.userEmail,
|
||||||
@ -100,7 +119,8 @@ class AuthController {
|
|||||||
id: req.session.userId,
|
id: req.session.userId,
|
||||||
email: req.session.userEmail,
|
email: req.session.userEmail,
|
||||||
name: req.session.userName,
|
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,
|
id: req.session.userId,
|
||||||
email: req.session.userEmail,
|
email: req.session.userEmail,
|
||||||
name: req.session.userName,
|
name: req.session.userName,
|
||||||
isAdmin: req.session.isAdmin
|
isAdmin: req.session.isAdmin,
|
||||||
|
userType: req.session.userType || 'user'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -3,6 +3,29 @@ const emailService = require('../services/email');
|
|||||||
const representAPI = require('../services/represent-api');
|
const representAPI = require('../services/represent-api');
|
||||||
const { generateSlug, validateSlug } = require('../utils/validators');
|
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
|
// Helper function to cache representatives
|
||||||
async function cacheRepresentatives(postalCode, representatives, representData) {
|
async function cacheRepresentatives(postalCode, representatives, representData) {
|
||||||
try {
|
try {
|
||||||
@ -41,6 +64,9 @@ class CampaignsController {
|
|||||||
emailCount = await nocoDB.getCampaignEmailCount(id);
|
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
|
// Normalize campaign data structure for frontend
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@ -50,22 +76,42 @@ class CampaignsController {
|
|||||||
email_subject: campaign['Email Subject'] || campaign.email_subject,
|
email_subject: campaign['Email Subject'] || campaign.email_subject,
|
||||||
email_body: campaign['Email Body'] || campaign.email_body,
|
email_body: campaign['Email Body'] || campaign.email_body,
|
||||||
call_to_action: campaign['Call to Action'] || campaign.call_to_action,
|
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_smtp_email: campaign['Allow SMTP Email'] || campaign.allow_smtp_email,
|
||||||
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
|
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
|
||||||
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
||||||
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
||||||
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
|
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,
|
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,
|
||||||
emailCount
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
campaigns: campaignsWithCounts
|
campaigns: filteredCampaigns
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get campaigns error:', error);
|
console.error('Get campaigns error:', error);
|
||||||
@ -111,15 +157,18 @@ class CampaignsController {
|
|||||||
email_subject: campaign['Email Subject'] || campaign.email_subject,
|
email_subject: campaign['Email Subject'] || campaign.email_subject,
|
||||||
email_body: campaign['Email Body'] || campaign.email_body,
|
email_body: campaign['Email Body'] || campaign.email_body,
|
||||||
call_to_action: campaign['Call to Action'] || campaign.call_to_action,
|
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_smtp_email: campaign['Allow SMTP Email'] || campaign.allow_smtp_email,
|
||||||
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
|
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
|
||||||
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
||||||
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
||||||
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
|
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,
|
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,
|
||||||
emailCount
|
emailCount
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -147,7 +196,7 @@ class CampaignsController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const campaignStatus = campaign['Status'] || campaign.status;
|
const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status);
|
||||||
if (campaignStatus !== 'active') {
|
if (campaignStatus !== 'active') {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
success: false,
|
success: false,
|
||||||
@ -180,11 +229,7 @@ class CampaignsController {
|
|||||||
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
||||||
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
||||||
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
|
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
|
||||||
target_government_levels: Array.isArray(campaign['Target Government Levels'] || campaign.target_government_levels)
|
target_government_levels: normalizeTargetLevels(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())
|
|
||||||
: []),
|
|
||||||
emailCount
|
emailCount
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -217,7 +262,10 @@ class CampaignsController {
|
|||||||
target_government_levels = ['Federal', 'Provincial', 'Municipal']
|
target_government_levels = ['Federal', 'Provincial', 'Municipal']
|
||||||
} = req.body;
|
} = 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);
|
let slug = generateSlug(title);
|
||||||
|
|
||||||
// Ensure slug is unique
|
// Ensure slug is unique
|
||||||
@ -235,18 +283,18 @@ class CampaignsController {
|
|||||||
email_subject,
|
email_subject,
|
||||||
email_body,
|
email_body,
|
||||||
call_to_action,
|
call_to_action,
|
||||||
status,
|
status: normalizedStatus,
|
||||||
allow_smtp_email,
|
allow_smtp_email,
|
||||||
allow_mailto_link,
|
allow_mailto_link,
|
||||||
collect_user_info,
|
collect_user_info,
|
||||||
show_email_count,
|
show_email_count,
|
||||||
allow_email_editing,
|
allow_email_editing,
|
||||||
// NocoDB MultiSelect expects an array of values
|
// NocoDB MultiSelect expects an array of values
|
||||||
target_government_levels: Array.isArray(target_government_levels)
|
target_government_levels: normalizeTargetLevels(target_government_levels),
|
||||||
? target_government_levels
|
// Add user ownership data
|
||||||
: (typeof target_government_levels === 'string' && target_government_levels.length > 0
|
created_by_user_id: ownerUserId,
|
||||||
? target_government_levels.split(',').map(s => s.trim())
|
created_by_user_email: ownerEmail,
|
||||||
: [])
|
created_by_user_name: ownerName
|
||||||
};
|
};
|
||||||
|
|
||||||
const campaign = await nocoDB.createCampaign(campaignData);
|
const campaign = await nocoDB.createCampaign(campaignData);
|
||||||
@ -262,15 +310,18 @@ class CampaignsController {
|
|||||||
email_subject: campaign['Email Subject'] || campaign.email_subject,
|
email_subject: campaign['Email Subject'] || campaign.email_subject,
|
||||||
email_body: campaign['Email Body'] || campaign.email_body,
|
email_body: campaign['Email Body'] || campaign.email_body,
|
||||||
call_to_action: campaign['Call to Action'] || campaign.call_to_action,
|
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_smtp_email: campaign['Allow SMTP Email'] || campaign.allow_smtp_email,
|
||||||
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
|
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
|
||||||
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
||||||
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
||||||
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
|
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,
|
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) {
|
} catch (error) {
|
||||||
@ -288,18 +339,45 @@ class CampaignsController {
|
|||||||
async updateCampaign(req, res, next) {
|
async updateCampaign(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
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) {
|
if (updates.title) {
|
||||||
let slug = generateSlug(updates.title);
|
let slug = generateSlug(updates.title);
|
||||||
|
|
||||||
// Ensure slug is unique (but allow current campaign to keep its slug)
|
const campaignWithSlug = await nocoDB.getCampaignBySlug(slug);
|
||||||
const existingCampaign = await nocoDB.getCampaignBySlug(slug);
|
const existingId = campaignWithSlug ? (campaignWithSlug.ID || campaignWithSlug.Id || campaignWithSlug.id) : null;
|
||||||
const existingId = existingCampaign ? (existingCampaign.ID || existingCampaign.Id || existingCampaign.id) : null;
|
if (campaignWithSlug && String(existingId) !== String(id)) {
|
||||||
if (existingCampaign && String(existingId) !== String(id)) {
|
|
||||||
let counter = 1;
|
let counter = 1;
|
||||||
let originalSlug = slug;
|
const originalSlug = slug;
|
||||||
while (await nocoDB.getCampaignBySlug(slug)) {
|
while (await nocoDB.getCampaignBySlug(slug)) {
|
||||||
slug = `${originalSlug}-${counter}`;
|
slug = `${originalSlug}-${counter}`;
|
||||||
counter++;
|
counter++;
|
||||||
@ -308,20 +386,33 @@ class CampaignsController {
|
|||||||
updates.slug = slug;
|
updates.slug = slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure target_government_levels remains an array for MultiSelect
|
if (updates.target_government_levels !== undefined) {
|
||||||
if (updates.target_government_levels) {
|
updates.target_government_levels = normalizeTargetLevels(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())
|
|
||||||
: []);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
const campaign = await nocoDB.updateCampaign(id, updates);
|
||||||
|
|
||||||
// Normalize the updated campaign data
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
campaign: {
|
campaign: {
|
||||||
@ -332,14 +423,18 @@ class CampaignsController {
|
|||||||
email_subject: campaign['Email Subject'] || campaign.email_subject,
|
email_subject: campaign['Email Subject'] || campaign.email_subject,
|
||||||
email_body: campaign['Email Body'] || campaign.email_body,
|
email_body: campaign['Email Body'] || campaign.email_body,
|
||||||
call_to_action: campaign['Call to Action'] || campaign.call_to_action,
|
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_smtp_email: campaign['Allow SMTP Email'] || campaign.allow_smtp_email,
|
||||||
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
|
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
|
||||||
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
||||||
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
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,
|
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) {
|
} catch (error) {
|
||||||
@ -401,9 +496,8 @@ class CampaignsController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status);
|
||||||
|
if (campaignStatus !== 'active') {
|
||||||
if ((campaign['Status'] || campaign.status) !== 'active') {
|
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Campaign is not currently active'
|
error: 'Campaign is not currently active'
|
||||||
@ -515,7 +609,7 @@ class CampaignsController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const campaignStatus = campaign['Status'] || campaign.status;
|
const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status);
|
||||||
if (campaignStatus !== 'active') {
|
if (campaignStatus !== 'active') {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
success: false,
|
success: false,
|
||||||
@ -570,7 +664,7 @@ class CampaignsController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const campaignStatus = campaign['Status'] || campaign.status;
|
const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status);
|
||||||
if (campaignStatus !== 'active') {
|
if (campaignStatus !== 'active') {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
success: false,
|
success: false,
|
||||||
@ -688,6 +782,36 @@ class CampaignsController {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
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);
|
const analytics = await nocoDB.getCampaignAnalytics(id);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
338
influence/app/controllers/usersController.js
Normal file
338
influence/app/controllers/usersController.js
Normal file
@ -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();
|
||||||
@ -1,15 +1,64 @@
|
|||||||
const nocodbService = require('../services/nocodb');
|
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 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);
|
(req.session && req.session.userId && req.session.userEmail);
|
||||||
|
|
||||||
if (isAuthenticated) {
|
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
|
// Set up req.user object for controllers that expect it
|
||||||
req.user = {
|
req.user = {
|
||||||
id: req.session.userId,
|
id: req.session.userId,
|
||||||
email: req.session.userEmail,
|
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();
|
next();
|
||||||
@ -38,11 +87,19 @@ const requireAdmin = async (req, res, next) => {
|
|||||||
(req.session && req.session.userId && req.session.userEmail);
|
(req.session && req.session.userId && req.session.userEmail);
|
||||||
|
|
||||||
if (isAuthenticated && req.session.isAdmin) {
|
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
|
// Set up req.user object for controllers that expect it
|
||||||
req.user = {
|
req.user = {
|
||||||
id: req.session.userId,
|
id: req.session.userId,
|
||||||
email: req.session.userEmail,
|
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();
|
next();
|
||||||
@ -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 = {
|
module.exports = {
|
||||||
requireAuth,
|
requireAuth,
|
||||||
requireAdmin
|
requireAdmin,
|
||||||
|
requireNonTemp
|
||||||
};
|
};
|
||||||
@ -467,6 +467,183 @@
|
|||||||
transform: translateY(-1px);
|
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) {
|
@media (max-width: 768px) {
|
||||||
.admin-nav {
|
.admin-nav {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -479,6 +656,16 @@
|
|||||||
.campaign-actions {
|
.campaign-actions {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-actions {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@ -496,6 +683,7 @@
|
|||||||
<button class="nav-btn active" data-tab="campaigns">Campaigns</button>
|
<button class="nav-btn active" data-tab="campaigns">Campaigns</button>
|
||||||
<button class="nav-btn" data-tab="create">Create Campaign</button>
|
<button class="nav-btn" data-tab="create">Create Campaign</button>
|
||||||
<button class="nav-btn" data-tab="edit">Edit Campaign</button>
|
<button class="nav-btn" data-tab="edit">Edit Campaign</button>
|
||||||
|
<button class="nav-btn" data-tab="users">User Management</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Success/Error Messages -->
|
<!-- Success/Error Messages -->
|
||||||
@ -521,6 +709,19 @@
|
|||||||
<div id="create-tab" class="tab-content">
|
<div id="create-tab" class="tab-content">
|
||||||
<h2>Create New Campaign</h2>
|
<h2>Create New Campaign</h2>
|
||||||
|
|
||||||
|
<!-- Campaign Search Dropdown -->
|
||||||
|
<div class="campaign-selector">
|
||||||
|
<label for="create-campaign-selector">Select existing campaign as template (optional):</label>
|
||||||
|
<div class="search-dropdown">
|
||||||
|
<input type="text" id="create-campaign-selector" placeholder="Search campaigns..." autocomplete="off">
|
||||||
|
<div class="dropdown-arrow">▼</div>
|
||||||
|
<div class="dropdown-menu" id="create-dropdown-menu">
|
||||||
|
<div class="dropdown-item" data-campaign-id="new">Create New Campaign</div>
|
||||||
|
<!-- Campaign options will be populated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form id="create-campaign-form">
|
<form id="create-campaign-form">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@ -628,6 +829,19 @@ Sincerely,
|
|||||||
<div id="edit-tab" class="tab-content">
|
<div id="edit-tab" class="tab-content">
|
||||||
<h2>Edit Campaign</h2>
|
<h2>Edit Campaign</h2>
|
||||||
|
|
||||||
|
<!-- Campaign Search Dropdown -->
|
||||||
|
<div class="campaign-selector">
|
||||||
|
<label for="edit-campaign-selector">Select campaign to edit:</label>
|
||||||
|
<div class="search-dropdown">
|
||||||
|
<input type="text" id="edit-campaign-selector" placeholder="Search campaigns..." autocomplete="off">
|
||||||
|
<div class="dropdown-arrow">▼</div>
|
||||||
|
<div class="dropdown-menu" id="edit-dropdown-menu">
|
||||||
|
<div class="dropdown-item" data-campaign-id="">Select a campaign to edit...</div>
|
||||||
|
<!-- Campaign options will be populated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form id="edit-campaign-form">
|
<form id="edit-campaign-form">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@ -720,6 +934,96 @@ Sincerely,
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- User Management Tab -->
|
||||||
|
<div id="users-tab" class="tab-content">
|
||||||
|
<div class="form-row" style="align-items: center; margin-bottom: 2rem;">
|
||||||
|
<h2 style="margin: 0;">User Management</h2>
|
||||||
|
<button class="btn btn-primary" data-action="create-user">Add New User</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="users-loading" class="loading hidden">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Loading users...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="users-list" class="users-list">
|
||||||
|
<!-- Users will be loaded here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Modal -->
|
||||||
|
<div id="user-modal" class="modal-overlay hidden">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="user-modal-title">Add New User</h3>
|
||||||
|
<button class="modal-close" data-action="close-user-modal">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="user-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="user-email">Email Address *</label>
|
||||||
|
<input type="email" id="user-email" name="email" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="user-password">Password *</label>
|
||||||
|
<input type="password" id="user-password" name="password" required autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="user-name">Full Name</label>
|
||||||
|
<input type="text" id="user-name" name="name">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="user-phone">Phone Number</label>
|
||||||
|
<input type="tel" id="user-phone" name="phone">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="user-type">User Type</label>
|
||||||
|
<select id="user-type" name="userType">
|
||||||
|
<option value="user">Standard User</option>
|
||||||
|
<option value="admin">Administrator</option>
|
||||||
|
<option value="temp">Temporary User</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="temp-user-options" style="display: none;">
|
||||||
|
<label for="user-expire-days">Expiration (Days)</label>
|
||||||
|
<input type="number" id="user-expire-days" name="expireDays" min="1" max="365" value="30">
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="user-admin" name="isAdmin">
|
||||||
|
<label for="user-admin">Grant Administrator Access</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<button type="submit" class="btn btn-primary">Create User</button>
|
||||||
|
<button type="button" class="btn btn-secondary" data-action="close-user-modal">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email Broadcast Modal -->
|
||||||
|
<div id="email-modal" class="modal-overlay hidden">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Send Email to All Users</h3>
|
||||||
|
<button class="modal-close" data-action="close-email-modal">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="email-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email-subject">Subject *</label>
|
||||||
|
<input type="text" id="email-subject" name="subject" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email-content">Message *</label>
|
||||||
|
<textarea id="email-content" name="content" rows="8" required
|
||||||
|
placeholder="Your message to all users..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<button type="submit" class="btn btn-primary">Send Email</button>
|
||||||
|
<button type="button" class="btn btn-secondary" data-action="close-email-modal">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="js/api-client.js"></script>
|
<script src="js/api-client.js"></script>
|
||||||
|
|||||||
851
influence/app/public/dashboard.html
Normal file
851
influence/app/public/dashboard.html
Normal file
@ -0,0 +1,851 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>User Dashboard - BNKops Influence</title>
|
||||||
|
<link rel="icon" href="data:,">
|
||||||
|
<link rel="stylesheet" href="css/styles.css">
|
||||||
|
<style>
|
||||||
|
.dashboard-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header {
|
||||||
|
background: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
padding: 2rem 0;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details p {
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-nav {
|
||||||
|
display: flex;
|
||||||
|
background: #34495e;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover {
|
||||||
|
background: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn.active {
|
||||||
|
background: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campaign-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campaign-card {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campaign-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.campaign-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campaign-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-draft {
|
||||||
|
background: #f39c12;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active {
|
||||||
|
background: #27ae60;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-paused {
|
||||||
|
background: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-archived {
|
||||||
|
background: #95a5a6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campaign-meta {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campaign-meta p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campaign-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #95a5a6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: #27ae60;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 4px solid #f3f3f3;
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top: 4px solid #3498db;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h3 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-info {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 2px solid #e0e6ed;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: border-color 0.3s, box-shadow 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group textarea:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3498db;
|
||||||
|
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 2px solid #e0e6ed;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: border-color 0.3s, box-shadow 0.3s;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
margin: 2rem 0 1rem 0;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid #e0e6ed;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-item input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-item label {
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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) {
|
||||||
|
.dashboard-nav {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campaign-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<h1>My Campaign Dashboard</h1>
|
||||||
|
<p>Manage your influence campaigns</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<!-- User Info -->
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-details">
|
||||||
|
<h3 id="user-name">Loading...</h3>
|
||||||
|
<p id="user-email">Loading...</p>
|
||||||
|
</div>
|
||||||
|
<div class="user-actions">
|
||||||
|
<span id="user-role-badge" class="user-badge">User</span>
|
||||||
|
<button id="logout-btn" class="btn btn-secondary">Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="dashboard-nav">
|
||||||
|
<button class="nav-btn active" data-tab="campaigns">My Campaigns</button>
|
||||||
|
<button class="nav-btn" data-tab="create">Create Campaign</button>
|
||||||
|
<button class="nav-btn" data-tab="edit">Edit Campaign</button>
|
||||||
|
<button class="nav-btn" data-tab="analytics">Analytics</button>
|
||||||
|
<button class="nav-btn" data-tab="account">Account</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Success/Error Messages -->
|
||||||
|
<div id="message-container" class="hidden"></div>
|
||||||
|
|
||||||
|
<!-- My Campaigns Tab -->
|
||||||
|
<div id="campaigns-tab" class="tab-content active">
|
||||||
|
<div style="margin-bottom: 2rem;">
|
||||||
|
<h2 style="margin: 0;">My Campaigns</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="campaigns-loading" class="loading hidden">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Loading your campaigns...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="campaigns-list" class="campaign-grid">
|
||||||
|
<!-- Campaigns will be loaded here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Campaign Tab -->
|
||||||
|
<div id="create-tab" class="tab-content">
|
||||||
|
<h2>Create New Campaign</h2>
|
||||||
|
|
||||||
|
<!-- Campaign Search Dropdown -->
|
||||||
|
<div class="campaign-selector">
|
||||||
|
<label for="create-campaign-selector">Select existing campaign as template (optional):</label>
|
||||||
|
<div class="search-dropdown">
|
||||||
|
<input type="text" id="create-campaign-selector" placeholder="Search campaigns..." autocomplete="off">
|
||||||
|
<div class="dropdown-arrow">▼</div>
|
||||||
|
<div class="dropdown-menu" id="create-dropdown-menu">
|
||||||
|
<div class="dropdown-item" data-campaign-id="new">Create New Campaign</div>
|
||||||
|
<!-- Campaign options will be populated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="create-campaign-form">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="create-title">Campaign Title *</label>
|
||||||
|
<input type="text" id="create-title" name="title" required
|
||||||
|
placeholder="Save Alberta Parks">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group status-select">
|
||||||
|
<label for="create-status">Campaign Status</label>
|
||||||
|
<select id="create-status" name="status">
|
||||||
|
<option value="draft">📝 Draft</option>
|
||||||
|
<option value="active">🚀 Active</option>
|
||||||
|
<option value="paused">⏸️ Paused</option>
|
||||||
|
<option value="archived">📦 Archived</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="create-description">Description</label>
|
||||||
|
<textarea id="create-description" name="description" rows="3"
|
||||||
|
placeholder="A brief description of the campaign"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="create-email-subject">Email Subject *</label>
|
||||||
|
<input type="text" id="create-email-subject" name="email_subject" required
|
||||||
|
placeholder="Protect Alberta's Provincial Parks">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="create-email-body">Email Body *</label>
|
||||||
|
<textarea id="create-email-body" name="email_body" rows="8" required
|
||||||
|
placeholder="Dear [Representative Name],
|
||||||
|
|
||||||
|
I am writing as your constituent to express my concern about...
|
||||||
|
|
||||||
|
Sincerely,
|
||||||
|
[Your Name]"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="create-call-to-action">Call to Action</label>
|
||||||
|
<textarea id="create-call-to-action" name="call_to_action" rows="3"
|
||||||
|
placeholder="Join thousands of Albertans in protecting our provincial parks. Send an email to your representatives today!"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header">⚙️ Campaign Settings</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="create-allow-smtp" name="allow_smtp_email" checked>
|
||||||
|
<label for="create-allow-smtp">📧 Allow SMTP Email Sending</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="create-allow-mailto" name="allow_mailto_link" checked>
|
||||||
|
<label for="create-allow-mailto">🔗 Allow Mailto Links</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="create-collect-info" name="collect_user_info" checked>
|
||||||
|
<label for="create-collect-info">👤 Collect User Information</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="create-show-count" name="show_email_count" checked>
|
||||||
|
<label for="create-show-count">📊 Show Email Count</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="create-allow-editing" name="allow_email_editing">
|
||||||
|
<label for="create-allow-editing">✏️ Allow Email Editing</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header">🏛️ Target Government Levels</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="create-federal" name="target_government_levels" value="Federal" checked>
|
||||||
|
<label for="create-federal">🍁 Federal (MPs)</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="create-provincial" name="target_government_levels" value="Provincial" checked>
|
||||||
|
<label for="create-provincial">🏛️ Provincial (MLAs)</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="create-municipal" name="target_government_levels" value="Municipal" checked>
|
||||||
|
<label for="create-municipal">🏙️ Municipal (Mayors, Councillors)</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="create-school" name="target_government_levels" value="School Board">
|
||||||
|
<label for="create-school">🎓 School Board</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<button type="submit" class="btn btn-primary">Create Campaign</button>
|
||||||
|
<button type="button" class="btn btn-secondary" data-action="cancel-create">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Campaign Tab -->
|
||||||
|
<div id="edit-tab" class="tab-content">
|
||||||
|
<h2>Edit Campaign</h2>
|
||||||
|
|
||||||
|
<!-- Campaign Search Dropdown -->
|
||||||
|
<div class="campaign-selector">
|
||||||
|
<label for="edit-campaign-selector">Select campaign to edit:</label>
|
||||||
|
<div class="search-dropdown">
|
||||||
|
<input type="text" id="edit-campaign-selector" placeholder="Search campaigns..." autocomplete="off">
|
||||||
|
<div class="dropdown-arrow">▼</div>
|
||||||
|
<div class="dropdown-menu" id="edit-dropdown-menu">
|
||||||
|
<div class="dropdown-item" data-campaign-id="">Select a campaign to edit...</div>
|
||||||
|
<!-- Campaign options will be populated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="edit-campaign-form">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-title">Campaign Title *</label>
|
||||||
|
<input type="text" id="edit-title" name="title" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-status">Status</label>
|
||||||
|
<select id="edit-status" name="status">
|
||||||
|
<option value="draft">📝 Draft</option>
|
||||||
|
<option value="active">🚀 Active</option>
|
||||||
|
<option value="paused">⏸️ Paused</option>
|
||||||
|
<option value="archived">📦 Archived</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-description">Description</label>
|
||||||
|
<textarea id="edit-description" name="description" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-email-subject">Email Subject *</label>
|
||||||
|
<input type="text" id="edit-email-subject" name="email_subject" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-email-body">Email Body *</label>
|
||||||
|
<textarea id="edit-email-body" name="email_body" rows="8" required></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-call-to-action">Call to Action</label>
|
||||||
|
<textarea id="edit-call-to-action" name="call_to_action" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header">⚙️ Campaign Settings</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="edit-allow-smtp" name="allow_smtp_email">
|
||||||
|
<label for="edit-allow-smtp">📧 Allow SMTP Email Sending</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="edit-allow-mailto" name="allow_mailto_link">
|
||||||
|
<label for="edit-allow-mailto">🔗 Allow Mailto Links</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="edit-collect-info" name="collect_user_info">
|
||||||
|
<label for="edit-collect-info">👤 Collect User Information</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="edit-show-count" name="show_email_count">
|
||||||
|
<label for="edit-show-count">📊 Show Email Count</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="edit-allow-editing" name="allow_email_editing">
|
||||||
|
<label for="edit-allow-editing">✏️ Allow Email Editing</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header">🏛️ Target Government Levels</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="edit-federal" name="target_government_levels" value="Federal">
|
||||||
|
<label for="edit-federal">🍁 Federal (MPs)</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="edit-provincial" name="target_government_levels" value="Provincial">
|
||||||
|
<label for="edit-provincial">🏛️ Provincial (MLAs)</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="edit-municipal" name="target_government_levels" value="Municipal">
|
||||||
|
<label for="edit-municipal">🏙️ Municipal (Mayors, Councillors)</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-item">
|
||||||
|
<input type="checkbox" id="edit-school" name="target_government_levels" value="School Board">
|
||||||
|
<label for="edit-school">🎓 School Board</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<button type="submit" class="btn btn-primary">Update Campaign</button>
|
||||||
|
<button type="button" class="btn btn-secondary" data-action="cancel-edit">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Analytics Tab -->
|
||||||
|
<div id="analytics-tab" class="tab-content">
|
||||||
|
<h2>Your Campaign Analytics</h2>
|
||||||
|
|
||||||
|
<div id="analytics-loading" class="loading hidden">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Loading analytics...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="analytics-content">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3 id="total-campaigns">0</h3>
|
||||||
|
<p>Total Campaigns</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3 id="active-campaigns">0</h3>
|
||||||
|
<p>Active Campaigns</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3 id="total-emails">0</h3>
|
||||||
|
<p>Emails Sent</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3 id="total-users-reached">0</h3>
|
||||||
|
<p>Users Reached</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Tab -->
|
||||||
|
<div id="account-tab" class="tab-content">
|
||||||
|
<h2>Account Settings</h2>
|
||||||
|
|
||||||
|
<div class="account-info">
|
||||||
|
<form id="account-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="account-name">Full Name</label>
|
||||||
|
<input type="text" id="account-name" name="name" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="account-email">Email Address</label>
|
||||||
|
<input type="email" id="account-email" name="email" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="account-phone">Phone Number</label>
|
||||||
|
<input type="tel" id="account-phone" name="phone" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="account-role">User Role</label>
|
||||||
|
<input type="text" id="account-role" name="role" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="account-expiration" style="display: none;">
|
||||||
|
<label for="account-expires">Account Expires</label>
|
||||||
|
<input type="text" id="account-expires" name="expires" readonly>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style="color: #666; font-size: 0.9rem; margin-top: 1rem;">
|
||||||
|
To update your account information, please contact an administrator.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="js/api-client.js"></script>
|
||||||
|
<script src="js/auth.js"></script>
|
||||||
|
<script src="js/dashboard.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -197,10 +197,29 @@
|
|||||||
crossorigin=""></script>
|
crossorigin=""></script>
|
||||||
|
|
||||||
<script src="js/api-client.js"></script>
|
<script src="js/api-client.js"></script>
|
||||||
|
<script src="js/auth.js"></script>
|
||||||
<script src="js/postal-lookup.js"></script>
|
<script src="js/postal-lookup.js"></script>
|
||||||
<script src="js/representatives-display.js"></script>
|
<script src="js/representatives-display.js"></script>
|
||||||
<script src="js/email-composer.js"></script>
|
<script src="js/email-composer.js"></script>
|
||||||
<script src="js/representatives-map.js"></script>
|
<script src="js/representatives-map.js"></script>
|
||||||
<script src="js/main.js"></script>
|
<script src="js/main.js"></script>
|
||||||
|
|
||||||
|
<!-- Check authentication and redirect if logged in -->
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
// Check if user is already authenticated
|
||||||
|
if (typeof authManager !== 'undefined') {
|
||||||
|
const isAuth = await authManager.checkSession();
|
||||||
|
if (isAuth && authManager.user) {
|
||||||
|
// Redirect to appropriate dashboard
|
||||||
|
if (authManager.user.isAdmin) {
|
||||||
|
window.location.href = '/admin.html';
|
||||||
|
} else {
|
||||||
|
window.location.href = '/dashboard.html';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -3,6 +3,7 @@ class AdminPanel {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.currentCampaign = null;
|
this.currentCampaign = null;
|
||||||
this.campaigns = [];
|
this.campaigns = [];
|
||||||
|
this.users = [];
|
||||||
this.authManager = null;
|
this.authManager = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,9 +48,6 @@ class AdminPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupEventListeners() {
|
|
||||||
}
|
|
||||||
|
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
// Tab navigation
|
// Tab navigation
|
||||||
document.querySelectorAll('.nav-btn').forEach(btn => {
|
document.querySelectorAll('.nav-btn').forEach(btn => {
|
||||||
@ -68,6 +66,14 @@ class AdminPanel {
|
|||||||
this.handleUpdateCampaign(e);
|
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
|
// Cancel buttons - using event delegation for proper handling
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (e.target.matches('[data-action="cancel-create"]')) {
|
if (e.target.matches('[data-action="cancel-create"]')) {
|
||||||
@ -76,8 +82,33 @@ class AdminPanel {
|
|||||||
if (e.target.matches('[data-action="cancel-edit"]')) {
|
if (e.target.matches('[data-action="cancel-edit"]')) {
|
||||||
this.switchTab('campaigns');
|
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() {
|
setupFormInteractions() {
|
||||||
@ -111,6 +142,230 @@ class AdminPanel {
|
|||||||
this.handleSettingsChange(checkbox);
|
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 = '<div class="dropdown-item" data-campaign-id="new">Create New Campaign</div>';
|
||||||
|
} else {
|
||||||
|
dropdown.innerHTML = '<div class="dropdown-item" data-campaign-id="">Select a campaign to edit...</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
switchTab(tabName) {
|
||||||
@ -139,9 +394,37 @@ class AdminPanel {
|
|||||||
// Special handling for different tabs
|
// Special handling for different tabs
|
||||||
if (tabName === 'campaigns') {
|
if (tabName === 'campaigns') {
|
||||||
this.loadCampaigns();
|
this.loadCampaigns();
|
||||||
} else if (tabName === 'edit' && this.currentCampaign) {
|
} 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();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadCampaigns() {
|
async loadCampaigns() {
|
||||||
@ -157,6 +440,7 @@ class AdminPanel {
|
|||||||
if (response.success) {
|
if (response.success) {
|
||||||
this.campaigns = response.campaigns;
|
this.campaigns = response.campaigns;
|
||||||
this.renderCampaignList();
|
this.renderCampaignList();
|
||||||
|
this.refreshDropdowns(); // Refresh dropdowns when campaigns are loaded
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.error || 'Failed to load campaigns');
|
throw new Error(response.error || 'Failed to load campaigns');
|
||||||
}
|
}
|
||||||
@ -192,6 +476,8 @@ class AdminPanel {
|
|||||||
<p><strong>Slug:</strong> <code>/campaign/${campaign.slug}</code></p>
|
<p><strong>Slug:</strong> <code>/campaign/${campaign.slug}</code></p>
|
||||||
<p><strong>Email Count:</strong> ${campaign.emailCount || 0}</p>
|
<p><strong>Email Count:</strong> ${campaign.emailCount || 0}</p>
|
||||||
<p><strong>Created:</strong> ${this.formatDate(campaign.created_at)}</p>
|
<p><strong>Created:</strong> ${this.formatDate(campaign.created_at)}</p>
|
||||||
|
${campaign.created_by_user_name || campaign.created_by_user_email ?
|
||||||
|
`<p><strong>Created By:</strong> ${this.escapeHtml(campaign.created_by_user_name || campaign.created_by_user_email)}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="campaign-actions">
|
<div class="campaign-actions">
|
||||||
@ -306,8 +592,14 @@ class AdminPanel {
|
|||||||
form.querySelector('[name="allow_email_editing"]').checked = campaign.allow_email_editing;
|
form.querySelector('[name="allow_email_editing"]').checked = campaign.allow_email_editing;
|
||||||
|
|
||||||
// Government levels
|
// Government levels
|
||||||
const targetLevels = campaign.target_government_levels ?
|
let targetLevels = [];
|
||||||
campaign.target_government_levels.split(',').map(l => l.trim()) : [];
|
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 => {
|
form.querySelectorAll('[name="target_government_levels"]').forEach(checkbox => {
|
||||||
checkbox.checked = targetLevels.includes(checkbox.value);
|
checkbox.checked = targetLevels.includes(checkbox.value);
|
||||||
@ -504,6 +796,216 @@ class AdminPanel {
|
|||||||
return dateString;
|
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 = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>No users yet</h3>
|
||||||
|
<p>Create your first user to get started.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add email all users button at the top
|
||||||
|
listDiv.innerHTML = `
|
||||||
|
<div style="margin-bottom: 2rem; text-align: center;">
|
||||||
|
<button class="btn btn-secondary" data-action="email-all-users">📧 Email All Users</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 `
|
||||||
|
<div class="user-card" data-user-id="${user.Id || user.id}">
|
||||||
|
<div class="user-header">
|
||||||
|
<div class="user-info">
|
||||||
|
<h4>${this.escapeHtml(user.Name || user.name || 'No Name')}</h4>
|
||||||
|
<p>${this.escapeHtml(user.Email || user.email)}</p>
|
||||||
|
${user.Phone || user.phone ? `<p>📞 ${this.escapeHtml(user.Phone || user.phone)}</p>` : ''}
|
||||||
|
${user.ExpiresAt ? `<p>⏰ Expires: ${this.formatDate(user.ExpiresAt)}</p>` : ''}
|
||||||
|
${user['Last Login'] ? `<p>🕒 Last Login: ${this.formatDate(user['Last Login'])}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="user-badges">
|
||||||
|
<span class="user-badge ${userTypeClass}">
|
||||||
|
${isExpired ? 'EXPIRED' : (user.Admin || user.admin ? 'ADMIN' : userTypeClass.toUpperCase())}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="user-actions">
|
||||||
|
<button class="btn btn-secondary btn-small" data-action="send-login-details" data-user-id="${user.Id || user.id}">
|
||||||
|
📧 Send Login Details
|
||||||
|
</button>
|
||||||
|
${user.Id !== this.authManager?.user?.id ? `
|
||||||
|
<button class="btn btn-danger btn-small" data-action="delete-user" data-user-id="${user.Id || user.id}">
|
||||||
|
🗑️ Delete
|
||||||
|
</button>
|
||||||
|
` : '<span class="btn btn-secondary btn-small" style="opacity: 0.5;">Current User</span>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).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
|
// Initialize admin panel when DOM is loaded
|
||||||
|
|||||||
@ -120,6 +120,19 @@ 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
|
// Set up event listeners for auth-related actions
|
||||||
setupAuthListeners() {
|
setupAuthListeners() {
|
||||||
// Global logout button
|
// Global logout button
|
||||||
|
|||||||
1057
influence/app/public/js/dashboard.js
Normal file
1057
influence/app/public/js/dashboard.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -30,8 +30,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
// Redirect to admin panel
|
// Redirect based on user role
|
||||||
|
if (response.user && response.user.isAdmin) {
|
||||||
window.location.href = '/admin.html';
|
window.location.href = '/admin.html';
|
||||||
|
} else {
|
||||||
|
window.location.href = '/dashboard.html';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showError(response.error || 'Login failed');
|
showError(response.error || 'Login failed');
|
||||||
}
|
}
|
||||||
@ -46,9 +50,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
async function checkSession() {
|
async function checkSession() {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/auth/session');
|
const response = await apiClient.get('/auth/session');
|
||||||
if (response.authenticated) {
|
if (response.authenticated && response.user) {
|
||||||
// Already logged in, redirect to admin
|
// Already logged in, redirect based on user role
|
||||||
|
if (response.user.isAdmin) {
|
||||||
window.location.href = '/admin.html';
|
window.location.href = '/admin.html';
|
||||||
|
} else {
|
||||||
|
window.location.href = '/dashboard.html';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Not logged in, continue with login form
|
// Not logged in, continue with login form
|
||||||
|
|||||||
@ -147,8 +147,8 @@
|
|||||||
<div class="login-container">
|
<div class="login-container">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="login-header">
|
<div class="login-header">
|
||||||
<h1>Admin Login</h1>
|
<h1>Login</h1>
|
||||||
<p>Access the campaign management panel</p>
|
<p>Access your campaign dashboard</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="error-message" class="error-message" style="display: none;"></div>
|
<div id="error-message" class="error-message" style="display: none;"></div>
|
||||||
|
|||||||
@ -5,7 +5,10 @@ const representativesController = require('../controllers/representatives');
|
|||||||
const emailsController = require('../controllers/emails');
|
const emailsController = require('../controllers/emails');
|
||||||
const campaignsController = require('../controllers/campaigns');
|
const campaignsController = require('../controllers/campaigns');
|
||||||
const rateLimiter = require('../utils/rate-limiter');
|
const rateLimiter = require('../utils/rate-limiter');
|
||||||
const { requireAdmin } = require('../middleware/auth');
|
const { requireAdmin, requireAuth, requireNonTemp } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// Import user routes
|
||||||
|
const userRoutes = require('./users');
|
||||||
|
|
||||||
// Validation middleware
|
// Validation middleware
|
||||||
const handleValidationErrors = (req, res, next) => {
|
const handleValidationErrors = (req, res, next) => {
|
||||||
@ -111,6 +114,28 @@ router.put('/admin/campaigns/:id', requireAdmin, rateLimiter.general, campaignsC
|
|||||||
router.delete('/admin/campaigns/:id', requireAdmin, rateLimiter.general, campaignsController.deleteCampaign);
|
router.delete('/admin/campaigns/:id', requireAdmin, rateLimiter.general, campaignsController.deleteCampaign);
|
||||||
router.get('/admin/campaigns/:id/analytics', requireAdmin, rateLimiter.general, campaignsController.getCampaignAnalytics);
|
router.get('/admin/campaigns/:id/analytics', requireAdmin, rateLimiter.general, campaignsController.getCampaignAnalytics);
|
||||||
|
|
||||||
|
// Campaign endpoints (Authenticated users)
|
||||||
|
router.get('/campaigns', requireAuth, rateLimiter.general, campaignsController.getAllCampaigns);
|
||||||
|
router.post(
|
||||||
|
'/campaigns',
|
||||||
|
requireNonTemp,
|
||||||
|
rateLimiter.general,
|
||||||
|
[
|
||||||
|
body('title').notEmpty().withMessage('Campaign title is required'),
|
||||||
|
body('email_subject').notEmpty().withMessage('Email subject is required'),
|
||||||
|
body('email_body').notEmpty().withMessage('Email body is required')
|
||||||
|
],
|
||||||
|
handleValidationErrors,
|
||||||
|
campaignsController.createCampaign
|
||||||
|
);
|
||||||
|
router.put(
|
||||||
|
'/campaigns/:id',
|
||||||
|
requireNonTemp,
|
||||||
|
rateLimiter.general,
|
||||||
|
campaignsController.updateCampaign
|
||||||
|
);
|
||||||
|
router.get('/campaigns/:id/analytics', requireAuth, rateLimiter.general, campaignsController.getCampaignAnalytics);
|
||||||
|
|
||||||
// Campaign endpoints (Public)
|
// Campaign endpoints (Public)
|
||||||
router.get('/campaigns/:slug', rateLimiter.general, campaignsController.getCampaignBySlug);
|
router.get('/campaigns/:slug', rateLimiter.general, campaignsController.getCampaignBySlug);
|
||||||
router.get('/campaigns/:slug/representatives/:postalCode', rateLimiter.representAPI, campaignsController.getRepresentativesForCampaign);
|
router.get('/campaigns/:slug/representatives/:postalCode', rateLimiter.representAPI, campaignsController.getRepresentativesForCampaign);
|
||||||
@ -136,4 +161,7 @@ router.post(
|
|||||||
campaignsController.sendCampaignEmail
|
campaignsController.sendCampaignEmail
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// User management routes (admin only)
|
||||||
|
router.use('/admin/users', userRoutes);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
13
influence/app/routes/dashboard.js
Normal file
13
influence/app/routes/dashboard.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { requireAuth } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// All dashboard routes require authentication
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
// Serve the dashboard page
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
res.sendFile('dashboard.html', { root: './app/public' });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
24
influence/app/routes/users.js
Normal file
24
influence/app/routes/users.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const usersController = require('../controllers/usersController');
|
||||||
|
const { requireAdmin } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// All user routes require admin access
|
||||||
|
router.use(requireAdmin);
|
||||||
|
|
||||||
|
// Get all users
|
||||||
|
router.get('/', usersController.getAll);
|
||||||
|
|
||||||
|
// Create new user
|
||||||
|
router.post('/', usersController.create);
|
||||||
|
|
||||||
|
// Send login details to user
|
||||||
|
router.post('/:id/send-login-details', usersController.sendLoginDetails);
|
||||||
|
|
||||||
|
// Email all users
|
||||||
|
router.post('/email-all', usersController.emailAllUsers);
|
||||||
|
|
||||||
|
// Delete user
|
||||||
|
router.delete('/:id', usersController.delete);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@ -7,7 +7,7 @@ require('dotenv').config();
|
|||||||
|
|
||||||
const apiRoutes = require('./routes/api');
|
const apiRoutes = require('./routes/api');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const { requireAdmin } = require('./middleware/auth');
|
const { requireAdmin, requireAuth } = require('./middleware/auth');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3333;
|
const PORT = process.env.PORT || 3333;
|
||||||
@ -72,6 +72,15 @@ app.get('/admin', requireAdmin, (req, res) => {
|
|||||||
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
|
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Serve user dashboard (protected)
|
||||||
|
app.get('/dashboard.html', requireAuth, (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/dashboard', requireAuth, (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
|
||||||
|
});
|
||||||
|
|
||||||
// Serve campaign landing pages
|
// Serve campaign landing pages
|
||||||
app.get('/campaign/:slug', (req, res) => {
|
app.get('/campaign/:slug', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, 'public', 'campaign.html'));
|
res.sendFile(path.join(__dirname, 'public', 'campaign.html'));
|
||||||
|
|||||||
@ -26,28 +26,7 @@ class EmailService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
this.transporter = nodemailer.createTransporter(transporterConfig);
|
this.transporter = nodemailer.createTransport(transporterConfig);
|
||||||
|
|
||||||
console.log('Email transporter initialized successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to initialize email transporter:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeTransporter() {
|
|
||||||
try {
|
|
||||||
this.transporter = nodemailer.createTransport({
|
|
||||||
host: process.env.SMTP_HOST,
|
|
||||||
port: parseInt(process.env.SMTP_PORT) || 587,
|
|
||||||
secure: process.env.SMTP_PORT === '465', // true for 465, false for other ports
|
|
||||||
auth: {
|
|
||||||
user: process.env.SMTP_USER,
|
|
||||||
pass: process.env.SMTP_PASS
|
|
||||||
},
|
|
||||||
tls: {
|
|
||||||
rejectUnauthorized: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Email transporter initialized successfully');
|
console.log('Email transporter initialized successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -352,6 +331,62 @@ class EmailService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User management email methods
|
||||||
|
async sendLoginDetails(user) {
|
||||||
|
try {
|
||||||
|
const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3333}`;
|
||||||
|
|
||||||
|
const isAdmin = user.admin || user.Admin || false;
|
||||||
|
|
||||||
|
const templateVariables = {
|
||||||
|
APP_NAME: 'BNKops Influence',
|
||||||
|
USER_NAME: user.Name || user.name || user.Email || user.email,
|
||||||
|
USER_EMAIL: user.Email || user.email,
|
||||||
|
PASSWORD: user.Password || user.password,
|
||||||
|
USER_ROLE: isAdmin ? 'Administrator' : 'User',
|
||||||
|
LOGIN_URL: `${baseUrl}/login.html`,
|
||||||
|
TIMESTAMP: new Date().toLocaleString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const emailOptions = {
|
||||||
|
to: user.Email || user.email,
|
||||||
|
from: {
|
||||||
|
email: process.env.SMTP_FROM_EMAIL,
|
||||||
|
name: process.env.SMTP_FROM_NAME
|
||||||
|
},
|
||||||
|
subject: `Your Login Details - ${templateVariables.APP_NAME}`
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.sendTemplatedEmail('login-details', templateVariables, emailOptions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send login details email:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendEmail(emailOptions) {
|
||||||
|
try {
|
||||||
|
if (!this.transporter) {
|
||||||
|
throw new Error('Email transporter not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailOptions = {
|
||||||
|
from: `${process.env.SMTP_FROM_NAME || 'BNKops Influence'} <${process.env.SMTP_FROM_EMAIL || 'noreply@example.com'}>`,
|
||||||
|
to: emailOptions.to,
|
||||||
|
subject: emailOptions.subject,
|
||||||
|
text: emailOptions.text,
|
||||||
|
html: emailOptions.html
|
||||||
|
};
|
||||||
|
|
||||||
|
const info = await this.transporter.sendMail(mailOptions);
|
||||||
|
console.log(`Email sent: ${info.messageId}`);
|
||||||
|
return info;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send email:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new EmailService();
|
module.exports = new EmailService();
|
||||||
@ -430,7 +430,10 @@ class NocoDBService {
|
|||||||
'Collect User Info': campaignData.collect_user_info,
|
'Collect User Info': campaignData.collect_user_info,
|
||||||
'Show Email Count': campaignData.show_email_count,
|
'Show Email Count': campaignData.show_email_count,
|
||||||
'Allow Email Editing': campaignData.allow_email_editing,
|
'Allow Email Editing': campaignData.allow_email_editing,
|
||||||
'Target Government Levels': campaignData.target_government_levels
|
'Target Government Levels': campaignData.target_government_levels,
|
||||||
|
'Created By User ID': campaignData.created_by_user_id,
|
||||||
|
'Created By User Email': campaignData.created_by_user_email,
|
||||||
|
'Created By User Name': campaignData.created_by_user_name
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await this.create(this.tableIds.campaigns, mappedData);
|
const response = await this.create(this.tableIds.campaigns, mappedData);
|
||||||
@ -605,12 +608,17 @@ class NocoDBService {
|
|||||||
throw new Error('Users table not configured');
|
throw new Error('Users table not configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const response = await this.getAll(this.tableIds.users, {
|
const response = await this.getAll(this.tableIds.users, {
|
||||||
where: `(Email,eq,${email})`,
|
where: `(Email,eq,${email})`,
|
||||||
limit: 1
|
limit: 1
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.list?.[0] || null;
|
return response.list?.[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getUserByEmail:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createUser(userData) {
|
async createUser(userData) {
|
||||||
@ -634,7 +642,20 @@ class NocoDBService {
|
|||||||
throw new Error('Users table not configured');
|
throw new Error('Users table not configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.delete(this.tableIds.users, userId);
|
const url = `${this.getTableUrl(this.tableIds.users)}/${userId}`;
|
||||||
|
const response = await this.client.delete(url);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(tableId, recordId) {
|
||||||
|
try {
|
||||||
|
const url = `${this.getTableUrl(tableId)}/${recordId}`;
|
||||||
|
const response = await this.client.get(url);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting record by ID:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllUsers(params = {}) {
|
async getAllUsers(params = {}) {
|
||||||
|
|||||||
116
influence/app/templates/email/login-details.html
Normal file
116
influence/app/templates/email/login-details.html
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Your Login Details</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 30px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
color: #d73027;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
background-color: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.credentials-box {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
.credential-item {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.credential-label {
|
||||||
|
font-weight: bold;
|
||||||
|
display: inline-block;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
.credential-value {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
.login-button {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #d73027;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
color: #3498db;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">{{APP_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>Your Login Details</h2>
|
||||||
|
<p>Hello {{USER_NAME}},</p>
|
||||||
|
<p>Here are your login credentials for {{APP_NAME}}:</p>
|
||||||
|
|
||||||
|
<div class="credentials-box">
|
||||||
|
<div class="credential-item">
|
||||||
|
<span class="credential-label">Email:</span>
|
||||||
|
<span class="credential-value">{{USER_EMAIL}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="credential-item">
|
||||||
|
<span class="credential-label">Password:</span>
|
||||||
|
<span class="credential-value">{{PASSWORD}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="credential-item">
|
||||||
|
<span class="credential-label">Role:</span>
|
||||||
|
<span class="credential-value">{{USER_ROLE}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>You can log in using the link below:</p>
|
||||||
|
<p style="text-align: center;">
|
||||||
|
<a href="{{LOGIN_URL}}" class="login-button">Login to {{APP_NAME}}</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="info">💡 For security reasons, we recommend changing your password after your first login.</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>This email was sent from {{APP_NAME}} at {{TIMESTAMP}}</p>
|
||||||
|
<p>If you have any questions, please contact your administrator.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
influence/app/templates/email/login-details.txt
Normal file
17
influence/app/templates/email/login-details.txt
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
Login Details - {{APP_NAME}}
|
||||||
|
|
||||||
|
Hello {{USER_NAME}},
|
||||||
|
|
||||||
|
Here are your login credentials for {{APP_NAME}}:
|
||||||
|
|
||||||
|
Email: {{USER_EMAIL}}
|
||||||
|
Password: {{PASSWORD}}
|
||||||
|
Role: {{USER_ROLE}}
|
||||||
|
|
||||||
|
You can log in at: {{LOGIN_URL}}
|
||||||
|
|
||||||
|
For security reasons, we recommend changing your password after your first login.
|
||||||
|
|
||||||
|
---
|
||||||
|
This email was sent from {{APP_NAME}} at {{TIMESTAMP}}
|
||||||
|
If you have any questions, please contact your administrator.
|
||||||
109
influence/app/templates/email/user-broadcast.html
Normal file
109
influence/app/templates/email/user-broadcast.html
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{{EMAIL_SUBJECT}}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 30px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
color: #d73027;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
background-color: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.message-content {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.message-content h1,
|
||||||
|
.message-content h2,
|
||||||
|
.message-content h3 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-top: 25px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.message-content h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
border-bottom: 2px solid #e9ecef;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
.message-content h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.message-content h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.message-content ul,
|
||||||
|
.message-content ol {
|
||||||
|
margin: 15px 0;
|
||||||
|
padding-left: 25px;
|
||||||
|
}
|
||||||
|
.message-content li {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.message-content a {
|
||||||
|
color: #d73027;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.message-content a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.message-content strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
.message-content blockquote {
|
||||||
|
border-left: 4px solid #d73027;
|
||||||
|
margin: 20px 0;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">{{APP_NAME}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Hello {{USER_NAME}},</p>
|
||||||
|
|
||||||
|
<div class="message-content">
|
||||||
|
{{EMAIL_CONTENT}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>This email was sent from {{APP_NAME}} at {{TIMESTAMP}}</p>
|
||||||
|
<p>{{SENDER_NAME}} - System Administrator</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9
influence/app/templates/email/user-broadcast.txt
Normal file
9
influence/app/templates/email/user-broadcast.txt
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{{EMAIL_SUBJECT}}
|
||||||
|
|
||||||
|
Hello {{USER_NAME}},
|
||||||
|
|
||||||
|
{{EMAIL_CONTENT_TEXT}}
|
||||||
|
|
||||||
|
---
|
||||||
|
This email was sent from {{APP_NAME}} at {{TIMESTAMP}}
|
||||||
|
{{SENDER_NAME}} - System Administrator
|
||||||
87
influence/app/utils/helpers.js
Normal file
87
influence/app/utils/helpers.js
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
// Extract ID from NocoDB response
|
||||||
|
function extractId(record) {
|
||||||
|
return record.Id || record.id || record.ID || record._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize user data for response
|
||||||
|
function sanitizeUser(user) {
|
||||||
|
const { Password, password, ...safeUser } = user;
|
||||||
|
return safeUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
function validateEmail(email) {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URL format
|
||||||
|
function validateUrl(url) {
|
||||||
|
if (!url || typeof url !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic URL validation
|
||||||
|
try {
|
||||||
|
new URL(trimmed);
|
||||||
|
return trimmed;
|
||||||
|
} catch (e) {
|
||||||
|
// If not a valid URL, check if it's a relative path or missing protocol
|
||||||
|
if (trimmed.startsWith('/') || !trimmed.includes('://')) {
|
||||||
|
// For relative paths or missing protocol, return as-is
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a random password
|
||||||
|
function generatePassword(length = 12) {
|
||||||
|
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
|
||||||
|
let password = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
password += charset.charAt(Math.floor(Math.random() * charset.length));
|
||||||
|
}
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean HTML for plain text
|
||||||
|
function stripHtmlTags(html) {
|
||||||
|
return html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date for display
|
||||||
|
function formatDate(date) {
|
||||||
|
if (!date) return '';
|
||||||
|
return new Date(date).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is expired (for temp users)
|
||||||
|
function isUserExpired(user) {
|
||||||
|
const userType = user['User Type'] || user.UserType || user.userType || 'user';
|
||||||
|
if (userType !== 'temp') return false;
|
||||||
|
|
||||||
|
const expiration = user.ExpiresAt || user.expiresAt || user.Expiration || user.expiration;
|
||||||
|
if (!expiration) return false;
|
||||||
|
|
||||||
|
const expirationDate = new Date(expiration);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
return now > expirationDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
extractId,
|
||||||
|
sanitizeUser,
|
||||||
|
validateEmail,
|
||||||
|
validateUrl,
|
||||||
|
generatePassword,
|
||||||
|
stripHtmlTags,
|
||||||
|
formatDate,
|
||||||
|
isUserExpired
|
||||||
|
};
|
||||||
@ -1066,6 +1066,24 @@ create_campaigns_table() {
|
|||||||
{"title": "School Board", "color": "#ffeab6"}
|
{"title": "School Board", "color": "#ffeab6"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "created_by_user_id",
|
||||||
|
"title": "Created By User ID",
|
||||||
|
"uidt": "Number",
|
||||||
|
"rqd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "created_by_user_email",
|
||||||
|
"title": "Created By User Email",
|
||||||
|
"uidt": "Email",
|
||||||
|
"rqd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "created_by_user_name",
|
||||||
|
"title": "Created By User Name",
|
||||||
|
"uidt": "SingleLineText",
|
||||||
|
"rqd": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}'
|
}'
|
||||||
@ -1221,7 +1239,7 @@ create_users_table() {
|
|||||||
"column_name": "name",
|
"column_name": "name",
|
||||||
"title": "Name",
|
"title": "Name",
|
||||||
"uidt": "SingleLineText",
|
"uidt": "SingleLineText",
|
||||||
"rqd": true
|
"rqd": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"column_name": "password",
|
"column_name": "password",
|
||||||
@ -1229,12 +1247,43 @@ create_users_table() {
|
|||||||
"uidt": "SingleLineText",
|
"uidt": "SingleLineText",
|
||||||
"rqd": true
|
"rqd": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"column_name": "phone",
|
||||||
|
"title": "Phone",
|
||||||
|
"uidt": "SingleLineText",
|
||||||
|
"rqd": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"column_name": "admin",
|
"column_name": "admin",
|
||||||
"title": "Admin",
|
"title": "Admin",
|
||||||
"uidt": "Checkbox",
|
"uidt": "Checkbox",
|
||||||
"cdf": "false"
|
"cdf": "false"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"column_name": "user_type",
|
||||||
|
"title": "User Type",
|
||||||
|
"uidt": "SingleSelect",
|
||||||
|
"cdf": "user",
|
||||||
|
"colOptions": {
|
||||||
|
"options": [
|
||||||
|
{"title": "user", "color": "#3498db"},
|
||||||
|
{"title": "admin", "color": "#e74c3c"},
|
||||||
|
{"title": "temp", "color": "#f39c12"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "expires_at",
|
||||||
|
"title": "ExpiresAt",
|
||||||
|
"uidt": "DateTime",
|
||||||
|
"rqd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "expire_days",
|
||||||
|
"title": "ExpireDays",
|
||||||
|
"uidt": "Number",
|
||||||
|
"rqd": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"column_name": "last_login",
|
"column_name": "last_login",
|
||||||
"title": "Last Login",
|
"title": "Last Login",
|
||||||
|
|||||||
137
map/README.md
137
map/README.md
@ -43,7 +43,11 @@
|
|||||||
- <20>🔐 Role-based access control (Admin vs User permissions)
|
- <20>🔐 Role-based access control (Admin vs User permissions)
|
||||||
- ⏰ Temporary user accounts with automatic expiration
|
- ⏰ Temporary user accounts with automatic expiration
|
||||||
- 📧 Email notifications and password recovery via SMTP
|
- 📧 Email notifications and password recovery via SMTP
|
||||||
- 📊 CSV data import with batch geocoding, visual progress tracking, and downloadable error reports
|
- 📊 **Enhanced CSV data import** with multi-provider geocoding, confidence scoring, and comprehensive error reporting
|
||||||
|
- 🌍 **Multi-provider geocoding system** - Mapbox (premium), Nominatim, Photon, LocationIQ, and ArcGIS with automatic fallback
|
||||||
|
- 🎯 **Geocoding confidence scoring** - Quality assessment and validation warnings for all geocoded addresses
|
||||||
|
- 🔍 **Database scan and geocode** - Admin tool to scan existing records and geocode missing location data
|
||||||
|
- 📈 **Comprehensive geocoding reports** - Downloadable CSV reports with success/failure analysis and recommendations
|
||||||
- ✂️ **Cut feature for geographic overlays** - Admin-drawn polygons for map regions
|
- ✂️ **Cut feature for geographic overlays** - Admin-drawn polygons for map regions
|
||||||
- 🗺️ Interactive polygon drawing with click-to-add-points system
|
- 🗺️ Interactive polygon drawing with click-to-add-points system
|
||||||
- 🎨 Customizable cut properties (color, opacity, category, visibility)
|
- 🎨 Customizable cut properties (color, opacity, category, visibility)
|
||||||
@ -69,8 +73,54 @@
|
|||||||
|
|
||||||
2. **Configure Environment**
|
2. **Configure Environment**
|
||||||
|
|
||||||
Edit the `.env` file with your NocoDB API and API Url:
|
Create a `.env` file in the `map/` directory with your configuration:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
|
# Core NocoDB Configuration
|
||||||
|
NOCODB_API_URL=https://your-nocodb-instance.com/api/v1
|
||||||
|
NOCODB_API_TOKEN=your-api-token-here
|
||||||
|
NOCODB_VIEW_URL=https://your-nocodb-instance.com/dashboard/#/nc/project-id/table-id
|
||||||
|
|
||||||
|
# Additional NocoDB Sheet URLs
|
||||||
|
NOCODB_LOGIN_SHEET=https://your-nocodb-instance.com/dashboard/#/nc/project-id/login-table-id
|
||||||
|
NOCODB_SETTINGS_SHEET=https://your-nocodb-instance.com/dashboard/#/nc/project-id/settings-table-id
|
||||||
|
NOCODB_SHIFTS_SHEET=https://your-nocodb-instance.com/dashboard/#/nc/project-id/shifts-table-id
|
||||||
|
NOCODB_SHIFT_SIGNUPS_SHEET=https://your-nocodb-instance.com/dashboard/#/nc/project-id/signups-table-id
|
||||||
|
NOCODB_CUTS_SHEET=https://your-nocodb-instance.com/dashboard/#/nc/project-id/cuts-table-id
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
DOMAIN=your-domain.com
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=production
|
||||||
|
SESSION_SECRET=your-secure-session-secret-here
|
||||||
|
|
||||||
|
# Map Defaults (adjust for your region)
|
||||||
|
DEFAULT_LAT=53.5461
|
||||||
|
DEFAULT_LNG=-113.4938
|
||||||
|
DEFAULT_ZOOM=11
|
||||||
|
|
||||||
|
# Enhanced Geocoding Configuration (Optional but Recommended)
|
||||||
|
# Mapbox API Key for premium geocoding with highest accuracy
|
||||||
|
MAPBOX_ACCESS_TOKEN=pk.your-mapbox-token-here
|
||||||
|
|
||||||
|
# LocationIQ API Key for additional premium geocoding option
|
||||||
|
# LOCATIONIQ_API_KEY=your-locationiq-key-here
|
||||||
|
|
||||||
|
# SMTP Email Configuration
|
||||||
|
SMTP_HOST=your-smtp-host.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=your-email@domain.com
|
||||||
|
SMTP_PASS=your-email-password
|
||||||
|
EMAIL_FROM_NAME="Your App Name"
|
||||||
|
EMAIL_FROM_ADDRESS=your-email@domain.com
|
||||||
|
|
||||||
|
# Optional: Listmonk Integration for Email Marketing
|
||||||
|
LISTMONK_API_URL=http://your-listmonk-instance:9000/api
|
||||||
|
LISTMONK_USERNAME=admin
|
||||||
|
LISTMONK_PASSWORD=your-listmonk-password
|
||||||
|
LISTMONK_SYNC_ENABLED=true
|
||||||
|
```
|
||||||
# NocoDB API Configuration
|
# NocoDB API Configuration
|
||||||
NOCODB_API_URL=https://db.cmlite.org/api/v1
|
NOCODB_API_URL=https://db.cmlite.org/api/v1
|
||||||
NOCODB_API_TOKEN=your-api-token-here
|
NOCODB_API_TOKEN=your-api-token-here
|
||||||
@ -331,6 +381,89 @@ The build script automatically creates the following table structure:
|
|||||||
- `created_at` (DateTime): Creation timestamp
|
- `created_at` (DateTime): Creation timestamp
|
||||||
- `updated_at` (DateTime): Last update timestamp
|
- `updated_at` (DateTime): Last update timestamp
|
||||||
|
|
||||||
|
## Enhanced Geocoding Features
|
||||||
|
|
||||||
|
The application includes a robust, multi-provider geocoding system designed for high accuracy and reliability in address processing.
|
||||||
|
|
||||||
|
### 🌍 Multi-Provider Geocoding System
|
||||||
|
|
||||||
|
- **Premium Providers**: Mapbox and LocationIQ for highest accuracy
|
||||||
|
- **Free Providers**: Nominatim (OpenStreetMap), Photon, and ArcGIS
|
||||||
|
- **Automatic Fallback**: If premium providers fail, automatically tries free alternatives
|
||||||
|
- **Provider Selection**: Intelligent provider selection based on availability and API keys
|
||||||
|
- **Rate Limiting**: Built-in delays to respect API rate limits
|
||||||
|
|
||||||
|
### 🎯 Geocoding Quality Assessment
|
||||||
|
|
||||||
|
- **Confidence Scoring**: Every geocoded address receives a confidence score (0-100%)
|
||||||
|
- **Address Validation**: Detects potentially malformed or problematic addresses
|
||||||
|
- **Quality Warnings**: Identifies addresses that may need manual review
|
||||||
|
- **Provider-Specific Scoring**: Different confidence algorithms per provider
|
||||||
|
- **Combined Confidence**: Unified scoring system across all providers
|
||||||
|
|
||||||
|
### 📊 CSV Data Import with Enhanced Processing
|
||||||
|
|
||||||
|
- **Batch Geocoding**: Process hundreds of addresses with real-time progress tracking
|
||||||
|
- **Unit Number Support**: Automatically handles apartment/unit numbers in addresses
|
||||||
|
- **Visual Progress**: Live progress bars and status updates during processing
|
||||||
|
- **Error Handling**: Comprehensive error tracking and reporting
|
||||||
|
- **Retry Logic**: Automatic retry for failed addresses with different providers
|
||||||
|
- **Downloadable Reports**: Detailed CSV reports with all results and recommendations
|
||||||
|
|
||||||
|
### 🔍 Database Scan and Geocode
|
||||||
|
|
||||||
|
Admin-only feature to scan existing database records and geocode missing location data:
|
||||||
|
|
||||||
|
- **Smart Scanning**: Identifies records missing geo-location data
|
||||||
|
- **Batch Processing**: Processes all missing records with progress tracking
|
||||||
|
- **Real-time Updates**: Updates database records directly with coordinates
|
||||||
|
- **Confidence Tracking**: Saves confidence scores and provider information
|
||||||
|
- **Comprehensive Reports**: Generates detailed reports of all geocoding activities
|
||||||
|
- **Safety Features**: Rate limiting and error handling to protect APIs
|
||||||
|
|
||||||
|
### 📈 Geocoding Reports and Analytics
|
||||||
|
|
||||||
|
- **Success Rate Tracking**: Monitor geocoding success rates over time
|
||||||
|
- **Provider Performance**: Compare performance across different geocoding providers
|
||||||
|
- **Quality Analysis**: Identify patterns in low-confidence or failed addresses
|
||||||
|
- **CSV Export**: Export all geocoding results for external analysis
|
||||||
|
- **Recommendations**: Automated suggestions for improving address quality
|
||||||
|
- **Error Categorization**: Detailed breakdown of geocoding failures
|
||||||
|
|
||||||
|
### ⚙️ Geocoding Configuration
|
||||||
|
|
||||||
|
Add these optional environment variables for enhanced geocoding:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Premium Geocoding Providers (Recommended)
|
||||||
|
MAPBOX_ACCESS_TOKEN=pk.your-mapbox-token-here
|
||||||
|
LOCATIONIQ_API_KEY=your-locationiq-key-here
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits of Premium Providers:**
|
||||||
|
- Higher accuracy for Canadian addresses
|
||||||
|
- Better handling of complex addresses
|
||||||
|
- More reliable service uptime
|
||||||
|
- Enhanced address normalization
|
||||||
|
- Detailed confidence scoring
|
||||||
|
|
||||||
|
**Free Provider Fallback:**
|
||||||
|
- Automatic fallback if premium providers fail
|
||||||
|
- No additional cost for basic geocoding needs
|
||||||
|
- Suitable for non-critical applications
|
||||||
|
- OpenStreetMap-based accuracy
|
||||||
|
|
||||||
|
### 🛠️ Admin Geocoding Tools
|
||||||
|
|
||||||
|
Administrators have access to powerful geocoding management tools:
|
||||||
|
|
||||||
|
- **Provider Status Dashboard**: View availability of all geocoding providers
|
||||||
|
- **Batch Operations**: Process multiple addresses simultaneously
|
||||||
|
- **Quality Control**: Review and approve low-confidence geocoding results
|
||||||
|
- **Database Maintenance**: Scan and update existing records with missing coordinates
|
||||||
|
- **Performance Monitoring**: Track geocoding success rates and provider performance
|
||||||
|
- **Cost Management**: Monitor API usage for premium providers
|
||||||
|
|
||||||
## Email Features
|
## Email Features
|
||||||
|
|
||||||
The system includes comprehensive email functionality powered by SMTP configuration:
|
The system includes comprehensive email functionality powered by SMTP configuration:
|
||||||
|
|||||||
@ -83,7 +83,7 @@ Controller for handling public-facing shift signup functionality. Manages public
|
|||||||
|
|
||||||
# app/controllers/dataConvertController.js
|
# app/controllers/dataConvertController.js
|
||||||
|
|
||||||
Controller for handling CSV upload and batch geocoding of addresses. Parses CSV files, validates address data, uses the geocoding service to get coordinates, and provides real-time progress updates via Server-Sent Events (SSE). Enhanced with comprehensive error logging and downloadable processing reports that include both successful and failed geocoding attempts for review and debugging.
|
Controller for handling CSV upload and batch geocoding of addresses with advanced multi-provider support. Features include CSV parsing with unit number detection, multi-provider geocoding with automatic fallback (Mapbox, LocationIQ, Nominatim, Photon, ArcGIS), confidence scoring and address validation, real-time progress tracking via Server-Sent Events (SSE), and comprehensive downloadable CSV reports. Also includes admin database scan-and-geocode functionality to identify and geocode existing records missing location data, with direct database updates and detailed reporting.
|
||||||
|
|
||||||
# app/controllers/dashboardController.js
|
# app/controllers/dashboardController.js
|
||||||
|
|
||||||
@ -135,7 +135,7 @@ Service for loading and rendering email templates with variable substitution. Ha
|
|||||||
|
|
||||||
# app/services/geocoding.js
|
# app/services/geocoding.js
|
||||||
|
|
||||||
Service for geocoding and reverse geocoding using external APIs, with caching. Updated to include forwardGeocodeSearch function for returning multiple address search results for the unified search feature.
|
Comprehensive geocoding service supporting multiple providers with intelligent fallback and quality assessment. Features premium providers (Mapbox, LocationIQ) and free alternatives (Nominatim, Photon, ArcGIS), automatic provider selection based on API key availability, confidence scoring and address validation for all providers, retry logic with exponential backoff, result normalization across different provider APIs, and caching for improved performance. Includes forwardGeocodeSearch for address search functionality and specialized Canadian address handling with unit number support.
|
||||||
|
|
||||||
# app/services/listmonk.js
|
# app/services/listmonk.js
|
||||||
|
|
||||||
@ -223,7 +223,9 @@ Utility for spatial operations including point-in-polygon calculations and geogr
|
|||||||
|
|
||||||
# app/public/admin.html
|
# app/public/admin.html
|
||||||
|
|
||||||
Admin panel HTML page for managing start location, walk sheet, shift management, user management, and email broadcasting. Features rich text editor with live preview for composing broadcast emails, shift volunteer management modals, comprehensive admin interface with user role controls, and quick access links to both NocoDB database management and Listmonk email marketing interfaces.
|
# app/public/admin.html
|
||||||
|
|
||||||
|
Comprehensive admin panel HTML page for managing start location, walk sheet, shift management, user management, email broadcasting, and enhanced geocoding operations. Features rich text editor with live preview for composing broadcast emails, shift volunteer management modals, CSV data import with multi-provider geocoding and real-time progress tracking, database scan-and-geocode functionality to update existing records, geocoding provider status dashboard, comprehensive admin interface with user role controls, and quick access links to both NocoDB database management and Listmonk email marketing interfaces.
|
||||||
|
|
||||||
# app/public/css/admin.css
|
# app/public/css/admin.css
|
||||||
|
|
||||||
@ -445,6 +447,10 @@ Documentation summarizing the CSS refactoring process that reorganized the admin
|
|||||||
|
|
||||||
**Email Broadcasting Module** - Handles mass email functionality including rich text editor with toolbar, email composition with live preview, progress tracking for bulk email operations, HTML email template management, and broadcast email status monitoring with detailed results display.
|
**Email Broadcasting Module** - Handles mass email functionality including rich text editor with toolbar, email composition with live preview, progress tracking for bulk email operations, HTML email template management, and broadcast email status monitoring with detailed results display.
|
||||||
|
|
||||||
|
# app/public/js/data-convert.js
|
||||||
|
|
||||||
|
**Data Import and Geocoding Module** - JavaScript module for CSV data import and database geocoding operations in the admin panel. Handles file upload with drag-and-drop support, real-time processing progress via Server-Sent Events, multi-provider geocoding status monitoring, interactive results preview with success/warning/error indicators, database scan-and-geocode functionality for existing records, comprehensive report generation and download (CSV format), geocoding provider availability checking, and responsive UI updates during batch processing operations. Includes error handling, progress tracking, and detailed status reporting for all geocoding activities.
|
||||||
|
|
||||||
# app/public/js/admin-integration.js
|
# app/public/js/admin-integration.js
|
||||||
|
|
||||||
**External Integration Module** - Manages external service integrations including NocoDB database link initialization and management, Listmonk email service link configuration, admin-only integration controls, and dynamic link setup based on service availability.
|
**External Integration Module** - Manages external service integrations including NocoDB database link initialization and management, Listmonk email service link configuration, admin-only integration controls, and dynamic link setup based on service availability.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user