forked from andika/membership-be
Email SMTP Fix
This commit is contained in:
33
.env.example
Normal file
33
.env.example
Normal 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.
Binary file not shown.
46
auth.py
46
auth.py
@@ -6,6 +6,7 @@ from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
import os
|
||||
import secrets
|
||||
from database import get_db
|
||||
from models import User, UserRole
|
||||
|
||||
@@ -22,6 +23,33 @@ def verify_password(plain_password, hashed_password):
|
||||
def get_password_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):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
@@ -78,3 +106,21 @@ async def get_current_admin_user(current_user: User = Depends(get_current_user))
|
||||
detail="Not enough permissions"
|
||||
)
|
||||
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
|
||||
|
||||
235
email_service.py
235
email_service.py
@@ -1,24 +1,97 @@
|
||||
import os
|
||||
import ssl
|
||||
import smtplib
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import aiosmtplib
|
||||
import logging
|
||||
from dotenv import load_dotenv
|
||||
|
||||
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_PORT = int(os.environ.get('SMTP_PORT', 587))
|
||||
SMTP_USERNAME = os.environ.get('SMTP_USERNAME', '')
|
||||
SMTP_PASSWORD = os.environ.get('SMTP_PASSWORD', '')
|
||||
SMTP_FROM_EMAIL = os.environ.get('SMTP_FROM_EMAIL', 'noreply@membership.com')
|
||||
SMTP_FROM_NAME = os.environ.get('SMTP_FROM_NAME', 'Membership Platform')
|
||||
SMTP_SECURE = os.environ.get('SMTP_SECURE', 'false').lower() == 'true'
|
||||
SMTP_VERIFY_CERT = os.environ.get('SMTP_VERIFY_CERT', 'true').lower() == 'true'
|
||||
SMTP_AUTH_METHOD = os.environ.get('SMTP_AUTH_METHOD', None) # PLAIN, LOGIN, or CRAM-MD5
|
||||
|
||||
# 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')
|
||||
|
||||
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):
|
||||
"""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:
|
||||
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['Subject'] = subject
|
||||
|
||||
@@ -26,25 +99,31 @@ async def send_email(to_email: str, subject: str, html_content: str):
|
||||
message.attach(html_part)
|
||||
|
||||
# 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] Subject: {subject}")
|
||||
logger.info(f"[EMAIL] Content: {html_content}")
|
||||
logger.info(f"[EMAIL] Content: {html_content[:200]}...")
|
||||
return True
|
||||
|
||||
# Send actual email
|
||||
await aiosmtplib.send(
|
||||
message,
|
||||
hostname=SMTP_HOST,
|
||||
port=SMTP_PORT,
|
||||
username=SMTP_USERNAME,
|
||||
password=SMTP_PASSWORD,
|
||||
start_tls=True
|
||||
)
|
||||
logger.info(f"Email sent successfully to {to_email}")
|
||||
logger.info(f"📧 Sending email to {to_email} via {SMTP_HOST}:{SMTP_PORT}")
|
||||
|
||||
# Run synchronous SMTP in thread pool to avoid blocking
|
||||
await asyncio.to_thread(_send_email_sync, message, to_email)
|
||||
|
||||
logger.info(f"✓ Email sent successfully to {to_email}")
|
||||
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:
|
||||
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
|
||||
|
||||
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>
|
||||
"""
|
||||
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
11
fix_enum.sql
Normal 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
43
migrate_password_reset.py
Normal 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()
|
||||
@@ -77,6 +77,11 @@ class User(Base):
|
||||
directory_dob = Column(DateTime, 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))
|
||||
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
173
server.py
173
server.py
@@ -20,9 +20,18 @@ from auth import (
|
||||
verify_password,
|
||||
create_access_token,
|
||||
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
|
||||
|
||||
# Load environment variables
|
||||
@@ -125,6 +134,20 @@ class LoginResponse(BaseModel):
|
||||
token_type: str
|
||||
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):
|
||||
id: str
|
||||
email: str
|
||||
@@ -317,6 +340,29 @@ async def verify_email(token: str, db: Session = Depends(get_db)):
|
||||
|
||||
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)
|
||||
async def login(request: LoginRequest, db: Session = Depends(get_db)):
|
||||
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,
|
||||
"last_name": user.last_name,
|
||||
"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)
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
return UserResponse(
|
||||
@@ -412,6 +508,7 @@ async def update_profile(
|
||||
# Event Routes
|
||||
@api_router.get("/events", response_model=List[EventResponse])
|
||||
async def get_events(
|
||||
current_user: User = Depends(get_active_member),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Get published events for all users
|
||||
@@ -445,6 +542,7 @@ async def get_events(
|
||||
@api_router.get("/events/{event_id}", response_model=EventResponse)
|
||||
async def get_event(
|
||||
event_id: str,
|
||||
current_user: User = Depends(get_active_member),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
event = db.query(Event).filter(Event.id == event_id).first()
|
||||
@@ -478,7 +576,7 @@ async def get_event(
|
||||
async def rsvp_to_event(
|
||||
event_id: str,
|
||||
request: RSVPRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(get_active_member),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
event = db.query(Event).filter(Event.id == event_id).first()
|
||||
@@ -743,6 +841,73 @@ async def activate_payment_manually(
|
||||
"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)
|
||||
async def create_event(
|
||||
request: EventCreate,
|
||||
|
||||
Reference in New Issue
Block a user