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;
+436
View File
@@ -0,0 +1,436 @@
<script setup lang="ts">
// Customer-admin Domains page. Lists the tenant's email domains on real data
// from platform-api (useDomains → /api/tenants/:slug/domains). Each card shows
// the monospace name, an overall status badge, a "X records to fix" hint, a
// Re-check button, and a 4-record grid (MX/SPF/DKIM/DMARC) clickable to expand
// inline detail with the exact record to publish (sourced from Stalwart's zone,
// so DKIM keys etc. are authoritative). The explanatory copy per status is
// static (DNS_FIX); the record values come from the server.
import type { DomainRecordView, DomainView, RecordStatus } from '~/composables/useDomains'
const router = useRouter()
const toast = useToast()
const { domains, refresh, recheck, remove } = useDomains()
type Tone = 'ok' | 'warn' | 'bad'
type RecordKey = 'mx' | 'spf' | 'dkim' | 'dmarc'
// Static explanatory copy per record + status. Record VALUES are no longer here
// — those come from the server (the real MX host, DKIM public key, etc.). We
// keep only the human guidance, keyed by record kind + observed tone.
const DNS_FIX: Record<RecordKey, {
label: string
purpose: string
states: Record<Tone | 'pending', { headline: string; body: string }>
}> = {
mx: {
label: 'MX · mail exchange',
purpose: 'Routes inbound mail for this domain to dezky.',
states: {
ok: { headline: 'Mail routing healthy', body: 'Inbound mail flows to dezky correctly.' },
warn: { headline: 'Secondary MX detected', body: 'An MX outside of dezky was found. This is allowed for failover, but make sure it forwards back to dezky.' },
bad: { headline: 'No MX record found', body: 'Mail to this domain will not reach dezky. Add the record below at your DNS provider.' },
pending: { headline: 'Not checked yet', body: 'Add the record below, then re-check.' },
},
},
spf: {
label: 'SPF · sender policy',
purpose: 'Tells receiving servers which IPs are allowed to send for this domain.',
states: {
ok: { headline: 'SPF aligned', body: 'Your SPF record correctly authorises dezky as a sender.' },
warn: { headline: 'SPF present but weak', body: 'SPF resolves but ends with a softfail (~all) or is missing the dezky mechanism. Use the record below for stronger protection.' },
bad: { headline: 'No SPF record', body: 'Mail sent from this domain via dezky will fail Gmail/Outlook authentication.' },
pending: { headline: 'Not checked yet', body: 'Add the record below, then re-check.' },
},
},
dkim: {
label: 'DKIM · message signing',
purpose: 'Cryptographic signature proving the message was not altered in transit.',
states: {
ok: { headline: 'DKIM signing live', body: 'Outbound mail is signed and verifiable.' },
warn: { headline: 'DKIM record mismatch', body: 'A DKIM record exists but its public key differs from dezkys. Replace it with the value(s) below.' },
bad: { headline: 'No DKIM record', body: 'Receiving servers cannot verify the signature on your outbound mail.' },
pending: { headline: 'Not checked yet', body: 'Add the record(s) below, then re-check.' },
},
},
dmarc: {
label: 'DMARC · policy enforcement',
purpose: 'Tells receiving servers what to do with mail that fails SPF or DKIM.',
states: {
ok: { headline: 'DMARC enforcing', body: 'Spoofed mail will be quarantined or rejected at Gmail/Outlook.' },
warn: { headline: 'DMARC at p=none', body: 'Youre collecting reports but not enforcing. Raise to quarantine once SPF/DKIM look stable.' },
bad: { headline: 'No DMARC record', body: 'Anyone can spoof this domain. Mail may fail Gmail / Outlook spam checks.' },
pending: { headline: 'Not checked yet', body: 'Add the record below, then re-check.' },
},
},
}
const RECORD_KEYS: RecordKey[] = ['mx', 'spf', 'dkim', 'dmarc']
const expanded = reactive<Record<string, RecordKey | null>>({})
const copied = ref<string | null>(null)
const rechecking = ref<string | null>(null)
// Remove flow. A domain can only be removed when no mailboxes use it (enforced
// server-side too); the button is disabled otherwise. removeTarget drives the
// confirm dialog.
const removeTarget = ref<DomainView | null>(null)
const removing = ref(false)
function toggle(domain: string, key: RecordKey) {
expanded[domain] = expanded[domain] === key ? null : key
}
function copyValue(text: string) {
if (typeof navigator !== 'undefined' && navigator.clipboard) navigator.clipboard.writeText(text)
copied.value = text
setTimeout(() => { if (copied.value === text) copied.value = null }, 1400)
toast.ok('Copied to clipboard')
}
function issuesFor(d: DomainView): RecordKey[] {
return RECORD_KEYS.filter((k) => d.checks[k] !== 'ok')
}
function recordsOfKind(d: DomainView, k: RecordKey): DomainRecordView[] {
return d.records.filter((r) => r.kind === k)
}
function tone(status: RecordStatus): Tone {
return status === 'ok' ? 'ok' : status === 'warn' ? 'warn' : status === 'pending' ? 'warn' : 'bad'
}
function statusIcon(t: Tone): 'check' | 'shield' | 'x' {
return t === 'ok' ? 'check' : t === 'warn' ? 'shield' : 'x'
}
function recordTint(t: Tone) {
return t === 'bad' ? 'rgba(226,48,48,0.12)'
: t === 'warn' ? 'rgba(232,154,31,0.12)'
: 'rgba(91,140,90,0.12)'
}
function badgeFor(d: DomainView): { tone: 'ok' | 'warn' | 'bad'; label: string } {
if (d.status === 'active') return { tone: 'ok', label: 'verified' }
if (d.status === 'error') return { tone: 'bad', label: 'error' }
return { tone: 'warn', label: 'attention' }
}
async function recheckDomain(domain: string) {
rechecking.value = domain
try {
await recheck(domain)
await refresh()
toast.ok(`Re-checked ${domain}`)
} catch (err) {
const e = err as { data?: { message?: string }; message?: string }
toast.bad('Could not re-check', e?.data?.message ?? e?.message ?? 'Unknown error')
} finally {
rechecking.value = null
}
}
async function confirmRemove() {
const d = removeTarget.value
if (!d) return
removing.value = true
try {
await remove(d.domain)
await refresh()
toast.ok(`Removed ${d.domain}`)
removeTarget.value = null
} catch (err) {
const e = err as { data?: { message?: string }; message?: string }
toast.bad('Could not remove domain', e?.data?.message ?? e?.message ?? 'Unknown error')
} finally {
removing.value = false
}
}
</script>
<template>
<div>
<PageHeader
eyebrow="Identity"
title="Domains"
subtitle="Your verified domains for mail, SSO, and user provisioning."
>
<template #actions>
<UiButton variant="primary" @click="router.push('/admin/domains/add')">
<template #leading><UiIcon name="plus" :size="14" /></template>
Add domain
</UiButton>
</template>
</PageHeader>
<div class="content">
<Card v-if="domains && domains.length === 0" class="empty">
<UiIcon name="globe" :size="22" stroke="var(--text-mute)" />
<div>
<div class="empty-title">No domains yet</div>
<div class="empty-sub">Add your first email domain to route mail and enable sign-in for your team.</div>
</div>
<UiButton variant="primary" @click="router.push('/admin/domains/add')">
<template #leading><UiIcon name="plus" :size="14" /></template>
Add domain
</UiButton>
</Card>
<Card v-for="d in domains" :key="d.id">
<div class="head">
<UiIcon name="globe" :size="20" stroke="var(--text-mute)" />
<div class="title">
<div class="domain-name">{{ d.domain }}</div>
<div class="domain-sub">
{{ d.mailboxes }} mailbox{{ d.mailboxes === 1 ? '' : 'es' }}
<template v-if="issuesFor(d).length">
· <span class="warn">{{ issuesFor(d).length }} record{{ issuesFor(d).length === 1 ? '' : 's' }} to fix</span>
</template>
</div>
</div>
<UiButton size="sm" variant="secondary" :disabled="rechecking === d.domain" @click.stop="recheckDomain(d.domain)">
<template #leading><UiIcon name="refresh" :size="12" /></template>
{{ rechecking === d.domain ? 'Checking…' : 'Re-check now' }}
</UiButton>
<button
class="remove"
:disabled="d.mailboxes > 0"
:title="d.mailboxes > 0
? `${d.mailboxes} mailbox${d.mailboxes === 1 ? '' : 'es'} use this domain — remove or reassign those users first`
: 'Remove domain'"
@click.stop="removeTarget = d"
>
<UiIcon name="trash" :size="14" />
</button>
<Badge :tone="badgeFor(d).tone" dot>{{ badgeFor(d).label }}</Badge>
</div>
<div v-if="d.stalwartError" class="prov-error">
<UiIcon name="x" :size="13" stroke="var(--bad)" />
Provisioning error: {{ d.stalwartError }}
</div>
<div class="records">
<button
v-for="k in RECORD_KEYS"
:key="k"
class="rec"
:class="{ active: expanded[d.domain] === k }"
@click="toggle(d.domain, k)"
>
<Mono>{{ k.toUpperCase() }}</Mono>
<div class="rec-right">
<Badge :tone="tone(d.checks[k])" dot>{{ d.checks[k] }}</Badge>
<UiIcon :name="expanded[d.domain] === k ? 'chevDown' : 'chevRight'" :size="11" stroke="var(--text-mute)" />
</div>
</button>
</div>
<div v-if="expanded[d.domain]" class="detail" :data-tone="tone(d.checks[expanded[d.domain]!])">
<div class="detail-head">
<div class="detail-icon" :style="{ background: recordTint(tone(d.checks[expanded[d.domain]!])), color: `var(--${tone(d.checks[expanded[d.domain]!])})` }">
<UiIcon :name="statusIcon(tone(d.checks[expanded[d.domain]!]))" :size="14" :stroke-width="d.checks[expanded[d.domain]!] === 'ok' ? 2.5 : 2" />
</div>
<div class="detail-body">
<div class="detail-title">
{{ DNS_FIX[expanded[d.domain]!].states[d.checks[expanded[d.domain]!]].headline }}
<Mono dim>{{ DNS_FIX[expanded[d.domain]!].label }}</Mono>
</div>
<div class="detail-text">{{ DNS_FIX[expanded[d.domain]!].states[d.checks[expanded[d.domain]!]].body }}</div>
<Mono dim style="display: block; margin-top: 10px">{{ DNS_FIX[expanded[d.domain]!].purpose }}</Mono>
</div>
<button class="detail-close" @click="expanded[d.domain] = null"><UiIcon name="x" :size="14" /></button>
</div>
<template v-if="d.checks[expanded[d.domain]!] !== 'ok'">
<div class="rec-action">
<Eyebrow>Add {{ recordsOfKind(d, expanded[d.domain]!).length > 1 ? 'these records' : 'this record' }} at your DNS provider</Eyebrow>
<div v-for="(rec, i) in recordsOfKind(d, expanded[d.domain]!)" :key="i" class="rec-grid">
<div class="rec-grid-label">Type</div>
<div class="rec-grid-val">{{ rec.type }}</div>
<div class="rec-grid-ttl">TTL 3600</div>
<div class="rec-grid-label sep">Host</div>
<div class="rec-grid-span sep">
<span>{{ rec.host }} <span class="muted">· resolves to {{ rec.fqdn }}</span></span>
<button class="copy" @click="copyValue(rec.host)"><UiIcon name="copy" :size="12" /></button>
</div>
<div class="rec-grid-label sep">Value</div>
<div class="rec-grid-span sep">
<span class="break">{{ rec.expected }}</span>
<button class="copy" @click="copyValue(rec.expected)"><UiIcon name="copy" :size="12" /></button>
</div>
<template v-if="rec.priority !== undefined">
<div class="rec-grid-label sep">Priority</div>
<div class="rec-grid-span sep">{{ rec.priority }}</div>
</template>
</div>
<div class="rec-actions-row">
<UiButton size="sm" variant="ghost" :disabled="rechecking === d.domain" @click="recheckDomain(d.domain)">
<template #leading><UiIcon name="refresh" :size="13" /></template>
{{ rechecking === d.domain ? 'Checking…' : 'Re-check this record' }}
</UiButton>
<div class="spacer" />
<Mono dim>changes can take up to 24h to propagate</Mono>
</div>
</div>
</template>
<template v-else>
<div v-for="(rec, i) in recordsOfKind(d, expanded[d.domain]!)" :key="i" class="currently-set">
<Eyebrow>Currently set</Eyebrow>
<div class="set-value">{{ rec.observed || rec.expected }}</div>
</div>
</template>
</div>
</Card>
</div>
<ConfirmDialog
:open="!!removeTarget"
eyebrow="Identity · Domains"
:title="`Remove ${removeTarget?.domain}?`"
confirm-label="Remove domain"
tone="danger"
:busy="removing"
@close="removeTarget = null"
@confirm="confirmRemove"
>
Mail routing and DKIM signing for <strong>{{ removeTarget?.domain }}</strong> are deleted from the mail
server immediately. Inbound mail to this domain will stop being delivered. This can't be undone — you'd
need to add the domain again and re-publish its DNS records.
</ConfirmDialog>
</div>
</template>
<style scoped>
.content { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 12px; }
.empty { display: flex; align-items: center; gap: 16px; }
.empty-title { font-weight: 600; }
.empty-sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
.head { display: flex; align-items: center; gap: 16px; }
.title { flex: 1; min-width: 0; }
.remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-mute);
cursor: pointer;
transition: color 120ms, border-color 120ms, background 120ms;
}
.remove:hover:not(:disabled) { color: var(--bad); border-color: var(--bad); }
.remove:disabled { opacity: 0.4; cursor: not-allowed; }
.domain-name { font-family: var(--font-mono); font-size: 16px; font-weight: 600; }
.domain-sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
.warn { color: var(--warn); }
.prov-error {
margin-top: 12px;
font-size: 12px;
color: var(--bad);
display: flex;
align-items: center;
gap: 6px;
}
.records {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
.rec {
padding: 10px 12px;
background: var(--bg);
border-radius: 6px;
cursor: pointer;
border: 1px solid transparent;
display: flex;
align-items: center;
justify-content: space-between;
font-family: inherit;
text-align: left;
transition: background 120ms, border-color 120ms;
}
.rec:hover { background: var(--surface); }
.rec.active { background: var(--surface); border-color: var(--text); }
.rec-right { display: flex; align-items: center; gap: 6px; }
.detail {
margin-top: 16px;
padding: 16px;
background: var(--bg);
border-radius: 6px;
border: 1px solid var(--border);
border-left: 3px solid var(--border);
}
.detail[data-tone='ok'] { border-left-color: var(--ok); }
.detail[data-tone='warn'] { border-left-color: var(--warn); }
.detail[data-tone='bad'] { border-left-color: var(--bad); }
.detail-head { display: flex; align-items: flex-start; gap: 12px; }
.detail-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.detail-body { flex: 1; }
.detail-title {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-family: var(--font-display);
font-weight: 600;
font-size: 15px;
}
.detail-text { font-size: 13px; color: var(--text-dim); margin-top: 6px; line-height: 1.55; }
.detail-close { background: transparent; border: none; padding: 4px; border-radius: 4px; color: var(--text-mute); cursor: pointer; }
.rec-action { margin-top: 16px; }
.rec-grid {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
display: grid;
grid-template-columns: 70px 1fr 80px;
font-family: var(--font-mono);
font-size: 12px;
overflow: hidden;
margin-top: 8px;
}
.rec-grid-label { padding: 10px 12px; color: var(--text-mute); border-right: 1px solid var(--border); }
.rec-grid-label.sep { border-top: 1px solid var(--border); }
.rec-grid-val { padding: 10px 12px; border-right: 1px solid var(--border); }
.rec-grid-ttl { padding: 10px 12px; color: var(--text-mute); }
.rec-grid-span {
padding: 10px 12px;
grid-column: 2 / 4;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.rec-grid-span.sep { border-top: 1px solid var(--border); }
.break { word-break: break-all; }
.muted { color: var(--text-mute); }
.copy { background: transparent; border: none; padding: 4px; border-radius: 4px; color: var(--text-mute); cursor: pointer; }
.copy:hover { background: var(--bg); }
.rec-actions-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
.spacer { flex: 1; }
.currently-set { margin-top: 12px; padding: 12px; background: var(--surface); border-radius: 6px; border: 1px solid var(--border); }
.set-value { font-family: var(--font-mono); font-size: 12px; color: var(--text-dim); word-break: break-all; margin-top: 4px; }
</style>