diff --git a/components/AppShell.tsx b/components/AppShell.tsx index b89aaa6..e874a73 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -5,16 +5,23 @@ import { useRouter } from "next/navigation"; import ScoutForm from "@/components/ScoutForm"; import CardFan from "@/components/CardFan"; import LoadingScreen from "@/components/LoadingScreen"; +import ResultView from "@/components/ResultView"; +import Background from "@/components/Background"; import dynamic from "next/dynamic"; import FooterCredit from "@/components/FooterCredit"; import BuyMeACoffee from "@/components/BuyMeACoffee"; import SupportProductHunt from "@/components/SupportProductHunt"; import GithubStar from "@/components/GithubStar"; +import PrivateButton from "@/components/private/PrivateButton"; import { SAMPLE_CARDS } from "@/lib/github/samples"; +import type { Card } from "@/lib/scoring/types"; const HowItWorksModal = dynamic(() => import("@/components/HowItWorksModal"), { ssr: false, }); +const PrivateDialog = dynamic(() => import("@/components/private/PrivateDialog"), { + ssr: false, +}); // Home-only: AppShell is rendered solely by app/page.tsx, so the TEAM NEWS // bulletin never mounts on scout/duel pages. Lazy + ssr:false like the modal. const WhatsNew = dynamic(() => import("@/components/WhatsNew"), { ssr: false }); @@ -30,6 +37,8 @@ export default function AppShell({ const [isPending, startTransition] = useTransition(); const [pending, setPending] = useState(null); const [modalOpen, setModalOpen] = useState(false); + const [privateOpen, setPrivateOpen] = useState(false); + const [privateCard, setPrivateCard] = useState(null); // Mark this tab as "has visited home" so a scouted card shows BACK, while a // directly-opened / shared card link (no home visit) shows a "make your card" @@ -52,12 +61,30 @@ export default function AppShell({ if (isPending && pending) return ; + // Private mode: show the result in-place, no server navigation. The card is + // local-only — no sharable URL exists — so we render ResultView directly. + if (privateCard) { + return ( +
+ + setPrivateCard(null)} + onCountryChange={() => {}} + stars={stars} + isPrivate + /> +
+ ); + } + return ( <>
{/* Overlaid in the corner (not a flow header) so it never pushes the vertically-centered hero down. */} -
+
+ setPrivateOpen(true)} />
@@ -79,6 +106,15 @@ export default function AppShell({ {modalOpen && setModalOpen(false)} />} + {privateOpen && ( + setPrivateOpen(false)} + onResult={(card) => { + setPrivateOpen(false); + setPrivateCard(card); + }} + /> + )} ); diff --git a/components/private/PrivateButton.tsx b/components/private/PrivateButton.tsx new file mode 100644 index 0000000..cb4774d --- /dev/null +++ b/components/private/PrivateButton.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { Lock } from "lucide-react"; + +// A compact button that sits next to GithubStar on the top bar. Toggles the +// private-scout dialog. Styled to be unobtrusive — it only stands out on hover. +export default function PrivateButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} diff --git a/components/private/PrivateDialog.tsx b/components/private/PrivateDialog.tsx new file mode 100644 index 0000000..9781ce7 --- /dev/null +++ b/components/private/PrivateDialog.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useState, useCallback, useRef, useEffect } from "react"; +import { Lock, X, Shield, Eye, EyeOff, AlertTriangle, Loader2 } from "lucide-react"; +import type { Card } from "@/lib/scoring/types"; +import { clientScout, type ClientScoutError } from "@/lib/private/clientScout"; + +interface Props { + onClose: () => void; + onResult: (card: Card) => void; +} + +// The private-scout dialog: explains the security model, takes a token + username, +// and runs the scout entirely client-side. The token is held only in a local ref +// (not even React state, so it never serialises), never stored, and sent only to +// api.github.com. Closing the dialog forgets it. +export default function PrivateDialog({ onClose, onResult }: Props) { + const [username, setUsername] = useState(""); + const [showToken, setShowToken] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + // Token lives in a ref, not state — it's never serialised, never in a snapshot, + // and the component unmount (dialog close) drops it from memory. + const tokenRef = useRef(""); + const [tokenDisplay, setTokenDisplay] = useState(""); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const handleTokenChange = useCallback((e: React.ChangeEvent) => { + tokenRef.current = e.target.value; + setTokenDisplay(e.target.value); + }, []); + + const handleScout = useCallback(async () => { + const token = tokenRef.current.trim(); + const login = username.trim(); + if (!token || !login) { + setError("Both a token and a username are required."); + return; + } + + setError(null); + setLoading(true); + try { + const card = await clientScout(login, token); + onResult(card); + } catch (e) { + const err = e as ClientScoutError; + setError(err.message || "Something went wrong."); + } finally { + setLoading(false); + } + }, [username, onResult]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !loading) handleScout(); + if (e.key === "Escape") onClose(); + }, + [handleScout, loading, onClose], + ); + + return ( + // backdrop +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* close */} + + + {/* header */} +
+
+ +
+
+

