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

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

12
.gitattributes vendored Normal file
View File

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

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

View File

@@ -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({
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<Providers>
<div className="flex h-screen bg-background">
<Sidebar />
<main className="flex-1 overflow-auto">
<div className="container mx-auto px-6 py-6 max-w-7xl">
<AuthGuard>
{children}
</div>
</main>
</div>
</AuthGuard>
</Providers>
</body>
</html>

View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="w-full max-w-sm space-y-6 p-8 border rounded-lg bg-card shadow-sm">
<div>
<h1 className="text-2xl font-bold">PocketVeto</h1>
<p className="text-muted-foreground text-sm mt-1">Sign in to your account</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="email">Email</label>
<input
id="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="password">Password</label>
<input
id="password"
type="password"
required
autoComplete="current-password"
value={password}
onChange={(e) => 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"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "Signing in..." : "Sign in"}
</button>
</form>
<p className="text-sm text-center text-muted-foreground">
No account?{" "}
<Link href="/register" className="text-primary hover:underline">
Register
</Link>
</p>
</div>
</div>
);
}

View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="w-full max-w-sm space-y-6 p-8 border rounded-lg bg-card shadow-sm">
<div>
<h1 className="text-2xl font-bold">PocketVeto</h1>
<p className="text-muted-foreground text-sm mt-1">Create your account</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="email">Email</label>
<input
id="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="password">
Password <span className="text-muted-foreground font-normal">(min 8 chars)</span>
</label>
<input
id="password"
type="password"
required
autoComplete="new-password"
value={password}
onChange={(e) => 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"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "Creating account..." : "Create account"}
</button>
</form>
<p className="text-sm text-center text-muted-foreground">
Already have an account?{" "}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</p>
</div>
</div>
);
}

View File

@@ -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<Record<string, string>>({});
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const testLLM = async () => {
setTesting(true);
@@ -46,17 +82,102 @@ export default function SettingsPage() {
setTaskIds((prev) => ({ ...prev, [name]: result.task_id }));
};
if (isLoading) return <div className="text-center py-20 text-muted-foreground">Loading settings...</div>;
if (settingsLoading) return <div className="text-center py-20 text-muted-foreground">Loading...</div>;
if (!currentUser?.is_admin) {
return (
<div className="text-center py-20 text-muted-foreground">
Admin access required.
</div>
);
}
return (
<div className="space-y-8 max-w-2xl">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Settings className="w-5 h-5" /> Settings
<Settings className="w-5 h-5" /> Admin
</h1>
<p className="text-muted-foreground text-sm mt-1">Configure LLM provider and system settings</p>
<p className="text-muted-foreground text-sm mt-1">Manage users, LLM provider, and system settings</p>
</div>
{/* User Management */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2">
<Users className="w-4 h-4" /> Users
</h2>
{usersLoading ? (
<p className="text-sm text-muted-foreground">Loading users...</p>
) : (
<div className="divide-y divide-border">
{(users ?? []).map((u: AdminUser) => (
<div key={u.id} className="flex items-center justify-between py-3 gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{u.email}</span>
{u.is_admin && (
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded font-medium">
admin
</span>
)}
{u.id === currentUser.id && (
<span className="text-xs text-muted-foreground">(you)</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{u.follow_count} follow{u.follow_count !== 1 ? "s" : ""} ·{" "}
joined {new Date(u.created_at).toLocaleDateString()}
</div>
</div>
{u.id !== currentUser.id && (
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => toggleAdmin.mutate(u.id)}
disabled={toggleAdmin.isPending}
title={u.is_admin ? "Remove admin" : "Make admin"}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
{u.is_admin ? (
<ShieldOff className="w-4 h-4" />
) : (
<ShieldCheck className="w-4 h-4" />
)}
</button>
{confirmDelete === u.id ? (
<div className="flex items-center gap-1">
<button
onClick={() => {
deleteUser.mutate(u.id);
setConfirmDelete(null);
}}
className="text-xs px-2 py-1 bg-destructive text-destructive-foreground rounded hover:bg-destructive/90"
>
Confirm
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-xs px-2 py-1 bg-muted rounded hover:bg-accent"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(u.id)}
title="Delete user"
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-accent transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
)}
</div>
))}
</div>
)}
</section>
{/* LLM Provider */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2">
@@ -99,7 +220,7 @@ export default function SettingsPage() {
<>
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-green-600 dark:text-green-400">
{testResult.provider}/{testResult.summary_preview?.slice(0, 50)}...
{testResult.provider} {testResult.summary_preview?.slice(0, 60)}...
</span>
</>
) : (
@@ -113,7 +234,7 @@ export default function SettingsPage() {
</div>
</section>
{/* Polling Settings */}
{/* Data Sources */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2">
<RefreshCw className="w-4 h-4" /> Data Sources

View File

@@ -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 (
<div className="flex h-screen bg-background">
<Sidebar />
<main className="flex-1 overflow-auto">
<div className="container mx-auto px-6 py-6 max-w-7xl">
{children}
</div>
</main>
</div>
);
}

View File

@@ -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 (
<aside className="w-56 shrink-0 border-r border-border bg-card flex flex-col">
@@ -34,7 +47,7 @@ export function Sidebar() {
</div>
<nav className="flex-1 p-3 space-y-1">
{NAV.map(({ href, label, icon: Icon }) => {
{NAV.filter(({ adminOnly }) => !adminOnly || user?.is_admin).map(({ href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
@@ -54,10 +67,26 @@ export function Sidebar() {
})}
</nav>
<div className="p-3 border-t border-border flex items-center justify-between">
<div className="p-3 border-t border-border space-y-2">
{user && (
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground truncate max-w-[120px]" title={user.email}>
{user.email}
</span>
<button
onClick={handleLogout}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent"
title="Sign out"
>
<LogOut className="w-3.5 h-3.5" />
</button>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Theme</span>
<ThemeToggle />
</div>
</div>
</aside>
);
}

View File

@@ -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<string, unknown>;
created_at: string;
}
interface TokenResponse {
access_token: string;
token_type: string;
user: AuthUser;
}
// Auth
export const authAPI = {
register: (email: string, password: string) =>
apiClient.post<TokenResponse>("/api/auth/register", { email, password }).then((r) => r.data),
login: (email: string, password: string) =>
apiClient.post<TokenResponse>("/api/auth/login", { email, password }).then((r) => r.data),
me: () =>
apiClient.get<AuthUser>("/api/auth/me").then((r) => r.data),
};
// Bills
export const billsAPI = {
list: (params?: Record<string, unknown>) =>
@@ -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<AdminUser[]>("/api/admin/users").then((r) => r.data),
deleteUser: (id: number) =>
apiClient.delete(`/api/admin/users/${id}`),
toggleAdmin: (id: number) =>
apiClient.patch<AdminUser>(`/api/admin/users/${id}/toggle-admin`).then((r) => r.data),
// Tasks
triggerPoll: () =>
apiClient.post("/api/admin/trigger-poll").then((r) => r.data),
triggerMemberSync: () =>

View File

@@ -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<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
setAuth: (token, user) => set({ token, user }),
logout: () => set({ token: null, user: null }),
}),
{ name: "pocketveto-auth" }
)
);