Files
Ronni Baslund 47eb9502f8 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.
2026-06-01 21:19:42 +02:00

492 lines
20 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// 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 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 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.' },
{ v: 'quarantine' as const, l: 'quarantine · recommended', d: 'Suspicious mail goes to spam. Catches almost all spoofing without breaking legitimate edge cases.' },
{ 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 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)
}
// 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>
<div class="wizard">
<div class="flow-head">
<div class="row top">
<div class="left">
<button v-if="step > 1 && step < 6" class="back" @click="step--">
<UiIcon name="chevLeft" :size="12" /> back
</button>
<Eyebrow>Add domain</Eyebrow>
</div>
<button class="cancel" @click="cancel">
<UiIcon name="x" :size="14" /> cancel
</button>
</div>
<div class="row title-row">
<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="rail-label">
<Mono dim>0{{ i + 1 }}</Mono>
<span :class="i + 1 === step ? 'is-active' : i + 1 < step ? 'is-done' : 'is-todo'">{{ s }}</span>
</div>
</div>
</div>
</div>
<div class="body">
<!-- Step 1: Domain -->
<div v-if="step === 1" class="step1">
<p class="lead">
Enter the domain you'll use for mail and identity. You'll need to add a few DNS records to prove you own it and route mail correctly.
</p>
<label class="field">
<Eyebrow>Domain</Eyebrow>
<div class="input-wrap">
<UiIcon name="globe" :size="14" stroke="var(--text-mute)" />
<input v-model="domainInput" placeholder="acme.dk" @keyup.enter="createDomain" />
</div>
</label>
<div class="info-box">
<Eyebrow>Need to know</Eyebrow>
<div class="info-body">
DNS changes typically propagate in 530 minutes<br />
You'll need access to your domain's DNS provider (Cloudflare, GoDaddy, etc.)<br />
For Danish .dk domains, you'll work with <Mono>DK-Hostmaster</Mono> or your registrar
</div>
</div>
</div>
<!-- Step 2: Verify ownership -->
<div v-else-if="step === 2" class="step2">
<p class="lead">
Add this TXT record to <Mono>{{ domainName }}</Mono>. We check every 10 seconds until it appears.
</p>
<div v-if="ownershipRecord" class="dns-rows">
<div class="dns-row">
<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="ownershipOk ? 'ok' : 'warn'" dot>{{ ownershipOk ? 'verified' : 'pending' }}</Badge>
</div>
</div>
</div>
<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">{{ ownershipOk ? 'Ownership verified' : 'Waiting for the TXT record' }}</div>
<div class="banner-text">
{{ 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" :disabled="busy" @click="recheckNow">{{ busy ? 'Checking' : 'Verify now' }}</UiButton>
</div>
</div>
<!-- Step 3: Mail (MX + SPF) -->
<div v-else-if="step === 3" class="step3">
<p class="lead">
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">
<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">
<RecordRow v-for="(r, i) in recordsOfKind('spf')" :key="'spf' + i" :rec="r" />
</div>
<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">{{ 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. We rotate the keys for you automatically.
</p>
<Eyebrow style="display: block; margin-top: 20px; margin-bottom: 10px">DKIM · message signing</Eyebrow>
<div class="dns-rows">
<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" :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">{{ 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>
<!-- Step 5: DMARC -->
<div v-else-if="step === 5" class="step5">
<p class="lead">
DMARC tells receiving servers what to do with email that fails authentication. We strongly recommend at least <Mono>quarantine</Mono>.
</p>
<Eyebrow style="display: block; margin-top: 20px; margin-bottom: 10px">Choose policy</Eyebrow>
<div class="policy-list">
<label v-for="p in policyOptions" :key="p.v" :class="{ active: policy === p.v }">
<span class="radio-dot"><span v-if="policy === p.v" /></span>
<input type="radio" :value="p.v" v-model="policy" />
<div>
<div class="policy-label">{{ p.l }}</div>
<div class="policy-d">{{ p.d }}</div>
</div>
</label>
</div>
<Eyebrow style="display: block; margin-top: 24px; margin-bottom: 10px">Add this record</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">_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>
<!-- Step 6: Done -->
<div v-else class="step6">
<div class="check-badge">
<UiIcon name="check" :size="36" :stroke-width="2.5" />
</div>
<h2>{{ domainName }} is connected.</h2>
<p class="lead-center">
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 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>
</div>
<div class="footer">
<template v-if="step < 6">
<UiButton variant="ghost" @click="cancel">Save and exit</UiButton>
<div class="spacer" />
<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>
<div class="spacer" />
<UiButton variant="secondary" @click="done">
<template #leading><UiIcon name="users" :size="13" /></template>
Invite users on this domain
</UiButton>
<UiButton variant="primary" @click="done">Back to domains</UiButton>
</template>
</div>
</div>
</template>
<style scoped>
.wizard { display: flex; flex-direction: column; min-height: 100%; }
.flow-head { border-bottom: 1px solid var(--border); }
.row { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
.row.top { padding: 14px 32px; }
.row.title-row { padding: 0 32px 18px 32px; align-items: flex-end; }
.left { display: flex; align-items: center; gap: 14px; }
.back, .cancel {
background: transparent;
border: none;
padding: 0;
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-mute);
font-size: 12px;
cursor: pointer;
font-family: var(--font-mono);
}
.cancel { padding: 6px; font-family: inherit; }
.row.title-row h1 {
font-family: var(--font-display);
font-weight: 600;
font-size: 28px;
letter-spacing: -0.025em;
margin: 0;
line-height: 1.05;
}
.rail { padding: 0 32px 18px 32px; display: flex; gap: 6px; }
.rail-cell { flex: 1; display: flex; flex-direction: column; gap: 6px; }
.bar { height: 3px; border-radius: 2px; }
.bar.done { background: var(--text); }
.bar.active { background: var(--accent); }
.bar.todo { background: var(--border); }
.rail-label { display: flex; align-items: center; gap: 6px; font-size: 12px; }
.is-active { font-weight: 600; color: var(--text); }
.is-done { color: var(--text); }
.is-todo { color: var(--text-mute); }
.body { flex: 1; padding: 24px 32px; max-width: 920px; margin: 0 auto; width: 100%; }
.lead { color: var(--text-dim); font-size: 14px; line-height: 1.6; margin-top: 0; }
.lead-center { color: var(--text-dim); font-size: 15px; line-height: 1.6; margin-top: 12px; max-width: 500px; margin-inline: auto; }
.field { display: flex; flex-direction: column; gap: 6px; max-width: 520px; }
.input-wrap {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
height: 36px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.input-wrap input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
.info-box {
margin-top: 18px;
padding: 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
max-width: 520px;
}
.info-body { margin-top: 10px; font-size: 13px; color: var(--text-dim); line-height: 1.65; }
.dns-rows { display: flex; flex-direction: column; gap: 8px; }
.dns-row {
display: grid;
grid-template-columns: 80px 220px 1fr 90px;
gap: 12px;
align-items: center;
padding: 12px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
}
.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; }
.empty-note { font-size: 12px; color: var(--text-mute); padding: 10px 2px; font-family: var(--font-mono); }
.banner {
margin-top: 16px;
padding: 14px;
border-radius: 6px;
display: flex;
align-items: flex-start;
gap: 12px;
}
.banner.warn { background: rgba(232, 154, 31, 0.06); border: 1px solid rgba(232, 154, 31, 0.24); border-left: 3px solid var(--warn); }
.banner.ok { background: rgba(31, 138, 91, 0.06); border: 1px solid rgba(31, 138, 91, 0.24); border-left: 3px solid var(--ok); }
.banner-body { flex: 1; font-size: 13px; }
.banner-title { font-weight: 600; }
.banner-text { color: var(--text-dim); margin-top: 4px; line-height: 1.5; }
.policy-list { display: flex; flex-direction: column; gap: 8px; }
.policy-list label {
display: flex;
gap: 14px;
padding: 14px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
cursor: pointer;
}
.policy-list label.active { background: var(--bg); border-color: var(--text); }
.policy-list input { display: none; }
.radio-dot {
width: 16px;
height: 16px;
border-radius: 999px;
border: 2px solid var(--border-hi, var(--border));
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2px;
}
.policy-list label.active .radio-dot { border-color: var(--text); }
.radio-dot span { width: 7px; height: 7px; border-radius: 999px; background: var(--text); }
.policy-label { font-size: 13px; font-weight: 600; }
.policy-d { font-size: 12px; color: var(--text-mute); margin-top: 4px; line-height: 1.5; }
.step6 { max-width: 680px; text-align: center; padding: 60px 0; margin: 0 auto; }
.check-badge {
width: 72px;
height: 72px;
border-radius: 16px;
background: var(--accent);
color: var(--accent-fg);
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
}
.step6 h2 {
font-family: var(--font-display);
font-weight: 600;
font-size: 36px;
letter-spacing: -0.025em;
margin: 0;
line-height: 1.05;
}
.summary-grid {
display: inline-grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-top: 36px;
padding: 16px 24px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
text-align: left;
}
.summary-cell { display: flex; align-items: center; gap: 6px; }
.footer {
border-top: 1px solid var(--border);
padding: 14px 32px;
display: flex;
align-items: center;
gap: 8px;
background: var(--surface);
position: sticky;
bottom: 0;
}
.spacer { flex: 1; }
</style>