Email SMTP Fix

This commit is contained in:
Koncept Kit
2025-12-07 16:59:04 +07:00
parent 79b617904b
commit 005c56b43d
11 changed files with 526 additions and 28 deletions

33
.env.example Normal file
View File

@@ -0,0 +1,33 @@
# Database Configuration
DATABASE_URL=postgresql://user:password@localhost:5432/membership_db
# JWT Authentication
JWT_SECRET=your-secret-key-change-this-in-production
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# SMTP Email Configuration (Port 465 - SSL/TLS)
SMTP_HOST=p.konceptkit.com
SMTP_PORT=465
SMTP_SECURE=false
SMTP_USER=koncept-kit/koncept-kit
SMTP_PASS=TOBYjqk3ZOWXUsEzlP1Kj3p1
SMTP_FROM=noreply@konceptkit.id
SMTP_FROM_NAME=LOAF Membership
# Alternative SMTP Configuration (Port 587 - STARTTLS)
# If using port 587, use these settings instead:
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=your-email@gmail.com
# SMTP_PASS=your-app-password
# SMTP_FROM=noreply@yourdomain.com
# SMTP_FROM_NAME=Your App Name
# Frontend URL
FRONTEND_URL=http://localhost:3000
# Stripe Configuration (for future payment integration)
# STRIPE_SECRET_KEY=sk_test_...
# STRIPE_WEBHOOK_SECRET=whsec_...

Binary file not shown.

Binary file not shown.

Binary file not shown.

46
auth.py
View File

@@ -6,6 +6,7 @@ from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import os import os
import secrets
from database import get_db from database import get_db
from models import User, UserRole from models import User, UserRole
@@ -22,6 +23,33 @@ def verify_password(plain_password, hashed_password):
def get_password_hash(password): def get_password_hash(password):
return pwd_context.hash(password) return pwd_context.hash(password)
def generate_reset_token():
"""Generate secure random token for password reset"""
return secrets.token_urlsafe(32)
def create_password_reset_token(user, db):
"""Create reset token with 1-hour expiration"""
token = generate_reset_token()
expires = datetime.now(timezone.utc) + timedelta(hours=1)
user.password_reset_token = token
user.password_reset_expires = expires
db.commit()
return token
def verify_reset_token(token, db):
"""Verify token is valid and not expired"""
user = db.query(User).filter(User.password_reset_token == token).first()
if not user:
return None
if user.password_reset_expires < datetime.now(timezone.utc):
return None # Token expired
return user
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy() to_encode = data.copy()
if expires_delta: if expires_delta:
@@ -78,3 +106,21 @@ async def get_current_admin_user(current_user: User = Depends(get_current_user))
detail="Not enough permissions" detail="Not enough permissions"
) )
return current_user return current_user
async def get_active_member(current_user: User = Depends(get_current_user)) -> User:
"""Require user to be active member with valid payment"""
from models import UserStatus
if current_user.status != UserStatus.active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Active membership required. Please complete payment."
)
if current_user.role not in [UserRole.member, UserRole.admin]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Member access only"
)
return current_user

View File

