feat(platform): real email domains, mailboxes & member lifecycle

Wire the mail/identity stack to real Stalwart/Authentik/OCIS provisioning,
replacing the mocked Domains and Users pages.

Domains (customer-admin):
- StalwartClient: real JMAP management (v0.16 dropped REST) — create/list/delete
  email domains via x:Domain at the internal http://stalwart:8080 listener;
  DKIM auto-generated; the records to publish are read from the domain's
  dnsZoneFile. Gated by STALWART_PROVISIONING_ENABLED.
- New Domain collection + DomainsModule: add/list/recheck/set-DMARC/remove,
  tenant-membership-gated and audited.
- DnsVerifierService: verifies MX/SPF/DKIM/DMARC/ownership against a public
  resolver (1.1.1.1/8.8.8.8) and diffs them against the expected records.
- Remove is guarded: refuses while accounts/aliases/mailing lists still use the
  domain (via Stalwart referential integrity).
- Domains page + add wizard on real data; sidebar badge counts domains needing
  attention.

Users & groups (customer-admin):
- Create a member provisioned across Authentik SSO, a Stalwart mailbox on the
  tenant's primary domain, and OCIS — returning a one-time password.
- Lifecycle: suspend/resume (Authentik is_active + freeze the mailbox via
  account permissions, original password preserved), force-logout (terminate
  sessions, filtered client-side so it can never end other users' sessions),
  reset password (new one-time password on SSO + mailbox), and remove (tear down
  mailbox + SSO identity + OCIS + doc; mailbox-in-use aware for multi-tenant
  users). Self-suspend / self-force-logout are blocked.

Infra: point platform-api at the internal Stalwart listener; document the new
STALWART_/provisioning vars in .env.example.
This commit is contained in:
Ronni Baslund
2026-06-01 21:19:42 +02:00
parent 2a43a7bbf3
commit 47eb9502f8
40 changed files with 3235 additions and 554 deletions
+26 -2
View File
@@ -63,7 +63,7 @@ const ADMIN_NAV: NavRow[] = [
{ id: 'mail', label: 'Mail settings', icon: 'mail', href: '/admin/mail' },
{ id: 'meetings', label: 'Meetings', icon: 'video', href: '/admin/meetings' },
{ id: 'chat', label: 'Chat', icon: 'chat', href: '/admin/chat' },
{ id: 'domains', label: 'Domains', icon: 'globe', href: '/admin/domains', badge: 1 },
{ id: 'domains', label: 'Domains', icon: 'globe', href: '/admin/domains' },
{ id: 'storage', label: 'Storage', icon: 'database', href: '/admin/storage' },
{ id: 'security', label: 'Security & audit', icon: 'shield', href: '/admin/security' },
{ sec: 'Commercial' },
@@ -97,7 +97,15 @@ const navItems = computed<NavRow[]>(() => {
: row,
)
}
if (section.value === 'admin') return ADMIN_NAV
if (section.value === 'admin') {
// Inject the count of domains needing attention onto the Domains row.
// Undefined when 0 so the badge hides rather than rendering "0".
return ADMIN_NAV.map((row) =>
'id' in row && row.id === 'domains'
? { ...row, badge: domainsNeedingAttention.value || undefined }
: row,
)
}
return END_USER_NAV
})
@@ -176,6 +184,22 @@ const { data: ownUsers } = await useFetch<TenantUserDoc[]>(
)
const seatsUsed = computed(() => (ownUsers.value ?? []).filter((u) => u.active !== false).length)
// Domains needing attention (anything not fully verified) drive the Domains nav
// badge. Shares the 'admin-domains' fetch key with the Domains page, so adding
// or fixing a domain updates the badge live. Gated like the seat usage fetch.
const { data: sidebarDomains } = await useFetch<{ status: string }[]>(
() => `/api/tenants/${ownSlug.value}/domains`,
{
key: 'admin-domains',
default: () => [],
immediate: !isPartnerStaff.value && !!ownSlug.value,
watch: [ownSlug],
},
)
const domainsNeedingAttention = computed(
() => (sidebarDomains.value ?? []).filter((d) => d.status !== 'active').length,
)
// Workspace mark colours. Default to the signal accent when no brandColor is
// saved (matches the Branding preview); readableOn flips the initial light on
// dark accents so it stays legible for any chosen colour.