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..a8796c63 --- /dev/null +++ b/src/app/api/user/data-export/route.ts @@ -0,0 +1,157 @@ +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 webhookIds = webhooks?.map((w) => w.id) || []; + const { data: webhookDeliveries } = await supabaseAdmin + .from("webhook_deliveries") + .select("*") + .in("webhook_id", webhookIds); + 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_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); + + 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; +} 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/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 new file mode 100644 index 00000000..0ae124a3 --- /dev/null +++ b/src/components/PrivacySettings.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useState } from "react"; + +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-[var(--destructive)]/30 bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] outline-none mb-3" + /> +
+ + +
+
+
+ )} +
+
+
+ ); +}