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:
@@ -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
58
backend/app/api/auth.py
Normal 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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user