diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb6f3e3..509d8d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,11 +11,12 @@ Please read this fully before picking up an issue. It explains exactly how issue Every issue is labeled with a difficulty. **The assignment process is different per label — read carefully.** ### 🟢 `good-first-issue` -**No assignment. First valid PR wins.** +**First to comment gets assigned. One person only.** -- Do not comment asking to be assigned — these are open season. -- Just fork, fix it, and open a PR. -- If two people submit a PR for the same issue, the first one opened (and passing review) gets merged. The other contributor will be redirected to a different issue — this is not personal, it's just how unassigned issues work at scale. +- Comment on the issue saying you want to work on it. +- The **first valid comment** gets assigned by a maintainer. +- Do not start working until you are assigned. +- Only one person assigned per issue. - These exist so new contributors can get a fast, frictionless first PR merged. ### 🟡 `intermediate` and 🔴 `advanced` diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index f3a5bc0..45350fc 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,8 +1,9 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import RedirectResponse from sqlalchemy.orm import Session import httpx import jwt +import secrets from datetime import datetime, timedelta from app.database import get_db @@ -11,6 +12,8 @@ router = APIRouter() +OAUTH_STATE_COOKIE = "oauth_state" + def create_access_token(data: dict) -> str: to_encode = data.copy() @@ -23,13 +26,18 @@ def create_access_token(data: dict) -> str: @router.get("/github") -def github_login(cli: bool = False): +def github_login(cli: bool = False, local_nonce: str | None = None): """Redirect user to GitHub OAuth page. - + Pass ?cli=true when initiating login from the CLI so the callback - redirects to the local CLI listener instead of the frontend. + redirects to the local CLI listener instead of the frontend. The CLI + also passes its own local_nonce, round-tripped back to it via `state` + so the local callback listener can reject forged requests from other + local processes racing to deliver a token to it first. """ - state = "cli" if cli else "web" + purpose = "cli" if cli else "web" + nonce = secrets.token_urlsafe(32) + state = f"{purpose}:{nonce}:{local_nonce}" if (purpose == "cli" and local_nonce) else f"{purpose}:{nonce}" github_auth_url = ( f"https://github.com/login/oauth/authorize" f"?client_id={settings.GITHUB_CLIENT_ID}" @@ -37,17 +45,38 @@ def github_login(cli: bool = False): f"&scope=read:user,user:email,repo" f"&state={state}" ) - return RedirectResponse(url=github_auth_url) + response = RedirectResponse(url=github_auth_url) + response.set_cookie( + key=OAUTH_STATE_COOKIE, + value=nonce, + max_age=600, + httponly=True, + secure=settings.ENVIRONMENT == "production", + samesite="lax", + ) + return response @router.get("/github/callback") -async def github_callback(code: str, state: str = "web", db: Session = Depends(get_db)): +async def github_callback(code: str, request: Request, state: str = "web", db: Session = Depends(get_db)): """Handle GitHub OAuth callback and return JWT. - + If state == 'cli', redirects to the local CLI callback listener. Otherwise redirects to the frontend. """ + # Validate the state nonce against the cookie set in github_login() to + # prevent login CSRF: without this, an attacker can feed their own + # authorization code to a victim's browser and log the victim into the + # attacker's account. + parts = state.split(":", 2) + purpose = parts[0] + nonce = parts[1] if len(parts) > 1 else "" + local_nonce = parts[2] if len(parts) > 2 else None + cookie_nonce = request.cookies.get(OAUTH_STATE_COOKIE) + if not nonce or not cookie_nonce or not secrets.compare_digest(nonce, cookie_nonce): + raise HTTPException(status_code=400, detail="Invalid or missing OAuth state") + # Exchange code for access token async with httpx.AsyncClient() as client: token_response = await client.post( @@ -104,11 +133,15 @@ async def github_callback(code: str, state: str = "web", db: Session = Depends(g jwt_token = create_access_token({"sub": str(user.id), "username": user.username}) # CLI login: redirect to local listener instead of frontend - if state == "cli": - return RedirectResponse( - url=f"http://localhost:{CLI_CALLBACK_PORT}/callback?token={jwt_token}" + if purpose == "cli": + callback_url = f"http://localhost:{CLI_CALLBACK_PORT}/callback?token={jwt_token}" + if local_nonce: + callback_url += f"&local_nonce={local_nonce}" + response = RedirectResponse(url=callback_url) + else: + response = RedirectResponse( + url=f"{settings.FRONTEND_URL}/auth/callback?token={jwt_token}" ) - return RedirectResponse( - url=f"{settings.FRONTEND_URL}/auth/callback?token={jwt_token}" - ) \ No newline at end of file + response.delete_cookie(OAUTH_STATE_COOKIE) + return response \ No newline at end of file diff --git a/cli/clutch_cli/authentication/login.py b/cli/clutch_cli/authentication/login.py index 1c32d74..915f4cd 100644 --- a/cli/clutch_cli/authentication/login.py +++ b/cli/clutch_cli/authentication/login.py @@ -1,3 +1,5 @@ +import hmac +import secrets import webbrowser from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import parse_qs, urlparse @@ -11,16 +13,25 @@ class _CallbackHandler(BaseHTTPRequestHandler): + expected_nonce = "" + def do_GET(self): parsed = urlparse(self.path) if parsed.path == "/callback": params = parse_qs(parsed.query) token = params.get("token", [None])[0] - if token: - _captured_token["value"] = token - self._respond(200, _success_page()) - else: - self._respond(400, _error_page("No token received.")) + received_nonce = params.get("local_nonce", [None])[0] + # Reject any request whose local_nonce doesn't match the one we + # generated for this login attempt — without this check, any + # other local process racing to hit this port first could feed + # us an attacker-controlled token before the real callback. + if not token or not received_nonce or not hmac.compare_digest( + received_nonce, self.expected_nonce + ): + self._respond(400, _error_page("Invalid or missing callback nonce.")) + return + _captured_token["value"] = token + self._respond(200, _success_page()) else: self._respond(404, b"Not found") @@ -69,9 +80,11 @@ def login(): console.print(f"[{DIM}]Starting local callback listener...[/{DIM}]") _captured_token.clear() + local_nonce = secrets.token_urlsafe(24) + _CallbackHandler.expected_nonce = local_nonce server = HTTPServer(("localhost", CLI_CALLBACK_PORT), _CallbackHandler) - login_url = f"{API_BASE_URL}/auth/github?cli=true" + login_url = f"{API_BASE_URL}/auth/github?cli=true&local_nonce={local_nonce}" console.print(f"[{DIM}]Opening GitHub in your browser...[/{DIM}]\n") webbrowser.open(login_url) diff --git a/frontend/src/components/common/Heatmap.tsx b/frontend/src/components/common/Heatmap.tsx index f260b35..6d8e714 100644 --- a/frontend/src/components/common/Heatmap.tsx +++ b/frontend/src/components/common/Heatmap.tsx @@ -1,9 +1,16 @@ -import type { HeatmapData } from '../../types/dashboard.types' +import { useState } from 'react' +import type { HeatmapData, HeatmapDay } from '../../types/dashboard.types' interface HeatmapProps { data: HeatmapData | null } +interface HoveredCell { + day: HeatmapDay + x: number + y: number +} + const DAY_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', ''] const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] @@ -16,7 +23,13 @@ function intensity(count: number, max: number): string { return '#b8b8c0' } +function formatCount(count: number): string { + return `${count} contribution${count === 1 ? '' : 's'}` +} + export default function Heatmap({ data }: HeatmapProps) { + const [hovered, setHovered] = useState(null) + if (!data || data.days.length === 0) { return (

