diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 632d3bf87..98a515d0a 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,21 +1,102 @@ -import { apiClient } from '../services/apiClient'; +import { apiClient, setAuthToken, ApiError } from '../services/apiClient'; import type { User } from '../types/user'; +/** + * Authentication tokens issued by the backend after a successful OAuth or credential login. + */ export interface AuthTokens { + /** JWT access token used to authenticate API requests. */ access_token: string; + /** Long-lived token used to obtain a new access token when it expires. */ refresh_token: string; + /** Token type (typically "Bearer"). */ token_type: string; } +/** + * Full response returned by the GitHub OAuth callback endpoint. + * Contains auth tokens plus the authenticated user's profile. + */ export interface GitHubCallbackResponse extends AuthTokens { + /** Authenticated user profile. */ user: User; } +/** + * Build the GitHub OAuth authorize URL directly using the configured client ID. + * Used as fallback when the backend /api/auth/github/authorize endpoint is unavailable. + * + * @param clientId - GitHub OAuth App client ID (defaults to VITE_GITHUB_CLIENT_ID) + * @param redirectUri - Callback URL (defaults to /github/callback) + * @param state - CSRF state token for security + */ +export function buildGitHubAuthorizeUrl( + clientId?: string, + redirectUri?: string, + state?: string, +): string { + const cid = clientId ?? import.meta.env?.VITE_GITHUB_CLIENT_ID ?? ''; + if (!cid) { + throw new Error('Missing GitHub OAuth client configuration: VITE_GITHUB_CLIENT_ID is not set'); + } + const uri = redirectUri ?? `${window.location.origin}/github/callback`; + const csrf = state ?? generateState(); + // Persist CSRF state for validation in callback + sessionStorage.setItem('oauth_state', csrf); + const params = new URLSearchParams({ + client_id: cid, + redirect_uri: uri, + scope: 'read:user user:email', + state: csrf, + }); + return `https://github.com/login/oauth/authorize?${params.toString()}`; +} + +/** Generate a random state token for CSRF protection. */ +function generateState(): string { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return Array.from(array, (b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Retrieve the GitHub OAuth authorize URL from the backend. + * Falls back to building the URL directly if the backend is unavailable. + */ export async function getGitHubAuthorizeUrl(): Promise { - const data = await apiClient<{ authorize_url: string }>('/api/auth/github/authorize'); - return data.authorize_url; + try { + const data = await apiClient<{ authorize_url: string }>('/api/auth/github/authorize', { + retries: 0, // Fail fast — don't retry on 404 + }); + if (!data.authorize_url || typeof data.authorize_url !== 'string') { + throw new Error('Invalid authorize_url from server'); + } + return data.authorize_url; + } catch (err) { + // Only fall back for genuine network errors (TypeError) or HTTP 5xx/503/504/404 + const isNetworkError = err instanceof TypeError; + const isServerError = err instanceof ApiError && ( + err.status === 404 || + err.status === 503 || + err.status === 504 || + err.status >= 500 + ); + if (isNetworkError || isServerError) { + console.warn('[auth] Backend /api/auth/github/authorize unavailable, using direct OAuth URL:', err); + return buildGitHubAuthorizeUrl(); + } + // For other errors (e.g., invalid response body), rethrow + throw err; + } } +/** + * Exchange a GitHub OAuth authorization code for JWT auth tokens and user info. + * Called by the GitHub callback page after the user approves the OAuth request. + * + * @param code - The authorization code returned by GitHub in the callback URL + * @param state - The CSRF state token returned by GitHub (optional, validated server-side) + */ export async function exchangeGitHubCode(code: string, state?: string): Promise { return apiClient('/api/auth/github', { method: 'POST', @@ -23,13 +104,46 @@ export async function exchangeGitHubCode(code: string, state?: string): Promise< }); } +/** + * Fetch the currently authenticated user's profile from the backend. + * Requires a valid access token in the Authorization header. + */ export async function getMe(): Promise { return apiClient('/api/auth/me'); } +/** + * Refresh the access token using a valid refresh token. + * Returns a new set of access + refresh tokens on success. + * + * @param refreshToken - The refresh token previously issued during login + */ export async function refreshTokens(refreshToken: string): Promise { return apiClient('/api/auth/refresh', { method: 'POST', body: { refresh_token: refreshToken }, }); } + +/** + * Clear all authentication data from localStorage and memory. + */ +export function clearAuthStorage(): void { + localStorage.removeItem('sf_access_token'); + localStorage.removeItem('sf_refresh_token'); + localStorage.removeItem('sf_user'); + setAuthToken(null); +} + +/** + * Revoke the current session (server-side logout). + * Clears local tokens afterwards regardless of server response. + */ +export async function logout(): Promise { + try { + await apiClient('/api/auth/logout', { method: 'POST' }); + clearAuthStorage(); + } finally { + clearAuthStorage(); + } +} diff --git a/frontend/src/components/bounty/BountyGrid.tsx b/frontend/src/components/bounty/BountyGrid.tsx index 7709ab94c..a9dfcfeaf 100644 --- a/frontend/src/components/bounty/BountyGrid.tsx +++ b/frontend/src/components/bounty/BountyGrid.tsx @@ -1,16 +1,32 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { motion } from 'framer-motion'; -import { ChevronDown, Loader2, Plus } from 'lucide-react'; +import { ChevronDown, Loader2, Plus, Search, X } from 'lucide-react'; import { BountyCard } from './BountyCard'; import { useInfiniteBounties } from '../../hooks/useBounties'; import { staggerContainer, staggerItem } from '../../lib/animations'; +/** Skill filter options available in the bounty grid filter bar. */ const FILTER_SKILLS = ['All', 'TypeScript', 'Rust', 'Solidity', 'Python', 'Go', 'JavaScript']; +/** + * BountyGrid — renders the full bounty listing page section. + * Includes a skill/language filter bar, status dropdown, search input, + * client-side debounced search, and a paginated card grid with infinite scroll. + */ export function BountyGrid() { const [activeSkill, setActiveSkill] = useState('All'); const [statusFilter, setStatusFilter] = useState('open'); + const [searchInput, setSearchInput] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + + // Debounce search input by 300ms + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(searchInput.trim().toLowerCase()); + }, 300); + return () => clearTimeout(timer); + }, [searchInput]); const params = { status: statusFilter, @@ -22,13 +38,44 @@ export function BountyGrid() { const allBounties = data?.pages.flatMap((p) => p.items) ?? []; + // Client-side search filter across title, description, and skills + const filteredBounties = useMemo(() => { + if (!debouncedQuery) return allBounties; + return allBounties.filter( + (bounty) => + bounty.title.toLowerCase().includes(debouncedQuery) || + (bounty.description ?? '').toLowerCase().includes(debouncedQuery) || + bounty.skills.some((skill) => skill.toLowerCase().includes(debouncedQuery)), + ); + }, [allBounties, debouncedQuery]); + return (
{/* Header row */}

Open Bounties

-
+
+ {/* Search bar */} +
+ + setSearchInput(e.target.value)} + className="pl-9 pr-8 py-1.5 bg-forge-800 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:border-emerald outline-none transition-colors duration-150 w-48 focus:w-64" + /> + {searchInput && ( + + )} +
-

