diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..ba2a6c01 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:system", + "python-envs.pythonProjects": [] +} \ No newline at end of file diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/.gitignore b/backend/app/.gitignore index e69de29b..c6f7e31d 100644 --- a/backend/app/.gitignore +++ b/backend/app/.gitignore @@ -0,0 +1,28 @@ +.env +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +# next.js +/.next/ +/out/ + +# production +/build + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts \ No newline at end of file diff --git a/backend/app/db/mongo.py b/backend/app/db/mongo.py new file mode 100644 index 00000000..75159b06 --- /dev/null +++ b/backend/app/db/mongo.py @@ -0,0 +1,38 @@ +import os +from typing import Optional + +from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase + + +_client: Optional[AsyncIOMotorClient] = None +_db: Optional[AsyncIOMotorDatabase] = None + + +def get_mongo_uri() -> str: + # Expect a MongoDB connection string in environment (Atlas URI) + return os.getenv("MONGODB_URI", "mongodb://localhost:27017") + + +def init_mongo(app=None) -> None: + global _client, _db + if _client is None: + uri = get_mongo_uri() + _client = AsyncIOMotorClient(uri) + # default database name + db_name = os.getenv("MONGODB_DB", "perspective") + _db = _client[db_name] + + +def close_mongo() -> None: + global _client, _db + if _client is not None: + _client.close() + # Reset globals so future calls re-init a fresh client instead of returning closed handle + _client = None + _db = None + + +def get_db() -> AsyncIOMotorDatabase: + if _db is None: + init_mongo() + return _db diff --git a/backend/app/db/user_store.py b/backend/app/db/user_store.py new file mode 100644 index 00000000..3e79ff27 --- /dev/null +++ b/backend/app/db/user_store.py @@ -0,0 +1,33 @@ +from typing import Optional +from datetime import datetime + +from app.models.user import User +from app.db.mongo import get_db + + +async def get_user_by_email(email: str) -> Optional[User]: + db = get_db() + doc = await db.users.find_one({"email": email}) + if not doc: + return None + # convert Mongo's _id and possible datetime + if "_id" in doc: + doc["id"] = str(doc.pop("_id")) + return User(**doc) + + +async def create_user(user: User) -> User: + db = get_db() + existing = await db.users.find_one({"email": user.email}) + if existing: + raise ValueError("User with this email already exists") + payload = user.model_dump() + # store created_at as datetime + if isinstance(payload.get("created_at"), str): + try: + payload["created_at"] = datetime.fromisoformat(payload["created_at"]) + except Exception: + payload["created_at"] = datetime.utcnow() + result = await db.users.insert_one(payload) + payload["id"] = str(result.inserted_id) + return User(**payload) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 00000000..03f94c6b --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1 @@ +"""User models for authentication.""" diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 00000000..8bdc0aec --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, EmailStr, Field +from typing import Optional +from datetime import datetime, timezone +from uuid import uuid4 + + +class UserCreate(BaseModel): + name: str + email: EmailStr + password: str + + +class User(BaseModel): + id: str = Field(default_factory=lambda: str(uuid4())) + name: str + email: EmailStr + hashed_password: str + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class UserPublic(BaseModel): + id: str + name: str + email: EmailStr + created_at: datetime diff --git a/backend/app/modules/vector_store/embed.py b/backend/app/modules/vector_store/embed.py index 3ab68f07..30c4a502 100644 --- a/backend/app/modules/vector_store/embed.py +++ b/backend/app/modules/vector_store/embed.py @@ -22,10 +22,59 @@ """ -from sentence_transformers import SentenceTransformer from typing import List, Dict, Any +import os +import logging -embedder = SentenceTransformer("all-MiniLM-L6-v2") + +_embedder = None +_model_name = os.getenv("EMBED_MODEL_NAME", "all-MiniLM-L6-v2") + + +def _get_embedder(): + """Lazily load the SentenceTransformer embedder. If loading fails (network/DNS), + return a deterministic fallback embedder that produces fixed-size vectors. + """ + global _embedder + if _embedder is not None: + return _embedder + + try: + from sentence_transformers import SentenceTransformer + + _embedder = SentenceTransformer(_model_name) + return _embedder + except Exception as exc: # pragma: no cover - defensive fallback + logging.warning( + "Failed to load SentenceTransformer '%s' (%s). Falling back to deterministic embedder.", + _model_name, + exc, + ) + + class _FallbackEmbedder: + def __init__(self, dim: int = 384): + self.dim = dim + + def encode(self, texts: List[str]): + # deterministic hash-based vectors (not semantically meaningful) + import hashlib + + out = [] + for t in texts: + h = hashlib.sha256(t.encode("utf-8")).digest() + # expand/repeat to required dim and convert to floats in [-1,1] + vals = [] + i = 0 + while len(vals) < self.dim: + b = h[i % len(h)] + # map byte to [-1,1] + vals.append((b / 127.5) - 1.0) + i += 1 + out.append(vals[: self.dim]) + return out + + _embedder = _FallbackEmbedder() + return _embedder def embed_chunks(chunks: List[Dict[str, Any]]) -> List[Dict[str, Any]]: @@ -40,7 +89,14 @@ def embed_chunks(chunks: List[Dict[str, Any]]) -> List[Dict[str, Any]]: ) texts = [chunk["text"] for chunk in chunks] - embeddings = embedder.encode(texts).tolist() + embedder = _get_embedder() + embeddings = embedder.encode(texts) + # some embedders return numpy arrays + try: + embeddings = embeddings.tolist() + except Exception: + # assume it's already a list of lists + pass vectors = [] for chunk, embedding in zip(chunks, embeddings): diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py new file mode 100644 index 00000000..ca86030f --- /dev/null +++ b/backend/app/routes/auth.py @@ -0,0 +1,60 @@ +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel, EmailStr +from app.models.user import User, UserCreate, UserPublic +from app.db.user_store import get_user_by_email, create_user +from app.utils.auth import hash_password, verify_password, create_access_token + + +router = APIRouter() + + +class SignupRequest(UserCreate): + pass + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +def _validate_password_strength(password: str): + if len(password) < 8: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters long") + + +@router.post("/signup") +async def signup(body: SignupRequest): + _validate_password_strength(body.password) + existing = await get_user_by_email(body.email) + if existing: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered") + user = User(name=body.name, email=body.email, hashed_password=hash_password(body.password)) + user = await create_user(user) + token = create_access_token(user.email) + return { + "access_token": token, + "token_type": "bearer", + "user": UserPublic(**user.model_dump()).model_dump(), + } + + +@router.post("/login") +async def login(body: LoginRequest): + # Timing attack mitigation: + # Always perform a password verification step even if user does not exist. + user = await get_user_by_email(body.email) + # Pre-generated dummy hash (bcrypt_sha256 of a constant) ensures constant-time path. + # We generate it lazily to avoid import-time work. + from app.utils.auth import hash_password as _hp, verify_password as _vp # local import to avoid circularity + dummy_hash = _hp("__dummy_constant_password__") + hashed = user.hashed_password if user else dummy_hash + password_ok = _vp(body.password, hashed) + if not user or not password_ok: + # Return generic error regardless of which check failed + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") + token = create_access_token(user.email) + return { + "access_token": token, + "token_type": "bearer", + "user": UserPublic(**user.model_dump()).model_dump(), + } diff --git a/backend/app/routes/routes.py b/backend/app/routes/routes.py index 6988f5e8..cfaf8f0c 100644 --- a/backend/app/routes/routes.py +++ b/backend/app/routes/routes.py @@ -30,7 +30,7 @@ """ -from fastapi import APIRouter +from fastapi import APIRouter, Depends from pydantic import BaseModel from app.modules.pipeline import run_scraper_pipeline from app.modules.pipeline import run_langgraph_workflow @@ -38,6 +38,7 @@ from app.modules.chat.get_rag_data import search_pinecone from app.modules.chat.llm_processing import ask_llm from app.logging.logging_config import setup_logger +from app.utils.auth import get_current_user import asyncio import json @@ -60,7 +61,7 @@ async def home(): @router.post("/bias") -async def bias_detection(request: URlRequest): +async def bias_detection(request: URlRequest, user=Depends(get_current_user)): content = await asyncio.to_thread(run_scraper_pipeline, (request.url)) bias_score = await asyncio.to_thread(check_bias, (content)) logger.info(f"Bias detection result: {bias_score}") @@ -68,7 +69,7 @@ async def bias_detection(request: URlRequest): @router.post("/process") -async def run_pipelines(request: URlRequest): +async def run_pipelines(request: URlRequest, user=Depends(get_current_user)): article_text = await asyncio.to_thread(run_scraper_pipeline, (request.url)) logger.debug(f"Scraper output: {json.dumps(article_text, indent=2, ensure_ascii=False)}") data = await asyncio.to_thread(run_langgraph_workflow, (article_text)) @@ -76,7 +77,7 @@ async def run_pipelines(request: URlRequest): @router.post("/chat") -async def answer_query(request: ChatQuery): +async def answer_query(request: ChatQuery, user=Depends(get_current_user)): query = request.message results = search_pinecone(query) answer = ask_llm(query, results) diff --git a/backend/app/utils/auth.py b/backend/app/utils/auth.py new file mode 100644 index 00000000..a9ce3d50 --- /dev/null +++ b/backend/app/utils/auth.py @@ -0,0 +1,56 @@ +import os +from datetime import datetime, timedelta, timezone +from typing import Optional + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from passlib.context import CryptContext + +JWT_SECRET = os.getenv("JWT_SECRET", "change-me") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "120")) + +pwd_context = CryptContext(schemes=["bcrypt_sha256"], deprecated="auto") +bearer_scheme = HTTPBearer(auto_error=True) + + +def hash_password(password: str) -> str: + # passlib's bcrypt_sha256 handles long passwords safely. + return pwd_context.hash(password) + + +def verify_password(password: str, hashed: str) -> bool: + return pwd_context.verify(password, hashed) + + +def create_access_token(subject: str) -> str: + now = datetime.now(timezone.utc) + payload = { + "sub": subject, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)).timestamp()), + } + return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) + + +def decode_token(token: str) -> dict: + try: + return jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") + except jwt.PyJWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + + +async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)) -> dict: + token = credentials.credentials + payload = decode_token(token) + from app.db.user_store import get_user_by_email # local import to avoid circulars + email = payload.get("sub") + if not email: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload") + user = await get_user_by_email(email) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User no longer exists") + return {"email": email} diff --git a/backend/main.py b/backend/main.py index 6f4c4552..dfc12d50 100644 --- a/backend/main.py +++ b/backend/main.py @@ -23,6 +23,8 @@ from app.routes.routes import router as article_router from fastapi.middleware.cors import CORSMiddleware from app.logging.logging_config import setup_logger +from app.routes.auth import router as auth_router +from app.db.mongo import init_mongo, close_mongo # Setup logger for this module logger = setup_logger(__name__) @@ -42,6 +44,18 @@ ) app.include_router(article_router, prefix="/api", tags=["Articles"]) +app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) + + +@app.on_event("startup") +async def on_startup(): + # initialize MongoDB client + init_mongo(app) + + +@app.on_event("shutdown") +async def on_shutdown(): + close_mongo() if __name__ == "__main__": import uvicorn diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 00000000..dfb18f11 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "backend", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 70037f72..009d041d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -25,4 +25,9 @@ dependencies = [ "sentence-transformers>=5.0.0", "trafilatura>=2.0.0", "uvicorn>=0.34.3", + "pyjwt>=2.9.0", + "passlib[bcrypt]>=1.7.4", + "bcrypt<4.0.0", + "pydantic[email]>=2.11.5", + "motor>=3.5.1", ] diff --git a/backend/uv.lock b/backend/uv.lock index fc1e19b5..2056b6d4 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" [[package]] @@ -102,6 +102,7 @@ name = "backend" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "bcrypt" }, { name = "bs4" }, { name = "dotenv" }, { name = "duckduckgo-search" }, @@ -113,9 +114,13 @@ dependencies = [ { name = "langchain-groq" }, { name = "langgraph" }, { name = "logging" }, + { name = "motor" }, { name = "newspaper3k" }, { name = "nltk" }, + { name = "passlib", extra = ["bcrypt"] }, { name = "pinecone" }, + { name = "pydantic", extra = ["email"] }, + { name = "pyjwt" }, { name = "rake-nltk" }, { name = "readability-lxml" }, { name = "requests" }, @@ -126,6 +131,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "bcrypt", specifier = "<4.0.0" }, { name = "bs4", specifier = ">=0.0.2" }, { name = "dotenv", specifier = ">=0.9.9" }, { name = "duckduckgo-search", specifier = ">=8.0.4" }, @@ -137,9 +143,13 @@ requires-dist = [ { name = "langchain-groq", specifier = ">=0.3.2" }, { name = "langgraph", specifier = ">=0.4.8" }, { name = "logging", specifier = ">=0.4.9.6" }, + { name = "motor", specifier = ">=3.5.1" }, { name = "newspaper3k", specifier = ">=0.2.8" }, { name = "nltk", specifier = ">=3.9.1" }, + { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "pinecone", specifier = ">=7.3.0" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.11.5" }, + { name = "pyjwt", specifier = ">=2.9.0" }, { name = "rake-nltk", specifier = ">=1.0.6" }, { name = "readability-lxml", specifier = ">=0.8.4.1" }, { name = "requests", specifier = ">=2.32.3" }, @@ -148,6 +158,27 @@ requires-dist = [ { name = "uvicorn", specifier = ">=0.34.3" }, ] +[[package]] +name = "bcrypt" +version = "3.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/36/edc85ab295ceff724506252b774155eff8a238f13730c8b13badd33ef866/bcrypt-3.2.2.tar.gz", hash = "sha256:433c410c2177057705da2a9f2cd01dd157493b2a7ac14c8593a16b3dab6b6bfb", size = 42455, upload-time = "2022-05-01T17:58:52.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c2/05354b1d4351d2e686a32296cc9dd1e63f9909a580636df0f7b06d774600/bcrypt-3.2.2-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:7180d98a96f00b1050e93f5b0f556e658605dd9f524d0b0e68ae7944673f525e", size = 50049, upload-time = "2022-05-01T18:05:47.625Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b3/1257f7d64ee0aa0eb4fb1de5da8c2647a57db7b737da1f2342ac1889d3b8/bcrypt-3.2.2-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:61bae49580dce88095d669226d5076d0b9d927754cedbdf76c6c9f5099ad6f26", size = 54914, upload-time = "2022-05-01T18:03:00.752Z" }, + { url = "https://files.pythonhosted.org/packages/61/3d/dce83194830183aa700cab07c89822471d21663a86a0b305d1e5c7b02810/bcrypt-3.2.2-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88273d806ab3a50d06bc6a2fc7c87d737dd669b76ad955f449c43095389bc8fb", size = 54403, upload-time = "2022-05-01T18:03:02.483Z" }, + { url = "https://files.pythonhosted.org/packages/86/1b/f4d7425dfc6cd0e405b48ee484df6d80fb39e05f25963dbfcc2c511e8341/bcrypt-3.2.2-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6d2cb9d969bfca5bc08e45864137276e4c3d3d7de2b162171def3d188bf9d34a", size = 62337, upload-time = "2022-05-01T18:05:49.524Z" }, + { url = "https://files.pythonhosted.org/packages/3e/df/289db4f31b303de6addb0897c8b5c01b23bd4b8c511ac80a32b08658847c/bcrypt-3.2.2-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b02d6bfc6336d1094276f3f588aa1225a598e27f8e3388f4db9948cb707b521", size = 61026, upload-time = "2022-05-01T18:05:51.107Z" }, + { url = "https://files.pythonhosted.org/packages/40/8f/b67b42faa2e4d944b145b1a402fc08db0af8fe2dfa92418c674b5a302496/bcrypt-3.2.2-cp36-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a2c46100e315c3a5b90fdc53e429c006c5f962529bc27e1dfd656292c20ccc40", size = 64672, upload-time = "2022-05-01T18:05:52.748Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9a/e1867f0b27a3f4ce90e21dd7f322f0e15d4aac2434d3b938dcf765e47c6b/bcrypt-3.2.2-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7d9ba2e41e330d2af4af6b1b6ec9e6128e91343d0b4afb9282e54e5508f31baa", size = 56795, upload-time = "2022-05-01T18:03:04.028Z" }, + { url = "https://files.pythonhosted.org/packages/18/76/057b0637c880e6cb0abdc8a867d080376ddca6ed7d05b7738f589cc5c1a8/bcrypt-3.2.2-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cd43303d6b8a165c29ec6756afd169faba9396a9472cdff753fe9f19b96ce2fa", size = 62075, upload-time = "2022-05-01T18:05:54.412Z" }, + { url = "https://files.pythonhosted.org/packages/f1/64/cd93e2c3e28a5fa8bcf6753d5cc5e858e4da08bf51404a0adb6a412532de/bcrypt-3.2.2-cp36-abi3-win32.whl", hash = "sha256:4e029cef560967fb0cf4a802bcf4d562d3d6b4b1bf81de5ec1abbe0f1adb027e", size = 27916, upload-time = "2022-05-01T18:05:56.45Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/7cd297ff571c4d86371ff024c0e008b37b59e895b28f69444a9b6f94ca1a/bcrypt-3.2.2-cp36-abi3-win_amd64.whl", hash = "sha256:7ff2069240c6bbe49109fe84ca80508773a904f5a8cb960e02a977f7f519b129", size = 29581, upload-time = "2022-05-01T18:05:57.878Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.13.4" @@ -316,6 +347,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + [[package]] name = "dotenv" version = "0.9.9" @@ -341,6 +381,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/f0/1332de2dc7e7cbcabcf3993b3383dbce6b43d91cb3759fb53916be02845d/duckduckgo_search-8.0.4-py3-none-any.whl", hash = "sha256:22490e83c0ca885998d6623d8274f24934faffc43dac3c3482fe24ea4f6799bb", size = 18219, upload-time = "2025-06-13T05:04:29.052Z" }, ] +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "fastapi" version = "0.115.12" @@ -760,7 +813,7 @@ name = "langgraph-checkpoint" version = "2.0.26" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "langchain-core", marker = "python_full_version < '4.0'" }, + { name = "langchain-core", marker = "python_full_version < '4'" }, { name = "ormsgpack" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c5/61/e2518ac9216a4e9f4efda3ac61595e3c9e9ac00833141c9688e8d56bd7eb/langgraph_checkpoint-2.0.26.tar.gz", hash = "sha256:2b800195532d5efb079db9754f037281225ae175f7a395523f4bf41223cbc9d6", size = 37874, upload-time = "2025-05-15T17:31:22.466Z" } @@ -900,6 +953,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/75/51952c7b2d3873b44a0028b1bd26a25078c18f92f256608e8d1dc61b39fd/marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c", size = 50878, upload-time = "2025-02-03T15:32:22.295Z" }, ] +[[package]] +name = "motor" +version = "3.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pymongo" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/ae/96b88362d6a84cb372f7977750ac2a8aed7b2053eed260615df08d5c84f4/motor-3.7.1.tar.gz", hash = "sha256:27b4d46625c87928f331a6ca9d7c51c2f518ba0e270939d395bc1ddc89d64526", size = 280997, upload-time = "2025-05-14T18:56:33.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/9a/35e053d4f442addf751ed20e0e922476508ee580786546d699b0567c4c67/motor-3.7.1-py3-none-any.whl", hash = "sha256:8a63b9049e38eeeb56b4fdd57c3312a6d1f25d01db717fe7d82222393c410298", size = 74996, upload-time = "2025-05-14T18:56:31.665Z" }, +] + [[package]] name = "mpmath" version = "1.3.0" @@ -1222,6 +1287,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, ] +[[package]] +name = "passlib" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, +] + +[package.optional-dependencies] +bcrypt = [ + { name = "bcrypt" }, +] + [[package]] name = "pillow" version = "11.2.1" @@ -1262,7 +1341,7 @@ dependencies = [ { name = "pinecone-plugin-interface" }, { name = "python-dateutil" }, { name = "typing-extensions" }, - { name = "urllib3", marker = "python_full_version < '4.0'" }, + { name = "urllib3", marker = "python_full_version < '4'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/38/12731d4af470851b4963eba616605868a8599ef4df51c7b6c928e5f3166d/pinecone-7.3.0.tar.gz", hash = "sha256:307edc155621d487c20dc71b76c3ad5d6f799569ba42064190d03917954f9a7b", size = 235256, upload-time = "2025-06-27T20:03:51.498Z" } wheels = [ @@ -1372,6 +1451,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/69/831ed22b38ff9b4b64b66569f0e5b7b97cf3638346eb95a2147fdb49ad5f/pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7", size = 444229, upload-time = "2025-05-22T21:18:06.329Z" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.33.2" @@ -1414,6 +1498,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/9e/fce9331fecf1d2761ff0516c5dceab8a5fd415e82943e727dc4c5fa84a90/pydantic_settings-2.10.0-py3-none-any.whl", hash = "sha256:33781dfa1c7405d5ed2b6f150830a93bb58462a847357bd8f162f8bacb77c027", size = 45232, upload-time = "2025-06-21T13:56:53.682Z" }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[[package]] +name = "pymongo" +version = "4.15.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/7b/a709c85dc716eb85b69f71a4bb375cf1e72758a7e872103f27551243319c/pymongo-4.15.3.tar.gz", hash = "sha256:7a981271347623b5319932796690c2d301668ac3a1965974ac9f5c3b8a22cea5", size = 2470801, upload-time = "2025-10-07T21:57:50.384Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/a4/e1ce9d408a1c1bcb1554ff61251b108e16cefd7db91b33faa2afc92294de/pymongo-4.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a47a3218f7900f65bf0f36fcd1f2485af4945757360e7e143525db9d715d2010", size = 975329, upload-time = "2025-10-07T21:56:44.674Z" }, + { url = "https://files.pythonhosted.org/packages/74/3c/6796f653d22be43cc0b13c07dbed84133eebbc334ebed4426459b7250163/pymongo-4.15.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:09440e78dff397b2f34a624f445ac8eb44c9756a2688b85b3bf344d351d198e1", size = 975129, upload-time = "2025-10-07T21:56:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/88/33/22453dbfe11031e89c9cbdfde6405c03960daaf5da1b4dfdd458891846b5/pymongo-4.15.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:97f9babdb98c31676f97d468f7fe2dc49b8a66fb6900effddc4904c1450196c8", size = 1950979, upload-time = "2025-10-07T21:56:47.877Z" }, + { url = "https://files.pythonhosted.org/packages/ba/07/094598e403112e2410a3376fb7845c69e2ec2dfc5ab5cc00b29dc2d26559/pymongo-4.15.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71413cd8f091ae25b1fec3af7c2e531cf9bdb88ce4079470e64835f6a664282a", size = 1995271, upload-time = "2025-10-07T21:56:49.396Z" }, + { url = "https://files.pythonhosted.org/packages/47/9a/29e44f3dee68defc56e50ed7c9d3802ebf967ab81fefb175d8d729c0f276/pymongo-4.15.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:76a8d4de8dceb69f6e06736198ff6f7e1149515ef946f192ff2594d2cc98fc53", size = 2086587, upload-time = "2025-10-07T21:56:50.896Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d5/e9ff16aa57f671349134475b904fd431e7b86e152b01a949aef4f254b2d5/pymongo-4.15.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:77353978be9fc9e5fe56369682efed0aac5f92a2a1570704d62b62a3c9e1a24f", size = 2070201, upload-time = "2025-10-07T21:56:52.425Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a3/820772c0b2bbb671f253cfb0bede4cf694a38fb38134f3993d491e23ec11/pymongo-4.15.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9897a837677e3814873d0572f7e5d53c23ce18e274f3b5b87f05fb6eea22615b", size = 1985260, upload-time = "2025-10-07T21:56:54.56Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7b/365ac821aefad7e8d36a4bc472a94429449aade1ccb7805d9ca754df5081/pymongo-4.15.3-cp313-cp313-win32.whl", hash = "sha256:d66da207ccb0d68c5792eaaac984a0d9c6c8ec609c6bcfa11193a35200dc5992", size = 938122, upload-time = "2025-10-07T21:56:55.993Z" }, + { url = "https://files.pythonhosted.org/packages/80/f3/5ca27e1765fa698c677771a1c0e042ef193e207c15f5d32a21fa5b13d8c3/pymongo-4.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:52f40c4b8c00bc53d4e357fe0de13d031c4cddb5d201e1a027db437e8d2887f8", size = 962610, upload-time = "2025-10-07T21:56:57.397Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/42f0b6997324023e94939f8f32b9a8dd928499f4b5d7b4412905368686b5/pymongo-4.15.3-cp313-cp313-win_arm64.whl", hash = "sha256:fb384623ece34db78d445dd578a52d28b74e8319f4d9535fbaff79d0eae82b3d", size = 944300, upload-time = "2025-10-07T21:56:58.969Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a3/d8aaf9c243ce1319bd2498004a9acccfcfb35a3ef9851abb856993d95255/pymongo-4.15.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dcff15b9157c16bc796765d4d3d151df669322acfb0357e4c3ccd056153f0ff4", size = 1029873, upload-time = "2025-10-07T21:57:00.759Z" }, + { url = "https://files.pythonhosted.org/packages/64/10/91fd7791425ed3b56cbece6c23a36fb2696706a695655d8ea829e5e23c3a/pymongo-4.15.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1f681722c9f27e86c49c2e8a838e61b6ecf2285945fd1798bd01458134257834", size = 1029611, upload-time = "2025-10-07T21:57:02.488Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9c/d9cf8d8a181f96877bca7bdec3e6ce135879d5e3d78694ea465833c53a3f/pymongo-4.15.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2c96dde79bdccd167b930a709875b0cd4321ac32641a490aebfa10bdcd0aa99b", size = 2211827, upload-time = "2025-10-07T21:57:03.907Z" }, + { url = "https://files.pythonhosted.org/packages/c2/40/12703964305216c155284100124222eaa955300a07d426c6e0ba3c9cbade/pymongo-4.15.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2d4ca446348d850ac4a5c3dc603485640ae2e7805dbb90765c3ba7d79129b37", size = 2264654, upload-time = "2025-10-07T21:57:05.41Z" }, + { url = "https://files.pythonhosted.org/packages/0f/70/bf3c18b5d0cae0b9714158b210b07b5891a875eb1c503271cfe045942fd3/pymongo-4.15.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0fd3de3a12ff0a8113a3f64cedb01f87397ab8eaaffa88d7f18ca66cd39385", size = 2371830, upload-time = "2025-10-07T21:57:06.9Z" }, + { url = "https://files.pythonhosted.org/packages/21/6d/2dfaed2ae66304ab842d56ed9a1bd2706ca0ecf97975b328a5eeceb2a4c0/pymongo-4.15.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e84dec392cf5f72d365e0aac73f627b0a3170193ebb038c3f7e7df11b7983ee7", size = 2351878, upload-time = "2025-10-07T21:57:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/17/ed/fe46ff9adfa6dc11ad2e0694503adfc98f40583cfcc6db4dbaf582f0e357/pymongo-4.15.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d4b01a48369ea6d5bc83fea535f56279f806aa3e4991189f0477696dd736289", size = 2251356, upload-time = "2025-10-07T21:57:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/12/c4/2e1a10b1e9bca9c106f2dc1b89d4ad70c63d387c194b3a1bfcca552b5a3f/pymongo-4.15.3-cp314-cp314-win32.whl", hash = "sha256:3561fa96c3123275ec5ccf919e595547e100c412ec0894e954aa0da93ecfdb9e", size = 992878, upload-time = "2025-10-07T21:57:12.119Z" }, + { url = "https://files.pythonhosted.org/packages/98/b5/14aa417a44ea86d4c31de83b26f6e6793f736cd60e7e7fda289ce5184bdf/pymongo-4.15.3-cp314-cp314-win_amd64.whl", hash = "sha256:9df2db6bd91b07400879b6ec89827004c0c2b55fc606bb62db93cafb7677c340", size = 1021209, upload-time = "2025-10-07T21:57:13.686Z" }, + { url = "https://files.pythonhosted.org/packages/94/9f/1097c6824fa50a4ffb11ba5194d2a9ef68d5509dd342e32ddb697d2efe4e/pymongo-4.15.3-cp314-cp314-win_arm64.whl", hash = "sha256:ff99864085d2c7f4bb672c7167680ceb7d273e9a93c1a8074c986a36dbb71cc6", size = 1000618, upload-time = "2025-10-07T21:57:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/ad/31/37c76607a4f793f4491611741fa7a7c4238b956f48c4a9505cea0b5cf7ef/pymongo-4.15.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ffe217d2502f3fba4e2b0dc015ce3b34f157b66dfe96835aa64432e909dd0d95", size = 1086576, upload-time = "2025-10-07T21:57:16.742Z" }, + { url = "https://files.pythonhosted.org/packages/92/b2/6d17d279cdd293eeeb0c9d5baeb4f8cdebb45354fd81cfcef2d1c69303ab/pymongo-4.15.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:390c4954c774eda280898e73aea36482bf20cba3ecb958dbb86d6a68b9ecdd68", size = 1086656, upload-time = "2025-10-07T21:57:18.774Z" }, + { url = "https://files.pythonhosted.org/packages/55/fd/c5da8619beca207d7e6231f24ed269cb537c5311dad59fd9f2ef7d43204a/pymongo-4.15.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7dd2a49f088890ca08930bbf96121443b48e26b02b84ba0a3e1ae2bf2c5a9b48", size = 2531646, upload-time = "2025-10-07T21:57:20.63Z" }, + { url = "https://files.pythonhosted.org/packages/93/8f/66a7e12b874f41eb205f352b3a719e5a964b5ba103996f6ac45e80560111/pymongo-4.15.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f6feb678f26171f2a6b2cbb340949889154c7067972bd4cc129b62161474f08", size = 2603799, upload-time = "2025-10-07T21:57:22.591Z" }, + { url = "https://files.pythonhosted.org/packages/10/98/baf0d1f8016087500899cc4ae14e591f29b016c643e99ab332fcafe6f7bc/pymongo-4.15.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:446417a34ff6c2411ce3809e17ce9a67269c9f1cb4966b01e49e0c590cc3c6b3", size = 2725238, upload-time = "2025-10-07T21:57:24.091Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a2/112d8d3882d6e842f501e166fbe08dfc2bc9a35f8773cbcaa804f7991043/pymongo-4.15.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cfa4a0a0f024a0336640e1201994e780a17bda5e6a7c0b4d23841eb9152e868b", size = 2704837, upload-time = "2025-10-07T21:57:25.626Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/043a9aac7b3fba5b8e216f48359bd18fdbe46a4d93b081786f773b25e997/pymongo-4.15.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b03db2fe37c950aff94b29ded5c349b23729bccd90a0a5907bbf807d8c77298", size = 2582294, upload-time = "2025-10-07T21:57:27.221Z" }, + { url = "https://files.pythonhosted.org/packages/5b/fe/7a6a6b331d9f2024ab171028ab53d5d9026959b1d713fe170be591a4d9a8/pymongo-4.15.3-cp314-cp314t-win32.whl", hash = "sha256:e7cde58ef6470c0da922b65e885fb1ffe04deef81e526bd5dea429290fa358ca", size = 1043993, upload-time = "2025-10-07T21:57:28.727Z" }, + { url = "https://files.pythonhosted.org/packages/70/c8/bc64321711e19bd48ea3371f0082f10295c433833245d73e7606d3b9afbe/pymongo-4.15.3-cp314-cp314t-win_amd64.whl", hash = "sha256:fae552767d8e5153ed498f1bca92d905d0d46311d831eefb0f06de38f7695c95", size = 1078481, upload-time = "2025-10-07T21:57:30.372Z" }, + { url = "https://files.pythonhosted.org/packages/39/31/2bb2003bb978eb25dfef7b5f98e1c2d4a86e973e63b367cc508a9308d31c/pymongo-4.15.3-cp314-cp314t-win_arm64.whl", hash = "sha256:47ffb068e16ae5e43580d5c4e3b9437f05414ea80c32a1e5cac44a835859c259", size = 1051179, upload-time = "2025-10-07T21:57:31.829Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" diff --git a/frontend/app/analyze/page.tsx b/frontend/app/analyze/page.tsx index c86c6c9e..bee807e6 100644 --- a/frontend/app/analyze/page.tsx +++ b/frontend/app/analyze/page.tsx @@ -24,6 +24,7 @@ import { } from "lucide-react"; import { useRouter } from "next/navigation"; import ThemeToggle from "@/components/theme-toggle"; +import ProfileMenu from "@/components/profile-menu"; /** * Renders the main page for submitting an article URL to initiate AI-powered analysis. @@ -102,8 +103,9 @@ export default function AnalyzePage() { Perspective -
+
+
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 3c5a4677..8284ca10 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -3,6 +3,7 @@ import "./globals.css" import type { Metadata } from "next" import { Inter } from "next/font/google" import { ThemeProvider } from "@/components/theme-provider" +import { Toaster } from "@/components/ui/toaster" const inter = Inter({ subsets: ["latin"] }) @@ -28,6 +29,7 @@ export default function RootLayout({ {children} + diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 00000000..ad9fedc9 --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,9 @@ +import AuthForm from "@/components/auth-form"; + +export const metadata = { + title: "Login | Perspective", +}; + +export default function Page() { + return ; +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index bc1f0d76..66699f43 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -20,6 +20,7 @@ import { Sparkles, } from "lucide-react"; import ThemeToggle from "@/components/theme-toggle"; +import ProfileMenu from "@/components/profile-menu"; /** * Renders the main landing page for the Perspective application, showcasing its features, technology stack, and calls to action. @@ -111,8 +112,9 @@ export default function Home() { Perspective -
+
+
diff --git a/frontend/components/auth-form.tsx b/frontend/components/auth-form.tsx new file mode 100644 index 00000000..7d6907e6 --- /dev/null +++ b/frontend/components/auth-form.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; +import { ArrowLeft, Eye, EyeOff } from "lucide-react"; +import { toast } from "@/hooks/use-toast"; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:7860/api"; + +export default function AuthForm() { + const [isSignUp, setIsSignUp] = useState(false); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + try { + const path = isSignUp ? "/auth/signup" : "/auth/login"; + const body = isSignUp ? { name, email, password } : { email, password }; + const res = await fetch(`${API_BASE}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + let t: any = {} + try { + t = await res.json() + } catch (_) { + t = {} + } + + // Provide human-friendly messages depending on endpoint and status + let message = t?.detail || "Authentication failed. Please try again." + if (!isSignUp) { + // login + if (res.status === 401) { + message = "Invalid email or password. If you don't have an account, click Sign up." + } else if (res.status === 404) { + message = "No account found for this email. Please sign up." + } + } else { + // signup + if (res.status === 400) { + const lower = String(t?.detail || "").toLowerCase() + if (lower.includes("already") || lower.includes("exists") || lower.includes("email")) { + message = "This email is already registered. Try logging in instead." + } + } + } + + toast({ title: "Authentication error", description: message, variant: "destructive" }) + setLoading(false) + return + } + const data = await res.json(); + const token = data?.access_token; + if (!token) throw new Error("No token returned"); + // Store token in a cookie for middleware to read + const maxAge = 60 * 60 * 2; // 2 hours + // httpOnly cannot be set from client-side JS; Secure only when served over HTTPS / production + const isSecure = typeof window !== 'undefined' && window.location.protocol === 'https:'; + const secureFlag = isSecure ? '; Secure' : ''; + document.cookie = `token=${token}; Path=/; Max-Age=${maxAge}; SameSite=Lax${secureFlag}`; + + // Show success toast + toast({ + title: isSignUp ? "Account created successfully!" : "Welcome back!", + description: isSignUp ? "You can now start analyzing articles." : "You've been logged in successfully.", + variant: "success", + }); + + router.push("/analyze"); + } catch (err: any) { + const message = err?.message || "Something went wrong" + toast({ title: "Authentication error", description: message, variant: "destructive" }) + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+ + + +
+ + {isSignUp ? "Create your account" : "Welcome back"} +

{isSignUp ? "Sign up to get started" : "Log in to continue"}

+
+ +
+ {isSignUp && ( +
+ + setName(e.target.value)} required /> +
+ )} +
+ + setEmail(e.target.value)} required /> +
+
+ +
+ setPassword(e.target.value)} + required + /> + +
+
+ {/* Errors are shown via toast notifications */} + +
+
+ {isSignUp ? ( + + Already have an account?{" "} + + + ) : ( + + Don’t have an account?{" "} + + + )} +
+
+
+
+ ); +} diff --git a/frontend/components/profile-menu.tsx b/frontend/components/profile-menu.tsx new file mode 100644 index 00000000..1352c0c2 --- /dev/null +++ b/frontend/components/profile-menu.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useRouter, usePathname } from "next/navigation"; +import { User, LogOut } from "lucide-react"; +import { useEffect, useState } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { toast } from "@/hooks/use-toast"; + +export default function ProfileMenu() { + const router = useRouter(); + const pathname = usePathname(); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [userEmail, setUserEmail] = useState(""); + + // Check authentication status on mount and pathname change + useEffect(() => { + const checkAuth = () => { + const token = document.cookie + .split("; ") + .find((row) => row.startsWith("token=")) + ?.split("=")[1]; + + if (token) { + setIsAuthenticated(true); + // Robust base64url decoding for JWT payload + try { + const part = token.split(".")[1]; + if (part) { + // base64url -> base64 (replace - and _ then pad) + let b64 = part.replace(/-/g, "+").replace(/_/g, "/"); + while (b64.length % 4 !== 0) b64 += "="; + const json = decodeURIComponent( + atob(b64) + .split("") + .map(c => "%" + c.charCodeAt(0).toString(16).padStart(2, "0")) + .join("") + ); + const payload = JSON.parse(json); + setUserEmail(payload.sub || ""); + } else { + setUserEmail(""); + } + } catch (e) { + setUserEmail(""); + } + } else { + setIsAuthenticated(false); + setUserEmail(""); + } + }; + + checkAuth(); + }, [pathname]); + + const handleLogout = () => { + // Remove token cookie + const isSecure = typeof window !== 'undefined' && window.location.protocol === 'https:'; + const secureFlag = isSecure ? '; Secure' : ''; + document.cookie = `token=; Path=/; Max-Age=0; SameSite=Lax${secureFlag}`; + setIsAuthenticated(false); + setUserEmail(""); + + toast({ + title: "Logged out successfully", + description: "You've been logged out of your account.", + variant: "success", + }); + + router.push("/"); + }; + + // Don't show on login page + if (pathname?.startsWith("/login")) { + return null; + } + + // If not authenticated, show login button + if (!isAuthenticated) { + return ( + + ); + } + + // If authenticated, show dropdown menu + return ( + + + + + + +
+

My Account

+ {userEmail && ( +

+ {userEmail} +

+ )} +
+
+ + + + Logout + +
+
+ ); +} diff --git a/frontend/components/ui/toast.tsx b/frontend/components/ui/toast.tsx index 521b94b0..4aa02fd8 100644 --- a/frontend/components/ui/toast.tsx +++ b/frontend/components/ui/toast.tsx @@ -3,7 +3,7 @@ import * as React from "react" import * as ToastPrimitives from "@radix-ui/react-toast" import { cva, type VariantProps } from "class-variance-authority" -import { X } from "lucide-react" +import { X, CheckCircle, XCircle, Info, AlertTriangle } from "lucide-react" import { cn } from "@/lib/utils" @@ -16,7 +16,8 @@ const ToastViewport = React.forwardRef< - {toasts.map(function ({ id, title, description, action, ...props }) { + {toasts.map(function ({ id, title, description, action, variant, ...props }) { + // choose icon and colors per variant + let Icon = Info + let badgeClass = "bg-slate-700" + + if (variant === "destructive") { + Icon = XCircle + badgeClass = "bg-red-600" + } else if (variant === "success") { + Icon = CheckCircle + // use a strong green that reads well on dark/light backgrounds + badgeClass = "bg-emerald-500" + } else if (variant === "info") { + Icon = Info + badgeClass = "bg-blue-600" + } else { + // default / neutral + Icon = Info + badgeClass = "bg-slate-700" + } + return ( - -
- {title && {title}} - {description && ( - {description} - )} + +
+
+ {/* circular badge with subtle ring and shadow for a professional look */} +
+ +
+
+
+ {title && {title}} + {description && {description}} +
{action} diff --git a/frontend/middleware.ts b/frontend/middleware.ts new file mode 100644 index 00000000..d45857df --- /dev/null +++ b/frontend/middleware.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +export function middleware(req: NextRequest) { + const token = req.cookies.get("token")?.value; + const isProtected = req.nextUrl.pathname.startsWith("/analyze"); + if (isProtected && !token) { + const url = new URL("/login", req.url); + url.searchParams.set("next", req.nextUrl.pathname + req.nextUrl.search); + return NextResponse.redirect(url); + } + return NextResponse.next(); +} + +export const config = { + matcher: ["/analyze/:path*"], +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..d26b1c53 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "my-Perspective-AOSSIE", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}