Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
61 changes: 47 additions & 14 deletions backend/app/routers/auth.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,6 +12,8 @@

router = APIRouter()

OAUTH_STATE_COOKIE = "oauth_state"


def create_access_token(data: dict) -> str:
to_encode = data.copy()
Expand All @@ -23,31 +26,57 @@ 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}"
f"&redirect_uri={settings.GITHUB_REDIRECT_URI}"
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(
Expand Down Expand Up @@ -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}"
)
response.delete_cookie(OAUTH_STATE_COOKIE)
return response
25 changes: 19 additions & 6 deletions cli/clutch_cli/authentication/login.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import hmac
import secrets
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse
Expand All @@ -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")

Expand Down Expand Up @@ -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)

Expand Down
62 changes: 58 additions & 4 deletions frontend/src/components/common/Heatmap.tsx
Original file line number Diff line number Diff line change
@@ -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']

Expand All @@ -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<HoveredCell | null>(null)

if (!data || data.days.length === 0) {
return (
<p style={{ fontFamily: 'var(--font-chrome)', fontSize: 'var(--text-sm)', color: 'var(--text-muted)', textAlign: 'center', padding: 'var(--space-8) 0' }}>
Expand Down Expand Up @@ -53,9 +66,23 @@ export default function Heatmap({ data }: HeatmapProps) {
const cellSize = 11
const cellGap = 3

const handleEnter = (day: HeatmapDay | null) => (e: React.MouseEvent<HTMLDivElement>) => {
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 (
<div style={{ overflowX: 'auto', paddingBottom: 'var(--space-2)' }}>
<div style={{ display: 'inline-block' }}>
<div data-heatmap-wrapper style={{ display: 'inline-block', position: 'relative' }}>
{/* Month labels */}
<div style={{ display: 'flex', marginLeft: 24, marginBottom: 'var(--space-1)' }}>
{weeks.map((_, wi) => {
Expand Down Expand Up @@ -84,12 +111,14 @@ export default function Heatmap({ data }: HeatmapProps) {
{week.map((day, di) => (
<div
key={di}
title={day ? `${day.count} contributions on ${day.date}` : ''}
onMouseEnter={handleEnter(day)}
onMouseLeave={handleLeave}
style={{
width: cellSize,
height: cellSize,
background: day ? intensity(day.count, data.max_count) : 'transparent',
border: day ? '1px solid var(--border)' : 'none',
cursor: day ? 'pointer' : 'default',
}}
/>
))}
Expand All @@ -105,7 +134,32 @@ export default function Heatmap({ data }: HeatmapProps) {
))}
<span style={{ fontFamily: 'var(--font-chrome)', fontSize: 10, color: 'var(--text-muted)', marginLeft: 'var(--space-1)' }}>More</span>
</div>

{/* Tooltip */}
{hovered && (
<div
style={{
position: 'absolute',
left: hovered.x,
top: hovered.y - 8,
transform: 'translate(-50%, -100%)',
background: 'var(--bg-card)',
border: '2px solid var(--accent-purple)',
boxShadow: '3px 3px 0px var(--accent-purple)',
padding: 'var(--space-2) var(--space-3)',
fontFamily: 'var(--font-chrome)',
fontSize: 11,
color: 'var(--text-primary)',
whiteSpace: 'nowrap',
pointerEvents: 'none',
zIndex: 20,
}}
>
<div style={{ fontWeight: 700 }}>{hovered.day.date}</div>
<div style={{ color: 'var(--text-muted)' }}>{formatCount(hovered.day.count)}</div>
</div>
)}
</div>
</div>
)
}
}
40 changes: 37 additions & 3 deletions frontend/src/components/layout/NavigationBar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<nav className="nb-nav">
<a href="/" style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', textDecoration: 'none' }}>
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: 'var(--text-xl)', color: 'var(--text-primary)', letterSpacing: '-0.3px' }}>Clutch</span>
<span className="tag tag-green">ONLINE</span>
</a>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>{rightContent}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>{rightContent}
<button
onClick={()=> setIsDark(!isDark)}
style={{cursor:'pointer',background:'none',border:'1px solid var(--text-primary)',padding:'8px',display:'flex',alignItems:'center',justifyContent:'center',borderRadius:'4px'}}
aria-label="Toggle theme"
>
{isDark ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
</svg>
)}
</button>

</div>
</nav>
)
}
13 changes: 12 additions & 1 deletion frontend/src/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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; }
Expand Down Expand Up @@ -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; }
Expand Down