Private Mode

+

Scout with your own GitHub token

+
+
+ + {/* security explainer */} +
+
+ + HOW YOUR TOKEN IS HANDLED +
+
    +
  • + + Sent only to api.github.com over HTTPS +
  • +
  • + + Never sent to GitFut servers, never logged +
  • +
  • + + Kept only in memory — closing this dialog forgets it +
  • +
  • + + Use a fine-grained, read-only token for least privilege +
  • +
+
+ + {/* token input */} + +
+ + +
+ + {/* username input */} + + setUsername(e.target.value)} + placeholder="octocat" + spellCheck={false} + autoComplete="off" + className="mb-4 h-[42px] w-full rounded-xl border border-white/[0.1] bg-white/[0.03] px-3 font-mono text-[13px] text-ink placeholder-ink-mute outline-none transition focus:border-brand/50 focus:ring-1 focus:ring-brand/30" + /> + + {/* error */} + {error && ( +
+ + {error} +
+ )} + + {/* actions */} + + +

+ Private cards are shown locally and cannot be shared. +

+
+
+ ); +} diff --git a/lib/private/clientScout.ts b/lib/private/clientScout.ts new file mode 100644 index 0000000..d7051a0 --- /dev/null +++ b/lib/private/clientScout.ts @@ -0,0 +1,296 @@ +// Browser-side GitHub scout — runs the same GraphQL profile + lifetime pipeline +// as the server's lib/github/client.ts, but from the visitor's own browser with +// their own GitHub token. The token is sent ONLY to api.github.com over HTTPS, +// never to GitFut's servers, never logged, never stored. +// +// Reuses signalsFromPayload + buildCard (both pure, no server-only) so the +// scoring is identical whether scouted server-side or privately. + +import type { Card } from "@/lib/scoring/types"; +import { signalsFromPayload } from "@/lib/github/signals"; +import { buildCard } from "@/lib/scoring/engine"; + +const ENDPOINT = "https://api.github.com/graphql"; +const VALID = /^(?=.*[a-z\d])[a-z\d-]{1,39}$/i; +const GITHUB_EPOCH_YEAR = 2008; +const LIFETIME_BATCH = 4; +const MIN_CONTRIBUTED_LANG_COMMITS = 3; +const REQUEST_TIMEOUT_MS = 15_000; // browser-side can afford a longer timeout + +export interface ClientScoutError { + type: "invalid" | "notfound" | "ratelimit" | "network" | "token"; + message: string; +} + +// --- GraphQL response shapes (mirrors server) --- +interface UserNode { + login: string; + name: string | null; + avatarUrl: string; + location: string | null; + createdAt: string; + followers: { totalCount: number }; + repositories: { + totalCount: number; + nodes: { + nameWithOwner: string; + stargazerCount: number; + primaryLanguage: { name: string } | null; + createdAt: string; + pushedAt: string; + }[]; + }; + recent: { + totalCommitContributions: number; + totalPullRequestContributions: number; + totalPullRequestReviewContributions: number; + totalIssueContributions: number; + restrictedContributionsCount: number; + commitContributionsByRepository: { + contributions: { totalCount: number }; + repository: { + nameWithOwner: string; + isFork: boolean; + isPrivate: boolean; + primaryLanguage: { name: string } | null; + }; + }[]; + contributionCalendar: { weeks: { contributionDays: { contributionCount: number }[] }[] }; + }; +} + +interface YearContrib { + totalCommitContributions: number; + totalIssueContributions: number; + totalPullRequestContributions: number; + totalPullRequestReviewContributions: number; + restrictedContributionsCount: number; +} + +// Matches the server's RawPayload shape — feeds into signalsFromPayload. +interface RawRepo { + stars: number; + language: string | null; + createdAt: string; + pushedAt: string; +} + +interface RawPayload { + login: string; + name: string | null; + avatarUrl: string; + location: string | null; + createdAt: string; + followers: number; + publicRepos: number; + repos: RawRepo[]; + languageRepos: { language: string | null }[]; + recentCommits: number; + recentPRs: number; + recentReviews: number; + recentIssues: number; + recentRestricted: number; + recentActiveDays: number; + lifetimeContributions: number; +} + +const fail = (type: ClientScoutError["type"], message: string): never => { + throw { type, message } satisfies ClientScoutError; +}; + +async function gql(query: string, login: string, token: string): Promise<{ user: T | null }> { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS); + let res: Response; + try { + res = await fetch(ENDPOINT, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ query, variables: { login } }), + signal: ctrl.signal, + }); + } catch { + return fail("network", "Couldn't reach GitHub — check your connection."); + } finally { + clearTimeout(timer); + } + + if (res.status === 401) return fail("token", "Token is invalid or expired."); + if (res.status === 403 || res.status === 429) + return fail("ratelimit", "GitHub rate limit hit. Try again shortly."); + if (!res.ok) return fail("network", `GitHub returned an error (${res.status}).`); + + let body: { data?: { user: T | null }; errors?: { type?: string; message?: string }[] }; + try { + body = await res.json(); + } catch { + return fail("network", "GitHub returned a malformed response."); + } + if (body.errors?.some((e) => e.type === "RATE_LIMITED" || e.type === "RATE_LIMIT")) + return fail("ratelimit", "GitHub rate limit hit. Try again shortly."); + return { user: body.data?.user ?? null }; +} + +function profileQuery(): string { + return ` + query Profile($login: String!) { + user(login: $login) { + login + name + avatarUrl(size: 480) + location + createdAt + followers { totalCount } + repositories(ownerAffiliations: [OWNER, ORGANIZATION_MEMBER], isFork: false, first: 100, orderBy: { field: STARGAZERS, direction: DESC }) { + totalCount + nodes { nameWithOwner stargazerCount primaryLanguage { name } createdAt pushedAt } + } + recent: contributionsCollection { + totalCommitContributions + totalPullRequestContributions + totalPullRequestReviewContributions + totalIssueContributions + restrictedContributionsCount + commitContributionsByRepository(maxRepositories: 100) { + contributions { totalCount } + repository { nameWithOwner isFork isPrivate primaryLanguage { name } } + } + contributionCalendar { weeks { contributionDays { contributionCount } } } + } + } + }`; +} + +function lifetimeQuery(years: number[], currentYear: number, nowIso: string): string { + const aliases = years + .map((y) => { + const to = y === currentYear ? nowIso : `${y}-12-31T23:59:59Z`; + return ` y${y}: contributionsCollection(from: "${y}-01-01T00:00:00Z", to: "${to}") { totalCommitContributions totalIssueContributions totalPullRequestContributions totalPullRequestReviewContributions restrictedContributionsCount }`; + }) + .join("\n"); + return ` + query Lifetime($login: String!) { + user(login: $login) { +${aliases} + } + }`; +} + +function chunk(arr: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} + +async function fetchLifetime( + login: string, + token: string, + createdYear: number, + currentYear: number, + nowIso: string, +): Promise { + const years: number[] = []; + for (let y = Math.max(createdYear, GITHUB_EPOCH_YEAR); y <= currentYear; y++) years.push(y); + + const sums = await Promise.all( + chunk(years, LIFETIME_BATCH).map(async (batch) => { + try { + const { user } = await gql>( + lifetimeQuery(batch, currentYear, nowIso), + login, + token, + ); + if (!user) return 0; + return batch.reduce((s, y) => { + const c = user[`y${y}`]; + return c + ? s + + c.totalCommitContributions + + c.totalIssueContributions + + c.totalPullRequestContributions + + c.totalPullRequestReviewContributions + + c.restrictedContributionsCount + : s; + }, 0); + } catch { + return 0; + } + }), + ); + return sums.reduce((a, b) => a + b, 0); +} + +function normalize(user: UserNode, lifetimeContributions: number): RawPayload { + const repos: RawRepo[] = user.repositories.nodes.map((n) => ({ + stars: n.stargazerCount ?? 0, + language: n.primaryLanguage?.name ?? null, + createdAt: n.createdAt, + pushedAt: n.pushedAt, + })); + + const languageByRepo = new Map(); + for (const n of user.repositories.nodes) { + languageByRepo.set(n.nameWithOwner, n.primaryLanguage?.name ?? null); + } + for (const c of user.recent.commitContributionsByRepository ?? []) { + const r = c.repository; + if (r.isFork || r.isPrivate || !r.primaryLanguage) continue; + if (c.contributions.totalCount < MIN_CONTRIBUTED_LANG_COMMITS) continue; + if (languageByRepo.has(r.nameWithOwner)) continue; + languageByRepo.set(r.nameWithOwner, r.primaryLanguage.name); + } + const languageRepos = [...languageByRepo.values()].map((language) => ({ language })); + + const recentActiveDays = user.recent.contributionCalendar.weeks.reduce( + (days, w) => days + w.contributionDays.filter((d) => d.contributionCount > 0).length, + 0, + ); + + return { + login: user.login, + name: user.name, + avatarUrl: user.avatarUrl, + location: user.location, + createdAt: user.createdAt, + followers: user.followers.totalCount, + publicRepos: user.repositories.totalCount, + repos, + languageRepos, + recentCommits: user.recent.totalCommitContributions, + recentPRs: user.recent.totalPullRequestContributions, + recentReviews: user.recent.totalPullRequestReviewContributions, + recentIssues: user.recent.totalIssueContributions, + recentRestricted: user.recent.restrictedContributionsCount, + recentActiveDays, + lifetimeContributions, + }; +} + +/** + * Scouts a GitHub profile client-side using the visitor's own token. The token + * is sent ONLY to api.github.com — never to GitFut's servers. Returns the same + * Card type as the server pipeline. + * + * Throws ClientScoutError on failure. + */ +export async function clientScout(username: string, token: string): Promise { + const login = username.trim().replace(/^@/, ""); + if (!VALID.test(login)) return fail("invalid", "That doesn't look like a GitHub username."); + if (!token.trim()) return fail("token", "Please provide a GitHub token."); + + const now = new Date(); + const { user } = await gql(profileQuery(), login, token); + if (!user) return fail("notfound", "No GitHub user by that name."); + + const createdYear = new Date(user.createdAt).getUTCFullYear(); + const lifetimeContributions = await fetchLifetime( + login, + token, + createdYear, + now.getUTCFullYear(), + now.toISOString(), + ); + + const payload = normalize(user, lifetimeContributions); + return buildCard(signalsFromPayload(payload)); +} diff --git a/tests/clientScout.test.ts b/tests/clientScout.test.ts new file mode 100644 index 0000000..77964e0 --- /dev/null +++ b/tests/clientScout.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// The client scout reuses signalsFromPayload + buildCard (no server-only), so +// this tests the browser-side fetch + normalize + pipeline identity. + +const USER = { + login: "testuser", + name: "Test User", + avatarUrl: "https://example.com/avatar.png", + location: null, + createdAt: "2023-02-01T00:00:00Z", + followers: { totalCount: 5 }, + repositories: { totalCount: 1, nodes: [] }, + recent: { + totalCommitContributions: 50, + totalPullRequestContributions: 3, + totalPullRequestReviewContributions: 1, + totalIssueContributions: 2, + restrictedContributionsCount: 10, + commitContributionsByRepository: [], + contributionCalendar: { weeks: [{ contributionDays: [{ contributionCount: 5 }] }] }, + }, +}; + +const okHeaders = { "content-type": "application/json" }; +const ok = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: okHeaders }); +const okFor = (reqBody: string) => + reqBody.includes("query Profile") + ? ok({ data: { user: USER } }) + : ok({ data: { user: {} } }); + +type Call = { auth: string; body: string }; +let calls: Call[] = []; + +function scriptFetch(respond: (body: string) => Response) { + const mock = vi.fn(async (_url: unknown, init?: RequestInit) => { + const auth = String((init?.headers as Record).Authorization); + const body = String(init?.body); + calls.push({ auth, body }); + return respond(body); + }); + vi.stubGlobal("fetch", mock); + return mock; +} + +afterEach(() => { + calls = []; + vi.unstubAllGlobals(); +}); + +describe("clientScout", () => { + it("sends the token only in the Authorization header to api.github.com", async () => { + scriptFetch((body) => okFor(body)); + const { clientScout } = await import("@/lib/private/clientScout"); + + await clientScout("testuser", "ghp_secret123"); + + // Every call must carry the token as a Bearer header + for (const c of calls) { + expect(c.auth).toBe("Bearer ghp_secret123"); + } + // At least profile + one lifetime batch + expect(calls.length).toBeGreaterThanOrEqual(2); + }); + + it("returns a Card with the same shape as the server pipeline", async () => { + scriptFetch((body) => okFor(body)); + const { clientScout } = await import("@/lib/private/clientScout"); + + const card = await clientScout("testuser", "ghp_secret123"); + + expect(card).toHaveProperty("login", "testuser"); + expect(card).toHaveProperty("overall"); + expect(card).toHaveProperty("stats"); + expect(card).toHaveProperty("position"); + expect(card).toHaveProperty("finish"); + expect(typeof card.overall).toBe("number"); + }); + + it("rejects an invalid username before any network call", async () => { + const mock = scriptFetch((body) => okFor(body)); + const { clientScout } = await import("@/lib/private/clientScout"); + + await expect(clientScout("", "ghp_tok")).rejects.toMatchObject({ type: "invalid" }); + await expect(clientScout("foo bar", "ghp_tok")).rejects.toMatchObject({ type: "invalid" }); + expect(mock).not.toHaveBeenCalled(); + }); + + it("rejects an empty token before any network call", async () => { + const mock = scriptFetch((body) => okFor(body)); + const { clientScout } = await import("@/lib/private/clientScout"); + + await expect(clientScout("testuser", "")).rejects.toMatchObject({ type: "token" }); + await expect(clientScout("testuser", " ")).rejects.toMatchObject({ type: "token" }); + expect(mock).not.toHaveBeenCalled(); + }); + + it("reports a token error on a 401 response", async () => { + scriptFetch(() => new Response("nope", { status: 401 })); + const { clientScout } = await import("@/lib/private/clientScout"); + + await expect(clientScout("testuser", "bad_token")).rejects.toMatchObject({ type: "token" }); + }); + + it("reports a rate limit on a 403 response", async () => { + scriptFetch(() => new Response("limit", { status: 403 })); + const { clientScout } = await import("@/lib/private/clientScout"); + + await expect(clientScout("testuser", "ghp_tok")).rejects.toMatchObject({ type: "ratelimit" }); + }); + + it("reports notfound when the user doesn't exist", async () => { + scriptFetch(() => ok({ data: { user: null } })); + const { clientScout } = await import("@/lib/private/clientScout"); + + await expect(clientScout("noone", "ghp_tok")).rejects.toMatchObject({ type: "notfound" }); + }); +});