diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 899c2eb52..9192e4a03 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -10,6 +10,7 @@ from datetime import datetime, timezone from typing import Optional +from urllib.parse import urlencode from fastapi import APIRouter, Depends, HTTPException, status, Header from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials @@ -94,22 +95,23 @@ async def get_github_authorize(state: Optional[str] = None): ) -@router.post("/github", response_model=GitHubOAuthResponse) -async def github_oauth_callback(request: GitHubOAuthRequest): +@router.get("/github/callback", response_model=GitHubOAuthResponse) +async def github_oauth_callback(code: str, state: Optional[str] = None): """ Complete GitHub OAuth flow. + GitHub redirects here after user authorizes. Exchange the authorization code for JWT tokens. Flow: - 1. User is redirected from GitHub with a code + 1. User is redirected from GitHub with a code and state 2. Exchange code for GitHub access token 3. Get user info from GitHub 4. Create/update user in database 5. Return JWT tokens """ try: - result = await auth_service.github_oauth_login(request.code) + result = await auth_service.github_oauth_login(code, state) return result except GitHubOAuthError as e: raise HTTPException( diff --git a/backend/app/main.py b/backend/app/main.py index aeaa3552c..5e2e24bcc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,6 +5,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from app.api.auth import router as auth_router from app.api.contributors import router as contributors_router from app.api.bounties import router as bounties_router from app.api.notifications import router as notifications_router @@ -46,6 +47,7 @@ async def lifespan(app: FastAPI): allow_headers=["Content-Type", "Authorization"], ) +app.include_router(auth_router, prefix="/api", tags=["authentication"]) app.include_router(contributors_router) app.include_router(bounties_router, prefix="/api", tags=["bounties"]) app.include_router(notifications_router, prefix="/api", tags=["notifications"]) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 5da519e41..6c2c253e0 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -39,4 +39,70 @@ class UserResponse(BaseModel): updated_at: datetime class Config: - from_attributes = True \ No newline at end of file + from_attributes = True + + +class GitHubOAuthRequest(BaseModel): + """Request model for GitHub OAuth callback.""" + code: str + state: Optional[str] = None + + +class GitHubOAuthResponse(BaseModel): + """Response model for successful GitHub OAuth login.""" + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int + user: UserResponse + + +class WalletAuthRequest(BaseModel): + """Request model for wallet authentication.""" + wallet_address: str + signature: str + message: str + nonce: Optional[str] = None + + +class WalletAuthResponse(BaseModel): + """Response model for successful wallet authentication.""" + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int + user: UserResponse + + +class LinkWalletRequest(BaseModel): + """Request model for linking a wallet to user account.""" + wallet_address: str + signature: str + message: str + nonce: Optional[str] = None + + +class LinkWalletResponse(BaseModel): + """Response model for wallet linking.""" + success: bool + message: str + user: UserResponse + + +class RefreshTokenRequest(BaseModel): + """Request model for token refresh.""" + refresh_token: str + + +class RefreshTokenResponse(BaseModel): + """Response model for token refresh.""" + access_token: str + token_type: str = "bearer" + expires_in: int + + +class AuthMessageResponse(BaseModel): + """Response model for auth message generation.""" + message: str + nonce: str + expires_at: datetime \ No newline at end of file diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 45fe2beb3..fa260ce89 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -94,9 +94,11 @@ def get_github_authorize_url(state: Optional[str] = None) -> tuple: state = state or secrets.token_urlsafe(32) _oauth_states[state] = {"created_at": datetime.now(timezone.utc), "expires_at": datetime.now(timezone.utc) + timedelta(minutes=10)} + from urllib.parse import urlencode params = {"client_id": GITHUB_CLIENT_ID, "redirect_uri": GITHUB_REDIRECT_URI, "scope": "read:user user:email", "state": state, "response_type": "code"} - return f"https://github.com/login/oauth/authorize?{'&'.join(f'{k}={v}' for k,v in params.items())}", state + encoded_params = urlencode(params) + return f"https://github.com/login/oauth/authorize?{encoded_params}", state def verify_oauth_state(state: str) -> bool: diff --git a/frontend/src/components/ContributorProfile.tsx b/frontend/src/components/ContributorProfile.tsx index f8773b015..588c97c29 100644 --- a/frontend/src/components/ContributorProfile.tsx +++ b/frontend/src/components/ContributorProfile.tsx @@ -1,16 +1,275 @@ 'use client'; -import React from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; -interface ContributorProfileProps { +// ============================================================================ +// Types +// ============================================================================ + +export interface GithubActivity { + date: string; // ISO date + commits: number; + prs: number; + issues: number; +} + +export interface EarningData { + week: string; // e.g. "Week 12" + amount: number; +} + +export interface ContributorProfileProps { username: string; avatarUrl?: string; walletAddress?: string; totalEarned?: number; bountiesCompleted?: number; reputationScore?: number; + /** GitHub activity for the last N weeks */ + githubActivity?: GithubActivity[]; + /** Weekly earning history */ + earningHistory?: EarningData[]; + /** GitHub handle — used to fetch real data if provided */ + githubHandle?: string; +} + +interface GithubStats { + totalCommits: number; + totalPRs: number; + totalIssues: number; + currentStreak: number; + longestStreak: number; + topLanguage?: string; } +// ============================================================================ +// Constants +// ============================================================================ + +const DEFAULT_ACTIVITY: GithubActivity[] = [ + { date: '2026-03-09', commits: 4, prs: 1, issues: 0 }, + { date: '2026-03-16', commits: 7, prs: 2, issues: 1 }, + { date: '2026-03-23', commits: 3, prs: 0, issues: 2 }, + { date: '2026-03-30', commits: 9, prs: 1, issues: 0 }, + { date: '2026-04-06', commits: 5, prs: 3, issues: 1 }, + { date: '2026-04-12', commits: 6, prs: 1, issues: 0 }, +]; + +const DEFAULT_EARNINGS: EarningData[] = [ + { week: 'W9', amount: 0 }, + { week: 'W10', amount: 100000 }, + { week: 'W11', amount: 50000 }, + { week: 'W12', amount: 200000 }, + { week: 'W13', amount: 150000 }, + { week: 'W14', amount: 300000 }, +]; + +const MAX_BAR_HEIGHT = 80; // px + +// ============================================================================ +// Chart Components +// ============================================================================ + +/** + * GitHub Activity Bar Chart + * Shows commits + PRs + issues per week as stacked bars + */ +const ActivityChart: React.FC<{ data: GithubActivity[] }> = ({ data }) => { + const maxVal = Math.max(...data.map((d) => d.commits + d.prs + d.issues), 1); + + return ( +
+

GitHub Activity (Last 6 Weeks)

+
+ {data.map((week, i) => { + const total = week.commits + week.prs + week.issues; + const commitH = Math.round((week.commits / maxVal) * MAX_BAR_HEIGHT); + const prH = Math.round((week.prs / maxVal) * MAX_BAR_HEIGHT); + const issueH = Math.round((week.issues / maxVal) * MAX_BAR_HEIGHT); + const label = week.date.slice(5); // MM-DD + + return ( +
+
+ {commitH > 0 && ( +
+ )} + {prH > 0 && ( +
+ )} + {issueH > 0 && ( +
+ )} +
+ {label} + {/* Hover tooltip */} +
+ {week.commits}C / {week.prs}P / {week.issues}I +
+
+ ); + })} +
+ {/* Legend */} +
+ {[['bg-green-500', 'Commits'], ['bg-purple-500', 'PRs'], ['bg-blue-500', 'Issues']].map(([c, l]) => ( +
+
+ {l} +
+ ))} +
+
+ ); +}; + +/** + * Earning History Line Chart + * Shows FNDRY earned per week as a line + area chart + */ +const EarningsChart: React.FC<{ data: EarningData[]; maxEarned: number }> = ({ data, maxEarned }) => { + const height = 100; + const width = 100; // percentage-based + const padX = 8; // % + const padY = 10; // px + const chartW = 100 - padX * 2; + const chartH = height - padY * 2; + + if (maxEarned === 0) return null; + + const points = data.map((d, i) => ({ + x: padX + (i / Math.max(data.length - 1, 1)) * chartW, + y: padY + chartH - (d.amount / maxEarned) * chartH, + week: d.week, + amount: d.amount, + })); + + const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' '); + const areaPath = `${linePath} L ${points[points.length - 1].x} ${padY + chartH} L ${points[0].x} ${padY + chartH} Z`; + + return ( +
+

FNDRY Earnings History

+
+ + {/* Grid lines */} + {[0, 0.25, 0.5, 0.75, 1].map((t) => ( + + ))} + {/* Area fill */} + + {/* Line */} + + {/* Dots */} + {points.map((p, i) => ( + + ))} + + + + + + + + {/* Week labels */} +
+ {points.map((p, i) => ( + {p.week} + ))} +
+
+
+ ); +}; + +/** + * Streak indicator with flame icon + */ +const StreakBadge: React.FC<{ streak: number }> = ({ streak }) => ( +
+ {streak >= 7 ? '🔥' : streak >= 3 ? '⚡' : '🌱'} +
+

{streak}

+

day streak

+
+
+); + +// ============================================================================ +// Stats Grid +// ============================================================================ + +const StatsGrid: React.FC<{ + totalEarned: number; + bountiesCompleted: number; + reputationScore: number; + ghStats: GithubStats; +}> = ({ totalEarned, bountiesCompleted, reputationScore, ghStats }) => ( +
+
+

Total Earned

+

+ {totalEarned >= 1000 ? `${(totalEarned / 1000).toFixed(0)}K` : totalEarned.toLocaleString()} +

+

FNDRY

+
+
+

Bounties

+

{bountiesCompleted}

+

completed

+
+
+

Reputation

+

{reputationScore}

+

pts

+
+
+

Commits

+

{ghStats.totalCommits}

+

this cycle

+
+
+); + +// ============================================================================ +// GitHub API Fetcher +// ============================================================================ + +const fetchGithubStats = async (handle: string): Promise => { + try { + const eventsRes = await fetch(`https://api.github.com/users/${handle}/events?per_page=100`, { + headers: { Accept: 'application/vnd.github.v3+json' }, + }); + if (!eventsRes.ok) return null; + const events = await eventsRes.json(); + + let commits = 0, prs = 0, issues = 0; + const today = new Date(); + const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000); + + for (const e of events) { + const d = new Date(e.created_at); + if (d < thirtyDaysAgo) continue; + if (e.type === 'PushEvent') commits += (e.payload?.commits?.length || 0); + if (e.type === 'PullRequestEvent' && e.payload?.action === 'opened') prs++; + if (e.type === 'IssuesEvent' && e.payload?.action === 'opened') issues++; + } + + return { totalCommits: commits, totalPRs: prs, totalIssues: issues, currentStreak: 0, longestStreak: 0 }; + } catch { + return null; + } +}; + +// ============================================================================ +// Main Component +// ============================================================================ + export const ContributorProfile: React.FC = ({ username, avatarUrl, @@ -18,46 +277,98 @@ export const ContributorProfile: React.FC = ({ totalEarned = 0, bountiesCompleted = 0, reputationScore = 0, + githubActivity = DEFAULT_ACTIVITY, + earningHistory = DEFAULT_EARNINGS, + githubHandle, }) => { - const truncatedWallet = walletAddress + const [ghStats, setGhStats] = useState({ + totalCommits: githubActivity.reduce((s, w) => s + w.commits, 0), + totalPRs: githubActivity.reduce((s, w) => s + w.prs, 0), + totalIssues: githubActivity.reduce((s, w) => s + w.issues, 0), + currentStreak: 4, + longestStreak: 12, + }); + const [loadingGh, setLoadingGh] = useState(false); + + useEffect(() => { + if (!githubHandle) return; + setLoadingGh(true); + fetchGithubStats(githubHandle).then((stats) => { + if (stats) setGhStats(stats); + setLoadingGh(false); + }); + }, [githubHandle]); + + const truncatedWallet = walletAddress ? `${walletAddress.slice(0, 6)}...${walletAddress.slice(-4)}` : 'Not connected'; + const maxEarning = Math.max(...earningHistory.map((e) => e.amount), 1); + return ( -
+
{/* Profile Header */} -
-
+
+
{avatarUrl ? ( - {username} + {username} ) : ( - {username.charAt(0).toUpperCase()} + {username.charAt(0).toUpperCase()} )}
-
-

