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:
@@ -0,0 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'communications'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="communications-page">
|
||||
<div class="page-header">
|
||||
<h1>{{ 'my_messages'|t }}</h1>
|
||||
</div>
|
||||
|
||||
{% if emails %}
|
||||
<div class="communications-list">
|
||||
{% for email in emails %}
|
||||
<div class="communication-card">
|
||||
<div class="communication-header">
|
||||
<span class="communication-event">{{ email.event_name }}</span>
|
||||
<span class="communication-date">{{ email.sent_at|localized_date }}</span>
|
||||
</div>
|
||||
<h3>{{ email.subject }}</h3>
|
||||
<p class="communication-body">{{ email.body }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="section-box">
|
||||
<p class="text-muted text-center">{{ 'no_messages_yet'|t }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -10,8 +10,13 @@
|
||||
</div>
|
||||
|
||||
<div class="staff-actions">
|
||||
<a href="{{ url_for('event_scan', event_id=event.id) }}" class="btn btn-primary btn-lg">📷 {{ 'start_qr_scanner'|t }}</a>
|
||||
<a href="{{ url_for('event_scan', event_id=event.id) }}" class="btn btn-primary btn-lg"><i data-lucide="scan-line"></i> {{ 'start_qr_scanner'|t }}</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'my_profile'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="profile-page">
|
||||
<h1>{{ 'my_profile'|t }}</h1>
|
||||
|
||||
<div class="profile-container">
|
||||
<form method="POST" class="profile-form">
|
||||
<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="{{ staff.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="{{ staff.last_name }}" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">{{ 'email'|t }}</label>
|
||||
<input type="email" id="email" name="email" value="{{ staff.email }}" required>
|
||||
</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 staff.preferred_language == lang %}selected{% endif %}>{{ lang }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>{{ 'staff_code'|t }}</label>
|
||||
<input type="text" value="{{ staff.staff_code or '' }}" readonly class="input-readonly">
|
||||
<small class="text-muted">{{ 'staff_code_readonly'|t }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ 'event'|t }}</label>
|
||||
<input type="text" value="{{ event_name or '' }}" readonly class="input-readonly">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>{{ 'member_since'|t }}</label>
|
||||
<input type="text" value="{{ staff.created_at|localized_date if staff.created_at else '' }}" readonly class="input-readonly">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ 'reminders_sent'|t }}</label>
|
||||
<input type="text" value="{{ staff.reminder_count or '0' }}" readonly class="input-readonly">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'update_profile'|t }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+23
-163
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,78 +9,33 @@
|
||||
<p>{{ 'welcome'|t }}, {{ staff_name }}</p>
|
||||
</div>
|
||||
|
||||
<div class="section-box">
|
||||
<section class="section-box card">
|
||||
<h2>{{ 'select_event'|t }}</h2>
|
||||
|
||||
{% if events %}
|
||||
<div class="events-list">
|
||||
{% for event in events %}
|
||||
<div class="event-card">
|
||||
<div class="event-card card-static">
|
||||
<div class="event-info">
|
||||
<h3>{{ event.name }}</h3>
|
||||
<p><strong>{{ 'location'|t }}:</strong> {{ event.location }}</p>
|
||||
<p><strong>{{ 'start'|t }}:</strong> {{ event.start_time|localized_date if event.start_time else 'TBD' }}</p>
|
||||
</div>
|
||||
<div class="event-actions">
|
||||
<a href="{{ url_for('staff_event_dashboard', event_id=event.id) }}" class="btn btn-primary btn-lg">{{ 'start_qr_scanner'|t }}</a>
|
||||
<a href="{{ url_for('staff_event_dashboard', event_id=event.id) }}" class="btn btn-primary btn-lg"><i data-lucide="scan-line"></i> {{ 'start_qr_scanner'|t }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-events">{{ 'no_events_assigned'|t }}</p>
|
||||
<p class="no-events"><i data-lucide="calendar"></i> {{ 'no_events_assigned'|t }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.staff-events-dashboard {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.staff-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.staff-header h1 {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.events-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.event-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.event-info h3 {
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.event-info p {
|
||||
margin: 5px 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.event-actions {
|
||||
flex-shrink: 0;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.no-events {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 40px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user