Merge pull request #139 from christianlouis/christianlouis/issue51

Add account deletion confirmation, privacy settings, and profile view templates
This commit is contained in:
Christian Krakau-Louis
2025-04-16 21:47:35 +02:00
committed by GitHub
16 changed files with 1169 additions and 145 deletions
+54 -6
View File
@@ -71,32 +71,80 @@ def seed_db():
username="admin",
email="admin@example.com",
hashed_password=get_password_hash("password"),
is_admin=True # Set admin privileges
is_admin=True, # Set admin privileges
privacy_settings={
"email": "private",
"full_name": "friends",
"teams": "public",
"points": "public",
"achievements": "public",
"events": "friends"
}
),
User(
username="john_quizmaster",
email="john@example.com",
hashed_password=get_password_hash("password123")
hashed_password=get_password_hash("password123"),
privacy_settings={
"email": "friends",
"full_name": "public",
"teams": "public",
"points": "public",
"achievements": "public",
"events": "public"
}
),
User(
username="sarah_johnson",
email="sarah@example.com",
hashed_password=get_password_hash("password123")
hashed_password=get_password_hash("password123"),
privacy_settings={
"email": "private",
"full_name": "public",
"teams": "public",
"points": "friends",
"achievements": "public",
"events": "friends"
}
),
User(
username="mike_peters",
email="mike@example.com",
hashed_password=get_password_hash("password123")
hashed_password=get_password_hash("password123"),
privacy_settings={
"email": "private",
"full_name": "friends",
"teams": "public",
"points": "public",
"achievements": "public",
"events": "public"
}
),
User(
username="emma_wilson",
email="emma@example.com",
hashed_password=get_password_hash("password123")
hashed_password=get_password_hash("password123"),
privacy_settings={
"email": "private",
"full_name": "private",
"teams": "friends",
"points": "private",
"achievements": "friends",
"events": "private"
}
),
User(
username="robert_brown",
email="robert@example.com",
hashed_password=get_password_hash("password123")
hashed_password=get_password_hash("password123"),
privacy_settings={
"email": "private",
"full_name": "friends",
"teams": "public",
"points": "public",
"achievements": "public",
"events": "friends"
}
),
]
db.add_all(users)
+64
View File
@@ -110,6 +110,12 @@ def run_migrations(engine):
# Add first_name and last_name columns if they don't exist
add_name_columns(connection)
# Add privacy_settings column if it doesn't exist
add_privacy_settings_column(connection)
# Add picture_manually_deleted column if it doesn't exist
add_picture_manually_deleted_column(connection)
print("Migrations completed successfully")
except Exception as e:
@@ -175,3 +181,61 @@ def add_name_columns(connection):
print("Column last_name already exists")
except Exception as e:
print(f"Error adding name columns: {str(e)}")
def add_privacy_settings_column(connection):
"""Add privacy_settings column to users table"""
try:
# Use database-agnostic way to check if column exists
inspector = inspect(engine)
columns = [col['name'] for col in inspector.get_columns('users')]
if 'privacy_settings' not in columns:
print("Adding privacy_settings column to users table")
# Add column with database-specific syntax
if engine.name == 'sqlite':
connection.execute(text("""
ALTER TABLE users
ADD COLUMN privacy_settings JSON
"""))
else: # MySQL
connection.execute(text("""
ALTER TABLE users
ADD COLUMN privacy_settings JSON NULL
"""))
connection.commit()
print("Successfully added privacy_settings column to users table")
else:
print("Column privacy_settings already exists")
except Exception as e:
print(f"Error adding privacy_settings column: {str(e)}")
def add_picture_manually_deleted_column(connection):
"""Add picture_manually_deleted column to users table"""
try:
# Use database-agnostic way to check if column exists
inspector = inspect(engine)
columns = [col['name'] for col in inspector.get_columns('users')]
if 'picture_manually_deleted' not in columns:
print("Adding picture_manually_deleted column to users table")
# Add column with database-specific syntax
if engine.name == 'sqlite':
connection.execute(text("""
ALTER TABLE users
ADD COLUMN picture_manually_deleted BOOLEAN DEFAULT FALSE
"""))
else: # MySQL
connection.execute(text("""
ALTER TABLE users
ADD COLUMN picture_manually_deleted BOOLEAN DEFAULT FALSE
"""))
connection.commit()
print("Successfully added picture_manually_deleted column to users table")
else:
print("Column picture_manually_deleted already exists")
except Exception as e:
print(f"Error adding picture_manually_deleted column: {str(e)}")
+22
View File
@@ -39,6 +39,11 @@ class User(Base, BaseUser):
first_name = Column(String(50), nullable=True)
last_name = Column(String(50), nullable=True)
picture = Column(String(255), nullable=True) # URL to profile picture
picture_manually_deleted = Column(Boolean, default=False) # Track if user has deleted their profile picture
# Privacy settings - JSON field to store privacy preferences
# Default: { "email": "private", "teams": "public", "points": "public", "achievements": "public" }
privacy_settings = Column(JSON, nullable=True)
# Relationships
memberships = relationship("TeamMembership", back_populates="user")
@@ -62,6 +67,23 @@ class User(Base, BaseUser):
"""Return the identity of this user."""
return str(self.id)
def get_default_privacy_settings(self):
"""Return the default privacy settings if none are set"""
return {
"email": "private",
"full_name": "friends",
"teams": "public",
"points": "public",
"achievements": "public",
"events": "friends"
}
def get_privacy_settings(self):
"""Get user's privacy settings or default if not set"""
if not self.privacy_settings:
return self.get_default_privacy_settings()
return self.privacy_settings
def __repr__(self):
return f"<User {self.username}>"
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

