Basic frontend

This commit is contained in:
James Pattinson
2025-11-10 14:51:15 +00:00
parent 3751ee0076
commit 93aeda8e83
27 changed files with 1828 additions and 2 deletions

View File

@@ -3,12 +3,14 @@ from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from typing import List
import uuid
from ...core.database import get_db
from ...core.security import verify_password, get_password_hash, create_access_token
from ...models.models import User, UserRole
from ...models.models import User, UserRole, PasswordResetToken
from ...schemas import (
UserCreate, UserResponse, Token, LoginRequest, MessageResponse
UserCreate, UserResponse, Token, LoginRequest, MessageResponse,
ForgotPasswordRequest, ResetPasswordRequest
)
from ...services.email_service import email_service
@@ -126,3 +128,92 @@ async def login_json(
"access_token": access_token,
"token_type": "bearer"
}
@router.post("/forgot-password", response_model=MessageResponse)
async def forgot_password(
request: ForgotPasswordRequest,
db: Session = Depends(get_db)
):
"""Request password reset for a user"""
# Find user by email
user = db.query(User).filter(User.email == request.email).first()
if not user or not user.is_active:
# Don't reveal if email exists or not for security
return {"message": "If an account with this email exists, a password reset link has been sent."}
# Invalidate any existing reset tokens for this user
db.query(PasswordResetToken).filter(
PasswordResetToken.user_id == user.id,
PasswordResetToken.used == False,
PasswordResetToken.expires_at > datetime.utcnow()
).update({"used": True})
# Generate new reset token
reset_token = str(uuid.uuid4())
expires_at = datetime.utcnow() + timedelta(hours=1) # Token expires in 1 hour
# Create password reset token
db_token = PasswordResetToken(
user_id=user.id,
token=reset_token,
expires_at=expires_at,
used=False
)
db.add(db_token)
db.commit()
# Send password reset email (non-blocking, ignore errors)
try:
await email_service.send_password_reset_email(
to_email=user.email,
first_name=user.first_name,
reset_token=reset_token
)
except Exception as e:
# Log error but don't fail the request
print(f"Failed to send password reset email: {e}")
return {"message": "If an account with this email exists, a password reset link has been sent."}
@router.post("/reset-password", response_model=MessageResponse)
async def reset_password(
request: ResetPasswordRequest,
db: Session = Depends(get_db)
):
"""Reset password using reset token"""
# Find valid reset token
reset_token = db.query(PasswordResetToken).filter(
PasswordResetToken.token == request.token,
PasswordResetToken.used == False,
PasswordResetToken.expires_at > datetime.utcnow()
).first()
if not reset_token:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired reset token"
)
# Get the user
user = db.query(User).filter(User.id == reset_token.user_id).first()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid reset token"
)
# Update password
hashed_password = get_password_hash(request.new_password)
user.hashed_password = hashed_password
user.updated_at = datetime.utcnow()
# Mark token as used
reset_token.used = True
db.commit()
return {"message": "Password has been reset successfully. You can now log in with your new password."}

View File

@@ -37,6 +37,7 @@ class Settings(BaseSettings):
SMTP2GO_API_URL: str = "https://api.smtp2go.com/v3/email/send"
EMAIL_FROM: str
EMAIL_FROM_NAME: str
FRONTEND_URL: str = "http://localhost:3500"
# CORS
BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8080"]

View File

@@ -259,3 +259,17 @@ class Notification(Base):
sent_at = Column(DateTime, nullable=True)
error_message = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
class PasswordResetToken(Base):
__tablename__ = "password_reset_tokens"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
token = Column(String(255), unique=True, nullable=False, index=True)
expires_at = Column(DateTime, nullable=False)
used = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User", backref="password_reset_tokens")

View File

@@ -7,6 +7,8 @@ from .schemas import (
Token,
TokenData,
LoginRequest,
ForgotPasswordRequest,
ResetPasswordRequest,
MembershipTierBase,
MembershipTierCreate,
MembershipTierUpdate,
@@ -31,6 +33,8 @@ __all__ = [
"Token",
"TokenData",
"LoginRequest",
"ForgotPasswordRequest",
"ResetPasswordRequest",
"MembershipTierBase",
"MembershipTierCreate",
"MembershipTierUpdate",

View File

@@ -53,6 +53,16 @@ class LoginRequest(BaseModel):
password: str
# Password Reset Schemas
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str = Field(..., min_length=1)
new_password: str = Field(..., min_length=8)
# Membership Tier Schemas
class MembershipTierBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)

View File

@@ -191,6 +191,59 @@ class EmailService:
"""
return await self.send_email(to_email, subject, html_body, text_body)
async def send_password_reset_email(
self,
to_email: str,
first_name: str,
reset_token: str
) -> dict:
"""Send password reset email with reset link"""
subject = f"Password Reset - {settings.APP_NAME}"
reset_url = f"{settings.FRONTEND_URL}/reset-password?token={reset_token}"
html_body = f"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<h2 style="color: #0066cc;">Password Reset Request</h2>
<p>Hello {first_name},</p>
<p>You have requested to reset your password for your {settings.APP_NAME} account.</p>
<p>Please click the button below to reset your password:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{reset_url}" style="background-color: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;">Reset Password</a>
</div>
<p>If the button doesn't work, you can copy and paste this link into your browser:</p>
<p style="word-break: break-all; background-color: #f5f5f5; padding: 10px; border-radius: 3px;">{reset_url}</p>
<p><strong>This link will expire in 1 hour.</strong></p>
<p>If you didn't request this password reset, please ignore this email. Your password will remain unchanged.</p>
<p>For security reasons, please don't share this email with anyone.</p>
<p>Best regards,<br>
<strong>{settings.APP_NAME}</strong></p>
</body>
</html>
"""
text_body = f"""
Password Reset Request
Hello {first_name},
You have requested to reset your password for your {settings.APP_NAME} account.
Please use this link to reset your password: {reset_url}
This link will expire in 1 hour.
If you didn't request this password reset, please ignore this email. Your password will remain unchanged.
For security reasons, please don't share this email with anyone.
Best regards,
{settings.APP_NAME}
"""
return await self.send_email(to_email, subject, html_body, text_body)
# Create a singleton instance