Add password reset flow, attendee profile enhancements, and spec
- Add forgot_password and reset_password templates and routes - Enhance profile.html with photo crop/zoom functionality - Add attendee type management to edit_attendee.html - Update templates styling and layout consistency - Document database schema in SPEC.md - Add CLAUDE.md project guidance Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,13 +6,15 @@ import smtplib
|
||||
import string
|
||||
import uuid
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from email.encoders import encode_base64
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.base import MIMEBase
|
||||
from functools import wraps
|
||||
|
||||
import secrets
|
||||
|
||||
import bcrypt
|
||||
import mysql.connector
|
||||
import qrcode
|
||||
@@ -351,6 +353,12 @@ ENGLISH_TRANSLATIONS = {
|
||||
'payment_secure': 'Your payment is secure and encrypted',
|
||||
'john_doe': 'John Doe',
|
||||
'mm/yy': 'MM/YY',
|
||||
'search_attendees': 'Search attendees',
|
||||
'search_min_chars': 'Enter at least %d characters to search',
|
||||
'search_too_broad': 'Too many results. Please enter more specific search criteria.',
|
||||
'no_attendees_found': 'No attendees found matching your search.',
|
||||
'event_attendees': 'Event Attendees',
|
||||
'visible_to_attendees': 'Visible to other attendees',
|
||||
}
|
||||
|
||||
|
||||
@@ -765,6 +773,83 @@ def clear_failed_logins(ip_address):
|
||||
del failed_login_attempts[ip_address]
|
||||
|
||||
|
||||
# Password reset rate limiting
|
||||
password_reset_attempts = {} # {ip_address: [(timestamp, count), ...]}
|
||||
PASSWORD_RESET_BLOCK_DURATION = 60 * 60 # 1 hour in seconds
|
||||
PASSWORD_RESET_MAX_ATTEMPTS = 5 # Max requests per hour before blocking
|
||||
|
||||
|
||||
def is_password_reset_blocked(ip_address):
|
||||
"""Check if an IP is blocked from password reset requests."""
|
||||
if ip_address not in password_reset_attempts:
|
||||
return False
|
||||
|
||||
current_time = datetime.now()
|
||||
password_reset_attempts[ip_address] = [
|
||||
(ts, count) for ts, count in password_reset_attempts[ip_address]
|
||||
if (current_time - ts).total_seconds() < PASSWORD_RESET_BLOCK_DURATION
|
||||
]
|
||||
|
||||
if not password_reset_attempts[ip_address]:
|
||||
del password_reset_attempts[ip_address]
|
||||
return False
|
||||
|
||||
total_attempts = sum(count for _, count in password_reset_attempts[ip_address])
|
||||
return total_attempts >= PASSWORD_RESET_MAX_ATTEMPTS
|
||||
|
||||
|
||||
def record_password_reset_attempt(ip_address):
|
||||
"""Record a password reset request attempt."""
|
||||
current_time = datetime.now()
|
||||
if ip_address not in password_reset_attempts:
|
||||
password_reset_attempts[ip_address] = []
|
||||
password_reset_attempts[ip_address].append((current_time, 1))
|
||||
|
||||
|
||||
def generate_password_reset_token():
|
||||
"""Generate a secure random token for password reset."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def send_password_reset_email(organizer_email, organizer_name, reset_token):
|
||||
"""Send password reset email to organizer."""
|
||||
reset_url = url_for('reset_password', token=reset_token, _external=True)
|
||||
|
||||
subject = "Password Reset - NetEvents"
|
||||
body = f"""Hello {organizer_name},
|
||||
|
||||
You requested a password reset for your NetEvents organizer account.
|
||||
|
||||
Click the link below to set a new password:
|
||||
{reset_url}
|
||||
|
||||
This link will expire in 1 hour.
|
||||
|
||||
If you did not request a password reset, please ignore this email.
|
||||
|
||||
Best regards,
|
||||
The NetEvents Team
|
||||
"""
|
||||
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = Config.MAIL_DEFAULT_SENDER
|
||||
msg['To'] = organizer_email
|
||||
msg['Subject'] = subject
|
||||
msg.attach(MIMEText(body, 'plain'))
|
||||
|
||||
try:
|
||||
with smtplib.SMTP(Config.MAIL_SERVER, Config.MAIL_PORT) as server:
|
||||
if Config.MAIL_USE_TLS:
|
||||
server.starttls()
|
||||
if Config.MAIL_USERNAME:
|
||||
server.login(Config.MAIL_USERNAME, Config.MAIL_PASSWORD)
|
||||
server.send_message(msg)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Email error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def ensure_staff_code_column():
|
||||
"""Ensure the staff_code column exists in organizers table."""
|
||||
try:
|
||||
@@ -1483,6 +1568,115 @@ def login():
|
||||
return render_template('auth/login.html', default_type=default_type)
|
||||
|
||||
|
||||
@app.route('/forgot-password', methods=['GET', 'POST'])
|
||||
def forgot_password():
|
||||
"""Forgot password page - allows organizer to request a reset link."""
|
||||
client_ip = request.remote_addr
|
||||
|
||||
if is_password_reset_blocked(client_ip):
|
||||
flash('Too many password reset requests. Please try again later.')
|
||||
return render_template('auth/forgot_password.html')
|
||||
|
||||
if request.method == 'POST':
|
||||
email = request.form.get('email', '').strip()
|
||||
|
||||
if not email:
|
||||
flash('Please enter your email address.')
|
||||
return render_template('auth/forgot_password.html')
|
||||
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT * FROM organizers WHERE email = %s", (email,))
|
||||
organizer = cursor.fetchone()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
if organizer:
|
||||
# Generate and store token
|
||||
token = generate_password_reset_token()
|
||||
expiry = datetime.now() + timedelta(hours=1)
|
||||
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE organizers SET reset_token = %s, reset_token_expiry = %s
|
||||
WHERE id = %s
|
||||
""", (token, expiry, organizer['id']))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
# Send email
|
||||
send_password_reset_email(organizer['email'], organizer['name'], token)
|
||||
record_password_reset_attempt(client_ip)
|
||||
|
||||
# Always show same message to prevent email enumeration
|
||||
flash('If an account exists with that email, a password reset link has been sent.')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
except Error as e:
|
||||
flash(f'Database error: {e}')
|
||||
return render_template('auth/forgot_password.html')
|
||||
|
||||
return render_template('auth/forgot_password.html')
|
||||
|
||||
|
||||
@app.route('/reset-password/<token>', methods=['GET', 'POST'])
|
||||
def reset_password(token):
|
||||
"""Reset password page - validates token and allows new password entry."""
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
# Find organizer with this valid token
|
||||
cursor.execute("""
|
||||
SELECT id, email, name FROM organizers
|
||||
WHERE reset_token = %s AND reset_token_expiry > NOW()
|
||||
""", (token,))
|
||||
organizer = cursor.fetchone()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
if not organizer:
|
||||
flash('Invalid or expired reset link. Please request a new one.')
|
||||
return redirect(url_for('forgot_password'))
|
||||
|
||||
if request.method == 'POST':
|
||||
password = request.form.get('password', '')
|
||||
confirm_password = request.form.get('confirm_password', '')
|
||||
|
||||
if not password or len(password) < 6:
|
||||
flash('Password must be at least 6 characters.')
|
||||
return render_template('auth/reset_password.html', token=token)
|
||||
|
||||
if password != confirm_password:
|
||||
flash('Passwords do not match.')
|
||||
return render_template('auth/reset_password.html', token=token)
|
||||
|
||||
# Update password and invalidate token
|
||||
password_hash = hash_password(password)
|
||||
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE organizers SET password_hash = %s, reset_token = NULL, reset_token_expiry = NULL
|
||||
WHERE id = %s
|
||||
""", (password_hash, organizer['id']))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
flash('Password successfully reset. Please log in with your new password.')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
return render_template('auth/reset_password.html', token=token)
|
||||
|
||||
except Error as e:
|
||||
flash(f'Database error: {e}')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
|
||||
@app.route('/presenter/dashboard')
|
||||
@login_required
|
||||
def presenter_dashboard():
|
||||
@@ -1666,6 +1860,7 @@ def register_attendee(code):
|
||||
organisation = request.form.get('organisation', '').strip()
|
||||
role = request.form.get('role', '').strip()
|
||||
introduction = request.form.get('introduction', '').strip()
|
||||
visible_to_others = request.form.get('visible_to_others') == '1'
|
||||
|
||||
if not all([first_name, last_name, email, password]):
|
||||
flash('First name, last name, email, and password are required.')
|
||||
@@ -1693,9 +1888,9 @@ def register_attendee(code):
|
||||
password_hash = hash_password(password)
|
||||
attendee_code = generate_attendee_code()
|
||||
cursor.execute("""
|
||||
INSERT INTO attendees (event_id, email, password_hash, first_name, last_name, organisation, role, introduction, attendee_code)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (event['id'], email, password_hash, first_name, last_name, organisation, role, introduction, attendee_code))
|
||||
INSERT INTO attendees (event_id, email, password_hash, first_name, last_name, organisation, role, introduction, attendee_code, visible_to_others)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (event['id'], email, password_hash, first_name, last_name, organisation, role, introduction, attendee_code, visible_to_others))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
@@ -3234,12 +3429,13 @@ def edit_attendee(attendee_id):
|
||||
role = request.form.get('role', '').strip()
|
||||
introduction = request.form.get('introduction', '').strip()
|
||||
attendee_type_id = request.form.get('attendee_type_id', '').strip()
|
||||
visible_to_others = request.form.get('visible_to_others') == '1'
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE attendees
|
||||
SET first_name = %s, last_name = %s, email = %s, organisation = %s, role = %s, introduction = %s, attendee_type_id = %s
|
||||
SET first_name = %s, last_name = %s, email = %s, organisation = %s, role = %s, introduction = %s, attendee_type_id = %s, visible_to_others = %s
|
||||
WHERE id = %s
|
||||
""", (first_name, last_name, email, organisation, role, introduction, attendee_type_id if attendee_type_id else None, attendee_id))
|
||||
""", (first_name, last_name, email, organisation, role, introduction, attendee_type_id if attendee_type_id else None, visible_to_others, attendee_id))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
@@ -3829,13 +4025,14 @@ def attendee_profile():
|
||||
organisation = request.form.get('organisation', '').strip()
|
||||
role = request.form.get('role', '').strip()
|
||||
introduction = request.form.get('introduction', '').strip()
|
||||
visible_to_others = request.form.get('visible_to_others') == '1'
|
||||
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE attendees SET first_name = %s, last_name = %s, organisation = %s, role = %s, introduction = %s, phone = %s, linkedin = %s
|
||||
UPDATE attendees SET first_name = %s, last_name = %s, organisation = %s, role = %s, introduction = %s, phone = %s, linkedin = %s, visible_to_others = %s
|
||||
WHERE id = %s
|
||||
""", (first_name, last_name, organisation, role, introduction, request.form.get('phone', '').strip(), request.form.get('linkedin', '').strip(), session['user_id']))
|
||||
""", (first_name, last_name, organisation, role, introduction, request.form.get('phone', '').strip(), request.form.get('linkedin', '').strip(), visible_to_others, session['user_id']))
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
@@ -3894,26 +4091,62 @@ def upload_photo():
|
||||
@app.route('/attendees')
|
||||
@login_required
|
||||
def list_attendees():
|
||||
"""List other attendees at the same event."""
|
||||
"""List other attendees at the same event based on search criteria."""
|
||||
if session.get('user_type') != 'attendee':
|
||||
flash('Access denied.')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
# Check if user has visibility enabled
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute("SELECT visible_to_others FROM attendees WHERE id = %s", (session['user_id'],))
|
||||
user = cursor.fetchone()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
if not user or not user['visible_to_others']:
|
||||
flash('You must enable visibility to see other attendees.')
|
||||
return redirect(url_for('attendee_dashboard'))
|
||||
|
||||
search_query = request.args.get('q', '').strip()
|
||||
attendees = []
|
||||
too_broad = False
|
||||
min_chars = 5
|
||||
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("""
|
||||
SELECT a.*,
|
||||
(SELECT status FROM connections WHERE attendee_id = %s AND connected_attendee_id = a.id) as my_status
|
||||
FROM attendees a
|
||||
WHERE a.event_id = %s AND a.id != %s
|
||||
""", (session['user_id'], session['event_id'], session['user_id']))
|
||||
attendees = cursor.fetchall()
|
||||
# Get event details
|
||||
cursor.execute("SELECT * FROM events WHERE id = %s", (session['event_id'],))
|
||||
event = cursor.fetchone()
|
||||
|
||||
if len(search_query) >= min_chars:
|
||||
# Search in first_name, last_name, and organisation
|
||||
search_pattern = f'%{search_query}%'
|
||||
cursor.execute("""
|
||||
SELECT a.*,
|
||||
(SELECT status FROM connections WHERE attendee_id = %s AND connected_attendee_id = a.id) as my_status
|
||||
FROM attendees a
|
||||
WHERE a.event_id = %s AND a.id != %s
|
||||
AND a.visible_to_others = TRUE
|
||||
AND (a.first_name LIKE %s OR a.last_name LIKE %s OR a.organisation LIKE %s)
|
||||
""", (session['user_id'], session['event_id'], session['user_id'], search_pattern, search_pattern, search_pattern))
|
||||
attendees = cursor.fetchall()
|
||||
|
||||
# Check if more than 2 attendees found
|
||||
if len(attendees) > 2:
|
||||
too_broad = True
|
||||
attendees = []
|
||||
else:
|
||||
# Don't show any attendees if search is less than 5 chars
|
||||
attendees = []
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
return render_template('attendee/attendees.html', attendees=attendees)
|
||||
return render_template('attendee/attendees.html', attendees=attendees, event=event,
|
||||
search_query=search_query, too_broad=too_broad, min_chars=min_chars)
|
||||
except Error as e:
|
||||
flash(f'Database error: {e}')
|
||||
return redirect(url_for('attendee_dashboard'))
|
||||
|
||||
Reference in New Issue
Block a user