From ddd74a02d58a89a258f2196a53f3cb34a3fbdee3 Mon Sep 17 00:00:00 2001 From: Jack Levy Date: Sun, 1 Mar 2026 15:54:54 -0500 Subject: [PATCH] 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 --- backend/app/api/dashboard.py | 54 ++++--- backend/app/core/dependencies.py | 14 ++ frontend/app/members/page.tsx | 49 +++++- frontend/app/page.tsx | 36 ++++- frontend/components/shared/AuthGuard.tsx | 17 +- frontend/components/shared/AuthModal.tsx | 39 +++++ frontend/components/shared/FollowButton.tsx | 162 +++++++++++--------- frontend/components/shared/Sidebar.tsx | 58 ++++--- frontend/lib/hooks/useFollows.ts | 13 +- 9 files changed, 314 insertions(+), 128 deletions(-) create mode 100644 frontend/components/shared/AuthModal.tsx diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py index 35b4a05..35cb9ff 100644 --- a/backend/app/api/dashboard.py +++ b/backend/app/api/dashboard.py @@ -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), diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py index 8e0d7fe..0071141 100644 --- a/backend/app/core/dependencies.py +++ b/backend/app/core/dependencies.py @@ -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: diff --git a/frontend/app/members/page.tsx b/frontend/app/members/page.tsx index 858ee1d..fc2add7 100644 --- a/frontend/app/members/page.tsx +++ b/frontend/app/members/page.tsx @@ -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 ( +
+
+ + {member.name} + +
+ {member.party && ( + + {member.party} + + )} + {member.state && {member.state}} + {member.chamber && {member.chamber}} +
+
+ +
+ ); +} + 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 (
@@ -51,6 +81,21 @@ export default function MembersPage() {
+ {token && followedMemberIds.length > 0 && ( +
+

+ + Following ({followedMemberIds.length}) +

+
+ {followedMemberIds.map((id) => ( + + ))} +
+
+
+ )} + {isLoading ? (
Loading members...
) : ( diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 698179a..e880902 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -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() {

- - Your Feed - {data?.follows && ( + {token ? : } + {token ? "Your Feed" : "Most Popular"} + {token && data?.follows && ( ({data.follows.bills} bills · {data.follows.members} members · {data.follows.topics} topics) )}

- {!data?.feed?.length ? ( + {!token ? ( +
+
+

+ Sign in to personalise this feed with bills and members you follow. +

+
+ + Register + + + Sign in + +
+
+ {data?.trending?.length ? ( +
+ {data.trending.map((bill) => ( + + ))} +
+ ) : null} +
+ ) : !data?.feed?.length ? (

Your feed is empty.

Follow bills, members, or topics to see activity here.

diff --git a/frontend/components/shared/AuthGuard.tsx b/frontend/components/shared/AuthGuard.tsx index 181dfd2..4698de9 100644 --- a/frontend/components/shared/AuthGuard.tsx +++ b/frontend/components/shared/AuthGuard.tsx @@ -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 (
{/* Desktop sidebar — hidden on mobile */} diff --git a/frontend/components/shared/AuthModal.tsx b/frontend/components/shared/AuthModal.tsx new file mode 100644 index 0000000..ea73c85 --- /dev/null +++ b/frontend/components/shared/AuthModal.tsx @@ -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 ( + + + + + + Sign in to follow bills + + + Create a free account to follow bills, set Pocket Veto or Pocket Boost modes, and receive alerts. + +
+ + Create account + + + Sign in + +
+ + + +
+
+
+ ); +} diff --git a/frontend/components/shared/FollowButton.tsx b/frontend/components/shared/FollowButton.tsx index 56a1ff8..1d00986 100644 --- a/frontend/components/shared/FollowButton.tsx +++ b/frontend/components/shared/FollowButton.tsx @@ -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(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,40 +68,48 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll // Simple toggle for non-bill follows if (!supportsModes) { const handleClick = () => { - if (isFollowing && existing) { - remove.mutate(existing.id); - } else { - add.mutate({ type, value }); - } + requireAuth(() => { + if (isFollowing && existing) { + remove.mutate(existing.id); + } else { + add.mutate({ type, value }); + } + }); }; return ( - + <> + + setShowAuthModal(false)} /> + ); } // Mode-aware follow button for bills if (!isFollowing) { return ( - + <> + + 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) => { - if (existing) updateMode.mutate({ id: existing.id, mode }); - setOpen(false); + requireAuth(() => { + if (existing) updateMode.mutate({ id: existing.id, mode }); + setOpen(false); + }); }; const handleUnfollow = () => { - if (existing) remove.mutate(existing.id); - setOpen(false); + requireAuth(() => { + if (existing) remove.mutate(existing.id); + setOpen(false); + }); }; const modeDescriptions: Record = { @@ -116,49 +137,52 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll }; return ( -
- + <> +
+ - {open && ( -
- {otherModes.map((mode) => { - const { label: optLabel, icon: OptIcon } = MODES[mode]; - return ( + {open && ( +
+ {otherModes.map((mode) => { + const { label: optLabel, icon: OptIcon } = MODES[mode]; + return ( + + ); + })} +
- ); - })} -
- +
-
- )} -
+ )} +
+ setShowAuthModal(false)} /> + ); } diff --git a/frontend/components/shared/Sidebar.tsx b/frontend/components/shared/Sidebar.tsx index 5cc20d6..f59c107 100644 --- a/frontend/components/shared/Sidebar.tsx +++ b/frontend/components/shared/Sidebar.tsx @@ -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 }) {
- {user && ( -
- - {user.email} - - + {token ? ( + <> + {user && ( +
+ + {user.email} + + +
+ )} + + ) : ( +
+ + Register + + + Sign in +
)}
diff --git a/frontend/lib/hooks/useFollows.ts b/frontend/lib/hooks/useFollows.ts index 40d4054..5b8e8ed 100644 --- a/frontend/lib/hooks/useFollows.ts +++ b/frontend/lib/hooks/useFollows.ts @@ -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"] }); + }, }); }