+28
View File
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-md mx-auto my-10 bg-white p-8 rounded-lg shadow-md">
<div class="flex flex-col items-center justify-center">
<div class="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
<i class="fas fa-user-slash text-red-600 text-2xl"></i>
</div>
<h1 class="text-2xl font-bold text-gray-800 mb-2">Account Deleted</h1>
<p class="text-gray-600 text-center mb-6">
Your account has been successfully deleted. All your personal information has been removed from our system.
</p>
<div class="border-t border-gray-200 w-full pt-6 mt-2">
<p class="text-gray-600 text-center mb-4">
We're sorry to see you go. You can always create a new account if you wish to return.
</p>
<div class="flex justify-center">
<a href="/" class="bg-irish-green hover:bg-opacity-90 text-white font-semibold py-2 px-4 rounded-md transition">
Return to Homepage
</a>
</div>
</div>
</div>
</div>
{% endblock %}
+152
View File
@@ -0,0 +1,152 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-3xl mx-auto my-8">
<div class="bg-white p-8 rounded-lg shadow-md">
<h1 class="text-2xl font-bold text-irish-green mb-6">Privacy Settings</h1>
<!-- Display messages if present -->
{% if request.query_params.message %}
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{{ request.query_params.message }}
</div>
{% endif %}
<!-- Display errors if present -->
{% if error %}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{{ error }}
</div>
{% endif %}
<p class="text-gray-600 mb-6">
Control who can see different parts of your profile information. Your information can be visible to everyone,
only members of your teams, or kept private (visible only to you and admins).
</p>
<form method="post" action="/auth/privacy-settings">
<div class="space-y-6">
<!-- Email Visibility -->
<div class="border-b pb-4">
<h3 class="font-medium text-gray-800 mb-3">Email Address</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% for option in privacy_options %}
<label class="flex items-center p-4 border rounded-lg {% if privacy_settings.email == option.value %}bg-green-50 border-irish-green{% endif %}">
<input type="radio" name="email_visibility" value="{{ option.value }}"
{% if privacy_settings.email == option.value %}checked{% endif %}
class="mr-2 text-irish-green focus:ring-irish-green">
<div>
<span class="font-medium block text-sm">{{ option.label }}</span>
<span class="text-xs text-gray-500">{{ option.description }}</span>
</div>
</label>
{% endfor %}
</div>
</div>
<!-- Full Name Visibility -->
<div class="border-b pb-4">
<h3 class="font-medium text-gray-800 mb-3">Full Name</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% for option in privacy_options %}
<label class="flex items-center p-4 border rounded-lg {% if privacy_settings.full_name == option.value %}bg-green-50 border-irish-green{% endif %}">
<input type="radio" name="full_name_visibility" value="{{ option.value }}"
{% if privacy_settings.full_name == option.value %}checked{% endif %}
class="mr-2 text-irish-green focus:ring-irish-green">
<div>
<span class="font-medium block text-sm">{{ option.label }}</span>
<span class="text-xs text-gray-500">{{ option.description }}</span>
</div>
</label>
{% endfor %}
</div>
</div>
<!-- Teams Visibility -->
<div class="border-b pb-4">
<h3 class="font-medium text-gray-800 mb-3">Teams Membership</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% for option in privacy_options %}
<label class="flex items-center p-4 border rounded-lg {% if privacy_settings.teams == option.value %}bg-green-50 border-irish-green{% endif %}">
<input type="radio" name="teams_visibility" value="{{ option.value }}"
{% if privacy_settings.teams == option.value %}checked{% endif %}
class="mr-2 text-irish-green focus:ring-irish-green">
<div>
<span class="font-medium block text-sm">{{ option.label }}</span>
<span class="text-xs text-gray-500">{{ option.description }}</span>
</div>
</label>
{% endfor %}
</div>
</div>
<!-- Points Visibility -->
<div class="border-b pb-4">
<h3 class="font-medium text-gray-800 mb-3">Points & Leaderboard Position</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% for option in privacy_options %}
<label class="flex items-center p-4 border rounded-lg {% if privacy_settings.points == option.value %}bg-green-50 border-irish-green{% endif %}">
<input type="radio" name="points_visibility" value="{{ option.value }}"
{% if privacy_settings.points == option.value %}checked{% endif %}
class="mr-2 text-irish-green focus:ring-irish-green">
<div>
<span class="font-medium block text-sm">{{ option.label }}</span>
<span class="text-xs text-gray-500">{{ option.description }}</span>
</div>
</label>
{% endfor %}
</div>
</div>
<!-- Achievements Visibility -->
<div class="border-b pb-4">
<h3 class="font-medium text-gray-800 mb-3">Achievements</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% for option in privacy_options %}
<label class="flex items-center p-4 border rounded-lg {% if privacy_settings.achievements == option.value %}bg-green-50 border-irish-green{% endif %}">
<input type="radio" name="achievements_visibility" value="{{ option.value }}"
{% if privacy_settings.achievements == option.value %}checked{% endif %}
class="mr-2 text-irish-green focus:ring-irish-green">
<div>
<span class="font-medium block text-sm">{{ option.label }}</span>
<span class="text-xs text-gray-500">{{ option.description }}</span>
</div>
</label>
{% endfor %}
</div>
</div>
<!-- Events Visibility -->
<div class="border-b pb-4">
<h3 class="font-medium text-gray-800 mb-3">Event Attendance</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% for option in privacy_options %}
<label class="flex items-center p-4 border rounded-lg {% if privacy_settings.events == option.value %}bg-green-50 border-irish-green{% endif %}">
<input type="radio" name="events_visibility" value="{{ option.value }}"
{% if privacy_settings.events == option.value %}checked{% endif %}
class="mr-2 text-irish-green focus:ring-irish-green">
<div>
<span class="font-medium block text-sm">{{ option.label }}</span>
<span class="text-xs text-gray-500">{{ option.description }}</span>
</div>
</label>
{% endfor %}
</div>
</div>
<div class="flex justify-between items-center pt-4">
<a href="/auth/profile" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-arrow-left mr-1"></i> Back to Profile
</a>
<button type="submit" class="bg-irish-green text-white py-2 px-6 rounded-md hover:bg-opacity-90 transition">
Save Privacy Settings
</button>
</div>
</div>
</form>
<div class="mt-8 pt-4 border-t border-gray-200 text-sm text-gray-500">
<p><i class="fas fa-shield-alt mr-1"></i> Note: Administrators can always view your complete profile information for support purposes.</p>
</div>
</div>
</div>
{% endblock %}
+47 -2
View File
@@ -9,6 +9,13 @@
</div>
{% endif %}
<!-- Display errors if present -->
{% if error %}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{{ error }}
</div>
{% endif %}
<div class="flex flex-col md:flex-row items-center md:items-start md:space-x-8">
<!-- Profile Image -->
<div class="mb-6 md:mb-0">
@@ -19,6 +26,26 @@
{{ user.username[0]|upper }}
</div>
{% endif %}
<!-- Profile Picture Upload Form -->
<div class="mt-3">
<form action="/auth/update-profile-picture" method="post" enctype="multipart/form-data">
<label class="block w-full bg-gray-200 text-center py-2 px-3 rounded{% if not user.picture %}-md{% else %}-t{% endif %} cursor-pointer hover:bg-gray-300 transition">
<i class="fas fa-camera mr-1"></i> Change Picture
<input type="file" name="file" accept="image/jpeg,image/png" class="hidden" onchange="this.form.submit()">
</label>
</form>
{% if user.picture %}
<form action="/auth/delete-profile-picture" method="post">
<button type="submit" class="w-full bg-red-100 text-red-700 text-center py-2 px-3 rounded-b hover:bg-red-200 transition">
<i class="fas fa-trash-alt mr-1"></i> Remove Picture
</button>
</form>
{% endif %}
<p class="text-xs text-gray-500 mt-1 text-center">JPG/PNG only</p>
</div>
</div>
<!-- User Info -->
@@ -41,6 +68,15 @@
<div class="space-y-4">
<h2 class="text-xl font-semibold text-irish-green">Account Settings</h2>
<div class="border-b border-gray-200 pb-4">
<h3 class="text-gray-700 font-medium mb-2">Username</h3>
<form action="/auth/update-username" method="post" class="flex">
<input type="text" name="username" value="{{ user.username }}" class="flex-grow border border-gray-300 rounded-l-md px-3 py-2">
<button type="submit" class="bg-irish-green text-white px-4 py-2 rounded-r-md">Update</button>
</form>
<p class="text-xs text-gray-500 mt-1">Change your username (must be unique)</p>
</div>
<div class="border-b border-gray-200 pb-4">
<h3 class="text-gray-700 font-medium mb-2">Change Password</h3>
<p class="text-sm text-gray-600 mb-3">Update your password to keep your account secure.</p>
@@ -49,13 +85,22 @@
</a>
</div>
<div class="border-b border-gray-200 pb-4">
<h3 class="text-gray-700 font-medium mb-2">Privacy Settings</h3>
<p class="text-sm text-gray-600 mb-3">Control who can see your profile information.</p>
<a href="/auth/privacy-settings" class="inline-block bg-irish-green text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition">
Manage Privacy Settings
</a>
</div>
<div class="pt-4">
<h3 class="text-gray-700 font-medium mb-2">Danger Zone</h3>
<p class="text-sm text-gray-600 mb-3">Permanently delete your account and all of your data.</p>
<button class="border border-red-600 text-red-600 py-2 px-4 rounded-md hover:bg-red-600 hover:text-white transition" disabled>
<form action="/auth/delete-account" method="post" onsubmit="return confirm('Are you sure you want to delete your account? This action cannot be undone.');">
<button type="submit" class="border border-red-600 text-red-600 py-2 px-4 rounded-md hover:bg-red-600 hover:text-white transition">
Delete Account
<span class="text-xs">(Coming Soon)</span>
</button>
</form>
</div>
</div>
</div>
+123
View File
@@ -0,0 +1,123 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="bg-white rounded-lg shadow-md overflow-hidden mb-8">
<!-- Profile Header -->
<div class="bg-irish-green text-white p-6">
<div class="flex flex-col sm:flex-row items-center">
<div class="w-24 h-24 rounded-full bg-white border-4 border-white overflow-hidden mb-4 sm:mb-0 sm:mr-6">
{% if profile.picture %}
<img src="{{ profile.picture }}" alt="Profile" class="w-full h-full object-cover">
{% else %}
<div class="w-full h-full bg-irish-green flex items-center justify-center text-white text-4xl">
{{ profile.username[0]|upper }}
</div>
{% endif %}
</div>
<div class="text-center sm:text-left">
<h1 class="text-2xl font-bold">{{ profile.username }}</h1>
<p class="text-green-100">Member since {{ profile.created_at.strftime('%B %Y') }}</p>
<p class="mt-2">
{% if profile.is_admin %}
<span class="bg-golden-ale text-black-stout text-xs px-2 py-1 rounded-full font-medium mr-1">Admin</span>
{% endif %}
</p>
</div>
</div>
</div>
<!-- Profile Content -->
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Left Column: Personal Info -->
<div class="md:col-span-1">
<h2 class="text-xl font-semibold text-irish-green mb-4">Profile Information</h2>
<div class="space-y-4">
<div class="bg-cream-white p-4 rounded-lg">
<h3 class="text-sm font-medium text-gray-600 mb-1">Username</h3>
<p class="font-medium">{{ profile.username }}</p>
</div>
{% if "email" in profile %}
<div class="bg-cream-white p-4 rounded-lg">
<h3 class="text-sm font-medium text-gray-600 mb-1">Email</h3>
<p>{{ profile.email }}</p>
</div>
{% endif %}
{% if "first_name" in profile and "last_name" in profile %}
<div class="bg-cream-white p-4 rounded-lg">
<h3 class="text-sm font-medium text-gray-600 mb-1">Full Name</h3>
<p>{{ profile.first_name }} {{ profile.last_name }}</p>
</div>
{% elif "first_name" in profile %}
<div class="bg-cream-white p-4 rounded-lg">
<h3 class="text-sm font-medium text-gray-600 mb-1">First Name</h3>
<p>{{ profile.first_name }}</p>
</div>
{% endif %}
<div class="bg-cream-white p-4 rounded-lg">
<h3 class="text-sm font-medium text-gray-600 mb-1">Account Type</h3>
<p>{% if profile.is_admin %}Administrator{% else %}User{% endif %}</p>
</div>
</div>
</div>
<!-- Right Column: Stats & Teams -->
<div class="md:col-span-2">
{% if profile.can_view_points %}
<div class="mb-6">
<h2 class="text-xl font-semibold text-irish-green mb-4">Statistics</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-cream-white p-4 rounded-lg text-center">
<p class="text-sm text-gray-600">Total Points</p>
<p class="text-2xl font-bold text-irish-green">{{ total_points }}</p>
</div>
</div>
</div>
{% endif %}
{% if profile.can_view_teams and teams %}
<div class="mb-6">
<h2 class="text-xl font-semibold text-irish-green mb-4">Teams</h2>
<div class="space-y-4">
{% for team in teams %}
<div class="border rounded-lg overflow-hidden">
<div class="bg-cream-white px-4 py-3 flex justify-between items-center">
<div class="font-medium">{{ team.name }}</div>
{% if team.is_captain %}
<span class="bg-golden-ale text-black-stout text-xs px-2 py-1 rounded-full">Captain</span>
{% else %}
<span class="bg-gray-200 text-gray-700 text-xs px-2 py-1 rounded-full">Member</span>
{% endif %}
</div>
<div class="px-4 py-3">
<a href="/teams/{{ team.id }}" class="text-irish-green hover:underline text-sm">View Team</a>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if profile.can_view_achievements %}
<div class="mb-6">
<h2 class="text-xl font-semibold text-irish-green mb-4">Achievements</h2>
<p class="text-gray-500 italic">Achievements data will be shown here</p>
</div>
{% endif %}
{% if profile.can_view_events %}
<div>
<h2 class="text-xl font-semibold text-irish-green mb-4">Recent Events</h2>
<p class="text-gray-500 italic">Recent events will be shown here</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+8 -8
View File
@@ -78,7 +78,7 @@
<i class="fas fa-chevron-down text-xs"></i>
</button>
<div class="dropdown-menu hidden absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50">
<a href="/profile" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<a href="/auth/profile" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-user mr-2"></i> Profile
</a>
<a href="/dashboard" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
@@ -90,13 +90,13 @@
</a>
{% endif %}
<div class="border-t border-gray-100 my-1"></div>
<a href="/logout" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<a href="/auth/logout" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-sign-out-alt mr-2"></i> Logout
</a>
</div>
</div>
{% else %}
<a href="/login" class="bg-golden-ale hover:bg-opacity-90 text-black-stout px-4 py-2 rounded-md transition">Sign In</a>
<a href="/auth/login" class="bg-golden-ale hover:bg-opacity-90 text-black-stout px-4 py-2 rounded-md transition">Sign In</a>
{% endif %}
</div>
@@ -116,14 +116,14 @@
<a href="/leaderboard" class="hover:text-golden-ale transition py-2">Leaderboard</a>
<a href="/dashboard/scan" class="hover:text-golden-ale transition py-2">Scan QR Code</a>
{% if user %}
<a href="/profile" class="hover:text-golden-ale transition py-2">Profile</a>
<a href="/auth/profile" class="hover:text-golden-ale transition py-2">Profile</a>
<a href="/dashboard" class="hover:text-golden-ale transition py-2">Dashboard</a>
{% if user.is_admin %}
<a href="/admin" class="hover:text-golden-ale transition py-2">Admin</a>
{% endif %}
<a href="/logout" class="hover:text-golden-ale transition py-2">Logout</a>
<a href="/auth/logout" class="hover:text-golden-ale transition py-2">Logout</a>
{% else %}
<a href="/login" class="bg-golden-ale hover:bg-opacity-90 text-black-stout px-4 py-2 rounded-md transition text-center">Sign In</a>
<a href="/auth/login" class="bg-golden-ale hover:bg-opacity-90 text-black-stout px-4 py-2 rounded-md transition text-center">Sign In</a>
{% endif %}
</div>
</div>
@@ -161,9 +161,9 @@
<div>
<h3 class="text-golden-ale font-bold mb-4">Account</h3>
<ul class="space-y-2">
<li><a href="/login" class="hover:text-golden-ale transition">Sign In</a></li>
<li><a href="/auth/login" class="hover:text-golden-ale transition">Sign In</a></li>
<li><a href="/register" class="hover:text-golden-ale transition">Register</a></li>
<li><a href="/profile" class="hover:text-golden-ale transition">Profile</a></li>
<li><a href="/auth/profile" class="hover:text-golden-ale transition">Profile</a></li>
<li><a href="/dashboard" class="hover:text-golden-ale transition">Dashboard</a></li>
</ul>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-6">
<div class="max-w-4xl mx-auto space-y-6">
<!-- User Welcome -->
<div class="bg-white rounded-lg shadow-md p-6">
<h1 class="text-2xl md:text-3xl font-garamond text-irish-green mb-2">Welcome, {{ user.username }}!</h1>
+3 -3
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block content %}
<div class="flex flex-col items-center">
<div class="max-w-4xl mx-auto">
<!-- Hero Section -->
<section class="w-full py-8 md:py-16 text-center">
<h1 class="text-4xl md:text-6xl font-bold text-irish-green mb-4">PubQuiz League Tracker</h1>
@@ -13,9 +13,9 @@
<!-- Features -->
<section class="w-full py-12 bg-white rounded-lg shadow-md my-8">
<div class="container mx-auto">
<div class="container mx-auto px-4">
<h2 class="text-3xl font-bold text-irish-green text-center mb-10">How It Works</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 px-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="flex flex-col items-center text-center">
<div class="bg-cream-white p-4 rounded-full mb-4">
<i class="fas fa-users text-irish-green text-3xl"></i>
+67 -20
View File
@@ -6,13 +6,21 @@
<div class="bg-irish-green text-white p-6">
<div class="flex flex-col sm:flex-row items-center">
<div class="w-24 h-24 rounded-full bg-white border-4 border-white overflow-hidden mb-4 sm:mb-0 sm:mr-6">
<img src="https://picsum.photos/150" alt="Profile" class="w-full h-full object-cover">
{% if user.picture %}
<img src="{{ user.picture }}" alt="Profile" class="w-full h-full object-cover">
{% else %}
<div class="w-full h-full bg-irish-green flex items-center justify-center text-white text-4xl">
{{ user.username[0]|upper }}
</div>
{% endif %}
</div>
<div class="text-center sm:text-left">
<h1 class="text-2xl font-bold">John Quizmaster</h1>
<p class="text-green-100">Member since October 2022</p>
<h1 class="text-2xl font-bold">{{ user.username }}</h1>
<p class="text-green-100">Member since {{ user.created_at.strftime('%B %Y') }}</p>
<p class="mt-2">
<span class="bg-golden-ale text-black-stout text-xs px-2 py-1 rounded-full font-medium mr-1">Quiz Master</span>
{% if user.is_admin %}
<span class="bg-golden-ale text-black-stout text-xs px-2 py-1 rounded-full font-medium mr-1">Admin</span>
{% endif %}
<span class="bg-white text-irish-green text-xs px-2 py-1 rounded-full font-medium">Team Captain</span>
</p>
</div>
@@ -21,45 +29,82 @@
<!-- Profile Content -->
<div class="p-6">
<!-- Display messages if present -->
{% if request.query_params.message %}
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{{ request.query_params.message }}
</div>
{% endif %}
<!-- Display errors if present -->
{% if error %}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{{ error }}
</div>
{% endif %}
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Left Column: Personal Info -->
<div class="md:col-span-1">
<h2 class="text-xl font-semibold text-irish-green mb-4">Personal Information</h2>
<div class="space-y-4">
<!-- Username -->
<div>
<label class="block text-sm font-medium text-gray-600">Display Name</label>
<div class="mt-1 flex">
<input type="text" value="John Quizmaster" readonly class="flex-grow bg-gray-100 border border-gray-300 rounded-l-md px-3 py-2">
<button class="bg-irish-green text-white px-3 py-2 rounded-r-md">
<i class="fas fa-pencil-alt"></i>
<form action="/auth/update-username" method="post" class="mt-1 flex">
<input type="text" name="username" value="{{ user.username }}" class="flex-grow border border-gray-300 rounded-l-md px-3 py-2">
<button type="submit" class="bg-irish-green text-white px-3 py-2 rounded-r-md">
<i class="fas fa-save"></i>
</button>
</div>
</form>
<p class="text-xs text-gray-500 mt-1">Change your username (must be unique)</p>
</div>
<!-- Email -->
<div>
<label class="block text-sm font-medium text-gray-600">Email</label>
<div class="mt-1 flex">
<input type="email" value="john@example.com" readonly class="flex-grow bg-gray-100 border border-gray-300 rounded-l-md px-3 py-2">
<button class="bg-irish-green text-white px-3 py-2 rounded-r-md">
<i class="fas fa-pencil-alt"></i>
</button>
<input type="email" value="{{ user.email }}" readonly class="flex-grow bg-gray-100 border border-gray-300 rounded-md px-3 py-2">
</div>
</div>
<!-- Profile Picture -->
<div>
<label class="block text-sm font-medium text-gray-600">Profile Picture</label>
<form action="/auth/update-profile-picture" method="post" enctype="multipart/form-data" class="mt-1">
<div class="flex items-center">
<label class="flex-grow bg-golden-ale hover:bg-opacity-90 text-black font-medium py-2 px-4 rounded-l-md cursor-pointer text-center transition">
<i class="fas fa-camera mr-1"></i> Upload Photo
<input type="file" name="file" accept="image/jpeg,image/png" class="hidden" onchange="this.form.submit()">
</label>
<button type="submit" class="bg-irish-green text-white px-3 py-2 rounded-r-md">
<i class="fas fa-upload"></i>
</button>
</div>
<p class="text-xs text-gray-500 mt-1">JPG or PNG formats only</p>
</form>
</div>
<!-- Password -->
<div>
<label class="block text-sm font-medium text-gray-600">Password</label>
<div class="mt-1">
<button class="w-full bg-golden-ale hover:bg-opacity-90 text-black font-medium py-2 px-4 rounded-md transition">
<a href="/auth/change-password" class="block w-full bg-golden-ale hover:bg-opacity-90 text-black font-medium py-2 px-4 rounded-md transition text-center">
Change Password
</button>
</a>
</div>
</div>
<div class="pt-4">
<button class="w-full bg-irish-green hover:bg-opacity-90 text-white font-medium py-2 px-4 rounded-md transition">
Save Changes
</button>
<!-- Privacy Settings -->
<div class="mt-4">
<label class="block text-sm font-medium text-gray-600">Privacy</label>
<div class="mt-1">
<a href="/auth/privacy-settings" class="block w-full bg-golden-ale hover:bg-opacity-90 text-black font-medium py-2 px-4 rounded-md transition text-center">
<i class="fas fa-user-shield mr-1"></i> Privacy Settings
</a>
</div>
<p class="text-xs text-gray-500 mt-1">Control who can see your information</p>
</div>
</div>
</div>
@@ -132,9 +177,11 @@
<p class="text-gray-600 mb-4">The following actions are irreversible. Please proceed with caution.</p>
<div class="flex flex-col sm:flex-row gap-4">
<button class="bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded-md transition">
<form action="/auth/delete-account" method="post" onsubmit="return confirm('Are you sure you want to delete your account? This action cannot be undone.');">
<button type="submit" class="bg-white hover:bg-red-50 text-red-600 border border-red-600 font-medium py-2 px-4 rounded-md transition">
Delete Account
</button>
</form>
<button class="bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded-md transition">
Leave All Teams
</button>
+11 -3
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-6">
<div class="max-w-4xl mx-auto space-y-6">
<!-- Team Header -->
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-irish-green p-6">
@@ -88,10 +88,18 @@
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="w-10 h-10 rounded-full bg-gray-200 mr-3 overflow-hidden">
<img src="https://picsum.photos/40" alt="User" class="w-full h-full object-cover">
{% if member.user.picture %}
<img src="{{ member.user.picture }}" alt="User" class="w-full h-full object-cover">
{% else %}
<div class="w-full h-full bg-irish-green flex items-center justify-center text-white text-sm">
{{ member.user.username[0]|upper }}
</div>
{% endif %}
</div>
<div>
<p class="font-medium">{{ member.user.username }}</p>
<p class="font-medium">
<a href="/auth/user/{{ member.user.id }}" class="hover:text-irish-green">{{ member.user.username }}</a>
</p>
<p class="text-xs text-gray-500">Joined {{ member.joined }}</p>
</div>
</div>
+2
View File
@@ -1,5 +1,6 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-4xl mx-auto">
<h2 class="text-2xl font-bold mb-6">Teams</h2>
{% if error %}
@@ -104,4 +105,5 @@
Every point counts in the journey to becoming pub quiz champions.
</p>
</div>
</div>
{% endblock %}
+102 -1
View File
@@ -3,7 +3,7 @@ Authentication utilities for LeagueLedger.
This module provides backward compatibility with the existing code while leveraging
the new Starlette authentication system.
"""
from typing import Optional
from typing import Optional, Dict, Any
from fastapi import Request, Depends
from sqlalchemy.orm import Session
from ..db import get_db
@@ -157,5 +157,106 @@ async def requires_admin(request: Request, db: Session = Depends(get_db)) -> Use
return user
def check_privacy_permission(
db: Session,
profile_user: User,
viewing_user_id: Optional[int],
setting_name: str
) -> bool:
"""
Check if the viewing user has permission to see a specific profile setting
Args:
db: Database session
profile_user: The user whose profile is being viewed
viewing_user_id: The ID of the user viewing the profile (None if not logged in)
setting_name: The name of the setting to check (email, full_name, teams, points, achievements, events)
Returns:
True if viewer has permission to see the setting, False otherwise
"""
# Admin users can see everything
if viewing_user_id:
viewing_user = db.query(User).filter(User.id == viewing_user_id).first()
if viewing_user and viewing_user.is_admin:
return True
# Owner can see everything on their own profile
if viewing_user_id and viewing_user_id == profile_user.id:
return True
# Get privacy settings for this user
privacy_settings = profile_user.get_privacy_settings()
privacy_level = privacy_settings.get(setting_name, "private")
# Public settings are visible to everyone
if privacy_level == "public":
return True
# Private settings are only visible to the user and admins (handled above)
if privacy_level == "private":
return False
# For "friends" level (team members), check if viewing user is in same team
if privacy_level == "friends" and viewing_user_id:
# Get teams of the profile user
profile_user_team_ids = [
membership.team_id
for membership in db.query(TeamMembership).filter(
TeamMembership.user_id == profile_user.id
).all()
]
# Check if viewing user is in any of the same teams
common_team = db.query(TeamMembership).filter(
TeamMembership.user_id == viewing_user_id,
TeamMembership.team_id.in_(profile_user_team_ids)
).first()
return common_team is not None
return False
def get_viewable_profile_data(
db: Session,
profile_user: User,
viewing_user_id: Optional[int]
) -> Dict[str, Any]:
"""
Get profile data respecting privacy settings
Args:
db: Database session
profile_user: The user whose profile is being viewed
viewing_user_id: The ID of the user viewing the profile (None if not logged in)
Returns:
Dictionary with profile data that the viewing user is allowed to see
"""
data = {
"username": profile_user.username,
"picture": profile_user.picture,
"is_admin": profile_user.is_admin,
"created_at": profile_user.created_at
}
# Only include email if permission allows
if check_privacy_permission(db, profile_user, viewing_user_id, "email"):
data["email"] = profile_user.email
# Only include full name if permission allows
if check_privacy_permission(db, profile_user, viewing_user_id, "full_name"):
data["first_name"] = profile_user.first_name
data["last_name"] = profile_user.last_name
# For teams, points, achievements, events - we'll just include permission flags
# The actual data will be loaded by the view functions when needed
data["can_view_teams"] = check_privacy_permission(db, profile_user, viewing_user_id, "teams")
data["can_view_points"] = check_privacy_permission(db, profile_user, viewing_user_id, "points")
data["can_view_achievements"] = check_privacy_permission(db, profile_user, viewing_user_id, "achievements")
data["can_view_events"] = check_privacy_permission(db, profile_user, viewing_user_id, "events")
return data
# Note: For new code, consider using the decorators in app.auth.permissions instead
# of these dependency functions directly
+387 -3
View File
@@ -1,11 +1,13 @@
from fastapi import APIRouter, Request, Depends, Form, HTTPException, status, BackgroundTasks
from fastapi.responses import HTMLResponse, RedirectResponse
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
@@ -445,7 +447,10 @@ async def oauth_callback(
user.first_name = first_name
if last_name and not user.last_name:
user.last_name = last_name
if picture and not user.picture:
# 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
@@ -529,6 +534,172 @@ async def profile_page(request: Request, db: Session = Depends(get_db)):
{"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"""
@@ -743,6 +914,219 @@ async def reset_password_post(
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: