feat(account): self-service GDPR Art.17 erasure; Enterprise price -> Custom
All checks were successful
Deploy to Production / deploy (push) Successful in 1m19s
All checks were successful
Deploy to Production / deploy (push) Successful in 1m19s
Account deletion (DELETE /v1/account): re-type email/phone to confirm, stops live containers and hard-deletes every org where the caller is sole member (FK cascade clears servers, builds, logs, encrypted secrets), deletes the user (cascade drops sessions), audits the action, clears the session cookie. Frontend danger-zone replaces the old open-a-ticket placeholder. Closes audit ACC-001. Enterprise price unified to Custom on landing + pricing, removing the 499/999 inconsistency. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bd82a67fba
commit
ee4713f82c
@ -6,6 +6,7 @@ import {
|
|||||||
eq,
|
eq,
|
||||||
inArray,
|
inArray,
|
||||||
mcpServers,
|
mcpServers,
|
||||||
|
memberships,
|
||||||
organizations,
|
organizations,
|
||||||
supportMessages,
|
supportMessages,
|
||||||
supportTickets,
|
supportTickets,
|
||||||
@ -14,8 +15,11 @@ import {
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { audit } from '../lib/audit.js';
|
import { audit } from '../lib/audit.js';
|
||||||
|
import { stopContainer } from '../lib/docker.js';
|
||||||
import { requireAuth } from '../plugins/session.js';
|
import { requireAuth } from '../plugins/session.js';
|
||||||
|
|
||||||
|
const SESSION_COOKIE = 'bmm_session';
|
||||||
|
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
|
|
||||||
export async function accountRoutes(app: FastifyInstance): Promise<void> {
|
export async function accountRoutes(app: FastifyInstance): Promise<void> {
|
||||||
@ -179,4 +183,87 @@ export async function accountRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
supportMessages: userTicketMessages,
|
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 });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,33 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { apiUrl } from '@/lib/api';
|
import { apiFetch, apiUrl } from '@/lib/api';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
export default function AccountPage() {
|
export default function AccountPage() {
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [confirmText, setConfirmText] = useState('');
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [delError, setDelError] = useState<string | null>(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() {
|
async function downloadExport() {
|
||||||
setDownloading(true);
|
setDownloading(true);
|
||||||
@ -42,20 +63,35 @@ export default function AccountPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel p-5">
|
<section className="panel border-[--color-danger]/30 p-5">
|
||||||
<h2 className="text-[14px] font-semibold tracking-tight">Delete account</h2>
|
<h2 className="text-[14px] font-semibold tracking-tight text-[--color-danger]">
|
||||||
|
Delete account
|
||||||
|
</h2>
|
||||||
<p className="mt-2 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
|
<p className="mt-2 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
|
||||||
We don't do one-click account deletion yet — too easy to fat-finger and lose
|
Permanently erases your account and every organization where you are the only member —
|
||||||
paid-tier server configs. Open a ticket and we'll wipe everything within 30
|
servers, encrypted secrets, builds and history are wiped and running containers are
|
||||||
days (servers, secrets, audit, tickets) per Swiss DSG Art. 32 / GDPR Art. 17.
|
stopped. This cannot be undone. Swiss DSG Art. 32 / GDPR Art. 17.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4">
|
<p className="mt-3 text-[12px] text-[--color-fg-subtle]">
|
||||||
<Link href="/settings/support">
|
Type your account email (or phone) to confirm:
|
||||||
<Button variant="secondary" size="md">
|
</p>
|
||||||
Open deletion ticket
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
</Button>
|
<input
|
||||||
</Link>
|
value={confirmText}
|
||||||
|
onChange={(e) => 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]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={deleteAccount}
|
||||||
|
disabled={deleting || !confirmText.trim()}
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-[--color-danger]/50 bg-[--color-danger]/10 px-4 text-[13px] font-medium text-[--color-danger] transition-colors hover:bg-[--color-danger]/20 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting…' : 'Delete my account'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{delError && <p className="mt-2 text-[12px] text-[--color-danger]">{delError}</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel p-5">
|
<section className="panel p-5">
|
||||||
|
|||||||
@ -129,8 +129,8 @@ const TIERS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Enterprise',
|
name: 'Enterprise',
|
||||||
price: '€499+',
|
price: 'Custom',
|
||||||
tag: '/ month',
|
tag: 'talk to us',
|
||||||
features: ['Unlimited', 'Custom infra · on request', 'SSO/SAML · on request', 'Dedicated hosting', 'Customer success'],
|
features: ['Unlimited', 'Custom infra · on request', 'SSO/SAML · on request', 'Dedicated hosting', 'Customer success'],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -65,8 +65,8 @@ const TIERS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Enterprise',
|
name: 'Enterprise',
|
||||||
price: '€999+',
|
price: 'Custom',
|
||||||
tag: '/ month',
|
tag: 'talk to us',
|
||||||
description: 'For organizations with custom infrastructure, compliance and scale needs.',
|
description: 'For organizations with custom infrastructure, compliance and scale needs.',
|
||||||
model: 'Claude AI',
|
model: 'Claude AI',
|
||||||
modelDetail: 'Top-tier Claude · EU data-residency option',
|
modelDetail: 'Top-tier Claude · EU data-residency option',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user