{username}

-

{truncatedWallet}

+
+
+

{username}

+ {githubHandle && ( + + @{githubHandle} + + )} +
+

{truncatedWallet}

+
+
+
- {/* Stats Cards - Responsive grid */} -
-
-

Total Earned

-

{totalEarned.toLocaleString()} FNDRY

+ {/* Stats Grid */} + + + {/* Charts Row */} +
+ + +
+ + {/* Additional GitHub Stats */} +
+
+

{ghStats.totalCommits}

+

commits

-
-

Bounties

-

{bountiesCompleted}

+
+

{ghStats.totalPRs}

+

PRs

-
-

Reputation

-

{reputationScore}

+
+

{ghStats.totalIssues}

+

issues

- {/* Hire as Agent Button - Touch friendly (min 44px height) */} - +); + +// ============================================================================ +// Step Components +// ============================================================================ + +const WelcomeStep: React.FC<{ onNext: () => void }> = ({ onNext }) => ( +
+
+ 🏭 +
+

Welcome to SolFoundry

+

+ The autonomous AI software factory. Find bounties, submit quality work, and earn $FNDRY rewards. +

+
+ {[ + { icon: '🔍', title: 'Find Bounties', desc: 'Browse T1/T2/T3 tasks across all categories' }, + { icon: '💻', title: 'Submit Work', desc: 'Open PRs with quality solutions' }, + { icon: '💰', title: 'Earn $FNDRY', desc: 'Get paid for verified contributions' }, + ].map(({ icon, title, desc }) => ( +
+ {icon} +

{title}

+

{desc}

+
+ ))} +
+ +
+); + +const ProfileStep: React.FC<{ + username: string; + bio: string; + onChange: (field: 'username' | 'bio', value: string) => void; + onNext: () => void; + onBack: () => void; +}> = ({ username, bio, onChange, onNext, onBack }) => ( +
+

Set Up Your Profile

+

Tell us about yourself so others can find you.

+ +
+
+ +
+ @ + onChange('username', e.target.value)} + placeholder="your-github-username" + className="w-full bg-gray-800 border border-gray-700 rounded-xl py-3 pl-8 pr-4 text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors" + /> +
+ {username && !/^[a-zA-Z0-9-]+$/.test(username) && ( +

Only letters, numbers, and hyphens allowed

+ )} +
+ +
+ +