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:
@@ -4,31 +4,78 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="presenter-dashboard">
|
||||
<h1>{{ 'presenter_dashboard'|t }}</h1>
|
||||
<p>{{ 'welcome'|t }}, {{ presenter_name }}</p>
|
||||
<div class="dashboard-header">
|
||||
<div class="welcome-section">
|
||||
<h1>{{ 'presenter_dashboard'|t }}</h1>
|
||||
<p class="welcome-subtitle">{{ 'welcome'|t }}, <strong>{{ presenter_name }}</strong></p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a href="{{ url_for('logout') }}" class="btn btn-ghost">
|
||||
<i data-lucide="log-out"></i> {{ 'logout'|t }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="my-sessions">
|
||||
<h2>{{ 'my_breakout_sessions'|t }}</h2>
|
||||
<div class="section-header">
|
||||
<h2>{{ 'my_breakout_sessions'|t }}</h2>
|
||||
</div>
|
||||
|
||||
{% if sessions %}
|
||||
<div class="sessions-list">
|
||||
{% for session in sessions %}
|
||||
<div class="session-card">
|
||||
<h3>{{ session.name }}</h3>
|
||||
<p><strong>{{ 'event'|t }}:</strong> {{ session.event_name }}</p>
|
||||
<p><strong>{{ 'time'|t }}:</strong> {{ session.start_time|localized_date if session.start_time else 'TBD' }}</p>
|
||||
<p><strong>{{ 'location'|t }}:</strong> {{ session.location }}</p>
|
||||
<p><strong>{{ 'registered'|t }}:</strong> {{ session.rsvp_count }}{% if session.max_attendees %} / {{ session.max_attendees }}{% endif %}</p>
|
||||
<a href="{{ url_for('view_breakout_session', code=session.code) }}" class="btn btn-outline">{{ 'view_details'|t }}</a>
|
||||
<div class="sessions-grid">
|
||||
{% for session in sessions %}
|
||||
<div class="session-card-lg">
|
||||
<div class="session-card-header">
|
||||
<h3>{{ session.name }}</h3>
|
||||
<span class="badge badge-info">{{ session.rsvp_count }}{% if session.max_attendees %} / {{ session.max_attendees }}{% endif %}</span>
|
||||
</div>
|
||||
|
||||
<div class="session-meta-list">
|
||||
<div class="meta-item">
|
||||
<i data-lucide="calendar"></i>
|
||||
<span>{{ session.start_time|localized_date if session.start_time else 'TBD' }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="meta-item">
|
||||
<i data-lucide="clock"></i>
|
||||
<span>{{ session.start_time|localized_date('%H:%M') if session.start_time else '' }} - {{ session.end_time|localized_date('%H:%M') if session.end_time else '' }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<i data-lucide="map-pin"></i>
|
||||
<span>{{ session.location or 'TBD' }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<i data-lucide="building"></i>
|
||||
<span>{{ session.event_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-scan-section">
|
||||
<div class="scan-prompt">
|
||||
<i data-lucide="scan-line"></i>
|
||||
<span>{{ 'scan_attendees_at_session'|t }}</span>
|
||||
</div>
|
||||
<a href="{{ url_for('presenter_scan', code=session.presenter_code) }}" class="btn btn-primary btn-lg">
|
||||
<i data-lucide="qr-code"></i> {{ 'open_scanner'|t }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="session-card-actions">
|
||||
<a href="{{ url_for('view_breakout_session', code=session.code) }}" class="btn btn-outline btn-sm">
|
||||
<i data-lucide="eye"></i> {{ 'view_details'|t }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-sessions">{{ 'no_sessions_assigned'|t }}</p>
|
||||
<div class="empty-state-card">
|
||||
<div class="empty-state-icon">
|
||||
<i data-lucide="calendar-x"></i>
|
||||
</div>
|
||||
<h3>{{ 'no_sessions_assigned'|t }}</h3>
|
||||
<p>{{ 'no_sessions_assigned_desc'|t }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<div class="logout-link">
|
||||
<a href="{{ url_for('logout') }}" class="btn btn-outline">{{ 'logout'|t }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,240 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'presenter_scan'|t }} - {{ session_name }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="presenter-scan-page">
|
||||
<div class="scan-header">
|
||||
<div class="session-info-header">
|
||||
<h1>{{ session_name }}</h1>
|
||||
<p class="session-meta">
|
||||
<span><i data-lucide="calendar"></i> {{ start_time|localized_date if start_time else '' }}</span>
|
||||
<span><i data-lucide="clock"></i> {{ start_time|localized_date('%H:%M') if start_time else '' }} - {{ end_time|localized_date('%H:%M') if end_time else '' }}</span>
|
||||
<span><i data-lucide="map-pin"></i> {{ location or 'TBD' }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{ url_for('presenter_dashboard') }}" class="btn btn-outline">
|
||||
<i data-lucide="arrow-left"></i> {{ 'back_to_dashboard'|t }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="presenter-scan-container">
|
||||
<div class="scanner-section">
|
||||
<div class="scanner-box">
|
||||
<div id="qr-reader" class="qr-reader"></div>
|
||||
<div id="scan-result" class="scan-result hidden">
|
||||
<div class="result-icon" id="result-icon"></div>
|
||||
<div class="result-content">
|
||||
<h2 id="result-title"></h2>
|
||||
<p id="result-message"></p>
|
||||
</div>
|
||||
<button id="scan-again" class="btn btn-primary">
|
||||
<i data-lucide="scan-line"></i> {{ 'scan_again'|t }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-section">
|
||||
<div class="stat-card">
|
||||
<span class="stat-number" id="total-count">{{ attendees|length }}</span>
|
||||
<span class="stat-label">{{ 'total_registered'|t }}</span>
|
||||
</div>
|
||||
<div class="stat-card stat-card-success">
|
||||
<span class="stat-number" id="checked-in-count">{{ checked_in_count }}</span>
|
||||
<span class="stat-label">{{ 'checked_in'|t }}</span>
|
||||
</div>
|
||||
<div class="stat-card stat-card-warning">
|
||||
<span class="stat-number" id="remaining-count">{{ attendees|length - checked_in_count }}</span>
|
||||
<span class="stat-label">{{ 'remaining'|t }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="attendees-list-section">
|
||||
<h3>{{ 'attendees'|t }}</h3>
|
||||
<div class="attendees-scroll-list">
|
||||
<ul id="attendees-list" class="attendees-checkin-list">
|
||||
{% for attendee in attendees %}
|
||||
<li class="attendee-item {% if attendee.checked_in %}checked-in{% endif %}" data-id="{{ attendee.id }}" data-code="{{ attendee.attendee_code or '' }}">
|
||||
<div class="attendee-info">
|
||||
<span class="attendee-avatar">{{ attendee.first_name[0] }}{{ attendee.last_name[0] }}</span>
|
||||
<div class="attendee-details">
|
||||
<span class="attendee-name">{{ attendee.first_name }} {{ attendee.last_name }}</span>
|
||||
<span class="attendee-email">{{ attendee.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="attendee-status">
|
||||
{% if attendee.checked_in %}
|
||||
<span class="status-badge status-checked-in">
|
||||
<i data-lucide="check-circle"></i> {{ 'checked_in'|t }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="status-badge status-pending">
|
||||
<i data-lucide="clock"></i> {{ 'pending'|t }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="empty-state-item">
|
||||
<p>{{ 'no_attendees_registered'|t }}</p>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
||||
<script>
|
||||
const breakoutSessionId = {{ breakout_session_id }};
|
||||
const attendeesData = {{ attendees|tojson }};
|
||||
let html5QrCode;
|
||||
let scanning = true;
|
||||
|
||||
function updateStats() {
|
||||
const items = document.querySelectorAll('.attendee-item');
|
||||
const total = items.length;
|
||||
const checkedIn = document.querySelectorAll('.attendee-item.checked-in').length;
|
||||
document.getElementById('total-count').textContent = total;
|
||||
document.getElementById('checked-in-count').textContent = checkedIn;
|
||||
document.getElementById('remaining-count').textContent = total - checkedIn;
|
||||
}
|
||||
|
||||
function markAttendeeCheckedIn(attendeeId) {
|
||||
const item = document.querySelector(`.attendee-item[data-id="${attendeeId}"]`);
|
||||
if (item) {
|
||||
item.classList.add('checked-in');
|
||||
const badge = item.querySelector('.status-badge');
|
||||
badge.className = 'status-badge status-checked-in';
|
||||
badge.innerHTML = '<i data-lucide="check-circle"></i> {{ "checked_in"|t }}';
|
||||
lucide.createIcons();
|
||||
|
||||
// Move to top of list
|
||||
const list = document.getElementById('attendees-list');
|
||||
list.insertBefore(item, list.firstChild);
|
||||
}
|
||||
updateStats();
|
||||
}
|
||||
|
||||
async function checkInAttendee(attendeeId) {
|
||||
try {
|
||||
const response = await fetch(`/presenter/session/${breakoutSessionId}/checkin/${attendeeId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const resultDiv = document.getElementById('scan-result');
|
||||
const icon = document.getElementById('result-icon');
|
||||
const title = document.getElementById('result-title');
|
||||
const message = document.getElementById('result-message');
|
||||
|
||||
icon.className = 'result-icon';
|
||||
icon.innerHTML = '';
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('qr-reader').style.display = 'none';
|
||||
resultDiv.classList.remove('hidden');
|
||||
|
||||
if (data.already_checked_in) {
|
||||
icon.classList.add('warning');
|
||||
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}`;
|
||||
markAttendeeCheckedIn(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);
|
||||
showToast('{{ "error_checking_in"|t }}', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function findAttendeeByCode(code) {
|
||||
return attendeesData.find(a => a.attendee_code && a.attendee_code.toUpperCase() === code.toUpperCase());
|
||||
}
|
||||
|
||||
function onScanSuccess(decodedText) {
|
||||
if (!scanning) return;
|
||||
|
||||
// Try NETEVENT format: NETEVENT:eventId:attendeeId
|
||||
const parts = decodedText.split(':');
|
||||
if (parts.length === 3 && parts[0] === 'NETEVENT') {
|
||||
const attendeeId = parseInt(parts[2]);
|
||||
if (!isNaN(attendeeId)) {
|
||||
const attendee = attendeesData.find(a => a.id === attendeeId);
|
||||
if (attendee) {
|
||||
checkInAttendee(attendeeId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try plain 10-char attendee code
|
||||
const match = decodedText.match(/^[A-Z0-9]{10}$/i);
|
||||
if (match) {
|
||||
const code = match[0].toUpperCase();
|
||||
const attendee = findAttendeeByCode(code);
|
||||
if (attendee) {
|
||||
checkInAttendee(attendee.id);
|
||||
} else {
|
||||
showToast('{{ "attendee_not_found"|t }}', 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Try legacy URL format: /organizer/event/XX/checkin/YY
|
||||
const urlMatch = decodedText.match(/\/checkin\/(\d+)/);
|
||||
if (urlMatch) {
|
||||
const attendeeId = parseInt(urlMatch[1]);
|
||||
const attendee = attendeesData.find(a => a.id === attendeeId);
|
||||
if (attendee) {
|
||||
checkInAttendee(attendeeId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// QR format not recognized
|
||||
}
|
||||
|
||||
function startScanner() {
|
||||
html5QrCode = new Html5Qrcode("qr-reader");
|
||||
|
||||
html5QrCode.start(
|
||||
{ facingMode: "environment" },
|
||||
{
|
||||
fps: 10,
|
||||
qrbox: { width: 250, height: 250 }
|
||||
},
|
||||
onScanSuccess,
|
||||
(errorMessage) => {}
|
||||
).catch(err => {
|
||||
console.error('Camera error:', err);
|
||||
showToast('{{ "camera_permission_error"|t }}', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function resetScanner() {
|
||||
document.getElementById('qr-reader').style.display = 'block';
|
||||
document.getElementById('scan-result').classList.add('hidden');
|
||||
scanning = true;
|
||||
}
|
||||
|
||||
document.getElementById('scan-again').addEventListener('click', resetScanner);
|
||||
window.addEventListener('DOMContentLoaded', startScanner);
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user