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:
|
||||
|
||||
Reference in New Issue
Block a user