@@ -37,10 +44,9 @@
diff --git a/app/templates/redeem.html b/app/templates/redeem.html
index 464cfcd..1fbb72e 100644
--- a/app/templates/redeem.html
+++ b/app/templates/redeem.html
@@ -17,13 +17,14 @@
Select Your Team
-
Choose which team should receive these points.
+
Choose which of your teams should receive these points.
diff --git a/app/views/auth.py b/app/views/auth.py
index 30738c9..8d6ad34 100644
--- a/app/views/auth.py
+++ b/app/views/auth.py
@@ -5,13 +5,16 @@ from typing import Optional
import secrets
import os
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 ..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
router = APIRouter(prefix="/auth", tags=["Auth"])
@@ -33,12 +36,53 @@ async def login_post(
db: Session = Depends(get_db)
):
"""Handle login form submission"""
- # This is a placeholder - implement real login logic here
- error = "This login method is not fully implemented yet"
- return templates.TemplateResponse(
- "auth/login.html",
- {"request": request, "error": error, "show_oauth": True, "oauth_provider_name": "Authentik"}
- )
+ 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"
+
+ # If there was an error, re-render the login page
+ if error:
+ return templates.TemplateResponse(
+ "auth/login.html",
+ {
+ "request": request,
+ "error": error,
+ "show_oauth": True,
+ "oauth_provider_name": "Authentik"
+ }
+ )
+
+ # 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):
@@ -206,3 +250,110 @@ async def profile_page(request: Request):
"auth/profile.html",
{"request": request, "user": user}
)
+
+@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
+ )
+
+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
diff --git a/app/views/redeem.py b/app/views/redeem.py
index 8c3adbc..c0252dd 100644
--- a/app/views/redeem.py
+++ b/app/views/redeem.py
@@ -73,13 +73,43 @@ def redeem_code(code: str, request: Request, db: Session = Depends(get_db)):
}
)
- # Get all available teams
- all_teams = db.query(Team).all()
+ # Get only teams the user is a member of, if logged in
+ user_teams = []
+ if user:
+ # Query teams where the user is a member using TeamMembership relation
+ user_teams = (
+ db.query(Team)
+ .join(TeamMembership, Team.id == TeamMembership.team_id)
+ .filter(TeamMembership.user_id == user.id)
+ .all()
+ )
+
+ # If user is not logged in or has no teams, instruct them to log in or join teams
+ if not user:
+ return templates.TemplateResponse(
+ "error.html",
+ {
+ "request": request,
+ "error_title": "Login Required",
+ "error_message": "You must be logged in to redeem QR codes. Please log in and try again.",
+ "user": None
+ }
+ )
+ elif not user_teams:
+ return templates.TemplateResponse(
+ "error.html",
+ {
+ "request": request,
+ "error_title": "No Teams Available",
+ "error_message": "You are not a member of any teams. Please join or create a team before redeeming QR codes.",
+ "user": user
+ }
+ )
return templates.TemplateResponse("redeem.html", {
"request": request,
"ticket": qr_code, # Using the same template variable name for compatibility
- "user_teams": all_teams,
+ "user_teams": user_teams,
"has_achievement": bool(qr_code.achievement_name),
"base_url": BASE_URL,
"user": user # Add user to the context
@@ -115,6 +145,16 @@ async def apply_code(
user_id = request.session.get("user_id")
if user_id:
user = db.query(User).get(user_id)
+ else:
+ return templates.TemplateResponse(
+ "error.html",
+ {
+ "request": request,
+ "error_title": "Login Required",
+ "error_message": "You must be logged in to redeem QR codes.",
+ "user": None
+ }
+ )
# Get form data
form_data = await request.form()
@@ -168,6 +208,7 @@ async def apply_code(
}
)
+ # Verify the team exists
team = db.query(Team).filter_by(id=team_id).first()
if not team:
return templates.TemplateResponse(
@@ -179,6 +220,23 @@ async def apply_code(
"user": user # Add user to the context
}
)
+
+ # Verify the user is a member of the selected team
+ is_team_member = db.query(TeamMembership).filter_by(
+ user_id=user.id,
+ team_id=team.id
+ ).first() is not None
+
+ if not is_team_member:
+ return templates.TemplateResponse(
+ "error.html",
+ {
+ "request": request,
+ "error_title": "Not a Team Member",
+ "error_message": "You can only redeem points for teams you are a member of.",
+ "user": user # Add user to the context
+ }
+ )
# Mark the QR code as redeemed
qr_code.redeemed_at_team = team.id