From 6f7503039dd29dae472bc6f2dfc1577d4149d8d3 Mon Sep 17 00:00:00 2001 From: Leon Date: Sat, 19 Jul 2025 10:12:11 +0200 Subject: [PATCH] feat: authentication --- .env.example | 15 +- GEMINI.md | 77 +++++- .../ce35472309a4_add_auth_to_settings.py | 34 +++ backend/app/core/auth.py | 108 ++++++++ backend/app/core/config.py | 7 + backend/app/core/hashing.py | 13 + backend/app/crud/newsletters.py | 21 +- backend/app/crud/settings.py | 38 ++- backend/app/main.py | 17 +- backend/app/models/settings.py | 2 + backend/app/routers/auth.py | 55 ++++ backend/app/schemas/auth.py | 14 ++ backend/app/schemas/settings.py | 3 + backend/app/tests/conftest.py | 1 + backend/app/tests/test_auth.py | 170 +++++++++++++ backend/app/tests/test_core.py | 8 +- backend/app/tests/test_crud.py | 21 ++ backend/app/tests/test_routers.py | 64 +++-- backend/pyproject.toml | 4 + backend/uv.lock | 194 +++++++++++++++ frontend/jest.config.js | 3 +- frontend/jest.setup.js | 3 + frontend/src/app/layout.tsx | 7 +- .../src/app/login/__tests__/page.test.tsx | 89 +++++++ frontend/src/app/login/page.tsx | 83 +++++++ frontend/src/app/page.tsx | 6 +- .../components/letterfeed/EmptyState.tsx | 0 .../components/letterfeed/Header.tsx | 9 +- .../components/letterfeed/LoadingSpinner.tsx | 0 .../components/letterfeed/NewsletterCard.tsx | 0 .../letterfeed/NewsletterDialog.tsx | 0 .../components/letterfeed/NewsletterList.tsx | 0 .../components/letterfeed/SettingsDialog.tsx | 0 .../letterfeed/__tests__/EmptyState.test.tsx | 0 .../letterfeed/__tests__/Header.test.tsx | 20 +- .../__tests__/LoadingSpinner.test.tsx | 0 .../__tests__/NewsletterCard.test.tsx | 0 .../__tests__/NewsletterDialog.test.tsx | 0 .../__tests__/NewsletterList.test.tsx | 0 .../__tests__/SettingsDialog.test.tsx | 0 frontend/{ => src}/components/ui/badge.tsx | 0 frontend/{ => src}/components/ui/button.tsx | 0 frontend/{ => src}/components/ui/card.tsx | 0 frontend/{ => src}/components/ui/checkbox.tsx | 0 .../{ => src}/components/ui/components.json | 0 frontend/{ => src}/components/ui/dialog.tsx | 0 frontend/{ => src}/components/ui/input.tsx | 0 frontend/{ => src}/components/ui/label.tsx | 0 frontend/{ => src}/components/ui/select.tsx | 0 frontend/{ => src}/components/ui/sonner.tsx | 0 frontend/src/components/withAuth.tsx | 31 +++ frontend/src/contexts/AuthContext.tsx | 74 ++++++ .../contexts/__tests__/AuthContext.test.tsx | 163 ++++++++++++ frontend/src/hooks/useAuth.ts | 12 + frontend/src/lib/__tests__/api.test.ts | 234 +++++------------- frontend/src/lib/api.ts | 46 ++++ frontend/tsconfig.json | 3 +- 57 files changed, 1405 insertions(+), 244 deletions(-) create mode 100644 backend/alembic/versions/ce35472309a4_add_auth_to_settings.py create mode 100644 backend/app/core/auth.py create mode 100644 backend/app/core/hashing.py create mode 100644 backend/app/routers/auth.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/tests/test_auth.py create mode 100644 frontend/src/app/login/__tests__/page.test.tsx create mode 100644 frontend/src/app/login/page.tsx rename frontend/{ => src}/components/letterfeed/EmptyState.tsx (100%) rename frontend/{ => src}/components/letterfeed/Header.tsx (86%) rename frontend/{ => src}/components/letterfeed/LoadingSpinner.tsx (100%) rename frontend/{ => src}/components/letterfeed/NewsletterCard.tsx (100%) rename frontend/{ => src}/components/letterfeed/NewsletterDialog.tsx (100%) rename frontend/{ => src}/components/letterfeed/NewsletterList.tsx (100%) rename frontend/{ => src}/components/letterfeed/SettingsDialog.tsx (100%) rename frontend/{ => src}/components/letterfeed/__tests__/EmptyState.test.tsx (100%) rename frontend/{ => src}/components/letterfeed/__tests__/Header.test.tsx (88%) rename frontend/{ => src}/components/letterfeed/__tests__/LoadingSpinner.test.tsx (100%) rename frontend/{ => src}/components/letterfeed/__tests__/NewsletterCard.test.tsx (100%) rename frontend/{ => src}/components/letterfeed/__tests__/NewsletterDialog.test.tsx (100%) rename frontend/{ => src}/components/letterfeed/__tests__/NewsletterList.test.tsx (100%) rename frontend/{ => src}/components/letterfeed/__tests__/SettingsDialog.test.tsx (100%) rename frontend/{ => src}/components/ui/badge.tsx (100%) rename frontend/{ => src}/components/ui/button.tsx (100%) rename frontend/{ => src}/components/ui/card.tsx (100%) rename frontend/{ => src}/components/ui/checkbox.tsx (100%) rename frontend/{ => src}/components/ui/components.json (100%) rename frontend/{ => src}/components/ui/dialog.tsx (100%) rename frontend/{ => src}/components/ui/input.tsx (100%) rename frontend/{ => src}/components/ui/label.tsx (100%) rename frontend/{ => src}/components/ui/select.tsx (100%) rename frontend/{ => src}/components/ui/sonner.tsx (100%) create mode 100644 frontend/src/components/withAuth.tsx create mode 100644 frontend/src/contexts/AuthContext.tsx create mode 100644 frontend/src/contexts/__tests__/AuthContext.test.tsx create mode 100644 frontend/src/hooks/useAuth.ts diff --git a/.env.example b/.env.example index bde44aa..337b43b 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,14 @@ LETTERFEED_IMAP_PASSWORD= # Email processing settings LETTERFEED_SEARCH_FOLDER=INBOX # The folder in which to search for new emails -LETTERFEED_MOVE_TO_FOLDER= # Optional: Folder to move processed emails -LETTERFEED_MARK_AS_READ=true # Mark processed emails as read -LETTERFEED_EMAIL_CHECK_INTERVAL=15 # Interval between checks for new emails -LETTERFEED_AUTO_ADD_NEW_SENDERS=false # Automatically set up new emails for unknown senders +# LETTERFEED_MOVE_TO_FOLDER= # Optional: Folder to move processed emails +# LETTERFEED_MARK_AS_READ=true # Mark processed emails as read +# LETTERFEED_EMAIL_CHECK_INTERVAL=15 # Interval between checks for new emails +# LETTERFEED_AUTO_ADD_NEW_SENDERS=false # Automatically set up new emails for unknown senders + +# Authentication +# To generate a new secret key, run: +# openssl rand -hex 32 +# LETTERFEED_SECRET_KEY= +# LETTERFEED_AUTH_USERNAME= +# LETTERFEED_AUTH_PASSWORD= diff --git a/GEMINI.md b/GEMINI.md index e4d3fde..614bd2d 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -25,9 +25,82 @@ Run `make` with these targets: ## Git Repo -The main branch for this project is called "main" +The main branch for this project is called "master" -## JavaScript/TypeScript +## Backend + +Of course. Here is a similar markdown for an AI agent on how to write Python code for a backend with FastAPI, SQLAlchemy, and Alembic: + +## Backend + +When contributing to this Python, FastAPI, and SQLAlchemy codebase, please adhere to the following principles to ensure the code is robust, maintainable, and performs well. The focus is on leveraging modern Python features, functional programming concepts, and the specific strengths of our chosen frameworks. + +### Prefer Functional Approaches and Data Classes over Traditional Classes + +While Python is a multi-paradigm language that fully supports object-oriented programming, for our backend services, we favor a more functional approach, especially for business logic and data handling. + +- **Simplicity and Predictability**: Functions that operate on data are often simpler to reason about than classes with internal state and methods. This leads to more predictable code with fewer side effects. Pure functions, which always produce the same output for the same input and have no side effects, are the ideal. + +- **Seamless FastAPI Integration**: FastAPI is designed around functions. Dependencies are injected into functions, and route handlers are functions. Writing your logic in functions aligns perfectly with this design, leading to cleaner and more idiomatic FastAPI code. + +- **Data-Oriented Design with Pydantic**: Instead of creating complex classes to hold data, use Pydantic models. Pydantic provides data validation, serialization, and deserialization out of the box, all based on standard Python type hints. This is more declarative and less error-prone than manual implementation. + +- **Reduced Boilerplate**: Traditional classes can introduce boilerplate like `__init__` methods, `self`, and method binding. For many tasks, simple functions operating on Pydantic models or dictionaries are more concise and just as effective. + +### Leveraging Python Modules for Encapsulation + +Python's module system is the primary way to organize and encapsulate code. We prefer using modules to control visibility over class-based access modifiers like `_` or `__`. + +- **Clear Public API**: Anything you import from a module is part of its public API. Anything you don't is considered private. This is a simple and effective way to define module boundaries. + +- **Enhanced Testability**: Test the public functions and interfaces of your modules. If you find yourself needing to test an "internal" function, consider if it should be part of the public API or if the module should be broken down further. + +- **Reduced Coupling**: Well-defined modules with clear public APIs reduce coupling between different parts of the application, making it easier to refactor and maintain. + +### Static Typing with Pydantic and Type Hints + +Python's optional static typing is a powerful tool for writing robust and maintainable code. We use it extensively. + +- **Avoid `Any`**: The `Any` type subverts the type checker. Avoid it whenever possible. If you have a truly unknown type, be explicit about how you handle it. + +- **Leverage Pydantic for Validation**: Use Pydantic models for all data coming into and out of your API. This includes request bodies, query parameters, and response models. This ensures that your data is always in the expected shape. + +- **Use Type Hints Everywhere**: All function signatures should have type hints. This improves readability and allows static analysis tools to catch errors before they happen in production. + +### Embracing Python's Built-in Data Structures and Comprehensions + +Python has a rich set of built-in data structures and powerful syntax for working with them. + +- **List Comprehensions and Generator Expressions**: Prefer list comprehensions and generator expressions over `for` loops for creating lists and other collections. They are more concise and often more performant. + +- **Use the Right Data Structure**: Understand the use cases for lists, tuples, sets, and dictionaries, and use the appropriate one for the task at hand. + +### FastAPI, SQLAlchemy, and Alembic Guidelines + +- **Dependency Injection**: Use FastAPI's dependency injection system to manage resources like database sessions. This makes your code more testable and easier to reason about. + +- **Database Sessions**: A database session should be created for each request and closed when the request is finished. The dependency injection system is the perfect place to manage this. + +- **Asynchronous Code**: Use `async` and `await` for all I/O-bound operations, especially database queries. This is crucial for the performance of a FastAPI application. + +- **SQLAlchemy ORM**: Use the SQLAlchemy ORM for all database interactions. Avoid raw SQL queries whenever possible to prevent SQL injection vulnerabilities. Define your models clearly, and use relationships to express the connections between your data. + +- **Alembic Migrations**: All database schema changes must be accompanied by an Alembic migration script. Write clear and reversible migrations. + +### Process + +1. **Analyze the User's Request**: Understand the desired functionality or change. +2. **Consult Best Practices**: Before writing code, think about the best way to implement the feature using the principles outlined above. +3. **Write Clear and Concise Code**: The code should be easy to read and understand. +4. **Provide Explanations**: When suggesting code, explain the reasoning behind the implementation and how it aligns with our best practices. + +### Optimization Guidelines + +- **Efficient Database Queries**: Write efficient SQLAlchemy queries. Avoid the N+1 problem by using `joinedload` or `selectinload`. +- **Isolate Side Effects**: Keep side effects (like sending emails or interacting with external services) separate from your core business logic. +- **Structure for Concurrency**: Write your `async` code to take advantage of concurrency, running I/O-bound operations in parallel when possible. + +## Frontend When contributing to this React, Node, and TypeScript codebase, please prioritize the use of plain JavaScript objects with accompanying TypeScript interface or type declarations over JavaScript class syntax. This approach offers significant advantages, especially concerning interoperability with React and overall code maintainability. diff --git a/backend/alembic/versions/ce35472309a4_add_auth_to_settings.py b/backend/alembic/versions/ce35472309a4_add_auth_to_settings.py new file mode 100644 index 0000000..7fde721 --- /dev/null +++ b/backend/alembic/versions/ce35472309a4_add_auth_to_settings.py @@ -0,0 +1,34 @@ +"""add auth to settings + +Revision ID: ce35472309a4 +Revises: fb190ac6937f +Create Date: 2025-07-17 22:45:31.442679 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ce35472309a4' +down_revision: Union[str, Sequence[str], None] = 'fb190ac6937f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('settings', sa.Column('auth_username', sa.String(), nullable=True)) + op.add_column('settings', sa.Column('auth_password_hash', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('settings', 'auth_password_hash') + op.drop_column('settings', 'auth_username') + # ### end Alembic commands ### diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py new file mode 100644 index 0000000..92ea079 --- /dev/null +++ b/backend/app/core/auth.py @@ -0,0 +1,108 @@ +import secrets +from datetime import UTC, datetime, timedelta +from functools import lru_cache + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from sqlalchemy.orm import Session + +from app.core.config import settings as env_settings +from app.core.database import get_db +from app.core.hashing import get_password_hash +from app.models.settings import Settings as SettingsModel +from app.schemas.auth import TokenData + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False) + + +@lru_cache(maxsize=1) +def _get_env_password_hash(): + """Get and cache the password hash from environment variables.""" + if env_settings.auth_password: + return get_password_hash(env_settings.auth_password) + return None + + +def _get_auth_credentials(db: Session) -> dict: + """Get auth credentials, prioritizing environment variables.""" + # Env vars take precedence + if env_settings.auth_username and env_settings.auth_password: + return { + "username": env_settings.auth_username, + "password_hash": _get_env_password_hash(), + } + + # Then check DB + db_settings = db.query(SettingsModel).first() + if db_settings and db_settings.auth_username and db_settings.auth_password_hash: + return { + "username": db_settings.auth_username, + "password_hash": db_settings.auth_password_hash, + } + + return {} + + +def create_access_token(data: dict, expires_delta: timedelta | None = None): + """Create a new access token.""" + to_encode = data.copy() + if expires_delta: + expire = datetime.now(UTC) + expires_delta + else: + expire = datetime.now(UTC) + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode( + to_encode, env_settings.secret_key, algorithm=env_settings.algorithm + ) + return encoded_jwt + + +def protected_route( + token: str | None = Depends(oauth2_scheme), + db: Session = Depends(get_db), +): + """Dependency to protect routes with JWTs.""" + auth_creds = _get_auth_credentials(db) + + # If no auth credentials are set up, access is allowed. + if not auth_creds.get("username") or not auth_creds.get("password_hash"): + return + + if token is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode( + token, env_settings.secret_key, algorithms=[env_settings.algorithm] + ) + username: str | None = payload.get("sub") + if username is None: + raise credentials_exception + token_data = TokenData(username=username) + except JWTError: + raise credentials_exception + + # Check if the username from the token matches the configured username + correct_username = secrets.compare_digest( + token_data.username, auth_creds["username"] + ) + if not correct_username: + raise credentials_exception + + return token_data.username + + +def is_auth_enabled(db: Session = Depends(get_db)): + """Dependency to check if auth is enabled.""" + auth_creds = _get_auth_credentials(db) + return bool(auth_creds.get("username")) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index c2f7538..4ac49c3 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -27,6 +27,13 @@ class Settings(BaseSettings): mark_as_read: bool = False email_check_interval: int = 15 auto_add_new_senders: bool = False + auth_username: str | None = None + auth_password: str | None = None + secret_key: str = Field( + ..., validation_alias=AliasChoices("SECRET_KEY", "LETTERFEED_SECRET_KEY") + ) + algorithm: str = "HS256" + access_token_expire_minutes: int = 30 settings = Settings() diff --git a/backend/app/core/hashing.py b/backend/app/core/hashing.py new file mode 100644 index 0000000..fd5bc02 --- /dev/null +++ b/backend/app/core/hashing.py @@ -0,0 +1,13 @@ +from passlib.context import CryptContext + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def get_password_hash(password: str) -> str: + """Hash a password using bcrypt.""" + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a plain password against a hashed one.""" + return pwd_context.verify(plain_password, hashed_password) diff --git a/backend/app/crud/newsletters.py b/backend/app/crud/newsletters.py index a9d29b6..06b14d1 100644 --- a/backend/app/crud/newsletters.py +++ b/backend/app/crud/newsletters.py @@ -86,16 +86,25 @@ def update_newsletter( db_newsletter.move_to_folder = newsletter_update.move_to_folder db_newsletter.extract_content = newsletter_update.extract_content - # Simple approach: delete existing senders and add new ones + # More efficient sender update + existing_emails = {sender.email for sender in db_newsletter.senders} + new_emails = set(newsletter_update.sender_emails) + + # Remove senders that are no longer in the list for sender in db_newsletter.senders: - db.delete(sender) - db.commit() + if sender.email not in new_emails: + db.delete(sender) - for email in newsletter_update.sender_emails: - db_sender = Sender(id=generate(), email=email, newsletter_id=db_newsletter.id) - db.add(db_sender) + # Add new senders + for email in new_emails: + if email not in existing_emails: + db_sender = Sender( + id=generate(), email=email, newsletter_id=db_newsletter.id + ) + db.add(db_sender) db.commit() + db.refresh(db_newsletter) logger.info(f"Successfully updated newsletter with id={db_newsletter.id}") return get_newsletter(db, newsletter_id) diff --git a/backend/app/crud/settings.py b/backend/app/crud/settings.py index 32a4d74..b84164e 100644 --- a/backend/app/crud/settings.py +++ b/backend/app/crud/settings.py @@ -1,6 +1,7 @@ from sqlalchemy.orm import Session from app.core.config import settings as env_settings +from app.core.hashing import get_password_hash from app.core.logging import get_logger from app.models.settings import Settings as SettingsModel from app.schemas.settings import Settings as SettingsSchema @@ -9,11 +10,10 @@ from app.schemas.settings import SettingsCreate logger = get_logger(__name__) -def get_settings(db: Session, with_password: bool = False) -> SettingsSchema: - """Retrieve application settings, prioritizing environment variables over database.""" - logger.debug("Querying for settings") +def create_initial_settings(db: Session): + """Create initial settings in the database if they don't exist.""" + logger.debug("Checking for initial settings.") db_settings = db.query(SettingsModel).first() - if not db_settings: logger.info( "No settings found in the database, creating new default settings from environment variables." @@ -25,12 +25,29 @@ def get_settings(db: Session, with_password: bool = False) -> SettingsSchema: k: v for k, v in env_settings.model_dump().items() if k in model_fields } + if env_settings.auth_password: + env_data_for_db["auth_password_hash"] = get_password_hash( + env_settings.auth_password + ) + if "auth_password" in env_data_for_db: + del env_data_for_db["auth_password"] + db_settings = SettingsModel(**env_data_for_db) db.add(db_settings) db.commit() db.refresh(db_settings) logger.info("Default settings created from environment variables.") + +def get_settings(db: Session, with_password: bool = False) -> SettingsSchema: + """Retrieve application settings, prioritizing environment variables over database.""" + logger.debug("Querying for settings") + db_settings = db.query(SettingsModel).first() + + if not db_settings: + # This should not happen if create_initial_settings is called at startup. + raise RuntimeError("Settings not initialized.") + # Build dictionary from DB model attributes, handling possible None values db_data = { "id": db_settings.id, @@ -41,6 +58,7 @@ def get_settings(db: Session, with_password: bool = False) -> SettingsSchema: "mark_as_read": db_settings.mark_as_read, "email_check_interval": db_settings.email_check_interval, "auto_add_new_senders": db_settings.auto_add_new_senders, + "auth_username": db_settings.auth_username, } # Get all environment settings that were explicitly set. @@ -80,14 +98,22 @@ def create_or_update_settings(db: Session, settings: SettingsCreate): db_settings = SettingsModel() db.add(db_settings) - update_data = settings.model_dump() + update_data = settings.model_dump(exclude_unset=True) # Do not update fields that are set by environment variables locked_fields = list(env_settings.model_dump(exclude_unset=True).keys()) logger.debug(f"Fields locked by environment variables: {locked_fields}") for key, value in update_data.items(): - if key not in locked_fields: + if key in locked_fields: + continue + + if key == "auth_password": + if value: + db_settings.auth_password_hash = get_password_hash(value) + else: + db_settings.auth_password_hash = None + elif hasattr(db_settings, key): setattr(db_settings, key, value) db.commit() diff --git a/backend/app/main.py b/backend/app/main.py index 1d31567..1b2e06e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,12 +1,14 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.core.database import Base, engine +from app.core.auth import protected_route +from app.core.database import Base, SessionLocal, engine from app.core.logging import get_logger, setup_logging from app.core.scheduler import scheduler, start_scheduler_with_interval -from app.routers import feeds, health, imap, newsletters +from app.crud.settings import create_initial_settings +from app.routers import auth, feeds, health, imap, newsletters @asynccontextmanager @@ -19,6 +21,10 @@ async def lifespan(app: FastAPI): logger.info(f"DATABASE_URL used: {settings.database_url}") logger.info("Starting up Letterfeed backend...") Base.metadata.create_all(bind=engine) + + with SessionLocal() as db: + create_initial_settings(db) + start_scheduler_with_interval() yield if scheduler.running: @@ -43,6 +49,7 @@ app.add_middleware( ) app.include_router(health.router) -app.include_router(imap.router) -app.include_router(newsletters.router) +app.include_router(auth.router) +app.include_router(imap.router, dependencies=[Depends(protected_route)]) +app.include_router(newsletters.router, dependencies=[Depends(protected_route)]) app.include_router(feeds.router) diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index 2c38583..27f3037 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -17,3 +17,5 @@ class Settings(Base): mark_as_read = Column(Boolean, default=False) email_check_interval = Column(Integer, default=15) # Interval in minutes auto_add_new_senders = Column(Boolean, default=False) + auth_username = Column(String, nullable=True) + auth_password_hash = Column(String, nullable=True) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..1195552 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,55 @@ +import secrets +from datetime import timedelta + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from app.core.auth import ( + _get_auth_credentials, + create_access_token, + is_auth_enabled, +) +from app.core.config import settings +from app.core.database import get_db +from app.core.hashing import verify_password +from app.schemas.auth import Token + +router = APIRouter() + + +@router.get("/auth/status") +def auth_status(auth_enabled: bool = Depends(is_auth_enabled)): + """Check if authentication is enabled.""" + return {"auth_enabled": auth_enabled} + + +@router.post("/auth/login", response_model=Token) +def login_for_access_token( + form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db) +): + """Verify username and password and return an access token.""" + auth_creds = _get_auth_credentials(db) + if not auth_creds.get("username") or not auth_creds.get("password_hash"): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication is not configured on the server", + ) + + correct_username = secrets.compare_digest( + form_data.username, auth_creds["username"] + ) + correct_password = verify_password(form_data.password, auth_creds["password_hash"]) + + if not (correct_username and correct_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token_expires = timedelta(minutes=settings.access_token_expire_minutes) + access_token = create_access_token( + data={"sub": form_data.username}, expires_delta=access_token_expires + ) + return {"access_token": access_token, "token_type": "bearer"} diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..1f0761d --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + + +class Token(BaseModel): + """Schema for the access token.""" + + access_token: str + token_type: str + + +class TokenData(BaseModel): + """Schema for the data encoded in the JWT.""" + + username: str | None = None diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 6f9e70b..a14cddd 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -13,12 +13,14 @@ class SettingsBase(BaseModel): mark_as_read: bool = False email_check_interval: int = 15 auto_add_new_senders: bool = False + auth_username: str | None = None class SettingsCreate(SettingsBase): """Schema for creating or updating settings, including the IMAP password.""" imap_password: str + auth_password: str | None = None class Settings(SettingsBase): @@ -26,6 +28,7 @@ class Settings(SettingsBase): id: int imap_password: str | None = Field(None, exclude=True) + auth_password: str | None = Field(None, exclude=True) locked_fields: List[str] = [] model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py index cd83d15..d237617 100644 --- a/backend/app/tests/conftest.py +++ b/backend/app/tests/conftest.py @@ -1,6 +1,7 @@ import os os.environ["LETTERFEED_DATABASE_URL"] = "sqlite:///./test.db" +os.environ["LETTERFEED_SECRET_KEY"] = "testsecret" import pytest from fastapi.testclient import TestClient diff --git a/backend/app/tests/test_auth.py b/backend/app/tests/test_auth.py new file mode 100644 index 0000000..0b44779 --- /dev/null +++ b/backend/app/tests/test_auth.py @@ -0,0 +1,170 @@ +from unittest.mock import patch + +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.crud.settings import create_or_update_settings +from app.schemas.settings import SettingsCreate + + +def test_auth_status_disabled(client: TestClient): + """Test auth status when auth is disabled.""" + response = client.get("/auth/status") + assert response.status_code == 200 + assert response.json() == {"auth_enabled": False} + + +def test_auth_status_enabled(client: TestClient, db_session: Session): + """Test auth status when auth is enabled.""" + settings_data = SettingsCreate( + imap_server="test.com", + imap_username="test", + imap_password="password", + auth_username="admin", + auth_password="password", + ) + create_or_update_settings(db_session, settings_data) + + response = client.get("/auth/status") + assert response.status_code == 200 + assert response.json() == {"auth_enabled": True} + + +def test_login_endpoint(client: TestClient, db_session: Session): + """Test the /auth/login endpoint directly.""" + # Setup auth credentials in the database + settings_data = SettingsCreate( + imap_server="test.com", + imap_username="test", + imap_password="password", + auth_username="admin", + auth_password="password", + ) + create_or_update_settings(db_session, settings_data) + + # Test with correct credentials + login_data = {"username": "admin", "password": "password"} + response = client.post("/auth/login", data=login_data) + assert response.status_code == 200 + json_response = response.json() + assert "access_token" in json_response + assert json_response["token_type"] == "bearer" + + # Test with incorrect password + login_data["password"] = "wrongpassword" + response = client.post("/auth/login", data=login_data) + assert response.status_code == 401 + + # Test with incorrect username + login_data["username"] = "wronguser" + login_data["password"] = "password" + response = client.post("/auth/login", data=login_data) + assert response.status_code == 401 + + # Test with no credentials + response = client.post("/auth/login") + assert response.status_code == 422 # FastAPI validation error for missing form data + + +def test_protected_route_no_auth(client: TestClient, db_session: Session): + """Test accessing a protected route without auth enabled.""" + # Health is not protected, newsletters is. + response = client.get("/newsletters") + assert response.status_code == 200 + + +def test_protected_route_with_auth_fail(client: TestClient, db_session: Session): + """Test accessing a protected route with auth enabled but wrong credentials.""" + settings_data = SettingsCreate( + imap_server="test.com", + imap_username="test", + imap_password="password", + auth_username="admin", + auth_password="password", + ) + create_or_update_settings(db_session, settings_data) + + response = client.get("/newsletters") + assert response.status_code == 401 + + response = client.get( + "/newsletters", headers={"Authorization": "Bearer wrongtoken"} + ) + assert response.status_code == 401 + + +def test_protected_route_with_auth_success(client: TestClient, db_session: Session): + """Test accessing a protected route with auth enabled and correct credentials.""" + settings_data = SettingsCreate( + imap_server="test.com", + imap_username="test", + imap_password="password", + auth_username="admin", + auth_password="password", + ) + create_or_update_settings(db_session, settings_data) + + # First, log in to get a token + login_data = {"username": "admin", "password": "password"} + response = client.post("/auth/login", data=login_data) + token = response.json()["access_token"] + + # Then, use the token to access the protected route + headers = {"Authorization": f"Bearer {token}"} + response = client.get("/newsletters", headers=headers) + assert response.status_code == 200 + + +def test_unprotected_route_with_auth(client: TestClient, db_session: Session): + """Test that feed endpoint is not protected.""" + settings_data = SettingsCreate( + imap_server="test.com", + imap_username="test", + imap_password="password", + auth_username="admin", + auth_password="password", + ) + create_or_update_settings(db_session, settings_data) + + # Log in to get a token + login_data = {"username": "admin", "password": "password"} + login_response = client.post("/auth/login", data=login_data) + token = login_response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + # Create a newsletter to get a feed from + newsletter_data = {"name": "Test Newsletter", "sender_emails": ["test@test.com"]} + create_response = client.post("/newsletters", json=newsletter_data, headers=headers) + newsletter_id = create_response.json()["id"] + + response = client.get(f"/feeds/{newsletter_id}") + assert response.status_code == 200 + + +def test_auth_with_env_vars(client: TestClient): + """Test authentication using environment variables.""" + with patch("app.core.auth.env_settings") as mock_env_settings: + mock_env_settings.auth_username = "env_admin" + mock_env_settings.auth_password = "env_password" + mock_env_settings.secret_key = "test-secret" + mock_env_settings.algorithm = "HS256" + mock_env_settings.access_token_expire_minutes = 30 + + # Log in to get a token + login_data = {"username": "env_admin", "password": "env_password"} + login_response = client.post("/auth/login", data=login_data) + assert login_response.status_code == 200 + token = login_response.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + response = client.get("/newsletters", headers=headers) + assert response.status_code == 200 + + response = client.get( + "/newsletters", headers={"Authorization": "Bearer wrongtoken"} + ) + assert response.status_code == 401 + + response = client.get("/auth/status") + assert response.status_code == 200 + assert response.json() == {"auth_enabled": True} diff --git a/backend/app/tests/test_core.py b/backend/app/tests/test_core.py index 83b4185..29809ab 100644 --- a/backend/app/tests/test_core.py +++ b/backend/app/tests/test_core.py @@ -167,8 +167,12 @@ def test_process_emails_auto_add_sender(mock_imap, db_session: Session): @patch("app.services.email_processor.imaplib.IMAP4_SSL") def test_process_emails_no_settings(mock_imap, db_session: Session): - """Test processing emails with no settings in the database.""" - # No settings in the DB + """Test processing emails with no settings configured.""" + # This test ensures that email processing is skipped if settings are not configured. + # In the new flow, initial settings are created at startup, so we call it here. + from app.crud.settings import create_initial_settings + + create_initial_settings(db_session) process_emails(db_session) mock_imap.assert_not_called() diff --git a/backend/app/tests/test_crud.py b/backend/app/tests/test_crud.py index e65fad4..bb49f00 100644 --- a/backend/app/tests/test_crud.py +++ b/backend/app/tests/test_crud.py @@ -20,10 +20,19 @@ def test_create_or_update_settings(db_session: Session): search_folder="INBOX", move_to_folder="Archive", mark_as_read=True, + auth_username="user", + auth_password="password", ) settings = create_or_update_settings(db_session, settings_data) assert settings.imap_server == "imap.test.com" assert settings.mark_as_read + assert settings.auth_username == "user" + + # check password hash + from app.models.settings import Settings as SettingsModel + + db_settings = db_session.query(SettingsModel).first() + assert db_settings.auth_password_hash is not None updated_settings_data = SettingsCreate( imap_server="imap.updated.com", @@ -32,11 +41,13 @@ def test_create_or_update_settings(db_session: Session): search_folder="Inbox", move_to_folder=None, mark_as_read=False, + auth_username="new_user", ) updated_settings = create_or_update_settings(db_session, updated_settings_data) assert updated_settings.imap_server == "imap.updated.com" assert not updated_settings.mark_as_read assert updated_settings.move_to_folder is None + assert updated_settings.auth_username == "new_user" def test_get_settings(db_session: Session): @@ -62,6 +73,8 @@ def test_get_settings_with_env_override(db_session: Session): imap_username="db_user", imap_password="db_pass", email_check_interval=15, + auth_username="db_user", + auth_password="db_password", ) create_or_update_settings(db_session, db_settings_data) @@ -72,8 +85,11 @@ def test_get_settings_with_env_override(db_session: Session): "imap_username": "env_user", "imap_password": "env_pass", "email_check_interval": 30, + "auth_username": "env_auth_user", + "auth_password": "env_auth_password", } mock_env_settings.imap_password = "env_pass" + mock_env_settings.auth_password = "env_auth_password" # 3. Call get_settings and assert the override settings = get_settings(db_session, with_password=True) @@ -81,8 +97,10 @@ def test_get_settings_with_env_override(db_session: Session): assert settings.imap_username == "env_user" assert settings.imap_password == "env_pass" assert settings.email_check_interval == 30 + assert settings.auth_username == "env_auth_user" assert "imap_server" in settings.locked_fields assert "imap_username" in settings.locked_fields + assert "auth_username" in settings.locked_fields # 4. Call create_or_update_settings and assert that locked fields are not updated update_data = SettingsCreate( @@ -90,11 +108,14 @@ def test_get_settings_with_env_override(db_session: Session): imap_username="new_user", imap_password="new_pass", email_check_interval=45, + auth_username="new_auth_user", + auth_password="new_auth_password", ) updated_settings = create_or_update_settings(db_session, update_data) assert updated_settings.imap_server == "env.imap.com" # Should not change assert updated_settings.imap_username == "env_user" # Should not change assert updated_settings.email_check_interval == 30 # Should not change + assert updated_settings.auth_username == "env_auth_user" # Should not change def test_create_newsletter(db_session: Session): diff --git a/backend/app/tests/test_routers.py b/backend/app/tests/test_routers.py index 40bcf81..053aabb 100644 --- a/backend/app/tests/test_routers.py +++ b/backend/app/tests/test_routers.py @@ -2,6 +2,10 @@ import uuid from unittest.mock import patch from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.crud.settings import create_or_update_settings +from app.schemas.settings import SettingsCreate def test_health_check(client: TestClient): @@ -11,12 +15,8 @@ def test_health_check(client: TestClient): assert response.json() == {"status": "ok"} -@patch("app.core.imap.imaplib.IMAP4_SSL") -def test_update_imap_settings(mock_imap, client: TestClient): +def test_update_imap_settings(client: TestClient): """Test updating IMAP settings.""" - mock_imap.return_value.login.return_value = (None, None) - mock_imap.return_value.logout.return_value = (None, None) - settings_data = { "imap_server": "imap.example.com", "imap_username": "test@example.com", @@ -34,12 +34,8 @@ def test_update_imap_settings(mock_imap, client: TestClient): assert response.json()["mark_as_read"] -@patch("app.core.imap.imaplib.IMAP4_SSL") -def test_get_imap_settings(mock_imap, client: TestClient): +def test_get_imap_settings(client: TestClient): """Test getting IMAP settings.""" - mock_imap.return_value.login.return_value = (None, None) - mock_imap.return_value.logout.return_value = (None, None) - settings_data = { "imap_server": "imap.example.com", "imap_username": "test@example.com", @@ -57,20 +53,20 @@ def test_get_imap_settings(mock_imap, client: TestClient): @patch("app.core.imap.imaplib.IMAP4_SSL") -def test_test_imap_connection(mock_imap, client: TestClient): +def test_test_imap_connection(mock_imap, client: TestClient, db_session: Session): """Test the IMAP connection.""" mock_imap.return_value.login.return_value = (None, None) mock_imap.return_value.logout.return_value = (None, None) - settings_data = { - "imap_server": "imap.example.com", - "imap_username": "test@example.com", - "imap_password": "password", - "search_folder": "INBOX", - "move_to_folder": "Processed", - "mark_as_read": True, - } - client.post("/imap/settings", json=settings_data) + settings_data = SettingsCreate( + imap_server="imap.example.com", + imap_username="test@example.com", + imap_password="password", + search_folder="INBOX", + move_to_folder="Processed", + mark_as_read=True, + ) + create_or_update_settings(db_session, settings_data) response = client.post("/imap/test") assert response.status_code == 200 @@ -78,7 +74,7 @@ def test_test_imap_connection(mock_imap, client: TestClient): @patch("app.core.imap.imaplib.IMAP4_SSL") -def test_get_imap_folders(mock_imap, client: TestClient): +def test_get_imap_folders(mock_imap, client: TestClient, db_session: Session): """Test getting IMAP folders.""" mock_imap.return_value.login.return_value = (None, None) mock_imap.return_value.logout.return_value = (None, None) @@ -87,15 +83,15 @@ def test_get_imap_folders(mock_imap, client: TestClient): [b'(NOCONNECT NOSELECT) "/" "INBOX"', b'(NOCONNECT NOSELECT) "/" "Processed"'], ) - settings_data = { - "imap_server": "imap.example.com", - "imap_username": "test@example.com", - "imap_password": "password", - "search_folder": "INBOX", - "move_to_folder": "Processed", - "mark_as_read": True, - } - client.post("/imap/settings", json=settings_data) + settings_data = SettingsCreate( + imap_server="imap.example.com", + imap_username="test@example.com", + imap_password="password", + search_folder="INBOX", + move_to_folder="Processed", + mark_as_read=True, + ) + create_or_update_settings(db_session, settings_data) response = client.get("/imap/folders") assert response.status_code == 200 @@ -141,7 +137,7 @@ def test_get_single_newsletter(client: TestClient): """Test getting a single newsletter.""" unique_email = f"newsletter_{uuid.uuid4()}@example.com" newsletter_data = {"name": "Third Newsletter", "sender_emails": [unique_email]} - create_response = client.post("/newsletters/", json=newsletter_data) + create_response = client.post("/newsletters", json=newsletter_data) newsletter_id = create_response.json()["id"] response = client.get(f"/newsletters/{newsletter_id}") @@ -151,7 +147,7 @@ def test_get_single_newsletter(client: TestClient): def test_get_nonexistent_newsletter(client: TestClient): """Test getting a nonexistent newsletter.""" - response = client.get("/newsletters/999") + response = client.get("/newsletters/nonexistent") assert response.status_code == 404 assert response.json() == {"detail": "Newsletter not found"} @@ -160,7 +156,7 @@ def test_get_newsletter_feed(client: TestClient): """Test generating a newsletter feed.""" unique_email = f"feed_test_{uuid.uuid4()}@example.com" newsletter_data = {"name": "Feed Test Newsletter", "sender_emails": [unique_email]} - create_response = client.post("/newsletters/", json=newsletter_data) + create_response = client.post("/newsletters", json=newsletter_data) newsletter_id = create_response.json()["id"] # Add some entries to the newsletter @@ -195,6 +191,6 @@ def test_get_newsletter_feed(client: TestClient): def test_get_newsletter_feed_nonexistent_newsletter(client: TestClient): """Test generating a feed for a nonexistent newsletter.""" - response = client.get("/feeds/999") + response = client.get("/feeds/nonexistent") assert response.status_code == 404 assert response.json() == {"detail": "Newsletter not found"} diff --git a/backend/pyproject.toml b/backend/pyproject.toml index fe70f41..e0a1663 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -7,11 +7,15 @@ requires-python = ">=3.13" dependencies = [ "alembic>=1.16.4", "apscheduler>=3.11.0", + "bcrypt>=4.3.0", "fastapi>=0.116.0", "feedgen>=1.0.0", "nanoid>=2.0.0", + "passlib>=1.7.4", "pydantic-settings>=2.10.1", "python-dotenv>=1.1.1", + "python-jose[cryptography]>=3.5.0", + "python-multipart>=0.0.20", "sqlalchemy>=2.0.41", "trafilatura>=1.10.0", "uvicorn>=0.35.0", diff --git a/backend/uv.lock b/backend/uv.lock index be738a2..46c4f26 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -59,6 +59,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, ] +[[package]] +name = "bcrypt" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697, upload-time = "2025-02-28T01:24:09.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/2c/3d44e853d1fe969d229bd58d39ae6902b3d924af0e2b5a60d17d4b809ded/bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281", size = 483719, upload-time = "2025-02-28T01:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e2/58ff6e2a22eca2e2cff5370ae56dba29d70b1ea6fc08ee9115c3ae367795/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb", size = 272001, upload-time = "2025-02-28T01:22:38.078Z" }, + { url = "https://files.pythonhosted.org/packages/37/1f/c55ed8dbe994b1d088309e366749633c9eb90d139af3c0a50c102ba68a1a/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180", size = 277451, upload-time = "2025-02-28T01:22:40.787Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1c/794feb2ecf22fe73dcfb697ea7057f632061faceb7dcf0f155f3443b4d79/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f", size = 272792, upload-time = "2025-02-28T01:22:43.144Z" }, + { url = "https://files.pythonhosted.org/packages/13/b7/0b289506a3f3598c2ae2bdfa0ea66969812ed200264e3f61df77753eee6d/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09", size = 289752, upload-time = "2025-02-28T01:22:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/dc/24/d0fb023788afe9e83cc118895a9f6c57e1044e7e1672f045e46733421fe6/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d", size = 277762, upload-time = "2025-02-28T01:22:47.023Z" }, + { url = "https://files.pythonhosted.org/packages/e4/38/cde58089492e55ac4ef6c49fea7027600c84fd23f7520c62118c03b4625e/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd", size = 272384, upload-time = "2025-02-28T01:22:49.221Z" }, + { url = "https://files.pythonhosted.org/packages/de/6a/d5026520843490cfc8135d03012a413e4532a400e471e6188b01b2de853f/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af", size = 277329, upload-time = "2025-02-28T01:22:51.603Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a3/4fc5255e60486466c389e28c12579d2829b28a527360e9430b4041df4cf9/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231", size = 305241, upload-time = "2025-02-28T01:22:53.283Z" }, + { url = "https://files.pythonhosted.org/packages/c7/15/2b37bc07d6ce27cc94e5b10fd5058900eb8fb11642300e932c8c82e25c4a/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c", size = 309617, upload-time = "2025-02-28T01:22:55.461Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1f/99f65edb09e6c935232ba0430c8c13bb98cb3194b6d636e61d93fe60ac59/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f", size = 335751, upload-time = "2025-02-28T01:22:57.81Z" }, + { url = "https://files.pythonhosted.org/packages/00/1b/b324030c706711c99769988fcb694b3cb23f247ad39a7823a78e361bdbb8/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d", size = 355965, upload-time = "2025-02-28T01:22:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/20372a0579dd915dfc3b1cd4943b3bca431866fcb1dfdfd7518c3caddea6/bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4", size = 155316, upload-time = "2025-02-28T01:23:00.763Z" }, + { url = "https://files.pythonhosted.org/packages/6d/52/45d969fcff6b5577c2bf17098dc36269b4c02197d551371c023130c0f890/bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669", size = 147752, upload-time = "2025-02-28T01:23:02.908Z" }, + { url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019, upload-time = "2025-02-28T01:23:05.838Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174, upload-time = "2025-02-28T01:23:07.274Z" }, + { url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870, upload-time = "2025-02-28T01:23:09.151Z" }, + { url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601, upload-time = "2025-02-28T01:23:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660, upload-time = "2025-02-28T01:23:12.989Z" }, + { url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083, upload-time = "2025-02-28T01:23:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237, upload-time = "2025-02-28T01:23:16.686Z" }, + { url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737, upload-time = "2025-02-28T01:23:18.897Z" }, + { url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741, upload-time = "2025-02-28T01:23:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472, upload-time = "2025-02-28T01:23:23.183Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606, upload-time = "2025-02-28T01:23:25.361Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867, upload-time = "2025-02-28T01:23:26.875Z" }, + { url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589, upload-time = "2025-02-28T01:23:28.381Z" }, + { url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794, upload-time = "2025-02-28T01:23:30.187Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969, upload-time = "2025-02-28T01:23:31.945Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158, upload-time = "2025-02-28T01:23:34.161Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285, upload-time = "2025-02-28T01:23:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583, upload-time = "2025-02-28T01:23:38.021Z" }, + { url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896, upload-time = "2025-02-28T01:23:39.575Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492, upload-time = "2025-02-28T01:23:40.901Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213, upload-time = "2025-02-28T01:23:42.653Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162, upload-time = "2025-02-28T01:23:43.964Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856, upload-time = "2025-02-28T01:23:46.011Z" }, + { url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726, upload-time = "2025-02-28T01:23:47.575Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664, upload-time = "2025-02-28T01:23:49.059Z" }, + { url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128, upload-time = "2025-02-28T01:23:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598, upload-time = "2025-02-28T01:23:51.775Z" }, + { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" }, +] + [[package]] name = "certifi" version = "2025.7.14" @@ -68,6 +118,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722, upload-time = "2025-07-14T03:29:26.863Z" }, ] +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, +] + [[package]] name = "cfgv" version = "3.4.0" @@ -134,6 +206,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/ca/6a667ccbe649856dcd3458bab80b016681b274399d6211187c6ab969fc50/courlan-1.3.2-py3-none-any.whl", hash = "sha256:d0dab52cf5b5b1000ee2839fbc2837e93b2514d3cb5bb61ae158a55b7a04c6be", size = 33848, upload-time = "2024-10-29T16:40:18.325Z" }, ] +[[package]] +name = "cryptography" +version = "45.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/1e/49527ac611af559665f71cbb8f92b332b5ec9c6fbc4e88b0f8e92f5e85df/cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a", size = 744903, upload-time = "2025-07-02T13:06:25.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/fb/09e28bc0c46d2c547085e60897fea96310574c70fb21cd58a730a45f3403/cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8", size = 7043092, upload-time = "2025-07-02T13:05:01.514Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/2194432935e29b91fb649f6149c1a4f9e6d3d9fc880919f4ad1bcc22641e/cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d", size = 4205926, upload-time = "2025-07-02T13:05:04.741Z" }, + { url = "https://files.pythonhosted.org/packages/07/8b/9ef5da82350175e32de245646b1884fc01124f53eb31164c77f95a08d682/cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5", size = 4429235, upload-time = "2025-07-02T13:05:07.084Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e1/c809f398adde1994ee53438912192d92a1d0fc0f2d7582659d9ef4c28b0c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57", size = 4209785, upload-time = "2025-07-02T13:05:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8b/07eb6bd5acff58406c5e806eff34a124936f41a4fb52909ffa4d00815f8c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0", size = 3893050, upload-time = "2025-07-02T13:05:11.069Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ef/3333295ed58d900a13c92806b67e62f27876845a9a908c939f040887cca9/cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d", size = 4457379, upload-time = "2025-07-02T13:05:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9d/44080674dee514dbb82b21d6fa5d1055368f208304e2ab1828d85c9de8f4/cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9", size = 4209355, upload-time = "2025-07-02T13:05:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d8/0749f7d39f53f8258e5c18a93131919ac465ee1f9dccaf1b3f420235e0b5/cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27", size = 4456087, upload-time = "2025-07-02T13:05:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/09/d7/92acac187387bf08902b0bf0699816f08553927bdd6ba3654da0010289b4/cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e", size = 4332873, upload-time = "2025-07-02T13:05:18.743Z" }, + { url = "https://files.pythonhosted.org/packages/03/c2/840e0710da5106a7c3d4153c7215b2736151bba60bf4491bdb421df5056d/cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174", size = 4564651, upload-time = "2025-07-02T13:05:21.382Z" }, + { url = "https://files.pythonhosted.org/packages/2e/92/cc723dd6d71e9747a887b94eb3827825c6c24b9e6ce2bb33b847d31d5eaa/cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9", size = 2929050, upload-time = "2025-07-02T13:05:23.39Z" }, + { url = "https://files.pythonhosted.org/packages/1f/10/197da38a5911a48dd5389c043de4aec4b3c94cb836299b01253940788d78/cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63", size = 3403224, upload-time = "2025-07-02T13:05:25.202Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2b/160ce8c2765e7a481ce57d55eba1546148583e7b6f85514472b1d151711d/cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8", size = 7017143, upload-time = "2025-07-02T13:05:27.229Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e7/2187be2f871c0221a81f55ee3105d3cf3e273c0a0853651d7011eada0d7e/cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd", size = 4197780, upload-time = "2025-07-02T13:05:29.299Z" }, + { url = "https://files.pythonhosted.org/packages/b9/cf/84210c447c06104e6be9122661159ad4ce7a8190011669afceeaea150524/cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e", size = 4420091, upload-time = "2025-07-02T13:05:31.221Z" }, + { url = "https://files.pythonhosted.org/packages/3e/6a/cb8b5c8bb82fafffa23aeff8d3a39822593cee6e2f16c5ca5c2ecca344f7/cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0", size = 4198711, upload-time = "2025-07-02T13:05:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/04/f7/36d2d69df69c94cbb2473871926daf0f01ad8e00fe3986ac3c1e8c4ca4b3/cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135", size = 3883299, upload-time = "2025-07-02T13:05:34.94Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f0ea40f016de72f81288e9fe8d1f6748036cb5ba6118774317a3ffc6022d/cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7", size = 4450558, upload-time = "2025-07-02T13:05:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/94b504dc1a3cdf642d710407c62e86296f7da9e66f27ab12a1ee6fdf005b/cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42", size = 4198020, upload-time = "2025-07-02T13:05:39.102Z" }, + { url = "https://files.pythonhosted.org/packages/05/2b/aaf0adb845d5dabb43480f18f7ca72e94f92c280aa983ddbd0bcd6ecd037/cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492", size = 4449759, upload-time = "2025-07-02T13:05:41.398Z" }, + { url = "https://files.pythonhosted.org/packages/91/e4/f17e02066de63e0100a3a01b56f8f1016973a1d67551beaf585157a86b3f/cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0", size = 4319991, upload-time = "2025-07-02T13:05:43.64Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2e/e2dbd629481b499b14516eed933f3276eb3239f7cee2dcfa4ee6b44d4711/cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a", size = 4554189, upload-time = "2025-07-02T13:05:46.045Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ea/a78a0c38f4c8736287b71c2ea3799d173d5ce778c7d6e3c163a95a05ad2a/cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f", size = 2911769, upload-time = "2025-07-02T13:05:48.329Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/28ac139109d9005ad3f6b6f8976ffede6706a6478e21c889ce36c840918e/cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97", size = 3390016, upload-time = "2025-07-02T13:05:50.811Z" }, +] + [[package]] name = "dateparser" version = "1.2.2" @@ -158,6 +265,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, ] +[[package]] +name = "ecdsa" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" }, +] + [[package]] name = "fastapi" version = "0.116.1" @@ -314,11 +433,15 @@ source = { virtual = "." } dependencies = [ { name = "alembic" }, { name = "apscheduler" }, + { name = "bcrypt" }, { name = "fastapi" }, { name = "feedgen" }, { name = "nanoid" }, + { name = "passlib" }, { name = "pydantic-settings" }, { name = "python-dotenv" }, + { name = "python-jose", extra = ["cryptography"] }, + { name = "python-multipart" }, { name = "sqlalchemy" }, { name = "trafilatura" }, { name = "uvicorn" }, @@ -336,11 +459,15 @@ test = [ requires-dist = [ { name = "alembic", specifier = ">=1.16.4" }, { name = "apscheduler", specifier = ">=3.11.0" }, + { name = "bcrypt", specifier = ">=4.3.0" }, { name = "fastapi", specifier = ">=0.116.0" }, { name = "feedgen", specifier = ">=1.0.0" }, { name = "nanoid", specifier = ">=2.0.0" }, + { name = "passlib", specifier = ">=1.7.4" }, { name = "pydantic-settings", specifier = ">=2.10.1" }, { name = "python-dotenv", specifier = ">=1.1.1" }, + { name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" }, + { name = "python-multipart", specifier = ">=0.0.20" }, { name = "sqlalchemy", specifier = ">=2.0.41" }, { name = "trafilatura", specifier = ">=1.10.0" }, { name = "uvicorn", specifier = ">=0.35.0" }, @@ -463,6 +590,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[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]] name = "platformdirs" version = "4.3.8" @@ -497,6 +633,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, +] + [[package]] name = "pydantic" version = "2.11.7" @@ -600,6 +754,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, ] +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[package.optional-dependencies] +cryptography = [ + { name = "cryptography" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + [[package]] name = "pytz" version = "2025.2" @@ -649,6 +831,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/94/bc295babb3062a731f52621cdc992d123111282e291abaf23faa413443ea/regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a", size = 273545, upload-time = "2024-11-06T20:11:15Z" }, ] +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "ruff" version = "0.12.3" diff --git a/frontend/jest.config.js b/frontend/jest.config.js index 47954e7..8e3410e 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -8,8 +8,7 @@ const customJestConfig = { setupFilesAfterEnv: ['/jest.setup.js'], testEnvironment: 'jest-environment-jsdom', moduleNameMapper: { - '^@/components/(.*)$': '/components/$1', - '^@/lib/(.*)$': '/src/lib/$1', + '^@/(.*)$': '/src/$1', }, }; diff --git a/frontend/jest.setup.js b/frontend/jest.setup.js index 98acb62..16c8e94 100644 --- a/frontend/jest.setup.js +++ b/frontend/jest.setup.js @@ -1,4 +1,7 @@ import '@testing-library/jest-dom'; +import fetchMock from 'jest-fetch-mock'; + +fetchMock.enableMocks(); Object.defineProperty(window, 'matchMedia', { writable: true, diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 31ce70d..c64460e 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,3 +1,4 @@ +import { AuthProvider } from "@/contexts/AuthContext" import type { Metadata } from "next" import { Geist, Geist_Mono } from "next/font/google" import "./globals.css" @@ -28,8 +29,10 @@ export default function RootLayout({ - {children} - + + {children} + + ) diff --git a/frontend/src/app/login/__tests__/page.test.tsx b/frontend/src/app/login/__tests__/page.test.tsx new file mode 100644 index 0000000..6b0fef9 --- /dev/null +++ b/frontend/src/app/login/__tests__/page.test.tsx @@ -0,0 +1,89 @@ +import React from "react" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import "@testing-library/jest-dom" +import LoginPage from "@/app/login/page" +import { useAuth } from "@/hooks/useAuth" +import { toast } from "sonner" + +jest.mock("@/hooks/useAuth") +jest.mock("sonner", () => ({ + toast: { + error: jest.fn(), + }, +})) + +const mockedUseAuth = useAuth as jest.Mock +const mockedToast = toast as jest.Mocked + +describe("LoginPage", () => { + const login = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + mockedUseAuth.mockReturnValue({ login }) + }) + + it("renders the login page", () => { + render() + expect(screen.getByText("LetterFeed")).toBeInTheDocument() + expect(screen.getByLabelText("Username")).toBeInTheDocument() + expect(screen.getByLabelText("Password")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Sign In" })).toBeInTheDocument() + }) + + it("allows typing in the username and password fields", () => { + render() + const usernameInput = screen.getByLabelText("Username") + const passwordInput = screen.getByLabelText("Password") + fireEvent.change(usernameInput, { target: { value: "test-user" } }) + fireEvent.change(passwordInput, { target: { value: "test-password" } }) + expect(usernameInput).toHaveValue("test-user") + expect(passwordInput).toHaveValue("test-password") + }) + + it("calls login on form submission with username and password", async () => { + render() + const usernameInput = screen.getByLabelText("Username") + const passwordInput = screen.getByLabelText("Password") + fireEvent.change(usernameInput, { target: { value: "test-user" } }) + fireEvent.change(passwordInput, { target: { value: "test-password" } }) + fireEvent.click(screen.getByRole("button", { name: "Sign In" })) + await waitFor(() => { + expect(login).toHaveBeenCalledWith("test-user", "test-password") + }) + }) + + it("does not show an error if login fails, as it is handled by the api layer", async () => { + login.mockRejectedValue(new Error("Invalid username or password")) + render() + const usernameInput = screen.getByLabelText("Username") + const passwordInput = screen.getByLabelText("Password") + fireEvent.change(usernameInput, { target: { value: "wrong-user" } }) + fireEvent.change(passwordInput, { target: { value: "wrong-password" } }) + fireEvent.click(screen.getByRole("button", { name: "Sign In" })) + await waitFor(() => { + expect(login).toHaveBeenCalled() + expect(mockedToast.error).not.toHaveBeenCalled() + }) + }) + + it("shows an error if username is not provided", async () => { + render() + const passwordInput = screen.getByLabelText("Password") + fireEvent.change(passwordInput, { target: { value: "test-password" } }) + fireEvent.click(screen.getByRole("button", { name: "Sign In" })) + await waitFor(() => { + expect(mockedToast.error).toHaveBeenCalledWith("Please fill in all fields") + }) + }) + + it("shows an error if password is not provided", async () => { + render() + const usernameInput = screen.getByLabelText("Username") + fireEvent.change(usernameInput, { target: { value: "test-user" } }) + fireEvent.click(screen.getByRole("button", { name: "Sign In" })) + await waitFor(() => { + expect(mockedToast.error).toHaveBeenCalledWith("Please fill in all fields") + }) + }) +}) diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..1b4a8cb --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -0,0 +1,83 @@ +"use client" + +import type React from "react" +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import Image from "next/image" +import { useAuth } from "@/hooks/useAuth" +import { toast } from "sonner" + +export default function LoginPage() { + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + const { login } = useAuth() + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault() + + if (!username || !password) { + toast.error("Please fill in all fields") + return + } + + try { + await login(username, password) + } catch { + // The error is already toasted by the API layer, + } + } + + return ( +
+
+
+
+ LetterFeed Logo +
+

