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:
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Utils package initialization
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user