Add multi-user auth system and admin panel

- User model with email/hashed_password/is_admin/notification_prefs
- JWT auth: POST /api/auth/register, /login, /me
- First registered user auto-promoted to admin
- Migration 0005: users table + user_id FK on follows (clears global follows)
- Follows, dashboard, settings, admin endpoints all require authentication
- Admin endpoints (settings writes, celery triggers) require is_admin
- Frontend: login/register pages, Zustand auth store (localStorage persist)
- AuthGuard component gates all app routes, shows app shell only when authed
- Sidebar shows user email + logout; Admin nav link visible to admins only
- Admin panel (/settings): user list with delete + promote/demote, LLM config,
  data source settings, and manual celery controls

Authored-By: Jack Levy
This commit is contained in:
Jack Levy
2026-02-28 21:40:45 -05:00
parent e418dd9ae0
commit 5b73b60d9e
26 changed files with 917 additions and 52 deletions

View File

@@ -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")

View File

@@ -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 {

58
backend/app/api/auth.py Normal file
View File

@@ -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

View File

@@ -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"]

View File

@@ -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()

View File

@@ -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:

View File

@@ -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"

View File

View File

@@ -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

View File

@@ -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)

View File

@@ -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"])

View File

@@ -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",
]

View File

@@ -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"),
)

View File

@@ -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")

View File

@@ -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):

View File

@@ -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