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
46 changes: 46 additions & 0 deletions app/api/user/account/route.ts
Original file line number Diff line number Diff line change
@@ -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);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copying response headers with out.headers.set(k, v) can collapse multiple Set-Cookie headers; this may drop cookies when the upstream sets more than one.

🤖 Was this useful? React with 👍 or 👎

}
});
// 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 });
}
}
79 changes: 79 additions & 0 deletions app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,40 @@ type DashboardTab = "generations" | "account";
function DashboardContent() {
const isDevelopment = process.env.NODE_ENV === 'development';
const [activeTab, setActiveTab] = useState<DashboardTab>("generations");
const [isDeleting, setIsDeleting] = useState(false);

// Always call hooks unconditionally, even in development
const { customer, isLoading, error, openBillingPortal, refetch } = useCustomer({
errorOnNotFound: !isDevelopment, // Don't error in development
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
Expand Down Expand Up @@ -91,6 +118,31 @@ function DashboardContent() {
<div>
<p>Credits: {credits} (mock)</p>
<p>Development mode - Autumn billing is disabled.</p>
<div style={{ marginTop: 24, paddingTop: 24, borderTop: "1px solid #e0e0e0" }}>
<h3 style={{ color: "#d32f2f", marginBottom: 8 }}>Delete Account</h3>
<p style={{ fontSize: "0.9em", color: "#666", marginBottom: 16 }}>
Permanently delete your account and all associated data. This action cannot be undone.
</p>
<button
className="nb-btn"
style={{ background: "#d32f2f", color: "white", opacity: isDeleting ? 0.6 : 1 }}
disabled={isDeleting}
onClick={() => {
if (window.confirm(
"Are you sure you want to delete your account?\n\n" +
"This will permanently delete:\n" +
"• All your templates\n" +
"• All your generated thumbnails\n" +
"• All your settings and preferences\n\n" +
"This action cannot be undone!"
)) {
handleDeleteAccount();
}
}}
>
{isDeleting ? 'Deleting...' : 'Delete My Account'}
</button>
</div>
</div>
)}
</div>
Expand Down Expand Up @@ -191,6 +243,33 @@ function DashboardContent() {
<p className="nb-muted">No active products.</p>
)}
</div>

<div className="nb-card" style={{ background: "#fff", borderColor: "#d32f2f" }}>
<div className="nb-feature-title" style={{ color: "#d32f2f" }}>Delete Account</div>
<p className="nb-muted" style={{ marginBottom: 16 }}>
Permanently delete your account and all associated data. This action cannot be undone.
</p>
<button
className="nb-btn"
style={{ background: "#d32f2f", color: "white", opacity: isDeleting ? 0.6 : 1 }}
disabled={isDeleting}
onClick={() => {
if (window.confirm(
"Are you sure you want to delete your account?\n\n" +
"This will permanently delete:\n" +
"• All your templates\n" +
"• All your generated thumbnails\n" +
"• All your settings and preferences\n" +
"• Your billing information\n\n" +
"This action cannot be undone!"
)) {
handleDeleteAccount();
}
}}
>
{isDeleting ? 'Deleting...' : 'Delete My Account'}
</button>
</div>
</div>
)}
</>
Expand Down
51 changes: 49 additions & 2 deletions workers/generate/src/api/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/')) {
Expand Down Expand Up @@ -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<Response> {
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'
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cookie invalidation header uses SameSite=Lax and no Secure, which doesn’t match the production auth cookie attributes; this can prevent reliable deletion in some browsers.

🤖 Was this useful? React with 👍 or 👎

}
});
} 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' }
});
Expand Down
66 changes: 66 additions & 0 deletions workers/generate/src/storage/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Limiting to 100 generations risks leaking older R2 files for users with more history; consider iterating through all generations to collect every key before deletion.

🤖 Was this useful? React with 👍 or 👎

for (const generation of generations) {
const outputs = await this.getGenerationOutputs(generation.id);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only generation_outputs keys are collected; if generation_inputs.r2_key is used for uploaded inputs, those files won’t be deleted and will leak (also applies to other locations in the PR).

🤖 Was this useful? React with 👍 or 👎

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 };
}
}