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]) => (
+
+ ))}
+
+
+ );
+};
+
+/**
+ * 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
+
+
+ {/* 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.charAt(0).toUpperCase()}
+
{username.charAt(0).toUpperCase()}
)}
-
-
{username}
-
{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) */}
-