Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions backend/app/db.py
Original file line number Diff line number Diff line change
@@ -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()
19 changes: 17 additions & 2 deletions backend/app/models.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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")
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
78 changes: 78 additions & 0 deletions backend/app/routers/auth.py
Original file line number Diff line number Diff line change
@@ -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"}
15 changes: 15 additions & 0 deletions backend/app/user_models.py
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 10 additions & 2 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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("/")
Expand Down Expand Up @@ -69,4 +77,4 @@ async def internal_error_handler(request, exc):
port=8000,
reload=True,
log_level="info"
)
)
Binary file added backend/stockvision.db
Binary file not shown.
73 changes: 73 additions & 0 deletions frontend/app/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-900">
<Toaster position="top-center" reverseOrder={false} />
<div className="w-full max-w-md mx-auto mt-20 p-8 bg-white dark:bg-gray-800 rounded-3xl shadow-2xl border border-gray-200 dark:border-gray-700">
<h1 className="text-2xl md:text-3xl font-bold text-center text-gray-900 dark:text-white mb-8">
Forgot Password
</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-400 dark:focus:border-blue-400 transition-all duration-200"
required
/>
<button
type="submit"
className="w-full px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-xl shadow-lg font-semibold transition-all duration-200"
disabled={loading}
>
{loading ? "Sending..." : "Send Reset Link"}
</button>
</form>
<div className="text-center mt-6 text-gray-600 dark:text-gray-300">
Remember your password?{" "}
<button
onClick={() => router.push("/login")}
className="text-blue-600 dark:text-blue-400 hover:underline"
>
Sign In
</button>
</div>
</div>
</main>
);
}
31 changes: 22 additions & 9 deletions frontend/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,13 @@ export default function LoginPage() {
const handleSocialSignIn = async (provider: "github" | "google") => {
setSocialLoading(provider);
setError("");

try {
const result = await signIn(provider, {
redirect: false,
callbackUrl: "/dashboard"
});

if (result?.error) {
setError(`Failed to sign in with ${provider}. Please try again.`);
} else if (result?.url) {
Expand Down Expand Up @@ -99,16 +99,12 @@ export default function LoginPage() {
className="absolute top-6 right-6 p-2 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg hover:shadow-xl transition-all duration-200"
aria-label="Toggle theme"
>
{isDark ? (
<Sun className="h-5 w-5 text-yellow-500" />
) : (
<Moon className="h-5 w-5 text-gray-600" />
)}
{isDark ? <Sun className="h-5 w-5 text-yellow-500" /> : <Moon className="h-5 w-5 text-gray-600" />}
</button>

<div className="w-full max-w-xs md:max-w-md mx-auto mt-20 p-8 bg-white dark:bg-gray-800 rounded-3xl shadow-2xl border border-gray-200 dark:border-gray-700">
<h1 className="text-2xl md:text-3xl font-bold text-center text-gray-900 dark:text-white mb-8">Sign In</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="space-y-2">
<input
type="email"
Expand All @@ -129,7 +125,20 @@ export default function LoginPage() {
required
/>
</div>

{/* Forgot Password Link */}
<div className="text-right">
<button
type="button"
onClick={() => router.push("/forgot-password")}
className="text-yellow-500 dark:text-yellow-400 hover:underline text-sm"
>
Forgot Password?
</button>
</div>

{error && <div className="text-red-600 dark:text-red-400 text-sm text-center bg-red-50 dark:bg-red-900/20 px-4 py-2 rounded-lg border border-red-200 dark:border-red-800">{error}</div>}

<button
type="submit"
className="w-full px-4 py-2 md:px-6 md:py-3 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white rounded-xl shadow-lg transition-all duration-200 font-semibold transform hover:scale-105 active:scale-95"
Expand All @@ -138,7 +147,9 @@ export default function LoginPage() {
{loading ? "Signing in..." : "Sign In"}
</button>
</form>
<div className="flex flex-col gap-3 mt-8">

{/* Social Sign-In Buttons */}
<div className="flex flex-col gap-3 mt-6">
<button
className="w-full px-4 py-2 md:px-6 md:py-3 bg-gray-900 dark:bg-gray-700 hover:bg-gray-800 dark:hover:bg-gray-600 text-white rounded-xl shadow-lg transition-all duration-200 font-semibold border border-gray-300 dark:border-gray-600 transform hover:scale-105 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
onClick={() => handleSocialSignIn("github")}
Expand All @@ -153,6 +164,7 @@ export default function LoginPage() {
"Sign in with GitHub"
)}
</button>

<button
className="w-full px-4 py-2 md:px-6 md:py-3 bg-red-600 hover:bg-red-700 text-white rounded-xl shadow-lg transition-all duration-200 font-semibold border border-red-300 dark:border-red-600 transform hover:scale-105 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
onClick={() => handleSocialSignIn("google")}
Expand All @@ -168,6 +180,7 @@ export default function LoginPage() {
)}
</button>
</div>

<div className="text-center mt-6 text-gray-600 dark:text-gray-300">
Don't have an account? <button onClick={() => handleNavigation("/signup")} className="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 underline transition-colors">Sign Up</button>
</div>
Expand Down
Loading