From 92a80dabb08a52eae1bd5d5dbc6605e5da929eea Mon Sep 17 00:00:00 2001 From: Atul Upadhyay Date: Wed, 20 May 2026 15:55:16 +0530 Subject: [PATCH 1/4] feat: add privacy and data control settings - Add data export endpoint to download all user data as JSON - Add account deletion endpoint with confirmation - Add PrivacySettings component to dashboard settings - Users can export their data or delete their account - GDPR compliance and user data ownership --- src/app/api/user/data-export/route.ts | 140 +++++++++++++++++++ src/app/dashboard/settings/page.tsx | 3 + src/components/PrivacySettings.tsx | 188 ++++++++++++++++++++++++++ 3 files changed, 331 insertions(+) create mode 100644 src/app/api/user/data-export/route.ts create mode 100644 src/components/PrivacySettings.tsx diff --git a/src/app/api/user/data-export/route.ts b/src/app/api/user/data-export/route.ts new file mode 100644 index 00000000..adc8def9 --- /dev/null +++ b/src/app/api/user/data-export/route.ts @@ -0,0 +1,140 @@ +import { getServerSession } from "next-auth"; +import { NextRequest, NextResponse } from "next/server"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const sections: Record = {}; + + const { data: userData } = await supabaseAdmin + .from("users") + .select("*") + .eq("id", user.id) + .single(); + if (userData) { + sections.user = { + githubLogin: userData.github_login, + isPublic: userData.is_public, + leaderboardOptIn: userData.leaderboard_opt_in, + createdAt: userData.created_at, + }; + } + + const { data: goals } = await supabaseAdmin + .from("goals") + .select("*") + .eq("user_id", user.id); + sections.goals = goals || []; + + const { data: snapshots } = await supabaseAdmin + .from("metric_snapshots") + .select("*") + .eq("user_id", user.id) + .order("snapshot_at", { ascending: false }) + .limit(1000); + sections.metricSnapshots = snapshots || []; + + const { data: webhooks } = await supabaseAdmin + .from("webhook_configs") + .select("id, name, url, events, is_enabled, created_at") + .eq("user_id", user.id); + sections.webhooks = webhooks || []; + + const { data: webhookDeliveries } = await supabaseAdmin + .from("webhook_deliveries") + .select("*") + .eq("webhook_id", webhooks?.map((w) => w.id) || []); + sections.webhookDeliveries = webhookDeliveries || []; + + const { data: streakFreezes } = await supabaseAdmin + .from("streak_freezes") + .select("*") + .eq("user_id", user.id); + sections.streakFreezes = streakFreezes || []; + + const { data: streakMilestones } = await supabaseAdmin + .from("streak_milestones") + .select("*") + .eq("user_id", user.id); + sections.streakMilestones = streakMilestones || []; + + const { data: linkedAccounts } = await supabaseAdmin + .from("user_github_accounts") + .select("*") + .eq("user_id", user.id); + sections.linkedAccounts = linkedAccounts || []; + + const { data: localCodingSessions } = await supabaseAdmin + .from("local_coding_sessions") + .select("*") + .eq("user_id", user.id) + .order("date", { ascending: false }) + .limit(365); + sections.localCodingSessions = localCodingSessions || []; + + return NextResponse.json({ + exportedAt: new Date().toISOString(), + userId: user.id, + githubLogin: session.githubLogin, + sections, + }); +} + +export async function DELETE(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const body = await req.json().catch(() => ({})); + const { confirmText } = body; + + if (confirmText !== "DELETE") { + return NextResponse.json( + { error: "Please type DELETE to confirm account deletion" }, + { status: 400 } + ); + } + + const tablesToDelete = [ + "streak_freezes", + "streak_milestones", + "local_coding_sessions", + "local_coding_api_keys", + "jira_credentials", + "webhook_deliveries", + "webhook_configs", + "user_github_accounts", + "goals", + "metric_snapshots", + ]; + + for (const table of tablesToDelete) { + await supabaseAdmin.from(table).delete().eq("user_id", user.id); + } + + await supabaseAdmin.from("users").delete().eq("id", user.id); + + return NextResponse.json({ + success: true, + message: "All user data has been deleted. You will be signed out.", + }); +} \ No newline at end of file diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index ab68ccb5..0f50cb25 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -4,6 +4,7 @@ import { Suspense, useEffect, useMemo, useState } from "react"; import { useSession } from "next-auth/react"; import { redirect, useSearchParams } from "next/navigation"; import { useHeatmapTheme } from "@/hooks/useHeatmapTheme"; +import PrivacySettings from "@/components/PrivacySettings"; interface UserSettings { id: string; @@ -543,6 +544,8 @@ function SettingsPageContent() { )} + + ); diff --git a/src/components/PrivacySettings.tsx b/src/components/PrivacySettings.tsx new file mode 100644 index 00000000..8a506146 --- /dev/null +++ b/src/components/PrivacySettings.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useState } from "react"; + +interface DataStats { + goals: number; + metricSnapshots: number; + webhooks: number; + linkedAccounts: number; + streakMilestones: number; + streakFreezes: number; + localCodingDays: number; +} + +export default function PrivacySettings() { + const [downloading, setDownloading] = useState(false); + const [deleting, setDeleting] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [deleteConfirmText, setDeleteConfirmText] = useState(""); + const [message, setMessage] = useState<{ kind: "success" | "error"; text: string } | null>(null); + + async function handleExport() { + setDownloading(true); + setMessage(null); + + try { + const res = await fetch("/api/user/data-export"); + if (!res.ok) { + throw new Error("Failed to export data"); + } + + const data = await res.json(); + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `devtrack-data-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + setMessage({ kind: "success", text: "Data exported successfully" }); + } catch { + setMessage({ kind: "error", text: "Failed to export data" }); + } finally { + setDownloading(false); + } + } + + async function handleDeleteAccount() { + if (deleteConfirmText !== "DELETE") { + setMessage({ kind: "error", text: "Please type DELETE to confirm" }); + return; + } + + setDeleting(true); + setMessage(null); + + try { + const res = await fetch("/api/user/data-export", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirmText: "DELETE" }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Failed to delete account"); + } + + window.location.href = "/api/auth/signout"; + } catch (err) { + setMessage({ + kind: "error", + text: err instanceof Error ? err.message : "Failed to delete account", + }); + setDeleting(false); + } + } + + return ( +
+

+ Privacy & Data +

+

+ Manage your data and privacy settings +

+ + {message && ( +
+ {message.text} +
+ )} + +
+
+

+ Data Export +

+

+ Download all your data in JSON format. This includes your goals, + metrics, settings, and more. +

+ +
+ +
+

+ Delete Account +

+

+ Permanently delete all your data from DevTrack. This action cannot be + undone. +

+ + {!showDeleteConfirm ? ( + + ) : ( +
+
+

+ This will permanently delete: +

+
    +
  • • Your account and profile
  • +
  • • All goals and progress data
  • +
  • • Metric history and snapshots
  • +
  • • Webhook configurations
  • +
  • • Linked accounts and integrations
  • +
  • • Local coding time data
  • +
+

+ Type DELETE to confirm: +

+ setDeleteConfirmText(e.target.value)} + placeholder="Type DELETE to confirm" + className="w-full rounded-lg border border-red-500/30 bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] outline-none mb-3" + /> +
+ + +
+
+
+ )} +
+
+
+ ); +} \ No newline at end of file From 936612dd6b5648b21130b6d06a2ce13e10fe302b Mon Sep 17 00:00:00 2001 From: Atul Upadhyay Date: Thu, 21 May 2026 09:23:15 +0530 Subject: [PATCH 2/4] fix: address mentor review feedback for privacy-data-control feature - Fix broken webhook_deliveries export query: change .eq() to .in() for array matching - Remove webhook_deliveries from tablesToDelete (handled by ON DELETE CASCADE) - Replace hardcoded Tailwind colors with CSS variables (--success, --destructive) - Add --destructive CSS variable to globals.css for both light and dark themes - Add EOF newlines to data-export/route.ts and PrivacySettings.tsx --- src/app/api/user/data-export/route.ts | 6 +++--- src/app/globals.css | 2 ++ src/components/PrivacySettings.tsx | 16 ++++++++-------- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/app/api/user/data-export/route.ts b/src/app/api/user/data-export/route.ts index adc8def9..ff6d83f0 100644 --- a/src/app/api/user/data-export/route.ts +++ b/src/app/api/user/data-export/route.ts @@ -53,10 +53,11 @@ export async function GET() { .eq("user_id", user.id); sections.webhooks = webhooks || []; + const webhookIds = webhooks?.map((w) => w.id) || []; const { data: webhookDeliveries } = await supabaseAdmin .from("webhook_deliveries") .select("*") - .eq("webhook_id", webhooks?.map((w) => w.id) || []); + .in("webhook_id", webhookIds); sections.webhookDeliveries = webhookDeliveries || []; const { data: streakFreezes } = await supabaseAdmin @@ -120,7 +121,6 @@ export async function DELETE(req: NextRequest) { "local_coding_sessions", "local_coding_api_keys", "jira_credentials", - "webhook_deliveries", "webhook_configs", "user_github_accounts", "goals", @@ -137,4 +137,4 @@ export async function DELETE(req: NextRequest) { success: true, message: "All user data has been deleted. You will be signed out.", }); -} \ No newline at end of file +} diff --git a/src/app/globals.css b/src/app/globals.css index d1d9f210..c3bc4151 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -13,6 +13,7 @@ --border: #cbd5e1; --accent: #6366f1; --success: #10b981; + --destructive: #ef4444; --accent-soft: rgba(99, 102, 241, 0.15); --accent-foreground: #ffffff; --control: #e2e8f0; @@ -32,6 +33,7 @@ --border: #334155; --accent: #818cf8; --success: #10b981; + --destructive: #ef4444; --accent-soft: rgba(99, 102, 241, 0.2); --accent-foreground: #ffffff; --control: #334155; diff --git a/src/components/PrivacySettings.tsx b/src/components/PrivacySettings.tsx index 8a506146..9e177ffa 100644 --- a/src/components/PrivacySettings.tsx +++ b/src/components/PrivacySettings.tsx @@ -94,7 +94,7 @@ export default function PrivacySettings() {
@@ -132,14 +132,14 @@ export default function PrivacySettings() { {!showDeleteConfirm ? ( ) : (
-
-

+

+

This will permanently delete:

    @@ -150,7 +150,7 @@ export default function PrivacySettings() {
  • • Linked accounts and integrations
  • • Local coding time data
-

+

Type DELETE to confirm:

setDeleteConfirmText(e.target.value)} placeholder="Type DELETE to confirm" - className="w-full rounded-lg border border-red-500/30 bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] outline-none mb-3" + className="w-full rounded-lg border border-[var(--destructive)]/30 bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] outline-none mb-3" />
@@ -185,4 +185,4 @@ export default function PrivacySettings() {
); -} \ No newline at end of file +} From 67a186c77ce1cd28c988346b5daf1451b5ca5fc8 Mon Sep 17 00:00:00 2001 From: Atul Upadhyay Date: Thu, 21 May 2026 09:34:32 +0530 Subject: [PATCH 3/4] fix: replace remaining hardcoded red colors with CSS variables and remove unused interface - Replace error message hardcoded red colors with --destructive CSS variable - Remove unused DataStats interface from PrivacySettings.tsx --- src/components/PrivacySettings.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/components/PrivacySettings.tsx b/src/components/PrivacySettings.tsx index 9e177ffa..0ae124a3 100644 --- a/src/components/PrivacySettings.tsx +++ b/src/components/PrivacySettings.tsx @@ -2,16 +2,6 @@ import { useState } from "react"; -interface DataStats { - goals: number; - metricSnapshots: number; - webhooks: number; - linkedAccounts: number; - streakMilestones: number; - streakFreezes: number; - localCodingDays: number; -} - export default function PrivacySettings() { const [downloading, setDownloading] = useState(false); const [deleting, setDeleting] = useState(false); @@ -95,7 +85,7 @@ export default function PrivacySettings() { className={`mb-4 rounded-lg border p-4 text-sm ${ message.kind === "success" ? "border-[var(--success)]/30 bg-[var(--success)]/10 text-[var(--success)]" - : "border-red-500/30 bg-red-500/10 text-red-400" + : "border-[var(--destructive)]/30 bg-[var(--destructive)]/10 text-[var(--destructive)]" }`} > {message.text} From 97bf7cdc86d48113d824d3d945584ef53fa75b29 Mon Sep 17 00:00:00 2001 From: Atul Upadhyay Date: Thu, 21 May 2026 21:55:27 +0530 Subject: [PATCH 4/4] fix: add session invalidation to DELETE account handler --- src/app/api/user/data-export/route.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/app/api/user/data-export/route.ts b/src/app/api/user/data-export/route.ts index ff6d83f0..a8796c63 100644 --- a/src/app/api/user/data-export/route.ts +++ b/src/app/api/user/data-export/route.ts @@ -133,8 +133,25 @@ export async function DELETE(req: NextRequest) { await supabaseAdmin.from("users").delete().eq("id", user.id); - return NextResponse.json({ + const response = NextResponse.json({ success: true, message: "All user data has been deleted. You will be signed out.", }); + + const useSecureCookies = process.env.NODE_ENV === "production"; + const sessionTokenCookieName = useSecureCookies + ? "__Secure-next-auth.session-token" + : "next-auth.session-token"; + + response.cookies.set({ + name: sessionTokenCookieName, + value: "", + httpOnly: true, + sameSite: "lax", + secure: useSecureCookies, + path: "/", + expires: new Date(0), + }); + + return response; }