diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts index 4474a2f..0bb3ff7 100644 --- a/apps/api/src/routes/account.ts +++ b/apps/api/src/routes/account.ts @@ -6,6 +6,7 @@ import { eq, inArray, mcpServers, + memberships, organizations, supportMessages, supportTickets, @@ -14,8 +15,11 @@ import { import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { audit } from '../lib/audit.js'; +import { stopContainer } from '../lib/docker.js'; import { requireAuth } from '../plugins/session.js'; +const SESSION_COOKIE = 'bmm_session'; + const db = createDb(); export async function accountRoutes(app: FastifyInstance): Promise { @@ -179,4 +183,87 @@ export async function accountRoutes(app: FastifyInstance): Promise { supportMessages: userTicketMessages, }); }); + + /** + * GDPR Art. 17 / Swiss DSG Art. 32 — right to erasure. Self-service account + * deletion. Requires the caller to re-type their email (or phone) as a + * confirmation guard. For every org where the caller is the SOLE member, we + * stop its running containers and hard-delete the org (FK cascade removes its + * servers, builds, logs and encrypted secrets). Orgs with other members are + * left intact — only the caller's membership goes. Finally the user row is + * deleted (cascade drops sessions; audit/ticket/template refs are set null). + */ + app.delete('/v1/account', { preHandler: requireAuth }, async (req, reply) => { + const user = req.user!; + const Body = z.object({ confirm: z.string().min(1) }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); + + const [row] = await db + .select({ email: users.email, phone: users.phone }) + .from(users) + .where(eq(users.id, user.userId)) + .limit(1); + if (!row) return reply.code(404).send({ error: 'user_not_found' }); + + // Confirmation: must match the account's own email or phone. + const expected = (row.email ?? row.phone ?? '').trim().toLowerCase(); + if (!expected || parsed.data.confirm.trim().toLowerCase() !== expected) { + return reply.code(400).send({ + error: 'confirm_mismatch', + detail: 'Type your account email (or phone) exactly to confirm deletion.', + }); + } + + const memberRows = await db + .select({ orgId: memberships.orgId }) + .from(memberships) + .where(eq(memberships.userId, user.userId)); + const orgIds = [...new Set(memberRows.map((m) => m.orgId))]; + const deletedOrgIds: string[] = []; + + for (const orgId of orgIds) { + const members = await db + .select({ userId: memberships.userId }) + .from(memberships) + .where(eq(memberships.orgId, orgId)); + // Only erase the org if this user is its sole member — never nuke a + // teammate's data. (Multi-member orgs: just the membership is dropped + // when the user row is deleted below.) + if (members.length > 1) continue; + + // Stop live containers before the cascade removes their DB rows. + const servers = await db + .select({ containerId: mcpServers.containerId, slug: mcpServers.slug }) + .from(mcpServers) + .where(eq(mcpServers.orgId, orgId)); + for (const s of servers) { + if (s.containerId) { + try { + await stopContainer(s.containerId, s.slug ?? undefined); + } catch { + // best-effort — a leftover container must not block erasure + } + } + } + await db.delete(organizations).where(eq(organizations.id, orgId)); + deletedOrgIds.push(orgId); + } + + // Audit while userId is still valid (the row survives erasure; its userId + // is set null by cascade). No orgId — those rows are already gone. + await audit({ + userId: user.userId, + action: 'account.deleted', + resourceType: 'account', + metadata: { deletedOrgIds, email: row.email ?? null }, + ipAddress: req.ip, + }); + + // Delete the user — cascade removes sessions; nulls audit/ticket/template refs. + await db.delete(users).where(eq(users.id, user.userId)); + + reply.clearCookie(SESSION_COOKIE, { path: '/' }); + return reply.send({ ok: true, deletedOrgIds }); + }); } diff --git a/apps/web/app/(dashboard)/settings/account/page.tsx b/apps/web/app/(dashboard)/settings/account/page.tsx index 17ba5d3..6eabd4d 100644 --- a/apps/web/app/(dashboard)/settings/account/page.tsx +++ b/apps/web/app/(dashboard)/settings/account/page.tsx @@ -1,12 +1,33 @@ 'use client'; import { Button } from '@/components/ui/button'; -import { apiUrl } from '@/lib/api'; +import { apiFetch, apiUrl } from '@/lib/api'; import Link from 'next/link'; import { useState } from 'react'; export default function AccountPage() { const [downloading, setDownloading] = useState(false); + const [confirmText, setConfirmText] = useState(''); + const [deleting, setDeleting] = useState(false); + const [delError, setDelError] = useState(null); + + async function deleteAccount() { + if (!confirmText.trim()) return; + if (!confirm('Permanently delete your account and all its data? This cannot be undone.')) return; + setDeleting(true); + setDelError(null); + try { + await apiFetch('/v1/account', { + method: 'DELETE', + body: JSON.stringify({ confirm: confirmText.trim() }), + }); + window.location.href = '/'; + } catch (e) { + const detail = (e as { detail?: { detail?: string; error?: string } }).detail; + setDelError(detail?.detail ?? detail?.error ?? (e as Error).message); + setDeleting(false); + } + } async function downloadExport() { setDownloading(true); @@ -42,20 +63,35 @@ export default function AccountPage() { -
-

Delete account

+
+

+ Delete account +

- We don't do one-click account deletion yet — too easy to fat-finger and lose - paid-tier server configs. Open a ticket and we'll wipe everything within 30 - days (servers, secrets, audit, tickets) per Swiss DSG Art. 32 / GDPR Art. 17. + Permanently erases your account and every organization where you are the only member — + servers, encrypted secrets, builds and history are wiped and running containers are + stopped. This cannot be undone. Swiss DSG Art. 32 / GDPR Art. 17.

-
- - - +

+ Type your account email (or phone) to confirm: +

+
+ setConfirmText(e.target.value)} + placeholder="you@example.com" + className="h-9 w-64 rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-3 text-[13px] outline-none transition-colors focus:border-[--color-border-strong]" + /> +
+ {delError &&

{delError}

}
diff --git a/apps/web/app/(marketing)/page.tsx b/apps/web/app/(marketing)/page.tsx index 722bf69..60e411e 100644 --- a/apps/web/app/(marketing)/page.tsx +++ b/apps/web/app/(marketing)/page.tsx @@ -129,8 +129,8 @@ const TIERS = [ }, { name: 'Enterprise', - price: '€499+', - tag: '/ month', + price: 'Custom', + tag: 'talk to us', features: ['Unlimited', 'Custom infra · on request', 'SSO/SAML · on request', 'Dedicated hosting', 'Customer success'], }, ]; diff --git a/apps/web/app/(marketing)/pricing/page.tsx b/apps/web/app/(marketing)/pricing/page.tsx index ec06ffe..5c05bd8 100644 --- a/apps/web/app/(marketing)/pricing/page.tsx +++ b/apps/web/app/(marketing)/pricing/page.tsx @@ -65,8 +65,8 @@ const TIERS = [ }, { name: 'Enterprise', - price: '€999+', - tag: '/ month', + price: 'Custom', + tag: 'talk to us', description: 'For organizations with custom infrastructure, compliance and scale needs.', model: 'Claude AI', modelDetail: 'Top-tier Claude · EU data-residency option',