feat(tenants): hard-delete (purge) for soft-deleted tenants
ci / tc_portal (push) Has been skipped
ci / build_operator (push) Successful in 30s
ci / test_platform_api (push) Successful in 33s
ci / changes (push) Successful in 4s
ci / tc_booking (push) Has been skipped
ci / tc_website (push) Has been skipped
ci / tc_platform_api (push) Successful in 24s
ci / tc_operator (push) Successful in 24s
ci / build_portal (push) Has been skipped
ci / build_booking (push) Has been skipped
ci / build_platform_api (push) Successful in 15s
ci / deploy (push) Successful in 40s

Soft-delete kept the slug occupied forever — no way to remove a test tenant
and reuse its name, and external resources lingered. DELETE
/tenants/:slug/purge (platform-admin only, two-step: refuses anything not
already soft-deleted) tears down the Stalwart service + customer domains
(never the platform apex — the management admin account lives there) and
the Authentik group, then removes domains/subscriptions/invoices/user
links/the tenant doc. Audit trail is kept. Operator detail page shows a
'Purge permanently' card once a tenant is soft-deleted.
This commit is contained in:
Ronni Baslund
2026-06-10 21:07:08 +02:00
parent 25d932d3c1
commit fb4ff48617
5 changed files with 112 additions and 3 deletions
+26 -3
View File
@@ -82,7 +82,7 @@ const INTEGRATIONS = ['authentik', 'stalwart', 'ocis'] as const
type IntegrationKey = (typeof INTEGRATIONS)[number]
// ── Danger-zone state ─────────────────────────────────────────────────────
const dangerAction = ref<'suspend' | 'resume' | 'delete' | null>(null)
const dangerAction = ref<'suspend' | 'resume' | 'delete' | 'purge' | null>(null)
const dangerBusy = ref(false)
const dangerError = ref<string | null>(null)
const reconcileBusy = ref(false)
@@ -99,6 +99,11 @@ async function confirmDanger() {
await $fetch(`/api/tenants/${slug.value}/resume`, { method: 'POST' })
} else if (dangerAction.value === 'delete') {
await $fetch(`/api/tenants/${slug.value}`, { method: 'DELETE' })
} else if (dangerAction.value === 'purge') {
await $fetch(`/api/tenants/${slug.value}/purge`, { method: 'DELETE' })
// The tenant no longer exists — back to the list.
await navigateTo('/tenants')
return
}
await refreshTenant()
dangerAction.value = null
@@ -343,14 +348,27 @@ async function reconcile() {
@click="dangerAction = 'delete'; dangerError = null"
>Soft-delete tenant</UiButton>
</Card>
<Card v-if="tenant.status === 'deleted'">
<h2 class="danger">Purge permanently</h2>
<p>
Tears down external resources (Stalwart domains, Authentik group) and removes
every Mongo record except the audit trail. Frees the slug for reuse.
Irreversible.
</p>
<UiButton
variant="danger"
@click="dangerAction = 'purge'; dangerError = null"
>Purge tenant</UiButton>
</Card>
</div>
</div>
<ConfirmDialog
:open="dangerAction !== null"
:eyebrow="`Tenant · ${tenant.slug}`"
:title="dangerAction === 'delete' ? 'Soft-delete this tenant?' : dangerAction === 'suspend' ? 'Suspend this tenant?' : 'Resume this tenant?'"
:confirm-label="dangerAction === 'delete' ? 'Delete' : dangerAction === 'suspend' ? 'Suspend' : 'Resume'"
:title="dangerAction === 'purge' ? 'Purge this tenant permanently?' : dangerAction === 'delete' ? 'Soft-delete this tenant?' : dangerAction === 'suspend' ? 'Suspend this tenant?' : 'Resume this tenant?'"
:confirm-label="dangerAction === 'purge' ? 'Purge forever' : dangerAction === 'delete' ? 'Delete' : dangerAction === 'suspend' ? 'Suspend' : 'Resume'"
:tone="dangerAction === 'resume' ? 'primary' : 'danger'"
:busy="dangerBusy"
@close="dangerAction = null"
@@ -363,6 +381,11 @@ async function reconcile() {
<p v-else-if="dangerAction === 'resume'">
Lift the suspension on <strong>{{ tenant.name }}</strong>. Logins resume immediately.
</p>
<p v-else-if="dangerAction === 'purge'">
Permanently erase <strong>{{ tenant.name }}</strong>: Stalwart domains and the
Authentik group are deleted, and all records except the audit trail are removed.
This cannot be undone.
</p>
<p v-else>
Mark <strong>{{ tenant.name }}</strong> as deleted. Their data stays in Mongo until
the cleanup job runs; external resources stay untouched.