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:
@@ -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;
|
||||
|
||||
@@ -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 dezky’s. 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: 'You’re 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>
|
||||
Reference in New Issue
Block a user