LetterFeed

+

Sign in to your account

+
+ + + +
+
+ + setUsername(e.target.value)} + placeholder="Enter your username" + /> +
+
+ + setPassword(e.target.value)} + placeholder="Enter your password" + /> +
+ + +
+
+
+
+
+ ) +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 075dc9a..fefdd8e 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,6 +1,8 @@ "use client" import { useState, useEffect, useCallback } from "react" + +import withAuth from "@/components/withAuth" import { getNewsletters, getSettings, @@ -15,7 +17,7 @@ import { EmptyState } from "@/components/letterfeed/EmptyState" import { NewsletterDialog } from "@/components/letterfeed/NewsletterDialog" import { SettingsDialog } from "@/components/letterfeed/SettingsDialog" -export default function LetterFeedApp() { +function LetterFeedApp() { const [newsletters, setNewsletters] = useState([]) const [isLoading, setIsLoading] = useState(true) const [settings, setSettings] = useState(null) @@ -103,3 +105,5 @@ export default function LetterFeedApp() { ) } + +export default withAuth(LetterFeedApp) diff --git a/frontend/components/letterfeed/EmptyState.tsx b/frontend/src/components/letterfeed/EmptyState.tsx similarity index 100% rename from frontend/components/letterfeed/EmptyState.tsx rename to frontend/src/components/letterfeed/EmptyState.tsx diff --git a/frontend/components/letterfeed/Header.tsx b/frontend/src/components/letterfeed/Header.tsx similarity index 86% rename from frontend/components/letterfeed/Header.tsx rename to frontend/src/components/letterfeed/Header.tsx index 8d70a17..4b0f000 100644 --- a/frontend/components/letterfeed/Header.tsx +++ b/frontend/src/components/letterfeed/Header.tsx @@ -2,7 +2,8 @@ import { Button } from "@/components/ui/button" import { processEmails } from "@/lib/api" -import { Mail, Plus, Settings } from "lucide-react" +import { useAuth } from "@/hooks/useAuth" +import { LogOut, Mail, Plus, Settings } from "lucide-react" import Image from "next/image" import { toast } from "sonner" @@ -12,6 +13,7 @@ interface HeaderProps { } export function Header({ onOpenAddNewsletter, onOpenSettings }: HeaderProps) { + const { logout } = useAuth() const handleProcessEmails = async () => { try { await processEmails() @@ -59,6 +61,11 @@ export function Header({ onOpenAddNewsletter, onOpenSettings }: HeaderProps) { Settings + + ) diff --git a/frontend/components/letterfeed/LoadingSpinner.tsx b/frontend/src/components/letterfeed/LoadingSpinner.tsx similarity index 100% rename from frontend/components/letterfeed/LoadingSpinner.tsx rename to frontend/src/components/letterfeed/LoadingSpinner.tsx diff --git a/frontend/components/letterfeed/NewsletterCard.tsx b/frontend/src/components/letterfeed/NewsletterCard.tsx similarity index 100% rename from frontend/components/letterfeed/NewsletterCard.tsx rename to frontend/src/components/letterfeed/NewsletterCard.tsx diff --git a/frontend/components/letterfeed/NewsletterDialog.tsx b/frontend/src/components/letterfeed/NewsletterDialog.tsx similarity index 100% rename from frontend/components/letterfeed/NewsletterDialog.tsx rename to frontend/src/components/letterfeed/NewsletterDialog.tsx diff --git a/frontend/components/letterfeed/NewsletterList.tsx b/frontend/src/components/letterfeed/NewsletterList.tsx similarity index 100% rename from frontend/components/letterfeed/NewsletterList.tsx rename to frontend/src/components/letterfeed/NewsletterList.tsx diff --git a/frontend/components/letterfeed/SettingsDialog.tsx b/frontend/src/components/letterfeed/SettingsDialog.tsx similarity index 100% rename from frontend/components/letterfeed/SettingsDialog.tsx rename to frontend/src/components/letterfeed/SettingsDialog.tsx diff --git a/frontend/components/letterfeed/__tests__/EmptyState.test.tsx b/frontend/src/components/letterfeed/__tests__/EmptyState.test.tsx similarity index 100% rename from frontend/components/letterfeed/__tests__/EmptyState.test.tsx rename to frontend/src/components/letterfeed/__tests__/EmptyState.test.tsx diff --git a/frontend/components/letterfeed/__tests__/Header.test.tsx b/frontend/src/components/letterfeed/__tests__/Header.test.tsx similarity index 88% rename from frontend/components/letterfeed/__tests__/Header.test.tsx rename to frontend/src/components/letterfeed/__tests__/Header.test.tsx index 0939b37..5d8f7ec 100644 --- a/frontend/components/letterfeed/__tests__/Header.test.tsx +++ b/frontend/src/components/letterfeed/__tests__/Header.test.tsx @@ -3,8 +3,14 @@ import { Header } from "../Header" import { Toaster } from "@/components/ui/sonner" import { toast } from "sonner" import * as api from "@/lib/api" +import { AuthProvider } from "@/contexts/AuthContext" jest.mock("@/lib/api") +jest.mock("next/navigation", () => ({ + useRouter: () => ({ + push: jest.fn(), + }), +})) const mockedApi = api as jest.Mocked // Mock the toast functions @@ -24,6 +30,10 @@ describe("Header", () => { const onOpenSettings = jest.fn() const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}) + const renderWithAuthProvider = (component: React.ReactElement) => { + return render({component}) + } + beforeEach(() => { jest.clearAllMocks() consoleError.mockClear() @@ -34,7 +44,7 @@ describe("Header", () => { }) it("renders the header with title and buttons", () => { - render( + renderWithAuthProvider(
{ }) it('calls onOpenAddNewsletter when "Add Newsletter" button is clicked', () => { - render( + renderWithAuthProvider(
{ }) it('calls onOpenSettings when "Settings" button is clicked', () => { - render( + renderWithAuthProvider(
{ it('calls the process emails API when "Process Now" button is clicked and shows success toast', async () => { mockedApi.processEmails.mockResolvedValue({ message: "Success" }) - render( + renderWithAuthProvider( <>
{ it("shows an error toast if the process emails API call fails", async () => { mockedApi.processEmails.mockRejectedValue(new Error("Failed to process")) - render( + renderWithAuthProvider( <>
( + WrappedComponent: React.ComponentType

+) => { + const WithAuthComponent = (props: P) => { + const { isAuthenticated, isLoading } = useAuth() + const router = useRouter() + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push("/login") + } + }, [isAuthenticated, isLoading, router]) + + if (isLoading || !isAuthenticated) { + return + } + + return + } + + return WithAuthComponent +} + +export default withAuth diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..d14a3e4 --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,74 @@ +"use client" + +import { createContext, useState, useEffect, ReactNode } from "react" +import { getAuthStatus, login as apiLogin, getSettings } from "@/lib/api" +import { useRouter } from "next/navigation" + +interface AuthContextType { + isAuthenticated: boolean + login: (username: string, password: string) => Promise + logout: () => void + isLoading: boolean +} + +export const AuthContext = createContext(undefined) + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + const [isAuthenticated, setIsAuthenticated] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const router = useRouter() + + useEffect(() => { + const checkAuth = async () => { + setIsLoading(true); + try { + const { auth_enabled } = await getAuthStatus(); + if (!auth_enabled) { + setIsAuthenticated(true); + } else { + const token = localStorage.getItem("authToken"); + if (token) { + // If a token exists, verify it by making a protected API call. + // getSettings is a good candidate. If it fails with a 401, + // the fetcher will remove the token and throw, which we catch here. + await getSettings(); + setIsAuthenticated(true); + } else { + setIsAuthenticated(false); + } + } + } catch (error) { + // This will catch errors from getAuthStatus or getSettings. + // If it was a 401, the token is already removed by the fetcher. + setIsAuthenticated(false); + console.error("Authentication check failed", error); + } finally { + setIsLoading(false); + } + }; + checkAuth(); + }, []); + + const login = async (username: string, password: string) => { + try { + await apiLogin(username, password) + setIsAuthenticated(true) + router.push("/") + } catch (error) { + console.error("Login failed", error) + throw error + } + } + + const logout = () => { + localStorage.removeItem("authToken") + setIsAuthenticated(false) + router.push("/login") + } + + return ( + + {children} + + ) +} diff --git a/frontend/src/contexts/__tests__/AuthContext.test.tsx b/frontend/src/contexts/__tests__/AuthContext.test.tsx new file mode 100644 index 0000000..f25794c --- /dev/null +++ b/frontend/src/contexts/__tests__/AuthContext.test.tsx @@ -0,0 +1,163 @@ +import React from "react" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import "@testing-library/jest-dom" +import { AuthProvider, AuthContext } from "@/contexts/AuthContext" +import * as api from "@/lib/api" +import { useRouter } from "next/navigation" + +jest.mock("@/lib/api") +jest.mock("next/navigation", () => ({ + useRouter: jest.fn(), +})) + +const mockedApi = api as jest.Mocked +const mockedUseRouter = useRouter as jest.Mock + +describe("AuthContext", () => { + const push = jest.fn() + const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}) + + beforeEach(() => { + jest.clearAllMocks() + localStorage.clear() + mockedUseRouter.mockReturnValue({ push }) + consoleError.mockClear() + }) + + afterAll(() => { + consoleError.mockRestore() + }) + + it("authenticates if auth is not enabled", async () => { + mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: false }) + render( + + + {(value) => ( + + Is Authenticated: {value?.isAuthenticated.toString()} + + )} + + + ) + await waitFor(() => { + expect(screen.getByText("Is Authenticated: true")).toBeInTheDocument() + }) + }) + + it("authenticates if auth is enabled and token is valid", async () => { + mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: true }) + mockedApi.getSettings.mockResolvedValue({} as api.Settings) // Mock a successful protected call + localStorage.setItem("authToken", "valid-token") + render( + + + {(value) => ( + + Is Authenticated: {value?.isAuthenticated.toString()} + + )} + + + ) + await waitFor(() => { + expect(screen.getByText("Is Authenticated: true")).toBeInTheDocument() + }) + }) + + it("does not authenticate if auth is enabled and no token", async () => { + mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: true }) + render( + + + {(value) => ( + + Is Authenticated: {value?.isAuthenticated.toString()} + + )} + + + ) + await waitFor(() => { + expect(screen.getByText("Is Authenticated: false")).toBeInTheDocument() + }) + }) + + it("does not authenticate if token is invalid", async () => { + mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: true }) + mockedApi.getSettings.mockRejectedValue(new Error("Invalid token")) // Mock a failed protected call + localStorage.setItem("authToken", "invalid-token") + render( + + + {(value) => ( + + Is Authenticated: {value?.isAuthenticated.toString()} + + )} + + + ) + await waitFor(() => { + expect(screen.getByText("Is Authenticated: false")).toBeInTheDocument() + }) + }) + + it("login works correctly", async () => { + mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: true }) + mockedApi.login.mockResolvedValue() + render( + + + {(value) => ( + <> + + Is Authenticated: {value?.isAuthenticated.toString()} + + + + )} + + + ) + await waitFor(() => { + expect(screen.getByText("Is Authenticated: false")).toBeInTheDocument() + }) + fireEvent.click(screen.getByText("Login")) + await waitFor(() => { + expect(mockedApi.login).toHaveBeenCalledWith("testuser", "password") + expect(screen.getByText("Is Authenticated: true")).toBeInTheDocument() + expect(push).toHaveBeenCalledWith("/") + }) + }) + + it("logout works correctly", async () => { + mockedApi.getAuthStatus.mockResolvedValue({ auth_enabled: true }) + mockedApi.getSettings.mockResolvedValue({} as api.Settings) + localStorage.setItem("authToken", "valid-token") + render( + + + {(value) => ( + <> + + Is Authenticated: {value?.isAuthenticated.toString()} + + + + )} + + + ) + await waitFor(() => { + expect(screen.getByText("Is Authenticated: true")).toBeInTheDocument() + }) + fireEvent.click(screen.getByText("Logout")) + await waitFor(() => { + expect(screen.getByText("Is Authenticated: false")).toBeInTheDocument() + expect(push).toHaveBeenCalledWith("/login") + expect(localStorage.getItem("authToken")).toBeNull() + }) + }) +}) diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..8a585f2 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,12 @@ +"use client" + +import { useContext } from "react" +import { AuthContext } from "@/contexts/AuthContext" + +export const useAuth = () => { + const context = useContext(AuthContext) + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider") + } + return context +} diff --git a/frontend/src/lib/__tests__/api.test.ts b/frontend/src/lib/__tests__/api.test.ts index 744158b..b5a9946 100644 --- a/frontend/src/lib/__tests__/api.test.ts +++ b/frontend/src/lib/__tests__/api.test.ts @@ -9,6 +9,7 @@ import { testImapConnection, processEmails, getFeedUrl, + login, NewsletterCreate, NewsletterUpdate, SettingsCreate, @@ -25,19 +26,21 @@ jest.mock("sonner", () => ({ }, })) -const mockFetch = (data: any, ok = true, statusText = "OK") => { // eslint-disable-line @typescript-eslint/no-explicit-any +const mockFetch = (data: T, ok = true, statusText = "OK") => { ;(fetch as jest.Mock).mockResolvedValueOnce({ ok, json: () => Promise.resolve(data), statusText, + status: ok ? 200 : 400, }) } -const mockFetchError = (data: any = {}, statusText = "Bad Request") => { // eslint-disable-line @typescript-eslint/no-explicit-any +const mockFetchError = (data: any = {}, statusText = "Bad Request", status = 400) => { // eslint-disable-line @typescript-eslint/no-explicit-any ;(fetch as jest.Mock).mockResolvedValueOnce({ ok: false, json: () => Promise.resolve(data), statusText, + status, }) } @@ -48,37 +51,53 @@ describe("API Functions", () => { // Reset the mock before each test ;(fetch as jest.Mock).mockClear() ;(toast.error as jest.Mock).mockClear() + localStorage.clear() + }) + + describe("login", () => { + it("should login successfully and store the token", async () => { + const mockToken = { access_token: "test-token", token_type: "bearer" } + mockFetch(mockToken) + + await login("user", "pass") + + expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ username: "user", password: "pass" }), + }) + expect(localStorage.getItem("authToken")).toBe("test-token") + expect(toast.error).not.toHaveBeenCalled() + }) + + it("should throw an error and clear token if login fails", async () => { + mockFetchError({ detail: "Incorrect username or password" }, "Unauthorized", 401) + localStorage.setItem("authToken", "old-token") + + await expect(login("user", "wrong-pass")).rejects.toThrow("Incorrect username or password") + expect(localStorage.getItem("authToken")).toBeNull() + expect(toast.error).toHaveBeenCalledWith("Incorrect username or password") + }) }) describe("getNewsletters", () => { - it("should fetch newsletters successfully", async () => { - const mockNewsletters = [ - { id: 1, name: "Newsletter 1", is_active: true, senders: [], entries_count: 5 }, - { id: 2, name: "Newsletter 2", is_active: false, senders: [], entries_count: 10 }, - ] + it("should fetch newsletters successfully with auth token", async () => { + localStorage.setItem("authToken", "test-token") + const mockNewsletters = [{ id: 1, name: "Newsletter 1" }] mockFetch(mockNewsletters) const newsletters = await getNewsletters() expect(newsletters).toEqual(mockNewsletters) - expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/newsletters`, {}) + expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/newsletters`, { + headers: { Authorization: "Bearer test-token" }, + }) expect(toast.error).not.toHaveBeenCalled() }) - - it("should throw an error and show toast if fetching newsletters fails with HTTP error", async () => { - mockFetchError({}, "Not Found") - await expect(getNewsletters()).rejects.toThrow("Failed to fetch newsletters: Not Found") - expect(toast.error).toHaveBeenCalledWith("Failed to fetch newsletters: Not Found") - }) - - it("should throw an error and show toast if fetching newsletters fails with network error", async () => { - ;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed")) - await expect(getNewsletters()).rejects.toThrow("Network request failed") - expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.") - }) }) describe("createNewsletter", () => { it("should create a newsletter successfully", async () => { + localStorage.setItem("authToken", "test-token") const newNewsletter: NewsletterCreate = { name: "New Newsletter", sender_emails: ["test@example.com"], extract_content: false } const createdNewsletter = { id: 3, @@ -93,36 +112,23 @@ describe("API Functions", () => { expect(result).toEqual(createdNewsletter) expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/newsletters`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", Authorization: "Bearer test-token" }, body: JSON.stringify(newNewsletter), }) expect(toast.error).not.toHaveBeenCalled() }) - - it("should throw an error and show toast if creating newsletter fails with HTTP error", async () => { - const newNewsletter: NewsletterCreate = { name: "New Newsletter", sender_emails: [], extract_content: false } - mockFetchError({}, "Conflict") - await expect(createNewsletter(newNewsletter)).rejects.toThrow("Failed to create newsletter: Conflict") - expect(toast.error).toHaveBeenCalledWith("Failed to create newsletter: Conflict") - }) - - it("should throw an error and show toast if creating newsletter fails with network error", async () => { - const newNewsletter: NewsletterCreate = { name: "New Newsletter", sender_emails: [], extract_content: false } - ;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed")) - await expect(createNewsletter(newNewsletter)).rejects.toThrow("Network request failed") - expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.") - }) }) describe("updateNewsletter", () => { it("should update a newsletter successfully", async () => { + localStorage.setItem("authToken", "test-token") const updatedNewsletter: NewsletterUpdate = { name: "Updated Newsletter", sender_emails: ["updated@example.com"], extract_content: true } - const newsletterId = 1 + const newsletterId = "1" const returnedNewsletter = { id: newsletterId, ...updatedNewsletter, is_active: true, - senders: [{ id: 1, email: "updated@example.com", newsletter_id: newsletterId }], + senders: [{ id: "1", email: "updated@example.com" }], entries_count: 12, } mockFetch(returnedNewsletter) @@ -131,58 +137,31 @@ describe("API Functions", () => { expect(result).toEqual(returnedNewsletter) expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/newsletters/${newsletterId}`, { method: "PUT", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", Authorization: "Bearer test-token" }, body: JSON.stringify(updatedNewsletter), }) expect(toast.error).not.toHaveBeenCalled() }) - - it("should throw an error and show toast if updating newsletter fails with HTTP error", async () => { - const updatedNewsletter: NewsletterUpdate = { name: "Updated Newsletter", sender_emails: [], extract_content: true } - const newsletterId = 1 - mockFetchError({}, "Bad Request") - await expect(updateNewsletter(newsletterId, updatedNewsletter)).rejects.toThrow("Failed to update newsletter: Bad Request") - expect(toast.error).toHaveBeenCalledWith("Failed to update newsletter: Bad Request") - }) - - it("should throw an error and show toast if updating newsletter fails with network error", async () => { - const updatedNewsletter: NewsletterUpdate = { name: "Updated Newsletter", sender_emails: [], extract_content: true } - const newsletterId = 1 - ;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed")) - await expect(updateNewsletter(newsletterId, updatedNewsletter)).rejects.toThrow("Network request failed") - expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.") - }) }) describe("deleteNewsletter", () => { it("should delete a newsletter successfully", async () => { - const newsletterId = 1 + localStorage.setItem("authToken", "test-token") + const newsletterId = "1" mockFetch({}, true) // Successful deletion might not have a body await deleteNewsletter(newsletterId) expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/newsletters/${newsletterId}`, { method: "DELETE", + headers: { Authorization: "Bearer test-token" }, }) expect(toast.error).not.toHaveBeenCalled() }) - - it("should throw an error and show toast if deleting newsletter fails with HTTP error", async () => { - const newsletterId = 1 - mockFetchError({}, "Forbidden") - await expect(deleteNewsletter(newsletterId)).rejects.toThrow("Failed to delete newsletter: Forbidden") - expect(toast.error).toHaveBeenCalledWith("Failed to delete newsletter: Forbidden") - }) - - it("should throw an error and show toast if deleting newsletter fails with network error", async () => { - const newsletterId = 1 - ;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed")) - await expect(deleteNewsletter(newsletterId)).rejects.toThrow("Network request failed") - expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.") - }) }) describe("getSettings", () => { it("should fetch settings successfully", async () => { + localStorage.setItem("authToken", "test-token") const mockSettings = { id: 1, imap_server: "imap.example.com", @@ -198,25 +177,16 @@ describe("API Functions", () => { const settings = await getSettings() expect(settings).toEqual(mockSettings) - expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/settings`, {}) + expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/settings`, { + headers: { Authorization: "Bearer test-token" }, + }) expect(toast.error).not.toHaveBeenCalled() }) - - it("should throw an error and show toast if fetching settings fails with HTTP error", async () => { - mockFetchError({}, "Unauthorized") - await expect(getSettings()).rejects.toThrow("Failed to fetch settings: Unauthorized") - expect(toast.error).toHaveBeenCalledWith("Failed to fetch settings: Unauthorized") - }) - - it("should throw an error and show toast if fetching settings fails with network error", async () => { - ;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed")) - await expect(getSettings()).rejects.toThrow("Network request failed") - expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.") - }) }) describe("updateSettings", () => { it("should update settings successfully", async () => { + localStorage.setItem("authToken", "test-token") const newSettings: SettingsCreate = { imap_server: "new.imap.com", imap_username: "newuser@example.com", @@ -234,69 +204,31 @@ describe("API Functions", () => { expect(result).toEqual(updatedSettings) expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/settings`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", Authorization: "Bearer test-token" }, body: JSON.stringify(newSettings), }) expect(toast.error).not.toHaveBeenCalled() }) - - it("should throw an error and show toast if updating settings fails with HTTP error", async () => { - const newSettings: SettingsCreate = { - imap_server: "new.imap.com", - imap_username: "newuser@example.com", - search_folder: "Archive", - mark_as_read: false, - email_check_interval: 120, - auto_add_new_senders: true, - } - mockFetchError({}, "Internal Server Error") - await expect(updateSettings(newSettings)).rejects.toThrow("Failed to update settings: Internal Server Error") - expect(toast.error).toHaveBeenCalledWith("Failed to update settings: Internal Server Error") - }) - - it("should throw an error and show toast if updating settings fails with network error", async () => { - const newSettings: SettingsCreate = { - imap_server: "new.imap.com", - imap_username: "newuser@example.com", - search_folder: "Archive", - mark_as_read: false, - email_check_interval: 120, - auto_add_new_senders: true, - } - ;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed")) - await expect(updateSettings(newSettings)).rejects.toThrow("Network request failed") - expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.") - }) }) describe("getImapFolders", () => { it("should fetch IMAP folders successfully", async () => { + localStorage.setItem("authToken", "test-token") const mockFolders = ["INBOX", "Sent", "Archive"] mockFetch(mockFolders) const folders = await getImapFolders() expect(folders).toEqual(mockFolders) - expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/folders`, {}) + expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/folders`, { + headers: { Authorization: "Bearer test-token" }, + }) expect(toast.error).not.toHaveBeenCalled() }) - - it("should return an empty array and show toast if fetching IMAP folders fails with HTTP error", async () => { - mockFetchError({}, "Forbidden") - const folders = await getImapFolders() - expect(folders).toEqual([]) - expect(toast.error).toHaveBeenCalledWith("Failed to fetch IMAP folders: Forbidden") - }) - - it("should return an empty array and show toast if fetching IMAP folders fails with network error", async () => { - ;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed")) - const folders = await getImapFolders() - expect(folders).toEqual([]) - expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.") - }) }) describe("testImapConnection", () => { it("should test IMAP connection successfully", async () => { + localStorage.setItem("authToken", "test-token") const mockResponse = { message: "Connection successful" } mockFetch(mockResponse) @@ -304,32 +236,15 @@ describe("API Functions", () => { expect(result).toEqual(mockResponse) expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/test`, { method: "POST", + headers: { Authorization: "Bearer test-token" }, }) expect(toast.error).not.toHaveBeenCalled() }) - - it("should throw an error with detail and show toast if testing IMAP connection fails with HTTP error", async () => { - const errorMessage = "Invalid credentials" - mockFetchError({ detail: errorMessage }, "Unauthorized") - await expect(testImapConnection()).rejects.toThrow(errorMessage) - expect(toast.error).toHaveBeenCalledWith(errorMessage) - }) - - it("should throw a generic error and show toast if testing IMAP connection fails without detail with HTTP error", async () => { - mockFetchError({}, "Bad Gateway") - await expect(testImapConnection()).rejects.toThrow("Failed to test IMAP connection: Bad Gateway") - expect(toast.error).toHaveBeenCalledWith("Failed to test IMAP connection: Bad Gateway") - }) - - it("should throw an error and show toast if testing IMAP connection fails with network error", async () => { - ;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed")) - await expect(testImapConnection()).rejects.toThrow("Network request failed") - expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.") - }) }) describe("processEmails", () => { it("should process emails successfully", async () => { + localStorage.setItem("authToken", "test-token") const mockResponse = { message: "Emails processed" } mockFetch(mockResponse) @@ -337,43 +252,18 @@ describe("API Functions", () => { expect(result).toEqual(mockResponse) expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/process`, { method: "POST", + headers: { Authorization: "Bearer test-token" }, }) expect(toast.error).not.toHaveBeenCalled() }) - - it("should throw an error with detail and show toast if processing emails fails with HTTP error", async () => { - const errorMessage = "IMAP not configured" - mockFetchError({ detail: errorMessage }, "Bad Request") - await expect(processEmails()).rejects.toThrow(errorMessage) - expect(toast.error).toHaveBeenCalledWith(errorMessage) - }) - - it("should throw a generic error and show toast if processing emails fails without detail with HTTP error", async () => { - mockFetchError({}, "Service Unavailable") - await expect(processEmails()).rejects.toThrow("Failed to process emails: Service Unavailable") - expect(toast.error).toHaveBeenCalledWith("Failed to process emails: Service Unavailable") - }) - - it("should throw an error and show toast if processing emails fails with network error", async () => { - ;(fetch as jest.Mock).mockRejectedValueOnce(new TypeError("Network request failed")) - await expect(processEmails()).rejects.toThrow("Network request failed") - expect(toast.error).toHaveBeenCalledWith("Network error: Could not connect to the backend.") - }) }) describe("getFeedUrl", () => { it("should return the correct feed URL", () => { - const newsletterId = 123 + const newsletterId = "123" const expectedUrl = `${API_BASE_URL}/feeds/${newsletterId}` const url = getFeedUrl(newsletterId) expect(url).toBe(expectedUrl) }) - - it("should handle newsletterId being 0", () => { - const newsletterId = 0 - const expectedUrl = `${API_BASE_URL}/feeds/0` - const url = getFeedUrl(newsletterId) - expect(url).toBe(expectedUrl) - }) }) }) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6b609ae..7e32f64 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -63,6 +63,14 @@ async function fetcher( returnEmptyArrayOnFailure: boolean = false ): Promise { try { + const token = localStorage.getItem("authToken"); + if (token) { + options.headers = { + ...options.headers, + 'Authorization': `Bearer ${token}`, + }; + } + const response = await fetch(url, options); if (!response.ok) { let errorText = `${errorMessagePrefix}: ${response.statusText}`; @@ -74,12 +82,22 @@ async function fetcher( } catch (e) { // eslint-disable-line @typescript-eslint/no-unused-vars // ignore error if response is not JSON } + + if (response.status === 401) { + localStorage.removeItem("authToken"); + // Do not redirect here. The AuthContext will handle it. + } + toast.error(errorText); if (returnEmptyArrayOnFailure) { return [] as T; } throw new Error(errorText); } + // For login or delete, we might not have a body + if (response.status === 204) { + return {} as T; + } return response.json(); } catch (error) { if (error instanceof TypeError) { @@ -92,6 +110,34 @@ async function fetcher( } } +export async function getAuthStatus(): Promise<{ auth_enabled: boolean }> { + return fetcher<{ auth_enabled: boolean }>(`${API_BASE_URL}/auth/status`, {}, "Failed to fetch auth status"); +} + +export async function login(username: string, password: string): Promise { + const formData = new URLSearchParams(); + formData.append('username', username); + formData.append('password', password); + + try { + const response = await fetcher<{ access_token: string }>(`${API_BASE_URL}/auth/login`, { + method: 'POST', + body: formData, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, "Login failed"); + + if (response.access_token) { + localStorage.setItem("authToken", response.access_token); + } + } catch (error) { + localStorage.removeItem("authToken"); + throw error; + } +} + + export async function getNewsletters(): Promise { return fetcher(`${API_BASE_URL}/newsletters`, {}, "Failed to fetch newsletters"); } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 4ec7f3d..c133409 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -19,8 +19,7 @@ } ], "paths": { - "@/*": ["./src/*"], - "@/components/*": ["./components/*"] + "@/*": ["./src/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],