Skip to content

Commit 9a4687c

Browse files
authored
Merge pull request #64 from letsgogeeky/feat/AIC-94
feat(compliance): AIC-93 + AIC-94 — privacy policy page and account d…
2 parents 370f52f + 027f1e9 commit 9a4687c

4 files changed

Lines changed: 338 additions & 0 deletions

File tree

app/api/account/route.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { createServerClient } from "@supabase/ssr";
3+
import { createClient } from "@supabase/supabase-js";
4+
import { cookies } from "next/headers";
5+
6+
export async function DELETE(req: NextRequest) {
7+
// Authenticate the request via the session cookie
8+
const cookieStore = await cookies();
9+
const supabaseUrl = process.env.NEXT_PUBLIC_POSTGRES_SUPABASE_URL!;
10+
const supabaseAnonKey = process.env.NEXT_PUBLIC_POSTGRES_SUPABASE_ANON_KEY!;
11+
const supabaseServiceKey = process.env.POSTGRES_SUPABASE_SERVICE_ROLE_KEY!;
12+
13+
if (!supabaseUrl || !supabaseAnonKey || !supabaseServiceKey) {
14+
return NextResponse.json({ error: "Server misconfigured" }, { status: 500 });
15+
}
16+
17+
// Session-aware client to verify the caller
18+
const sessionClient = createServerClient(supabaseUrl, supabaseAnonKey, {
19+
cookies: {
20+
getAll() {
21+
return cookieStore.getAll();
22+
},
23+
setAll(cookiesToSet: { name: string; value: string; options?: Record<string, unknown> }[]) {
24+
cookiesToSet.forEach(({ name, value, options }) =>
25+
cookieStore.set(name, value, options as Parameters<typeof cookieStore.set>[2])
26+
);
27+
},
28+
},
29+
});
30+
31+
const {
32+
data: { user },
33+
error: authError,
34+
} = await sessionClient.auth.getUser();
35+
36+
if (authError || !user) {
37+
return NextResponse.json({ error: "Unauthenticated" }, { status: 401 });
38+
}
39+
40+
// Service role client for privileged operations
41+
const adminClient = createClient(supabaseUrl, supabaseServiceKey);
42+
43+
// 1. Delete tool_usage rows
44+
const { error: usageError } = await adminClient
45+
.from("tool_usage")
46+
.delete()
47+
.eq("user_id", user.id);
48+
49+
if (usageError) {
50+
console.error("[account/delete] tool_usage delete failed:", usageError);
51+
return NextResponse.json({ error: "Failed to delete usage data" }, { status: 500 });
52+
}
53+
54+
// 2. Delete profile row
55+
const { error: profileError } = await adminClient.from("profiles").delete().eq("id", user.id);
56+
57+
if (profileError) {
58+
console.error("[account/delete] profiles delete failed:", profileError);
59+
return NextResponse.json({ error: "Failed to delete profile" }, { status: 500 });
60+
}
61+
62+
// 3. Delete the auth user
63+
const { error: deleteUserError } = await adminClient.auth.admin.deleteUser(user.id);
64+
65+
if (deleteUserError) {
66+
console.error("[account/delete] auth user delete failed:", deleteUserError);
67+
return NextResponse.json({ error: "Failed to delete auth account" }, { status: 500 });
68+
}
69+
70+
return NextResponse.json({ ok: true });
71+
}

