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:
+137
-331
@@ -3,205 +3,142 @@
|
||||
{% block title %}{{ 'dashboard'|t }} - {{ 'organizer'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.dashboard {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 30px 20px;
|
||||
box-sizing: border-box;
|
||||
max-width: 1600px;
|
||||
}
|
||||
.events-list {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)) !important;
|
||||
gap: 1.5rem !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.event-item {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
.event-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.event-info {
|
||||
width: 100%;
|
||||
}
|
||||
.progress-bar-container {
|
||||
background: #e9ecef;
|
||||
border-radius: 4px;
|
||||
height: 20px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: #28a745;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.progress-bar-fill.warning {
|
||||
background: #ffc107;
|
||||
}
|
||||
.progress-bar-fill.full {
|
||||
background: #dc3545;
|
||||
}
|
||||
.event-capacity {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.breakout-sessions-list {
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
width: 100%;
|
||||
}
|
||||
.breakout-session-item {
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.breakout-session-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.breakout-session-name {
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
}
|
||||
.breakout-session-capacity {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
.event-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.event-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
.event-actions-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.event-actions-inline .btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.section-toggle {
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
background: #e9ecef;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.section-toggle:hover {
|
||||
background: #dee2e6;
|
||||
}
|
||||
.section-toggle::after {
|
||||
content: '▶';
|
||||
font-size: 10px;
|
||||
}
|
||||
.section-toggle.collapsed::after {
|
||||
content: '▼';
|
||||
}
|
||||
.section-content {
|
||||
padding: 10px 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.section-content.collapsed {
|
||||
display: none;
|
||||
}
|
||||
.item-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
}
|
||||
.list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.list-item-info {
|
||||
flex: 1;
|
||||
}
|
||||
.list-item-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
.pagination-controls button {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
<div class="dashboard organizer-dashboard">
|
||||
<h1>{{ 'dashboard'|t }} - {{ 'organizer'|t }}</h1>
|
||||
|
||||
/* Wide screen - spread content */
|
||||
@media (min-width: 1400px) {
|
||||
.dashboard {
|
||||
max-width: 100%;
|
||||
}
|
||||
<div class="dashboard-actions">
|
||||
<a href="{{ url_for('create_event') }}" class="btn btn-primary"><i data-lucide="plus-circle"></i> {{ 'create_event'|t }}</a>
|
||||
</div>
|
||||
|
||||
.events-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
<section class="my-events">
|
||||
<h2>{{ 'my_events'|t }}</h2>
|
||||
{% if events %}
|
||||
<div class="events-list organizer-events-list">
|
||||
{% for event in events %}
|
||||
<div class="event-item organizer-event-item card-static">
|
||||
<div class="event-item-header">
|
||||
<h3>{{ event.name }}</h3>
|
||||
<div class="event-actions-inline">
|
||||
<a href="{{ url_for('event_detail', code=event.code) }}" class="btn btn-sm btn-outline"><i data-lucide="eye"></i> {{ 'details'|t }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-info">
|
||||
<p class="event-date">{{ event.start_time|localized_date if event.start_time else 'TBD' }}</p>
|
||||
<p class="event-location">{{ event.location }}</p>
|
||||
|
||||
.event-item {
|
||||
padding: 20px 25px;
|
||||
}
|
||||
{% if event.max_attendees %}
|
||||
{% set percent = (event.attendee_count / event.max_attendees * 100)|round|int %}
|
||||
<div class="organizer-event-capacity">
|
||||
<span>{{ event.attendee_count }} / {{ event.max_attendees }} {{ 'attendees'|t }}</span>
|
||||
<span>({{ percent }}%)</span>
|
||||
</div>
|
||||
<div class="organizer-progress-bar">
|
||||
<div class="progress-fill {% if percent >= 100 %}full{% elif percent >= 80 %}warning{% endif %}" style="width: {{ percent if percent <= 100 else 100 }}%"></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="organizer-event-capacity">
|
||||
<span>{{ event.attendee_count }} {{ 'attendees'|t }} ({{ 'unlimited'|t }})</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
.event-info {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
{% if event.staff %}
|
||||
<div class="organizer-event-section">
|
||||
<div class="section-toggle collapsed" onclick="toggleSection('staff-{{ event.id }}')">
|
||||
<strong><i data-lucide="users"></i> Staff ({{ event.staff|length }})</strong>
|
||||
</div>
|
||||
<div id="staff-{{ event.id }}" class="section-content collapsed" data-item-count="{{ event.staff|length }}" data-per-page="5">
|
||||
<div class="item-list">
|
||||
{% for s in event.staff %}
|
||||
<div class="list-item">
|
||||
<div class="list-item-info">
|
||||
<strong><span class="first-name">{{ s.first_name }}</span> <span class="last-name">{{ s.last_name }}</span></strong>
|
||||
<span>{{ s.email }}</span>
|
||||
</div>
|
||||
<div class="list-item-actions">
|
||||
<a href="{{ url_for('edit_staff', event_id=event.id, staff_id=s.id) }}" class="btn btn-sm btn-outline"><i data-lucide="pencil"></i></a>
|
||||
<form method="POST" action="{{ url_for('delete_staff', event_id=event.id, staff_id=s.id) }}" class="d-inline" data-confirm="Remove {{ s.first_name }} {{ s.last_name }}?">
|
||||
<button type="submit" class="btn btn-sm btn-danger"><i data-lucide="trash-2"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="pagination-controls"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
.breakout-sessions-list {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
{% if event.breakout_sessions %}
|
||||
<div class="organizer-event-section">
|
||||
<div class="section-toggle collapsed" onclick="toggleSection('sessions-{{ event.id }}')">
|
||||
<strong><i data-lucide="calendar"></i> {{ 'breakout_sessions'|t }} ({{ event.breakout_sessions|length }})</strong>
|
||||
</div>
|
||||
<div id="sessions-{{ event.id }}" class="section-content collapsed" data-item-count="{{ event.breakout_sessions|length }}" data-per-page="5">
|
||||
<div class="item-list">
|
||||
{% for session in event.breakout_sessions %}
|
||||
<div class="list-item">
|
||||
<div class="list-item-info">
|
||||
<strong>{{ session.name }}</strong>
|
||||
{% if session.max_attendees %}
|
||||
<span>{{ session.rsvp_count }} / {{ session.max_attendees }}</span>
|
||||
{% else %}
|
||||
<span>{{ session.rsvp_count }} registered</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="list-item-actions">
|
||||
<a href="{{ url_for('edit_breakout_session', session_id=session.id) }}" class="btn btn-sm btn-outline"><i data-lucide="pencil"></i></a>
|
||||
<form method="POST" action="{{ url_for('delete_breakout_session', session_id=session.id) }}" class="d-inline" data-confirm="Delete {{ session.name }}?">
|
||||
<button type="submit" class="btn btn-sm btn-danger"><i data-lucide="trash-2"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="pagination-controls"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@media (min-width: 1800px) {
|
||||
.events-list {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% if event.attendees %}
|
||||
<div class="organizer-event-section">
|
||||
<div class="section-toggle collapsed" onclick="toggleSection('attendees-{{ event.id }}')">
|
||||
<strong><i data-lucide="users"></i> {{ 'attendees'|t }} ({{ event.attendees|length }})</strong>
|
||||
</div>
|
||||
<div id="attendees-{{ event.id }}" class="section-content collapsed" data-item-count="{{ event.attendees|length }}" data-per-page="5">
|
||||
<div class="item-list">
|
||||
{% for att in event.attendees %}
|
||||
<div class="list-item">
|
||||
<div class="list-item-info">
|
||||
<strong><span class="first-name">{{ att.first_name }}</span> <span class="last-name">{{ att.last_name }}</span></strong>
|
||||
<span>{{ att.email }}</span>
|
||||
{% if att.checked_in %}
|
||||
<span class="badge badge-success badge-sm"><i data-lucide="check"></i> {{ 'checked_in'|t }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="list-item-actions">
|
||||
<a href="{{ url_for('edit_attendee', attendee_id=att.id) }}" class="btn btn-sm btn-outline"><i data-lucide="pencil"></i></a>
|
||||
<form method="POST" action="{{ url_for('delete_attendee', attendee_id=att.id) }}" class="d-inline" data-confirm="Remove {{ att.first_name }} {{ att.last_name }}?">
|
||||
<button type="submit" class="btn btn-sm btn-danger"><i data-lucide="trash-2"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="pagination-controls"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-events">{{ 'no_events_yet'|t }}
|
||||
<a href="{{ url_for('create_event') }}">{{ 'create_first_event'|t }}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleSection(id) {
|
||||
@@ -248,143 +185,12 @@ function setupPagination(sectionId, totalItems, perPage) {
|
||||
}
|
||||
showPage(sectionId, 1, perPage);
|
||||
}
|
||||
|
||||
// Re-initialize icons after dynamic content
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="dashboard">
|
||||
<h1>{{ 'dashboard'|t }} - {{ 'organizer'|t }}</h1>
|
||||
|
||||
<div class="dashboard-actions">
|
||||
<a href="{{ url_for('create_event') }}" class="btn btn-primary">{{ 'create_event'|t }}</a>
|
||||
</div>
|
||||
|
||||
<section class="my-events">
|
||||
<h2>{{ 'my_events'|t }}</h2>
|
||||
{% if events %}
|
||||
<div class="events-list">
|
||||
{% for event in events %}
|
||||
<div class="event-item">
|
||||
<div class="event-item-header">
|
||||
<h3>{{ event.name }}</h3>
|
||||
<div class="event-actions-inline">
|
||||
<a href="{{ url_for('event_detail', code=event.code) }}" class="btn btn-sm btn-outline">{{ 'details'|t }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-info">
|
||||
<p class="event-date">{{ event.start_time|localized_date if event.start_time else 'TBD' }}</p>
|
||||
<p class="event-location">{{ event.location }}</p>
|
||||
|
||||
{% if event.max_attendees %}
|
||||
{% set percent = (event.attendee_count / event.max_attendees * 100)|round|int %}
|
||||
<div class="event-capacity">
|
||||
<span>{{ event.attendee_count }} / {{ event.max_attendees }} {{ 'attendees'|t }}</span>
|
||||
<span>({{ percent }}%)</span>
|
||||
</div>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar-fill {% if percent >= 100 %}full{% elif percent >= 80 %}warning{% endif %}"
|
||||
style="width: {{ percent if percent <= 100 else 100 }}%"></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="event-capacity">
|
||||
<span>{{ event.attendee_count }} {{ 'attendees'|t }} ({{ 'unlimited'|t }})</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if event.staff %}
|
||||
<div class="breakout-sessions-list">
|
||||
<div class="section-toggle" onclick="toggleSection('staff-{{ event.id }}')">
|
||||
<strong>Staff ({{ event.staff|length }})</strong>
|
||||
</div>
|
||||
<div id="staff-{{ event.id }}" class="section-content collapsed" data-item-count="{{ event.staff|length }}" data-per-page="5">
|
||||
<div class="item-list">
|
||||
{% for s in event.staff %}
|
||||
<div class="list-item">
|
||||
<div class="list-item-info">
|
||||
<strong>{{ s.first_name }} {{ s.last_name }}</strong>
|
||||
<span style="color: #666; font-size: 12px;">{{ s.email }}</span>
|
||||
</div>
|
||||
<div class="list-item-actions">
|
||||
<a href="{{ url_for('edit_staff', event_id=event.id, staff_id=s.id) }}" class="btn btn-sm btn-outline">Edit</a>
|
||||
<form method="POST" action="{{ url_for('delete_staff', event_id=event.id, staff_id=s.id) }}" style="display: inline;" onsubmit="return confirm('Remove {{ s.first_name }} {{ s.last_name }}?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="pagination-controls"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if event.breakout_sessions %}
|
||||
<div class="breakout-sessions-list">
|
||||
<div class="section-toggle" onclick="toggleSection('sessions-{{ event.id }}')">
|
||||
<strong>{{ 'breakout_sessions'|t }} ({{ event.breakout_sessions|length }})</strong>
|
||||
</div>
|
||||
<div id="sessions-{{ event.id }}" class="section-content collapsed" data-item-count="{{ event.breakout_sessions|length }}" data-per-page="5">
|
||||
<div class="item-list">
|
||||
{% for session in event.breakout_sessions %}
|
||||
<div class="list-item">
|
||||
<div class="list-item-info">
|
||||
<strong>{{ session.name }}</strong>
|
||||
{% if session.max_attendees %}
|
||||
<span style="color: #666; font-size: 12px;">{{ session.rsvp_count }} / {{ session.max_attendees }}</span>
|
||||
{% else %}
|
||||
<span style="color: #666; font-size: 12px;">{{ session.rsvp_count }} registered</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="list-item-actions">
|
||||
<a href="{{ url_for('edit_breakout_session', session_id=session.id) }}" class="btn btn-sm btn-outline">Edit</a>
|
||||
<form method="POST" action="{{ url_for('delete_breakout_session', session_id=session.id) }}" style="display: inline;" onsubmit="return confirm('Delete {{ session.name }}?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="pagination-controls"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if event.attendees %}
|
||||
<div class="breakout-sessions-list">
|
||||
<div class="section-toggle" onclick="toggleSection('attendees-{{ event.id }}')">
|
||||
<strong>{{ 'attendees'|t }} ({{ event.attendees|length }})</strong>
|
||||
</div>
|
||||
<div id="attendees-{{ event.id }}" class="section-content collapsed" data-item-count="{{ event.attendees|length }}" data-per-page="5">
|
||||
<div class="item-list">
|
||||
{% for att in event.attendees %}
|
||||
<div class="list-item">
|
||||
<div class="list-item-info">
|
||||
<strong>{{ att.first_name }} {{ att.last_name }}</strong>
|
||||
<span style="color: #666; font-size: 12px;">{{ att.email }}</span>
|
||||
{% if att.checked_in %}
|
||||
<span class="badge badge-success" style="font-size: 10px;">Checked In</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="list-item-actions">
|
||||
<a href="{{ url_for('edit_attendee', attendee_id=att.id) }}" class="btn btn-sm btn-outline">Edit</a>
|
||||
<form method="POST" action="{{ url_for('delete_attendee', attendee_id=att.id) }}" style="display: inline;" onsubmit="return confirm('Remove {{ att.first_name }} {{ att.last_name }}?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="pagination-controls"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-events">{{ 'no_events_yet'|t }}
|
||||
<a href="{{ url_for('create_event') }}">{{ 'create_first_event'|t }}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user