Whole new user interface and user system

This commit is contained in:
admin 2025-09-30 15:47:57 -06:00
parent 9aaefd149e
commit dfe244f821
26 changed files with 4125 additions and 134 deletions

View File

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

View File

@ -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,8 +196,8 @@ 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,
error: 'Campaign is not currently active' error: 'Campaign is not currently active'
@ -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) {
@ -393,7 +488,7 @@ class CampaignsController {
} = req.body; } = req.body;
// Get campaign // Get campaign
const campaign = await nocoDB.getCampaignBySlug(slug); const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) { if (!campaign) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
@ -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'
@ -425,14 +519,14 @@ class CampaignsController {
}); });
} }
// Use custom email content if provided (when email editing is enabled), otherwise use campaign defaults // Use custom email content if provided (when email editing is enabled), otherwise use campaign defaults
const allowEmailEditing = campaign['Allow Email Editing'] || campaign.allow_email_editing; const allowEmailEditing = campaign['Allow Email Editing'] || campaign.allow_email_editing;
const subject = (allowEmailEditing && customEmailSubject) const subject = (allowEmailEditing && customEmailSubject)
? customEmailSubject ? customEmailSubject
: (campaign['Email Subject'] || campaign.email_subject); : (campaign['Email Subject'] || campaign.email_subject);
const message = (allowEmailEditing && customEmailBody) const message = (allowEmailEditing && customEmailBody)
? customEmailBody ? customEmailBody
: (campaign['Email Body'] || campaign.email_body); : (campaign['Email Body'] || campaign.email_body);
let emailResult = { success: true }; let emailResult = { success: true };
@ -515,7 +609,7 @@ class CampaignsController {
}); });
} }
const campaignStatus = campaign['Status'] || campaign.status; const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status);
if (campaignStatus !== 'active') { 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({

View 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();

View File

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

View File

@ -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">&times;</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">&times;</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>

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

View File

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

View File

@ -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,8 +394,36 @@ 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') {
this.populateEditForm(); // Ensure campaigns are loaded for template selection
if (!this.campaigns || this.campaigns.length === 0) {
this.loadCampaigns();
}
} else if (tabName === 'edit') {
// Ensure campaigns are loaded for editing
if (!this.campaigns || this.campaigns.length === 0) {
this.loadCampaigns();
}
if (this.currentCampaign) {
this.populateEditForm();
}
} else if (tabName === 'users') {
this.loadUsers();
}
// Refresh dropdowns when switching to create or edit tabs
if (tabName === 'create' || tabName === 'edit') {
setTimeout(() => {
const createDropdown = document.getElementById('create-dropdown-menu');
const editDropdown = document.getElementById('edit-dropdown-menu');
if (tabName === 'create' && createDropdown) {
this.populateDropdown(createDropdown, 'create');
}
if (tabName === 'edit' && editDropdown) {
this.populateDropdown(editDropdown, 'edit');
}
}, 100);
} }
} }
@ -157,6 +440,7 @@ class AdminPanel {
if (response.success) { 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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -30,8 +30,12 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
if (response.success) { if (response.success) {
// Redirect to admin panel // Redirect based on user role
window.location.href = '/admin.html'; if (response.user && response.user.isAdmin) {
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
window.location.href = '/admin.html'; if (response.user.isAdmin) {
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

View File

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

View File

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

View 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;

View 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;

View File

@ -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'));

View File

@ -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();

View File

@ -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');
} }
const response = await this.getAll(this.tableIds.users, { try {
where: `(Email,eq,${email})`, const response = await this.getAll(this.tableIds.users, {
limit: 1 where: `(Email,eq,${email})`,
}); 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 = {}) {

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

View 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.

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

View 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

View 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
};

View File

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

View File

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

View File

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