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({ -
- -
-
- {children} -
-
-
+ + {children} +
diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 0000000..d075da4 --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { authAPI } from "@/lib/api"; +import { useAuthStore } from "@/stores/authStore"; + +export default function LoginPage() { + const router = useRouter(); + const setAuth = useAuthStore((s) => s.setAuth); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + try { + const { access_token, user } = await authAPI.login(email.trim(), password); + setAuth(access_token, { id: user.id, email: user.email, is_admin: user.is_admin }); + router.replace("/"); + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || + "Login failed. Check your email and password."; + setError(msg); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+

PocketVeto

+

Sign in to your account

+
+ +
+
+ + setEmail(e.target.value)} + className="w-full px-3 py-2 border rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-3 py-2 border rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+ + {error &&

{error}

} + + +
+ +

+ No account?{" "} + + Register + +

+
+
+ ); +} diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx new file mode 100644 index 0000000..883ace1 --- /dev/null +++ b/frontend/app/register/page.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { authAPI } from "@/lib/api"; +import { useAuthStore } from "@/stores/authStore"; + +export default function RegisterPage() { + const router = useRouter(); + const setAuth = useAuthStore((s) => s.setAuth); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + if (password.length < 8) { + setError("Password must be at least 8 characters."); + return; + } + setLoading(true); + try { + const { access_token, user } = await authAPI.register(email.trim(), password); + setAuth(access_token, { id: user.id, email: user.email, is_admin: user.is_admin }); + router.replace("/"); + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || + "Registration failed. Please try again."; + setError(msg); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+

PocketVeto

+

Create your account

+
+ +
+
+ + setEmail(e.target.value)} + className="w-full px-3 py-2 border rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-3 py-2 border rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+ + {error &&

{error}

} + + +
+ +

+ Already have an account?{" "} + + Sign in + +

