diff --git a/.env.example b/.env.example index 6c75e96..acd088c 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,10 @@ LOCAL_URL=http://localhost # Public-facing URL when accessed via your reverse proxy (leave blank if none) PUBLIC_URL= +# ─── Auth ────────────────────────────────────────────────────────────────────── +# Generate a strong random secret: python -c "import secrets; print(secrets.token_hex(32))" +JWT_SECRET_KEY= + # ─── PostgreSQL ─────────────────────────────────────────────────────────────── POSTGRES_USER=congress POSTGRES_PASSWORD=congress diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..76723cd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Normalize text files to LF in the repo +* text=auto eol=lf + +# (Optional) Explicit common types +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.jsx text eol=lf +*.json text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf \ No newline at end of file diff --git a/backend/alembic/versions/0005_add_users_and_user_follows.py b/backend/alembic/versions/0005_add_users_and_user_follows.py new file mode 100644 index 0000000..ae29e86 --- /dev/null +++ b/backend/alembic/versions/0005_add_users_and_user_follows.py @@ -0,0 +1,74 @@ +"""add users table and user_id to follows + +Revision ID: 0005 +Revises: 0004 +Create Date: 2026-03-01 00:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB +from alembic import op + +revision: str = "0005" +down_revision: Union[str, None] = "0004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. Clear existing global follows — they have no user and cannot be migrated + op.execute("DELETE FROM follows") + + # 2. Create users table + op.create_table( + "users", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("email", sa.String(), nullable=False), + sa.Column("hashed_password", sa.String(), nullable=False), + sa.Column("is_admin", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("notification_prefs", JSONB(), nullable=False, server_default="{}"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + + # 3. Add user_id to follows (nullable first, then tighten after FK is set) + op.add_column("follows", sa.Column("user_id", sa.Integer(), nullable=True)) + + # 4. FK constraint + op.create_foreign_key( + "fk_follows_user_id", + "follows", + "users", + ["user_id"], + ["id"], + ondelete="CASCADE", + ) + + # 5. Drop old unique constraint and add user-scoped one + op.drop_constraint("uq_follows_type_value", "follows", type_="unique") + op.create_unique_constraint( + "uq_follows_user_type_value", + "follows", + ["user_id", "follow_type", "follow_value"], + ) + + # 6. Make user_id NOT NULL (table is empty so this is safe) + op.alter_column("follows", "user_id", nullable=False) + + +def downgrade() -> None: + op.alter_column("follows", "user_id", nullable=True) + op.drop_constraint("uq_follows_user_type_value", "follows", type_="unique") + op.create_unique_constraint("uq_follows_type_value", "follows", ["follow_type", "follow_value"]) + op.drop_constraint("fk_follows_user_id", "follows", type_="foreignkey") + op.drop_column("follows", "user_id") + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_table("users") diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index dfc5ed1..2130e19 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -1,35 +1,109 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.dependencies import get_current_admin +from app.database import get_db +from app.models import Follow +from app.models.user import User +from app.schemas.schemas import UserResponse router = APIRouter() +# ── User Management ─────────────────────────────────────────────────────────── + +class UserWithStats(UserResponse): + follow_count: int + + +@router.get("/users", response_model=list[UserWithStats]) +async def list_users( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_admin), +): + """List all users with their follow counts.""" + users_result = await db.execute(select(User).order_by(User.created_at)) + users = users_result.scalars().all() + + counts_result = await db.execute( + select(Follow.user_id, func.count(Follow.id).label("cnt")) + .group_by(Follow.user_id) + ) + counts = {row.user_id: row.cnt for row in counts_result} + + return [ + UserWithStats( + id=u.id, + email=u.email, + is_admin=u.is_admin, + notification_prefs=u.notification_prefs or {}, + created_at=u.created_at, + follow_count=counts.get(u.id, 0), + ) + for u in users + ] + + +@router.delete("/users/{user_id}", status_code=204) +async def delete_user( + user_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_admin), +): + """Delete a user account (cascades to their follows). Cannot delete yourself.""" + if user_id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot delete your own account") + user = await db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + await db.delete(user) + await db.commit() + + +@router.patch("/users/{user_id}/toggle-admin", response_model=UserResponse) +async def toggle_admin( + user_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_admin), +): + """Promote or demote a user's admin status.""" + if user_id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot change your own admin status") + user = await db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + user.is_admin = not user.is_admin + await db.commit() + await db.refresh(user) + return user + + +# ── Celery Tasks ────────────────────────────────────────────────────────────── + @router.post("/trigger-poll") -async def trigger_poll(): - """Manually trigger a Congress.gov poll without waiting for the Beat schedule.""" +async def trigger_poll(current_user: User = Depends(get_current_admin)): from app.workers.congress_poller import poll_congress_bills task = poll_congress_bills.delay() return {"task_id": task.id, "status": "queued"} @router.post("/trigger-member-sync") -async def trigger_member_sync(): - """Manually trigger a member sync.""" +async def trigger_member_sync(current_user: User = Depends(get_current_admin)): from app.workers.congress_poller import sync_members task = sync_members.delay() return {"task_id": task.id, "status": "queued"} @router.post("/trigger-trend-scores") -async def trigger_trend_scores(): - """Manually trigger trend score calculation.""" +async def trigger_trend_scores(current_user: User = Depends(get_current_admin)): from app.workers.trend_scorer import calculate_all_trend_scores task = calculate_all_trend_scores.delay() return {"task_id": task.id, "status": "queued"} @router.get("/task-status/{task_id}") -async def get_task_status(task_id: str): - """Check the status of an async task.""" +async def get_task_status(task_id: str, current_user: User = Depends(get_current_admin)): from app.workers.celery_app import celery_app result = celery_app.AsyncResult(task_id) return { diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..c2d1cd2 --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,58 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.dependencies import get_current_user +from app.core.security import create_access_token, hash_password, verify_password +from app.database import get_db +from app.models.user import User +from app.schemas.schemas import TokenResponse, UserCreate, UserResponse + +router = APIRouter() + + +@router.post("/register", response_model=TokenResponse, status_code=201) +async def register(body: UserCreate, db: AsyncSession = Depends(get_db)): + if len(body.password) < 8: + raise HTTPException(status_code=400, detail="Password must be at least 8 characters") + if "@" not in body.email: + raise HTTPException(status_code=400, detail="Invalid email address") + + # Check for duplicate email + existing = await db.execute(select(User).where(User.email == body.email.lower())) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Email already registered") + + # First registered user becomes admin + count_result = await db.execute(select(func.count()).select_from(User)) + is_first_user = count_result.scalar() == 0 + + user = User( + email=body.email.lower(), + hashed_password=hash_password(body.password), + is_admin=is_first_user, + ) + db.add(user) + await db.commit() + await db.refresh(user) + + return TokenResponse(access_token=create_access_token(user.id), user=user) + + +@router.post("/login", response_model=TokenResponse) +async def login(body: UserCreate, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where(User.email == body.email.lower())) + user = result.scalar_one_or_none() + + if not user or not verify_password(body.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + ) + + return TokenResponse(access_token=create_access_token(user.id), user=user) + + +@router.get("/me", response_model=UserResponse) +async def me(current_user: User = Depends(get_current_user)): + return current_user diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py index 084a334..35b4a05 100644 --- a/backend/app/api/dashboard.py +++ b/backend/app/api/dashboard.py @@ -6,17 +6,24 @@ from sqlalchemy import desc, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.core.dependencies import get_current_user from app.database import get_db from app.models import Bill, BillBrief, Follow, TrendScore +from app.models.user import User from app.schemas.schemas import BillSchema router = APIRouter() @router.get("") -async def get_dashboard(db: AsyncSession = Depends(get_db)): - # Load all follows - follows_result = await db.execute(select(Follow)) +async def get_dashboard( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + # Load follows for the current user + follows_result = await db.execute( + select(Follow).where(Follow.user_id == current_user.id) + ) follows = follows_result.scalars().all() followed_bill_ids = [f.follow_value for f in follows if f.follow_type == "bill"] diff --git a/backend/app/api/follows.py b/backend/app/api/follows.py index de12887..4b098e7 100644 --- a/backend/app/api/follows.py +++ b/backend/app/api/follows.py @@ -3,8 +3,10 @@ from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from app.core.dependencies import get_current_user from app.database import get_db from app.models import Follow +from app.models.user import User from app.schemas.schemas import FollowCreate, FollowSchema router = APIRouter() @@ -13,16 +15,31 @@ VALID_FOLLOW_TYPES = {"bill", "member", "topic"} @router.get("", response_model=list[FollowSchema]) -async def list_follows(db: AsyncSession = Depends(get_db)): - result = await db.execute(select(Follow).order_by(Follow.created_at.desc())) +async def list_follows( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute( + select(Follow) + .where(Follow.user_id == current_user.id) + .order_by(Follow.created_at.desc()) + ) return result.scalars().all() @router.post("", response_model=FollowSchema, status_code=201) -async def add_follow(body: FollowCreate, db: AsyncSession = Depends(get_db)): +async def add_follow( + body: FollowCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): if body.follow_type not in VALID_FOLLOW_TYPES: raise HTTPException(status_code=400, detail=f"follow_type must be one of {VALID_FOLLOW_TYPES}") - follow = Follow(follow_type=body.follow_type, follow_value=body.follow_value) + follow = Follow( + user_id=current_user.id, + follow_type=body.follow_type, + follow_value=body.follow_value, + ) db.add(follow) try: await db.commit() @@ -32,6 +49,7 @@ async def add_follow(body: FollowCreate, db: AsyncSession = Depends(get_db)): # Already following — return existing result = await db.execute( select(Follow).where( + Follow.user_id == current_user.id, Follow.follow_type == body.follow_type, Follow.follow_value == body.follow_value, ) @@ -41,9 +59,15 @@ async def add_follow(body: FollowCreate, db: AsyncSession = Depends(get_db)): @router.delete("/{follow_id}", status_code=204) -async def remove_follow(follow_id: int, db: AsyncSession = Depends(get_db)): +async def remove_follow( + follow_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): follow = await db.get(Follow, follow_id) if not follow: raise HTTPException(status_code=404, detail="Follow not found") + if follow.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not your follow") await db.delete(follow) await db.commit() diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 1f6db0c..ec50221 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -3,15 +3,20 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings +from app.core.dependencies import get_current_admin, get_current_user from app.database import get_db from app.models import AppSetting +from app.models.user import User from app.schemas.schemas import SettingUpdate, SettingsResponse router = APIRouter() @router.get("", response_model=SettingsResponse) -async def get_settings(db: AsyncSession = Depends(get_db)): +async def get_settings( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Return current effective settings (env + DB overrides).""" # DB overrides take precedence over env vars overrides: dict[str, str] = {} @@ -29,7 +34,11 @@ async def get_settings(db: AsyncSession = Depends(get_db)): @router.put("") -async def update_setting(body: SettingUpdate, db: AsyncSession = Depends(get_db)): +async def update_setting( + body: SettingUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_admin), +): """Update a runtime setting.""" ALLOWED_KEYS = {"llm_provider", "llm_model", "congress_poll_interval_minutes"} if body.key not in ALLOWED_KEYS: @@ -46,7 +55,7 @@ async def update_setting(body: SettingUpdate, db: AsyncSession = Depends(get_db) @router.post("/test-llm") -async def test_llm_connection(): +async def test_llm_connection(current_user: User = Depends(get_current_admin)): """Test that the configured LLM provider responds correctly.""" from app.services.llm_service import get_llm_provider try: diff --git a/backend/app/config.py b/backend/app/config.py index 07ca81b..0b19eff 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -9,6 +9,10 @@ class Settings(BaseSettings): LOCAL_URL: str = "http://localhost" PUBLIC_URL: str = "" + # Auth / JWT + JWT_SECRET_KEY: str = "change-me-in-production" + JWT_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days + # Database DATABASE_URL: str = "postgresql+asyncpg://congress:congress@postgres:5432/pocketveto" SYNC_DATABASE_URL: str = "postgresql://congress:congress@postgres:5432/pocketveto" diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py new file mode 100644 index 0000000..8e0d7fe --- /dev/null +++ b/backend/app/core/dependencies.py @@ -0,0 +1,41 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import decode_token +from app.database import get_db +from app.models.user import User + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") + + +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db), +) -> User: + credentials_error = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + user_id = decode_token(token) + except JWTError: + raise credentials_error + + user = await db.get(User, user_id) + if user is None: + raise credentials_error + return user + + +async def get_current_admin( + current_user: User = Depends(get_current_user), +) -> User: + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required", + ) + return current_user diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..cc96860 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,36 @@ +from datetime import datetime, timedelta, timezone + +from jose import JWTError, jwt +from passlib.context import CryptContext + +from app.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +ALGORITHM = "HS256" + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_access_token(user_id: int) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES) + return jwt.encode( + {"sub": str(user_id), "exp": expire}, + settings.JWT_SECRET_KEY, + algorithm=ALGORITHM, + ) + + +def decode_token(token: str) -> int: + """Decode JWT and return user_id. Raises JWTError on failure.""" + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[ALGORITHM]) + user_id = payload.get("sub") + if user_id is None: + raise JWTError("Missing sub claim") + return int(user_id) diff --git a/backend/app/main.py b/backend/app/main.py index bfa6b00..fba258e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api import bills, members, follows, dashboard, search, settings, admin, health +from app.api import bills, members, follows, dashboard, search, settings, admin, health, auth from app.config import settings as config app = FastAPI( @@ -18,6 +18,7 @@ app.add_middleware( allow_headers=["*"], ) +app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(bills.router, prefix="/api/bills", tags=["bills"]) app.include_router(members.router, prefix="/api/members", tags=["members"]) app.include_router(follows.router, prefix="/api/follows", tags=["follows"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 0b3d6a3..e07aee5 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -6,6 +6,7 @@ from app.models.news import NewsArticle from app.models.setting import AppSetting from app.models.trend import TrendScore from app.models.committee import Committee, CommitteeBill +from app.models.user import User __all__ = [ "Bill", @@ -19,4 +20,5 @@ __all__ = [ "TrendScore", "Committee", "CommitteeBill", + "User", ] diff --git a/backend/app/models/follow.py b/backend/app/models/follow.py index ee3ff18..aa63d75 100644 --- a/backend/app/models/follow.py +++ b/backend/app/models/follow.py @@ -1,4 +1,5 @@ -from sqlalchemy import Column, Integer, String, DateTime, UniqueConstraint +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, UniqueConstraint +from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.database import Base @@ -8,10 +9,13 @@ class Follow(Base): __tablename__ = "follows" id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) follow_type = Column(String(20), nullable=False) # bill | member | topic follow_value = Column(String, nullable=False) # bill_id | bioguide_id | tag string created_at = Column(DateTime(timezone=True), server_default=func.now()) + user = relationship("User", back_populates="follows") + __table_args__ = ( - UniqueConstraint("follow_type", "follow_value", name="uq_follows_type_value"), + UniqueConstraint("user_id", "follow_type", "follow_value", name="uq_follows_user_type_value"), ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..7f1bb18 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,19 @@ +from sqlalchemy import Boolean, Column, DateTime, Integer, String +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, autoincrement=True) + email = Column(String, unique=True, nullable=False, index=True) + hashed_password = Column(String, nullable=False) + is_admin = Column(Boolean, nullable=False, default=False) + notification_prefs = Column(JSONB, nullable=False, default=dict) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + follows = relationship("Follow", back_populates="user", cascade="all, delete-orphan") diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 6ee9c49..52f9bf5 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -123,6 +123,7 @@ class FollowCreate(BaseModel): class FollowSchema(BaseModel): id: int + user_id: int follow_type: str follow_value: str created_at: datetime @@ -130,6 +131,31 @@ class FollowSchema(BaseModel): model_config = {"from_attributes": True} +# ── Settings ────────────────────────────────────────────────────────────────── + +# ── Auth ────────────────────────────────────────────────────────────────────── + +class UserCreate(BaseModel): + email: str + password: str + + +class UserResponse(BaseModel): + id: int + email: str + is_admin: bool + notification_prefs: dict + created_at: Optional[datetime] = None + + model_config = {"from_attributes": True} + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user: "UserResponse" + + # ── Settings ────────────────────────────────────────────────────────────────── class SettingUpdate(BaseModel): diff --git a/backend/requirements.txt b/backend/requirements.txt index 776d967..f40f701 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -39,6 +39,11 @@ pytrends==4.9.2 # Redis client (for health check) redis==5.2.1 +# Auth +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 + # Utilities python-dateutil==2.9.0 tiktoken==0.8.0 diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index caa0df4..d92d5ae 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; import { Providers } from "./providers"; -import { Sidebar } from "@/components/shared/Sidebar"; +import { AuthGuard } from "@/components/shared/AuthGuard"; const inter = Inter({ subsets: ["latin"] }); @@ -20,14 +20,9 @@ export default function RootLayout({
Sign in to your account
++ No account?{" "} + + Register + +
+Create your account
++ Already have an account?{" "} + + Sign in + +
+Configure LLM provider and system settings
+Manage users, LLM provider, and system settings
Loading users...
+ ) : ( +