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
+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;