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
+23 -163
View File
@@ -3,11 +3,11 @@
{% block title %}{{ 'scan_qr'|t }} - {{ event.name }}{% endblock %}
{% block content %}
<div class="scan-page">
<div class="scan-page staff-scan-page">
<div class="scan-header">
<h1>{{ event.name }}</h1>
<p>{{ 'qr_scanner_checkin'|t }}</p>
<a href="{{ url_for('staff_event_dashboard', event_id=event.id) }}" class="btn btn-outline">{{ 'back_to_dashboard'|t }}</a>
<a href="{{ url_for('staff_event_dashboard', event_id=event.id) }}" class="btn btn-outline"><i data-lucide="arrow-left"></i> {{ 'back_to_dashboard'|t }}</a>
</div>
<div class="scanner-container">
@@ -16,7 +16,7 @@
<div class="result-icon" id="result-icon"></div>
<h2 id="result-title"></h2>
<p id="result-message"></p>
<button id="scan-again" class="btn btn-primary">{{ 'scan_again'|t }}</button>
<button id="scan-again" class="btn btn-primary"><i data-lucide="scan-line"></i> {{ 'scan_again'|t }}</button>
</div>
</div>
@@ -38,163 +38,11 @@
<div class="recent-checkins">
<h3>{{ 'recent_checkins'|t }}</h3>
<ul id="recent-list">
<li class="empty-state">{{ 'no_checkins_yet'|t }}</li>
<li class="empty-state"><i data-lucide="users"></i> {{ 'no_checkins_yet'|t }}</li>
</ul>
</div>
</div>
<style>
.scan-page {
max-width: 600px;
margin: 0 auto;
}
.scan-header {
text-align: center;
margin-bottom: 20px;
}
.scan-header h1 {
margin-bottom: 5px;
}
.scan-header p {
color: #666;
margin-bottom: 15px;
}
.scanner-container {
background: #000;
border-radius: 12px;
overflow: hidden;
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.qr-reader {
width: 100%;
}
.qr-reader video {
width: 100% !important;
border-radius: 12px;
}
.scan-result {
text-align: center;
padding: 40px 20px;
background: #fff;
width: 100%;
}
.scan-result.hidden {
display: none;
}
.result-icon {
font-size: 64px;
margin-bottom: 10px;
}
.result-icon.success::before {
content: '✓';
color: #22c55e;
}
.result-icon.error::before {
content: '✗';
color: #ef4444;
}
.result-icon.duplicate::before {
content: '⏳';
color: #f59e0b;
}
#result-title {
margin: 0 0 10px 0;
}
#result-message {
color: #666;
margin-bottom: 20px;
}
.scan-stats {
display: flex;
justify-content: space-around;
padding: 20px;
background: #f8fafc;
border-radius: 12px;
margin: 20px 0;
}
.stat {
text-align: center;
}
.stat-number {
display: block;
font-size: 32px;
font-weight: bold;
color: #1e293b;
}
.stat-label {
font-size: 14px;
color: #64748b;
}
.recent-checkins {
background: #fff;
border-radius: 12px;
padding: 20px;
border: 1px solid #e2e8f0;
}
.recent-checkins h3 {
margin: 0 0 15px 0;
font-size: 16px;
}
.recent-checkins ul {
list-style: none;
padding: 0;
margin: 0;
max-height: 200px;
overflow-y: auto;
}
.recent-checkins li {
padding: 10px;
border-bottom: 1px solid #f1f5f9;
display: flex;
justify-content: space-between;
}
.recent-checkins li:last-child {
border-bottom: none;
}
.recent-checkins li.empty-state {
color: #94a3b8;
text-align: center;
padding: 20px;
}
.recent-checkins .attendee-name {
font-weight: 500;
}
.recent-checkins .checkin-time {
color: #22c55e;
font-size: 14px;
}
</style>
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
<script>
const eventId = {{ event.id }};
@@ -223,12 +71,14 @@ function addRecentCheckin(name) {
const li = document.createElement('li');
const time = new Date().toLocaleTimeString();
li.innerHTML = `<span class="attendee-name">${name}</span><span class="checkin-time">${time}</span>`;
li.innerHTML = `<span class="attendee-name">${name}</span><span class="checkin-time"><i data-lucide="check"></i> ${time}</span>`;
list.insertBefore(li, list.firstChild);
while (list.children.length > 10) {
list.removeChild(list.lastChild);
}
lucide.createIcons();
}
async function checkInAttendee(attendeeId) {
@@ -244,6 +94,7 @@ async function checkInAttendee(attendeeId) {
const message = document.getElementById('result-message');
icon.className = 'result-icon';
icon.innerHTML = '';
if (data.success) {
document.getElementById('qr-reader').style.display = 'none';
@@ -251,10 +102,12 @@ async function checkInAttendee(attendeeId) {
if (data.already_checked_in) {
icon.classList.add('duplicate');
icon.innerHTML = '<i data-lucide="clock"></i>';
title.textContent = '{{ "already_checked_in"|t }}';
message.textContent = `${data.attendee_name} {{ "was_already_checked_in"|t }}`;
} else {
icon.classList.add('success');
icon.innerHTML = '<i data-lucide="check-circle"></i>';
title.textContent = '{{ "checked_in"|t }}!';
message.textContent = `{{ "success_checked_in"|t }} ${data.attendee_name}`;
addRecentCheckin(data.attendee_name);
@@ -262,14 +115,16 @@ async function checkInAttendee(attendeeId) {
}
} else {
icon.classList.add('error');
icon.innerHTML = '<i data-lucide="x-circle"></i>';
title.textContent = '{{ "error"|t }}';
message.textContent = data.error || '{{ "failed_checkin"|t }}';
}
lucide.createIcons();
scanning = false;
} catch (error) {
console.error('Check-in error:', error);
alert('{{ "error_checking_in"|t }}');
showToast('{{ "error_checking_in"|t }}', 'error');
}
}
@@ -292,7 +147,7 @@ function onScanSuccess(decodedText) {
if (qrEventId === eventId) {
checkInAttendee(attendeeId);
} else {
alert('{{ "qr_different_event"|t }}');
showToast('{{ "qr_different_event"|t }}', 'error');
}
} else {
const match = decodedText.match(/\/organizer\/event\/(\d+)\/checkin\/(\d+)/);
@@ -302,10 +157,10 @@ function onScanSuccess(decodedText) {
if (qrEventId === eventId) {
checkInAttendee(attendeeId);
} else {
alert('{{ "qr_different_event"|t }}');
showToast('{{ "qr_different_event"|t }}', 'error');
}
} else {
console.log('Unknown QR format:', decodedText);
// QR format not recognized
}
}
}
@@ -327,6 +182,7 @@ async function checkInByCode(attendeeCode) {
const message = document.getElementById('result-message');
icon.className = 'result-icon';
icon.innerHTML = '';
if (data.success) {
document.getElementById('qr-reader').style.display = 'none';
@@ -334,10 +190,12 @@ async function checkInByCode(attendeeCode) {
if (data.already_checked_in) {
icon.classList.add('duplicate');
icon.innerHTML = '<i data-lucide="clock"></i>';
title.textContent = '{{ "already_checked_in"|t }}';
message.textContent = `${data.attendee_name} {{ "was_already_checked_in"|t }}`;
} else {
icon.classList.add('success');
icon.innerHTML = '<i data-lucide="check-circle"></i>';
title.textContent = '{{ "checked_in"|t }}!';
message.textContent = `{{ "success_checked_in"|t }} ${data.attendee_name}`;
addRecentCheckin(data.attendee_name);
@@ -345,14 +203,16 @@ async function checkInByCode(attendeeCode) {
}
} else {
icon.classList.add('error');
icon.innerHTML = '<i data-lucide="x-circle"></i>';
title.textContent = '{{ "error"|t }}';
message.textContent = data.error || '{{ "failed_checkin"|t }}';
}
lucide.createIcons();
scanning = false;
} catch (error) {
console.error('Check-in error:', error);
alert('{{ "error_checking_in"|t }}');
showToast('{{ "error_checking_in"|t }}', 'error');
}
}
@@ -370,7 +230,7 @@ function startScanner() {
}
).catch(err => {
console.error('Camera error:', err);
alert('{{ "camera_permission_error"|t }}');
showToast('{{ "camera_permission_error"|t }}', 'error');
});
}