+
+
+ ); +} diff --git a/frontend/app/settings/page.tsx b/frontend/app/settings/page.tsx index 556d903..d4481c6 100644 --- a/frontend/app/settings/page.tsx +++ b/frontend/app/settings/page.tsx @@ -2,8 +2,20 @@ import { useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Settings, Cpu, RefreshCw, CheckCircle, XCircle, Play } from "lucide-react"; -import { settingsAPI, adminAPI } from "@/lib/api"; +import { + Settings, + Cpu, + RefreshCw, + CheckCircle, + XCircle, + Play, + Users, + Trash2, + ShieldCheck, + ShieldOff, +} from "lucide-react"; +import { settingsAPI, adminAPI, type AdminUser } from "@/lib/api"; +import { useAuthStore } from "@/stores/authStore"; const LLM_PROVIDERS = [ { value: "openai", label: "OpenAI (GPT-4o)", hint: "Requires OPENAI_API_KEY in .env" }, @@ -14,19 +26,43 @@ const LLM_PROVIDERS = [ export default function SettingsPage() { const qc = useQueryClient(); - const { data: settings, isLoading } = useQuery({ + const currentUser = useAuthStore((s) => s.user); + + const { data: settings, isLoading: settingsLoading } = useQuery({ queryKey: ["settings"], queryFn: () => settingsAPI.get(), }); + const { data: users, isLoading: usersLoading } = useQuery({ + queryKey: ["admin-users"], + queryFn: () => adminAPI.listUsers(), + enabled: !!currentUser?.is_admin, + }); + const updateSetting = useMutation({ mutationFn: ({ key, value }: { key: string; value: string }) => settingsAPI.update(key, value), onSuccess: () => qc.invalidateQueries({ queryKey: ["settings"] }), }); - const [testResult, setTestResult] = useState<{ status: string; detail?: string; summary_preview?: string; provider?: string } | null>(null); + const deleteUser = useMutation({ + mutationFn: (id: number) => adminAPI.deleteUser(id), + onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-users"] }), + }); + + const toggleAdmin = useMutation({ + mutationFn: (id: number) => adminAPI.toggleAdmin(id), + onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-users"] }), + }); + + const [testResult, setTestResult] = useState<{ + status: string; + detail?: string; + summary_preview?: string; + provider?: string; + } | null>(null); const [testing, setTesting] = useState(false); const [taskIds, setTaskIds] = useState>({}); + const [confirmDelete, setConfirmDelete] = useState(null); const testLLM = async () => { setTesting(true); @@ -46,17 +82,102 @@ export default function SettingsPage() { setTaskIds((prev) => ({ ...prev, [name]: result.task_id })); }; - if (isLoading) return
Loading settings...
; + if (settingsLoading) return
Loading...
; + + if (!currentUser?.is_admin) { + return ( +
+ Admin access required. +
+ ); + } return (

- Settings + Admin

-

Configure LLM provider and system settings

+

Manage users, LLM provider, and system settings

+ {/* User Management */} +
+

+ Users +

+ {usersLoading ? ( +

Loading users...

+ ) : ( +
+ {(users ?? []).map((u: AdminUser) => ( +
+
+
+ {u.email} + {u.is_admin && ( + + admin + + )} + {u.id === currentUser.id && ( + (you) + )} +
+
+ {u.follow_count} follow{u.follow_count !== 1 ? "s" : ""} ·{" "} + joined {new Date(u.created_at).toLocaleDateString()} +
+
+ {u.id !== currentUser.id && ( +
+ + {confirmDelete === u.id ? ( +
+ + +
+ ) : ( + + )} +
+ )} +
+ ))} +
+ )} +
+ {/* LLM Provider */}

@@ -99,7 +220,7 @@ export default function SettingsPage() { <> - {testResult.provider}/{testResult.summary_preview?.slice(0, 50)}... + {testResult.provider} — {testResult.summary_preview?.slice(0, 60)}... ) : ( @@ -113,7 +234,7 @@ export default function SettingsPage() {

- {/* Polling Settings */} + {/* Data Sources */}

Data Sources diff --git a/frontend/components/shared/AuthGuard.tsx b/frontend/components/shared/AuthGuard.tsx new file mode 100644 index 0000000..52f1444 --- /dev/null +++ b/frontend/components/shared/AuthGuard.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { useAuthStore } from "@/stores/authStore"; +import { Sidebar } from "./Sidebar"; + +const PUBLIC_PATHS = ["/login", "/register"]; + +export function AuthGuard({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const pathname = usePathname(); + const token = useAuthStore((s) => s.token); + // Zustand persist hydrates asynchronously — wait for it before rendering + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + setHydrated(true); + }, []); + + useEffect(() => { + if (!hydrated) return; + if (!token && !PUBLIC_PATHS.includes(pathname)) { + router.replace("/login"); + } + }, [hydrated, token, pathname, router]); + + if (!hydrated) return null; + + // Public pages (login/register) render without the app shell + if (PUBLIC_PATHS.includes(pathname)) { + return <>{children}; + } + + // Not logged in yet — blank while redirecting + if (!token) return null; + + // Authenticated: render the full app shell + return ( +
+ +
+
+ {children} +
+
+
+ ); +} diff --git a/frontend/components/shared/Sidebar.tsx b/frontend/components/shared/Sidebar.tsx index 1ea8c7e..f56ff5f 100644 --- a/frontend/components/shared/Sidebar.tsx +++ b/frontend/components/shared/Sidebar.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { LayoutDashboard, FileText, @@ -10,21 +10,34 @@ import { Heart, Settings, Landmark, + LogOut, } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; import { cn } from "@/lib/utils"; import { ThemeToggle } from "./ThemeToggle"; +import { useAuthStore } from "@/stores/authStore"; const NAV = [ - { href: "/", label: "Dashboard", icon: LayoutDashboard }, - { href: "/bills", label: "Bills", icon: FileText }, - { href: "/members", label: "Members", icon: Users }, - { href: "/topics", label: "Topics", icon: Tags }, - { href: "/following", label: "Following", icon: Heart }, - { href: "/settings", label: "Settings", icon: Settings }, + { href: "/", label: "Dashboard", icon: LayoutDashboard, adminOnly: false }, + { href: "/bills", label: "Bills", icon: FileText, adminOnly: false }, + { href: "/members", label: "Members", icon: Users, adminOnly: false }, + { href: "/topics", label: "Topics", icon: Tags, adminOnly: false }, + { href: "/following", label: "Following", icon: Heart, adminOnly: false }, + { href: "/settings", label: "Admin", icon: Settings, adminOnly: true }, ]; export function Sidebar() { const pathname = usePathname(); + const router = useRouter(); + const qc = useQueryClient(); + const user = useAuthStore((s) => s.user); + const logout = useAuthStore((s) => s.logout); + + function handleLogout() { + logout(); + qc.clear(); + router.replace("/login"); + } return ( ); diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 9c9fcfa..c973730 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -17,6 +17,48 @@ const apiClient = axios.create({ timeout: 30000, }); +// Attach JWT from localStorage on every request +apiClient.interceptors.request.use((config) => { + if (typeof window !== "undefined") { + try { + const stored = localStorage.getItem("pocketveto-auth"); + if (stored) { + const { state } = JSON.parse(stored); + if (state?.token) { + config.headers.Authorization = `Bearer ${state.token}`; + } + } + } catch { + // ignore parse errors + } + } + return config; +}); + +interface AuthUser { + id: number; + email: string; + is_admin: boolean; + notification_prefs: Record; + created_at: string; +} + +interface TokenResponse { + access_token: string; + token_type: string; + user: AuthUser; +} + +// Auth +export const authAPI = { + register: (email: string, password: string) => + apiClient.post("/api/auth/register", { email, password }).then((r) => r.data), + login: (email: string, password: string) => + apiClient.post("/api/auth/login", { email, password }).then((r) => r.data), + me: () => + apiClient.get("/api/auth/me").then((r) => r.data), +}; + // Bills export const billsAPI = { list: (params?: Record) => @@ -73,8 +115,24 @@ export const settingsAPI = { apiClient.post("/api/settings/test-llm").then((r) => r.data), }; +export interface AdminUser { + id: number; + email: string; + is_admin: boolean; + follow_count: number; + created_at: string; +} + // Admin export const adminAPI = { + // Users + listUsers: () => + apiClient.get("/api/admin/users").then((r) => r.data), + deleteUser: (id: number) => + apiClient.delete(`/api/admin/users/${id}`), + toggleAdmin: (id: number) => + apiClient.patch(`/api/admin/users/${id}/toggle-admin`).then((r) => r.data), + // Tasks triggerPoll: () => apiClient.post("/api/admin/trigger-poll").then((r) => r.data), triggerMemberSync: () => diff --git a/frontend/stores/authStore.ts b/frontend/stores/authStore.ts new file mode 100644 index 0000000..19ecfe0 --- /dev/null +++ b/frontend/stores/authStore.ts @@ -0,0 +1,27 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface AuthUser { + id: number; + email: string; + is_admin: boolean; +} + +interface AuthState { + token: string | null; + user: AuthUser | null; + setAuth: (token: string, user: AuthUser) => void; + logout: () => void; +} + +export const useAuthStore = create()( + persist( + (set) => ({ + token: null, + user: null, + setAuth: (token, user) => set({ token, user }), + logout: () => set({ token: null, user: null }), + }), + { name: "pocketveto-auth" } + ) +);