diff --git a/next.config.mjs b/next.config.mjs index 6bc575c06..d0ec323bd 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,4 +1,4 @@ -import withPWAInit from "next-pwa"; +import withPWAInit from "@ducanh2912/next-pwa"; const withPWA = withPWAInit({ dest: "public", diff --git a/src/app/api/auth/link-github/callback/route.ts b/src/app/api/auth/link-github/callback/route.ts index 54c8276ef..997bcc5e3 100644 --- a/src/app/api/auth/link-github/callback/route.ts +++ b/src/app/api/auth/link-github/callback/route.ts @@ -25,7 +25,7 @@ export async function GET(req: NextRequest) { const state = req.nextUrl.searchParams.get("state"); const code = req.nextUrl.searchParams.get("code"); - const cookieStore = cookies(); + const cookieStore = await cookies(); const stateCookie = cookieStore.get("link_github_state")?.value; if (!stateCookie || !state || stateCookie !== state) { diff --git a/src/app/api/goals/[id]/route.ts b/src/app/api/goals/[id]/route.ts index 9b4b21f96..3cd897651 100644 --- a/src/app/api/goals/[id]/route.ts +++ b/src/app/api/goals/[id]/route.ts @@ -7,8 +7,9 @@ export const dynamic = "force-dynamic"; export async function DELETE( _req: Request, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { + const { id } = await params; try { const session = await getServerSession(authOptions); if (!session?.githubId) { @@ -27,7 +28,7 @@ export async function DELETE( const { error } = await supabaseAdmin .from("goals") .delete() - .eq("id", params.id) + .eq("id", id) .eq("user_id", user.id); if (error) { @@ -45,8 +46,9 @@ export async function DELETE( // PATCH /api/goals/[id] — update an existing goal export async function PATCH( req: Request, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { + const { id } = await params; const session = await getServerSession(authOptions); if (!session?.githubId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); @@ -59,7 +61,7 @@ export async function PATCH( const { data: existing, error: fetchError } = await supabaseAdmin .from("goals") .select("*") - .eq("id", params.id) + .eq("id", id) .eq("user_id", user.id) .maybeSingle(); @@ -153,7 +155,7 @@ if (current !== undefined) { const { data: updated, error: updateError } = await supabaseAdmin .from("goals") .update(updates) - .eq("id", params.id) + .eq("id", id) .eq("user_id", user.id) .select() .single(); diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index ab97f9697..75eeaee00 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -101,8 +101,7 @@ interface LeaderboardPayload { function getRateLimitKey(req: NextRequest): string { return ( - req.ip ?? - req.headers.get("x-real-ip") ?? + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown" ); } diff --git a/src/app/api/metrics/repos/[owner]/[name]/commits/route.ts b/src/app/api/metrics/repos/[owner]/[name]/commits/route.ts index a8ee4770a..201bd399f 100644 --- a/src/app/api/metrics/repos/[owner]/[name]/commits/route.ts +++ b/src/app/api/metrics/repos/[owner]/[name]/commits/route.ts @@ -10,14 +10,16 @@ export const dynamic = "force-dynamic"; export async function GET( req: NextRequest, - { params }: { params: { owner: string; name: string } } + { params }: { params: Promise<{ owner: string; name: string }> } ) { + const resolvedParams = await params; + const session = await getServerSession(authOptions); if (!session?.accessToken || !session.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - const repoFullName = `${params.owner}/${params.name}`; + const repoFullName = `${resolvedParams.owner}/${resolvedParams.name}`; const accountId = req.nextUrl.searchParams.get("accountId"); let token = session.accessToken; diff --git a/src/app/api/notifications/[id]/route.ts b/src/app/api/notifications/[id]/route.ts index 7170798a1..26a844415 100644 --- a/src/app/api/notifications/[id]/route.ts +++ b/src/app/api/notifications/[id]/route.ts @@ -9,8 +9,10 @@ export const dynamic = "force-dynamic"; // PATCH /api/notifications/[id] — mark single notification as read export async function PATCH( req: NextRequest, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { + const resolvedParams = await params; + const session = await getServerSession(authOptions); if (!session?.githubId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -21,7 +23,7 @@ export async function PATCH( return NextResponse.json({ error: "User not found" }, { status: 404 }); } - const notificationId = params.id; + const notificationId = resolvedParams.id; // Fetch the notification first to verify ownership const { data: notification, error: fetchError } = await supabaseAdmin @@ -59,8 +61,10 @@ export async function PATCH( // DELETE /api/notifications/[id] — delete a single notification export async function DELETE( req: NextRequest, - { params }: { params: { id: string } } + { params }: { params: Promise<{ id: string }> } ) { + const resolvedParams = await params; + const session = await getServerSession(authOptions); if (!session?.githubId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -71,7 +75,7 @@ export async function DELETE( return NextResponse.json({ error: "User not found" }, { status: 404 }); } - const notificationId = params.id; + const notificationId = resolvedParams.id; // Verify ownership before deleting const { data: notification, error: fetchError } = await supabaseAdmin diff --git a/src/app/api/public/[username]/route.ts b/src/app/api/public/[username]/route.ts index 47ce34938..13e242ea2 100644 --- a/src/app/api/public/[username]/route.ts +++ b/src/app/api/public/[username]/route.ts @@ -25,7 +25,7 @@ function cleanOldEntries(map: Map) { } function getRateLimitKey(req: NextRequest): string { - // req.ip is populated by the Next.js / Vercel runtime from the verified + // req.headers.get("x-forwarded-for") is populated by the Next.js / Vercel runtime from the verified // network-layer source address and cannot be spoofed by the caller. // // x-forwarded-for is intentionally excluded here: it is a plain request @@ -33,7 +33,7 @@ function getRateLimitKey(req: NextRequest): string { // primary key allows an attacker to rotate the header on every request, // bypass the per-IP limit entirely, and exhaust the shared GITHUB_TOKEN // quota (5 000 req/hr), making the endpoint unavailable for all users. - return req.ip || req.headers.get("x-real-ip") || "unknown"; + return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "unknown"; } function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } { @@ -61,10 +61,11 @@ function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } { export async function GET( req: NextRequest, - { params }: { params: { username: string } } + { params }: { params: Promise<{ username: string }> } ): Promise { cleanOldEntries(ipRateLimits); - const { username } = params; + const resolvedParams = await params; + const username = resolvedParams.username; // Rate limiting const ip = getRateLimitKey(req); const rateLimit = getUpstashConfig() diff --git a/src/app/api/user/github-accounts/[githubId]/route.ts b/src/app/api/user/github-accounts/[githubId]/route.ts index ae2a1e0d3..5e93e4a89 100644 --- a/src/app/api/user/github-accounts/[githubId]/route.ts +++ b/src/app/api/user/github-accounts/[githubId]/route.ts @@ -8,15 +8,17 @@ export const dynamic = "force-dynamic"; export async function DELETE( req: NextRequest, - { params }: { params: { githubId: string } } + { params }: { params: Promise<{ githubId: string }> } ) { + const resolvedParams = await params; + const session = await getServerSession(authOptions); if (!session?.githubId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (!params.githubId || typeof params.githubId !== "string" || !/^\d+$/.test(params.githubId)) { + if (!resolvedParams.githubId || typeof resolvedParams.githubId !== "string" || !/^\d+$/.test(resolvedParams.githubId)) { return NextResponse.json({ error: "Invalid githubId parameter" }, { status: 400 }); } @@ -26,7 +28,7 @@ export async function DELETE( return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (params.githubId === session.githubId) { + if (resolvedParams.githubId === session.githubId) { return NextResponse.json( { error: "Cannot remove primary account" }, { status: 400 } @@ -37,7 +39,7 @@ export async function DELETE( .from("user_github_accounts") .delete() .eq("user_id", userRow.id) - .eq("github_id", params.githubId) + .eq("github_id", resolvedParams.githubId) .select("github_id"); if (error) { diff --git a/src/app/compare/[users]/page.tsx b/src/app/compare/[users]/page.tsx index ae854923a..8d858947a 100644 --- a/src/app/compare/[users]/page.tsx +++ b/src/app/compare/[users]/page.tsx @@ -17,9 +17,10 @@ interface ComparePageProps { type Winner = "left" | "right" | "tie"; -function parseUsers(users: string): [string, string] | null { +async function parseUsers(params: Promise<{ users: string }>): Promise<[string, string] | null> { let decoded: string; try { + const { users } = await params; decoded = decodeURIComponent(users); } catch { return null; @@ -53,8 +54,8 @@ function repoCommitTotal(repos: TopRepo[]): number { export async function generateMetadata({ params, -}: ComparePageProps): Promise { - const parsed = parseUsers(params.users); +}: { params: Promise<{ users: string }> }): Promise { + const parsed = await parseUsers(params); if (!parsed) { return { title: "Compare Public Profiles", @@ -71,8 +72,9 @@ export async function generateMetadata({ export default async function PublicProfileComparePage({ params, -}: ComparePageProps) { - const parsed = parseUsers(params.users); +}: { params: Promise<{ users: string }> }) { + const { users } = await params; + const parsed = await parseUsers(params); if (!parsed) { return ; diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 2cd3aae9f..8ed6069bd 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -7,8 +7,13 @@ import { toast } from "sonner"; import type { ReactNode } from "react"; export default function DashboardLayout({ children }: { children: ReactNode }) { - const { status } = useSession({ required: true }); const router = useRouter(); + const { status } = useSession({ + required: true, + onUnauthenticated() { + router.push("/"); + }, + }); useEffect(() => { const originalFetch = window.fetch; diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 6f3bc2712..a23ee1371 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,4 +1,4 @@ -import LazyWidget from "@/components/LazyWidget"; +import LazyWidget from "@/components/LazyWidget"; import DiscussionsWidget from "@/components/DiscussionsWidget"; import CommunityMetrics from "@/components/CommunityMetrics"; import GoalTracker from "@/components/GoalTracker"; @@ -55,47 +55,46 @@ const PRMetricsSkeleton = () => ( const CodingActivityInsightsCard = dynamic( () => import("@/components/CodingActivityInsightsCard"), - { ssr: false, loading: () => }, + { loading: () => }, ); const FriendComparison = dynamic( () => import("@/components/FriendComparison"), - { ssr: false, loading: () => }, + { loading: () => }, ); const ActivityRingChart = dynamic( () => import("@/components/ActivityRingChart"), - { ssr: false, loading: () => }, + { loading: () => }, ); const ContributionGraph = dynamic( () => import("@/components/ContributionGraph"), - { ssr: false, loading: () => }, + { loading: () => }, ); const ContributionHeatmap = dynamic( () => import("@/components/ContributionHeatmap"), - { ssr: false, loading: () => }, + { loading: () => }, ); const PRMetrics = dynamic(() => import("@/components/PRMetrics"), { - ssr: false, loading: () => , }); const PRBreakdownChart = dynamic( () => import("@/components/PRBreakdownChart"), - { ssr: false, loading: () => }, + { loading: () => }, ); const CommitTimeChart = dynamic( () => import("@/components/CommitTimeChart"), - { ssr: false, loading: () => }, + { loading: () => }, ); const PRReviewTrendChart = dynamic( () => import("@/components/PRReviewTrendChart"), - { ssr: false, loading: () => }, + { loading: () => }, ); export default async function DashboardPage() { diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx index 2b40b61cf..30e9f47c9 100644 --- a/src/app/leaderboard/page.tsx +++ b/src/app/leaderboard/page.tsx @@ -60,10 +60,11 @@ function getMetricValue(entry: LeaderboardEntry, tab: LeaderboardTab): number { export default async function LeaderboardPage({ searchParams, }: { - searchParams: { tab?: string }; + searchParams: Promise<{ tab?: string }>; }) { - const activeTab = tabs.some((tab) => tab.id === searchParams.tab) - ? (searchParams.tab as LeaderboardTab) + const resolvedSearchParams = await searchParams; + const activeTab = tabs.some((tab) => tab.id === resolvedSearchParams.tab) + ? (resolvedSearchParams.tab as LeaderboardTab) : "streak"; const leaderboard = await fetchLeaderboard(); const activeMeta = tabs.find((tab) => tab.id === activeTab) ?? tabs[0]; diff --git a/src/app/u/[username]/feed.xml/route.ts b/src/app/u/[username]/feed.xml/route.ts index f63202bbf..d115e63ad 100644 --- a/src/app/u/[username]/feed.xml/route.ts +++ b/src/app/u/[username]/feed.xml/route.ts @@ -53,9 +53,10 @@ ${entries} export async function GET( _req: NextRequest, - { params }: { params: { username: string } } + { params }: { params: Promise<{ username: string }> } ) { - const { username } = params; + const resolvedParams = await params; + const username = resolvedParams.username; // Check if user has a public profile const { data: user } = await supabaseAdmin diff --git a/src/app/u/[username]/page.tsx b/src/app/u/[username]/page.tsx index 69c4dedbb..6d2fbdfd2 100644 --- a/src/app/u/[username]/page.tsx +++ b/src/app/u/[username]/page.tsx @@ -42,9 +42,9 @@ function getProfileUrl(username: string) { export async function generateMetadata({ params, }: { - params: { username: string }; + params: Promise<{ username: string }>; }): Promise { - const { username } = params; + const { username } = await params; // Minimal lookup — avoids duplicating 3 GitHub API calls that the page already makes const user = await getUserByUsername(username); const profileUrl = getProfileUrl(username); @@ -77,9 +77,9 @@ export async function generateMetadata({ export default async function PublicProfilePage({ params, }: { - params: { username: string }; + params: Promise<{ username: string }>; }) { - const { username } = params; + const { username } = await params; const [profile, loggedInUsername] = await Promise.all([ fetchPublicProfile(username, { includeAchievements: true }), getLoggedInGitHubUsername(), diff --git a/src/components/AppNavbar.tsx b/src/components/AppNavbar.tsx index fb4ee318a..f87521b72 100644 --- a/src/components/AppNavbar.tsx +++ b/src/components/AppNavbar.tsx @@ -133,6 +133,13 @@ export default function AppNavbar() { sign out → + ) : pathname.startsWith("/u/") ? null : ( + + Sign in with GitHub + ) : ( !isPublicProfileRoute && ( +
+ {identityLabel} +
+ + + ) : pathname.startsWith("/u/") ? null : ( + + Sign in with GitHub + + )}
{isAuthenticated ? ( <> diff --git a/src/components/CodingActivityInsightsCard.tsx b/src/components/CodingActivityInsightsCard.tsx index f3d6375b7..a90272747 100644 --- a/src/components/CodingActivityInsightsCard.tsx +++ b/src/components/CodingActivityInsightsCard.tsx @@ -75,7 +75,7 @@ function TrendBadge({ function HourTooltip({ active, payload, -}: TooltipProps) { +}: any) { if (!active || !payload || payload.length === 0) { return null; } diff --git a/src/components/CodingTimeWidget.tsx b/src/components/CodingTimeWidget.tsx index 1af551569..ac34ca166 100644 --- a/src/components/CodingTimeWidget.tsx +++ b/src/components/CodingTimeWidget.tsx @@ -117,7 +117,7 @@ export default function CodingTimeWidget() { borderRadius: "8px", color: "var(--card-foreground)", }} - formatter={(value: number) => [`${value} hours`, 'Time']} + formatter={(value: any) => [`${value} hrs`, 'Time']} labelFormatter={(label) => formatDate(label as string)} /> diff --git a/src/components/ContributionGraph.tsx b/src/components/ContributionGraph.tsx index 5d60575e2..e8060c80d 100644 --- a/src/components/ContributionGraph.tsx +++ b/src/components/ContributionGraph.tsx @@ -563,7 +563,7 @@ export default function ContributionGraph() {
{chartType === "bar" ? ( - + ) : chartType === "line" ? ( - + ) : ( - + & { total: number }) { +}: any) { if (!active || !payload || payload.length === 0) return null; const entry = payload[0]; const value = entry.value ?? 0; @@ -166,7 +166,7 @@ export default function PRStatusDonutChart({ ) => ( + content={(props: any) => ( )} /> diff --git a/src/components/repo-analytics/RepoLanguagePie.tsx b/src/components/repo-analytics/RepoLanguagePie.tsx index 0d038c586..62550c78e 100644 --- a/src/components/repo-analytics/RepoLanguagePie.tsx +++ b/src/components/repo-analytics/RepoLanguagePie.tsx @@ -27,7 +27,7 @@ export default function RepoLanguagePie({ data }: { data: LanguageSlice[] }) { }} labelStyle={{ color: "var(--card-foreground)" }} itemStyle={{ color: "var(--card-foreground)" }} - formatter={(value: number, name: string) => [`${value}%`, name]} + formatter={(value: any, name: any) => [`${value} repos`, name]} /> diff --git a/src/lib/badge-rate-limit.ts b/src/lib/badge-rate-limit.ts index 8a212fa2b..c6efa4032 100644 --- a/src/lib/badge-rate-limit.ts +++ b/src/lib/badge-rate-limit.ts @@ -43,7 +43,6 @@ export function checkBadgeRateLimit(ip: string): BadgeRateLimitResult { export function getBadgeClientIp(req: NextRequest): string { return ( - req.ip ?? req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? req.headers.get("x-real-ip") ?? "unknown" diff --git a/src/middleware.ts b/src/middleware.ts index 2db425352..4d6aef675 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -30,7 +30,6 @@ type RateLimitResult = { function getIp(req: NextRequest) { return ( - req.ip ?? req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? req.headers.get("x-real-ip") ?? "unknown" diff --git a/test/github-accounts-api.test.ts b/test/github-accounts-api.test.ts index 96422d213..893b2f5ee 100644 --- a/test/github-accounts-api.test.ts +++ b/test/github-accounts-api.test.ts @@ -120,28 +120,28 @@ describe("GitHub Accounts API Endpoints", () => { (getServerSession as any).mockResolvedValue(null); const req = new NextRequest("http://localhost/api/user/github-accounts/999", { method: "DELETE" }); - const res = await DELETE(req, { params: { githubId: "999" } }); + const res = await DELETE(req, { params: Promise.resolve({ githubId: "999" }) }); expect(res.status).toBe(401); expect(await res.json()).toEqual({ error: "Unauthorized" }); }); it("returns 400 when githubId parameter is empty", async () => { const req = new NextRequest("http://localhost/api/user/github-accounts/ ", { method: "DELETE" }); - const res = await DELETE(req, { params: { githubId: "" } }); + const res = await DELETE(req, { params: Promise.resolve({ githubId: "" }) }); expect(res.status).toBe(400); expect(await res.json()).toEqual({ error: "Invalid githubId parameter" }); }); it("returns 400 when githubId parameter is non-numeric", async () => { const req = new NextRequest("http://localhost/api/user/github-accounts/abc", { method: "DELETE" }); - const res = await DELETE(req, { params: { githubId: "abc" } }); + const res = await DELETE(req, { params: Promise.resolve({ githubId: "abc" }) }); expect(res.status).toBe(400); expect(await res.json()).toEqual({ error: "Invalid githubId parameter" }); }); it("returns 400 when githubId parameter has spaces or special characters", async () => { const req = new NextRequest("http://localhost/api/user/github-accounts/ 123", { method: "DELETE" }); - const res = await DELETE(req, { params: { githubId: " 123 " } }); + const res = await DELETE(req, { params: Promise.resolve({ githubId: " 123 " }) }); expect(res.status).toBe(400); expect(await res.json()).toEqual({ error: "Invalid githubId parameter" }); }); @@ -150,14 +150,14 @@ describe("GitHub Accounts API Endpoints", () => { (resolveAppUser as any).mockResolvedValue(null); const req = new NextRequest("http://localhost/api/user/github-accounts/999", { method: "DELETE" }); - const res = await DELETE(req, { params: { githubId: "999" } }); + const res = await DELETE(req, { params: Promise.resolve({ githubId: "999" }) }); expect(res.status).toBe(401); expect(await res.json()).toEqual({ error: "Unauthorized" }); }); it("returns 400 when trying to remove the primary account", async () => { const req = new NextRequest("http://localhost/api/user/github-accounts/12345", { method: "DELETE" }); - const res = await DELETE(req, { params: { githubId: "12345" } }); + const res = await DELETE(req, { params: Promise.resolve({ githubId: "12345" }) }); expect(res.status).toBe(400); expect(await res.json()).toEqual({ error: "Cannot remove primary account" }); }); @@ -175,7 +175,7 @@ describe("GitHub Accounts API Endpoints", () => { }); const req = new NextRequest("http://localhost/api/user/github-accounts/999", { method: "DELETE" }); - const res = await DELETE(req, { params: { githubId: "999" } }); + const res = await DELETE(req, { params: Promise.resolve({ githubId: "999" }) }); expect(res.status).toBe(500); expect(await res.json()).toEqual({ error: "Delete failed" }); }); @@ -193,14 +193,14 @@ describe("GitHub Accounts API Endpoints", () => { }); const req = new NextRequest("http://localhost/api/user/github-accounts/999", { method: "DELETE" }); - const res = await DELETE(req, { params: { githubId: "999" } }); + const res = await DELETE(req, { params: Promise.resolve({ githubId: "999" }) }); expect(res.status).toBe(404); expect(await res.json()).toEqual({ error: "Account not found" }); }); it("successfully deletes the secondary linked account", async () => { const req = new NextRequest("http://localhost/api/user/github-accounts/999", { method: "DELETE" }); - const res = await DELETE(req, { params: { githubId: "999" } }); + const res = await DELETE(req, { params: Promise.resolve({ githubId: "999" }) }); expect(res.status).toBe(200); expect(await res.json()).toEqual({ success: true }); });