Skip to content
Merged
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
71 changes: 71 additions & 0 deletions app/api/account/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { createServerClient } from "@supabase/ssr";
import { createClient } from "@supabase/supabase-js";
import { cookies } from "next/headers";

export async function DELETE(req: NextRequest) {

Check warning on line 6 in app/api/account/route.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck & Build

'req' is defined but never used
// Authenticate the request via the session cookie
const cookieStore = await cookies();
const supabaseUrl = process.env.NEXT_PUBLIC_POSTGRES_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_POSTGRES_SUPABASE_ANON_KEY!;
const supabaseServiceKey = process.env.POSTGRES_SUPABASE_SERVICE_ROLE_KEY!;

if (!supabaseUrl || !supabaseAnonKey || !supabaseServiceKey) {
return NextResponse.json({ error: "Server misconfigured" }, { status: 500 });
}

// Session-aware client to verify the caller
const sessionClient = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet: { name: string; value: string; options?: Record<string, unknown> }[]) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options as Parameters<typeof cookieStore.set>[2])
);
},
},
});

const {
data: { user },
error: authError,
} = await sessionClient.auth.getUser();

if (authError || !user) {
return NextResponse.json({ error: "Unauthenticated" }, { status: 401 });
}

// Service role client for privileged operations
const adminClient = createClient(supabaseUrl, supabaseServiceKey);

// 1. Delete tool_usage rows
const { error: usageError } = await adminClient
.from("tool_usage")
.delete()
.eq("user_id", user.id);

if (usageError) {
console.error("[account/delete] tool_usage delete failed:", usageError);
return NextResponse.json({ error: "Failed to delete usage data" }, { status: 500 });
}

// 2. Delete profile row
const { error: profileError } = await adminClient.from("profiles").delete().eq("id", user.id);

if (profileError) {
console.error("[account/delete] profiles delete failed:", profileError);
return NextResponse.json({ error: "Failed to delete profile" }, { status: 500 });
}

// 3. Delete the auth user
const { error: deleteUserError } = await adminClient.auth.admin.deleteUser(user.id);

if (deleteUserError) {
console.error("[account/delete] auth user delete failed:", deleteUserError);
return NextResponse.json({ error: "Failed to delete auth account" }, { status: 500 });
}

