diff --git a/backend/app/db.py b/backend/app/db.py
new file mode 100644
index 0000000..1e568b3
--- /dev/null
+++ b/backend/app/db.py
@@ -0,0 +1,19 @@
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker, declarative_base
+import os
+
+DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./stockvision.db")
+
+# For SQLite + multithreading with FastAPI, use connect_args
+connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
+
+engine = create_engine(DATABASE_URL, connect_args=connect_args, echo=False)
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+Base = declarative_base()
+
+def get_db():
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/backend/app/models.py b/backend/app/models.py
index 9a969e7..dd2e738 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -1,4 +1,4 @@
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, EmailStr
from typing import List, Optional, Dict
from datetime import datetime
@@ -80,4 +80,19 @@ class CreatePortfolioRequest(BaseModel):
class UpdatePortfolioRequest(BaseModel):
name: Optional[str] = Field(None, description="Portfolio name")
- description: Optional[str] = Field(None, description="Portfolio description")
\ No newline at end of file
+ description: Optional[str] = Field(None, description="Portfolio description")
+
+class User(BaseModel):
+ email: EmailStr
+ password: str
+
+class UserInDB(User):
+ reset_token: Optional[str] = None
+ reset_token_expiry: Optional[datetime] = None
+
+class ForgotPasswordRequest(BaseModel):
+ email: EmailStr
+
+class ResetPasswordRequest(BaseModel):
+ token: str
+ new_password: str
\ No newline at end of file
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
new file mode 100644
index 0000000..18dae90
--- /dev/null
+++ b/backend/app/routers/auth.py
@@ -0,0 +1,78 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+from datetime import datetime, timedelta
+from pydantic import EmailStr
+import secrets
+import os
+import smtplib
+from email.mime.text import MIMEText
+from passlib.context import CryptContext
+
+from app.db import get_db
+from app.user_models import UserDB
+from app.models import ForgotPasswordRequest, ResetPasswordRequest
+
+router = APIRouter(prefix="/auth", tags=["Auth"])
+
+pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+
+# Signup (for testing/demo)
+@router.post("/signup")
+def signup(email: EmailStr, password: str, db: Session = Depends(get_db)):
+ user = db.query(UserDB).filter(UserDB.email == email).first()
+ if user:
+ raise HTTPException(status_code=400, detail="User already exists")
+ hashed = pwd_context.hash(password)
+ new_user = UserDB(email=email, hashed_password=hashed)
+ db.add(new_user)
+ db.commit()
+ db.refresh(new_user)
+ return {"message": "User registered successfully"}
+
+# Forgot password
+@router.post("/forgot-password")
+def forgot_password(payload: ForgotPasswordRequest, db: Session = Depends(get_db)):
+ user = db.query(UserDB).filter(UserDB.email == payload.email).first()
+ generic_response = {"message": "If an account with that email exists, a reset link has been sent."}
+ if not user:
+ return generic_response
+
+ token = secrets.token_urlsafe(32)
+ expiry = datetime.utcnow() + timedelta(minutes=15)
+ user.reset_token = token
+ user.reset_token_expiry = expiry
+ db.add(user)
+ db.commit()
+
+ reset_link = f"{os.getenv('FRONTEND_URL', 'https://stock-vision-seven.vercel.app')}/reset-password/{token}"
+
+ msg = MIMEText(f"Click to reset your password: {reset_link}")
+ msg["Subject"] = "StockVision Password Reset"
+ msg["From"] = os.getenv("EMAIL_USER")
+ msg["To"] = user.email
+
+ try:
+ smtp_server = os.getenv("SMTP_SERVER", "smtp.gmail.com")
+ smtp_port = int(os.getenv("SMTP_PORT", 587))
+ with smtplib.SMTP(smtp_server, smtp_port) as server:
+ server.starttls()
+ server.login(os.getenv("EMAIL_USER"), os.getenv("EMAIL_PASS"))
+ server.send_message(msg)
+ except smtplib.SMTPException as e:
+ # TODO: Log the error `e` for debugging purposes.
+ raise HTTPException(status_code=500, detail="Could not send email. Please try again later.")
+
+ return generic_response
+
+# Reset password
+@router.post("/reset-password")
+def reset_password(payload: ResetPasswordRequest, db: Session = Depends(get_db)):
+ user = db.query(UserDB).filter(UserDB.reset_token == payload.token).first()
+ if not user or not user.reset_token_expiry or user.reset_token_expiry < datetime.utcnow():
+ raise HTTPException(status_code=400, detail="Invalid or expired token")
+ user.hashed_password = pwd_context.hash(payload.new_password)
+ user.reset_token = None
+ user.reset_token_expiry = None
+ db.add(user)
+ db.commit()
+ return {"message": "Password has been reset successfully"}
diff --git a/backend/app/user_models.py b/backend/app/user_models.py
new file mode 100644
index 0000000..ba7cf4f
--- /dev/null
+++ b/backend/app/user_models.py
@@ -0,0 +1,15 @@
+from sqlalchemy import Column, Integer, String, DateTime, Boolean
+from datetime import datetime
+from .db import Base
+
+class UserDB(Base):
+ __tablename__ = "users"
+
+ id = Column(Integer, primary_key=True, index=True)
+ email = Column(String, unique=True, index=True, nullable=False)
+ hashed_password = Column(String, nullable=False)
+ reset_token = Column(String, nullable=True, index=True)
+ reset_token_expiry = Column(DateTime, nullable=True)
+ is_active = Column(Boolean, default=True)
+ created_at = Column(DateTime, default=datetime.utcnow)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
diff --git a/backend/main.py b/backend/main.py
index 3c42895..9e3f5b2 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -6,7 +6,11 @@
from dotenv import load_dotenv
# Import routers
-from app.routers import stocks, market, portfolios
+from app.routers import stocks, market, portfolios, auth
+
+# Import database
+from app.db import engine, Base
+from app.user_models import UserDB
# Load environment variables
load_dotenv()
@@ -37,6 +41,10 @@
app.include_router(stocks.router)
app.include_router(market.router)
app.include_router(portfolios.router)
+app.include_router(auth.router) # Auth routes added
+
+# Create all DB tables
+Base.metadata.create_all(bind=engine)
# Health check endpoints
@app.get("/")
@@ -69,4 +77,4 @@ async def internal_error_handler(request, exc):
port=8000,
reload=True,
log_level="info"
- )
\ No newline at end of file
+ )
diff --git a/backend/stockvision.db b/backend/stockvision.db
new file mode 100644
index 0000000..649038a
Binary files /dev/null and b/backend/stockvision.db differ
diff --git a/frontend/app/forgot-password/page.tsx b/frontend/app/forgot-password/page.tsx
new file mode 100644
index 0000000..9da9f86
--- /dev/null
+++ b/frontend/app/forgot-password/page.tsx
@@ -0,0 +1,73 @@
+"use client";
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import toast, { Toaster } from "react-hot-toast";
+
+export default function ForgotPasswordPage() {
+ const [email, setEmail] = useState("");
+ const [loading, setLoading] = useState(false);
+ const router = useRouter();
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+
+ try {
+ const res = await fetch(
+ `${process.env.NEXT_PUBLIC_BACKEND_URL}/auth/forgot-password`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email }),
+ }
+ );
+ const data = await res.json();
+ if (!res.ok) {
+ toast.error("Unable to send reset link. Please try again later.");
+ } else {
+ toast.success("Password reset link sent to your email!");
+ }
+ } catch (err) {
+ toast.error("Unable to send reset link. Try again later.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ Forgot Password
+
+
+