Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions src/app/api/user/data-export/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};

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;
}
3 changes: 3 additions & 0 deletions src/app/dashboard/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -543,6 +544,8 @@ function SettingsPageContent() {
)}
</div>
</div>

<PrivacySettings />
</div>
</div>
);
Expand Down
2 changes: 2 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
178 changes: 178 additions & 0 deletions src/components/PrivacySettings.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm">
<h2 className="text-xl font-semibold text-[var(--card-foreground)] mb-1">
Privacy & Data
</h2>
<p className="text-sm text-[var(--muted-foreground)] mb-6">
Manage your data and privacy settings
</p>

{message && (
<div
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-[var(--destructive)]/30 bg-[var(--destructive)]/10 text-[var(--destructive)]"
}`}
>
{message.text}
</div>
)}

<div className="space-y-6">
<div>
<h3 className="text-sm font-semibold text-[var(--card-foreground)] mb-2">
Data Export
</h3>
<p className="text-sm text-[var(--muted-foreground)] mb-4">
Download all your data in JSON format. This includes your goals,
metrics, settings, and more.
</p>
<button
onClick={handleExport}
disabled={downloading}
className="rounded-lg bg-[var(--accent)] px-4 py-2 text-sm font-medium text-white transition hover:opacity-90 disabled:opacity-60"
>
{downloading ? "Exporting..." : "Export My Data"}
</button>
</div>

<div className="border-t border-[var(--border)] pt-6">
<h3 className="text-sm font-semibold text-[var(--card-foreground)] mb-2">
Delete Account
</h3>
<p className="text-sm text-[var(--muted-foreground)] mb-4">
Permanently delete all your data from DevTrack. This action cannot be
undone.
</p>

{!showDeleteConfirm ? (
<button
onClick={() => setShowDeleteConfirm(true)}
className="rounded-lg border border-[var(--destructive)]/30 px-4 py-2 text-sm font-medium text-[var(--destructive)] transition hover:bg-[var(--destructive)]/10"
>
Delete My Account
</button>
) : (
<div className="space-y-4">
<div className="rounded-lg border border-[var(--destructive)]/30 bg-[var(--destructive)]/10 p-4">
<p className="text-sm text-[var(--destructive)] mb-3">
This will permanently delete:
</p>
<ul className="text-xs text-[var(--muted-foreground)] space-y-1 mb-4">
<li>• Your account and profile</li>
<li>• All goals and progress data</li>
<li>• Metric history and snapshots</li>
<li>• Webhook configurations</li>
<li>• Linked accounts and integrations</li>
<li>• Local coding time data</li>
</ul>
<p className="text-sm text-[var(--destructive)] mb-3">
Type <strong>DELETE</strong> to confirm:
</p>
<input
type="text"
value={deleteConfirmText}
onChange={(e) => 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"
/>
<div className="flex gap-2">
<button
onClick={handleDeleteAccount}
disabled={deleting || deleteConfirmText !== "DELETE"}
className="rounded-lg bg-[var(--destructive)] px-4 py-2 text-sm font-medium text-white transition hover:bg-[var(--destructive)]/90 disabled:opacity-60"
>
{deleting ? "Deleting..." : "Confirm Delete"}
</button>
<button
onClick={() => {
setShowDeleteConfirm(false);
setDeleteConfirmText("");
}}
className="rounded-lg border border-[var(--border)] px-4 py-2 text-sm font-medium text-[var(--foreground)] transition hover:bg-[var(--control)]"
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
Loading