Security hardening: env-based config, CSRF protection, secure code generation, plus presenter management and email communications system

- Move all credentials from hardcoded to .env with python-dotenv (add .env.example template and .gitignore)
- Add CSRF token generation and validation on all state-changing requests
- Replace random.choices with secrets.choice for all secure code generation
- Add session cookie security headers (HttpOnly, Secure, SameSite)
- Add presenter management: assign/remove presenters to breakout sessions, presenter QR scanning
- Add email communications system: templates per event, custom email sending, sent email tracking
- Add staff/organizer profile pages with staff code display
- New DB tables: event_email_templates, sent_emails, user_communications
- New DB columns: staff.reminder_count/reminder_dates, breakout_session_organizers.presenter_code
- Expand English translation strings across all new features

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 20:34:48 +00:00
parent 934848b7f5
commit a3546b4c01
45 changed files with 8296 additions and 3207 deletions
+60 -138
View File
@@ -2,61 +2,30 @@
{% block title %}{{ 'profile'|t }} - NetEvents{% endblock %}
{% block extra_styles %}
<style>
.profile-page {
max-width: 900px;
margin: 0 auto;
padding: 50px 40px;
}
.profile-container {
display: flex;
gap: 30px;
align-items: flex-start;
}
.photo-box {
background: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 10px;
padding: 20px;
text-align: center;
}
.photo-box h3 {
margin-bottom: 15px;
color: #2c3e50;
}
.profile-photo-section {
display: flex;
flex-direction: column;
align-items: center;
}
</style>
{% endblock %}
{% block content %}
<div class="profile-page">
<h1>{{ 'my_profile'|t }}</h1>
<div class="profile-container">
<div class="photo-box">
<div class="profile-photo-box">
<h3>{{ 'profile_photo'|t }}</h3>
<div class="profile-photo-section">
<div class="current-photo" id="photo-container">
{% if attendee.profile_picture %}
<img src="{{ url_for('static', filename='uploads/' + attendee.profile_picture) }}" alt="{{ 'profile_photo'|t }}" class="profile-img" id="profile-preview">
{% else %}
<img src="" alt="{{ 'profile_preview'|t }}" class="profile-img" id="profile-preview" style="display: none;">
<div class="no-photo" id="no-photo">{{ 'no_photo'|t }}</div>
<img src="" alt="{{ 'profile_preview'|t }}" class="profile-img d-none" id="profile-preview">
<div class="no-photo" id="no-photo"><i data-lucide="user"></i></div>
{% endif %}
</div>
<canvas id="crop-canvas" style="display: none;"></canvas>
<canvas id="crop-canvas" class="d-none"></canvas>
<div class="zoom-controls">
<button type="button" class="btn btn-sm btn-outline" onclick="adjustZoom(-0.25)">-</button>
<button type="button" class="btn btn-sm btn-outline" onclick="adjustZoom(-0.25)"><i data-lucide="minus"></i></button>
<span id="zoom-level">100%</span>
<button type="button" class="btn btn-sm btn-outline" onclick="adjustZoom(0.25)">+</button>
<button type="button" class="btn btn-sm btn-outline" onclick="handleFormSubmit()">{{ 'save'|t }}</button>
<button type="button" class="btn btn-sm btn-outline" onclick="adjustZoom(0.25)"><i data-lucide="plus"></i></button>
<button type="button" class="btn btn-sm btn-primary" onclick="handleFormSubmit()">{{ 'save'|t }}</button>
</div>
<div class="form-group">
@@ -67,14 +36,21 @@
</div>
<form method="POST" action="{{ url_for('attendee_profile') }}" class="profile-form">
<div class="form-group">
<label for="first_name">{{ 'first_name'|t }}</label>
<input type="text" id="first_name" name="first_name" value="{{ attendee.first_name }}" required>
<div class="form-row">
<div class="form-group">
<label for="first_name">{{ 'first_name'|t }}</label>
<input type="text" id="first_name" name="first_name" value="{{ attendee.first_name }}" required>
</div>
<div class="form-group">
<label for="last_name">{{ 'last_name'|t }}</label>
<input type="text" id="last_name" name="last_name" value="{{ attendee.last_name }}" required>
</div>
</div>
<div class="form-group">
<label for="last_name">{{ 'last_name'|t }}</label>
<input type="text" id="last_name" name="last_name" value="{{ attendee.last_name }}" required>
<label for="email">{{ 'email'|t }}</label>
<input type="email" id="email" name="email" value="{{ attendee.email }}" required>
</div>
<div class="form-group">
@@ -87,14 +63,16 @@
<input type="text" id="role" name="role" value="{{ attendee.role or '' }}">
</div>
<div class="form-group">
<label for="phone">{{ 'phone'|t }}</label>
<input type="tel" id="phone" name="phone" value="{{ attendee.phone or '' }}">
</div>
<div class="form-row">
<div class="form-group">
<label for="phone">{{ 'phone'|t }}</label>
<input type="tel" id="phone" name="phone" value="{{ attendee.phone or '' }}">
</div>
<div class="form-group">
<label for="linkedin">{{ 'linkedin'|t }}</label>
<input type="url" id="linkedin" name="linkedin" value="{{ attendee.linkedin or '' }}">
<div class="form-group">
<label for="linkedin">{{ 'linkedin'|t }}</label>
<input type="url" id="linkedin" name="linkedin" value="{{ attendee.linkedin or '' }}">
</div>
</div>
<div class="form-group">
@@ -102,14 +80,35 @@
<textarea id="introduction" name="introduction" rows="4">{{ attendee.introduction or '' }}</textarea>
</div>
<div class="form-group">
<label for="preferred_language">{{ 'language'|t }}</label>
<select id="preferred_language" name="preferred_language">
{% for lang in ['en', 'nl', 'de', 'fr', 'es', 'it', 'pl'] %}
<option value="{{ lang }}" {% if attendee.preferred_language == lang %}selected{% endif %}>{{ lang }}</option>
{% endfor %}
</select>
</div>
<div class="form-group switch-group">
<label for="visible_to_others">{{ 'visible_to_attendees'|t }}</label>
<label class="switch">
<label class="toggle">
<input type="checkbox" id="visible_to_others" name="visible_to_others" value="1" {% if attendee.visible_to_others %}checked{% endif %}>
<span class="slider"></span>
<span class="toggle-slider"></span>
</label>
</div>
<div class="form-row">
<div class="form-group">
<label>{{ 'attendee_code'|t }}</label>
<input type="text" value="{{ attendee.attendee_code or '' }}" readonly class="input-readonly">
</div>
<div class="form-group">
<label>{{ 'member_since'|t }}</label>
<input type="text" value="{{ attendee.created_at|localized_date if attendee.created_at else '' }}" readonly class="input-readonly">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'update_profile'|t }}</button>
</div>
@@ -117,87 +116,6 @@
</div>
</div>
<style>
.switch-group {
display: flex;
align-items: center;
gap: 1rem;
}
.switch-group label {
margin-bottom: 0;
}
.switch {
position: relative;
display: inline-block;
width: 50px;
height: 26px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.3s;
border-radius: 26px;
}
.slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #2ecc71;
}
input:checked + .slider:before {
transform: translateX(24px);
}
.current-photo {
width: 150px;
height: 150px;
margin-bottom: 10px;
overflow: hidden;
border-radius: 0;
position: relative;
background: #e0e0e0;
}
.profile-img {
position: absolute;
border-radius: 0;
cursor: move;
}
.no-photo, #no-photo {
width: 150px;
height: 150px;
border-radius: 50%;
background: #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
color: #888;
font-size: 14px;
}
.zoom-controls {
display: flex;
gap: 5px;
align-items: center;
margin-bottom: 10px;
}
</style>
<script>
var zoomLevel = 1.0;
var translateX = 0;
@@ -334,7 +252,7 @@ function handleFormSubmit(e) {
if (e) e.preventDefault();
if (!originalImage) {
alert('Please select an image first');
showToast('Please select an image first', 'warning');
return;
}
@@ -393,16 +311,20 @@ function handleFormSubmit(e) {
return response.json();
}).then(function(data) {
if (data.success) {
location.reload();
showToast('Profile photo updated!', 'success');
setTimeout(function() { location.reload(); }, 1000);
} else {
alert(data.error || 'Upload failed');
showToast(data.error || 'Upload failed', 'error');
}
}).catch(function(error) {
alert('Error uploading photo');
showToast('Error uploading photo', 'error');
});
}, 'image/jpeg', 0.9);
}
document.addEventListener('DOMContentLoaded', init);
document.addEventListener('DOMContentLoaded', function() {
init();
lucide.createIcons();
});
</script>
{% endblock %}