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:
@@ -63,7 +63,7 @@ const ADMIN_NAV: NavRow[] = [
|
||||
{ id: 'mail', label: 'Mail settings', icon: 'mail', href: '/admin/mail' },
|
||||
{ id: 'meetings', label: 'Meetings', icon: 'video', href: '/admin/meetings' },
|
||||
{ id: 'chat', label: 'Chat', icon: 'chat', href: '/admin/chat' },
|
||||
{ id: 'domains', label: 'Domains', icon: 'globe', href: '/admin/domains', badge: 1 },
|
||||
{ id: 'domains', label: 'Domains', icon: 'globe', href: '/admin/domains' },
|
||||
{ id: 'storage', label: 'Storage', icon: 'database', href: '/admin/storage' },
|
||||
{ id: 'security', label: 'Security & audit', icon: 'shield', href: '/admin/security' },
|
||||
{ sec: 'Commercial' },
|
||||
@@ -97,7 +97,15 @@ const navItems = computed<NavRow[]>(() => {
|
||||
: row,
|
||||
)
|
||||
}
|
||||
if (section.value === 'admin') return ADMIN_NAV
|
||||
if (section.value === 'admin') {
|
||||
// Inject the count of domains needing attention onto the Domains row.
|
||||
// Undefined when 0 so the badge hides rather than rendering "0".
|
||||
return ADMIN_NAV.map((row) =>
|
||||
'id' in row && row.id === 'domains'
|
||||
? { ...row, badge: domainsNeedingAttention.value || undefined }
|
||||
: row,
|
||||
)
|
||||
}
|
||||
return END_USER_NAV
|
||||
})
|
||||
|
||||
@@ -176,6 +184,22 @@ const { data: ownUsers } = await useFetch<TenantUserDoc[]>(
|
||||
)
|
||||
const seatsUsed = computed(() => (ownUsers.value ?? []).filter((u) => u.active !== false).length)
|
||||
|
||||
// Domains needing attention (anything not fully verified) drive the Domains nav
|
||||
// badge. Shares the 'admin-domains' fetch key with the Domains page, so adding
|
||||
// or fixing a domain updates the badge live. Gated like the seat usage fetch.
|
||||
const { data: sidebarDomains } = await useFetch<{ status: string }[]>(
|
||||
() => `/api/tenants/${ownSlug.value}/domains`,
|
||||
{
|
||||
key: 'admin-domains',
|
||||
default: () => [],
|
||||
immediate: !isPartnerStaff.value && !!ownSlug.value,
|
||||
watch: [ownSlug],
|
||||
},
|
||||
)
|
||||
const domainsNeedingAttention = computed(
|
||||
() => (sidebarDomains.value ?? []).filter((d) => d.status !== 'active').length,
|
||||
)
|
||||
|
||||
// Workspace mark colours. Default to the signal accent when no brandColor is
|
||||
// saved (matches the Branding preview); readableOn flips the initial light on
|
||||
// dark accents so it stays legible for any chosen colour.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
// One DNS record row used by the add-domain wizard: TYPE / HOST / VALUE with a
|
||||
// live status badge and copy buttons. Values come straight from the server's
|
||||
// expected records (Stalwart's authoritative zone), so the DKIM key etc. is real.
|
||||
import type { DomainRecordView, RecordStatus } from '~/composables/useDomains'
|
||||
|
||||
const props = defineProps<{ rec: DomainRecordView }>()
|
||||
const toast = useToast()
|
||||
|
||||
function badgeTone(status: RecordStatus): 'ok' | 'warn' | 'bad' {
|
||||
return status === 'ok' ? 'ok' : status === 'bad' ? 'bad' : 'warn'
|
||||
}
|
||||
function copy(text: string) {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) navigator.clipboard.writeText(text)
|
||||
toast.ok('Copied to clipboard')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dns-row">
|
||||
<div><Mono dim>TYPE</Mono><div class="dns-val">{{ rec.type }}</div></div>
|
||||
<div>
|
||||
<Mono dim>HOST</Mono>
|
||||
<button class="dns-val link" @click="copy(rec.fqdn)" :title="rec.fqdn">{{ rec.fqdn }}</button>
|
||||
</div>
|
||||
<div>
|
||||
<Mono dim>VALUE</Mono>
|
||||
<button class="dns-val dim link" @click="copy(rec.expected)" :title="rec.expected">{{ rec.expected }}</button>
|
||||
</div>
|
||||
<div class="dns-right">
|
||||
<Badge :tone="badgeTone(rec.status)" dot>{{ rec.status }}</Badge>
|
||||
<span v-if="rec.priority !== undefined" class="prio"><Mono dim>prio {{ rec.priority }}</Mono></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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; }
|
||||
.dns-val.link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
.dns-val.link:hover { color: var(--text); }
|
||||
.dns-right { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
||||
.prio { font-size: 11px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user