diff --git a/app/api/user/account/route.ts b/app/api/user/account/route.ts new file mode 100644 index 0000000..87bd77a --- /dev/null +++ b/app/api/user/account/route.ts @@ -0,0 +1,46 @@ +export const runtime = 'edge'; + +import { NextRequest, NextResponse } from 'next/server'; + +const WORKER_BASE = process.env.WORKER_BASE_URL || 'https://creator-tool-hub.techfren.workers.dev'; + +function forwardHeaders(req: NextRequest) { + const headers = new Headers(); + const keep = ['cookie', 'authorization', 'content-type', 'accept']; + for (const key of keep) { + const v = req.headers.get(key); + if (v) headers.set(key, v); + } + // Forward client IP hints when present + const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for'); + if (ip) headers.set('x-forwarded-for', ip); + const host = req.headers.get('host'); + if (host) headers.set('x-forwarded-host', host); + return headers; +} + +export async function DELETE(req: NextRequest) { + try { + const target = `${WORKER_BASE}/api/user/account`; + + const res = await fetch(target, { + method: 'DELETE', + headers: forwardHeaders(req), + }); + + // Stream through status and body + const body = await res.arrayBuffer(); + const out = new Response(body, { status: res.status, statusText: res.statusText }); + res.headers.forEach((v, k) => { + // Avoid setting hop-by-hop headers + if (!['content-encoding', 'transfer-encoding'].includes(k.toLowerCase())) { + out.headers.set(k, v); + } + }); + // Ensure JSON content-type for JSON responses + if (!out.headers.get('content-type')) out.headers.set('content-type', 'application/json'); + return out; + } catch (err: any) { + return NextResponse.json({ error: 'Upstream error', message: String(err?.message || err) }, { status: 502 }); + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 2691483..7302a1f 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -11,6 +11,7 @@ type DashboardTab = "generations" | "account"; function DashboardContent() { const isDevelopment = process.env.NODE_ENV === 'development'; const [activeTab, setActiveTab] = useState("generations"); + const [isDeleting, setIsDeleting] = useState(false); // Always call hooks unconditionally, even in development const { customer, isLoading, error, openBillingPortal, refetch } = useCustomer({ @@ -18,6 +19,32 @@ function DashboardContent() { expand: ["invoices", "entities"] }); + const handleDeleteAccount = async () => { + if (isDeleting) return; + + setIsDeleting(true); + try { + const response = await fetch('/api/user/account', { + method: 'DELETE', + credentials: 'include', + }); + + if (response.ok) { + // Account deleted successfully + alert('Your account has been deleted successfully.'); + // Force a hard refresh to clear all cached state and redirect to home + window.location.replace('/'); + } else { + const data = await response.json(); + alert(`Failed to delete account: ${data.error || 'Unknown error'}`); + setIsDeleting(false); + } + } catch (err) { + alert('Network error during account deletion. Please try again.'); + setIsDeleting(false); + } + }; + const credits = useMemo(() => { if (isDevelopment) return 999; // Mock credits in development @@ -91,6 +118,31 @@ function DashboardContent() {

Credits: {credits} (mock)

Development mode - Autumn billing is disabled.

+
+

Delete Account

+

+ Permanently delete your account and all associated data. This action cannot be undone. +

+ +
)} @@ -191,6 +243,33 @@ function DashboardContent() {

No active products.

)} + +
+
Delete Account
+

+ Permanently delete your account and all associated data. This action cannot be undone. +

+ +
)} diff --git a/workers/generate/src/api/user.ts b/workers/generate/src/api/user.ts index f9fd702..29f8d07 100644 --- a/workers/generate/src/api/user.ts +++ b/workers/generate/src/api/user.ts @@ -67,6 +67,8 @@ export class UserAPI { // Route to appropriate handler if (path === '/api/user/profile') { return this.handleProfile(request, userId, method); + } else if (path === '/api/user/account') { + return this.handleAccount(request, userId, user.email, method); } else if (path === '/api/user/templates') { return this.handleTemplates(request, userId, method); } else if (path.startsWith('/api/user/templates/')) { @@ -115,8 +117,53 @@ export class UserAPI { headers: { 'Content-Type': 'application/json' } }); } - - return new Response(JSON.stringify({ error: "Method not allowed" }), { + + return new Response(JSON.stringify({ error: "Method not allowed" }), { + status: 405, + headers: { 'Content-Type': 'application/json' } + }); + } + + private async handleAccount(request: Request, userId: string, email: string, method: string): Promise { + if (method === 'DELETE') { + try { + // Delete all user data from the database and get R2 keys to delete + const { r2Keys } = await this.db.deleteUser(userId); + + // Delete all files from R2 storage + for (const key of r2Keys) { + try { + await this.r2.deleteFile(key); + } catch (error) { + console.warn(`Failed to delete R2 file: ${key}`, error); + // Continue with other deletions even if one fails + } + } + + // Return success response with invalidated auth cookie + return new Response(JSON.stringify({ + success: true, + message: 'Account deleted successfully' + }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + // Clear the auth cookie + 'Set-Cookie': 'auth-token=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0' + } + }); + } catch (error) { + console.error('Account deletion error:', error); + return new Response(JSON.stringify({ + error: error instanceof Error ? error.message : 'Failed to delete account' + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + } + + return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { 'Content-Type': 'application/json' } }); diff --git a/workers/generate/src/storage/database.ts b/workers/generate/src/storage/database.ts index 6a16c02..ce8a550 100644 --- a/workers/generate/src/storage/database.ts +++ b/workers/generate/src/storage/database.ts @@ -676,4 +676,70 @@ export class DatabaseService { if (!result) return null; return convertUserSettingsRow(result); } + + /** + * Delete all user data and the user record + * Returns objects to be deleted from R2 storage + */ + async deleteUser(userId: string): Promise<{ r2Keys: string[] }> { + const r2Keys: string[] = []; + + // Get all templates and their reference images + const templates = await this.getTemplates(userId); + for (const template of templates) { + const refImages = await this.getReferenceImages(template.id); + for (const img of refImages) { + r2Keys.push(img.r2_key); + } + } + + // Get all generations and their outputs + const generations = await this.getGenerations(userId, { limit: 100 }); + for (const generation of generations) { + const outputs = await this.getGenerationOutputs(generation.id); + for (const output of outputs) { + r2Keys.push(output.r2_key); + } + } + + // Delete in correct order (respecting foreign key constraints) + // 1. Delete generation outputs and inputs (no foreign keys) + await this.db.prepare(` + DELETE FROM generation_outputs + WHERE generation_id IN (SELECT id FROM generations WHERE user_id = ?) + `).bind(userId).run(); + + await this.db.prepare(` + DELETE FROM generation_inputs + WHERE generation_id IN (SELECT id FROM generations WHERE user_id = ?) + `).bind(userId).run(); + + // 2. Delete generations + await this.db.prepare(` + DELETE FROM generations WHERE user_id = ? + `).bind(userId).run(); + + // 3. Delete reference images + await this.db.prepare(` + DELETE FROM reference_images + WHERE template_id IN (SELECT id FROM user_templates WHERE user_id = ?) + `).bind(userId).run(); + + // 4. Delete templates + await this.db.prepare(` + DELETE FROM user_templates WHERE user_id = ? + `).bind(userId).run(); + + // 5. Delete settings + await this.db.prepare(` + DELETE FROM user_settings WHERE user_id = ? + `).bind(userId).run(); + + // 6. Finally delete the user + await this.db.prepare(` + DELETE FROM users WHERE id = ? + `).bind(userId).run(); + + return { r2Keys }; + } }