No bounties found

+

+ {debouncedQuery ? 'No bounties match your search' : 'No bounties found'} +

- {activeSkill !== 'All' ? `Try a different language filter.` : 'Check back soon for new bounties.'} + {debouncedQuery + ? 'Try a different search term or clear the filter.' + : activeSkill !== 'All' + ? 'Try a different language filter.' + : 'Check back soon for new bounties.'}

)} {/* Bounty grid */} - {!isLoading && allBounties.length > 0 && ( + {!isLoading && filteredBounties.length > 0 && ( - {allBounties.map((bounty) => ( + {filteredBounties.map((bounty) => ( diff --git a/frontend/src/components/layout/Navbar.tsx b/frontend/src/components/layout/Navbar.tsx index e4ec31b03..406d32832 100644 --- a/frontend/src/components/layout/Navbar.tsx +++ b/frontend/src/components/layout/Navbar.tsx @@ -6,18 +6,25 @@ import { useAuth } from '../../hooks/useAuth'; import { useStats } from '../../hooks/useStats'; import { getGitHubAuthorizeUrl } from '../../api/auth'; +/** GitHub mark SVG icon used in the sign-in button. */ const GitHubIcon = () => ( ); +/** Static navigation links rendered in the desktop navbar. */ const NAV_LINKS = [ { label: 'Bounties', to: '/bounties' }, { label: 'Leaderboard', to: '/leaderboard' }, { label: 'How It Works', to: '/how-it-works' }, ]; +/** + * Navbar — the top navigation bar, fixed above content. + * Shows logo, nav links, live bounty count badge, and auth controls + * (GitHub sign-in button or user avatar dropdown). + */ export function Navbar() { const location = useLocation(); const navigate = useNavigate(); @@ -33,16 +40,46 @@ export function Navbar() { return () => window.removeEventListener('scroll', onScroll); }, []); + /** + * Initiate GitHub OAuth sign-in. + * Fetches the authorize URL from the backend (falling back to a direct URL if the backend is unavailable), + * validates that it is a same-origin HTTPS URL, then navigates the browser to GitHub. + * Shows an alert on failure. + */ const handleGitHubSignIn = async () => { try { const url = await getGitHubAuthorizeUrl(); + // Validate that we got a valid HTTPS URL with same-origin + if (!url) { + console.error('[Navbar] GitHub sign-in failed: empty authorization URL'); + return; + } + let urlObj: URL; + try { + urlObj = new URL(url, window.location.origin); + } catch { + console.error('[Navbar] GitHub sign-in failed: invalid URL format', url); + return; + } + if (urlObj.protocol !== 'https:') { + console.error('[Navbar] GitHub sign-in failed: URL must use HTTPS', url); + return; + } + if (urlObj.origin !== window.location.origin) { + console.error('[Navbar] GitHub sign-in failed: URL must be same-origin', url); + return; + } window.location.href = url; - } catch { - // Fallback: direct to backend authorize endpoint - window.location.href = '/api/auth/github/authorize'; + } catch (err) { + console.error('[Navbar] GitHub sign-in failed:', err); + window.alert('Sign-in failed. Please try again or contact support.'); } }; + /** + * Returns true when the given path matches the current browser location. + * Root path "/" matches exactly; all other paths match if the pathname starts with the path. + */ const isActive = (to: string) => { if (to === '/') return location.pathname === '/'; return location.pathname.startsWith(to); diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 760003c44..f556b1c1c 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -4,6 +4,7 @@ */ import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; import { setAuthToken, setOnAuthExpired } from '../services/apiClient'; +import { logout as apiLogout } from '../api/auth'; export interface AuthUser { id: string; @@ -36,6 +37,13 @@ const TOKEN_KEY = 'sf_access_token'; const REFRESH_KEY = 'sf_refresh_token'; const USER_KEY = 'sf_user'; +/** + * AuthContext provider — manages JWT token + current user state, persisted to localStorage. + * Provides login (token storage), logout, and the authenticated user object. + * Wire this into the app root to make auth state available via `useAuthContext`. + * + * @param children - The React subtree that will have access to the auth context + */ export function AuthProvider({ children }: { children: React.ReactNode }) { const [state, setState] = useState({ user: null, @@ -75,11 +83,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setState({ user, accessToken, refreshToken, isAuthenticated: true, isLoading: false }); }, []); - const logout = useCallback(() => { - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(REFRESH_KEY); - localStorage.removeItem(USER_KEY); - setAuthToken(null); + const logout = useCallback(async () => { + await apiLogout(); setState({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false, isLoading: false }); }, []); @@ -105,6 +110,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { ); } +/** + * Hook to access the auth context value. + * Throws if called outside of an `AuthProvider` tree. + */ export function useAuthContext(): AuthContextValue { const ctx = useContext(AuthContext); if (!ctx) throw new Error('useAuthContext must be used inside AuthProvider'); diff --git a/frontend/src/lib/animations.ts b/frontend/src/lib/animations.ts new file mode 100644 index 000000000..035a22884 --- /dev/null +++ b/frontend/src/lib/animations.ts @@ -0,0 +1,84 @@ +import type { Variants } from 'framer-motion'; + +/** + * Page-level transition variants for framer-motion. + * Fades and slides content in/out when navigating between routes. + */ +export const pageTransition: Variants = { + initial: { opacity: 0, y: 8 }, + animate: { opacity: 1, y: 0, transition: { duration: 0.35, ease: 'easeOut' } }, + exit: { opacity: 0, y: -8, transition: { duration: 0.2, ease: 'easeIn' } }, +}; + +/** + * Stagger container variants — apply to a parent motion.div to stagger + * the animate transitions of its children. + */ +export const staggerContainer: Variants = { + initial: {}, + animate: { + transition: { staggerChildren: 0.06 }, + }, +}; + +/** + * Single item variant for use within a staggerContainer. + * Children of the container should use this as their `variants` prop. + */ +export const staggerItem: Variants = { + initial: { opacity: 0, y: 12 }, + animate: { + opacity: 1, + y: 0, + transition: { duration: 0.3, ease: 'easeOut' }, + }, +}; + +/** + * Card hover effect — scale + glow shadow on hover, rest state otherwise. + * Apply to a motion.div wrapping a card element. + */ +export const cardHover: Variants = { + rest: { scale: 1, boxShadow: '0 0 0 0 transparent' }, + hover: { + scale: 1.02, + boxShadow: '0 0 20px rgba(0,230,118,0.15)', + transition: { duration: 0.2, ease: 'easeOut' }, + }, +}; + +/** + * Simple fade-in with subtle upward slide. + * Suitable for onboarding screens, modals, or inline content reveals. + */ +export const fadeIn: Variants = { + initial: { opacity: 0, y: 10 }, + animate: { + opacity: 1, + y: 0, + transition: { duration: 0.4, ease: 'easeOut' }, + }, +}; + +/** + * Slide-in from the right animation. + * Use for notifications, drawers, or content entering from the side. + */ +export const slideInRight: Variants = { + initial: { opacity: 0, x: 30 }, + animate: { + opacity: 1, + x: 0, + transition: { duration: 0.35, ease: 'easeOut' }, + }, +}; + +/** + * Button hover/tap micro-interaction — subtle scale up on hover, + * scale down on tap. Apply to a motion.button element. + */ +export const buttonHover: Variants = { + rest: { scale: 1 }, + hover: { scale: 1.04, transition: { duration: 0.15, ease: 'easeOut' } }, + tap: { scale: 0.97, transition: { duration: 0.1, ease: 'easeIn' } }, +}; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 000000000..369f6484b --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,84 @@ +/** + * Mapping of programming language names to their canonical brand colors. + * Used for skill/tag badges throughout the UI. + */ +export const LANG_COLORS: Record = { + TypeScript: '#3178C6', + JavaScript: '#F7DF1E', + Python: '#3776AB', + Rust: '#CE422B', + Go: '#00ADD8', + Solidity: '#363636', + Ruby: '#CC342D', + Java: '#B07219', + 'C++': '#F34B7D', + C: '#555555', + 'C#': '#178600', + Swift: '#F05138', + Kotlin: '#A97BFF', + PHP: '#4F5D95', + Dart: '#00B4AB', + Elixir: '#6E4A7E', + Haskell: '#5E5086', + Lua: '#000080', + Shell: '#89E051', + Zig: '#EC915C', +}; + +/** + * Format a numeric token amount into a human-readable currency string. + * USDC amounts are prefixed with "$" and show "USDC" explicitly; + * other tokens display the raw amount followed by the token symbol. + * + * @param amount - The numeric amount of the token + * @param token - The token symbol (e.g. "USDC", "SOL") + */ +export function formatCurrency(amount: number, token: string): string { + if (token === 'USDC') { + return `$${amount.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} USDC`; + } + return `${amount.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} ${token}`; +} + +/** + * Returns a human-readable string describing the time remaining until a deadline. + * Returns "Expired" if the deadline has already passed. + * + * @param deadline - ISO-8601 date string or any parseable date string + */ +export function timeLeft(deadline: string): string { + const now = Date.now(); + const deadlineMs = new Date(deadline).getTime(); + const diff = deadlineMs - now; + + if (diff <= 0) return 'Expired'; + + const minutes = Math.floor(diff / 60_000); + const hours = Math.floor(diff / 3_600_000); + const days = Math.floor(diff / 86_400_000); + + if (days > 0) return `${days}d ${hours % 24}h`; + if (hours > 0) return `${hours}h ${minutes % 60}m`; + return `${minutes}m`; +} + +/** + * Returns a human-readable "time ago" string for a past date. + * e.g. "2d ago", "5h ago", "just now". + * + * @param dateStr - ISO-8601 date string or any parseable date string + */ +export function timeAgo(dateStr: string): string { + const now = Date.now(); + const dateMs = new Date(dateStr).getTime(); + const diff = now - dateMs; + + const minutes = Math.floor(diff / 60_000); + const hours = Math.floor(diff / 3_600_000); + const days = Math.floor(diff / 86_400_000); + + if (days > 0) return `${days}d ago`; + if (hours > 0) return `${hours}h ago`; + if (minutes > 0) return `${minutes}m ago`; + return 'just now'; +} diff --git a/frontend/src/pages/GitHubCallbackPage.tsx b/frontend/src/pages/GitHubCallbackPage.tsx index de11ffaa1..e223a495d 100644 --- a/frontend/src/pages/GitHubCallbackPage.tsx +++ b/frontend/src/pages/GitHubCallbackPage.tsx @@ -6,6 +6,11 @@ import { exchangeGitHubCode } from '../api/auth'; import { setAuthToken } from '../services/apiClient'; import { fadeIn } from '../lib/animations'; +/** + * GitHubCallbackPage — handles the OAuth callback from GitHub. + * Exchanges the authorization code for JWT tokens, validates CSRF state, + * stores the session, and redirects to the home page. + */ export function GitHubCallbackPage() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); @@ -25,6 +30,16 @@ export function GitHubCallbackPage() { return; } + // Validate CSRF state + const storedState = sessionStorage.getItem('oauth_state'); + if (state !== storedState) { + console.error('[GitHubCallback] CSRF state mismatch — possible attack'); + sessionStorage.removeItem('oauth_state'); + navigate('/', { replace: true }); + return; + } + sessionStorage.removeItem('oauth_state'); + exchangeGitHubCode(code, state ?? undefined) .then((response) => { // Store tokens + user in auth context