return NextResponse.json({ ok: true });
}
1 change: 1 addition & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1592,6 +1592,7 @@ export default async function LandingPage() {
{ label: "Builder", href: "/builder" },
{ label: "Compare", href: "/compare" },
{ label: "Genome", href: "/genome" },
{ label: "Privacy", href: "/privacy" },
{ label: "GitHub", href: GITHUB_URL, external: true },
].map(({ label, href, external }) =>
external ? (
Expand Down
197 changes: 197 additions & 0 deletions app/privacy/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import Link from "next/link";
import { pageMeta } from "@/lib/metadata";

export const metadata = pageMeta({
title: "Privacy Policy",
description: "How AIchitect collects, uses, and protects your data.",
path: "/privacy",
});

export default function PrivacyPage() {
return (
<div className="max-w-2xl mx-auto px-6 py-12">
<nav
className="flex items-center gap-1.5 text-xs mb-8"
style={{ color: "var(--text-muted)" }}
>
<Link href="/" className="hover:underline">
AIchitect
</Link>
<span>/</span>
<span style={{ color: "var(--text-secondary)" }}>Privacy Policy</span>
</nav>

<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--text-primary)" }}>
Privacy Policy
</h1>
<p className="text-xs mb-10" style={{ color: "var(--text-muted)" }}>
Last updated: March 2026
</p>

<div className="space-y-8 text-sm leading-relaxed" style={{ color: "var(--text-secondary)" }}>
<section>
<h2 className="text-base font-semibold mb-2" style={{ color: "var(--text-primary)" }}>
1. Who we are
</h2>
<p>
AIchitect (<strong>aichitect.dev</strong>) is a tool discovery and stack-building
platform for AI developers. This policy explains what personal data we collect, why we
collect it, and your rights under applicable data protection law including the EU
General Data Protection Regulation (GDPR).
</p>
<p className="mt-2">
For data-related requests, contact us at:{" "}
<a href="mailto:privacy@aichitect.dev" style={{ color: "var(--accent)" }}>
privacy@aichitect.dev
</a>
</p>
</section>

<section>
<h2 className="text-base font-semibold mb-2" style={{ color: "var(--text-primary)" }}>
2. What we collect
</h2>
<p>When you sign in with GitHub OAuth, we receive and store:</p>
<ul className="mt-2 space-y-1 pl-4 list-disc">
<li>GitHub username</li>
<li>GitHub user ID</li>
<li>Avatar URL (your GitHub profile picture URL)</li>
</ul>
<p className="mt-3">When you interact with the product, we store:</p>
<ul className="mt-2 space-y-1 pl-4 list-disc">
<li>
Tool usage selections — the tools you mark as &quot;I use this&quot;, along with a
timestamp
</li>
</ul>
<p className="mt-3">
We do not collect email addresses, passwords, browsing history, or any data beyond what
is listed above.
</p>
</section>

<section>
<h2 className="text-base font-semibold mb-2" style={{ color: "var(--text-primary)" }}>
3. Why we collect it
</h2>
<ul className="space-y-1 pl-4 list-disc">
<li>
<strong>Authentication</strong> — to identify you across sessions via GitHub OAuth
</li>
<li>
<strong>Personalisation</strong> — to display your tool badge wall at{" "}
<code>/profile/[username]</code> and surface usage counts on tools
</li>
</ul>
<p className="mt-3">
Our lawful basis for processing is <strong>legitimate interests</strong> (providing the
service you signed up for). No data is sold or used for advertising.
</p>
</section>

<section>
<h2 className="text-base font-semibold mb-2" style={{ color: "var(--text-primary)" }}>
4. How long we keep it
</h2>
<p>
Your data is retained for as long as your account exists. Deleting your account
permanently removes all stored data — see section 6.
</p>
</section>

<section>
<h2 className="text-base font-semibold mb-2" style={{ color: "var(--text-primary)" }}>
5. Sub-processors
</h2>
<p>We use the following third-party services to operate AIchitect:</p>
<ul className="mt-2 space-y-2 pl-4 list-disc">
<li>
<strong>Supabase</strong> — database and authentication. Your profile and tool usage
data is stored in a Supabase-managed PostgreSQL instance. Supabase acts as a data
processor under a signed DPA.{" "}
<a
href="https://supabase.com/privacy"
target="_blank"
rel="noopener noreferrer"
style={{ color: "var(--accent)" }}
>
Supabase Privacy Policy ↗
</a>
</li>
<li>
<strong>Vercel</strong> — application hosting and edge network. All HTTP traffic,
including OAuth callbacks, passes through Vercel infrastructure.{" "}
<a
href="https://vercel.com/legal/privacy-policy"
target="_blank"
rel="noopener noreferrer"
style={{ color: "var(--accent)" }}
>
Vercel Privacy Policy ↗
</a>
</li>
</ul>
</section>

<section>
<h2 className="text-base font-semibold mb-2" style={{ color: "var(--text-primary)" }}>
6. Your rights
</h2>
<p>Under GDPR you have the right to:</p>
<ul className="mt-2 space-y-1 pl-4 list-disc">
<li>
<strong>Access</strong> — request a copy of the data we hold about you
</li>
<li>
<strong>Rectification</strong> — ask us to correct inaccurate data
</li>
<li>
<strong>Erasure</strong> — delete your account and all associated data at any time
from your{" "}
<Link href="/profile" style={{ color: "var(--accent)" }}>
profile page
</Link>
</li>
<li>
<strong>Portability</strong> — request your data in a machine-readable format
</li>
<li>
<strong>Object</strong> — object to processing based on legitimate interests
</li>
</ul>
<p className="mt-3">
To exercise any of these rights, email{" "}
<a href="mailto:privacy@aichitect.dev" style={{ color: "var(--accent)" }}>
privacy@aichitect.dev
</a>
. We will respond within 30 days.
</p>
</section>

<section>
<h2 className="text-base font-semibold mb-2" style={{ color: "var(--text-primary)" }}>
7. Cookies
</h2>
<p>
We use a single session cookie set by Supabase Auth to keep you signed in. This cookie
is strictly necessary for the service to function and does not require consent under
GDPR or the ePrivacy Directive.
</p>
<p className="mt-2">
We do not use analytics cookies, advertising cookies, or tracking pixels.
</p>
</section>

<section>
<h2 className="text-base font-semibold mb-2" style={{ color: "var(--text-primary)" }}>
8. Changes to this policy
</h2>
<p>
If we make material changes we will update the date at the top of this page. Continued
use of the service after a change constitutes acceptance.
</p>
</section>
</div>
</div>
);
}
69 changes: 69 additions & 0 deletions app/profile/[username]/ProfileClient.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"use client";

import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { createSupabaseBrowserClient } from "@/lib/db";
import { useUser } from "@/hooks/useUser";
import toolsData from "@/data/tools.json";
import { Tool, getCategoryColor } from "@/lib/types";
import { SITE_URL } from "@/lib/constants";
Expand All @@ -25,6 +27,12 @@ export default function ProfileClient({ username }: Props) {
const [copiedAll, setCopiedAll] = useState(false);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [failedBadges, setFailedBadges] = useState<Set<string>>(new Set());
const [deleteConfirm, setDeleteConfirm] = useState("");
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const { user, signOut } = useUser();
const router = useRouter();
const isOwner = !!user && (user.user_metadata?.user_name as string) === username;

useEffect(() => {
const supabase = createSupabaseBrowserClient();
Expand Down Expand Up @@ -72,6 +80,23 @@ export default function ProfileClient({ username }: Props) {
});
}

async function deleteAccount() {
setDeleteLoading(true);
setDeleteError(null);
try {
const res = await fetch("/api/account", { method: "DELETE" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? "Deletion failed");
}
await signOut();
router.push("/");
} catch (e) {
setDeleteError(e instanceof Error ? e.message : "Something went wrong");
setDeleteLoading(false);
}
}

return (
<div className="max-w-2xl mx-auto px-6 py-10">
{/* Breadcrumb */}
Expand Down Expand Up @@ -231,6 +256,50 @@ export default function ProfileClient({ username }: Props) {
</div>
</>
)}
{isOwner && (
<div className="mt-16 pt-8" style={{ borderTop: "1px solid var(--border)" }}>
<p className="text-xs font-semibold mb-1" style={{ color: "var(--danger, #ff6b6b)" }}>
Delete account
</p>
<p className="text-xs mb-4" style={{ color: "var(--text-muted)" }}>
Permanently deletes your account and all associated data. This cannot be undone.
</p>
<div className="space-y-2 max-w-sm">
<input
type="text"
value={deleteConfirm}
onChange={(e) => setDeleteConfirm(e.target.value)}
placeholder={`Type "${username}" to confirm`}
className="w-full px-3 py-2 rounded-lg text-xs"
style={{
background: "var(--surface-2)",
border: "1px solid var(--border)",
color: "var(--text-primary)",
outline: "none",
}}
/>
{deleteError && (
<p className="text-xs" style={{ color: "var(--danger, #ff6b6b)" }}>
{deleteError}
</p>
)}
<button
onClick={deleteAccount}
disabled={deleteConfirm !== username || deleteLoading}
className="w-full py-2 rounded-lg text-xs font-semibold transition-all"
style={{
background: deleteConfirm === username ? "#ff6b6b22" : "var(--surface-2)",
border: `1px solid ${deleteConfirm === username ? "#ff6b6b44" : "var(--border)"}`,
color: deleteConfirm === username ? "#ff6b6b" : "var(--text-muted)",
cursor: deleteConfirm === username && !deleteLoading ? "pointer" : "default",
opacity: deleteLoading ? 0.6 : 1,
}}
>
{deleteLoading ? "Deleting…" : "Delete my account"}
</button>
</div>
</div>
)}
</div>
);
}
Loading