feat(public_page): allow unauthenticated browsing with auth-gated interactivity

- Add get_optional_user dependency; dashboard returns guest-safe payload
- AuthGuard only redirects /following and /notifications for guests
- Sidebar hides auth-required nav items and shows Sign In/Register for guests
- Dashboard shows trending bills as "Most Popular" for unauthenticated visitors
- FollowButton opens AuthModal instead of acting when not signed in
- Members page pins followed members at the top for quick unfollowing
- useFollows skips API call and invalidates dashboard on follow/unfollow

Authored-By: Jack Levy
This commit is contained in:
Jack Levy
2026-03-01 15:54:54 -05:00
parent 73881b2404
commit ddd74a02d5
9 changed files with 314 additions and 128 deletions

View File

@@ -6,7 +6,7 @@ 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.core.dependencies import get_optional_user
from app.database import get_db
from app.models import Bill, BillBrief, Follow, TrendScore
from app.models.user import User
@@ -15,11 +15,38 @@ from app.schemas.schemas import BillSchema
router = APIRouter()
async def _get_trending(db: AsyncSession) -> list[dict]:
trending_result = await db.execute(
select(Bill)
.options(selectinload(Bill.sponsor), selectinload(Bill.briefs), selectinload(Bill.trend_scores))
.join(TrendScore, Bill.bill_id == TrendScore.bill_id)
.where(TrendScore.score_date >= date.today() - timedelta(days=1))
.order_by(desc(TrendScore.composite_score))
.limit(10)
)
trending_bills = trending_result.scalars().unique().all()
return [_serialize_bill(b) for b in trending_bills]
def _serialize_bill(bill: Bill) -> dict:
b = BillSchema.model_validate(bill)
if bill.briefs:
b.latest_brief = bill.briefs[0]
if bill.trend_scores:
b.latest_trend = bill.trend_scores[0]
return b.model_dump()
@router.get("")
async def get_dashboard(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
current_user: User | None = Depends(get_optional_user),
):
trending = await _get_trending(db)
if current_user is None:
return {"feed": [], "trending": trending, "follows": {"bills": 0, "members": 0, "topics": 0}}
# Load follows for the current user
follows_result = await db.execute(
select(Follow).where(Follow.user_id == current_user.id)
@@ -79,28 +106,9 @@ async def get_dashboard(
# Sort feed by latest action date
feed_bills.sort(key=lambda b: b.latest_action_date or date.min, reverse=True)
# 4. Trending bills (top 10 by composite score today)
trending_result = await db.execute(
select(Bill)
.options(selectinload(Bill.sponsor), selectinload(Bill.briefs), selectinload(Bill.trend_scores))
.join(TrendScore, Bill.bill_id == TrendScore.bill_id)
.where(TrendScore.score_date >= date.today() - timedelta(days=1))
.order_by(desc(TrendScore.composite_score))
.limit(10)
)
trending_bills = trending_result.scalars().unique().all()
def serialize_bill(bill: Bill) -> dict:
b = BillSchema.model_validate(bill)
if bill.briefs:
b.latest_brief = bill.briefs[0]
if bill.trend_scores:
b.latest_trend = bill.trend_scores[0]
return b.model_dump()
return {
"feed": [serialize_bill(b) for b in feed_bills[:50]],
"trending": [serialize_bill(b) for b in trending_bills],
"feed": [_serialize_bill(b) for b in feed_bills[:50]],
"trending": trending,
"follows": {
"bills": len(followed_bill_ids),
"members": len(followed_member_ids),

View File

@@ -8,6 +8,7 @@ from app.database import get_db
from app.models.user import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
async def get_current_user(
@@ -30,6 +31,19 @@ async def get_current_user(
return user
async def get_optional_user(
token: str | None = Depends(oauth2_scheme_optional),
db: AsyncSession = Depends(get_db),
) -> User | None:
if not token:
return None
try:
user_id = decode_token(token)
return await db.get(User, user_id)
except Exception:
return None
async def get_current_admin(
current_user: User = Depends(get_current_user),
) -> User:

View File

@@ -2,11 +2,37 @@
import { useState } from "react";
import Link from "next/link";
import { Search } from "lucide-react";
import { useMembers } from "@/lib/hooks/useMembers";
import { Search, Heart } from "lucide-react";
import { useMembers, useMember } from "@/lib/hooks/useMembers";
import { useFollows } from "@/lib/hooks/useFollows";
import { useAuthStore } from "@/stores/authStore";
import { FollowButton } from "@/components/shared/FollowButton";
import { cn, partyBadgeColor } from "@/lib/utils";
function FollowedMemberRow({ bioguideId }: { bioguideId: string }) {
const { data: member } = useMember(bioguideId);
if (!member) return null;
return (
<div className="bg-card border border-border rounded-lg p-4 flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<Link href={`/members/${member.bioguide_id}`} className="font-medium text-sm hover:text-primary transition-colors">
{member.name}
</Link>
<div className="flex items-center gap-1.5 mt-1">
{member.party && (
<span className={cn("px-1.5 py-0.5 rounded text-xs font-medium", partyBadgeColor(member.party))}>
{member.party}
</span>
)}
{member.state && <span className="text-xs text-muted-foreground">{member.state}</span>}
{member.chamber && <span className="text-xs text-muted-foreground">{member.chamber}</span>}
</div>
</div>
<FollowButton type="member" value={member.bioguide_id} />
</div>
);
}
export default function MembersPage() {
const [q, setQ] = useState("");
const [chamber, setChamber] = useState("");
@@ -18,6 +44,10 @@ export default function MembersPage() {
page, per_page: 50,
});
const token = useAuthStore((s) => s.token);
const { data: follows } = useFollows();
const followedMemberIds = follows?.filter((f) => f.follow_type === "member").map((f) => f.follow_value) ?? [];
return (
<div className="space-y-6">
<div>
@@ -51,6 +81,21 @@ export default function MembersPage() {
</select>
</div>
{token && followedMemberIds.length > 0 && (
<div className="space-y-3">
<h2 className="font-semibold text-sm flex items-center gap-2">
<Heart className="w-4 h-4 text-red-500 fill-red-500" />
Following ({followedMemberIds.length})
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{followedMemberIds.map((id) => (
<FollowedMemberRow key={id} bioguideId={id} />
))}
</div>
<div className="border-t border-border pt-2" />
</div>
)}
{isLoading ? (
<div className="text-center py-20 text-muted-foreground">Loading members...</div>
) : (

View File

@@ -1,14 +1,17 @@
"use client";
import { TrendingUp, BookOpen, RefreshCw } from "lucide-react";
import { TrendingUp, BookOpen, Flame, RefreshCw } from "lucide-react";
import Link from "next/link";
import { useDashboard } from "@/lib/hooks/useDashboard";
import { BillCard } from "@/components/shared/BillCard";
import { adminAPI } from "@/lib/api";
import { useState } from "react";
import { useAuthStore } from "@/stores/authStore";
export default function DashboardPage() {
const { data, isLoading, refetch } = useDashboard();
const [polling, setPolling] = useState(false);
const token = useAuthStore((s) => s.token);
const triggerPoll = async () => {
setPolling(true);
@@ -43,15 +46,38 @@ export default function DashboardPage() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-8">
<div className="md:col-span-2 space-y-4">
<h2 className="font-semibold flex items-center gap-2">
<BookOpen className="w-4 h-4" />
Your Feed
{data?.follows && (
{token ? <BookOpen className="w-4 h-4" /> : <Flame className="w-4 h-4" />}
{token ? "Your Feed" : "Most Popular"}
{token && data?.follows && (
<span className="text-xs text-muted-foreground font-normal">
({data.follows.bills} bills · {data.follows.members} members · {data.follows.topics} topics)
</span>
)}
</h2>
{!data?.feed?.length ? (
{!token ? (
<div className="space-y-3">
<div className="rounded-lg border border-dashed px-4 py-3 flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground">
Sign in to personalise this feed with bills and members you follow.
</p>
<div className="flex gap-2 shrink-0">
<Link href="/register" className="px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors">
Register
</Link>
<Link href="/login" className="px-3 py-1.5 text-xs font-medium rounded-md border border-border text-foreground hover:bg-accent transition-colors">
Sign in
</Link>
</div>
</div>
{data?.trending?.length ? (
<div className="space-y-3">
{data.trending.map((bill) => (
<BillCard key={bill.bill_id} bill={bill} />
))}
</div>
) : null}
</div>
) : !data?.feed?.length ? (
<div className="bg-card border border-border rounded-lg p-8 text-center text-muted-foreground">
<p className="text-sm">Your feed is empty.</p>
<p className="text-xs mt-1">Follow bills, members, or topics to see activity here.</p>

View File

@@ -6,7 +6,8 @@ import { useAuthStore } from "@/stores/authStore";
import { Sidebar } from "./Sidebar";
import { MobileHeader } from "./MobileHeader";
const PUBLIC_PATHS = ["/login", "/register"];
const NO_SHELL_PATHS = ["/login", "/register"];
const AUTH_REQUIRED = ["/following", "/notifications"];
export function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
@@ -22,22 +23,24 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (!hydrated) return;
if (!token && !PUBLIC_PATHS.includes(pathname)) {
const needsAuth = AUTH_REQUIRED.some((p) => pathname.startsWith(p));
if (!token && needsAuth) {
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)) {
// Login/register pages render without the app shell
if (NO_SHELL_PATHS.includes(pathname)) {
return <>{children}</>;
}
// Not logged in yet — blank while redirecting
if (!token) return null;
// Auth-required pages: blank while redirecting
const needsAuth = AUTH_REQUIRED.some((p) => pathname.startsWith(p));
if (!token && needsAuth) return null;
// Authenticated: render the full app shell
// Authenticated or guest browsing: render the full app shell
return (
<div className="flex h-screen bg-background">
{/* Desktop sidebar — hidden on mobile */}

View File

@@ -0,0 +1,39 @@
"use client";
import Link from "next/link";
import * as Dialog from "@radix-ui/react-dialog";
import { X } from "lucide-react";
interface AuthModalProps {
open: boolean;
onClose: () => void;
}
export function AuthModal({ open, onClose }: AuthModalProps) {
return (
<Dialog.Root open={open} onOpenChange={onClose}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 w-full max-w-sm bg-card border border-border rounded-lg shadow-lg p-6 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95">
<Dialog.Title className="text-base font-semibold">
Sign in to follow bills
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-muted-foreground">
Create a free account to follow bills, set Pocket Veto or Pocket Boost modes, and receive alerts.
</Dialog.Description>
<div className="flex gap-3 mt-4">
<Link href="/register" onClick={onClose} className="flex-1 px-4 py-2 text-sm font-medium text-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors">
Create account
</Link>
<Link href="/login" onClick={onClose} className="flex-1 px-4 py-2 text-sm font-medium text-center rounded-md border border-border text-foreground hover:bg-accent transition-colors">
Sign in
</Link>
</div>
<Dialog.Close className="absolute right-4 top-4 p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors">
<X className="w-4 h-4" />
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -3,6 +3,8 @@
import { useRef, useEffect, useState } from "react";
import { Heart, Shield, Zap, ChevronDown } from "lucide-react";
import { useAddFollow, useIsFollowing, useRemoveFollow, useUpdateFollowMode } from "@/lib/hooks/useFollows";
import { useAuthStore } from "@/stores/authStore";
import { AuthModal } from "./AuthModal";
import { cn } from "@/lib/utils";
const MODES = {
@@ -37,9 +39,16 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
const add = useAddFollow();
const remove = useRemoveFollow();
const updateMode = useUpdateFollowMode();
const token = useAuthStore((s) => s.token);
const [open, setOpen] = useState(false);
const [showAuthModal, setShowAuthModal] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
function requireAuth(action: () => void) {
if (!token) { setShowAuthModal(true); return; }
action();
}
const isFollowing = !!existing;
const currentMode: FollowMode = (existing?.follow_mode as FollowMode) ?? "neutral";
const isPending = add.isPending || remove.isPending || updateMode.isPending;
@@ -59,13 +68,16 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
// Simple toggle for non-bill follows
if (!supportsModes) {
const handleClick = () => {
requireAuth(() => {
if (isFollowing && existing) {
remove.mutate(existing.id);
} else {
add.mutate({ type, value });
}
});
};
return (
<>
<button
onClick={handleClick}
disabled={isPending}
@@ -79,20 +91,25 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
<Heart className={cn("w-3.5 h-3.5", isFollowing && "fill-current")} />
{isFollowing ? "Unfollow" : label || "Follow"}
</button>
<AuthModal open={showAuthModal} onClose={() => setShowAuthModal(false)} />
</>
);
}
// Mode-aware follow button for bills
if (!isFollowing) {
return (
<>
<button
onClick={() => add.mutate({ type, value })}
onClick={() => requireAuth(() => add.mutate({ type, value }))}
disabled={isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Heart className="w-3.5 h-3.5" />
{label || "Follow"}
</button>
<AuthModal open={showAuthModal} onClose={() => setShowAuthModal(false)} />
</>
);
}
@@ -100,13 +117,17 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
const otherModes = (Object.keys(MODES) as FollowMode[]).filter((m) => m !== currentMode);
const switchMode = (mode: FollowMode) => {
requireAuth(() => {
if (existing) updateMode.mutate({ id: existing.id, mode });
setOpen(false);
});
};
const handleUnfollow = () => {
requireAuth(() => {
if (existing) remove.mutate(existing.id);
setOpen(false);
});
};
const modeDescriptions: Record<FollowMode, string> = {
@@ -116,6 +137,7 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
};
return (
<>
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setOpen((v) => !v)}
@@ -160,5 +182,7 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
</div>
)}
</div>
<AuthModal open={showAuthModal} onClose={() => setShowAuthModal(false)} />
</>
);
}

View File

@@ -20,13 +20,13 @@ import { ThemeToggle } from "./ThemeToggle";
import { useAuthStore } from "@/stores/authStore";
const NAV = [
{ 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: "/notifications", label: "Notifications", icon: Bell, adminOnly: false },
{ href: "/settings", label: "Admin", icon: Settings, adminOnly: true },
{ href: "/", label: "Dashboard", icon: LayoutDashboard, adminOnly: false, requiresAuth: false },
{ href: "/bills", label: "Bills", icon: FileText, adminOnly: false, requiresAuth: false },
{ href: "/members", label: "Members", icon: Users, adminOnly: false, requiresAuth: false },
{ href: "/topics", label: "Topics", icon: Tags, adminOnly: false, requiresAuth: false },
{ href: "/following", label: "Following", icon: Heart, adminOnly: false, requiresAuth: true },
{ href: "/notifications", label: "Notifications", icon: Bell, adminOnly: false, requiresAuth: true },
{ href: "/settings", label: "Admin", icon: Settings, adminOnly: true, requiresAuth: false },
];
export function Sidebar({ onClose }: { onClose?: () => void }) {
@@ -34,6 +34,7 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
const router = useRouter();
const qc = useQueryClient();
const user = useAuthStore((s) => s.user);
const token = useAuthStore((s) => s.token);
const logout = useAuthStore((s) => s.logout);
function handleLogout() {
@@ -55,7 +56,11 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
</div>
<nav className="flex-1 p-3 space-y-1">
{NAV.filter(({ adminOnly }) => !adminOnly || user?.is_admin).map(({ href, label, icon: Icon }) => {
{NAV.filter(({ adminOnly, requiresAuth }) => {
if (adminOnly && !user?.is_admin) return false;
if (requiresAuth && !token) return false;
return true;
}).map(({ href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
@@ -77,6 +82,8 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
</nav>
<div className="p-3 border-t border-border space-y-2">
{token ? (
<>
{user && (
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground truncate max-w-[120px]" title={user.email}>
@@ -91,6 +98,17 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
</button>
</div>
)}
</>
) : (
<div className="flex flex-col gap-2">
<Link href="/register" onClick={onClose} className="w-full px-3 py-1.5 text-sm font-medium text-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors">
Register
</Link>
<Link href="/login" onClick={onClose} className="w-full px-3 py-1.5 text-sm font-medium text-center rounded-md border border-border text-foreground hover:bg-accent transition-colors">
Sign in
</Link>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Theme</span>
<ThemeToggle />

View File

@@ -1,11 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { followsAPI } from "../api";
import { useAuthStore } from "@/stores/authStore";
export function useFollows() {
const token = useAuthStore((s) => s.token);
return useQuery({
queryKey: ["follows"],
queryFn: () => followsAPI.list(),
staleTime: 30 * 1000,
enabled: !!token,
});
}
@@ -14,7 +17,10 @@ export function useAddFollow() {
return useMutation({
mutationFn: ({ type, value }: { type: string; value: string }) =>
followsAPI.add(type, value),
onSuccess: () => qc.invalidateQueries({ queryKey: ["follows"] }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["follows"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
@@ -22,7 +28,10 @@ export function useRemoveFollow() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => followsAPI.remove(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["follows"] }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["follows"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}