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 +

+
+ 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 + /> + +
+
+ Remember your password?{" "} + +
+
+
+ ); +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 121cb77..fbac7d1 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -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) { @@ -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 ? ( - - ) : ( - - )} + {isDark ? : }

Sign In

-
+
+ + {/* Forgot Password Link */} +
+ +
+ {error &&
{error}
} +
+
+
Don't have an account?
diff --git a/frontend/app/reset-password/[token]/page.tsx b/frontend/app/reset-password/[token]/page.tsx new file mode 100644 index 0000000..4614514 --- /dev/null +++ b/frontend/app/reset-password/[token]/page.tsx @@ -0,0 +1,81 @@ +"use client"; +import { useState } from "react"; +import { useRouter, useParams } from "next/navigation"; +import toast, { Toaster } from "react-hot-toast"; + +export default function ResetPasswordPage() { + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [loading, setLoading] = useState(false); + const router = useRouter(); + const params = useParams(); + const token = params.token; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (newPassword !== confirmPassword) { + toast.error("Passwords do not match"); + return; + } + + setLoading(true); + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/auth/reset-password`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, new_password: newPassword }), + } + ); + const data = await res.json(); + if (!res.ok) { + toast.error(data.message || "Failed to reset password"); + } else { + toast.success("Password reset successfully! Redirecting to login..."); + setTimeout(() => router.push("/login"), 3000); + } + } catch (err) { + toast.error("Unable to reset password. Try again later."); + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+

+ Reset Password +

+
+ setNewPassword(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 + /> + setConfirmPassword(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 + /> + +
+
+
+ ); +} diff --git a/frontend/package.json b/frontend/package.json index e11d3f5..8278d8c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -70,6 +70,7 @@ "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", "react-hook-form": "^7.53.0", + "react-hot-toast": "^2.6.0", "react-resizable-panels": "^2.1.3", "recharts": "^2.15.4", "shadcn": "^2.8.0", diff --git a/package-lock.json b/package-lock.json index 552e190..9dadfbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "figlet": "^1.8.2", "next-auth": "^4.24.11", "prisma": "^6.13.0", + "react-hot-toast": "^2.6.0", "react-intersection-observer": "^9.16.0", "sqlite3": "^5.1.7" }, @@ -97,6 +98,7 @@ "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", "react-hook-form": "^7.53.0", + "react-hot-toast": "^2.6.0", "react-resizable-panels": "^2.1.3", "recharts": "^2.15.4", "shadcn": "^2.8.0", @@ -8585,6 +8587,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/goober": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", + "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -14279,6 +14289,22 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-intersection-observer": { "version": "9.16.0", "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.16.0.tgz", diff --git a/package.json b/package.json index 207f4f2..de0e622 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "figlet": "^1.8.2", "next-auth": "^4.24.11", "prisma": "^6.13.0", + "react-hot-toast": "^2.6.0", "react-intersection-observer": "^9.16.0", "sqlite3": "^5.1.7" },