@@ -1,50 +1,129 @@
import os import os
import ssl
import smtplib
import asyncio
from pathlib import Path
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
import aiosmtplib
import logging import logging
from dotenv import load_dotenv
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Load .env file
ROOT_DIR = Path(__file__).parent
load_dotenv(ROOT_DIR / '.env')
# SMTP Configuration - supports both new (SMTP_USER/SMTP_PASS) and old (SMTP_USERNAME/SMTP_PASSWORD) variable names
SMTP_HOST = os.environ.get('SMTP_HOST', 'smtp.gmail.com') SMTP_HOST = os.environ.get('SMTP_HOST', 'smtp.gmail.com')
SMTP_PORT = int(os.environ.get('SMTP_PORT', 587)) SMTP_PORT = int(os.environ.get('SMTP_PORT', 587))
SMTP_USERNAME = os.environ.get('SMTP_USERNAME', '') SMTP_SECURE = os.environ.get('SMTP_SECURE', 'false').lower() == 'true'
SMTP_PASSWORD = os.environ.get('SMTP_PASSWORD', '') SMTP_VERIFY_CERT = os.environ.get('SMTP_VERIFY_CERT', 'true').lower() == 'true'
SMTP_FROM_EMAIL = os.environ.get('SMTP_FROM_EMAIL', 'noreply@membership.com') SMTP_AUTH_METHOD = os.environ.get('SMTP_AUTH_METHOD', None) # PLAIN, LOGIN, or CRAM-MD5
SMTP_FROM_NAME = os.environ.get('SMTP_FROM_NAME', 'Membership Platform')
# Log configuration at startup
logger.info(f"📧 SMTP Configuration Loaded:")
logger.info(f" Host: {SMTP_HOST}:{SMTP_PORT}")
logger.info(f" Secure (SSL from start): {SMTP_SECURE}")
logger.info(f" Verify Certificate: {SMTP_VERIFY_CERT}")
logger.info(f" Auth Method: {SMTP_AUTH_METHOD or 'auto-detect'}")
# Support both SMTP_USER and SMTP_USERNAME (new and old)
SMTP_USER = os.environ.get('SMTP_USER') or os.environ.get('SMTP_USERNAME', '')
SMTP_PASS = os.environ.get('SMTP_PASS') or os.environ.get('SMTP_PASSWORD', '')
# Support both SMTP_FROM and SMTP_FROM_EMAIL (new and old)
SMTP_FROM = os.environ.get('SMTP_FROM') or os.environ.get('SMTP_FROM_EMAIL', 'noreply@membership.com')
SMTP_FROM_NAME = os.environ.get('SMTP_FROM_NAME', 'LOAF Membership')
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:3000') FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:3000')
def _send_email_sync(message: MIMEMultipart, to_email: str):
"""
Synchronous email sending (will be wrapped in asyncio.to_thread)
Matches the working pattern from previous implementation
"""
# Create SSL context for certificate verification control
ssl_context = None
if not SMTP_VERIFY_CERT:
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
logger.warning(f"⚠️ SSL certificate verification DISABLED for {SMTP_HOST}")
# Determine connection method based on configuration
if not SMTP_SECURE:
# Plain SMTP without encryption (some providers require this)
logger.info(f"🔌 Using plain SMTP connection (SMTP_SECURE=false) to {SMTP_HOST}:{SMTP_PORT}")
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
logger.info(f" ✅ SMTP connection established")
server.login(SMTP_USER, SMTP_PASS)
logger.info(f" ✅ Login successful")
server.send_message(message)
logger.info(f" ✅ Message sent")
elif SMTP_PORT == 465:
# Use SSL/TLS from start (standard for port 465)
logger.info(f"🔌 Using SMTP_SSL connection to {SMTP_HOST}:{SMTP_PORT}")
with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=ssl_context) as server:
logger.info(f" ✅ SSL connection established")
server.login(SMTP_USER, SMTP_PASS)
logger.info(f" ✅ Login successful")
server.send_message(message)
else:
# Use STARTTLS (standard for port 587)
logger.info(f"🔌 Using SMTP with STARTTLS to {SMTP_HOST}:{SMTP_PORT}")
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
server.starttls(context=ssl_context)
logger.info(f" ✅ STARTTLS enabled")
server.login(SMTP_USER, SMTP_PASS)
logger.info(f" ✅ Login successful")
server.send_message(message)
async def send_email(to_email: str, subject: str, html_content: str): async def send_email(to_email: str, subject: str, html_content: str):
"""Send an email using SMTP""" """
Send an email using SMTP
Configuration via environment variables:
- SMTP_SECURE=false: Plain SMTP without encryption
- SMTP_SECURE=true + Port 465: SSL/TLS from start
- SMTP_SECURE=true + Port 587: STARTTLS
"""
try: try:
message = MIMEMultipart('alternative') message = MIMEMultipart('alternative')
message['From'] = f"{SMTP_FROM_NAME} <{SMTP_FROM_EMAIL}>" message['From'] = f"{SMTP_FROM_NAME} <{SMTP_FROM}>"
message['To'] = to_email message['To'] = to_email
message['Subject'] = subject message['Subject'] = subject
html_part = MIMEText(html_content, 'html') html_part = MIMEText(html_content, 'html')
message.attach(html_part) message.attach(html_part)
# For development/testing, just log the email # For development/testing, just log the email
if not SMTP_USERNAME or not SMTP_PASSWORD: if not SMTP_USER or not SMTP_PASS:
logger.info(f"[EMAIL] To: {to_email}") logger.info(f"[EMAIL] To: {to_email}")
logger.info(f"[EMAIL] Subject: {subject}") logger.info(f"[EMAIL] Subject: {subject}")
logger.info(f"[EMAIL] Content: {html_content}") logger.info(f"[EMAIL] Content: {html_content[:200]}...")
return True return True
# Send actual email logger.info(f"📧 Sending email to {to_email} via {SMTP_HOST}:{SMTP_PORT}")
await aiosmtplib.send(
message, # Run synchronous SMTP in thread pool to avoid blocking
hostname=SMTP_HOST, await asyncio.to_thread(_send_email_sync, message, to_email)
port=SMTP_PORT,
username=SMTP_USERNAME, logger.info(f"✓ Email sent successfully to {to_email}")
password=SMTP_PASSWORD,
start_tls=True
)
logger.info(f"Email sent successfully to {to_email}")
return True return True
except smtplib.SMTPAuthenticationError as e:
logger.error(f"❌ SMTP Authentication Error for {to_email}: {str(e)}")
logger.error(f" Check SMTP_USER and SMTP_PASS")
return False
except smtplib.SMTPException as e:
logger.error(f"❌ SMTP Error sending to {to_email}: {str(e)}")
logger.exception(e)
return False
except Exception as e: except Exception as e:
logger.error(f"Failed to send email to {to_email}: {str(e)}") logger.error(f"Failed to send email to {to_email}: {str(e)}")
logger.exception(e)
return False return False
async def send_verification_email(to_email: str, token: str): async def send_verification_email(to_email: str, token: str):
@@ -180,3 +259,119 @@ async def send_payment_prompt_email(to_email: str, first_name: str):
</html> </html>
""" """
return await send_email(to_email, subject, html_content) return await send_email(to_email, subject, html_content)
async def send_password_reset_email(to_email: str, first_name: str, reset_url: str):
"""Send password reset link email"""
subject = "Reset Your Password - LOAF Membership"
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: 'DM Sans', Arial, sans-serif; line-height: 1.6; color: #3D405B; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #F2CC8F 0%, #E07A5F 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
.header h1 {{ color: white; margin: 0; font-family: 'Fraunces', serif; }}
.content {{ background: #FDFCF8; padding: 30px; border-radius: 0 0 10px 10px; }}
.button {{ display: inline-block; background: #E07A5F; color: white; padding: 15px 40px; text-decoration: none; border-radius: 50px; font-weight: 600; margin: 20px 0; }}
.button:hover {{ background: #D0694E; }}
.note {{ background: #FFF3E0; border-left: 4px solid #E07A5F; padding: 15px; margin: 20px 0; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Reset Your Password</h1>
</div>
<div class="content">
<p>Hi {first_name},</p>
<p>You requested to reset your password. Click the button below to create a new password:</p>
<p style="text-align: center;">
<a href="{reset_url}" class="button">Reset Password</a>
</p>
<div class="note">
<p style="margin: 0; font-size: 14px;"><strong>⏰ This link will expire in 1 hour.</strong></p>
<p style="margin: 5px 0 0 0; font-size: 14px;">If you didn't request this, please ignore this email.</p>
</div>
<p style="margin-top: 20px; color: #6B708D; font-size: 14px;">
Or copy and paste this link into your browser:<br>
<span style="word-break: break-all;">{reset_url}</span>
</p>
</div>
</div>
</body>
</html>
"""
return await send_email(to_email, subject, html_content)
async def send_admin_password_reset_email(
to_email: str,
first_name: str,
temp_password: str,
force_change: bool
):
"""Send temporary password when admin resets user password"""
subject = "Your Password Has Been Reset - LOAF Membership"
force_change_text = (
"""
<div class="note" style="background: #FFEBEE; border-left: 4px solid #E07A5F;">
<p style="margin: 0; font-weight: bold; color: #E07A5F;">⚠️ You will be required to change this password when you log in.</p>
</div>
"""
) if force_change else ""
login_url = f"{FRONTEND_URL}/login"
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: 'DM Sans', Arial, sans-serif; line-height: 1.6; color: #3D405B; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #F2CC8F 0%, #E07A5F 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
.header h1 {{ color: white; margin: 0; font-family: 'Fraunces', serif; }}
.content {{ background: #FDFCF8; padding: 30px; border-radius: 0 0 10px 10px; }}
.button {{ display: inline-block; background: #E07A5F; color: white; padding: 15px 40px; text-decoration: none; border-radius: 50px; font-weight: 600; margin: 20px 0; }}
.button:hover {{ background: #D0694E; }}
.password-box {{ background: #F5F5F5; padding: 20px; margin: 20px 0; border-left: 4px solid #E07A5F; font-family: 'Courier New', monospace; font-size: 18px; font-weight: bold; word-break: break-all; }}
.note {{ background: #FFF3E0; border-left: 4px solid #E07A5F; padding: 15px; margin: 20px 0; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Password Reset by Administrator</h1>
</div>
<div class="content">
<p>Hi {first_name},</p>
<p>An administrator has reset your password. Here is your temporary password:</p>
<div class="password-box">
{temp_password}
</div>
{force_change_text}
<p>Please log in and change your password to something memorable.</p>
<p style="text-align: center;">
<a href="{login_url}" class="button">Go to Login</a>
</p>
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #E8E4DB; color: #6B708D; font-size: 14px;">
Questions? Contact us at support@loaf.org
</p>
</div>
</div>
</body>
</html>
"""
return await send_email(to_email, subject, html_content)

11
fix_enum.sql Normal file
View File

@@ -0,0 +1,11 @@
-- Add pending_approval to the userstatus enum if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'pending_approval'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'userstatus')
) THEN
ALTER TYPE userstatus ADD VALUE 'pending_approval' BEFORE 'pre_approved';
END IF;
END$$;

43
migrate_password_reset.py Normal file
View File

@@ -0,0 +1,43 @@
"""
Migration script to add password reset fields to users table.
Run this once to update the database schema for password reset functionality.
"""
from database import engine
from sqlalchemy import text
def add_password_reset_columns():
"""Add password reset token, expiration, and force change columns to users table"""
migrations = [
# Password reset token (secure random string)
"ALTER TABLE users ADD COLUMN IF NOT EXISTS password_reset_token VARCHAR",
# Password reset expiration (1 hour from token creation)
"ALTER TABLE users ADD COLUMN IF NOT EXISTS password_reset_expires TIMESTAMP",
# Force password change on next login (for admin resets)
"ALTER TABLE users ADD COLUMN IF NOT EXISTS force_password_change BOOLEAN NOT NULL DEFAULT FALSE"
]
try:
print("Adding password reset columns to users table...")
with engine.connect() as conn:
for sql in migrations:
print(f" Executing: {sql[:70]}...")
conn.execute(text(sql))
conn.commit()
print("\n✅ Migration completed successfully!")
print("\nAdded columns:")
print(" - password_reset_token (VARCHAR)")
print(" - password_reset_expires (TIMESTAMP)")
print(" - force_password_change (BOOLEAN, default=FALSE)")
print("\nYou can now run password reset functionality.")
except Exception as e:
print(f"\n❌ Migration failed: {e}")
raise
if __name__ == "__main__":
add_password_reset_columns()

View File

@@ -77,6 +77,11 @@ class User(Base):
directory_dob = Column(DateTime, nullable=True) directory_dob = Column(DateTime, nullable=True)
directory_partner_name = Column(String, nullable=True) directory_partner_name = Column(String, nullable=True)
# Password Reset Fields
password_reset_token = Column(String, nullable=True)
password_reset_expires = Column(DateTime, nullable=True)
force_password_change = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))

175
server.py
View File

@@ -20,9 +20,18 @@ from auth import (
verify_password, verify_password,
create_access_token, create_access_token,
get_current_user, get_current_user,
get_current_admin_user get_current_admin_user,
get_active_member,
create_password_reset_token,
verify_reset_token
)
from email_service import (
send_verification_email,
send_approval_notification,
send_payment_prompt_email,
send_password_reset_email,
send_admin_password_reset_email
) )
from email_service import send_verification_email, send_approval_notification, send_payment_prompt_email
from payment_service import create_checkout_session, verify_webhook_signature, get_subscription_end_date from payment_service import create_checkout_session, verify_webhook_signature, get_subscription_end_date
# Load environment variables # Load environment variables
@@ -125,6 +134,20 @@ class LoginResponse(BaseModel):
token_type: str token_type: str
user: dict user: dict
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str
new_password: str = Field(min_length=6)
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str = Field(min_length=6)
class AdminPasswordUpdateRequest(BaseModel):
force_change: bool = True
class UserResponse(BaseModel): class UserResponse(BaseModel):
id: str id: str
email: str email: str
@@ -314,9 +337,32 @@ async def verify_email(token: str, db: Session = Depends(get_db)):
db.refresh(user) db.refresh(user)
logger.info(f"Email verified for user: {user.email}") logger.info(f"Email verified for user: {user.email}")
return {"message": "Email verified successfully", "status": user.status.value} return {"message": "Email verified successfully", "status": user.status.value}
@api_router.post("/auth/resend-verification-email")
async def resend_verification_email(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""User requests to resend their verification email"""
# Check if email already verified
if current_user.email_verified:
raise HTTPException(status_code=400, detail="Email is already verified")
# Generate new token
verification_token = secrets.token_urlsafe(32)
current_user.email_verification_token = verification_token
db.commit()
# Send verification email
await send_verification_email(current_user.email, verification_token)
logger.info(f"Verification email resent to: {current_user.email}")
return {"message": "Verification email has been resent. Please check your inbox."}
@api_router.post("/auth/login", response_model=LoginResponse) @api_router.post("/auth/login", response_model=LoginResponse)
async def login(request: LoginRequest, db: Session = Depends(get_db)): async def login(request: LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == request.email).first() user = db.query(User).filter(User.email == request.email).first()
@@ -338,10 +384,60 @@ async def login(request: LoginRequest, db: Session = Depends(get_db)):
"first_name": user.first_name, "first_name": user.first_name,
"last_name": user.last_name, "last_name": user.last_name,
"status": user.status.value, "status": user.status.value,
"role": user.role.value "role": user.role.value,
"force_password_change": user.force_password_change
} }
} }
@api_router.post("/auth/forgot-password")
async def forgot_password(request: ForgotPasswordRequest, db: Session = Depends(get_db)):
"""Request password reset - sends email with reset link"""
user = db.query(User).filter(User.email == request.email).first()
# Always return success (security: don't reveal if email exists)
if user:
token = create_password_reset_token(user, db)
reset_url = f"{os.getenv('FRONTEND_URL')}/reset-password?token={token}"
await send_password_reset_email(user.email, user.first_name, reset_url)
return {"message": "If email exists, reset link has been sent"}
@api_router.post("/auth/reset-password")
async def reset_password(request: ResetPasswordRequest, db: Session = Depends(get_db)):
"""Complete password reset using token"""
user = verify_reset_token(request.token, db)
if not user:
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
# Update password
user.password_hash = get_password_hash(request.new_password)
user.password_reset_token = None
user.password_reset_expires = None
user.force_password_change = False # Reset flag if it was set
db.commit()
return {"message": "Password reset successful"}
@api_router.put("/users/change-password")
async def change_password(
request: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""User changes their own password"""
# Verify current password
if not verify_password(request.current_password, current_user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect")
# Update password
current_user.password_hash = get_password_hash(request.new_password)
current_user.force_password_change = False # Clear flag if set
db.commit()
return {"message": "Password changed successfully"}
@api_router.get("/auth/me", response_model=UserResponse) @api_router.get("/auth/me", response_model=UserResponse)
async def get_me(current_user: User = Depends(get_current_user)): async def get_me(current_user: User = Depends(get_current_user)):
return UserResponse( return UserResponse(
@@ -412,6 +508,7 @@ async def update_profile(
# Event Routes # Event Routes
@api_router.get("/events", response_model=List[EventResponse]) @api_router.get("/events", response_model=List[EventResponse])
async def get_events( async def get_events(
current_user: User = Depends(get_active_member),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
# Get published events for all users # Get published events for all users
@@ -445,6 +542,7 @@ async def get_events(
@api_router.get("/events/{event_id}", response_model=EventResponse) @api_router.get("/events/{event_id}", response_model=EventResponse)
async def get_event( async def get_event(
event_id: str, event_id: str,
current_user: User = Depends(get_active_member),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
event = db.query(Event).filter(Event.id == event_id).first() event = db.query(Event).filter(Event.id == event_id).first()
@@ -478,7 +576,7 @@ async def get_event(
async def rsvp_to_event( async def rsvp_to_event(
event_id: str, event_id: str,
request: RSVPRequest, request: RSVPRequest,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_active_member),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
event = db.query(Event).filter(Event.id == event_id).first() event = db.query(Event).filter(Event.id == event_id).first()
@@ -743,6 +841,73 @@ async def activate_payment_manually(
"subscription_id": str(subscription.id) "subscription_id": str(subscription.id)
} }
@api_router.put("/admin/users/{user_id}/reset-password")
async def admin_reset_user_password(
user_id: str,
request: AdminPasswordUpdateRequest,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""Admin resets user password - generates temp password and emails it"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Generate random temporary password
temp_password = secrets.token_urlsafe(12)
# Update user
user.password_hash = get_password_hash(temp_password)
user.force_password_change = request.force_change
db.commit()
# Email user the temporary password
await send_admin_password_reset_email(
user.email,
user.first_name,
temp_password,
request.force_change
)
# Log admin action
logger.info(
f"Admin {current_user.email} reset password for user {user.email} "
f"(force_change={request.force_change})"
)
return {"message": f"Password reset for {user.email}. Temporary password emailed."}
@api_router.post("/admin/users/{user_id}/resend-verification")
async def admin_resend_verification(
user_id: str,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""Admin resends verification email for any user"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Check if email already verified
if user.email_verified:
raise HTTPException(status_code=400, detail="User's email is already verified")
# Generate new token
verification_token = secrets.token_urlsafe(32)
user.email_verification_token = verification_token
db.commit()
# Send verification email
await send_verification_email(user.email, verification_token)
# Log admin action
logger.info(
f"Admin {current_user.email} resent verification email to user {user.email}"
)
return {"message": f"Verification email resent to {user.email}"}
@api_router.post("/admin/events", response_model=EventResponse) @api_router.post("/admin/events", response_model=EventResponse)
async def create_event( async def create_event(
request: EventCreate, request: EventCreate,