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
+160 -99
View File
@@ -1,18 +1,38 @@
<script setup lang="ts">
// Strict port of platform-flows.jsx `DomainSetupWizard` (lines 134-176) +
// step components 178-369. 6-step full-page route: Domain · Verify · Mail ·
// DKIM · DMARC · Done. Same step rail at the top, same DNS record rows and
// per-step copy.
// Add-domain wizard, wired to platform-api. 6 full-page steps:
// 1 Domain — POST the domain (provisions it in Stalwart, which auto-creates
// DKIM keys and returns the records to publish + an ownership token)
// 2 Verify — poll the ownership TXT until it resolves
// 3 Mail — show + re-check the MX/SPF records
// 4 DKIM — show + re-check the DKIM record(s)
// 5 DMARC — pick a policy (PATCH) and re-check
// 6 Done — summary of live status
// All record values come from the server; only the guidance copy is static.
import type { DmarcPolicy, DomainRecordView, DomainView, RecordKind, RecordStatus } from '~/composables/useDomains'
const router = useRouter()
const toast = useToast()
const { add, recheck, setDmarcPolicy } = useDomains()
const step = ref(1)
const domain = ref('lyngby-biler.dk')
const policy = ref<'none' | 'quarantine' | 'reject'>('quarantine')
const domainInput = ref('')
const dv = ref<DomainView | null>(null)
const busy = ref(false)
const policy = ref<DmarcPolicy>('quarantine')
const steps = ['Domain', 'Verify', 'Mail', 'DKIM', 'DMARC', 'Done']
const dmarcValue = computed(() => `v=DMARC1; p=${policy.value}; rua=mailto:dmarc@${domain.value}; pct=100; adkim=s; aspf=s`)
const domainName = computed(() => dv.value?.domain ?? domainInput.value)
type Tone = 'ok' | 'warn' | 'bad'
function tone(status: RecordStatus): Tone {
return status === 'ok' ? 'ok' : status === 'warn' ? 'warn' : 'bad'
}
function recordsOfKind(kind: RecordKind): DomainRecordView[] {
return dv.value?.records.filter((r) => r.kind === kind) ?? []
}
const ownershipRecord = computed(() => recordsOfKind('ownership')[0])
const ownershipOk = computed(() => dv.value?.checks.ownership === 'ok')
const policyOptions = [
{ v: 'none' as const, l: 'none · monitor only', d: 'Reports failures but never blocks. Use only for the first 2 weeks while you confirm legitimate mail flows.' },
@@ -20,12 +40,67 @@ const policyOptions = [
{ v: 'reject' as const, l: 'reject · strictest', d: "Suspicious mail is bounced. Use after you've been at quarantine for 30+ days with no surprises." },
]
function cancel() {
router.push('/admin/domains')
function toastError(err: unknown, title: string) {
const e = err as { data?: { message?: string | string[] }; message?: string }
const msg = e?.data?.message ?? e?.message ?? 'Unknown error'
toast.bad(title, Array.isArray(msg) ? msg.join(', ') : msg)
}
function done() {
router.push('/admin/domains')
// Step 1 → create the domain.
async function createDomain() {
const name = domainInput.value.trim().toLowerCase()
if (!name) return
busy.value = true
try {
dv.value = await add(name)
step.value = 2
} catch (err) {
toastError(err, 'Could not add domain')
} finally {
busy.value = false
}
}
// Re-run DNS checks and refresh the wizard's domain snapshot.
async function recheckNow() {
if (!dv.value) return
busy.value = true
try {
dv.value = await recheck(dv.value.domain)
} catch (err) {
toastError(err, 'Could not re-check')
} finally {
busy.value = false
}
}
// Step 5 → persist the DMARC policy, then finish.
async function finishWithDmarc() {
if (!dv.value) { step.value = 6; return }
busy.value = true
try {
dv.value = await setDmarcPolicy(dv.value.domain, policy.value)
step.value = 6
} catch (err) {
toastError(err, 'Could not set DMARC policy')
} finally {
busy.value = false
}
}
// While on the Verify step, poll for the ownership TXT every 10s until it lands.
let pollTimer: ReturnType<typeof setInterval> | null = null
function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null } }
watch([step, ownershipOk], ([s, ok]) => {
stopPoll()
if (s === 2 && !ok) {
pollTimer = setInterval(() => { if (!busy.value) recheckNow() }, 10000)
}
})
onBeforeUnmount(stopPoll)
function cancel() { router.push('/admin/domains') }
function done() { router.push('/admin/domains') }
</script>
<template>
@@ -43,15 +118,12 @@ function done() {
</button>
</div>
<div class="row title-row">
<h1>{{ step < 6 ? 'Verify and configure your domain' : `${domain} is ready` }}</h1>
<h1>{{ step < 6 ? 'Verify and configure your domain' : `${domainName} is ready` }}</h1>
<Mono dim>Step {{ step }} of 6</Mono>
</div>
<div class="rail">
<div v-for="(s, i) in steps" :key="s" class="rail-cell">
<div
class="bar"
:class="i + 1 < step ? 'done' : i + 1 === step ? 'active' : 'todo'"
/>
<div class="bar" :class="i + 1 < step ? 'done' : i + 1 === step ? 'active' : 'todo'" />
<div class="rail-label">
<Mono dim>0{{ i + 1 }}</Mono>
<span :class="i + 1 === step ? 'is-active' : i + 1 < step ? 'is-done' : 'is-todo'">{{ s }}</span>
@@ -70,7 +142,7 @@ function done() {
<Eyebrow>Domain</Eyebrow>
<div class="input-wrap">
<UiIcon name="globe" :size="14" stroke="var(--text-mute)" />
<input v-model="domain" placeholder="acme.dk" />
<input v-model="domainInput" placeholder="acme.dk" @keyup.enter="createDomain" />
</div>
</label>
<div class="info-box">
@@ -83,98 +155,76 @@ function done() {
</div>
</div>
<!-- Step 2: Verify -->
<!-- Step 2: Verify ownership -->
<div v-else-if="step === 2" class="step2">
<p class="lead">
Add this TXT record to <Mono>{{ domain }}</Mono>. We check every 30 seconds until it appears.
Add this TXT record to <Mono>{{ domainName }}</Mono>. We check every 10 seconds until it appears.
</p>
<div class="dns-rows">
<div v-if="ownershipRecord" class="dns-rows">
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">TXT</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">_dezky-verify.{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">dezky-verify=8a3f9c2e-4b7d-4e1a-9c8f-2d6e1a3b5c7e</div></div>
<div><Mono dim>TYPE</Mono><div class="dns-val">{{ ownershipRecord.type }}</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">{{ ownershipRecord.fqdn }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">{{ ownershipRecord.expected }}</div></div>
<div class="dns-right">
<Badge tone="warn" dot>pending</Badge>
<button class="copy-btn"><UiIcon name="copy" :size="11" stroke="var(--text-mute)" /> COPY</button>
<Badge :tone="ownershipOk ? 'ok' : 'warn'" dot>{{ ownershipOk ? 'verified' : 'pending' }}</Badge>
</div>
</div>
</div>
<div class="banner warn">
<UiIcon name="refresh" :size="14" stroke="var(--warn)" />
<div class="banner" :class="ownershipOk ? 'ok' : 'warn'">
<UiIcon :name="ownershipOk ? 'check' : 'refresh'" :size="14" :stroke="ownershipOk ? 'var(--ok)' : 'var(--warn)'" />
<div class="banner-body">
<div class="banner-title">Last check · 14:42:08 · still waiting</div>
<div class="banner-title">{{ ownershipOk ? 'Ownership verified' : 'Waiting for the TXT record' }}</div>
<div class="banner-text">
We saw <Mono>NS · ns1.gratisdns.dk</Mono> but no TXT record at <Mono>_dezky-verify.{{ domain }}</Mono> yet. Add the record above and click verify, or wait — we'll check every 30 seconds.
{{ ownershipOk
? 'We found the verification record. Continue to set up mail.'
: 'Add the record above, then click verify or wait, we re-check automatically every 10 seconds.' }}
</div>
</div>
<UiButton size="sm" variant="primary">Verify now</UiButton>
<UiButton size="sm" variant="primary" :disabled="busy" @click="recheckNow">{{ busy ? 'Checking' : 'Verify now' }}</UiButton>
</div>
</div>
<!-- Step 3: Mail -->
<!-- Step 3: Mail (MX + SPF) -->
<div v-else-if="step === 3" class="step3">
<p class="lead">
Add these records so mail to <Mono>@{{ domain }}</Mono> reaches dezky and outgoing mail is trusted.
Add these records so mail to <Mono>@{{ domainName }}</Mono> reaches dezky and outgoing mail is trusted.
</p>
<Eyebrow style="display: block; margin-top: 20px; margin-bottom: 10px">MX · inbound</Eyebrow>
<div class="dns-rows">
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">MX</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">10 inbound.mx.dezky.com</div></div>
<div class="dns-right"><Badge tone="ok" dot>verified</Badge></div>
</div>
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">MX</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">20 inbound-backup.mx.dezky.com</div></div>
<div class="dns-right"><Badge tone="ok" dot>verified</Badge></div>
</div>
<RecordRow v-for="(r, i) in recordsOfKind('mx')" :key="'mx' + i" :rec="r" />
<div v-if="!recordsOfKind('mx').length" class="empty-note">No MX record yet — re-check after mail provisioning completes.</div>
</div>
<Eyebrow style="display: block; margin-top: 24px; margin-bottom: 10px">SPF · sender policy</Eyebrow>
<div class="dns-rows">
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">TXT</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">v=spf1 include:_spf.dezky.com -all</div></div>
<div class="dns-right"><Badge tone="ok" dot>verified</Badge></div>
</div>
<RecordRow v-for="(r, i) in recordsOfKind('spf')" :key="'spf' + i" :rec="r" />
</div>
<div class="banner ok">
<UiIcon name="check" :size="14" stroke="var(--ok)" :stroke-width="2.5" />
<div class="banner" :class="dv && dv.checks.mx === 'ok' && dv.checks.spf === 'ok' ? 'ok' : 'warn'">
<UiIcon :name="dv && dv.checks.mx === 'ok' && dv.checks.spf === 'ok' ? 'check' : 'refresh'" :size="14" :stroke="dv && dv.checks.mx === 'ok' && dv.checks.spf === 'ok' ? 'var(--ok)' : 'var(--warn)'" />
<div class="banner-body">
<div class="banner-title">Mail routing verified</div>
<div class="banner-text">All MX and SPF records resolve correctly. Test by sending mail to <Mono>postmaster@{{ domain }}</Mono>.</div>
<div class="banner-title">{{ dv && dv.checks.mx === 'ok' && dv.checks.spf === 'ok' ? 'Mail routing verified' : 'Waiting for MX / SPF' }}</div>
<div class="banner-text">Add the records above. You can continue now and re-check from the Domains page later.</div>
</div>
<UiButton size="sm" variant="secondary" :disabled="busy" @click="recheckNow">{{ busy ? 'Checking' : 'Re-check' }}</UiButton>
</div>
</div>
<!-- Step 4: DKIM -->
<div v-else-if="step === 4" class="step4">
<p class="lead">
DKIM signs every outgoing email so Gmail and Outlook trust it. Two records, then we'll rotate the keys for you automatically every 90 days.
DKIM signs every outgoing email so Gmail and Outlook trust it. We rotate the keys for you automatically.
</p>
<Eyebrow style="display: block; margin-top: 20px; margin-bottom: 10px">DKIM · selector 1</Eyebrow>
<Eyebrow style="display: block; margin-top: 20px; margin-bottom: 10px">DKIM · message signing</Eyebrow>
<div class="dns-rows">
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">CNAME</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">dezky1._domainkey.{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">dezky1.dkim.dezky.com</div></div>
<div class="dns-right"><Badge tone="ok" dot>verified</Badge></div>
</div>
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">CNAME</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">dezky2._domainkey.{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">dezky2.dkim.dezky.com</div></div>
<div class="dns-right"><Badge tone="ok" dot>verified</Badge></div>
</div>
<RecordRow v-for="(r, i) in recordsOfKind('dkim')" :key="'dkim' + i" :rec="r" />
<div v-if="!recordsOfKind('dkim').length" class="empty-note">No DKIM record yet — re-check after mail provisioning completes.</div>
</div>
<div class="banner ok">
<UiIcon name="check" :size="14" stroke="var(--ok)" :stroke-width="2.5" />
<div class="banner" :class="dv && dv.checks.dkim === 'ok' ? 'ok' : 'warn'">
<UiIcon :name="dv && dv.checks.dkim === 'ok' ? 'check' : 'refresh'" :size="14" :stroke="dv && dv.checks.dkim === 'ok' ? 'var(--ok)' : 'var(--warn)'" />
<div class="banner-body">
<div class="banner-title">DKIM is signing</div>
<div class="banner-text">Selectors verified · key rotation enabled · next rotation 14 Aug 2026.</div>
<div class="banner-title">{{ dv && dv.checks.dkim === 'ok' ? 'DKIM is signing' : 'Waiting for DKIM' }}</div>
<div class="banner-text">Publish the record(s) above. Both selectors must be present for full coverage.</div>
</div>
<UiButton size="sm" variant="secondary" :disabled="busy" @click="recheckNow">{{ busy ? 'Checking' : 'Re-check' }}</UiButton>
</div>
</div>
@@ -198,9 +248,11 @@ function done() {
<div class="dns-rows">
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">TXT</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">_dmarc.{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">{{ dmarcValue }}</div></div>
<div class="dns-right"><button class="copy-btn"><UiIcon name="copy" :size="11" stroke="var(--text-mute)" /> COPY</button></div>
<div><Mono dim>HOST</Mono><div class="dns-val">_dmarc.{{ domainName }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">v=DMARC1; p={{ policy }}; rua=mailto:postmaster@{{ domainName }}</div></div>
<div class="dns-right">
<Badge :tone="dv && dv.checks.dmarc === 'ok' ? 'ok' : 'warn'" dot>{{ dv ? dv.checks.dmarc : 'pending' }}</Badge>
</div>
</div>
</div>
</div>
@@ -210,14 +262,14 @@ function done() {
<div class="check-badge">
<UiIcon name="check" :size="36" :stroke-width="2.5" />
</div>
<h2>{{ domain }} is connected.</h2>
<h2>{{ domainName }} is connected.</h2>
<p class="lead-center">
Mail is routing. DKIM is signing. DMARC is enforcing. You can now invite users on this domain and they'll receive working email immediately.
The domain is provisioned. Publish any remaining DNS records and they'll go green automatically you can track status from the Domains page.
</p>
<div class="summary-grid">
<div v-for="k in ['MX', 'SPF', 'DKIM', 'DMARC']" :key="k" class="summary-cell">
<Badge tone="ok" dot>verified</Badge>
<Mono>{{ k }}</Mono>
<div v-if="dv" class="summary-grid">
<div v-for="k in (['mx','spf','dkim','dmarc'] as const)" :key="k" class="summary-cell">
<Badge :tone="dv.checks[k] === 'ok' ? 'ok' : 'warn'" dot>{{ dv.checks[k] }}</Badge>
<Mono>{{ k.toUpperCase() }}</Mono>
</div>
</div>
</div>
@@ -227,11 +279,32 @@ function done() {
<template v-if="step < 6">
<UiButton variant="ghost" @click="cancel">Save and exit</UiButton>
<div class="spacer" />
<UiButton v-if="step === 5" variant="secondary" @click="step = 6">Skip DMARC for now</UiButton>
<UiButton variant="primary" @click="step++">
<template v-if="step === 5" #leading><UiIcon name="check" :size="13" /></template>
{{ step === 1 ? 'Continue' : step === 5 ? 'Add DMARC & finish' : 'Verified · continue' }}
<template v-if="step < 5" #trailing><UiIcon name="arrowRight" :size="13" /></template>
<UiButton v-if="step === 5" variant="secondary" :disabled="busy" @click="step = 6">Skip DMARC for now</UiButton>
<UiButton
v-if="step === 1"
variant="primary"
:disabled="busy || !domainInput.trim()"
@click="createDomain"
>
{{ busy ? 'Adding…' : 'Continue' }}
<template #trailing><UiIcon name="arrowRight" :size="13" /></template>
</UiButton>
<UiButton
v-else-if="step === 2"
variant="primary"
:disabled="!ownershipOk"
@click="step = 3"
>
{{ ownershipOk ? 'Verified · continue' : 'Waiting for verification' }}
<template #trailing><UiIcon name="arrowRight" :size="13" /></template>
</UiButton>
<UiButton v-else-if="step === 5" variant="primary" :disabled="busy" @click="finishWithDmarc">
<template #leading><UiIcon name="check" :size="13" /></template>
{{ busy ? 'Saving…' : 'Add DMARC & finish' }}
</UiButton>
<UiButton v-else variant="primary" @click="step++">
Continue
<template #trailing><UiIcon name="arrowRight" :size="13" /></template>
</UiButton>
</template>
<template v-else>
@@ -327,19 +400,7 @@ function done() {
.dns-val { font-family: var(--font-mono); font-size: 13px; font-weight: 600; margin-top: 2px; }
.dns-val.dim { color: var(--text-dim); font-weight: 400; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dns-right { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.copy-btn {
background: transparent;
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-dim);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
}
.empty-note { font-size: 12px; color: var(--text-mute); padding: 10px 2px; font-family: var(--font-mono); }
.banner {
margin-top: 16px;