app/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1592,6 +1592,7 @@ export default async function LandingPage() {
15921592
{ label: "Builder", href: "/builder" },
15931593
{ label: "Compare", href: "/compare" },
15941594
{ label: "Genome", href: "/genome" },
1595+
{ label: "Privacy", href: "/privacy" },
15951596
{ label: "GitHub", href: GITHUB_URL, external: true },
15961597
].map(({ label, href, external }) =>
15971598
external ? (

app/privacy/page.tsx

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import Link from "next/link";
2+
import { pageMeta } from "@/lib/metadata";
3+
4+
export const metadata = pageMeta({
5+
title: "Privacy Policy",
6+
description: "How AIchitect collects, uses, and protects your data.",
7+
path: "/privacy",
8+
});
9+
10+
export default function PrivacyPage() {
11+
return (
12+
<div className="max-w-2xl mx-auto px-6 py-12">
13+
<nav
14+
className="flex items-center gap-1.5 text-xs mb-8"
15+
style={{ color: "var(--text-muted)" }}
16+
>
17+
<Link href="/" className="hover:underline">
18+
AIchitect
19+
</Link>
20+
<span>/</span>
21+
<span style={{ color: "var(--text-secondary)" }}>Privacy Policy</span>
22+
</nav>
23+
24+
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--text-primary)" }}>
25+
Privacy Policy
26+
</h1>
27+
<p className="text-xs mb-10" style={{ color: "var(--text-muted)" }}>
28+
Last updated: March 2026
29+
</p>
30+
31+
<div className="space-y-8 text-sm leading-relaxed" style={{ color: "var(--text-secondary)" }}>
32+
<section>
33+
<h2 className="text-base font-semibold mb-2" style={{ color: "var(--text-primary)" }}>
34+
1. Who we are
35+
</h2>
36+
<p>
37+
AIchitect (<strong>aichitect.dev</strong>) is a tool discovery and stack-building
38+
platform for AI developers. This policy explains what personal data we collect, why we
39+
collect it, and your rights under applicable data protection law including the EU
40+
General Data Protection Regulation (GDPR).
41+
</p>
42+
<p className="mt-2">
43+
For data-related requests, contact us at:{" "}
44+
<a href="mailto:privacy@aichitect.dev" style={{ color: "var(--accent)" }}>
45+
privacy@aichitect.dev
46+
</a>
47+
</p>
48+
</section>
49+
50+
<section>
51+
<h2 className="text-base font-semibold mb-2" style={{ color: "var(--text-primary)" }}>
52+
2. What we collect
53+
</h2>
54+
<p>When you sign in with GitHub OAuth, we receive and store:</p>
55+
<ul className="mt-2 space-y-1 pl-4 list-disc">
56+
<li>GitHub username</li>
57+
<li>GitHub user ID</li>
58+
<li>Avatar URL (your GitHub profile picture URL)</li>
59+
</ul>
60+
<p className="mt-3">When you interact with the product, we store:</p>
61+
<ul className="mt-2 space-y-1 pl-4 list-disc">
62+
<li>
63+
Tool usage selections — the tools you mark as &quot;I use this&quot;, along with a
64+
timestamp
65+
</li>
66+
</ul>
67+
<p className="mt-3">
68+
We do not collect email addresses, passwords, browsing history, or any data beyond what
69+
is listed above.
70+
</p>
71+
</section>
72+
73+
<section>
74+
<h2 className="text-base font-semibold mb-2" style={{ color: "var(--text-primary)" }}>
75+
3. Why we collect it
76+
</h2>
77+
<ul className="space-y-1 pl-4 list-disc">
78+
<li>
79+
<strong>Authentication</strong> — to identify you across sessions via GitHub OAuth
80+
</li>
81+
<li>
82+
<strong>Personalisation</strong> — to display your tool badge wall at{" "}
83+
<code>/profile/[username]</code> and surface usage counts on tools
84+
</li>
85+
</ul>
86+
<p className="mt-3">
87+
Our lawful basis for processing is <strong>legitimate interests</strong> (providing the
88+
service you signed up for). No data is sold or used for advertising.
89+
</p>
90+
</section>
91+
92+
<section>
93+
<h2 className="text-base font-semibold mb-2" style={{ color: "var(--text-primary)" }}>
94+
4. How long we keep it
95+
</h2>
96+
<p>
97+
Your data is retained for as long as your account exists. Deleting your account
98+
permanently removes all stored data — see section 6.
99+
</p>
100+
</section>
101+
102+
<section>
103+
<h2 className="text-base font-semibold mb-2" style={{ color: "var(--text-primary)" }}>
104+
5. Sub-processors
105+
</h2>
106+
<p>We use the following third-party services to operate AIchitect:</p>
107+
<ul className="mt-2 space-y-2 pl-4 list-disc">
108+
<li>
109+
<strong>Supabase</strong> — database and authentication. Your profile and tool usage
110+
data is stored in a Supabase-managed PostgreSQL instance. Supabase acts as a data
111+
processor under a signed DPA.{" "}
112+
<a
113+
href="https://supabase.com/privacy"
114+
target="_blank"
115+
rel="noopener noreferrer"
116+
style={{ color: "var(--accent)" }}
117+
>
118+
Supabase Privacy Policy ↗
119+
</a>
120+
</li>
121+
<li>
122+
<strong>Vercel</strong> — application hosting and edge network. All HTTP traffic,
123+
including OAuth callbacks, passes through Vercel infrastructure.{" "}
124+
<a
125+
href="https://vercel.com/legal/privacy-policy"
126+
target="_blank"
127+
rel="noopener noreferrer"
128+
style={{ color: "var(--accent)" }}
129+
>
130+
Vercel Privacy Policy ↗
131+
</a>
132+
</li>
133+
</ul>
134+
</section>
135+
136+
<section>
137+
<h2 className="text-base font-semibold mb-2" style={{ color: "var(--text-primary)" }}>
138+
6. Your rights
139+
</h2>
140+
<p>Under GDPR you have the right to:</p>
141+
<ul className="mt-2 space-y-1 pl-4 list-disc">
142+
<li>
143+
<strong>Access</strong> — request a copy of the data we hold about you
144+
</li>
145+
<li>
146+
<strong>Rectification</strong> — ask us to correct inaccurate data
147+
</li>
148+
<li>
149+
<strong>Erasure</strong> — delete your account and all associated data at any time
150+
from your{" "}
151+
<Link href="/profile" style={{ color: "var(--accent)" }}>
152+
profile page
153+
</Link>
154+
</li>
155+
<li>
156+
<strong>Portability</strong> — request your data in a machine-readable format
157+
</li>
158+
<li>
159+
<strong>Object</strong> — object to processing based on legitimate interests
160+
</li>
161+
</ul>
162+
<p className="mt-3">
163+
To exercise any of these rights, email{" "}
164+
<a href="mailto:privacy@aichitect.dev" style={{ color: "var(--accent)" }}>
165+
privacy@aichitect.dev
166+
</a>
167+
. We will respond within 30 days.
168+
</p>
169+
</section>
170+
171+
<section>
172+
<h2 className="text-base font-semibold mb-2" style={{ color: "var(--text-primary)" }}>
173+
7. Cookies
174+
</h2>
175+
<p>
176+
We use a single session cookie set by Supabase Auth to keep you signed in. This cookie
177+
is strictly necessary for the service to function and does not require consent under
178+
GDPR or the ePrivacy Directive.
179+
</p>
180+
<p className="mt-2">
181+
We do not use analytics cookies, advertising cookies, or tracking pixels.
182+
</p>
183+
</section>
184+
185+
<section>
186+
<h2 className="text-base font-semibold mb-2" style={{ color: "var(--text-primary)" }}>
187+
8. Changes to this policy
188+
</h2>
189+
<p>
190+
If we make material changes we will update the date at the top of this page. Continued
191+
use of the service after a change constitutes acceptance.
192+
</p>
193+
</section>
194+
</div>
195+
</div>
196+
);
197+
}

app/profile/[username]/ProfileClient.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"use client";
22

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

2937
useEffect(() => {
3038
const supabase = createSupabaseBrowserClient();
@@ -72,6 +80,23 @@ export default function ProfileClient({ username }: Props) {
7280
});
7381
}
7482

83+
async function deleteAccount() {
84+
setDeleteLoading(true);
85+
setDeleteError(null);
86+
try {
87+
const res = await fetch("/api/account", { method: "DELETE" });
88+
if (!res.ok) {
89+
const body = await res.json().catch(() => ({}));
90+
throw new Error(body.error ?? "Deletion failed");
91+
}
92+
await signOut();
93+
router.push("/");
94+
} catch (e) {
95+
setDeleteError(e instanceof Error ? e.message : "Something went wrong");
96+
setDeleteLoading(false);
97+
}
98+
}
99+
75100
return (
76101
<div className="max-w-2xl mx-auto px-6 py-10">
77102
{/* Breadcrumb */}
@@ -231,6 +256,50 @@ export default function ProfileClient({ username }: Props) {
231256
</div>
232257
</>
233258
)}
259+
{isOwner && (
260+
<div className="mt-16 pt-8" style={{ borderTop: "1px solid var(--border)" }}>
261+
<p className="text-xs font-semibold mb-1" style={{ color: "var(--danger, #ff6b6b)" }}>
262+
Delete account
263+
</p>
264+
<p className="text-xs mb-4" style={{ color: "var(--text-muted)" }}>
265+
Permanently deletes your account and all associated data. This cannot be undone.
266+
</p>
267+
<div className="space-y-2 max-w-sm">
268+
<input
269+
type="text"
270+
value={deleteConfirm}
271+
onChange={(e) => setDeleteConfirm(e.target.value)}
272+
placeholder={`Type "${username}" to confirm`}
273+
className="w-full px-3 py-2 rounded-lg text-xs"
274+
style={{
275+
background: "var(--surface-2)",
276+
border: "1px solid var(--border)",
277+
color: "var(--text-primary)",
278+
outline: "none",
279+
}}
280+
/>
281+
{deleteError && (
282+
<p className="text-xs" style={{ color: "var(--danger, #ff6b6b)" }}>
283+
{deleteError}
284+
</p>
285+
)}
286+
<button
287+
onClick={deleteAccount}
288+
disabled={deleteConfirm !== username || deleteLoading}
289+
className="w-full py-2 rounded-lg text-xs font-semibold transition-all"
290+
style={{
291+
background: deleteConfirm === username ? "#ff6b6b22" : "var(--surface-2)",
292+
border: `1px solid ${deleteConfirm === username ? "#ff6b6b44" : "var(--border)"}`,
293+
color: deleteConfirm === username ? "#ff6b6b" : "var(--text-muted)",
294+
cursor: deleteConfirm === username && !deleteLoading ? "pointer" : "default",
295+
opacity: deleteLoading ? 0.6 : 1,
296+
}}
297+
>
298+
{deleteLoading ? "Deleting…" : "Delete my account"}
299+
</button>
300+
</div>
301+
</div>
302+
)}
234303
</div>
235304
);
236305
}

0 commit comments

Comments
 (0)