feat: Implement email functionality with FastAPI-Mail and Mailhog

- Updated docker-compose.yml to include Mailhog service for email testing.
- Added environment variables for email configuration in docker-compose.
- Updated requirements.txt to include fastapi-mail for email support.
- Created templates for password reset, email verification, and welcome emails.
- Developed mail utility module to handle sending emails using FastAPI-Mail.
- Added documentation for email testing with Mailhog.
This commit is contained in:
Christian Krakau-Louis
2025-04-14 07:23:16 +02:00
parent 583f5aac42
commit 5cf3f944b1
22 changed files with 1425 additions and 157 deletions
+138 -61
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
import os
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.orm import sessionmaker, declarative_base
from sqlalchemy.orm import sessionmaker
# Get database connection details from environment variables with fallbacks
DB_HOST = os.environ.get("DB_HOST", "localhost")
@@ -23,13 +23,13 @@ engine = create_engine(
# Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create base class for models
Base = declarative_base()
# Import Base from models to ensure we use the same instance
from .models import Base
def init_db():
"""Initialize the database with all tables."""
# Import all models to ensure they're loaded
from . import models
# No need to import models here as we're already importing Base from models
# This ensures all models are loaded because they're defined in the models module
# Create all tables if they don't exist
Base.metadata.create_all(bind=engine)
@@ -44,8 +44,11 @@ def migrate_schema():
connection = engine.connect()
inspector = inspect(engine)
# Check if tables exist first
tables = inspector.get_table_names()
# Check User table
if 'users' in inspector.get_table_names():
if 'users' in tables:
columns = [col['name'] for col in inspector.get_columns('users')]
# Add all missing columns for User table
@@ -68,9 +71,11 @@ def migrate_schema():
connection.commit()
except Exception as e:
print(f"Error adding column {col_name}: {e}")
else:
print("Users table doesn't exist yet, skipping User table migrations")
# Check Team table
if 'teams' in inspector.get_table_names():
if 'teams' in tables:
columns = [col['name'] for col in inspector.get_columns('teams')]
if 'is_public' not in columns:
print("Adding is_public column to teams table")
@@ -87,18 +92,22 @@ def migrate_schema():
connection.execute(text(
"ALTER TABLE teams ADD COLUMN description TEXT"
))
else:
print("Teams table doesn't exist yet, skipping Team table migrations")
# Check TeamMembership table
if 'team_membership' in inspector.get_table_names():
if 'team_membership' in tables:
columns = [col['name'] for col in inspector.get_columns('team_membership')]
if 'joined_at' not in columns:
print("Adding joined_at column to team_membership table")
connection.execute(text(
"ALTER TABLE team_membership ADD COLUMN joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
))
else:
print("TeamMembership table doesn't exist yet, skipping TeamMembership table migrations")
# Check QRCode table (formerly QRTicket)
if 'qr_codes' in inspector.get_table_names():
if 'qr_codes' in tables:
columns = [col['name'] for col in inspector.get_columns('qr_codes')]
if 'created_at' not in columns:
print("Adding created_at column to qr_codes table")
@@ -110,9 +119,11 @@ def migrate_schema():
connection.execute(text(
"ALTER TABLE qr_codes ADD COLUMN redeemed_at TIMESTAMP NULL"
))
else:
print("QRCodes table doesn't exist yet, skipping QRCode table migrations")
# Handle legacy QRTicket table migration if it exists
if 'qr_tickets' in inspector.get_table_names() and 'qr_codes' in inspector.get_table_names():
if 'qr_tickets' in tables and 'qr_codes' in tables:
print("Migrating data from legacy qr_tickets table to qr_codes table")
try:
# Check if migration has already been done
@@ -129,43 +140,124 @@ def migrate_schema():
except Exception as e:
print(f"Error during qr_tickets migration: {e}")
# Create OAuthAccount table if it doesn't exist
if 'oauth_accounts' not in inspector.get_table_names():
print("Creating oauth_accounts table")
connection.execute(text("""
CREATE TABLE oauth_accounts (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
provider VARCHAR(50),
provider_user_id VARCHAR(255),
access_token VARCHAR(255),
expires_at TIMESTAMP NULL,
refresh_token VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
"""))
# Create TeamAchievement table if it doesn't exist
if 'team_achievements' not in inspector.get_table_names():
print("Creating team_achievements table")
connection.execute(text("""
CREATE TABLE team_achievements (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT,
name VARCHAR(255) NOT NULL,
event_id INT,
description TEXT,
achieved_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
qr_code_id INT,
FOREIGN KEY (team_id) REFERENCES teams(id),
FOREIGN KEY (event_id) REFERENCES events(id),
FOREIGN KEY (qr_code_id) REFERENCES qr_codes(id)
)
"""))
# Create tables that don't exist only if users table exists first
# This ensures we can properly create foreign keys
if 'users' in tables:
# Create OAuthAccount table if it doesn't exist
if 'oauth_accounts' not in tables:
print("Creating oauth_accounts table")
connection.execute(text("""
CREATE TABLE oauth_accounts (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
provider VARCHAR(50),
provider_user_id VARCHAR(255),
access_token VARCHAR(255),
expires_at TIMESTAMP NULL,
refresh_token VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
"""))
connection.commit()
# Create teams table if it doesn't exist
if 'teams' not in tables:
print("Creating teams table")
connection.execute(text("""
CREATE TABLE teams (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
is_public BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
owner_id INT,
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL
)
"""))
connection.commit()
# Create team_members table if it doesn't exist and teams table exists
if 'team_members' not in tables and 'teams' in tables:
print("Creating team_members table")
connection.execute(text("""
CREATE TABLE team_members (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
team_id INT NOT NULL,
is_captain BOOLEAN DEFAULT FALSE,
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE
)
"""))
connection.commit()
# Create team_membership table if it doesn't exist and teams table exists
if 'team_membership' not in tables and 'teams' in tables:
print("Creating team_membership table")
connection.execute(text("""
CREATE TABLE team_membership (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT,
team_id INT,
is_admin BOOLEAN DEFAULT FALSE,
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (team_id) REFERENCES teams(id)
)
"""))
connection.commit()
# Create Event table if it doesn't exist
if 'events' not in tables:
print("Creating events table")
connection.execute(text("""
CREATE TABLE events (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
location VARCHAR(200),
event_date TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
"""))
connection.commit()
# Create event_attendees table if it doesn't exist and events table exists
if 'event_attendees' not in tables and 'events' in tables:
print("Creating event_attendees table")
connection.execute(text("""
CREATE TABLE event_attendees (
id INT AUTO_INCREMENT PRIMARY KEY,
event_id INT NOT NULL,
user_id INT NOT NULL,
check_in_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""))
connection.commit()
# Create user_points table if it doesn't exist
if 'user_points' not in tables:
print("Creating user_points table")
connection.execute(text("""
CREATE TABLE user_points (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
points FLOAT NOT NULL DEFAULT 0,
reason VARCHAR(200),
awarded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""))
connection.commit()
# Create QRSet table if it doesn't exist
if 'qr_sets' not in inspector.get_table_names():
if 'qr_sets' not in tables:
print("Creating qr_sets table")
connection.execute(text("""
CREATE TABLE qr_sets (
@@ -177,23 +269,8 @@ def migrate_schema():
FOREIGN KEY (created_by) REFERENCES users(id)
)
"""))
connection.commit()
# Create Event table if it doesn't exist
if 'events' not in inspector.get_table_names():
print("Creating events table")
connection.execute(text("""
CREATE TABLE events (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
location VARCHAR(200),
event_date TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
"""))
connection.commit()
print("Schema migrations completed successfully")
except Exception as e:
print(f"Error during schema migration: {e}")
+49 -44
View File
@@ -1,22 +1,40 @@
from sqlalchemy import text
from sqlalchemy import text, inspect
from .db import engine
def table_exists(conn, table_name):
"""Check if a table exists in the database."""
result = conn.execute(text(f"""
SELECT COUNT(*) as count
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = '{table_name}'
"""))
return result.scalar() > 0
def column_exists(conn, table_name, column_name):
"""Check if a column exists in a table."""
result = conn.execute(text(f"""
SELECT COUNT(*) as count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = '{table_name}'
AND column_name = '{column_name}'
"""))
return result.scalar() > 0
def apply_migrations():
"""Apply all pending database migrations."""
# Check and add OAuth columns to users table
try:
with engine.connect() as conn:
# Check if the OAuth columns exist
result = conn.execute(text("""
SELECT COUNT(*) as count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'users'
AND column_name = 'is_oauth_user'
"""))
# Make sure tables exist before trying to alter them
if not table_exists(conn, 'users'):
print("Users table doesn't exist yet. Skipping OAuth columns migration.")
return
if result.fetchone()[0] == 0:
# Check if the OAuth columns exist
if not column_exists(conn, 'users', 'is_oauth_user'):
print("Adding OAuth columns to users table...")
# Add the OAuth columns
@@ -34,15 +52,7 @@ def apply_migrations():
print("OAuth columns already exist in users table.")
# Check if the is_admin column exists in the users table
result = conn.execute(text("""
SELECT COUNT(*) as count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'users'
AND column_name = 'is_admin'
"""))
if result.fetchone()[0] == 0:
if not column_exists(conn, 'users', 'is_admin'):
print("Adding is_admin column to users table...")
# Add the is_admin column to users table
@@ -56,32 +66,27 @@ def apply_migrations():
else:
print("is_admin column already exists in users table.")
# Check if the owner_id column exists in the teams table
result = conn.execute(text("""
SELECT COUNT(*) as count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'teams'
AND column_name = 'owner_id'
"""))
if result.fetchone()[0] == 0:
print("Adding owner_id column to teams table...")
# Add the owner_id column to teams table
conn.execute(text("""
ALTER TABLE teams
ADD COLUMN owner_id INT NULL,
ADD CONSTRAINT fk_teams_owner
FOREIGN KEY (owner_id) REFERENCES users(id)
ON DELETE SET NULL
"""))
conn.commit()
print("owner_id column added successfully to teams table.")
# Only proceed with teams table if it exists
if table_exists(conn, 'teams'):
# Check if the owner_id column exists in the teams table
if not column_exists(conn, 'teams', 'owner_id'):
print("Adding owner_id column to teams table...")
# Add the owner_id column to teams table
conn.execute(text("""
ALTER TABLE teams
ADD COLUMN owner_id INT NULL,
ADD CONSTRAINT fk_teams_owner
FOREIGN KEY (owner_id) REFERENCES users(id)
ON DELETE SET NULL
"""))
conn.commit()
print("owner_id column added successfully to teams table.")
else:
print("owner_id column already exists in teams table.")
else:
print("owner_id column already exists in teams table.")
print("Teams table doesn't exist yet. Skipping teams migrations.")
except Exception as e:
print(f"Error applying migrations: {str(e)}")
raise
+36 -13
View File
@@ -7,6 +7,8 @@ from pathlib import Path
import os
from starlette.middleware.sessions import SessionMiddleware
from dotenv import load_dotenv
import logging
import contextlib
from .db import init_db, engine, get_db
from . import models
@@ -15,25 +17,46 @@ from .views import qr, redeem, teams, admin, leaderboard, dashboard, static, pag
from .db_init import seed_db
from .db_migrations import apply_migrations
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Load environment variables
load_dotenv()
# Create tables on startup
init_db()
# Apply any pending database migrations
apply_migrations()
# Seed database with initial test data
# In a production app, you would handle this differently
seed_db()
# Create the FastAPI application
app = FastAPI(title="LeagueLedger")
# Add SessionMiddleware with a secure secret key
# Must be added first before other middleware to ensure it's available
app.add_middleware(SessionMiddleware, secret_key=os.getenv("SESSION_SECRET_KEY", "your-very-secret-session-key"))
# Initialize database on startup
@app.on_event("startup")
async def startup_db_client():
logger.info("Starting database initialization")
try:
# Import models to ensure they're registered with Base before initialization
from . import models
# Create all tables first
init_db()
logger.info("Base tables created successfully")
# Apply migrations to add additional columns and constraints
with contextlib.suppress(Exception):
apply_migrations()
logger.info("Migrations applied successfully")
# Seed the database with test data if needed
with contextlib.suppress(Exception):
seed_db()
logger.info("Database seeded successfully")
except Exception as e:
logger.error(f"Database initialization error: {str(e)}")
# We continue even if there was an error, as the application might still work with partial functionality
# Mount static files
app.mount("/static", StaticFiles(directory="app/static"), name="static")
@@ -43,14 +66,14 @@ static.configure_static_files(app)
# Setup Jinja2 templates
templates = Jinja2Templates(directory="app/templates")
# User context middleware to make template globals available
# User context middleware
@app.middleware("http")
async def add_template_globals(request: Request, call_next):
"""Add template globals"""
try:
# Update template globals for all templates
user = None
if "user_id" in request.session and request.session.get("is_authenticated"):
if hasattr(request, "session") and "user_id" in request.session and request.session.get("is_authenticated"):
# Mock user object - in a real app, you'd fetch this from the database
user = {
"id": request.session["user_id"],
@@ -59,7 +82,7 @@ async def add_template_globals(request: Request, call_next):
}
templates.env.globals["current_user"] = user
except Exception as e:
print(f"Error setting template globals: {str(e)}")
logger.error(f"Error setting template globals: {str(e)}")
# Process the request
response = await call_next(request)
+3
View File
@@ -4,6 +4,7 @@ from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from sqlalchemy.ext.declarative import declarative_base
# This Base should be the single source of truth
Base = declarative_base()
class User(Base):
@@ -18,6 +19,8 @@ class User(Base):
is_verified = Column(Boolean, default=False)
is_admin = Column(Boolean, default=False)
verification_token = Column(String(255), nullable=True)
verification_token_expires_at = Column(DateTime, nullable=True) # Added field for verification token expiration
last_verification_email_sent = Column(DateTime, nullable=True) # Track when verification email was last sent
reset_token = Column(String(255), nullable=True)
reset_token_expires_at = Column(DateTime, nullable=True)
last_login = Column(DateTime, nullable=True)
+57
View File
@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block content %}
<div class="flex justify-center mt-10">
<div class="w-full max-w-md">
<div class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
<h2 class="text-2xl font-bold text-irish-green mb-6 text-center">Reset Your Password</h2>
{% if error %}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{{ error }}
</div>
{% endif %}
{% if message %}
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{{ message }}
</div>
{% endif %}
<p class="mb-6 text-gray-600">
Enter your email address and we'll send you a link to reset your password.
</p>
<form method="POST" action="/auth/forgot-password">
<div class="mb-6">
<label class="block text-gray-700 text-sm font-bold mb-2" for="email">
Email Address
</label>
<input
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="email"
name="email"
type="email"
placeholder="Enter your email"
required
>
</div>
<div class="flex items-center justify-between">
<button
class="bg-irish-green hover:bg-opacity-90 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline w-full"
type="submit"
>
Send Reset Link
</button>
</div>
<div class="text-center mt-6">
<a href="/auth/login" class="text-sm text-irish-green hover:underline">
Back to Login
</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
+16 -2
View File
@@ -8,6 +8,17 @@
{% if error %}
<div class="mb-4 p-3 rounded bg-red-100 text-red-700">
{{ error }}
<!-- Display resend verification link if applicable -->
{% if unverified_user_id %}
<div class="mt-2 p-2 border-t border-red-200">
<p class="mb-2 text-sm">Didn't receive the verification email?</p>
<a href="/auth/resend-verification?user_id={{ unverified_user_id }}"
class="text-irish-green hover:underline text-sm font-medium">
Resend verification email to {{ unverified_email }}
</a>
</div>
{% endif %}
</div>
{% endif %}
@@ -55,13 +66,16 @@
<label for="remember" class="ml-2 block text-sm text-gray-700">Remember me</label>
</div>
<div>
<div class="flex items-center justify-between">
<button
type="submit"
class="w-full bg-irish-green text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-irish-green"
class="bg-irish-green text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-irish-green"
>
Log In
</button>
<a class="inline-block align-baseline font-bold text-sm text-irish-green hover:text-opacity-75" href="/auth/forgot-password">
Forgot Password?
</a>
</div>
</form>
+1 -1
View File
@@ -34,7 +34,7 @@
<div class="bg-cream-white p-4 rounded-md">
<h3 class="font-semibold text-irish-green mb-1">Member Since</h3>
<p>{{ user.created_at.split(' ')[0] }}</p>
<p>{{ user.created_at.strftime('%Y-%m-%d') }}</p>
</div>
</div>
+12 -1
View File
@@ -5,7 +5,18 @@
<div class="text-center">
<i class="fas fa-check-circle text-green-500 text-5xl mb-4"></i>
<h1 class="text-2xl font-bold text-irish-green mb-3">Registration Successful!</h1>
<p class="text-gray-600 mb-6">Your account has been created successfully.</p>
{% if email_verification_required %}
<p class="text-gray-600 mb-4">Your account has been created successfully.</p>
<div class="bg-blue-50 p-4 rounded-md mb-6">
<p class="text-blue-800">
<i class="fas fa-envelope mr-2"></i>
Please check your email inbox to verify your email address before logging in.
</p>
</div>
{% else %}
<p class="text-gray-600 mb-6">Your account has been created successfully.</p>
{% endif %}
<a href="/auth/login" class="inline-block bg-irish-green text-white py-2 px-6 rounded-md hover:bg-opacity-90 transition">
Log In
+171
View File
@@ -0,0 +1,171 @@
{% extends "base.html" %}
{% block content %}
<div class="flex justify-center mt-10">
<div class="w-full max-w-md">
<div class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
<h2 class="text-2xl font-bold text-irish-green mb-6 text-center">Set New Password</h2>
{% if error %}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{{ error }}
</div>
{% endif %}
<form method="POST" action="/auth/reset-password" id="password-form" onsubmit="return validateForm()">
<input type="hidden" name="token" value="{{ token }}">
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="new_password">
New Password
</label>
<input
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="new_password"
name="new_password"
type="password"
placeholder="Enter your new password"
required
oninput="checkPasswordStrength()"
>
<div class="mt-2">
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div class="bg-red-600 h-2.5 rounded-full" id="password-strength-meter" style="width: 0%"></div>
</div>
<p class="text-xs mt-1" id="password-strength-text">Password strength: Too weak</p>
</div>
<ul class="text-xs text-gray-600 mt-2 list-disc pl-5">
<li id="length-check" class="text-red-500">At least 8 characters</li>
<li id="lowercase-check" class="text-red-500">At least one lowercase letter</li>
<li id="uppercase-check" class="text-red-500">At least one uppercase letter</li>
<li id="number-check" class="text-red-500">At least one number</li>
<li id="special-check" class="text-red-500">At least one special character</li>
</ul>
</div>
<div class="mb-6">
<label class="block text-gray-700 text-sm font-bold mb-2" for="confirm_password">
Confirm New Password
</label>
<input
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="confirm_password"
name="confirm_password"
type="password"
placeholder="Confirm your new password"
required
oninput="checkPasswordMatch()"
>
<p id="password-match" class="text-xs mt-1 hidden text-red-500">Passwords do not match</p>
</div>
<div class="flex items-center justify-center">
<button
class="bg-irish-green hover:bg-opacity-90 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline w-full"
type="submit"
id="submit-button"
>
Reset Password
</button>
</div>
</form>
</div>
</div>
</div>
<script>
function checkPasswordStrength() {
const password = document.getElementById('new_password').value;
const meter = document.getElementById('password-strength-meter');
const strengthText = document.getElementById('password-strength-text');
// Check requirements
const hasLength = password.length >= 8;
const hasLower = /[a-z]/.test(password);
const hasUpper = /[A-Z]/.test(password);
const hasNumber = /\d/.test(password);
const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(password);
// Update requirement indicators
document.getElementById('length-check').className = hasLength ? 'text-green-500' : 'text-red-500';
document.getElementById('lowercase-check').className = hasLower ? 'text-green-500' : 'text-red-500';
document.getElementById('uppercase-check').className = hasUpper ? 'text-green-500' : 'text-red-500';
document.getElementById('number-check').className = hasNumber ? 'text-green-500' : 'text-red-500';
document.getElementById('special-check').className = hasSpecial ? 'text-green-500' : 'text-red-500';
// Calculate strength percentage (20% for each criteria)
let strength = 0;
if (hasLength) strength += 20;
if (hasLower) strength += 20;
if (hasUpper) strength += 20;
if (hasNumber) strength += 20;
if (hasSpecial) strength += 20;
// Update meter
meter.style.width = `${strength}%`;
// Set color based on strength
if (strength < 40) {
meter.className = 'bg-red-600 h-2.5 rounded-full';
strengthText.textContent = 'Password strength: Too weak';
strengthText.className = 'text-xs mt-1 text-red-600';
} else if (strength < 80) {
meter.className = 'bg-yellow-500 h-2.5 rounded-full';
strengthText.textContent = 'Password strength: Medium';
strengthText.className = 'text-xs mt-1 text-yellow-600';
} else {
meter.className = 'bg-green-500 h-2.5 rounded-full';
strengthText.textContent = 'Password strength: Strong';
strengthText.className = 'text-xs mt-1 text-green-600';
}
}
function checkPasswordMatch() {
const password = document.getElementById('new_password').value;
const confirmPassword = document.getElementById('confirm_password').value;
const matchMessage = document.getElementById('password-match');
if (confirmPassword) {
if (password !== confirmPassword) {
matchMessage.classList.remove('hidden');
} else {
matchMessage.classList.add('hidden');
}
}
}
function validateForm() {
const password = document.getElementById('new_password').value;
const confirmPassword = document.getElementById('confirm_password').value;
// Check if passwords match
if (password !== confirmPassword) {
alert('Passwords do not match.');
return false;
}
// Check password requirements
const hasLength = password.length >= 8;
const hasLower = /[a-z]/.test(password);
const hasUpper = /[A-Z]/.test(password);
const hasNumber = /\d/.test(password);
const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(password);
if (!hasLength || !hasLower || !hasUpper || !hasNumber || !hasSpecial) {
alert('Password does not meet the strength requirements.');
return false;
}
return true;
}
// Initial check on page load
window.onload = function() {
if (document.getElementById('new_password').value) {
checkPasswordStrength();
}
if (document.getElementById('confirm_password').value) {
checkPasswordMatch();
}
};
</script>
{% endblock %}
@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block content %}
<div class="flex justify-center mt-10">
<div class="w-full max-w-md">
<div class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<h2 class="text-2xl font-bold text-red-600 mt-4">Password Reset Error</h2>
{% if error %}
<p class="mt-2 text-gray-600">{{ error }}</p>
{% else %}
<p class="mt-2 text-gray-600">The password reset link is invalid or has expired.</p>
{% endif %}
<p class="mt-4 text-gray-600">
Please request a new password reset link.
</p>
<div class="mt-8">
<a href="/auth/forgot-password" class="bg-irish-green hover:bg-opacity-90 text-white font-bold py-2 px-4 rounded">
Request New Password Reset
</a>
</div>
<div class="mt-4">
<a href="/auth/login" class="text-irish-green hover:underline">
Back to Login
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-md mx-auto my-8">
<div class="bg-white p-8 rounded-lg shadow-md">
<div class="text-center">
<i class="fas fa-exclamation-circle text-red-500 text-5xl mb-4"></i>
<h1 class="text-2xl font-bold text-red-600 mb-3">Verification Failed</h1>
{% if error %}
<p class="text-gray-600 mb-6">{{ error }}</p>
{% else %}
<p class="text-gray-600 mb-6">We couldn't verify your email address with the provided link.</p>
{% endif %}
<p class="text-gray-600 mb-6">If your verification link has expired, you can request a new one by logging in with your credentials.</p>
<a href="/auth/login" class="inline-block bg-irish-green text-white py-2 px-6 rounded-md hover:bg-opacity-90 transition">
Return to Login
</a>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-md mx-auto my-8">
<div class="bg-white p-8 rounded-lg shadow-md">
<div class="text-center">
<i class="fas fa-check-circle text-green-500 text-5xl mb-4"></i>
<h1 class="text-2xl font-bold text-irish-green mb-3">Email Verified!</h1>
<p class="text-gray-600 mb-6">Your email has been successfully verified. You can now log in to your account.</p>
<a href="/auth/login" class="inline-block bg-irish-green text-white py-2 px-6 rounded-md hover:bg-opacity-90 transition">
Log In
</a>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify Your Email - LeagueLedger</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #2D7738;
padding: 20px;
text-align: center;
color: white;
border-radius: 5px 5px 0 0;
}
.content {
padding: 20px;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 5px 5px;
}
.button {
display: inline-block;
background-color: #2D7738;
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 5px;
margin: 20px 0;
}
.footer {
margin-top: 20px;
font-size: 12px;
text-align: center;
color: #777;
}
</style>
</head>
<body>
<div class="header">
<h1>Verify Your Email Address</h1>
</div>
<div class="content">
<p>Hello {{ username }},</p>
<p>Thank you for registering with LeagueLedger! To complete your registration, please verify your email address by clicking the button below:</p>
<p style="text-align: center;">
<a href="{{ verification_link }}" class="button">Verify Email</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<p>{{ verification_link }}</p>
<p>If you did not create an account with us, please ignore this email.</p>
<p>Best regards,<br>The LeagueLedger Team</p>
</div>
<div class="footer">
<p>© LeagueLedger. All rights reserved.</p>
<p>This is an automated message, please do not reply to this email.</p>
</div>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Password Reset - LeagueLedger</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #2D7738;
padding: 20px;
text-align: center;
color: white;
border-radius: 5px 5px 0 0;
}
.content {
padding: 20px;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 5px 5px;
}
.button {
display: inline-block;
background-color: #2D7738;
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 5px;
margin: 20px 0;
}
.footer {
margin-top: 20px;
font-size: 12px;
text-align: center;
color: #777;
}
</style>
</head>
<body>
<div class="header">
<h1>LeagueLedger Password Reset</h1>
</div>
<div class="content">
<p>Hello {{ username }},</p>
<p>We received a request to reset your password for your LeagueLedger account. If you didn't make this request, you can safely ignore this email.</p>
<p>To reset your password, please click the button below:</p>
<p style="text-align: center;">
<a href="{{ reset_link }}" class="button">Reset Password</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<p>{{ reset_link }}</p>
<p>This link will expire in 24 hours.</p>
<p>If you have any questions, please contact us at {{ support_email }}</p>
<p>Best regards,<br>The LeagueLedger Team</p>
</div>
<div class="footer">
<p>© LeagueLedger. All rights reserved.</p>
<p>This is an automated message, please do not reply to this email.</p>
</div>
</body>
</html>
+78
View File
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to LeagueLedger</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #2D7738;
padding: 20px;
text-align: center;
color: white;
border-radius: 5px 5px 0 0;
}
.content {
padding: 20px;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 5px 5px;
}
.button {
display: inline-block;
background-color: #2D7738;
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 5px;
margin: 20px 0;
}
.footer {
margin-top: 20px;
font-size: 12px;
text-align: center;
color: #777;
}
</style>
</head>
<body>
<div class="header">
<h1>Welcome to LeagueLedger!</h1>
</div>
<div class="content">
<p>Hello {{ username }},</p>
<p>Welcome to LeagueLedger! We're excited to have you join our community.</p>
<p>With LeagueLedger, you can:</p>
<ul>
<li>Track your team's progress</li>
<li>Participate in events</li>
<li>Collect points and earn achievements</li>
<li>Connect with other teams and players</li>
</ul>
<p>If you have any questions or need assistance, feel free to reach out to our support team.</p>
<p style="text-align: center;">
<a href="{{ base_url }}/dashboard" class="button">Go to Dashboard</a>
</p>
<p>Best regards,<br>The LeagueLedger Team</p>
</div>
<div class="footer">
<p>© LeagueLedger. All rights reserved.</p>
<p>This is an automated message, please do not reply to this email.</p>
</div>
</body>
</html>
+17 -14
View File
@@ -1,19 +1,22 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-2xl mx-auto mt-8 p-8 bg-white rounded-lg shadow-md">
<div class="text-center">
<i class="fas fa-exclamation-circle text-red-600 text-5xl mb-4"></i>
<h1 class="text-2xl font-bold mb-4">{{ error_title|default("Error") }}</h1>
<p class="text-gray-700 mb-6">{{ error_message|default("An error occurred. Please try again.") }}</p>
<div class="mt-8">
<a href="/" class="text-irish-green hover:underline mr-6">
<i class="fas fa-home mr-1"></i> Go to Home
</a>
<a href="javascript:history.back()" class="text-irish-green hover:underline">
<i class="fas fa-arrow-left mr-1"></i> Go Back
</a>
</div>
<div class="flex flex-col items-center justify-center min-h-[60vh] px-4 py-12">
<div class="text-center">
<div class="mb-6">
<svg class="mx-auto h-16 w-16 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">Oops! Something went wrong</h1>
<p class="text-gray-600 mb-6">{{ error }}</p>
<div class="flex justify-center">
<a href="/" class="bg-irish-green hover:bg-opacity-90 text-white font-bold py-2 px-6 rounded-md mr-4">
Go Home
</a>
<button onclick="window.history.back()" class="border border-irish-green text-irish-green hover:bg-gray-100 font-bold py-2 px-6 rounded-md">
Go Back
</button>
</div>
</div>
</div>
{% endblock %}
+1
View File
@@ -0,0 +1 @@
# Utils package initialization
+199
View File
@@ -0,0 +1,199 @@
"""
Email utility module for LeagueLedger using FastAPI-Mail
"""
import os
from pathlib import Path
from typing import List, Dict, Any, Optional
from fastapi import BackgroundTasks
from fastapi_mail import FastMail, MessageSchema, ConnectionConfig, MessageType
from pydantic import EmailStr
from dotenv import load_dotenv
# Load environment variables if not already loaded
load_dotenv()
# Configure email connection
mail_config = ConnectionConfig(
MAIL_USERNAME=os.getenv("MAIL_USERNAME"),
MAIL_PASSWORD=os.getenv("MAIL_PASSWORD"),
MAIL_FROM=os.getenv("MAIL_FROM"),
MAIL_PORT=int(os.getenv("MAIL_PORT", 587)),
MAIL_SERVER=os.getenv("MAIL_SERVER"),
MAIL_FROM_NAME=os.getenv("MAIL_FROM_NAME", "LeagueLedger"),
MAIL_STARTTLS=os.getenv("MAIL_STARTTLS", "True").lower() in ("true", "1", "t"),
MAIL_SSL_TLS=os.getenv("MAIL_SSL_TLS", "False").lower() in ("true", "1", "t"),
USE_CREDENTIALS=os.getenv("MAIL_USE_CREDENTIALS", "True").lower() in ("true", "1", "t"),
VALIDATE_CERTS=os.getenv("MAIL_VALIDATE_CERTS", "True").lower() in ("true", "1", "t"),
TEMPLATE_FOLDER=Path(__file__).parent.parent / 'templates' / 'email',
)
# Create FastMail instance
mail = FastMail(mail_config)
async def send_email(
recipients: List[EmailStr],
subject: str,
body: str,
template_name: Optional[str] = None,
template_body: Optional[Dict[str, Any]] = None,
background_tasks: Optional[BackgroundTasks] = None,
subtype: MessageType = MessageType.html,
cc: Optional[List[EmailStr]] = None,
bcc: Optional[List[EmailStr]] = None,
attachments: Optional[List] = None,
headers: Optional[Dict[str, str]] = None,
) -> None:
"""
Send an email using FastAPI-Mail
Args:
recipients: List of recipient email addresses
subject: Email subject
body: Email body content (used if template_name is None)
template_name: Optional name of the template file in the TEMPLATE_FOLDER
template_body: Optional dictionary of template variables
background_tasks: Optional BackgroundTasks for sending email in background
subtype: Message type (html or plain)
cc: Optional list of CC recipients
bcc: Optional list of BCC recipients
attachments: Optional list of attachments
headers: Optional custom email headers
"""
# Create message schema with empty lists for optional parameters to prevent validation errors
message = MessageSchema(
subject=subject,
recipients=recipients,
body=body if not template_name else None,
template_body=template_body,
subtype=subtype,
cc=cc or [], # Use empty list if None
bcc=bcc or [], # Use empty list if None
attachments=attachments or [], # Use empty list if None
headers=headers,
)
# Send email
try:
if background_tasks:
if template_name:
background_tasks.add_task(mail.send_message, message, template_name=template_name)
else:
background_tasks.add_task(mail.send_message, message)
else:
if template_name:
await mail.send_message(message, template_name=template_name)
else:
await mail.send_message(message)
except Exception as e:
# Log the error but don't crash the application
print(f"Error sending email: {str(e)}")
# In a production app, you would use a proper logging system
async def send_password_reset_email(
email: EmailStr,
username: str,
reset_token: str,
background_tasks: Optional[BackgroundTasks] = None,
) -> None:
"""
Send password reset email
Args:
email: Recipient email address
username: User's username
reset_token: Password reset token
background_tasks: Optional BackgroundTasks for sending in background
"""
# Base URL for the application (should be configured in environment vars)
base_url = os.getenv("LEAGUELEDGER_BASE_URL", "http://localhost:8000")
reset_link = f"{base_url}/auth/reset-password?token={reset_token}"
# Template data
template_data = {
"username": username,
"reset_link": reset_link,
"support_email": os.getenv("MAIL_FROM", "support@leagueledger.net"),
"base_url": base_url,
}
# Send email
await send_email(
recipients=[email],
subject="Password Reset - LeagueLedger",
body="", # Empty as we're using a template
template_name="password_reset.html",
template_body=template_data,
background_tasks=background_tasks,
)
async def send_verification_email(
email: EmailStr,
username: str,
verification_token: str,
background_tasks: Optional[BackgroundTasks] = None,
) -> None:
"""
Send email verification link
Args:
email: Recipient email address
username: User's username
verification_token: Email verification token
background_tasks: Optional BackgroundTasks for sending in background
"""
# Base URL for the application
base_url = os.getenv("LEAGUELEDGER_BASE_URL", "http://localhost:8000")
verification_link = f"{base_url}/auth/verify-email?token={verification_token}"
# Template data
template_data = {
"username": username,
"verification_link": verification_link,
"base_url": base_url,
}
# Send email
await send_email(
recipients=[email],
subject="Verify Your Email - LeagueLedger",
body="", # Empty as we're using a template
template_name="email_verification.html",
template_body=template_data,
background_tasks=background_tasks,
)
async def send_welcome_email(
email: EmailStr,
username: str,
background_tasks: Optional[BackgroundTasks] = None,
) -> None:
"""
Send welcome email to new users
Args:
email: Recipient email address
username: User's username
background_tasks: Optional BackgroundTasks for sending in background
"""
# Get base URL from environment variables
base_url = os.getenv("LEAGUELEDGER_BASE_URL", "http://localhost:8000")
# Template data
template_data = {
"username": username,
"base_url": base_url,
}
# Send email
await send_email(
recipients=[email],
subject="Welcome to LeagueLedger!",
body="", # Empty as we're using a template
template_name="welcome.html",
template_body=template_data,
background_tasks=background_tasks,
)
+336 -20
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Request, Depends, Form, HTTPException, status
from fastapi import APIRouter, Request, Depends, Form, HTTPException, status, BackgroundTasks
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from typing import Optional
@@ -8,13 +8,14 @@ import uuid
import re
from starlette.status import HTTP_303_SEE_OTHER, HTTP_302_FOUND
from sqlalchemy.orm import Session
from datetime import datetime
from datetime import datetime, timedelta
from ..db import get_db
from ..models import User
from ..auth.oauth import authentik_oauth
from ..templates_config import templates
from ..security import verify_password, get_password_hash
from ..utils.mail import send_password_reset_email
router = APIRouter(prefix="/auth", tags=["Auth"])
@@ -52,15 +53,29 @@ async def login_post(
error = "Invalid password"
elif not user.is_active:
error = "This account has been deactivated"
# If there was an error, re-render the login page
if error:
elif not user.is_verified and not user.is_oauth_user:
# For unverified users, show special error message with option to resend verification
# Pass user ID in the template to enable resending verification email
return templates.TemplateResponse(
"auth/login.html",
{
"request": request,
"error": error,
"error": "Please verify your email address before logging in",
"show_oauth": True,
"oauth_provider_name": "Authentik",
"unverified_user_id": user.id,
"unverified_email": user.email
}
)
# If any error was detected, return to the login page with the error message
if error:
return templates.TemplateResponse(
"auth/login.html",
{
"request": request,
"error": error,
"show_oauth": True,
"oauth_provider_name": "Authentik"
}
)
@@ -95,6 +110,7 @@ async def register_page(request: Request, error: Optional[str] = None):
@router.post("/register", response_class=HTMLResponse)
async def register_post(
request: Request,
background_tasks: BackgroundTasks,
username: str = Form(...),
email: str = Form(...),
password: str = Form(...),
@@ -102,19 +118,186 @@ async def register_post(
db: Session = Depends(get_db)
):
"""Handle registration form submission"""
# This is a placeholder - implement real registration logic here
# Validate passwords match
if password != confirm_password:
return templates.TemplateResponse(
"auth/register.html",
{"request": request, "error": "Passwords do not match"}
)
# Validate password strength
password_validation_error = validate_password_strength(password)
if password_validation_error:
return templates.TemplateResponse(
"auth/register.html",
{"request": request, "error": password_validation_error}
)
# Check if username already exists
if db.query(User).filter(User.username == username).first():
return templates.TemplateResponse(
"auth/register.html",
{"request": request, "error": "Username already taken"}
)
# Check if email already exists
if db.query(User).filter(User.email == email).first():
return templates.TemplateResponse(
"auth/register.html",
{"request": request, "error": "Email already registered"}
)
try:
# Create the user with verification token
verification_token = secrets.token_urlsafe(32)
expiration = datetime.utcnow() + timedelta(hours=24)
new_user = User(
username=username,
email=email,
hashed_password=get_password_hash(password),
is_verified=False,
verification_token=verification_token,
verification_token_expires_at=expiration,
last_verification_email_sent=datetime.utcnow() # Add this line
)
db.add(new_user)
db.commit()
db.refresh(new_user)
# Send verification email
try:
from ..utils.mail import send_verification_email
await send_verification_email(
email=email,
username=username,
verification_token=verification_token,
background_tasks=background_tasks
)
except Exception as e:
print(f"Failed to send verification email: {str(e)}")
# We'll show success anyway, but log the error
# Return success template
return templates.TemplateResponse(
"auth/registration_success.html",
{"request": request, "email_verification_required": True}
)
except Exception as e:
print(f"Registration error: {str(e)}")
return templates.TemplateResponse(
"auth/register.html",
{"request": request, "error": "An error occurred during registration"}
)
# Check username and email uniqueness, then create user
@router.get("/verify-email", response_class=HTMLResponse)
async def verify_email(
request: Request,
token: str,
db: Session = Depends(get_db)
):
"""Verify user email address with verification token"""
# Find user by verification token
user = db.query(User).filter(User.verification_token == token).first()
# Check if token exists and hasn't expired
if not user or not user.verification_token_expires_at or user.verification_token_expires_at < datetime.utcnow():
return templates.TemplateResponse(
"auth/verification_error.html",
{"request": request, "error": "Invalid or expired verification link"}
)
# Mark user as verified and clear verification token
user.is_verified = True
user.verification_token = None
user.verification_token_expires_at = None
db.commit()
# Send welcome email in the background
try:
from ..utils.mail import send_welcome_email
background_tasks = BackgroundTasks()
background_tasks.add_task(
send_welcome_email,
email=user.email,
username=user.username
)
except Exception as e:
print(f"Failed to queue welcome email: {str(e)}")
# Redirect to login page with success message
return templates.TemplateResponse(
"auth/registration_success.html",
{"request": request}
"auth/verification_success.html",
{"request": request, "username": user.username}
)
@router.get("/resend-verification", response_class=HTMLResponse)
async def resend_verification(
request: Request,
user_id: int,
db: Session = Depends(get_db),
background_tasks: BackgroundTasks = BackgroundTasks()
):
"""Resend verification email with cooldown period"""
# Get the user
user = db.query(User).filter(User.id == user_id).first()
if not user:
return RedirectResponse(
"/auth/login?error=User+not+found",
status_code=HTTP_303_SEE_OTHER
)
# Check if user is already verified
if user.is_verified:
return RedirectResponse(
"/auth/login?message=Your+account+is+already+verified.+Please+log+in.",
status_code=HTTP_303_SEE_OTHER
)
# Check cooldown period (1 hour)
if user.last_verification_email_sent and (datetime.utcnow() - user.last_verification_email_sent) < timedelta(hours=1):
# Calculate time remaining in cooldown
time_since_last_email = datetime.utcnow() - user.last_verification_email_sent
minutes_remaining = max(0, 60 - int(time_since_last_email.total_seconds() / 60))
return RedirectResponse(
f"/auth/login?error=Verification+email+was+recently+sent.+Please+wait+{minutes_remaining}+minutes+before+requesting+another+one.",
status_code=HTTP_303_SEE_OTHER
)
# Generate new verification token
verification_token = secrets.token_urlsafe(32)
expiration = datetime.utcnow() + timedelta(hours=24)
# Update user record
user.verification_token = verification_token
user.verification_token_expires_at = expiration
user.last_verification_email_sent = datetime.utcnow()
db.commit()
# Send verification email
try:
from ..utils.mail import send_verification_email
await send_verification_email(
email=user.email,
username=user.username,
verification_token=verification_token,
background_tasks=background_tasks
)
return RedirectResponse(
"/auth/login?message=Verification+email+has+been+resent.+Please+check+your+inbox.",
status_code=HTTP_303_SEE_OTHER
)
except Exception as e:
print(f"Failed to send verification email: {str(e)}")
return RedirectResponse(
"/auth/login?error=Failed+to+send+verification+email.+Please+try+again+later.",
status_code=HTTP_303_SEE_OTHER
)
@router.get("/oauth-login")
async def oauth_login(request: Request):
"""Start the OAuth login flow"""
@@ -228,7 +411,7 @@ async def logout(request: Request):
return RedirectResponse("/", status_code=HTTP_303_SEE_OTHER)
@router.get("/profile", response_class=HTMLResponse)
async def profile_page(request: Request):
async def profile_page(request: Request, db: Session = Depends(get_db)):
"""User profile page"""
# Get the user ID from the session
user_id = request.session.get("user_id")
@@ -236,15 +419,13 @@ async def profile_page(request: Request):
if not user_id:
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
# Mock user data - in a real app, you'd fetch this from the database
user = {
"id": user_id,
"username": request.session.get("username", "User"),
"email": "user@example.com",
"is_admin": request.session.get("is_admin", False),
"created_at": "2023-01-01 12:00:00",
"picture": None
}
# Fetch the actual user data from the database
user = db.query(User).filter(User.id == user_id).first()
if not user:
# If user doesn't exist in the database but has a session, clear the session
request.session.clear()
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
return templates.TemplateResponse(
"auth/profile.html",
@@ -330,6 +511,141 @@ async def change_password_post(
status_code=HTTP_303_SEE_OTHER
)
@router.get("/forgot-password", response_class=HTMLResponse)
async def forgot_password_page(request: Request, error: Optional[str] = None, message: Optional[str] = None):
"""Forgot password page"""
return templates.TemplateResponse(
"auth/forgot_password.html",
{"request": request, "error": error, "message": message}
)
@router.post("/forgot-password", response_class=HTMLResponse)
async def forgot_password_post(
request: Request,
background_tasks: BackgroundTasks,
email: str = Form(...),
db: Session = Depends(get_db)
):
"""Handle forgot password form submission"""
try:
# Find user by email
user = db.query(User).filter(User.email == email).first()
# Always show success message even if email doesn't exist (security best practice)
if not user:
return templates.TemplateResponse(
"auth/forgot_password.html",
{
"request": request,
"message": "If your email is in our system, you will receive a password reset link shortly."
}
)
# Generate reset token
reset_token = secrets.token_urlsafe(32)
user.reset_token = reset_token
user.reset_token_expires_at = datetime.utcnow() + timedelta(hours=24)
db.commit()
try:
# Send reset email
await send_password_reset_email(
email=user.email,
username=user.username,
reset_token=reset_token,
background_tasks=background_tasks,
)
except Exception as e:
print(f"Failed to send password reset email: {str(e)}")
# We don't show this error to the user for security reasons
# In a production app, you would log this error properly
return templates.TemplateResponse(
"auth/forgot_password.html",
{
"request": request,
"message": "If your email is in our system, you will receive a password reset link shortly."
}
)
except Exception as e:
print(f"Password reset error: {str(e)}")
return templates.TemplateResponse(
"auth/forgot_password.html",
{
"request": request,
"error": "An error occurred. Please try again later."
}
)
@router.get("/reset-password", response_class=HTMLResponse)
async def reset_password_page(
request: Request,
token: str,
error: Optional[str] = None,
db: Session = Depends(get_db)
):
"""Reset password page"""
# Validate token
user = db.query(User).filter(User.reset_token == token).first()
# Check if token exists and hasn't expired
if not user or not user.reset_token_expires_at or user.reset_token_expires_at < datetime.utcnow():
return templates.TemplateResponse(
"auth/reset_password_error.html",
{"request": request, "error": "Invalid or expired reset token."}
)
return templates.TemplateResponse(
"auth/reset_password.html",
{"request": request, "token": token, "error": error}
)
@router.post("/reset-password", response_class=HTMLResponse)
async def reset_password_post(
request: Request,
token: str = Form(...),
new_password: str = Form(...),
confirm_password: str = Form(...),
db: Session = Depends(get_db)
):
"""Handle reset password form submission"""
# Validate token
user = db.query(User).filter(User.reset_token == token).first()
# Check if token exists and hasn't expired
if not user or not user.reset_token_expires_at or user.reset_token_expires_at < datetime.utcnow():
return templates.TemplateResponse(
"auth/reset_password_error.html",
{"request": request, "error": "Invalid or expired reset token."}
)
# Validate passwords
if new_password != confirm_password:
return templates.TemplateResponse(
"auth/reset_password.html",
{"request": request, "token": token, "error": "Passwords do not match."}
)
# Server-side password strength validation
password_validation_error = validate_password_strength(new_password)
if password_validation_error:
return templates.TemplateResponse(
"auth/reset_password.html",
{"request": request, "token": token, "error": password_validation_error}
)
# Update password and clear reset token
user.hashed_password = get_password_hash(new_password)
user.reset_token = None
user.reset_token_expires_at = None # Clear the token after use
db.commit()
# Redirect to login page with success message
return RedirectResponse(
"/auth/login?message=Password+has+been+reset+successfully.+Please+login+with+your+new+password.",
status_code=HTTP_303_SEE_OTHER
)
def validate_password_strength(password: str) -> Optional[str]:
"""
Validates password strength based on the following criteria:
+26 -1
View File
@@ -34,7 +34,19 @@ services:
DB_USER: "pubquiz_user"
DB_PASS: "pubquiz_pass"
PYTHONUNBUFFERED: "1"
LEAGUELEDGER_BASE_URL: "https://rover.leagueledger.net" # Base URL for QR codes
# Use environment variable from .env instead of hard-coding
LEAGUELEDGER_BASE_URL: ${LEAGUELEDGER_BASE_URL:-http://localhost:8000}
# Email configuration for Mailhog
MAIL_USERNAME: ""
MAIL_PASSWORD: ""
MAIL_FROM: "noreply@leagueledger.net"
MAIL_FROM_NAME: "LeagueLedger"
MAIL_PORT: 1025
MAIL_SERVER: "mailpit"
MAIL_STARTTLS: "False"
MAIL_SSL_TLS: "False"
MAIL_USE_CREDENTIALS: "False"
MAIL_VALIDATE_CERTS: "False"
command: uvicorn app.main:app --host 0.0.0.0 --reload
ports:
- "8000:8000"
@@ -55,4 +67,17 @@ services:
ports:
- "8001:80"
mailpit:
image: axllent/mailpit # Updated image name
container_name: pubquiz_mailpit
restart: unless-stopped
ports:
- "8025:8025" # Web UI
- "1025:1025" # SMTP Server
environment:
MH_STORAGE: "memory" # Store emails in memory (they will be lost on container restart)
MH_UI_WEB_PATH: "/" # Base path for the web UI
networks:
- default
# No persistent volumes defined - database will reset when container stops
+55
View File
@@ -0,0 +1,55 @@
# Email Testing with Mailhog
This project is configured to use Mailhog for email testing during development. Mailhog provides a fake SMTP server that captures all outgoing emails and displays them in a web interface instead of actually sending them.
## How it Works
When running the application in the Docker development environment, all emails are sent to the Mailhog container instead of real recipients. This allows you to test email functionality without worrying about sending actual emails.
## Viewing Captured Emails
1. Start the Docker containers using:
```
docker-compose up -d
```
2. Access the Mailhog web interface at:
```
http://localhost:8025
```
3. Any emails sent by the application will appear in this interface, where you can:
- View the email content (HTML and text versions)
- See all recipients, headers, and attachments
- Release emails to actually be delivered (if configured)
- Delete emails
## Configuration
The Mailhog SMTP server is configured with:
- Host: `mailhog`
- Port: `1025`
- No authentication required
These settings are already configured in the `.env` file and the Docker environment variables.
## Switching to Production Email
When deploying to production, update the SMTP configuration in the `.env` file to use your actual email service provider. There are commented-out production settings in the `.env` file that you can uncomment and configure.
## Troubleshooting
If emails aren't appearing in the Mailhog interface:
1. Make sure all containers are running:
```
docker-compose ps
```
2. Check the logs for errors:
```
docker-compose logs app
docker-compose logs mailhog
```
3. Verify that the application is using the correct SMTP settings by checking the environment variables passed to the app container.
+3
View File
@@ -30,6 +30,9 @@ email-validator>=2.0.0
pydantic>=2.3.0
qrcode>=7.4.2
# Email support
fastapi-mail>=1.4.2
# Image processing library for QR code generation
Pillow>=9.0.0