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
-319
View File
@@ -1,319 +0,0 @@
<script setup lang="ts">
// Strict port of project/platform-app.jsx `DomainsScreen` (lines 440-585) +
// `DomainCard` (502) + `DomainRecordDetail` (586). Each domain card shows
// monospace name, status badge, "X records to fix" hint, Re-check button,
// and a 4-record grid (MX/SPF/DKIM/DMARC) clickable to expand inline detail.
import { sampleDomainsFlat } from '~/data/workspace'
const router = useRouter()
const toast = useToast()
type Tone = 'ok' | 'warn' | 'bad'
type RecordKey = 'mx' | 'spf' | 'dkim' | 'dmarc'
// DNS_FIX (platform-app.jsx line 459) — copy strings, record values, per-status headlines.
const DNS_FIX: Record<RecordKey, {
label: string
purpose: string
record: { type: string; host: string; value: string; priority?: number; ttl: number }
states: Record<Tone, { headline: string; body: string }>
}> = {
mx: {
label: 'MX · mail exchange',
purpose: 'Routes inbound mail for this domain to dezky.',
record: { type: 'MX', host: '@', value: 'mx.dezky.com', priority: 10, ttl: 3600 },
states: {
ok: { headline: 'Mail routing healthy', body: 'Inbound mail flows to dezky correctly. Verified 4 minutes ago.' },
warn: { headline: 'Lower-priority MX detected', body: 'A secondary MX outside of dezky was found. This is allowed for failover but make sure it forwards back to mx.dezky.com.' },
bad: { headline: 'No MX record found', body: 'Mail to this domain will not reach dezky. Add the record below at your DNS provider.' },
},
},
spf: {
label: 'SPF · sender policy',
purpose: 'Tells receiving servers which IPs are allowed to send for this domain.',
record: { type: 'TXT', host: '@', value: 'v=spf1 include:_spf.dezky.com -all', ttl: 3600 },
states: {
ok: { headline: 'SPF aligned', body: 'Your SPF record correctly authorises dezky as a sender. Verified 4 minutes ago.' },
warn: { headline: 'SPF includes dezky but ends with ~all (softfail)', body: 'Receiving mail servers may still accept spoofed mail. Change the trailing mechanism to -all (hardfail) for stronger protection.' },
bad: { headline: 'No SPF record', body: 'Mail sent from this domain via dezky will fail Gmail/Outlook authentication.' },
},
},
dkim: {
label: 'DKIM · message signing',
purpose: 'Cryptographic signature proving the message was not altered in transit.',
record: { type: 'CNAME', host: 'dezky._domainkey', value: 'dkim.dezky.com', ttl: 3600 },
states: {
ok: { headline: 'DKIM signing live', body: 'Outbound mail is signed with selector dezky. Verified 4 minutes ago.' },
warn: { headline: 'DKIM CNAME points somewhere else', body: 'A DKIM record exists but does not delegate to dezky. Replace it with the CNAME below.' },
bad: { headline: 'No DKIM record', body: 'Outbound mail will be signed but receiving servers cannot verify the signature.' },
},
},
dmarc: {
label: 'DMARC · policy enforcement',
purpose: 'Tells receiving servers what to do with mail that fails SPF or DKIM.',
record: { type: 'TXT', host: '_dmarc', value: 'v=DMARC1; p=quarantine; rua=mailto:dmarc@dezky.com; pct=100; adkim=s; aspf=s', ttl: 3600 },
states: {
ok: { headline: 'DMARC at quarantine', body: 'Spoofed mail will be sent to spam at Gmail/Outlook. Aggregate reports flowing.' },
warn: { headline: 'DMARC at p=none', body: 'Youre collecting reports but not enforcing. Raise to quarantine once your SPF/DKIM look stable for a week.' },
bad: { headline: 'No DMARC record', body: 'Anyone can spoof this domain. Mail from this domain may fail Gmail / Outlook spam checks.' },
},
},
}
const expanded = reactive<Record<string, RecordKey | null>>({})
const copied = ref<string | null>(null)
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: typeof sampleDomainsFlat[number]) {
return (['mx', 'spf', 'dkim', 'dmarc'] as const).filter((k) => d[k] !== 'ok')
}
function statusIcon(tone: Tone): 'check' | 'shield' | 'x' {
return tone === 'ok' ? 'check' : tone === 'warn' ? 'shield' : 'x'
}
function recordTint(tone: Tone) {
return tone === 'bad' ? 'rgba(226,48,48,0.12)'
: tone === 'warn' ? 'rgba(232,154,31,0.12)'
: 'rgba(91,140,90,0.12)'
}
</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-for="d in sampleDomainsFlat" :key="d.domain">
<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.users }} mailboxes
<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 v-if="issuesFor(d).length" size="sm" variant="secondary" @click.stop="toast.ok('Re-checking ' + d.domain)">
<template #leading><UiIcon name="refresh" :size="12" /></template>
Re-check now
</UiButton>
<Badge :tone="d.status === 'ok' ? 'ok' : 'warn'" dot>{{ d.status === 'ok' ? 'verified' : 'attention' }}</Badge>
</div>
<div class="records">
<button
v-for="k in (['mx', 'spf', 'dkim', 'dmarc'] as RecordKey[])"
: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="d[k]" dot>{{ d[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="d[expanded[d.domain]!]">
<div class="detail-head">
<div class="detail-icon" :style="{ background: recordTint(d[expanded[d.domain]!] as Tone), color: `var(--${d[expanded[d.domain]!]})` }">
<UiIcon :name="statusIcon(d[expanded[d.domain]!] as Tone)" :size="14" :stroke-width="d[expanded[d.domain]!] === 'ok' ? 2.5 : 2" />
</div>
<div class="detail-body">
<div class="detail-title">
{{ DNS_FIX[expanded[d.domain]!].states[d[expanded[d.domain]!] as Tone].headline }}
<Mono dim>{{ DNS_FIX[expanded[d.domain]!].label }}</Mono>
</div>
<div class="detail-text">{{ DNS_FIX[expanded[d.domain]!].states[d[expanded[d.domain]!] as Tone].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[expanded[d.domain]!] !== 'ok'">
<div class="rec-action">
<Eyebrow>Add this record at your DNS provider</Eyebrow>
<div class="rec-grid">
<div class="rec-grid-label">Type</div>
<div class="rec-grid-val">{{ DNS_FIX[expanded[d.domain]!].record.type }}</div>
<div class="rec-grid-ttl">TTL {{ DNS_FIX[expanded[d.domain]!].record.ttl }}</div>
<div class="rec-grid-label sep">Host</div>
<div class="rec-grid-span sep">
<span>{{ DNS_FIX[expanded[d.domain]!].record.host }} <span class="muted">· resolves to {{ DNS_FIX[expanded[d.domain]!].record.host === '@' ? d.domain : `${DNS_FIX[expanded[d.domain]!].record.host}.${d.domain}` }}</span></span>
<button class="copy" @click="copyValue(DNS_FIX[expanded[d.domain]!].record.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">{{ DNS_FIX[expanded[d.domain]!].record.value }}</span>
<button class="copy" @click="copyValue(DNS_FIX[expanded[d.domain]!].record.value)"><UiIcon name="copy" :size="12" /></button>
</div>
<template v-if="DNS_FIX[expanded[d.domain]!].record.priority !== undefined">
<div class="rec-grid-label sep">Priority</div>
<div class="rec-grid-span sep">{{ DNS_FIX[expanded[d.domain]!].record.priority }}</div>
</template>
</div>
<div class="rec-actions-row">
<UiButton size="sm" variant="primary" @click="copyValue(DNS_FIX[expanded[d.domain]!].record.value)">
<template #leading><UiIcon name="copy" :size="13" /></template>
{{ copied === DNS_FIX[expanded[d.domain]!].record.value ? 'Copied · paste at your DNS provider' : 'Copy record value' }}
</UiButton>
<UiButton size="sm" variant="secondary" @click="toast.info('Opening DNS provider guide…')">
<template #leading><UiIcon name="external" :size="13" /></template>
Open DNS guide
</UiButton>
<UiButton size="sm" variant="ghost" @click="toast.ok('Re-checking record')">
<template #leading><UiIcon name="refresh" :size="13" /></template>
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 class="currently-set">
<Eyebrow>Currently set</Eyebrow>
<div class="set-value">{{ DNS_FIX[expanded[d.domain]!].record.value }}</div>
</div>
</template>
</div>
</Card>
</div>
</div>
</template>
<style scoped>
.content { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 12px; }
.head { display: flex; align-items: center; gap: 16px; }
.title { flex: 1; min-width: 0; }
.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); }
.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>
+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>
+341 -69
View File
@@ -29,9 +29,23 @@ const statusFilter = ref<'all' | 'active' | 'suspended'>('all')
const selected = ref<Set<string>>(new Set())
const openUser = ref<TenantUserDoc | null>(null)
const inviteOpen = ref(false)
const inviteStep = ref(1)
const importOpen = ref(false)
// Real invite flow — creates a member provisioned across SSO + mailbox + storage.
const { request } = useApiFetch()
const { domains } = useDomains()
const primaryDomain = computed(() => domains.value?.find((d) => d.isPrimary) ?? domains.value?.[0])
const inviteBusy = ref(false)
const inviteForm = reactive({ name: '', localPart: '', role: 'member' as 'member' | 'admin', domain: '' })
const inviteResult = ref<{
email: string
tempPassword: string
provisioning: { authentik: string; stalwart: string; ocis: string }
stalwartError?: string
ocisNote?: string
} | null>(null)
const inviteDomain = computed(() => inviteForm.domain || primaryDomain.value?.domain || '')
const userStatus = (u: TenantUserDoc): 'active' | 'suspended' => (u.active === false ? 'suspended' : 'active')
const roleLabel = (r: string) => r.charAt(0).toUpperCase() + r.slice(1)
@@ -83,10 +97,47 @@ const changeRoleOpen = ref(false)
const suspendOpen = ref(false)
const roleChoice = ref<'member' | 'admin' | 'owner'>('member')
function sendInvite() {
function openInvite() {
inviteResult.value = null
inviteForm.name = ''
inviteForm.localPart = ''
inviteForm.role = 'member'
inviteForm.domain = primaryDomain.value?.domain ?? ''
inviteOpen.value = true
}
function closeInvite() {
inviteOpen.value = false
inviteStep.value = 1
toast.ok('Invitation sent to magnus@dezky.com')
inviteResult.value = null
}
async function submitInvite() {
if (!inviteForm.name.trim() || !inviteForm.localPart.trim() || !inviteDomain.value) return
inviteBusy.value = true
try {
inviteResult.value = await request(`/api/tenants/${slug.value}/users`, {
method: 'POST',
body: {
name: inviteForm.name.trim(),
localPart: inviteForm.localPart.trim(),
role: inviteForm.role,
domain: inviteForm.domain || undefined,
},
})
await refreshNuxtData('admin-users')
toast.ok('User created', inviteResult.value?.email)
} catch (err) {
const e = err as { data?: { message?: string | string[] }; message?: string }
const m = e?.data?.message ?? e?.message ?? 'Unknown error'
toast.bad('Could not create user', Array.isArray(m) ? m.join(', ') : m)
} finally {
inviteBusy.value = false
}
}
function copyText(t: string) {
if (typeof navigator !== 'undefined' && navigator.clipboard) navigator.clipboard.writeText(t)
toast.ok('Copied to clipboard')
}
function provTone(s: string): 'ok' | 'warn' | 'bad' {
return s === 'ok' ? 'ok' : s === 'skipped' ? 'warn' : 'bad'
}
function applyBulkRole() {
@@ -110,20 +161,115 @@ function bulkExport() {
// Per-row kebab — open the user detail panel by default.
function rowAction(u: TenantUserDoc, id: string) {
if (id === 'open') openUser.value = u
else if (id === 'reset') toast.info(`Password reset link sent to ${u.email}`)
else if (id === 'force') toast.info(`Forcing logout for ${u.name}`)
else if (id === 'suspend') toast.warn(`${u.name} suspended`)
else if (id === 'delete') toast.bad(`${u.name} deletion scheduled`)
else if (id === 'reset') resetTarget.value = u
else if (id === 'force') forceLogoutUser(u)
else if (id === 'suspend') suspendTarget.value = u
else if (id === 'resume') resumeUser(u)
else if (id === 'delete') removeTarget.value = u
}
const userRowItems = [
{ id: 'open', label: 'Open profile', icon: 'external' as const },
{ id: 'reset', label: 'Send password reset', icon: 'key' as const },
{ id: 'force', label: 'Force logout', icon: 'logout' as const },
{ id: 'sep1', separator: true },
{ id: 'suspend', label: 'Suspend user', icon: 'shield' as const, danger: true },
{ id: 'delete', label: 'Delete user', icon: 'trash' as const, danger: true },
]
// Menu varies per user: a suspended user shows Resume instead of Suspend.
function rowItems(u: TenantUserDoc) {
const suspended = u.active === false
return [
{ id: 'open', label: 'Open profile', icon: 'external' as const },
{ id: 'reset', label: 'Send password reset', icon: 'key' as const },
{ id: 'force', label: 'Force logout', icon: 'logout' as const },
{ id: 'sep1', separator: true },
suspended
? { id: 'resume', label: 'Resume user', icon: 'check' as const }
: { id: 'suspend', label: 'Suspend user', icon: 'shield' as const, danger: true },
{ id: 'delete', label: 'Delete user', icon: 'trash' as const, danger: true },
]
}
function toastErr(err: unknown, title: string) {
const e = err as { data?: { message?: string }; message?: string }
toast.bad(title, e?.data?.message ?? e?.message ?? 'Unknown error')
}
// Remove-user flow — tears down mailbox + SSO + storage via the server.
const removeTarget = ref<TenantUserDoc | null>(null)
const removing = ref(false)
async function confirmRemoveUser() {
const u = removeTarget.value
if (!u) return
removing.value = true
try {
await request(`/api/tenants/${slug.value}/users/${u._id}`, { method: 'DELETE' })
await refreshNuxtData('admin-users')
toast.ok('User removed', u.email)
removeTarget.value = null
openUser.value = null
} catch (err) {
toastErr(err, 'Could not remove user')
} finally {
removing.value = false
}
}
// Suspend / resume.
const suspendTarget = ref<TenantUserDoc | null>(null)
const suspendBusy = ref(false)
async function confirmSuspend() {
const u = suspendTarget.value
if (!u) return
suspendBusy.value = true
try {
await request(`/api/tenants/${slug.value}/users/${u._id}/suspend`, { method: 'POST' })
await refreshNuxtData('admin-users')
toast.ok('User suspended', u.email)
suspendTarget.value = null
openUser.value = null
} catch (err) {
toastErr(err, 'Could not suspend user')
} finally {
suspendBusy.value = false
}
}
async function resumeUser(u: TenantUserDoc) {
try {
await request(`/api/tenants/${slug.value}/users/${u._id}/resume`, { method: 'POST' })
await refreshNuxtData('admin-users')
toast.ok('User resumed', u.email)
openUser.value = null
} catch (err) {
toastErr(err, 'Could not resume user')
}
}
// Force logout — low-risk, no confirm.
async function forceLogoutUser(u: TenantUserDoc) {
try {
const r = await request<{ sessions: number }>(
`/api/tenants/${slug.value}/users/${u._id}/force-logout`,
{ method: 'POST' },
)
toast.ok('Sessions ended', `${u.name} · ${r.sessions} session${r.sessions === 1 ? '' : 's'} terminated`)
} catch (err) {
toastErr(err, 'Could not force logout')
}
}
// Reset password — confirm, then show the new one-time password.
const resetTarget = ref<TenantUserDoc | null>(null)
const resetBusy = ref(false)
const resetResult = ref<{ email: string; tempPassword: string } | null>(null)
async function confirmReset() {
const u = resetTarget.value
if (!u) return
resetBusy.value = true
try {
resetResult.value = await request(`/api/tenants/${slug.value}/users/${u._id}/reset-password`, {
method: 'POST',
})
resetTarget.value = null
} catch (err) {
toastErr(err, 'Could not reset password')
} finally {
resetBusy.value = false
}
}
</script>
<template>
@@ -142,7 +288,7 @@ const userRowItems = [
<template #leading><UiIcon name="download" :size="14" /></template>
Export
</UiButton>
<UiButton variant="primary" @click="inviteOpen = true">
<UiButton variant="primary" @click="openInvite">
<template #leading><UiIcon name="plus" :size="14" /></template>
Invite user
</UiButton>
@@ -210,7 +356,7 @@ const userRowItems = [
<td><Badge :tone="statusTone(userStatus(u))" dot>{{ userStatus(u) }}</Badge></td>
<td><Mono dim>{{ lastSeen(u.lastLoginAt) }}</Mono></td>
<td class="right" @click.stop>
<AdminKebabMenu :items="userRowItems" :icon-size="16" @select="(id) => rowAction(u, id)" />
<AdminKebabMenu :items="rowItems(u)" :icon-size="16" @select="(id) => rowAction(u, id)" />
</td>
</tr>
<tr v-if="filteredUsers.length === 0" class="no-hover">
@@ -276,67 +422,159 @@ const userRowItems = [
</div>
</div>
<template #footer>
<UiButton variant="danger" @click="openUser && rowAction(openUser, 'force')">
<template #leading><UiIcon name="logout" :size="13" /></template>
Force logout
<UiButton variant="danger" @click="openUser && (removeTarget = openUser)">
<template #leading><UiIcon name="trash" :size="13" /></template>
Remove user
</UiButton>
<UiButton variant="secondary" @click="openUser && rowAction(openUser, 'reset')">Reset password</UiButton>
</template>
</SidePanel>
<!-- Invite user modal (3 steps) -->
<Modal :open="inviteOpen" :title="'Invite user'" :eyebrow="`Step ${inviteStep} of 3`" size="md" @close="inviteOpen = false; inviteStep = 1">
<div v-if="inviteStep === 1" class="form-stack">
<label class="field"><Eyebrow>Full name</Eyebrow><input class="input" value="Magnus Eriksen" /></label>
<label class="field"><Eyebrow>Email</Eyebrow><input class="input" value="magnus@dezky.com" /></label>
<label class="field"><Eyebrow>Role</Eyebrow>
<div class="radio-row">
<button class="active">Member</button><button>Admin</button>
<!-- Remove user confirm -->
<ConfirmDialog
:open="!!removeTarget"
eyebrow="Users"
:title="`Remove ${removeTarget?.name || removeTarget?.email}?`"
confirm-label="Remove user"
tone="danger"
:busy="removing"
@close="removeTarget = null"
@confirm="confirmRemoveUser"
>
Their sign-in, mailbox <strong>{{ removeTarget?.email }}</strong> and storage are deleted from the
mail server and identity provider. Any mail in the mailbox is lost. This cant be undone.
</ConfirmDialog>
<!-- Suspend user confirm -->
<ConfirmDialog
:open="!!suspendTarget"
eyebrow="Users"
:title="`Suspend ${suspendTarget?.name || suspendTarget?.email}?`"
confirm-label="Suspend user"
tone="danger"
:busy="suspendBusy"
@close="suspendTarget = null"
@confirm="confirmSuspend"
>
Theyll be blocked from signing in and their mailbox <strong>{{ suspendTarget?.email }}</strong>
stops sending and receiving — until you resume them. Nothing is deleted.
</ConfirmDialog>
<!-- Reset password confirm -->
<ConfirmDialog
:open="!!resetTarget"
eyebrow="Users"
:title="`Reset password for ${resetTarget?.name || resetTarget?.email}?`"
confirm-label="Reset password"
tone="danger"
:busy="resetBusy"
@close="resetTarget = null"
@confirm="confirmReset"
>
A new one-time password is generated for both their sign-in and mailbox. Their current password
stops working immediately.
</ConfirmDialog>
<!-- New password result -->
<Modal :open="!!resetResult" title="New password" eyebrow="Users" size="md" @close="resetResult = null">
<div v-if="resetResult" class="invite-result">
<div class="ir-check"><UiIcon name="key" :size="20" /></div>
<div class="ir-title">Password reset</div>
<p class="ir-sub">Share this securely. It works for both sign-in and webmail at <Mono>mail.dezky.local</Mono>.</p>
<div class="cred">
<div class="cred-row">
<span class="cred-k">Email</span><Mono class="cred-v">{{ resetResult.email }}</Mono>
<button class="copy" @click="copyText(resetResult.email)"><UiIcon name="copy" :size="13" /></button>
</div>
</label>
<label class="field"><Eyebrow>License tier</Eyebrow>
<div class="radio-row">
<button>Basic</button><button class="active">Business</button>
<div class="cred-row">
<span class="cred-k">New password</span><Mono class="cred-v">{{ resetResult.tempPassword }}</Mono>
<button class="copy" @click="copyText(resetResult.tempPassword)"><UiIcon name="copy" :size="13" /></button>
</div>
</label>
</div>
<div v-else-if="inviteStep === 2" class="form-stack">
<div>
<Eyebrow>Group memberships</Eyebrow>
<div class="check-stack">
<label v-for="(g, i) in ['Engineering', 'Design', 'Operations', 'Finance', 'Sales']" :key="g">
<input type="checkbox" :checked="i === 0" /> {{ g }}
</label>
</div>
</div>
<div>
<Eyebrow>Apps</Eyebrow>
<div class="check-stack">
<label v-for="a in ['Mail', 'Drev', 'Møder', 'Chat']" :key="a">
<input type="checkbox" checked /> {{ a }}
</label>
</div>
</div>
</div>
<div v-else>
<div class="review-box">
<dl class="def">
<div><dt>Name</dt><dd>Magnus Eriksen</dd></div>
<div><dt>Email</dt><dd>magnus@dezky.com</dd></div>
<div><dt>Role</dt><dd>Member · Business</dd></div>
<div><dt>Groups</dt><dd>Engineering</dd></div>
<div><dt>Apps</dt><dd>Mail · Drev · Møder · Chat</dd></div>
</dl>
</div>
<div class="muted">
We'll provision the account across Authentik, Stalwart, OCIS, Jitsi and Zulip, then email Magnus an activation link valid for 7 days.
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="inviteOpen = false; inviteStep = 1">Cancel</UiButton>
<UiButton v-if="inviteStep > 1" variant="secondary" @click="inviteStep--">Back</UiButton>
<UiButton v-if="inviteStep < 3" variant="primary" @click="inviteStep++">Continue</UiButton>
<UiButton v-else variant="primary" @click="sendInvite">Send invitation</UiButton>
<div style="flex: 1" />
<UiButton variant="primary" @click="resetResult = null">Done</UiButton>
</template>
</Modal>
<!-- Invite user modal (3 steps) -->
<Modal :open="inviteOpen" title="Invite user" eyebrow="Users" size="md" @close="closeInvite">
<!-- No domain yet -->
<div v-if="!primaryDomain" class="no-domain">
<UiIcon name="globe" :size="22" stroke="var(--text-mute)" />
<div class="nd-text">
<div class="nd-title">Add a domain first</div>
<div class="nd-sub">Users get an email address on your domain. Add one on the Domains page, then come back.</div>
</div>
<UiButton variant="primary" @click="closeInvite(); navigateTo('/admin/domains')">Go to Domains</UiButton>
</div>
<!-- Result: credentials + per-system status -->
<div v-else-if="inviteResult" class="invite-result">
<div class="ir-check"><UiIcon name="check" :size="22" :stroke-width="2.5" /></div>
<div class="ir-title">{{ inviteResult.email }} is ready</div>
<p class="ir-sub">Share these credentials securely. They sign in to the portal and to webmail at <Mono>mail.dezky.local</Mono>.</p>
<div class="cred">
<div class="cred-row">
<span class="cred-k">Email</span><Mono class="cred-v">{{ inviteResult.email }}</Mono>
<button class="copy" @click="copyText(inviteResult.email)"><UiIcon name="copy" :size="13" /></button>
</div>
<div class="cred-row">
<span class="cred-k">Temp password</span><Mono class="cred-v">{{ inviteResult.tempPassword }}</Mono>
<button class="copy" @click="copyText(inviteResult.tempPassword)"><UiIcon name="copy" :size="13" /></button>
</div>
</div>
<div class="prov">
<Badge :tone="provTone(inviteResult.provisioning.authentik)" dot>SSO login</Badge>
<Badge :tone="provTone(inviteResult.provisioning.stalwart)" dot>Mailbox</Badge>
<Badge :tone="provTone(inviteResult.provisioning.ocis)" dot>Storage</Badge>
</div>
<div v-if="inviteResult.stalwartError" class="prov-note bad">Mailbox could not be created: {{ inviteResult.stalwartError }}</div>
<div v-else-if="inviteResult.ocisNote" class="prov-note">Storage {{ inviteResult.ocisNote }}.</div>
</div>
<!-- Form -->
<div v-else class="form-stack">
<label class="field"><Eyebrow>Full name</Eyebrow><input class="input" v-model="inviteForm.name" placeholder="Jane Doe" /></label>
<label class="field"><Eyebrow>Email address</Eyebrow>
<div class="alias-row">
<input class="input" v-model="inviteForm.localPart" placeholder="jane" />
<span class="at">@</span>
<select v-if="(domains?.length ?? 0) > 1" class="input" v-model="inviteForm.domain">
<option v-for="d in domains" :key="d.id" :value="d.domain">{{ d.domain }}</option>
</select>
<Mono v-else class="domain-fixed">{{ inviteDomain }}</Mono>
</div>
</label>
<label class="field"><Eyebrow>Role</Eyebrow>
<div class="radio-row">
<button type="button" :class="{ active: inviteForm.role === 'member' }" @click="inviteForm.role = 'member'">Member</button>
<button type="button" :class="{ active: inviteForm.role === 'admin' }" @click="inviteForm.role = 'admin'">Admin</button>
</div>
</label>
<div class="muted">
We'll create their SSO login, a mailbox at <Mono>{{ (inviteForm.localPart || 'name') + '@' + inviteDomain }}</Mono>, and OCIS storage then show you a one-time password.
</div>
</div>
<template #footer>
<template v-if="inviteResult">
<div style="flex: 1" />
<UiButton variant="primary" @click="closeInvite">Done</UiButton>
</template>
<template v-else-if="primaryDomain">
<UiButton variant="ghost" @click="closeInvite">Cancel</UiButton>
<div style="flex: 1" />
<UiButton variant="primary" :disabled="inviteBusy || !inviteForm.name.trim() || !inviteForm.localPart.trim()" @click="submitInvite">
<template #leading><UiIcon name="check" :size="13" /></template>
{{ inviteBusy ? 'Creating…' : 'Create user' }}
</UiButton>
</template>
<template v-else>
<div style="flex: 1" />
<UiButton variant="ghost" @click="closeInvite">Close</UiButton>
</template>
</template>
</Modal>
@@ -505,6 +743,40 @@ Mikkel Sørensen,mikkel@baslund.dk,admin,Engineering,business</pre>
.review-box { padding: 16px; background: var(--bg); border-radius: 6px; margin-bottom: 16px; }
.muted { font-size: 12px; color: var(--text-mute); line-height: 1.55; }
/* Invite modal — address row */
.alias-row { display: flex; align-items: center; gap: 8px; }
.alias-row .input:first-child { flex: 1; }
.at { font-family: var(--font-mono); color: var(--text-mute); }
.domain-fixed { font-size: 13px; color: var(--text-dim); white-space: nowrap; }
/* Invite modal — no-domain notice */
.no-domain { display: flex; align-items: center; gap: 14px; padding: 8px 0; }
.nd-text { flex: 1; }
.nd-title { font-weight: 600; font-size: 14px; }
.nd-sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; line-height: 1.5; }
/* Invite modal — result */
.invite-result { text-align: center; padding: 8px 0; }
.ir-check {
width: 48px; height: 48px; border-radius: 12px; margin: 0 auto 14px;
background: var(--accent); color: var(--accent-fg);
display: inline-flex; align-items: center; justify-content: center;
}
.ir-title { font-family: var(--font-display); font-weight: 600; font-size: 18px; }
.ir-sub { font-size: 13px; color: var(--text-mute); margin: 6px auto 16px; max-width: 380px; line-height: 1.55; }
.cred { display: flex; flex-direction: column; gap: 8px; text-align: left; }
.cred-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
}
.cred-k { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-mute); width: 100px; flex-shrink: 0; }
.cred-v { flex: 1; font-size: 13px; word-break: break-all; }
.copy { background: transparent; border: none; padding: 4px; border-radius: 4px; color: var(--text-mute); cursor: pointer; }
.copy:hover { background: var(--surface); }
.prov { display: flex; justify-content: center; gap: 10px; margin-top: 16px; }
.prov-note { font-size: 12px; color: var(--text-mute); margin-top: 12px; }
.prov-note.bad { color: var(--bad); }
.import { display: flex; flex-direction: column; gap: 14px; }
.upload-stage {
padding: 32px 24px;