@@ -53,9 +66,23 @@ export default function Heatmap({ data }: HeatmapProps) { const cellSize = 11 const cellGap = 3 + const handleEnter = (day: HeatmapDay | null) => (e: React.MouseEvent) => { + if (!day) return + const cellRect = e.currentTarget.getBoundingClientRect() + const wrapperRect = e.currentTarget.closest('[data-heatmap-wrapper]')?.getBoundingClientRect() + if (!wrapperRect) return + setHovered({ + day, + x: cellRect.left - wrapperRect.left + cellRect.width / 2, + y: cellRect.top - wrapperRect.top, + }) + } + + const handleLeave = () => setHovered(null) + return (

-
+
{/* Month labels */}
{weeks.map((_, wi) => { @@ -84,12 +111,14 @@ export default function Heatmap({ data }: HeatmapProps) { {week.map((day, di) => (
))} @@ -105,7 +134,32 @@ export default function Heatmap({ data }: HeatmapProps) { ))} More
+ + {/* Tooltip */} + {hovered && ( +
+
{hovered.day.date}
+
{formatCount(hovered.day.count)}
+
+ )}
) -} +} \ No newline at end of file diff --git a/frontend/src/components/layout/NavigationBar.tsx b/frontend/src/components/layout/NavigationBar.tsx index cc2a149..ffda8bc 100644 --- a/frontend/src/components/layout/NavigationBar.tsx +++ b/frontend/src/components/layout/NavigationBar.tsx @@ -1,15 +1,49 @@ -import type { ReactNode } from 'react' - +import { useState, useEffect, type ReactNode } from 'react' interface NavigationBarProps { rightContent?: ReactNode } export default function NavigationBar({ rightContent }: NavigationBarProps) { + const[isDark,setIsDark] =useState(()=>{ + if( typeof window !=='undefined'){ + return localStorage.getItem('theme')==='dark' + } + return false + }) + + useEffect(()=>{ + const root=document.documentElement + if(isDark) { + root.setAttribute('data-theme','dark') + localStorage.setItem('theme','dark') + } + else{ + root.setAttribute('data-theme','light') + localStorage.setItem('theme','light') + } + },[isDark]); return ( ) } diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index 6df719b..52068ea 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -62,7 +62,15 @@ } * { box-sizing: border-box; margin: 0; padding: 0; } - +[data-theme='dark']{ + --bg:#000000; + --text-primary:#ffffff; + --bg-card:#25253a; + --border:#4a4a6a; + --border-light:#4a4a6a; + --text-secondary:#e0e0e0; + +} body { background: var(--bg); color: var(--text-primary); @@ -71,6 +79,8 @@ body { font-size: var(--text-md); line-height: var(--leading-normal); -webkit-font-smoothing: antialiased; + transition: background-color 0.2s ease, color 0.2s ease; + } ::-webkit-scrollbar { width: 4px; } @@ -129,6 +139,7 @@ body { letter-spacing: 0.02em; transition: transform 0.1s ease, box-shadow 0.1s ease; text-transform: uppercase; + } .btn-nb:hover { transform: translate(2px, 2px); box-shadow: var(--shadow-hover); } .btn-nb:active { transform: translate(3px, 3px); box-shadow: none; }