From d4b08abff36509694144976d969fc2c1098afe2c Mon Sep 17 00:00:00 2001 From: Srushti-Kamble Date: Wed, 20 May 2026 21:07:31 +0530 Subject: [PATCH 1/8] feat: add developer profile summary export --- src/components/ExportButton.tsx | 583 ++++++++++++++++++++++++-------- 1 file changed, 448 insertions(+), 135 deletions(-) diff --git a/src/components/ExportButton.tsx b/src/components/ExportButton.tsx index d7c6de9c..c4790ac7 100644 --- a/src/components/ExportButton.tsx +++ b/src/components/ExportButton.tsx @@ -1,8 +1,8 @@ + "use client"; import { useState } from "react"; import jsPDF from "jspdf"; -import autoTable from "jspdf-autotable"; interface PRData { open: number; @@ -11,11 +11,6 @@ interface PRData { mergeRate: string; } -interface DayData { - day: string; - commits: number; -} - interface Goal { id: string; label: string; @@ -23,8 +18,27 @@ interface Goal { current: number; } +interface ContributionResponse { + data: Record; +} + +interface StreakData { + current: number; + longest: number; + lastCommitDate?: string | null; + totalActiveDays?: number; +} +interface RepoData { + name?: string; + repo?: string; + commits?: number; + contributions?: number; + commitCount?: number; + description?: string; +} + export default function ExportButton() { - const [isExportingCSV, setIsExportingCSV] = useState(false); + const [isCopying, setIsCopying] = useState(false); const [isExportingPDF, setIsExportingPDF] = useState(false); const fetchData = async () => { @@ -32,173 +46,472 @@ export default function ExportButton() { cache: "no-store", }; - const [prRes, goalsRes, contribRes] = await Promise.all([ - fetch(`/api/metrics/prs`, fetchOptions), - fetch(`/api/goals`, fetchOptions), - fetch(`/api/metrics/contributions?days=365`, fetchOptions), - ]); + try { + const [ + prRes, + goalsRes, + contribRes, + streakRes, + reposRes, + ] = await Promise.all([ + fetch("/api/metrics/prs", fetchOptions), + fetch("/api/goals", fetchOptions), + fetch("/api/metrics/contributions?days=365", fetchOptions), + fetch("/api/metrics/streak", fetchOptions), + fetch("/api/metrics/repos", fetchOptions), + ]); - const prData: PRData | null = prRes.ok ? await prRes.json() : null; - const goalsData = goalsRes.ok ? await goalsRes.json() : { goals: [] }; - const contribDataRaw = contribRes.ok ? await contribRes.json() : { data: {} }; + + const prData: PRData | null = prRes.ok + ? await prRes.json() + : null; - const contribData: DayData[] = Object.entries(contribDataRaw.data ?? {}) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([day, commits]) => ({ day, commits: commits as number })); + + const goalsJson = goalsRes.ok + ? await goalsRes.json() + : { goals: [] }; - return { prData, contribData, goalsData: goalsData?.goals as Goal[] }; - }; + const goalsData: Goal[] = Array.isArray(goalsJson?.goals) + ? goalsJson.goals + : []; - const downloadFile = (content: string, filename: string, type: string) => { - const blob = new Blob([content], { type }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }; + + const contribData: ContributionResponse = contribRes.ok + ? await contribRes.json() + : { data: {} }; - const exportCSV = async () => { - setIsExportingCSV(true); - try { - const { prData, goalsData, contribData } = await fetchData(); - - // FIX: Separate sheets using proper CSV sections without dashes - // PR Metrics sheet - let csv = "PR Metrics\n"; - csv += "Open,Merged,Avg Review Hours,Merge Rate\n"; - if (prData) { - csv += `${prData.open},${prData.merged},${prData.avgReviewHours},${prData.mergeRate}\n`; + + const streakData: StreakData | null = streakRes.ok + ? await streakRes.json() + : null; + + + const reposJson = reposRes.ok + ? await reposRes.json() + : { repos: [] }; + console.log("RAW REPOS RESPONSE:", reposJson); + + let reposData: RepoData[] = []; + + + if (Array.isArray(reposJson)) { + reposData = reposJson; + } else if (Array.isArray(reposJson?.repos)) { + reposData = reposJson.repos; + } else if (Array.isArray(reposJson?.data)) { + reposData = reposJson.data; } - // Contributions sheet - if (contribData && contribData.length > 0) { - csv += "\nCommit Activity\n"; - csv += "Date,Commits\n"; - contribData.forEach((d) => { - csv += `${d.day},${d.commits}\n`; - }); + return { + prData, + goalsData, + contribData, + streakData, + reposData, + }; + } catch (error) { + console.error("Fetch error:", error); + + return { + prData: null, + goalsData: [], + contribData: { data: {} }, + streakData: null, + reposData: [], + }; + } + }; + + +const buildSummary = async () => { + const { + prData, + goalsData, + contribData, + streakData, + reposData, + } = await fetchData(); + + + const contributionEntries = Object.entries( + contribData?.data || {} + ); + + const totalCommits = contributionEntries.reduce( + (acc, [, value]) => acc + Number(value || 0), + 0 + ); + + + const completedGoals = goalsData.filter( + (goal) => + Number(goal.current) >= Number(goal.target) + ).length; + + + let bestDayCount = 0; + let bestDayLabel = "โ€”"; + + for (const [date, count] of contributionEntries) { + if (Number(count) > bestDayCount) { + bestDayCount = Number(count); + + bestDayLabel = new Date(date).toLocaleDateString( + "en-US", + { + month: "short", + day: "numeric", + year: "numeric", + } + ); + } + } + + const weeklyData: Record = {}; + + contributionEntries.forEach(([date, count]) => { + const d = new Date(date); + + const firstDay = new Date(d); + + const day = d.getDay(); + + const diff = + firstDay.getDate() - + day + + (day === 0 ? -6 : 1); + + firstDay.setDate(diff); + + const weekKey = firstDay + .toISOString() + .slice(0, 10); + + weeklyData[weekKey] = + (weeklyData[weekKey] || 0) + + Number(count); + }); + + let bestWeekCount = 0; + let bestWeekLabel = "โ€”"; + + Object.entries(weeklyData).forEach( + ([week, count]) => { + if (count > bestWeekCount) { + bestWeekCount = count; + + bestWeekLabel = `Week of ${new Date( + week + ).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })}`; } + } + ); + + const monthlyData: Record = {}; + + contributionEntries.forEach(([date, count]) => { + const monthKey = date.slice(0, 7); + + monthlyData[monthKey] = + (monthlyData[monthKey] || 0) + + Number(count); + }); + + let bestMonthCount = 0; + let bestMonthLabel = "โ€”"; + + Object.entries(monthlyData).forEach( + ([month, count]) => { + if (count > bestMonthCount) { + bestMonthCount = count; - // Goals sheet - if (goalsData && goalsData.length > 0) { - csv += "\nGoals\n"; - csv += "Label,Current,Target,Progress (%)\n"; - goalsData.forEach((g) => { - const pct = g.target > 0 ? ((g.current / g.target) * 100).toFixed(1) : "0"; - csv += `"${g.label}",${g.current},${g.target},${pct}%\n`; + const [year, mon] = month.split("-"); + + bestMonthLabel = new Date( + Number(year), + Number(mon) - 1 + ).toLocaleDateString("en-US", { + month: "long", + year: "numeric", }); } + } + ); + + + const sortedRepos = [...reposData].sort( + (a: any, b: any) => + Number( + b.commits || + b.contributions || + b.commitCount || + b.totalCommits || + b.contributionCount || + 0 + ) - + Number( + a.commits || + a.contributions || + a.commitCount || + a.totalCommits || + a.contributionCount || + 0 + ) + ); + + const topRepo = sortedRepos[0]; - downloadFile(csv, "dashboard-metrics.csv", "text/csv"); + const repoCommits = + Number( + topRepo?.commits || + topRepo?.contributions || + topRepo?.commitCount + ) || 0; + + const currentStreak = + Number(streakData?.current) || 0; + +const longestStreak = + Number(streakData?.longest) || 0; + + + const summary = ` +๐Ÿš€ DevTrack Developer Productivity Summary + +Hey everyone !! + +Excited to share my latest developer productivity snapshot powered by DevTrack! + +๐Ÿ”ฅ Current Streak: ${currentStreak} days + +๐Ÿ† Longest Streak: ${longestStreak} days + +๐Ÿ“ฆ Total Contributions: ${totalCommits} commits + +โšก Best Day: ${bestDayCount} commits (${bestDayLabel}) + +๐Ÿ”ฅ Best Week: ${bestWeekCount} commits (${bestWeekLabel}) + +๐Ÿ“… Most Active Month: ${bestMonthCount} commits (${bestMonthLabel}) + +๐Ÿ”€ PR Merge Rate: ${ + prData?.mergeRate || "0%" + } + +โญ Top Repository: ${ + topRepo?.name || + topRepo?.repo || + "N/A" + } + +Consistent progress is better than perfect progress. + +Looking forward to building more, contributing more, and learning every single day ๐Ÿš€ + +#DevTrack #OpenSource #GitHub #WebDevelopment #GSSoC #DeveloperJourney +`; + + return summary; +}; + const copySummary = async () => { + setIsCopying(true); + + try { + const summary = await buildSummary(); + + await navigator.clipboard.writeText(summary); + + alert("Profile summary copied to clipboard!"); + } catch (error) { + console.error(error); + alert("Failed to copy summary."); } finally { - setIsExportingCSV(false); + setIsCopying(false); } }; const exportPDF = async () => { - setIsExportingPDF(true); - try { - const { prData, goalsData, contribData } = await fetchData(); - const doc = new jsPDF(); - - // Title - doc.setFontSize(20); - doc.setTextColor(40, 40, 40); - doc.text("Dashboard Metrics Export", 14, 20); - doc.setFontSize(10); - doc.setTextColor(120, 120, 120); - doc.text(`Generated on ${new Date().toLocaleDateString()}`, 14, 27); - - // FIX: Track Y position properly after each table - let currentY = 35; - - // PR Analytics section - if (prData) { - doc.setFontSize(13); - doc.setTextColor(40, 40, 40); - doc.text("PR Analytics", 14, currentY); - autoTable(doc, { - startY: currentY + 5, - head: [["Open PRs", "Merged", "Avg Review Time", "Merge Rate"]], - body: [[ - prData.open, - prData.merged, - `${prData.avgReviewHours}h`, - prData.mergeRate, - ]], - styles: { fontSize: 10 }, - headStyles: { fillColor: [59, 130, 246] }, - }); - // FIX: Update currentY after table using lastAutoTable - currentY = (doc as any).lastAutoTable.finalY + 12; - } + setIsExportingPDF(true); - // Goals section - if (goalsData && goalsData.length > 0) { - doc.setFontSize(13); - doc.setTextColor(40, 40, 40); - doc.text("Goals Tracker", 14, currentY); - autoTable(doc, { - startY: currentY + 5, - head: [["Goal Label", "Current", "Target", "Progress"]], - body: goalsData.map((g) => { - const pct = g.target > 0 ? ((g.current / g.target) * 100).toFixed(1) : "0"; - return [g.label, g.current, g.target, `${pct}%`]; - }), - styles: { fontSize: 10 }, - headStyles: { fillColor: [59, 130, 246] }, - }); - currentY = (doc as any).lastAutoTable.finalY + 12; + try { + + const summary = await buildSummary(); + + const cleanSummary = summary + .replace(/๐Ÿš€/g, "") + .replace(/๐Ÿ”ฅ/g, "") + .replace(/๐Ÿ“ฆ/g, "") + .replace(/๐Ÿ”€/g, "") + .replace(/โญ/g, "") + .replace(/๐Ÿ“…/g, "") + .replace(/โšก/g, "") + .replace(/๐Ÿ†/g, "") + .replace(/๐Ÿ“/g, "") + .replace(/๐Ÿ“–/g, "") + .replace(/๐ŸŽฏ/g, ""); + + const doc = new jsPDF({ + orientation: "portrait", + unit: "mm", + format: "a4", + }); + + const pageWidth = + doc.internal.pageSize.getWidth(); + + const pageHeight = + doc.internal.pageSize.getHeight(); + + // HEADER + doc.setFillColor(15, 23, 42); + + doc.rect(0, 0, pageWidth, 28, "F"); + + doc.setTextColor(255, 255, 255); + + doc.setFont("helvetica", "bold"); + + doc.setFontSize(18); + + doc.text( + "DevTrack Productivity Summary", + 14, + 18 + ); + + doc.setFontSize(10); + + doc.text( + `Generated on ${new Date().toLocaleDateString()}`, + 14, + 24 + ); + + + doc.setTextColor(40, 40, 40); + + doc.setFont("helvetica", "normal"); + + doc.setFontSize(11); + + const lines = doc.splitTextToSize( + cleanSummary, + 180 + ); + + let y = 40; + + lines.forEach((line: string) => { + + if (y > pageHeight - 20) { + doc.addPage(); + y = 20; } - // Commit Activity section - if (contribData && contribData.length > 0) { - doc.setFontSize(13); - doc.setTextColor(40, 40, 40); - doc.text("Commit Activity", 14, currentY); - autoTable(doc, { - startY: currentY + 5, - head: [["Date", "Commits"]], - body: contribData.map((d) => [d.day, d.commits]), - styles: { fontSize: 10 }, - headStyles: { fillColor: [59, 130, 246] }, - }); + if ( + line.includes("Current Streak") || + line.includes("Longest Streak") || + line.includes("Total Contributions") || + line.includes("Best Day") || + line.includes("Best Week") || + line.includes("Most Active Month") || + line.includes("PR Merge Rate") || + line.includes("Top Repository") || + line.includes("Repository Activity") || + line.includes("Goals Completed") + ) { + doc.setFont("helvetica", "bold"); + } else { + doc.setFont("helvetica", "normal"); } - doc.save("dashboard-metrics.pdf"); - } finally { - setIsExportingPDF(false); + doc.text(line, 14, y); + + y += 7; + }); + + + const totalPages = doc.getNumberOfPages(); + + for (let i = 1; i <= totalPages; i++) { + doc.setPage(i); + + doc.setFontSize(9); + + doc.setTextColor(120); + + doc.text( + `Page ${i} of ${totalPages}`, + pageWidth - 30, + pageHeight - 10 + ); } - }; + doc.save("devtrack-profile-summary.pdf"); + } catch (error) { + console.error(error); + + alert("Failed to export PDF."); + } finally { + setIsExportingPDF(false); + } +}; return ( -
+
+ +
); From 421fce2cfc9a550b7ed45bd989e407c114c3558d Mon Sep 17 00:00:00 2001 From: Srushti-Kamble Date: Thu, 21 May 2026 00:38:07 +0530 Subject: [PATCH 2/8] fix: address PR review comments --- src/components/ExportButton.tsx | 177 ++++++++++++++++++++++---------- 1 file changed, 120 insertions(+), 57 deletions(-) diff --git a/src/components/ExportButton.tsx b/src/components/ExportButton.tsx index 4d621002..02edc80f 100644 --- a/src/components/ExportButton.tsx +++ b/src/components/ExportButton.tsx @@ -40,6 +40,8 @@ interface RepoData { export default function ExportButton() { const [isCopying, setIsCopying] = useState(false); const [isExportingPDF, setIsExportingPDF] = useState(false); + const [isExportingCSV, setIsExportingCSV] = useState(false); + const [copied, setCopied] = useState(false); const fetchData = async () => { const fetchOptions: RequestInit = { @@ -89,7 +91,7 @@ export default function ExportButton() { const reposJson = reposRes.ok ? await reposRes.json() : { repos: [] }; - console.log("RAW REPOS RESPONSE:", reposJson); + let reposData: RepoData[] = []; @@ -244,33 +246,22 @@ const buildSummary = async () => { const sortedRepos = [...reposData].sort( - (a: any, b: any) => + (a, b) => Number( b.commits || b.contributions || - b.commitCount || - b.totalCommits || - b.contributionCount || - 0 + b.commitCount ) - Number( a.commits || a.contributions || - a.commitCount || - a.totalCommits || - a.contributionCount || - 0 + a.commitCount ) ); const topRepo = sortedRepos[0]; - const repoCommits = - Number( - topRepo?.commits || - topRepo?.contributions || - topRepo?.commitCount - ) || 0; + const currentStreak = Number(streakData?.current) || 0; @@ -308,6 +299,8 @@ Excited to share my latest developer productivity snapshot powered by DevTrack! "N/A" } + ๐ŸŽฏ Goals Completed: ${completedGoals}/${goalsData.length} + Consistent progress is better than perfect progress. Looking forward to building more, contributing more, and learning every single day ๐Ÿš€ @@ -317,23 +310,68 @@ Looking forward to building more, contributing more, and learning every single d return summary; }; - const copySummary = async () => { - setIsCopying(true); - try { - const summary = await buildSummary(); +const downloadFile = ( + content: string, + filename: string, + type: string +) => { + const blob = new Blob([content], { type }); - await navigator.clipboard.writeText(summary); + const url = URL.createObjectURL(blob); - alert("Profile summary copied to clipboard!"); - } catch (error) { - console.error(error); - alert("Failed to copy summary."); - } finally { - setIsCopying(false); - } - }; + const a = document.createElement("a"); + + a.href = url; + + a.download = filename; + + document.body.appendChild(a); + + a.click(); + + document.body.removeChild(a); + + URL.revokeObjectURL(url); +}; + +const exportCSV = async () => { + setIsExportingCSV(true); + + try { + const summary = await buildSummary(); + + downloadFile( + summary, + "devtrack-summary.csv", + "text/csv" + ); + } catch (error) { + console.error(error); + } finally { + setIsExportingCSV(false); + } +}; + + const copySummary = async () => { + setIsCopying(true); + + try { + const summary = await buildSummary(); + await navigator.clipboard.writeText(summary); + + setCopied(true); + + setTimeout(() => { + setCopied(false); + }, 2000); + } catch (error) { + console.error("Failed to copy summary.", error); + } finally { + setIsCopying(false); + } +}; const exportPDF = async () => { setIsExportingPDF(true); @@ -366,7 +404,7 @@ Looking forward to building more, contributing more, and learning every single d const pageHeight = doc.internal.pageSize.getHeight(); - // HEADER + doc.setFillColor(15, 23, 42); doc.rect(0, 0, pageWidth, 28, "F"); @@ -453,9 +491,8 @@ Looking forward to building more, contributing more, and learning every single d doc.save("devtrack-profile-summary.pdf"); } catch (error) { - console.error(error); - - alert("Failed to export PDF."); + + console.error("Failed to export PDF.", error); } finally { setIsExportingPDF(false); } @@ -464,30 +501,56 @@ Looking forward to building more, contributing more, and learning every single d
+ type="button" + onClick={exportCSV} + disabled={isExportingCSV} + className="px-4 py-2 bg-[var(--control)] border border-[var(--border)] text-[var(--card-foreground)] hover:border-[var(--accent)] rounded-lg text-sm font-medium transition-colors flex items-center gap-2 disabled:opacity-50" +> + + + + + {isExportingCSV + ? "Exporting..." + : "Export CSV"} + +
); -} \ No newline at end of file +} From b65a6c977bee9252c906999fd88567c6cc376e12 Mon Sep 17 00:00:00 2001 From: Srushti-Kamble Date: Thu, 21 May 2026 14:00:25 +0530 Subject: [PATCH 3/8] feat: enhance CSV export functionality with detailed metrics --- src/components/ExportButton.tsx | 61 +++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/src/components/ExportButton.tsx b/src/components/ExportButton.tsx index 02edc80f..e8ff14fe 100644 --- a/src/components/ExportButton.tsx +++ b/src/components/ExportButton.tsx @@ -339,20 +339,67 @@ const exportCSV = async () => { setIsExportingCSV(true); try { - const summary = await buildSummary(); + const { + prData, + goalsData, + contribData, + streakData, + } = await fetchData(); + + const contributionEntries = Object.entries( + contribData?.data || {} + ); + + const totalCommits = contributionEntries.reduce( + (acc, [, value]) => acc + Number(value || 0), + 0 + ); + + const completedGoals = goalsData.filter( + (goal) => + Number(goal.current) >= Number(goal.target) + ).length; + + const csvRows = [ + "PR Metrics", + "Open,Merged,Avg Review Hours,Merge Rate", + `${prData?.open || 0},${prData?.merged || 0},${prData?.avgReviewHours || 0},${prData?.mergeRate || "0%"}`, + "", + + "Contribution Metrics", + "Total Contributions,Current Streak,Longest Streak", + `${totalCommits},${streakData?.current || 0},${streakData?.longest || 0}`, + "", + + "Goals", +"Goal,Current,Target,Completed", + +...(goalsData.length > 0 + ? goalsData.map( + (goal) => + `"${goal.label}",${goal.current},${goal.target},${ + Number(goal.current) >= + Number(goal.target) + ? "Yes" + : "No" + }` + ) + : ["NA,NA,NA,NA"]), + ]; + + const csvContent = csvRows.join("\n"); downloadFile( - summary, - "devtrack-summary.csv", - "text/csv" + csvContent, + "devtrack-dashboard-metrics.csv", + "text/csv;charset=utf-8;" ); } catch (error) { - console.error(error); + console.error("Failed to export CSV.", error); } finally { setIsExportingCSV(false); } }; - const copySummary = async () => { setIsCopying(true); @@ -579,3 +626,5 @@ const exportCSV = async () => {
); } + + From 10e9ae8b1c533a3ad5a771a52062b2077013379f Mon Sep 17 00:00:00 2001 From: Srushti-Kamble Date: Sat, 23 May 2026 00:11:59 +0530 Subject: [PATCH 4/8] secure-jwt-authentication --- README.md | 2 +- src/app/api/auth/refresh/route.ts | 83 +++++++++++++++++ src/app/dashboard/page.tsx | 39 ++++++++ src/lib/auth-tokens.ts | 145 ++++++++++++++++++++++++++++++ 4 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 src/app/api/auth/refresh/route.ts create mode 100644 src/lib/auth-tokens.ts diff --git a/README.md b/README.md index 4352e478..9b9d5fdb 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Developer metrics live in too many places โ€” GitHub activity, PR turnaround, is | Layer | Technology | |-------|-----------| | Framework | [Next.js](https://nextjs.org) (App Router) + TypeScript | -| Auth | [NextAuth.js](https://next-auth.js.org) โ€” GitHub OAuth | +| Auth | [NextAuth.js](https://next-auth.js.org) โ€” GitHub OAuth with secure JWT access/refresh token support | | Database | [Supabase](https://supabase.com) (PostgreSQL + Row-Level Security) | | Charts | [Recharts](https://recharts.org) | | Styling | Tailwind CSS with CSS custom properties | diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts new file mode 100644 index 00000000..e0670078 --- /dev/null +++ b/src/app/api/auth/refresh/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + ACCESS_TOKEN_MAX_AGE, + REFRESH_TOKEN_MAX_AGE, + USE_SECURE_COOKIES, + createAccessToken, + createRefreshToken, + getTokenCookieName, + verifyRefreshToken, +} from "@/lib/auth-tokens"; + +function unauthorizedResponse() { + const response = NextResponse.json( + { error: "Invalid refresh token" }, + { status: 401 } + ); + + response.cookies.set({ + name: getTokenCookieName("access"), + value: "", + maxAge: 0, + path: "/", + }); + response.cookies.set({ + name: getTokenCookieName("refresh"), + value: "", + maxAge: 0, + path: "/", + }); + + return response; +} + +export async function POST(req: NextRequest) { + const refreshToken = req.cookies.get(getTokenCookieName("refresh"))?.value; + if (!refreshToken) { + return unauthorizedResponse(); + } + + let payload; + try { + payload = verifyRefreshToken(refreshToken); + } catch { + return unauthorizedResponse(); + } + + const accessToken = createAccessToken({ + githubId: payload.githubId, + githubLogin: payload.githubLogin, + }); + const newRefreshToken = createRefreshToken({ + githubId: payload.githubId, + githubLogin: payload.githubLogin, + }); + + const response = NextResponse.json({ + ok: true, + accessTokenExpiresIn: ACCESS_TOKEN_MAX_AGE, + }); + + response.cookies.set({ + name: getTokenCookieName("access"), + value: accessToken, + httpOnly: true, + sameSite: "lax", + secure: USE_SECURE_COOKIES, + path: "/", + maxAge: ACCESS_TOKEN_MAX_AGE, + }); + response.cookies.set({ + name: getTokenCookieName("refresh"), + value: newRefreshToken, + httpOnly: true, + sameSite: "lax", + secure: USE_SECURE_COOKIES, + path: "/", + maxAge: REFRESH_TOKEN_MAX_AGE, + }); + + return response; +} + +export const GET = POST; diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 333e6d73..e171f24c 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -22,6 +22,14 @@ import { authOptions } from "@/lib/auth"; import { cookies } from "next/headers"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; +import { + ACCESS_TOKEN_MAX_AGE, + createAccessToken, + createRefreshToken, + getTokenCookieName, + REFRESH_TOKEN_MAX_AGE, + USE_SECURE_COOKIES, +} from "@/lib/auth-tokens"; export default async function DashboardPage() { const allowPlaywrightBypass = @@ -31,6 +39,37 @@ export default async function DashboardPage() { ? null : await getServerSession(authOptions); + if (session?.githubId && session?.githubLogin) { + const cookieStore = cookies(); + const accessToken = createAccessToken({ + githubId: session.githubId, + githubLogin: session.githubLogin, + }); + const refreshToken = createRefreshToken({ + githubId: session.githubId, + githubLogin: session.githubLogin, + }); + + cookieStore.set({ + name: getTokenCookieName("access"), + value: accessToken, + httpOnly: true, + sameSite: "lax", + secure: USE_SECURE_COOKIES, + path: "/", + maxAge: ACCESS_TOKEN_MAX_AGE, + }); + cookieStore.set({ + name: getTokenCookieName("refresh"), + value: refreshToken, + httpOnly: true, + sameSite: "lax", + secure: USE_SECURE_COOKIES, + path: "/", + maxAge: REFRESH_TOKEN_MAX_AGE, + }); + } + if (!session && !allowPlaywrightBypass) { redirect("/"); } diff --git a/src/lib/auth-tokens.ts b/src/lib/auth-tokens.ts new file mode 100644 index 00000000..bbf41ae0 --- /dev/null +++ b/src/lib/auth-tokens.ts @@ -0,0 +1,145 @@ +import { createHmac, timingSafeEqual } from "crypto"; + +export const ACCESS_TOKEN_MAX_AGE = 15 * 60; // 15 minutes +export const REFRESH_TOKEN_MAX_AGE = 30 * 24 * 60 * 60; // 30 days +export const USE_SECURE_COOKIES = process.env.NODE_ENV === "production"; + +export type AccessTokenPayload = { + type: "access"; + githubId: string; + githubLogin: string; + iat: number; + exp: number; +}; + +export type RefreshTokenPayload = { + type: "refresh"; + githubId: string; + githubLogin: string; + iat: number; + exp: number; +}; + +function base64UrlEncode(value: Buffer | string) { + return Buffer.from(value) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function base64UrlDecode(value: string) { + const padded = value.padEnd(value.length + ((4 - (value.length % 4)) % 4), "="); + return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64"); +} + +function sign(value: string, secret: string): string { + return createHmac("sha256", secret).update(value).digest("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function createJwt(payload: T, secret: string): string { + const header = { alg: "HS256", typ: "JWT" }; + const encodedHeader = base64UrlEncode(JSON.stringify(header)); + const encodedPayload = base64UrlEncode(JSON.stringify(payload)); + const signature = sign(`${encodedHeader}.${encodedPayload}`, secret); + return `${encodedHeader}.${encodedPayload}.${signature}`; +} + +function verifyJwt(token: string, secret: string) { + const parts = token.split("."); + if (parts.length !== 3) { + throw new Error("Invalid JWT format"); + } + + const [encodedHeader, encodedPayload, signature] = parts; + const expectedSignature = sign(`${encodedHeader}.${encodedPayload}`, secret); + if (!cryptoTimingSafeEqual(signature, expectedSignature)) { + throw new Error("Invalid JWT signature"); + } + + const payloadJson = base64UrlDecode(encodedPayload).toString("utf8"); + const payload = JSON.parse(payloadJson) as { exp?: number }; + + if (typeof payload.exp !== "number" || payload.exp * 1000 < Date.now()) { + throw new Error("JWT expired"); + } + + return payload; +} + +function cryptoTimingSafeEqual(a: string, b: string) { + const aBuffer = Buffer.from(a, "utf8"); + const bBuffer = Buffer.from(b, "utf8"); + if (aBuffer.length !== bBuffer.length) { + return false; + } + return timingSafeEqual(aBuffer, bBuffer); +} + +export function getAuthTokenSecret() { + const secret = process.env.NEXTAUTH_SECRET; + if (!secret) { + throw new Error("NEXTAUTH_SECRET is required for JWT authentication"); + } + return secret; +} + +export function getTokenCookieName(type: "access" | "refresh") { + return `${USE_SECURE_COOKIES ? "__Secure-" : ""}devtrack-${type}-token`; +} + +export function createAccessToken({ + githubId, + githubLogin, +}: { + githubId: string; + githubLogin: string; +}) { + const now = Math.floor(Date.now() / 1000); + return createJwt( + { + type: "access", + githubId, + githubLogin, + iat: now, + exp: now + ACCESS_TOKEN_MAX_AGE, + }, + getAuthTokenSecret() + ); +} + +export function createRefreshToken({ + githubId, + githubLogin, +}: { + githubId: string; + githubLogin: string; +}) { + const now = Math.floor(Date.now() / 1000); + return createJwt( + { + type: "refresh", + githubId, + githubLogin, + iat: now, + exp: now + REFRESH_TOKEN_MAX_AGE, + }, + getAuthTokenSecret() + ); +} + +export function verifyAccessToken(token: string) { + const payload = verifyJwt(token, getAuthTokenSecret()) as AccessTokenPayload; + if (payload.type !== "access") { + throw new Error("Invalid access token"); + } + return payload; +} + +export function verifyRefreshToken(token: string) { + const payload = verifyJwt(token, getAuthTokenSecret()) as RefreshTokenPayload; + if (payload.type !== "refresh") { + throw new Error("Invalid refresh token"); + } + return payload; +} From 98f99180ea88222611381c64121ebcf4c6c9e000 Mon Sep 17 00:00:00 2001 From: Srushti-Kamble Date: Sat, 23 May 2026 00:21:06 +0530 Subject: [PATCH 5/8] secure-jwt-authentication plus fixed merge conflicts --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9b9d5fdb..4352e478 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Developer metrics live in too many places โ€” GitHub activity, PR turnaround, is | Layer | Technology | |-------|-----------| | Framework | [Next.js](https://nextjs.org) (App Router) + TypeScript | -| Auth | [NextAuth.js](https://next-auth.js.org) โ€” GitHub OAuth with secure JWT access/refresh token support | +| Auth | [NextAuth.js](https://next-auth.js.org) โ€” GitHub OAuth | | Database | [Supabase](https://supabase.com) (PostgreSQL + Row-Level Security) | | Charts | [Recharts](https://recharts.org) | | Styling | Tailwind CSS with CSS custom properties | From f9b907ee5919980e1d7caa2c577db60975d7b133 Mon Sep 17 00:00:00 2001 From: Srushti-Kamble Date: Sat, 23 May 2026 00:25:28 +0530 Subject: [PATCH 6/8] Remove unwanted files --- src/components/ExportButton.tsx | 630 -------------------------------- 1 file changed, 630 deletions(-) delete mode 100644 src/components/ExportButton.tsx diff --git a/src/components/ExportButton.tsx b/src/components/ExportButton.tsx deleted file mode 100644 index e8ff14fe..00000000 --- a/src/components/ExportButton.tsx +++ /dev/null @@ -1,630 +0,0 @@ - -"use client"; - -import { useState } from "react"; -import jsPDF from "jspdf"; - -interface PRData { - open: number; - merged: number; - avgReviewHours: number; - mergeRate: string; -} - -interface Goal { - id: string; - label: string; - target: number; - current: number; -} - -interface ContributionResponse { - data: Record; -} - -interface StreakData { - current: number; - longest: number; - lastCommitDate?: string | null; - totalActiveDays?: number; -} -interface RepoData { - name?: string; - repo?: string; - commits?: number; - contributions?: number; - commitCount?: number; - description?: string; -} - -export default function ExportButton() { - const [isCopying, setIsCopying] = useState(false); - const [isExportingPDF, setIsExportingPDF] = useState(false); - const [isExportingCSV, setIsExportingCSV] = useState(false); - const [copied, setCopied] = useState(false); - - const fetchData = async () => { - const fetchOptions: RequestInit = { - cache: "no-store", - }; - - try { - const [ - prRes, - goalsRes, - contribRes, - streakRes, - reposRes, - ] = await Promise.all([ - fetch("/api/metrics/prs", fetchOptions), - fetch("/api/goals", fetchOptions), - fetch("/api/metrics/contributions?days=365", fetchOptions), - fetch("/api/metrics/streak", fetchOptions), - fetch("/api/metrics/repos", fetchOptions), - ]); - - - const prData: PRData | null = prRes.ok - ? await prRes.json() - : null; - - - const goalsJson = goalsRes.ok - ? await goalsRes.json() - : { goals: [] }; - - const goalsData: Goal[] = Array.isArray(goalsJson?.goals) - ? goalsJson.goals - : []; - - - const contribData: ContributionResponse = contribRes.ok - ? await contribRes.json() - : { data: {} }; - - - const streakData: StreakData | null = streakRes.ok - ? await streakRes.json() - : null; - - - const reposJson = reposRes.ok - ? await reposRes.json() - : { repos: [] }; - - - let reposData: RepoData[] = []; - - - if (Array.isArray(reposJson)) { - reposData = reposJson; - } else if (Array.isArray(reposJson?.repos)) { - reposData = reposJson.repos; - } else if (Array.isArray(reposJson?.data)) { - reposData = reposJson.data; - } - - return { - prData, - goalsData, - contribData, - streakData, - reposData, - }; - } catch (error) { - console.error("Fetch error:", error); - - return { - prData: null, - goalsData: [], - contribData: { data: {} }, - streakData: null, - reposData: [], - }; - } - }; - - -const buildSummary = async () => { - const { - prData, - goalsData, - contribData, - streakData, - reposData, - } = await fetchData(); - - - const contributionEntries = Object.entries( - contribData?.data || {} - ); - - const totalCommits = contributionEntries.reduce( - (acc, [, value]) => acc + Number(value || 0), - 0 - ); - - - const completedGoals = goalsData.filter( - (goal) => - Number(goal.current) >= Number(goal.target) - ).length; - - - let bestDayCount = 0; - let bestDayLabel = "โ€”"; - - for (const [date, count] of contributionEntries) { - if (Number(count) > bestDayCount) { - bestDayCount = Number(count); - - bestDayLabel = new Date(date).toLocaleDateString( - "en-US", - { - month: "short", - day: "numeric", - year: "numeric", - } - ); - } - } - - const weeklyData: Record = {}; - - contributionEntries.forEach(([date, count]) => { - const d = new Date(date); - - const firstDay = new Date(d); - - const day = d.getDay(); - - const diff = - firstDay.getDate() - - day + - (day === 0 ? -6 : 1); - - firstDay.setDate(diff); - - const weekKey = firstDay - .toISOString() - .slice(0, 10); - - weeklyData[weekKey] = - (weeklyData[weekKey] || 0) + - Number(count); - }); - - let bestWeekCount = 0; - let bestWeekLabel = "โ€”"; - - Object.entries(weeklyData).forEach( - ([week, count]) => { - if (count > bestWeekCount) { - bestWeekCount = count; - - bestWeekLabel = `Week of ${new Date( - week - ).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - })}`; - } - } - ); - - const monthlyData: Record = {}; - - contributionEntries.forEach(([date, count]) => { - const monthKey = date.slice(0, 7); - - monthlyData[monthKey] = - (monthlyData[monthKey] || 0) + - Number(count); - }); - - let bestMonthCount = 0; - let bestMonthLabel = "โ€”"; - - Object.entries(monthlyData).forEach( - ([month, count]) => { - if (count > bestMonthCount) { - bestMonthCount = count; - - const [year, mon] = month.split("-"); - - bestMonthLabel = new Date( - Number(year), - Number(mon) - 1 - ).toLocaleDateString("en-US", { - month: "long", - year: "numeric", - }); - } - } - ); - - - const sortedRepos = [...reposData].sort( - (a, b) => - Number( - b.commits || - b.contributions || - b.commitCount - ) - - Number( - a.commits || - a.contributions || - a.commitCount - ) - ); - - const topRepo = sortedRepos[0]; - - - - const currentStreak = - Number(streakData?.current) || 0; - -const longestStreak = - Number(streakData?.longest) || 0; - - - const summary = ` -๐Ÿš€ DevTrack Developer Productivity Summary - -Hey everyone !! - -Excited to share my latest developer productivity snapshot powered by DevTrack! - -๐Ÿ”ฅ Current Streak: ${currentStreak} days - -๐Ÿ† Longest Streak: ${longestStreak} days - -๐Ÿ“ฆ Total Contributions: ${totalCommits} commits - -โšก Best Day: ${bestDayCount} commits (${bestDayLabel}) - -๐Ÿ”ฅ Best Week: ${bestWeekCount} commits (${bestWeekLabel}) - -๐Ÿ“… Most Active Month: ${bestMonthCount} commits (${bestMonthLabel}) - -๐Ÿ”€ PR Merge Rate: ${ - prData?.mergeRate || "0%" - } - -โญ Top Repository: ${ - topRepo?.name || - topRepo?.repo || - "N/A" - } - - ๐ŸŽฏ Goals Completed: ${completedGoals}/${goalsData.length} - -Consistent progress is better than perfect progress. - -Looking forward to building more, contributing more, and learning every single day ๐Ÿš€ - -#DevTrack #OpenSource #GitHub #WebDevelopment #GSSoC #DeveloperJourney -`; - - return summary; -}; - -const downloadFile = ( - content: string, - filename: string, - type: string -) => { - const blob = new Blob([content], { type }); - - const url = URL.createObjectURL(blob); - - const a = document.createElement("a"); - - a.href = url; - - a.download = filename; - - document.body.appendChild(a); - - a.click(); - - document.body.removeChild(a); - - URL.revokeObjectURL(url); -}; - -const exportCSV = async () => { - setIsExportingCSV(true); - - try { - const { - prData, - goalsData, - contribData, - streakData, - } = await fetchData(); - - const contributionEntries = Object.entries( - contribData?.data || {} - ); - - const totalCommits = contributionEntries.reduce( - (acc, [, value]) => acc + Number(value || 0), - 0 - ); - - const completedGoals = goalsData.filter( - (goal) => - Number(goal.current) >= Number(goal.target) - ).length; - - const csvRows = [ - "PR Metrics", - "Open,Merged,Avg Review Hours,Merge Rate", - `${prData?.open || 0},${prData?.merged || 0},${prData?.avgReviewHours || 0},${prData?.mergeRate || "0%"}`, - "", - - "Contribution Metrics", - "Total Contributions,Current Streak,Longest Streak", - `${totalCommits},${streakData?.current || 0},${streakData?.longest || 0}`, - "", - - "Goals", -"Goal,Current,Target,Completed", - -...(goalsData.length > 0 - ? goalsData.map( - (goal) => - `"${goal.label}",${goal.current},${goal.target},${ - Number(goal.current) >= - Number(goal.target) - ? "Yes" - : "No" - }` - ) - : ["NA,NA,NA,NA"]), - ]; - - const csvContent = csvRows.join("\n"); - - downloadFile( - csvContent, - "devtrack-dashboard-metrics.csv", - "text/csv;charset=utf-8;" - ); - } catch (error) { - console.error("Failed to export CSV.", error); - } finally { - setIsExportingCSV(false); - } -}; - const copySummary = async () => { - setIsCopying(true); - - try { - const summary = await buildSummary(); - - await navigator.clipboard.writeText(summary); - - setCopied(true); - - setTimeout(() => { - setCopied(false); - }, 2000); - } catch (error) { - console.error("Failed to copy summary.", error); - } finally { - setIsCopying(false); - } -}; - const exportPDF = async () => { - setIsExportingPDF(true); - - try { - - const summary = await buildSummary(); - - const cleanSummary = summary - .replace(/๐Ÿš€/g, "") - .replace(/๐Ÿ”ฅ/g, "") - .replace(/๐Ÿ“ฆ/g, "") - .replace(/๐Ÿ”€/g, "") - .replace(/โญ/g, "") - .replace(/๐Ÿ“…/g, "") - .replace(/โšก/g, "") - .replace(/๐Ÿ†/g, "") - .replace(/๐Ÿ“/g, "") - .replace(/๐Ÿ“–/g, "") - .replace(/๐ŸŽฏ/g, ""); - - const doc = new jsPDF({ - orientation: "portrait", - unit: "mm", - format: "a4", - }); - - const pageWidth = - doc.internal.pageSize.getWidth(); - - const pageHeight = - doc.internal.pageSize.getHeight(); - - - doc.setFillColor(15, 23, 42); - - doc.rect(0, 0, pageWidth, 28, "F"); - - doc.setTextColor(255, 255, 255); - - doc.setFont("helvetica", "bold"); - - doc.setFontSize(18); - - doc.text( - "DevTrack Productivity Summary", - 14, - 18 - ); - - doc.setFontSize(10); - - doc.text( - `Generated on ${new Date().toLocaleDateString()}`, - 14, - 24 - ); - - - doc.setTextColor(40, 40, 40); - - doc.setFont("helvetica", "normal"); - - doc.setFontSize(11); - - const lines = doc.splitTextToSize( - cleanSummary, - 180 - ); - - let y = 40; - - lines.forEach((line: string) => { - - if (y > pageHeight - 20) { - doc.addPage(); - y = 20; - } - - if ( - line.includes("Current Streak") || - line.includes("Longest Streak") || - line.includes("Total Contributions") || - line.includes("Best Day") || - line.includes("Best Week") || - line.includes("Most Active Month") || - line.includes("PR Merge Rate") || - line.includes("Top Repository") || - line.includes("Repository Activity") || - line.includes("Goals Completed") - ) { - doc.setFont("helvetica", "bold"); - } else { - doc.setFont("helvetica", "normal"); - } - - doc.text(line, 14, y); - - y += 7; - }); - - - const totalPages = doc.getNumberOfPages(); - - for (let i = 1; i <= totalPages; i++) { - doc.setPage(i); - - doc.setFontSize(9); - - doc.setTextColor(120); - - doc.text( - `Page ${i} of ${totalPages}`, - pageWidth - 30, - pageHeight - 10 - ); - } - - doc.save("devtrack-profile-summary.pdf"); - } catch (error) { - - console.error("Failed to export PDF.", error); - } finally { - setIsExportingPDF(false); - } -}; - return ( -
- - - - - - -
- ); -} - - From 4bfc88de1e2e17857b07ad10e702978618eae107 Mon Sep 17 00:00:00 2001 From: Srushti-Kamble Date: Sat, 23 May 2026 16:21:18 +0530 Subject: [PATCH 7/8] Remove GET support from refresh token endpoint --- src/app/api/auth/refresh/route.ts | 3 +- src/app/dashboard/page.tsx | 50 ++--- src/app/leaderboard/page.tsx | 306 ++++++++++++++++-------------- src/components/ExportButton.tsx | 2 +- 4 files changed, 197 insertions(+), 164 deletions(-) diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts index e0670078..13d3bd36 100644 --- a/src/app/api/auth/refresh/route.ts +++ b/src/app/api/auth/refresh/route.ts @@ -80,4 +80,5 @@ export async function POST(req: NextRequest) { return response; } -export const GET = POST; + + diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index d12777a8..f52401ba 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -20,9 +20,11 @@ import Link from "next/link"; import PersonalRecords from "@/components/PersonalRecords"; import LocalCodingTime from "@/components/LocalCodingTime"; import RecentActivity from "@/components/RecentActivity"; + import { authOptions } from "@/lib/auth"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; + import { ACCESS_TOKEN_MAX_AGE, createAccessToken, @@ -32,21 +34,23 @@ import { USE_SECURE_COOKIES, } from "@/lib/auth-tokens"; +import { cookies } from "next/headers"; + export default async function DashboardPage() { -<<<<<<< HEAD - const allowPlaywrightBypass = - process.env.PLAYWRIGHT_AUTH_BYPASS === "1" && - cookies().get("playwright-dashboard-auth")?.value === "1"; - const session = allowPlaywrightBypass - ? null - : await getServerSession(authOptions); - - if (session?.githubId && session?.githubLogin) { + const session = await getServerSession(authOptions); + + if (!session) { + redirect("/"); + } + + if (session.githubId && session.githubLogin) { const cookieStore = cookies(); + const accessToken = createAccessToken({ githubId: session.githubId, githubLogin: session.githubLogin, }); + const refreshToken = createRefreshToken({ githubId: session.githubId, githubLogin: session.githubLogin, @@ -61,6 +65,7 @@ export default async function DashboardPage() { path: "/", maxAge: ACCESS_TOKEN_MAX_AGE, }); + cookieStore.set({ name: getTokenCookieName("refresh"), value: refreshToken, @@ -72,17 +77,10 @@ export default async function DashboardPage() { }); } - if (!session && !allowPlaywrightBypass) { - redirect("/"); - } -======= - const session = await getServerSession(authOptions); - if (!session) redirect("/"); ->>>>>>> 2d78d3134d63a440febb87bd49cc261c288d54e5 - return (
+
Settings +
+
@@ -102,13 +102,15 @@ export default async function DashboardPage() {
- {/* Row 1: Contribution graph + Streak + Local Coding Time */} + {/* Row 1 */}
+
+
@@ -120,7 +122,7 @@ export default async function DashboardPage() {
- {/* Row 2: PR metrics, PR breakdown & Time Chart */} + {/* Row 2 */}
@@ -131,30 +133,32 @@ export default async function DashboardPage() {
- {/* Row 3: Issue metrics + CI analytics */} + {/* Row 3 */}
+
- {/* Row 4: Pinned repositories */} + {/* Row 4 */}
- {/* Row 5: Top repos + Language breakdown + Goal tracker */} + {/* Row 5 */}
- {/* Row 6: Recent GitHub activity */} + {/* Row 6 */}
); } + diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx index a11935e9..3a12bd9e 100644 --- a/src/app/leaderboard/page.tsx +++ b/src/app/leaderboard/page.tsx @@ -1,156 +1,184 @@ -import ContributionGraph from "@/components/ContributionGraph"; -import ContributionHeatmap from "@/components/ContributionHeatmap"; -import PRMetrics from "@/components/PRMetrics"; -import PRBreakdownChart from "@/components/PRBreakdownChart"; -import GoalTracker from "@/components/GoalTracker"; -import DashboardHeader from "@/components/DashboardHeader"; -import StreakTracker from "@/components/StreakTracker"; -import TopRepos from "@/components/TopRepos"; -import PinnedRepos from "@/components/PinnedRepos"; -import LanguageBreakdown from "@/components/LanguageBreakdown"; -import CommitTimeChart from "@/components/CommitTimeChart"; -import PRReviewTrendChart from "@/components/PRReviewTrendChart"; -import CIAnalytics from "@/components/CIAnalytics"; -import IssueMetrics from "@/components/IssueMetrics"; -import StreakAtRiskBanner from "@/components/StreakAtRiskBanner"; -import FriendComparison from "@/components/FriendComparison"; -import WeeklySummaryCard from "@/components/WeeklySummaryCard"; -import ExportButton from "@/components/ExportButton"; import Link from "next/link"; -import PersonalRecords from "@/components/PersonalRecords"; -import LocalCodingTime from "@/components/LocalCodingTime"; -import RecentActivity from "@/components/RecentActivity"; -import { authOptions } from "@/lib/auth"; -import { cookies } from "next/headers"; -import { getServerSession } from "next-auth"; -import { redirect } from "next/navigation"; -import { - ACCESS_TOKEN_MAX_AGE, - createAccessToken, - createRefreshToken, - getTokenCookieName, - REFRESH_TOKEN_MAX_AGE, - USE_SECURE_COOKIES, -} from "@/lib/auth-tokens"; - -export default async function DashboardPage() { - const allowPlaywrightBypass = - process.env.PLAYWRIGHT_AUTH_BYPASS === "1" && - cookies().get("playwright-dashboard-auth")?.value === "1"; - const session = allowPlaywrightBypass - ? null - : await getServerSession(authOptions); - - if (session?.githubId && session?.githubLogin) { - const cookieStore = cookies(); - const accessToken = createAccessToken({ - githubId: session.githubId, - githubLogin: session.githubLogin, - }); - const refreshToken = createRefreshToken({ - githubId: session.githubId, - githubLogin: session.githubLogin, - }); - cookieStore.set({ - name: getTokenCookieName("access"), - value: accessToken, - httpOnly: true, - sameSite: "lax", - secure: USE_SECURE_COOKIES, - path: "/", - maxAge: ACCESS_TOKEN_MAX_AGE, - }); - cookieStore.set({ - name: getTokenCookieName("refresh"), - value: refreshToken, - httpOnly: true, - sameSite: "lax", - secure: USE_SECURE_COOKIES, - path: "/", - maxAge: REFRESH_TOKEN_MAX_AGE, - }); - } +type LeaderboardTab = "streak" | "commits" | "prs"; - if (!session && !allowPlaywrightBypass) { - redirect("/"); - } +interface LeaderboardEntry { + rank: number; + username: string; + avatarUrl: string; + profileUrl: string; + streak: number; + commits: number; + prs: number; + score: number; +} - return ( -
- -
- - Settings - - -
- +interface LeaderboardPayload { + generatedAt: string; + refreshSeconds: number; + leaders: Record; +} -
- -
+const tabs: Array<{ id: LeaderboardTab; label: string; metric: string }> = [ + { id: "streak", label: "Streak", metric: "days" }, + { id: "commits", label: "Commits", metric: "this month" }, + { id: "prs", label: "PRs", metric: "this month" }, +]; -
- -
+async function fetchLeaderboard(): Promise { + const baseUrl = + process.env.NEXT_PUBLIC_APP_URL || + process.env.NEXTAUTH_URL || + "http://localhost:3000"; - {/* Row 1: Contribution graph + Streak + Local Coding Time */} -
-
- -
- -
-
- -
-
+ try { + const res = await fetch(`${baseUrl}/api/leaderboard`, { + next: { revalidate: 3600 }, + }); -
- - -
-
+ if (!res.ok) { + return null; + } - {/* Row 2: PR metrics, PR breakdown & Time Chart */} -
- - - -
+ return (await res.json()) as LeaderboardPayload; + } catch (error) { + console.error("Failed to fetch leaderboard:", error); + return null; + } +} -
- -
+function getMetricValue(entry: LeaderboardEntry, tab: LeaderboardTab): number { + if (tab === "streak") return entry.streak; + if (tab === "commits") return entry.commits; + return entry.prs; +} + +export default async function LeaderboardPage({ + searchParams, +}: { + searchParams: { tab?: string }; +}) { + const activeTab = tabs.some((tab) => tab.id === searchParams.tab) + ? (searchParams.tab as LeaderboardTab) + : "streak"; + const leaderboard = await fetchLeaderboard(); + const activeMeta = tabs.find((tab) => tab.id === activeTab) ?? tabs[0]; + const rows = leaderboard?.leaders[activeTab] ?? []; + + return ( +
+
+
+
+ + DevTrack + +

+ Public Leaderboard +

+

+ Opted-in developers ranked by current streak, monthly commits, + and monthly pull request activity. +

+
- {/* Row 3: Issue metrics + CI analytics */} -
-
- + {leaderboard && ( +
+ Updated {new Date(leaderboard.generatedAt).toLocaleString()} +
+ )}
- -
- {/* Row 4: Pinned repositories */} -
- -
+
+ {tabs.map((tab) => { + const active = tab.id === activeTab; + return ( + + {tab.label} + + ); + })} +
- {/* Row 5: Top repos + Language breakdown + Goal tracker */} -
- - - -
+
+
+
Rank
+
Contributor
+
{activeMeta.label}
+
Score
+
Profile
+
- {/* Row 6: Recent GitHub activity */} -
- + {!leaderboard ? ( +
+ Leaderboard data is temporarily unavailable. +
+ ) : rows.length === 0 ? ( +
+ No opted-in public profiles yet. +
+ ) : ( + rows.map((entry) => ( +
+
+ #{entry.rank} +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+
+ @{entry.username} +
+
+ {entry.commits} commits ยท {entry.prs} PRs ยท {entry.streak}d + streak +
+
+
+
+
+ {getMetricValue(entry, activeTab)} +
+
+ {activeMeta.metric} +
+
+
+ {entry.score} +
+
+ + View + +
+
+ )) + )} +
-
+
); -} \ No newline at end of file +} + diff --git a/src/components/ExportButton.tsx b/src/components/ExportButton.tsx index 1578c009..b742548b 100644 --- a/src/components/ExportButton.tsx +++ b/src/components/ExportButton.tsx @@ -202,4 +202,4 @@ export default function ExportButton() {
); -} \ No newline at end of file +} From 1ccd01898469c18f09b118e1aa23a0c39ec0ae87 Mon Sep 17 00:00:00 2001 From: Satyam Pandey Date: Sat, 23 May 2026 20:17:45 +0530 Subject: [PATCH 8/8] fix: address architectural feedback for JWT authentication --- src/app/api/auth/token/route.ts | 64 +++++++++++++++++++++++++++++++++ src/app/dashboard/page.tsx | 44 ++--------------------- src/components/Footer.tsx | 13 +++---- src/lib/auth-tokens.ts | 12 +++++-- 4 files changed, 83 insertions(+), 50 deletions(-) create mode 100644 src/app/api/auth/token/route.ts diff --git a/src/app/api/auth/token/route.ts b/src/app/api/auth/token/route.ts new file mode 100644 index 00000000..c0caeb5b --- /dev/null +++ b/src/app/api/auth/token/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { + ACCESS_TOKEN_MAX_AGE, + REFRESH_TOKEN_MAX_AGE, + USE_SECURE_COOKIES, + createAccessToken, + createRefreshToken, + getTokenCookieName, +} from "@/lib/auth-tokens"; + +/** + * POST /api/auth/token + * + * This endpoint allows users to exchange their existing NextAuth session + * for the parallel JWT authentication tokens. This is intended for + * setting up access for third-party tools, CLI, or mobile applications. + */ +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions); + + if (!session || !session.githubId || !session.githubLogin) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const accessToken = createAccessToken({ + githubId: session.githubId, + githubLogin: session.githubLogin, + }); + + const refreshToken = createRefreshToken({ + githubId: session.githubId, + githubLogin: session.githubLogin, + }); + + const response = NextResponse.json({ + ok: true, + message: "Tokens generated successfully", + accessTokenExpiresIn: ACCESS_TOKEN_MAX_AGE, + }); + + response.cookies.set({ + name: getTokenCookieName("access"), + value: accessToken, + httpOnly: true, + sameSite: "lax", + secure: USE_SECURE_COOKIES, + path: "/", + maxAge: ACCESS_TOKEN_MAX_AGE, + }); + + response.cookies.set({ + name: getTokenCookieName("refresh"), + value: refreshToken, + httpOnly: true, + sameSite: "lax", + secure: USE_SECURE_COOKIES, + path: "/", + maxAge: REFRESH_TOKEN_MAX_AGE, + }); + + return response; +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index b83a9486..3066bd61 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -27,17 +27,6 @@ import { authOptions } from "@/lib/auth"; import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; -import { - ACCESS_TOKEN_MAX_AGE, - createAccessToken, - createRefreshToken, - getTokenCookieName, - REFRESH_TOKEN_MAX_AGE, - USE_SECURE_COOKIES, -} from "@/lib/auth-tokens"; - -import { cookies } from "next/headers"; - export default async function DashboardPage() { const session = await getServerSession(authOptions); @@ -46,37 +35,8 @@ export default async function DashboardPage() { } if (session.githubId && session.githubLogin) { - const cookieStore = cookies(); - - const accessToken = createAccessToken({ - githubId: session.githubId, - githubLogin: session.githubLogin, - }); - - const refreshToken = createRefreshToken({ - githubId: session.githubId, - githubLogin: session.githubLogin, - }); - - cookieStore.set({ - name: getTokenCookieName("access"), - value: accessToken, - httpOnly: true, - sameSite: "lax", - secure: USE_SECURE_COOKIES, - path: "/", - maxAge: ACCESS_TOKEN_MAX_AGE, - }); - - cookieStore.set({ - name: getTokenCookieName("refresh"), - value: refreshToken, - httpOnly: true, - sameSite: "lax", - secure: USE_SECURE_COOKIES, - path: "/", - maxAge: REFRESH_TOKEN_MAX_AGE, - }); + // Note: JWT token generation for third-party clients (mobile apps, CLI, etc.) + // is available via the /api/auth/token endpoint. } return ( diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 89c690e1..a6cd179b 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -47,7 +47,7 @@ export default function Footer() { className="transition-colors hover:text-[var(--card-foreground)]" href="https://github.com/Priyanshu-byte-coder/devtrack/discussions" target="_blank" - rel="noreferrer" + rel="noopener noreferrer" > Discussions @@ -55,7 +55,7 @@ export default function Footer() { className="transition-colors hover:text-[var(--card-foreground)]" href="https://github.com/Priyanshu-byte-coder/devtrack/issues" target="_blank" - rel="noreferrer" + rel="noopener noreferrer" > Issues @@ -63,7 +63,7 @@ export default function Footer() { className="transition-colors hover:text-[var(--card-foreground)]" href="https://github.com/Priyanshu-byte-coder/devtrack" target="_blank" - rel="noreferrer" + rel="noopener noreferrer" > GitHub Repository @@ -79,7 +79,7 @@ export default function Footer() { className="transition-colors hover:text-[var(--card-foreground)]" href="https://www.linkedin.com/in/priyanshu-doshi-21a54230a/" target="_blank" - rel="noreferrer" + rel="noopener noreferrer" > LinkedIn @@ -87,7 +87,7 @@ export default function Footer() { className="transition-colors hover:text-[var(--card-foreground)]" href="https://github.com/Priyanshu-byte-coder" target="_blank" - rel="noreferrer" + rel="noopener noreferrer" > GitHub @@ -95,7 +95,7 @@ export default function Footer() { className="transition-colors hover:text-[var(--card-foreground)]" href="https://portfolio-eta-gilt-84.vercel.app/" target="_blank" - rel="noreferrer" + rel="noopener noreferrer" > Portfolio @@ -103,6 +103,7 @@ export default function Footer() { className="transition-colors hover:text-[var(--card-foreground)]" href="mailto:doshipriyanshu3@gmail.com" target="_blank" + rel="noopener noreferrer" > Email diff --git a/src/lib/auth-tokens.ts b/src/lib/auth-tokens.ts index bbf41ae0..1eda3e6e 100644 --- a/src/lib/auth-tokens.ts +++ b/src/lib/auth-tokens.ts @@ -1,3 +1,11 @@ +/** + * Intended Use Case for this JWT System: + * The primary DevTrack web application uses NextAuth (session cookies) for authentication. + * This parallel JWT-based authentication system provides long-lived refresh tokens and + * short-lived access tokens specifically designed for external API clients, CLI tools, + * or mobile applications that cannot rely on browser-based NextAuth session mechanisms. + */ + import { createHmac, timingSafeEqual } from "crypto"; export const ACCESS_TOKEN_MAX_AGE = 15 * 60; // 15 minutes @@ -77,9 +85,9 @@ function cryptoTimingSafeEqual(a: string, b: string) { } export function getAuthTokenSecret() { - const secret = process.env.NEXTAUTH_SECRET; + const secret = process.env.JWT_SECRET || process.env.NEXTAUTH_SECRET; if (!secret) { - throw new Error("NEXTAUTH_SECRET is required for JWT authentication"); + throw new Error("JWT_SECRET or NEXTAUTH_SECRET is required for JWT authentication"); } return secret; }