6c260c5936
- Created a new HTML template for account deletion confirmation with user-friendly messaging and a return link. - Developed a privacy settings page allowing users to control visibility of their personal information with appropriate messaging for success and error states. - Implemented a profile view template displaying user information, statistics, teams, and achievements based on user permissions. - Added responsive design elements and improved user interface with Tailwind CSS classes for better aesthetics and usability.
1157 lines
42 KiB
Python
1157 lines
42 KiB
Python
from fastapi import APIRouter, Request, Depends, Form, HTTPException, status, BackgroundTasks, UploadFile, File
|
|
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
from typing import Optional, List, Dict, Any
|
|
import secrets
|
|
import os
|
|
import uuid
|
|
import re
|
|
import shutil
|
|
from pathlib import Path
|
|
from starlette.status import HTTP_303_SEE_OTHER, HTTP_302_FOUND
|
|
from sqlalchemy.orm import Session
|
|
from datetime import datetime, timedelta
|
|
|
|
from ..db import get_db
|
|
from ..models import User
|
|
from ..auth.oauth import oauth_manager
|
|
from ..templates_config import templates
|
|
from ..security import verify_password, get_password_hash
|
|
from ..utils.mail import send_password_reset_email
|
|
|
|
router = APIRouter(tags=["Auth"])
|
|
|
|
@router.get("/login", response_class=HTMLResponse)
|
|
async def login_page(request: Request, error: Optional[str] = None, message: Optional[str] = None, db: Session = Depends(get_db)):
|
|
"""Login page route"""
|
|
# Check if user is already logged in
|
|
user = None
|
|
user_id = request.session.get("user_id")
|
|
if user_id:
|
|
user = db.query(User).get(user_id)
|
|
# If user is already logged in, show a message
|
|
if not message:
|
|
message = "You are already logged in."
|
|
|
|
# Get available OAuth providers for the login screen
|
|
oauth_providers = oauth_manager.get_available_providers()
|
|
|
|
return templates.TemplateResponse(
|
|
"auth/login.html",
|
|
{
|
|
"request": request,
|
|
"error": error,
|
|
"message": message,
|
|
"user": user,
|
|
"oauth_providers": oauth_providers
|
|
}
|
|
)
|
|
|
|
@router.post("/login", response_class=HTMLResponse)
|
|
async def login_post(
|
|
request: Request,
|
|
username: str = Form(...),
|
|
password: str = Form(...),
|
|
remember: Optional[bool] = Form(False),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Handle login form submission"""
|
|
error = None
|
|
|
|
# Look up the user by username or email
|
|
user = db.query(User).filter(
|
|
(User.username == username) | (User.email == username)
|
|
).first()
|
|
|
|
# Check if user exists and password is correct
|
|
if not user:
|
|
error = "Invalid username or email"
|
|
elif user.is_oauth_user and not user.hashed_password:
|
|
error = "This account uses OAuth for login. Please use the OAuth login option."
|
|
elif not verify_password(password, user.hashed_password):
|
|
error = "Invalid password"
|
|
elif not user.is_active:
|
|
error = "This account has been deactivated"
|
|
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": "Please verify your email address before logging in",
|
|
"oauth_providers": oauth_manager.get_available_providers(),
|
|
"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,
|
|
"oauth_providers": oauth_manager.get_available_providers()
|
|
}
|
|
)
|
|
|
|
# Update the last login timestamp
|
|
user.last_login = datetime.utcnow()
|
|
db.commit()
|
|
|
|
# Set session data
|
|
request.session["user_id"] = user.id
|
|
request.session["username"] = user.username
|
|
request.session["is_authenticated"] = True
|
|
request.session["is_admin"] = user.is_admin
|
|
|
|
# If remember me is checked, set session expiry to a longer time (30 days)
|
|
if remember:
|
|
# Session middleware handles this through cookies, so we just need to set the flag
|
|
request.session["remember_me"] = True
|
|
|
|
# Redirect to dashboard or previously requested page
|
|
next_page = request.query_params.get("next", "/dashboard")
|
|
return RedirectResponse(next_page, status_code=HTTP_303_SEE_OTHER)
|
|
|
|
@router.get("/register", response_class=HTMLResponse)
|
|
async def register_page(request: Request, error: Optional[str] = None, db: Session = Depends(get_db)):
|
|
"""Registration page route"""
|
|
# Check if user is already logged in
|
|
user = None
|
|
user_id = request.session.get("user_id")
|
|
if user_id:
|
|
user = db.query(User).get(user_id)
|
|
# If no specific error is set, inform user they're already registered
|
|
if not error:
|
|
error = "You are already registered and logged in. You can logout first if you want to create a new account."
|
|
|
|
return templates.TemplateResponse(
|
|
"auth/register.html",
|
|
{"request": request, "error": error, "user": user} # Add user to context
|
|
)
|
|
|
|
@router.post("/register", response_class=HTMLResponse)
|
|
async def register_post(
|
|
request: Request,
|
|
background_tasks: BackgroundTasks,
|
|
username: str = Form(...),
|
|
email: str = Form(...),
|
|
password: str = Form(...),
|
|
confirm_password: str = Form(...),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Handle registration form submission"""
|
|
# 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"}
|
|
)
|
|
|
|
@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/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/{provider_id}")
|
|
async def oauth_login(request: Request, provider_id: str):
|
|
"""Start the OAuth login flow for the specified provider"""
|
|
# Generate the redirect URI
|
|
base_url = str(request.base_url)
|
|
redirect_uri = f"{base_url}auth/oauth-callback/{provider_id}"
|
|
|
|
# Request a login URL from the provider
|
|
try:
|
|
# Set a session ID to validate the callback
|
|
if "session_id" not in request.session:
|
|
request.session["session_id"] = str(uuid.uuid4())
|
|
|
|
# Get the authorization URL
|
|
auth_url = await oauth_manager.get_login_url(request, provider_id, redirect_uri)
|
|
|
|
# Redirect to the authorization URL
|
|
return RedirectResponse(auth_url)
|
|
except Exception as e:
|
|
print(f"OAuth login error: {str(e)}")
|
|
return RedirectResponse(
|
|
f"/auth/login?error=OAuth+login+failed:+{str(e)}",
|
|
status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
@router.get("/oauth-callback/{provider_id}")
|
|
async def oauth_callback(
|
|
request: Request,
|
|
provider_id: str,
|
|
code: Optional[str] = None,
|
|
state: Optional[str] = None,
|
|
error: Optional[str] = None,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Handle the OAuth callback for the specified provider"""
|
|
if error:
|
|
return RedirectResponse(
|
|
f"/auth/login?error=OAuth+login+failed:+{error}",
|
|
status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
if not code:
|
|
return RedirectResponse(
|
|
"/auth/login?error=No+authorization+code+received",
|
|
status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
# Generate the redirect URI that matches the one used in the initial request
|
|
base_url = str(request.base_url)
|
|
redirect_uri = f"{base_url}auth/oauth-callback/{provider_id}"
|
|
|
|
try:
|
|
# Get normalized user info from the provider
|
|
user_info = await oauth_manager.get_user_info(request, provider_id, redirect_uri, code)
|
|
|
|
if not user_info:
|
|
return RedirectResponse(
|
|
"/auth/login?error=Could+not+retrieve+user+information",
|
|
status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
# Extract user details from the normalized OAuth info
|
|
provider_user_id = user_info.get("id")
|
|
email = user_info.get("email")
|
|
name = user_info.get("name") or (email.split("@")[0] if email else f"user_{provider_id}")
|
|
first_name = user_info.get("first_name")
|
|
last_name = user_info.get("last_name")
|
|
picture = user_info.get("picture")
|
|
|
|
if not email:
|
|
return RedirectResponse(
|
|
"/auth/login?error=Email+address+not+provided+by+the+OAuth+provider",
|
|
status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
# Check if the user already exists with this email
|
|
user = db.query(User).filter(User.email == email).first()
|
|
|
|
if not user:
|
|
# Set username to be unique if it already exists
|
|
username = name
|
|
base_username = username
|
|
counter = 1
|
|
|
|
# Check if username already exists
|
|
while db.query(User).filter(User.username == username).first():
|
|
username = f"{base_username}{counter}"
|
|
counter += 1
|
|
|
|
# Create a new user
|
|
user = User(
|
|
username=username,
|
|
email=email,
|
|
oauth_id=provider_user_id,
|
|
is_oauth_user=True,
|
|
oauth_provider=provider_id,
|
|
first_name=first_name,
|
|
last_name=last_name,
|
|
picture=picture,
|
|
is_verified=True # OAuth users are considered verified
|
|
)
|
|
db.add(user)
|
|
db.commit()
|
|
db.refresh(user)
|
|
else:
|
|
# Update existing user's OAuth information
|
|
if not user.is_oauth_user:
|
|
user.is_oauth_user = True
|
|
user.oauth_id = provider_user_id
|
|
|
|
# If user has a different OAuth provider, add this one as additional
|
|
if user.oauth_provider and user.oauth_provider != provider_id:
|
|
if not user.additional_oauth_providers:
|
|
user.additional_oauth_providers = {}
|
|
user.additional_oauth_providers[provider_id] = provider_user_id
|
|
else:
|
|
user.oauth_provider = provider_id
|
|
|
|
# Update profile info if not already set
|
|
if first_name and not user.first_name:
|
|
user.first_name = first_name
|
|
if last_name and not user.last_name:
|
|
user.last_name = last_name
|
|
|
|
# Only update profile picture if one doesn't exist yet or if it was never manually deleted
|
|
# We track manual deletion by setting a flag in the database
|
|
if picture and (user.picture is None and not user.picture_manually_deleted):
|
|
user.picture = picture
|
|
|
|
# Update last login time
|
|
user.last_login = datetime.utcnow()
|
|
|
|
db.commit()
|
|
|
|
# Set session data
|
|
request.session["user_id"] = user.id
|
|
request.session["username"] = user.username
|
|
request.session["is_authenticated"] = True
|
|
request.session["is_admin"] = user.is_admin
|
|
request.session["oauth_provider"] = provider_id # Store the provider for possible UI customization
|
|
|
|
# Redirect to dashboard or the requested next page
|
|
next_page = request.query_params.get("next", "/dashboard")
|
|
return RedirectResponse(next_page, status_code=HTTP_303_SEE_OTHER)
|
|
|
|
except Exception as e:
|
|
print(f"OAuth callback error: {str(e)}")
|
|
return RedirectResponse(
|
|
f"/auth/login?error=OAuth+login+failed:+{str(e)}",
|
|
status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
# Legacy route for backward compatibility
|
|
@router.get("/oauth-login")
|
|
async def legacy_oauth_login(request: Request):
|
|
"""Redirect to Authentik OAuth login for backward compatibility"""
|
|
return RedirectResponse("/auth/oauth-login/authentik", status_code=HTTP_302_FOUND)
|
|
|
|
@router.get("/oauth-callback")
|
|
async def legacy_oauth_callback(
|
|
request: Request,
|
|
code: Optional[str] = None,
|
|
state: Optional[str] = None,
|
|
error: Optional[str] = None,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Redirect to Authentik OAuth callback handler for backward compatibility"""
|
|
params = []
|
|
if code:
|
|
params.append(f"code={code}")
|
|
if state:
|
|
params.append(f"state={state}")
|
|
if error:
|
|
params.append(f"error={error}")
|
|
|
|
query_string = "&".join(params)
|
|
redirect_url = f"/auth/oauth-callback/authentik"
|
|
if query_string:
|
|
redirect_url = f"{redirect_url}?{query_string}"
|
|
|
|
return RedirectResponse(redirect_url, status_code=HTTP_302_FOUND)
|
|
|
|
@router.get("/logout")
|
|
async def logout(request: Request):
|
|
"""Log out the user"""
|
|
request.session.clear()
|
|
return RedirectResponse("/", status_code=HTTP_303_SEE_OTHER)
|
|
|
|
@router.get("/profile", response_class=HTMLResponse)
|
|
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")
|
|
|
|
if not user_id:
|
|
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
|
|
|
|
# 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",
|
|
{"request": request, "user": user}
|
|
)
|
|
|
|
@router.post("/update-profile-picture", response_class=HTMLResponse)
|
|
async def update_profile_picture(
|
|
request: Request,
|
|
file: UploadFile = File(...),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Handle profile picture upload"""
|
|
# Check if user is logged in
|
|
user_id = request.session.get("user_id")
|
|
if not user_id:
|
|
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
|
|
|
|
# Get user from database
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
request.session.clear()
|
|
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
|
|
|
|
# Validate file type
|
|
valid_extensions = [".jpg", ".jpeg", ".png"]
|
|
file_ext = os.path.splitext(file.filename)[1].lower()
|
|
|
|
if file_ext not in valid_extensions:
|
|
return templates.TemplateResponse(
|
|
"auth/profile.html",
|
|
{"request": request, "user": user, "error": "Invalid file type. Only JPG and PNG are allowed."}
|
|
)
|
|
|
|
# Create directory if it doesn't exist
|
|
upload_dir = Path("app/static/uploads/profile_pictures")
|
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Generate unique filename
|
|
unique_filename = f"{uuid.uuid4()}{file_ext}"
|
|
file_path = upload_dir / unique_filename
|
|
|
|
# Save the file
|
|
with open(file_path, "wb") as buffer:
|
|
shutil.copyfileobj(file.file, buffer)
|
|
|
|
# Update user profile with the picture URL
|
|
user.picture = f"/static/uploads/profile_pictures/{unique_filename}"
|
|
user.picture_manually_deleted = False # Reset manual deletion flag
|
|
db.commit()
|
|
|
|
# Redirect back to profile with success message
|
|
return RedirectResponse(
|
|
"/auth/profile?message=Profile+picture+updated+successfully",
|
|
status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
@router.post("/delete-profile-picture", response_class=HTMLResponse)
|
|
async def delete_profile_picture(
|
|
request: Request,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Handle profile picture deletion"""
|
|
# Check if user is logged in
|
|
user_id = request.session.get("user_id")
|
|
if not user_id:
|
|
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
|
|
|
|
# Get user from database
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
request.session.clear()
|
|
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
|
|
|
|
# Only proceed if user has a profile picture
|
|
if user.picture:
|
|
# Get the file path
|
|
image_path = user.picture.replace("/static/", "app/static/")
|
|
|
|
# Try to delete the file if it exists
|
|
try:
|
|
if os.path.exists(image_path):
|
|
os.remove(image_path)
|
|
except Exception as e:
|
|
print(f"Error deleting profile picture file: {str(e)}")
|
|
# Continue anyway since we still want to clear the database entry
|
|
|
|
# Clear the picture field in the database and set the manually deleted flag
|
|
user.picture = None
|
|
user.picture_manually_deleted = True
|
|
db.commit()
|
|
|
|
# Redirect back to profile with success message
|
|
return RedirectResponse(
|
|
"/auth/profile?message=Profile+picture+deleted+successfully",
|
|
status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
@router.post("/update-username", response_class=HTMLResponse)
|
|
async def update_username(
|
|
request: Request,
|
|
username: str = Form(...),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Handle username update"""
|
|
# Check if user is logged in
|
|
user_id = request.session.get("user_id")
|
|
if not user_id:
|
|
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
|
|
|
|
# Get user from database
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
request.session.clear()
|
|
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
|
|
|
|
# Check if username is unchanged
|
|
if user.username == username:
|
|
return RedirectResponse(
|
|
"/auth/profile?message=No+changes+made+to+username",
|
|
status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
# Validate username
|
|
if len(username) < 3:
|
|
return templates.TemplateResponse(
|
|
"auth/profile.html",
|
|
{"request": request, "user": user, "error": "Username must be at least 3 characters long"}
|
|
)
|
|
|
|
if len(username) > 30:
|
|
return templates.TemplateResponse(
|
|
"auth/profile.html",
|
|
{"request": request, "user": user, "error": "Username must be less than 30 characters long"}
|
|
)
|
|
|
|
# Check if username contains only allowed characters (alphanumeric, underscore, hyphen)
|
|
if not re.match(r'^[a-zA-Z0-9_-]+$', username):
|
|
return templates.TemplateResponse(
|
|
"auth/profile.html",
|
|
{"request": request, "user": user, "error": "Username can only contain letters, numbers, underscores and hyphens"}
|
|
)
|
|
|
|
# Check if username already exists
|
|
existing_user = db.query(User).filter(User.username == username).first()
|
|
if existing_user:
|
|
return templates.TemplateResponse(
|
|
"auth/profile.html",
|
|
{"request": request, "user": user, "error": "Username already taken"}
|
|
)
|
|
|
|
# Update user's username
|
|
old_username = user.username
|
|
user.username = username
|
|
|
|
# Update session with new username
|
|
request.session["username"] = username
|
|
|
|
try:
|
|
db.commit()
|
|
return RedirectResponse(
|
|
"/auth/profile?message=Username+updated+successfully",
|
|
status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
except Exception as e:
|
|
db.rollback()
|
|
print(f"Error updating username: {str(e)}")
|
|
return templates.TemplateResponse(
|
|
"auth/profile.html",
|
|
{"request": request, "user": user, "error": "An error occurred while updating your username"}
|
|
)
|
|
|
|
@router.get("/change-password", response_class=HTMLResponse)
|
|
async def change_password_page(request: Request, error: Optional[str] = None, message: Optional[str] = None):
|
|
"""Change password page"""
|
|
# Check if user is logged in
|
|
user_id = request.session.get("user_id")
|
|
if not user_id:
|
|
return RedirectResponse("/auth/login?next=/auth/change-password", status_code=HTTP_303_SEE_OTHER)
|
|
|
|
return templates.TemplateResponse(
|
|
"auth/change_password.html",
|
|
{"request": request, "error": error, "message": message}
|
|
)
|
|
|
|
@router.post("/change-password", response_class=HTMLResponse)
|
|
async def change_password_post(
|
|
request: Request,
|
|
current_password: str = Form(...),
|
|
new_password: str = Form(...),
|
|
confirm_password: str = Form(...),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Handle change password form submission"""
|
|
# Check if user is logged in
|
|
user_id = request.session.get("user_id")
|
|
if not user_id:
|
|
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
|
|
|
|
# Validate form data
|
|
if new_password != confirm_password:
|
|
return templates.TemplateResponse(
|
|
"auth/change_password.html",
|
|
{"request": request, "error": "New passwords do not match"}
|
|
)
|
|
|
|
# Get user from database
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
request.session.clear()
|
|
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
|
|
|
|
# Check if this is an OAuth user without a password
|
|
if user.is_oauth_user and not user.hashed_password:
|
|
return templates.TemplateResponse(
|
|
"auth/change_password.html",
|
|
{"request": request, "error": "OAuth users cannot change passwords this way"}
|
|
)
|
|
|
|
# Verify current password
|
|
if not verify_password(current_password, user.hashed_password):
|
|
return templates.TemplateResponse(
|
|
"auth/change_password.html",
|
|
{"request": request, "error": "Current password is incorrect"}
|
|
)
|
|
|
|
# Check if new password is the same as current password
|
|
if current_password == new_password:
|
|
return templates.TemplateResponse(
|
|
"auth/change_password.html",
|
|
{"request": request, "error": "New password must be different from your current password"}
|
|
)
|
|
|
|
# Server-side password strength validation
|
|
password_validation_error = validate_password_strength(new_password)
|
|
if password_validation_error:
|
|
return templates.TemplateResponse(
|
|
"auth/change_password.html",
|
|
{"request": request, "error": password_validation_error}
|
|
)
|
|
|
|
# Update password
|
|
user.hashed_password = get_password_hash(new_password)
|
|
db.commit()
|
|
|
|
# Redirect to profile page with success message
|
|
return RedirectResponse(
|
|
"/auth/profile?message=Password+changed+successfully",
|
|
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
|
|
)
|
|
|
|
@router.post("/delete-account", response_class=HTMLResponse)
|
|
async def delete_account(
|
|
request: Request,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Handle account deletion"""
|
|
# Check if user is logged in
|
|
user_id = request.session.get("user_id")
|
|
if not user_id:
|
|
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
|
|
|
|
# Get user from database
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
request.session.clear()
|
|
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
|
|
|
|
# Don't allow admins to delete their accounts through this flow
|
|
# to prevent accidentally removing the only admin account
|
|
if user.is_admin:
|
|
return templates.TemplateResponse(
|
|
"auth/profile.html",
|
|
{"request": request, "user": user, "error": "Admin accounts cannot be deleted through this page. Please contact the system administrator."}
|
|
)
|
|
|
|
try:
|
|
# Handle team memberships (anonymize rather than delete)
|
|
from ..models import TeamMembership, TeamJoinRequest, UserPoints, EventAttendee
|
|
|
|
# Get all team memberships
|
|
memberships = db.query(TeamMembership).filter(TeamMembership.user_id == user_id).all()
|
|
|
|
# Clean up any pending join requests
|
|
db.query(TeamJoinRequest).filter(TeamJoinRequest.user_id == user_id).delete()
|
|
|
|
# Instead of deleting data completely, we'll anonymize it to keep integrity
|
|
# Update the username and email to indicate this is a deleted account
|
|
anonymous_username = f"deleted_user_{user_id}"
|
|
anonymous_email = f"deleted_{user_id}@deleted.user"
|
|
|
|
user.username = anonymous_username
|
|
user.email = anonymous_email
|
|
user.is_active = False
|
|
user.hashed_password = None
|
|
user.picture = None
|
|
user.first_name = None
|
|
user.last_name = None
|
|
user.oauth_id = None
|
|
user.oauth_provider = None
|
|
user.additional_oauth_providers = None
|
|
|
|
# Mark account as deactivated
|
|
db.commit()
|
|
|
|
# Clear session
|
|
request.session.clear()
|
|
|
|
# Show success page
|
|
return templates.TemplateResponse(
|
|
"auth/account_deleted.html",
|
|
{"request": request}
|
|
)
|
|
except Exception as e:
|
|
db.rollback()
|
|
print(f"Error deleting account: {str(e)}")
|
|
return templates.TemplateResponse(
|
|
"auth/profile.html",
|
|
{"request": request, "user": user, "error": "An error occurred while deleting your account. Please try again later."}
|
|
)
|
|
|
|
@router.get("/privacy-settings", response_class=HTMLResponse)
|
|
async def privacy_settings_page(request: Request, db: Session = Depends(get_db)):
|
|
"""Display privacy settings page"""
|
|
# Check if user is logged in
|
|
user_id = request.session.get("user_id")
|
|
if not user_id:
|
|
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
|
|
|
|
# Get user from database
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
request.session.clear()
|
|
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
|
|
|
|
# Get current privacy settings
|
|
privacy_settings = user.get_privacy_settings()
|
|
|
|
return templates.TemplateResponse(
|
|
"auth/privacy_settings.html",
|
|
{
|
|
"request": request,
|
|
"user": user,
|
|
"privacy_settings": privacy_settings,
|
|
"privacy_options": [
|
|
{"value": "public", "label": "Everyone", "description": "Visible to all users"},
|
|
{"value": "friends", "label": "Team Members", "description": "Only visible to members of your teams"},
|
|
{"value": "private", "label": "Private", "description": "Only visible to you and admins"}
|
|
]
|
|
}
|
|
)
|
|
|
|
@router.post("/privacy-settings", response_class=HTMLResponse)
|
|
async def update_privacy_settings(
|
|
request: Request,
|
|
email_visibility: str = Form(...),
|
|
full_name_visibility: str = Form(...),
|
|
teams_visibility: str = Form(...),
|
|
points_visibility: str = Form(...),
|
|
achievements_visibility: str = Form(...),
|
|
events_visibility: str = Form(...),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Handle privacy settings update"""
|
|
# Check if user is logged in
|
|
user_id = request.session.get("user_id")
|
|
if not user_id:
|
|
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
|
|
|
|
# Get user from database
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
request.session.clear()
|
|
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
|
|
|
|
# Validate inputs
|
|
valid_options = ["public", "friends", "private"]
|
|
privacy_settings = {
|
|
"email": email_visibility if email_visibility in valid_options else "private",
|
|
"full_name": full_name_visibility if full_name_visibility in valid_options else "friends",
|
|
"teams": teams_visibility if teams_visibility in valid_options else "public",
|
|
"points": points_visibility if points_visibility in valid_options else "public",
|
|
"achievements": achievements_visibility if achievements_visibility in valid_options else "public",
|
|
"events": events_visibility if events_visibility in valid_options else "friends"
|
|
}
|
|
|
|
# Update user privacy settings
|
|
user.privacy_settings = privacy_settings
|
|
db.commit()
|
|
|
|
# Redirect back to privacy settings with success message
|
|
return RedirectResponse(
|
|
"/auth/privacy-settings?message=Privacy+settings+updated+successfully",
|
|
status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
@router.get("/user/{user_id}", response_class=HTMLResponse)
|
|
async def view_user_profile(
|
|
request: Request,
|
|
user_id: int,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""View another user's profile with privacy settings applied"""
|
|
# Check if the requested user exists
|
|
profile_user = db.query(User).filter(User.id == user_id).first()
|
|
if not profile_user:
|
|
return templates.TemplateResponse(
|
|
"error.html",
|
|
{"request": request, "error": "User not found"}
|
|
)
|
|
|
|
# Get current logged-in user (if any)
|
|
current_user_id = request.session.get("user_id")
|
|
current_user = None
|
|
if current_user_id:
|
|
current_user = db.query(User).filter(User.id == current_user_id).first()
|
|
|
|
# Check if the user is viewing their own profile
|
|
if current_user_id and current_user_id == user_id:
|
|
return RedirectResponse("/auth/profile", status_code=HTTP_303_SEE_OTHER)
|
|
|
|
# Import privacy utilities
|
|
from ..utils.auth import get_viewable_profile_data, check_privacy_permission
|
|
|
|
# Get viewable profile data based on privacy settings
|
|
profile_data = get_viewable_profile_data(db, profile_user, current_user_id)
|
|
|
|
# If the user can view teams, fetch team data
|
|
teams = []
|
|
if profile_data["can_view_teams"]:
|
|
from ..models import TeamMembership, Team
|
|
team_memberships = db.query(TeamMembership, Team).join(
|
|
Team, TeamMembership.team_id == Team.id
|
|
).filter(
|
|
TeamMembership.user_id == user_id
|
|
).all()
|
|
|
|
teams = [
|
|
{
|
|
"id": team.id,
|
|
"name": team.name,
|
|
"is_captain": membership.is_captain
|
|
} for membership, team in team_memberships
|
|
]
|
|
|
|
# If the user can view points, fetch points data
|
|
total_points = 0
|
|
if profile_data["can_view_points"]:
|
|
from ..models import UserPoints
|
|
points_records = db.query(UserPoints).filter(UserPoints.user_id == user_id).all()
|
|
total_points = sum(record.points for record in points_records)
|
|
|
|
return templates.TemplateResponse(
|
|
"auth/view_profile.html",
|
|
{
|
|
"request": request,
|
|
"profile": profile_data,
|
|
"profile_user_id": user_id,
|
|
"user": current_user, # Pass the current user for menu display
|
|
"teams": teams,
|
|
"total_points": total_points,
|
|
}
|
|
)
|
|
|
|
def validate_password_strength(password: str) -> Optional[str]:
|
|
"""
|
|
Validates password strength based on the following criteria:
|
|
- At least 8 characters long
|
|
- Contains at least one lowercase letter
|
|
- Contains at least one uppercase letter
|
|
- Contains at least one digit
|
|
- Contains at least one special character
|
|
|
|
Returns error message if validation fails, None if password is valid
|
|
"""
|
|
if len(password) < 8:
|
|
return "Password must be at least 8 characters long"
|
|
|
|
if not re.search(r"[a-z]", password):
|
|
return "Password must contain at least one lowercase letter"
|
|
|
|
if not re.search(r"[A-Z]", password):
|
|
return "Password must contain at least one uppercase letter"
|
|
|
|
if not re.search(r"\d", password):
|
|
return "Password must contain at least one number"
|
|
|
|
if not re.search(r"[!@#$%^&*(),.?\":{}|<>]", password):
|
|
return "Password must contain at least one special character"
|
|
|
|
return None
|