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
+9
View File
@@ -46,7 +46,16 @@ OPERATOR_OIDC_CLIENT_SECRET=changeme_run_openssl_rand_hex_64
# ────────────────────────────────────────
# Stalwart Mail
# ────────────────────────────────────────
# Fallback admin login (config.toml authentication.fallback-admin). platform-api
# uses admin + this password for Basic auth on the JMAP management API.
STALWART_ADMIN_USER=admin
STALWART_ADMIN_PASSWORD=changeme_use_openssl_rand
# HMAC secret Stalwart signs its audit webhook POSTs with (verified by
# platform-api at /ingest/stalwart/webhook). openssl rand -hex 32
STALWART_WEBHOOK_SECRET=changeme_use_openssl_rand_hex_32
# Set true to let platform-api create/delete domains + DKIM in Stalwart from the
# customer-admin Domains page. Off by default (domain steps record 'skipped').
STALWART_PROVISIONING_ENABLED=false
# ────────────────────────────────────────
# OCIS
+26 -2
View File
@@ -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.
+65
View File
@@ -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>
+68
View File
@@ -0,0 +1,68 @@
// Customer-admin email-domain data + mutations, backed by platform-api's
// /api/tenants/:slug/domains endpoints. Reads use useFetch (SSR-friendly list);
// writes go through useApiFetch so a lapsed session refreshes silently instead
// of redirecting away mid-action. Mirrors the read/write split in
// pages/admin/security.vue.
export type RecordStatus = 'ok' | 'warn' | 'bad' | 'pending'
export type DomainStatus = 'pending' | 'verifying' | 'active' | 'error'
export type RecordKind = 'ownership' | 'mx' | 'spf' | 'dkim' | 'dmarc'
export type DmarcPolicy = 'none' | 'quarantine' | 'reject'
export interface DomainRecordView {
kind: RecordKind
type: string
host: string
fqdn: string
expected: string
priority?: number
observed?: string
status: RecordStatus
}
export interface DomainView {
id: string
domain: string
isPrimary: boolean
status: DomainStatus
ownershipVerified: boolean
verificationToken: string
dmarcPolicy: DmarcPolicy
stalwartProvisioned: boolean
stalwartError?: string
mailboxes: number
checks: Record<'ownership' | 'mx' | 'spf' | 'dkim' | 'dmarc', RecordStatus>
records: DomainRecordView[]
lastCheckedAt?: string
}
export function useDomains() {
const { tenant } = useTenant()
const slug = computed(() => tenant.value?.slug ?? '')
const { request } = useApiFetch()
const base = () => `/api/tenants/${slug.value}/domains`
const one = (domain: string) => `${base()}/${encodeURIComponent(domain)}`
const { data: domains, refresh, pending } = useFetch<DomainView[]>(base, {
key: 'admin-domains',
default: () => [],
immediate: !!slug.value,
watch: [slug],
})
const add = (domain: string) =>
request<DomainView>(base(), { method: 'POST', body: { domain } })
const getOne = (domain: string) => request<DomainView>(one(domain))
const recheck = (domain: string) =>
request<DomainView>(`${one(domain)}/recheck`, { method: 'POST' })
const setDmarcPolicy = (domain: string, dmarcPolicy: DmarcPolicy) =>
request<DomainView>(`${one(domain)}/dmarc`, { method: 'PATCH', body: { dmarcPolicy } })
const remove = (domain: string) => request(one(domain), { method: 'DELETE' })
return { domains, pending, refresh, slug, add, getOne, recheck, setDmarcPolicy, remove }
}
-47
View File
@@ -50,45 +50,6 @@ export const sampleGroups = [
{ id: 'g-5', name: 'Sales', description: 'Outbound + customer success', members: 3, owner: 'Bo Christensen', resources: ['sales@', 'Drev/Sales', '#sales'] },
]
export const sampleDomains = [
{
id: 'd-1',
domain: 'baslund.dk',
primary: true,
status: 'partial',
records: [
{ type: 'MX', status: 'ok', value: '10 mail.dezky.com' },
{ type: 'SPF', status: 'warn', value: 'v=spf1 ~all', expected: 'v=spf1 include:_spf.dezky.com ~all' },
{ type: 'DKIM', status: 'ok', value: 'k=rsa; p=MIGfMA0GCSq...' },
{ type: 'DMARC', status: 'bad', value: '— not found —', expected: 'v=DMARC1; p=quarantine; rua=mailto:dmarc@baslund.dk' },
],
addedOn: '2025-12-04',
},
{
id: 'd-2',
domain: 'baslund.shop',
primary: false,
status: 'healthy',
records: [
{ type: 'MX', status: 'ok', value: '10 mail.dezky.com' },
{ type: 'SPF', status: 'ok', value: 'v=spf1 include:_spf.dezky.com ~all' },
{ type: 'DKIM', status: 'ok', value: 'k=rsa; p=MIGfMA0G...' },
{ type: 'DMARC', status: 'ok', value: 'v=DMARC1; p=quarantine' },
],
addedOn: '2026-02-11',
},
{
id: 'd-3',
domain: 'baslund.io',
primary: false,
status: 'verifying',
records: [
{ type: 'TXT', status: 'warn', value: 'dezky-site-verification=…', hint: 'Awaiting propagation · ~10 min remaining' },
],
addedOn: '2026-05-22',
},
]
export const sampleInvoices = [
{ id: 'inv-2026-001247', date: '2026-05-01', period: '2026-05', amount: 1940, status: 'paid', method: 'MobilePay' },
{ id: 'inv-2026-001112', date: '2026-04-01', period: '2026-04', amount: 1940, status: 'paid', method: 'MobilePay' },
@@ -173,14 +134,6 @@ export const sampleUsersFlat = [
{ id: 'u_tt55', name: 'Clara Bjerre', email: 'clara@dezky.com', role: 'Member', status: 'active', last: '5 d ago', group: 'Sales', storage: 2.0 },
]
// Source-fidelity domains (platform-screens.jsx SAMPLE_DOMAINS line 23) — flat
// shape with per-record-type status used by DomainsScreen / DomainCard.
export const sampleDomainsFlat = [
{ domain: 'dezky.com', status: 'ok' as const, mx: 'ok' as const, spf: 'ok' as const, dkim: 'ok' as const, dmarc: 'ok' as const, users: 11 },
{ domain: 'dezky.io', status: 'ok' as const, mx: 'ok' as const, spf: 'ok' as const, dkim: 'ok' as const, dmarc: 'warn' as const, users: 0 },
{ domain: 'baslund.dk', status: 'warn' as const, mx: 'ok' as const, spf: 'warn' as const, dkim: 'ok' as const, dmarc: 'bad' as const, users: 2 },
]
// Meeting rooms — strict port of platform-collab.jsx MEETING_ROOMS (line 8)
export const meetingRooms = [
{ id: 'r_eng', name: 'Engineering standup', alias: 'eng-standup', type: 'recurring' as const, when: 'Daily · 09:30', owner: 'Mikkel Nørgaard', members: 4, recording: 'auto' as const, protected: false },
-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>
<RecordRow v-for="(r, i) in recordsOfKind('spf')" :key="'spf' + i" :rec="r" />
</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.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>
<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="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>
</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>
+336 -64
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 = [
// 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 },
{ id: 'suspend', label: 'Suspend user', icon: 'shield' as const, danger: 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;
@@ -0,0 +1,22 @@
// Set the DMARC policy for a domain (wizard step 5). Proxies
// PATCH /tenants/:slug/domains/:domain/dmarc with { dmarcPolicy };
// platform-api updates the expected record, re-verifies, and enforces membership.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const domain = getRouterParam(event, 'domain')
const body = await readBody(event)
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/domains/${encodeURIComponent(domain ?? '')}/dmarc`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${accessToken}` },
body,
})
})
@@ -0,0 +1,20 @@
// Remove a domain. Proxies DELETE /tenants/:slug/domains/:domain; platform-api
// deletes it from Stalwart (DKIM sigs first) and enforces tenant membership.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const domain = getRouterParam(event, 'domain')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
await $fetch(`${base}/tenants/${slug}/domains/${encodeURIComponent(domain ?? '')}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
})
return { ok: true }
})
@@ -0,0 +1,18 @@
// Single domain detail (expected + observed records). Proxies
// GET /tenants/:slug/domains/:domain; platform-api enforces tenant membership.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const domain = getRouterParam(event, 'domain')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/domains/${encodeURIComponent(domain ?? '')}`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
})
@@ -0,0 +1,20 @@
// Re-run the live DNS checks for a domain. Proxies
// POST /tenants/:slug/domains/:domain/recheck; platform-api re-verifies
// MX/SPF/DKIM/DMARC/ownership against public DNS and enforces membership.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const domain = getRouterParam(event, 'domain')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/domains/${encodeURIComponent(domain ?? '')}/recheck`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
})
})
@@ -0,0 +1,18 @@
// List the workspace's email domains (Domains page + sidebar badge). Proxies
// GET /tenants/:slug/domains with the signed-in user's access token;
// platform-api enforces tenant membership.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/domains`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
})
@@ -0,0 +1,21 @@
// Add an email domain. Proxies POST /tenants/:slug/domains with { domain };
// platform-api provisions it in Stalwart (auto-generating DKIM), seeds the
// expected records, runs an initial DNS check, and enforces tenant membership.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const body = await readBody(event)
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/domains`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
body,
})
})
@@ -0,0 +1,21 @@
// Create a workspace member. Proxies POST /tenants/:slug/users; platform-api
// provisions the user across Authentik (SSO), Stalwart (mailbox on the default
// domain) and OCIS, then returns the email + one-time temp password.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const body = await readBody(event)
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/users`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
body,
})
})
@@ -0,0 +1,21 @@
// Remove a workspace member. Proxies DELETE /tenants/:slug/users/:userId;
// platform-api tears down the mailbox, OCIS account and (if it was their last
// workspace) the SSO identity. Enforces tenant membership + blocks self-removal.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) {
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
}
const slug = getRouterParam(event, 'slug')
const userId = getRouterParam(event, 'userId')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
await $fetch(`${base}/tenants/${slug}/users/${userId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
})
return { ok: true }
})
@@ -0,0 +1,16 @@
// Force-logout a member (terminate their SSO sessions). Proxies POST
// /tenants/:slug/users/:userId/force-logout.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
const slug = getRouterParam(event, 'slug')
const userId = getRouterParam(event, 'userId')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/users/${userId}/force-logout`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
})
})
@@ -0,0 +1,16 @@
// Reset a member's password (new one-time password on SSO + mailbox). Proxies
// POST /tenants/:slug/users/:userId/reset-password and returns { email, tempPassword }.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
const slug = getRouterParam(event, 'slug')
const userId = getRouterParam(event, 'userId')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/users/${userId}/reset-password`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
})
})
@@ -0,0 +1,16 @@
// Resume a suspended member. Proxies POST /tenants/:slug/users/:userId/resume.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
const slug = getRouterParam(event, 'slug')
const userId = getRouterParam(event, 'userId')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
await $fetch(`${base}/tenants/${slug}/users/${userId}/resume`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
})
return { ok: true }
})
@@ -0,0 +1,17 @@
// Suspend a member (freeze SSO + mailbox). Proxies POST
// /tenants/:slug/users/:userId/suspend.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
const slug = getRouterParam(event, 'slug')
const userId = getRouterParam(event, 'userId')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
await $fetch(`${base}/tenants/${slug}/users/${userId}/suspend`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
})
return { ok: true }
})
@@ -553,9 +553,14 @@ services:
MONGODB_URI: mongodb://root:${MONGO_ROOT_PASSWORD}@mongo:27017/dezky?authSource=admin
AUTHENTIK_API_URL: https://auth.dezky.local/api/v3
AUTHENTIK_API_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN}
STALWART_API_URL: https://mail.dezky.local
# Internal hostname (NOT https://mail.dezky.local — that's Traefik + a
# mkcert cert Node's fetch rejects). Stalwart's HTTP/JMAP listener is :8080.
STALWART_API_URL: http://stalwart:8080
STALWART_ADMIN_USER: admin
STALWART_ADMIN_PASSWORD: ${STALWART_ADMIN_PASSWORD}
# Gates real domain provisioning (x:Domain/set via JMAP). Off → domain
# steps record 'skipped' and the Domains page works without a live Stalwart.
STALWART_PROVISIONING_ENABLED: ${STALWART_PROVISIONING_ENABLED:-false}
# HMAC secret Stalwart signs its webhook POSTs with; we verify on
# /ingest/stalwart/webhook. Both ends read the same env var.
STALWART_WEBHOOK_SECRET: ${STALWART_WEBHOOK_SECRET}
+2
View File
@@ -4,6 +4,7 @@ import { MongooseModule } from '@nestjs/mongoose'
import { AuditModule } from './audit/audit.module.js'
import { AuthModule } from './auth/auth.module.js'
import { BillingModule } from './billing/billing.module.js'
import { DomainsModule } from './domains/domains.module.js'
import { FlagsModule } from './flags/flags.module.js'
import { HealthModule } from './health/health.module.js'
import { IngestModule } from './ingest/ingest.module.js'
@@ -25,6 +26,7 @@ import { UsersModule } from './users/users.module.js'
AuditModule,
HealthModule,
TenantsModule,
DomainsModule,
PartnersModule,
UsersModule,
MeModule,
@@ -0,0 +1,131 @@
import { Injectable, Logger } from '@nestjs/common'
import { Resolver } from 'node:dns/promises'
import type { DomainRecord, RecordStatus } from '../schemas/domain.schema.js'
export interface CheckResult {
observed?: string
status: RecordStatus
}
// Verifies a domain's DNS records against what Stalwart expects. Uses a
// dedicated resolver pointed at public DNS (Cloudflare / Google) rather than the
// container's system resolver, so results reflect real-world propagation and
// aren't skewed by Docker's internal resolver cache. The instance is private —
// we never touch the global `dns.setServers`, which would affect the rest of
// the app (Authentik/OCIS/Stripe fetches).
//
// Tone semantics mirror the customer-admin DNS_FIX copy on the Domains page:
// ok — record present and correct
// warn — present but weak/secondary (e.g. SPF ~all, DMARC p=none)
// bad — missing or wrong
@Injectable()
export class DnsVerifierService {
private readonly logger = new Logger(DnsVerifierService.name)
private readonly resolver: Resolver
constructor() {
this.resolver = new Resolver({ timeout: 5000, tries: 2 })
this.resolver.setServers(['1.1.1.1', '8.8.8.8'])
}
// Dispatch a single expected record to the right check. The DomainsService
// calls this for every record on a (re)check and writes back observed+status.
async check(record: DomainRecord, domain: string): Promise<CheckResult> {
switch (record.kind) {
case 'ownership':
return this.checkOwnership(record.fqdn, record.expected)
case 'mx':
return this.checkMx(domain, record.expected)
case 'spf':
return this.checkSpf(domain, record.expected)
case 'dkim':
return this.checkDkim(record.fqdn, record.expected)
case 'dmarc':
return this.checkDmarc(domain)
default:
return { status: 'pending' }
}
}
// Resolve TXT, joining each record's character-strings into one value.
private async txt(fqdn: string): Promise<string[]> {
try {
const recs = await this.resolver.resolveTxt(fqdn)
return recs.map((chunks) => chunks.join(''))
} catch {
// NXDOMAIN / NODATA / SERVFAIL → treat as "no record".
return []
}
}
// Ownership: the `_dezky-verify.<domain>` TXT must contain our token verbatim.
private async checkOwnership(fqdn: string, token: string): Promise<CheckResult> {
const records = await this.txt(fqdn)
const hit = records.find((v) => v.includes(token))
return hit ? { observed: hit, status: 'ok' } : { status: 'bad' }
}
// MX: at least one exchange must point at the expected host. A record that
// exists but doesn't match is a secondary/foreign MX → warn (allowed for
// failover, but the customer should know).
private async checkMx(domain: string, expectedHost: string): Promise<CheckResult> {
let mx: { exchange: string; priority: number }[]
try {
mx = await this.resolver.resolveMx(domain)
} catch {
return { status: 'bad' }
}
if (!mx.length) return { status: 'bad' }
const norm = (h: string) => h.replace(/\.$/, '').toLowerCase()
const want = norm(expectedHost)
const observed = mx
.sort((a, b) => a.priority - b.priority)
.map((r) => `${r.priority} ${norm(r.exchange)}`)
.join(', ')
const match = mx.some((r) => norm(r.exchange) === want)
return { observed, status: match ? 'ok' : 'warn' }
}
// SPF: find the v=spf1 record at the apex. Correct = authorises our sender
// (the expected mechanism — `mx` or an `include:` — is present) AND hardfails
// with `-all`. A softfail (`~all`/`?all`) or a missing mechanism is warn;
// no SPF record at all is bad.
private async checkSpf(domain: string, expected: string): Promise<CheckResult> {
const records = await this.txt(domain)
const spf = records.find((v) => /^v=spf1\b/i.test(v.trim()))
if (!spf) return { status: 'bad' }
const mechanism = expected.match(/include:\S+/i)?.[0] ?? 'mx'
const hasMechanism = new RegExp(`(^|\\s)${escapeRegex(mechanism)}(\\s|$)`, 'i').test(spf)
const hardFail = /[\s]-all\b/.test(spf) || /(^|\s)-all$/.test(spf.trim())
if (hasMechanism && hardFail) return { observed: spf, status: 'ok' }
return { observed: spf, status: 'warn' }
}
// DKIM: the selector TXT must carry the same public key Stalwart generated.
// We compare the `p=` value. Present-but-different = warn; absent = bad.
private async checkDkim(fqdn: string, expected: string): Promise<CheckResult> {
const records = await this.txt(fqdn)
const dkim = records.find((v) => /(^|;)\s*v=DKIM1/i.test(v) || /k=(rsa|ed25519)/i.test(v))
if (!dkim) return { status: 'bad' }
const expectedKey = expected.match(/p=([A-Za-z0-9+/=]+)/)?.[1]
const observedKey = dkim.match(/p=([A-Za-z0-9+/=]+)/)?.[1]
if (expectedKey && observedKey && expectedKey === observedKey) {
return { observed: dkim, status: 'ok' }
}
return { observed: dkim, status: 'warn' }
}
// DMARC: a `_dmarc` TXT must exist. p=none is monitor-only (warn); any
// stronger policy (quarantine/reject) is ok; no record is bad.
private async checkDmarc(domain: string): Promise<CheckResult> {
const records = await this.txt(`_dmarc.${domain}`)
const dmarc = records.find((v) => /^v=DMARC1\b/i.test(v.trim()))
if (!dmarc) return { status: 'bad' }
const policy = dmarc.match(/(^|;)\s*p=(\w+)/i)?.[2]?.toLowerCase()
return { observed: dmarc, status: policy === 'none' ? 'warn' : 'ok' }
}
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
@@ -0,0 +1,118 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
Param,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common'
import { ActorService } from '../auth/actor.service.js'
import { clientIp } from '../auth/client-ip.js'
import { CurrentUser } from '../auth/current-user.decorator.js'
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
import type { AuditActor } from '../audit/audit.service.js'
import { TenantsService } from '../tenants/tenants.service.js'
import { AddDomainDto } from './dto/add-domain.dto.js'
import { SetDmarcPolicyDto } from './dto/set-dmarc-policy.dto.js'
import { DomainsService, type TenantRef } from './domains.service.js'
function auditActor(
user: { _id: unknown; email: string },
req: Parameters<typeof clientIp>[0],
): AuditActor {
return { userId: String(user._id), email: user.email, ip: clientIp(req) }
}
// Customer-admin domain management, mounted under the tenant. Same membership
// gate as the other tenant-scoped portal resources (GET :slug/users etc.): any
// member of the tenant — or a platform admin — may manage its domains.
@Controller('tenants/:slug/domains')
@UseGuards(JwtAuthGuard)
export class DomainsController {
constructor(
private readonly domains: DomainsService,
private readonly tenants: TenantsService,
private readonly actor: ActorService,
) {}
// Resolve the tenant and assert the caller belongs to it (or is a platform
// admin). Returns the lightweight ref the service works with.
private async gate(slug: string, jwt: AuthentikJwtPayload): Promise<TenantRef> {
const actor = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
return { _id: tenant._id, slug: tenant.slug }
}
@Get()
async list(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
const tenant = await this.gate(slug, jwt)
return this.domains.list(tenant)
}
@Post()
async add(
@Param('slug') slug: string,
@Body() dto: AddDomainDto,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const tenant = await this.gate(slug, jwt)
const user = await this.actor.resolve(jwt)
return this.domains.add(tenant, dto.domain, auditActor(user, req))
}
@Get(':domain')
async getOne(
@Param('slug') slug: string,
@Param('domain') domain: string,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
const tenant = await this.gate(slug, jwt)
return this.domains.getOne(tenant, domain)
}
@Post(':domain/recheck')
async recheck(
@Param('slug') slug: string,
@Param('domain') domain: string,
@CurrentUser() jwt: AuthentikJwtPayload,
) {
const tenant = await this.gate(slug, jwt)
return this.domains.recheck(tenant, domain)
}
@Patch(':domain/dmarc')
async setDmarc(
@Param('slug') slug: string,
@Param('domain') domain: string,
@Body() dto: SetDmarcPolicyDto,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const tenant = await this.gate(slug, jwt)
const user = await this.actor.resolve(jwt)
return this.domains.setDmarcPolicy(tenant, domain, dto.dmarcPolicy, auditActor(user, req))
}
@Delete(':domain')
@HttpCode(204)
async remove(
@Param('slug') slug: string,
@Param('domain') domain: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const tenant = await this.gate(slug, jwt)
const user = await this.actor.resolve(jwt)
await this.domains.remove(tenant, domain, auditActor(user, req))
}
}
@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { AuditModule } from '../audit/audit.module.js'
import { AuthModule } from '../auth/auth.module.js'
import { IntegrationsModule } from '../integrations/integrations.module.js'
import { Domain, DomainSchema } from '../schemas/domain.schema.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js'
import { TenantsModule } from '../tenants/tenants.module.js'
import { DnsVerifierService } from './dns-verifier.service.js'
import { DomainsController } from './domains.controller.js'
import { DomainsService } from './domains.service.js'
@Module({
imports: [
MongooseModule.forFeature([
{ name: Domain.name, schema: DomainSchema },
{ name: Tenant.name, schema: TenantSchema },
{ name: User.name, schema: UserSchema },
]),
AuthModule,
AuditModule,
IntegrationsModule,
TenantsModule, // TenantsService — resolve tenant by slug for the membership gate
],
controllers: [DomainsController],
providers: [DomainsService, DnsVerifierService],
exports: [DomainsService],
})
export class DomainsModule {}
@@ -0,0 +1,463 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { randomBytes } from 'node:crypto'
import { Model, Types } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import {
DomainInUseError,
StalwartClient,
type StalwartLinkedObject,
type StalwartZoneRecord,
} from '../integrations/stalwart.client.js'
import {
Domain,
DomainDocument,
DomainRecord,
DmarcPolicy,
DomainStatus,
RecordKind,
RecordStatus,
} from '../schemas/domain.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { User, UserDocument } from '../schemas/user.schema.js'
import { DnsVerifierService } from './dns-verifier.service.js'
// The four status slots the customer-admin Domains page renders, plus ownership.
const CHECK_KINDS: RecordKind[] = ['ownership', 'mx', 'spf', 'dkim', 'dmarc']
// Minimal tenant identity the service needs — the controller resolves the full
// doc for its membership gate and hands us this.
export interface TenantRef {
_id: Types.ObjectId
slug: string
}
@Injectable()
export class DomainsService {
private readonly logger = new Logger(DomainsService.name)
constructor(
@InjectModel(Domain.name) private readonly domainModel: Model<DomainDocument>,
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
private readonly stalwart: StalwartClient,
private readonly dns: DnsVerifierService,
private readonly audit: AuditService,
) {}
async list(tenant: TenantRef): Promise<DomainView[]> {
const docs = await this.domainModel.find({ tenantId: tenant._id }).sort({ createdAt: 1 }).exec()
return Promise.all(docs.map((d) => this.toView(d, tenant)))
}
async getOne(tenant: TenantRef, domain: string): Promise<DomainView> {
const doc = await this.findOrThrow(tenant, domain)
return this.toView(doc, tenant)
}
// Add a domain: provision it in Stalwart (which auto-generates DKIM), seed the
// expected records from Stalwart's zone file + our ownership token, then run an
// immediate DNS check so the page shows live status right away.
async add(tenant: TenantRef, rawDomain: string, actor: AuditActor): Promise<DomainView> {
const domain = rawDomain.trim().toLowerCase()
const existing = await this.domainModel.findOne({ tenantId: tenant._id, domain }).exec()
if (existing) throw new ConflictException(`Domain "${domain}" is already added`)
const isPrimary = (await this.domainModel.countDocuments({ tenantId: tenant._id })) === 0
const verificationToken = `dezky-verify=${randomBytes(16).toString('hex')}`
let stalwartId: string | undefined
let provisioned = false
let records: DomainRecord[] = [ownershipRecord(domain, verificationToken)]
// Provision in Stalwart BEFORE persisting. A rejected domain (bad name,
// Stalwart outage) then fails the add cleanly with the real reason, rather
// than leaving an orphaned error-domain on the customer's list. On a partial
// failure (created but couldn't read the zone) we roll back the Stalwart side.
if (this.stalwart.configured) {
try {
const created = await this.stalwart.ensureDomain(domain, `Dezky tenant ${tenant.slug}`)
stalwartId = created.id
// Stalwart generates the DKIM keypair asynchronously after the domain is
// created, so the zone file omits the DKIM records for a moment. Poll
// briefly so the very first add response already carries them.
const zone = await this.zoneWithDkim(domain)
records = this.buildRecords(domain, zone, verificationToken, 'quarantine')
provisioned = true
} catch (err) {
const msg = (err as Error).message
this.logger.error(`Stalwart provisioning failed for "${domain}": ${msg}`)
await this.stalwart.deleteDomain(domain).catch(() => {})
throw new BadRequestException(
`Could not provision "${domain}" in the mail server: ${cleanStalwartError(msg)}`,
)
}
}
const doc = await this.domainModel.create({
tenantId: tenant._id,
domain,
isPrimary,
verificationToken,
dmarcPolicy: 'quarantine',
status: 'pending',
stalwartId,
stalwartProvisioned: provisioned,
records,
})
await this.tenantModel.updateOne({ _id: tenant._id }, { $addToSet: { domains: domain } }).exec()
await this.audit.record(
{
action: 'domain.added',
resourceType: 'domain',
resourceId: domain,
resourceName: domain,
tenantSlug: tenant.slug,
metadata: { stalwartProvisioned: doc.stalwartProvisioned, isPrimary },
},
actor,
)
// Immediate check so the customer sees real status without a manual refresh.
await this.runChecks(doc)
return this.toView(doc, tenant)
}
// Re-run the DNS checks for a domain on demand (the "Re-check" buttons).
async recheck(tenant: TenantRef, domain: string): Promise<DomainView> {
const doc = await this.findOrThrow(tenant, domain)
// (Re)seed expected records if they're incomplete — Stalwart was unreachable
// at add time, or the async DKIM keys hadn't landed yet (no dkim record).
const hasMail = doc.records.some((r) => r.kind === 'mx')
const hasDkim = doc.records.some((r) => r.kind === 'dkim')
if (this.stalwart.configured && (!hasMail || !hasDkim)) {
try {
const { id } = await this.stalwart.ensureDomain(domain, `Dezky tenant ${tenant.slug}`)
const zone = await this.zoneWithDkim(domain)
doc.stalwartId = id
doc.stalwartProvisioned = true
doc.stalwartError = undefined
doc.records = this.buildRecords(domain, zone, doc.verificationToken, doc.dmarcPolicy)
} catch (err) {
doc.stalwartError = (err as Error).message
}
}
await this.runChecks(doc)
return this.toView(doc, tenant)
}
// Read the domain's zone records, polling briefly until the asynchronously
// generated DKIM records have all appeared (Stalwart creates the keypair just
// after the domain — and the ed25519 + RSA selectors land a beat apart). We
// wait until the DKIM count is non-zero AND stable across two reads, so we
// don't capture just the first selector. Budget-capped (~5s) either way.
private async zoneWithDkim(domain: string): Promise<StalwartZoneRecord[]> {
const dkimCount = (z: StalwartZoneRecord[]) =>
z.filter((r) => r.fqdn.includes('._domainkey.')).length
let zone = await this.stalwart.getZoneRecords(domain)
let prev = -1
for (let i = 0; i < 8; i++) {
const count = dkimCount(zone)
if (count > 0 && count === prev) break // stable — all selectors present
prev = count
await new Promise((resolve) => setTimeout(resolve, 600))
zone = await this.stalwart.getZoneRecords(domain)
}
return zone
}
// Set the DMARC enforcement level (wizard step 5) and re-verify.
async setDmarcPolicy(
tenant: TenantRef,
domain: string,
policy: DmarcPolicy,
actor: AuditActor,
): Promise<DomainView> {
const doc = await this.findOrThrow(tenant, domain)
doc.dmarcPolicy = policy
const dmarc = doc.records.find((r) => r.kind === 'dmarc')
if (dmarc) dmarc.expected = dmarcExpected(domain, policy)
await this.audit.record(
{
action: 'domain.dmarc_policy_set',
resourceType: 'domain',
resourceId: domain,
resourceName: domain,
tenantSlug: tenant.slug,
metadata: { dmarcPolicy: policy },
},
actor,
)
await this.runChecks(doc)
return this.toView(doc, tenant)
}
// Remove a domain: delete it from Stalwart (best-effort) and drop our records.
// Guarded — a domain that still has mailboxes can't be removed, or those users
// would lose their email identity. This is enforced here (not just in the UI)
// so the rule holds even for direct API callers.
async remove(tenant: TenantRef, domain: string, actor: AuditActor): Promise<void> {
const doc = await this.findOrThrow(tenant, domain)
const mailboxes = await this.mailboxCount(tenant, doc.domain)
if (mailboxes > 0) {
throw new ConflictException(
`Cannot remove "${doc.domain}" — ${mailboxes} mailbox${mailboxes === 1 ? '' : 'es'} still use${
mailboxes === 1 ? 's' : ''
} it. Remove or reassign those users first.`,
)
}
if (this.stalwart.configured) {
try {
await this.stalwart.deleteDomain(doc.domain)
} catch (err) {
// Accounts / aliases / mailing lists still on the domain — block the
// removal with a clear, actionable message rather than orphaning them.
if (err instanceof DomainInUseError) {
throw new ConflictException(
`Cannot remove "${doc.domain}" — it still has ${summarizeLinks(err.linkedObjects)} in the mail server. Remove ${err.linkedObjects.length === 1 ? 'it' : 'them'} first.`,
)
}
// Any other Stalwart failure: don't drop our record, so the two sides
// stay consistent and the customer can retry.
this.logger.error(`Stalwart delete failed for "${doc.domain}": ${(err as Error).message}`)
throw err
}
}
await this.domainModel.deleteOne({ _id: doc._id }).exec()
await this.tenantModel.updateOne({ _id: tenant._id }, { $pull: { domains: domain } }).exec()
await this.audit.record(
{
action: 'domain.removed',
resourceType: 'domain',
resourceId: domain,
resourceName: domain,
tenantSlug: tenant.slug,
},
actor,
)
}
// ── internals ──────────────────────────────────────────────────────────────
private async findOrThrow(tenant: TenantRef, domain: string): Promise<DomainDocument> {
const doc = await this.domainModel
.findOne({ tenantId: tenant._id, domain: domain.trim().toLowerCase() })
.exec()
if (!doc) throw new NotFoundException(`Domain "${domain}" not found`)
return doc
}
// Check every record against DNS, write back observed+status, recompute the
// domain's overall status, and persist. Records out to public DNS in parallel.
private async runChecks(doc: DomainDocument): Promise<void> {
const wasVerified = doc.ownershipVerified
await Promise.all(
doc.records.map(async (rec) => {
const { observed, status } = await this.dns.check(rec, doc.domain)
rec.observed = observed
rec.status = status
rec.checkedAt = new Date()
}),
)
const tone = (kind: RecordKind): RecordStatus => aggregateTone(doc.records, kind)
doc.ownershipVerified = tone('ownership') === 'ok'
if (doc.ownershipVerified && !wasVerified) doc.verifiedAt = new Date()
if (doc.stalwartError) {
doc.status = 'error'
} else if (!doc.ownershipVerified) {
doc.status = 'pending'
} else {
const mail: RecordKind[] = ['mx', 'spf', 'dkim', 'dmarc']
doc.status = mail.every((k) => tone(k) === 'ok') ? 'active' : 'verifying'
}
doc.lastCheckedAt = new Date()
doc.markModified('records')
await doc.save()
}
// Map Stalwart's parsed zone records → our verifiable record set. Always
// includes the ownership TXT (which Stalwart doesn't know about). DMARC is
// overridden to reflect the customer's chosen policy.
private buildRecords(
domain: string,
zone: StalwartZoneRecord[],
token: string,
policy: DmarcPolicy,
): DomainRecord[] {
const records: DomainRecord[] = [ownershipRecord(domain, token)]
for (const z of zone) {
const kind = classify(z, domain)
if (!kind) continue
records.push({
kind,
type: z.type,
host: relativeHost(z.fqdn, domain),
fqdn: z.fqdn,
expected: kind === 'dmarc' ? dmarcExpected(domain, policy) : z.value,
priority: z.priority,
status: 'pending',
})
}
return records
}
// Count mailboxes on this domain — tenant users whose email is @domain.
private async mailboxCount(tenant: TenantRef, domain: string): Promise<number> {
return this.userModel
.countDocuments({
tenantIds: tenant._id,
email: { $regex: `@${escapeRegex(domain)}$`, $options: 'i' },
})
.exec()
}
private async toView(doc: DomainDocument, tenant: TenantRef): Promise<DomainView> {
const mailboxes = await this.mailboxCount(tenant, doc.domain)
return {
id: String(doc._id),
domain: doc.domain,
isPrimary: doc.isPrimary,
status: doc.status,
ownershipVerified: doc.ownershipVerified,
verificationToken: doc.verificationToken,
dmarcPolicy: doc.dmarcPolicy,
stalwartProvisioned: doc.stalwartProvisioned,
stalwartError: doc.stalwartError,
mailboxes,
checks: {
ownership: aggregateTone(doc.records, 'ownership'),
mx: aggregateTone(doc.records, 'mx'),
spf: aggregateTone(doc.records, 'spf'),
dkim: aggregateTone(doc.records, 'dkim'),
dmarc: aggregateTone(doc.records, 'dmarc'),
},
records: doc.records.map((r) => ({
kind: r.kind,
type: r.type,
host: r.host,
fqdn: r.fqdn,
expected: r.expected,
priority: r.priority,
observed: r.observed,
status: r.status,
})),
lastCheckedAt: doc.lastCheckedAt?.toISOString(),
}
}
}
// ── view types ─────────────────────────────────────────────────────────────
export interface DomainRecordView {
kind: RecordKind
type: string
host: string
fqdn: string
expected: string
priority?: number
observed?: string
status: RecordStatus
}
export interface DomainView {
id: string
domain: string
isPrimary: boolean
status: DomainStatus
ownershipVerified: boolean
verificationToken: string
dmarcPolicy: DmarcPolicy
stalwartProvisioned: boolean
stalwartError?: string
mailboxes: number
checks: Record<'ownership' | 'mx' | 'spf' | 'dkim' | 'dmarc', RecordStatus>
records: DomainRecordView[]
lastCheckedAt?: string
}
// ── pure helpers ─────────────────────────────────────────────────────────────
function ownershipRecord(domain: string, token: string): DomainRecord {
return {
kind: 'ownership',
type: 'TXT',
host: '_dezky-verify',
fqdn: `_dezky-verify.${domain}`,
expected: token,
status: 'pending',
}
}
// Classify a Stalwart zone record into one of our verifiable kinds, or null for
// the records we don't surface as status slots (SRV/MTA-STS/autoconfig/…).
function classify(z: StalwartZoneRecord, domain: string): RecordKind | null {
if (z.type === 'MX' && z.fqdn === domain) return 'mx'
if (z.type === 'TXT' && z.fqdn === domain && /^v=spf1\b/i.test(z.value)) return 'spf'
if (z.type === 'TXT' && z.fqdn.endsWith(`._domainkey.${domain}`)) return 'dkim'
if (z.type === 'TXT' && z.fqdn === `_dmarc.${domain}` && /^v=DMARC1\b/i.test(z.value)) return 'dmarc'
return null
}
// Relative host for display ('@' at apex, otherwise the label prefix).
function relativeHost(fqdn: string, domain: string): string {
if (fqdn === domain) return '@'
return fqdn.endsWith(`.${domain}`) ? fqdn.slice(0, -(domain.length + 1)) : fqdn
}
function dmarcExpected(domain: string, policy: DmarcPolicy): string {
return `v=DMARC1; p=${policy}; rua=mailto:postmaster@${domain}`
}
// Aggregate per-kind tone: a kind is only 'ok' if ALL its records are ok (DKIM
// has two — ed25519 + RSA — both must be published). Worst tone wins, with a
// kind that has no records yet reading as 'pending'.
function aggregateTone(records: DomainRecord[], kind: RecordKind): RecordStatus {
const mine = records.filter((r) => r.kind === kind)
if (!mine.length) return 'pending'
const order: RecordStatus[] = ['bad', 'warn', 'pending', 'ok']
return mine
.map((r) => r.status)
.sort((a, b) => order.indexOf(a) - order.indexOf(b))[0]
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
// Human summary of what still links to a domain, e.g. "2 mailboxes and 1
// mailing list". Groups Stalwart's linked-object types and pluralises.
function summarizeLinks(links: StalwartLinkedObject[]): string {
const label: Record<string, string> = {
MailingList: 'mailing list',
Account: 'mailbox',
Principal: 'mailbox',
Individual: 'mailbox',
Group: 'group',
List: 'list',
}
const counts = new Map<string, number>()
for (const l of links) {
const name = label[l.object] ?? l.object.toLowerCase()
counts.set(name, (counts.get(name) ?? 0) + 1)
}
return [...counts.entries()]
.map(([name, n]) => `${n} ${name}${n === 1 ? '' : 's'}`)
.join(' and ')
}
// Stalwart JMAP errors arrive as a JSON blob embedded in the message, e.g.
// `…: {"type":"invalidPatch","description":"Invalid domain name",…}`. Surface
// just the human description when present, otherwise the raw message.
function cleanStalwartError(msg: string): string {
return msg.match(/"description":"([^"]+)"/)?.[1] ?? msg
}
@@ -0,0 +1,13 @@
import { IsString, Matches, MaxLength } from 'class-validator'
// Add a custom email domain to a tenant. The domain is lowercased + trimmed by
// the schema; we validate it's a plausible hostname here (no protocol, no path,
// at least one dot). Punycode/IDN is accepted as already-encoded ascii.
export class AddDomainDto {
@IsString()
@MaxLength(253)
@Matches(/^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/i, {
message: 'domain must be a valid hostname like acme.dk',
})
domain!: string
}
@@ -0,0 +1,9 @@
import { IsIn } from 'class-validator'
import type { DmarcPolicy } from '../../schemas/domain.schema.js'
// Set the DMARC enforcement level chosen in the add-domain wizard (step 5).
// Drives the expected `_dmarc` TXT value we verify against.
export class SetDmarcPolicyDto {
@IsIn(['none', 'quarantine', 'reject'])
dmarcPolicy!: DmarcPolicy
}
@@ -70,6 +70,52 @@ export class AuthentikClient {
return created
}
// Fully delete a user from Authentik (used when a member is removed from their
// last tenant). 404 is tolerated so a re-run after a partial removal is safe.
async deleteUser(userPk: number): Promise<void> {
const res = await fetch(`${this.base}/core/users/${userPk}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${this.token}` },
})
if (!res.ok && res.status !== 404) {
const body = await res.text().catch(() => '')
throw new Error(`Authentik DELETE user ${userPk}${res.status}: ${body.slice(0, 200)}`)
}
this.logger.log(`Deleted Authentik user ${userPk}`)
}
// Enable / disable a user. is_active=false blocks all sign-in (portal, SSO,
// and OCIS-via-SSO) without deleting anything — the basis of suspend/resume.
async setUserActive(userPk: number, active: boolean): Promise<void> {
await this.request(`/core/users/${userPk}/`, {
method: 'PATCH',
body: JSON.stringify({ is_active: active }),
})
this.logger.log(`Set Authentik user ${userPk} is_active=${active}`)
}
// Force-logout: terminate the user's active sessions so they must sign in
// again. Returns how many were terminated. We pass the `?user=` filter AND
// re-filter client-side on the session's `user` pk — Authentik's endpoint
// silently ignores an unknown query filter, which would otherwise return (and
// delete) EVERY user's session. The client-side filter makes that impossible.
async terminateSessions(userPk: number): Promise<number> {
const res = await this.request<{ results: Array<{ uuid: string; user: number }> }>(
`/core/authenticated_sessions/?user=${userPk}`,
)
const sessions = (res.results ?? []).filter((s) => s.user === userPk)
await Promise.all(
sessions.map((s) =>
fetch(`${this.base}/core/authenticated_sessions/${s.uuid}/`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${this.token}` },
}).catch(() => {}),
),
)
this.logger.log(`Terminated ${sessions.length} Authentik session(s) for user ${userPk}`)
return sessions.length
}
async deleteGroup(groupId: string): Promise<void> {
const res = await fetch(`${this.base}/core/groups/${groupId}/`, {
method: 'DELETE',
@@ -165,6 +165,34 @@ export class OcisClient {
return (await res.json()) as T
}
// Write-capable variant of request() for POST/DELETE libregraph calls.
private async mutate<T>(
method: 'POST' | 'DELETE',
path: string,
body?: unknown,
): Promise<T | undefined> {
const token = await this.getToken()
const res = await fetch(`${this.base}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
if (res.status === 401) {
this.accessToken = undefined
this.accessExpiresAt = 0
}
const text = await res.text().catch(() => '')
throw new Error(`OCIS ${method} ${path}${res.status}: ${text.slice(0, 200)}`)
}
if (res.status === 204 || res.headers.get('content-length') === '0') return undefined
return (await res.json()) as T
}
// List all drives, optionally filtered with an OData $filter expression
// (e.g. `driveType eq 'personal'`). Requires the OCIS admin role. libregraph
// caps the page at 100 items; a tenant's personal drives stay well under that.
@@ -183,6 +211,43 @@ export class OcisClient {
return body.value ?? []
}
// Proactively create the OCIS account so the user shows up immediately. OCIS
// runs with PROXY_AUTOPROVISION_ACCOUNTS, so it ALSO creates the account (and
// personal drive) on the user's first SSO login — this just does it up front.
// `username` must match the OIDC claim OCIS keys on (preferred_username, which
// is the Authentik username = the user's email here). Returns the libregraph
// user id, or { deferred: true } if creation isn't available (external-IdP
// setups can reject graph user-create) so the caller falls back to autoprovision.
async ensureUser(input: {
username: string
displayName: string
mail: string
}): Promise<{ id?: string; deferred: boolean }> {
if (!this.configured) return { deferred: true }
try {
const user = await this.mutate<{ id?: string }>('POST', '/graph/v1.0/users', {
onPremisesSamAccountName: input.username,
displayName: input.displayName,
mail: input.mail,
accountEnabled: true,
})
this.logger.log(`Created OCIS user ${input.mail} (id=${user?.id})`)
return { id: user?.id, deferred: false }
} catch (err) {
this.logger.warn(
`OCIS user pre-create unavailable for ${input.mail} (${(err as Error).message.slice(0, 120)}) — will auto-provision on first sign-in`,
)
return { deferred: true }
}
}
async deleteUser(id: string): Promise<void> {
if (!this.configured) return
await this.mutate('DELETE', `/graph/v1.0/users/${id}`).catch((err) => {
this.logger.error(`OCIS user delete failed (id=${id}): ${(err as Error).message}`)
})
}
// ── Provisioning (stubbed) ────────────────────────────────────────────────
// Real implementation needs POST /graph/v1.0/drives { name, driveType:
// 'project' } to create a space and assign it to the tenant's group / users.
@@ -1,30 +1,351 @@
import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
// Stalwart v0.16 removed the REST management API — all admin operations now go
// through the JMAP /jmap endpoint with Principal/set, Domain/set, etc. method
// calls. Implementing a JMAP client is meaningful work and out of scope for
// Phase 4. Stubbed for now; the orchestration code records this as 'skipped'.
// Stalwart v0.16 removed the REST management API (`/api/principal`, `/api/dkim`,
// `/api/dns/records` all 404). All admin operations now go through the JMAP
// endpoint at `${base}/jmap` using the Stalwart-specific `urn:stalwart:jmap`
// capability. Domains are `x:Domain` objects (NOT JMAP principals — `type:
// "domain"` is rejected); DKIM signatures are `x:DkimSignature` objects.
//
// TODO (follow-up): Build a minimal JMAP client that wraps Principal/set + the
// DKIM key generation method. See https://stalw.art/docs/api/management/overview
// Auth is HTTP Basic with the fallback admin from config.toml
// (admin / STALWART_ADMIN_PASSWORD). Calls go to the internal docker hostname
// `http://stalwart:8080` — NOT the public `https://mail.dezky.local`, which is
// Traefik + a mkcert cert that Node's fetch rejects.
//
// Creating a domain auto-generates its DKIM keys (dkimManagement defaults to
// "Automatic") and leaves DNS as "Manual" (the customer publishes the records).
// The full set of records to publish comes back on the domain's server-set
// `dnsZoneFile` field as BIND zone text — we parse it rather than computing the
// records ourselves, so the DKIM public keys etc. are always authoritative.
const JMAP_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap']
// A single DNS record extracted from a domain's `dnsZoneFile`. `fqdn` carries
// the trailing-dot-stripped name (e.g. `_dmarc.acme.dk`); `value` is the
// unquoted record data (TXT strings joined). `priority` is set for MX only.
export interface StalwartZoneRecord {
fqdn: string
type: string // 'MX' | 'TXT' | 'CNAME' | 'SRV' | …
value: string
priority?: number
}
// What the WebAdmin's x:Domain/get returns (only the fields we read).
interface StalwartDomain {
id: string
name: string
dnsZoneFile?: string
}
type JmapMethodCall = [string, Record<string, unknown>, string]
type JmapMethodResponse = [string, Record<string, any>, string]
@Injectable()
export class StalwartClient {
private readonly logger = new Logger(StalwartClient.name)
private readonly base: string
private readonly authHeader: string
// Live provisioning is gated like billing's `stripeLive`: off by default so
// dev without a reachable Stalwart (or without the flag) records 'skipped'
// instead of erroring. Requires the flag AND an admin password.
readonly enabled: boolean
constructor(config: ConfigService) {
this.base = config.getOrThrow<string>('STALWART_API_URL')
}
async ensureDomain(domain: string, _description?: string): Promise<{ name: string }> {
const user = config.get<string>('STALWART_ADMIN_USER') || 'admin'
const password = config.get<string>('STALWART_ADMIN_PASSWORD') || ''
this.authHeader = `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`
this.enabled =
config.get<string>('STALWART_PROVISIONING_ENABLED') === 'true' && !!password
if (!this.enabled) {
this.logger.warn(
`Stalwart domain provisioning is stubbed — would create "${domain}" via JMAP at ${this.base}/jmap`,
'Stalwart provisioning disabled (STALWART_PROVISIONING_ENABLED != true or no admin password) — domain steps record as skipped.',
)
return { name: domain }
}
}
async deleteDomain(domain: string): Promise<void> {
this.logger.warn(`Stalwart domain delete is stubbed — would delete "${domain}"`)
// Static config present and provisioning turned on. DomainsService checks this
// to decide between a real call and the honest "skipped" state.
get configured(): boolean {
return this.enabled
}
// Run one or more JMAP method calls. Returns the methodResponses array. A
// request-level error (e.g. malformed envelope) comes back as a flat
// {type,status,detail} object with no methodResponses — surfaced as a throw.
private async jmap(methodCalls: JmapMethodCall[]): Promise<JmapMethodResponse[]> {
const res = await fetch(`${this.base}/jmap`, {
method: 'POST',
headers: {
Authorization: this.authHeader,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ using: JMAP_USING, methodCalls }),
})
const text = await res.text()
if (!res.ok) {
throw new Error(`Stalwart JMAP → ${res.status}: ${text.slice(0, 300)}`)
}
let json: { methodResponses?: JmapMethodResponse[] }
try {
json = JSON.parse(text)
} catch {
throw new Error(`Stalwart JMAP returned non-JSON: ${text.slice(0, 200)}`)
}
if (!json.methodResponses) {
throw new Error(`Stalwart JMAP error: ${text.slice(0, 300)}`)
}
return json.methodResponses
}
// The Stalwart domain id for a name, or undefined if it doesn't exist.
private async findDomainId(name: string): Promise<string | undefined> {
const resp = await this.jmap([['x:Domain/query', { filter: { name } }, '0']])
const ids = resp[0]?.[1]?.ids as string[] | undefined
return ids?.[0]
}
// Idempotent: returns the existing domain id if present, otherwise creates the
// domain (which auto-generates its DKIM keys) and returns the new id.
async ensureDomain(name: string, _description?: string): Promise<{ id: string }> {
const existing = await this.findDomainId(name)
if (existing) {
this.logger.log(`Stalwart domain "${name}" already exists (id=${existing})`)
return { id: existing }
}
const resp = await this.jmap([['x:Domain/set', { create: { d1: { name } } }, '0']])
const result = resp[0][1]
const created = result.created?.d1
if (!created?.id) {
const err = result.notCreated?.d1
throw new Error(`Stalwart domain create failed for "${name}": ${JSON.stringify(err)}`)
}
this.logger.log(`Created Stalwart domain "${name}" (id=${created.id})`)
return { id: created.id }
}
// Fetch the domain's authoritative DNS records (parsed from its dnsZoneFile).
// Returns [] if the domain isn't found. Used to populate the expected records
// the customer must publish, including the live DKIM public keys.
async getZoneRecords(name: string): Promise<StalwartZoneRecord[]> {
const resp = await this.jmap([
['x:Domain/query', { filter: { name } }, '0'],
[
'x:Domain/get',
{ '#ids': { resultOf: '0', name: 'x:Domain/query', path: '/ids' } },
'1',
],
])
const domain = (resp[1]?.[1]?.list as StalwartDomain[] | undefined)?.[0]
if (!domain?.dnsZoneFile) return []
return parseZoneFile(domain.dnsZoneFile)
}
// Delete a domain. Stalwart enforces referential integrity: a domain can't be
// destroyed while anything links to it, reported as notDestroyed/objectIsLinked
// with the linked object ids. The auto-generated DKIM signatures always link,
// so we remove those and retry — but any OTHER link (accounts, aliases, mailing
// lists) is real user data, so we refuse with a DomainInUseError and let the
// caller surface it. 404-equivalent (no such domain) is a silent no-op.
async deleteDomain(name: string): Promise<void> {
const id = await this.findDomainId(name)
if (!id) return
const resp = await this.jmap([['x:Domain/set', { destroy: [id] }, '0']])
const result = resp[0][1]
if ((result.destroyed as string[] | undefined)?.includes(id)) {
this.logger.log(`Deleted Stalwart domain "${name}" (id=${id})`)
return
}
const notDestroyed = result.notDestroyed?.[id]
if (notDestroyed?.type === 'objectIsLinked') {
const links: StalwartLinkedObject[] = notDestroyed.linkedObjects ?? []
const blockers = links.filter((o) => o.object !== 'DkimSignature')
if (blockers.length) {
// The (failed) destroy above mutated nothing, so the domain is untouched.
throw new DomainInUseError(name, blockers)
}
const dkimIds = links.filter((o) => o.object === 'DkimSignature').map((o) => o.id)
await this.jmap([
['x:DkimSignature/set', { destroy: dkimIds }, '0'],
['x:Domain/set', { destroy: [id] }, '1'],
])
this.logger.log(
`Deleted Stalwart domain "${name}" (id=${id}) after removing ${dkimIds.length} DKIM signature(s)`,
)
return
}
throw new Error(
`Stalwart domain delete failed for "${name}": ${JSON.stringify(notDestroyed)}`,
)
}
// Create a user mailbox on a domain. The account's address is name@domain
// (Stalwart forms it from the domain), and the password lets the user sign in
// to webmail / IMAP / SMTP. `credentials` is an index-keyed MAP (not an array)
// — a quirk of Stalwart's patch format. Returns the new account id.
async createMailbox(input: {
domainId: string
localPart: string
fullName: string
password: string
}): Promise<{ id: string }> {
const resp = await this.jmap([
[
'x:Account/set',
{
create: {
u1: {
'@type': 'User',
name: input.localPart,
domainId: input.domainId,
description: input.fullName,
credentials: { '0': { '@type': 'Password', secret: input.password } },
},
},
},
'0',
],
])
const result = resp[0][1]
const created = result.created?.u1
if (!created?.id) {
const err = result.notCreated?.u1
throw new Error(`Stalwart mailbox create failed for "${input.localPart}": ${JSON.stringify(err)}`)
}
this.logger.log(`Created Stalwart mailbox "${input.localPart}" (id=${created.id})`)
return { id: created.id }
}
// Freeze / unfreeze a mailbox. Suspending disables the authenticate / send /
// receive permissions (so they can't sign in, send, or receive), while keeping
// the account + password intact — resuming restores the inherited defaults, so
// the user's original credential works again.
async setMailboxSuspended(accountId: string, suspended: boolean): Promise<void> {
const permissions = suspended
? {
'@type': 'Merge',
disabledPermissions: { authenticate: true, emailReceive: true, emailSend: true },
}
: { '@type': 'Inherit' }
const resp = await this.jmap([
['x:Account/set', { update: { [accountId]: { permissions } } }, '0'],
])
const notUpdated = resp[0][1].notUpdated?.[accountId]
if (notUpdated) {
throw new Error(
`Stalwart mailbox ${suspended ? 'suspend' : 'resume'} failed (id=${accountId}): ${JSON.stringify(notUpdated)}`,
)
}
}
// Set a new mailbox password (replaces the primary credential).
async setMailboxPassword(accountId: string, password: string): Promise<void> {
const resp = await this.jmap([
[
'x:Account/set',
{ update: { [accountId]: { credentials: { '0': { '@type': 'Password', secret: password } } } } },
'0',
],
])
const notUpdated = resp[0][1].notUpdated?.[accountId]
if (notUpdated) {
throw new Error(`Stalwart mailbox password update failed (id=${accountId}): ${JSON.stringify(notUpdated)}`)
}
}
// Delete a mailbox by account id. Missing id is a silent no-op.
async deleteMailbox(accountId: string): Promise<void> {
const resp = await this.jmap([['x:Account/set', { destroy: [accountId] }, '0']])
const result = resp[0][1]
if ((result.destroyed as string[] | undefined)?.includes(accountId)) {
this.logger.log(`Deleted Stalwart mailbox (id=${accountId})`)
return
}
const notDestroyed = result.notDestroyed?.[accountId]
if (notDestroyed && notDestroyed.type !== 'notFound') {
throw new Error(`Stalwart mailbox delete failed (id=${accountId}): ${JSON.stringify(notDestroyed)}`)
}
}
}
export interface StalwartLinkedObject {
object: string // 'DkimSignature' | 'MailingList' | 'Account' | …
id: string
}
// Thrown when a domain still has accounts, aliases or mailing lists in Stalwart
// and therefore can't be removed. `linkedObjects` excludes the auto-generated
// DKIM signatures (which we remove automatically).
export class DomainInUseError extends Error {
constructor(
public readonly domain: string,
public readonly linkedObjects: StalwartLinkedObject[],
) {
super(`Domain "${domain}" is still in use by ${linkedObjects.length} mail object(s)`)
this.name = 'DomainInUseError'
}
}
// ── BIND zone-file parsing ───────────────────────────────────────────────────
// Stalwart's dnsZoneFile is BIND zone text. RSA DKIM records span multiple lines
// with parenthesised, concatenated quoted strings, e.g.:
// sel._domainkey.acme.dk. IN TXT (
// "v=DKIM1; k=rsa; ... "
// "...more base64..."
// )
// We first fold parenthesised groups onto one logical line, then tokenise each
// line as `<name> IN <type> <rest>`. Exported for unit testing.
export function parseZoneFile(zone: string): StalwartZoneRecord[] {
const records: StalwartZoneRecord[] = []
for (const line of foldZoneLines(zone)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith(';')) continue
// <name> IN <TYPE> <rest…>
const m = trimmed.match(/^(\S+)\s+IN\s+(\S+)\s+(.+)$/)
if (!m) continue
const [, rawName, type, rest] = m
const fqdn = rawName.replace(/\.$/, '')
if (type === 'TXT') {
records.push({ fqdn, type, value: unquoteTxt(rest) })
} else if (type === 'MX') {
const mx = rest.match(/^(\d+)\s+(\S+)$/)
if (mx) {
records.push({ fqdn, type, priority: Number(mx[1]), value: mx[2].replace(/\.$/, '') })
}
} else {
records.push({ fqdn, type, value: rest.trim().replace(/\.$/, '') })
}
}
return records
}
// Collapse parenthesised multi-line records into single logical lines.
function foldZoneLines(zone: string): string[] {
const out: string[] = []
let buffer = ''
let depth = 0
for (const raw of zone.split('\n')) {
const line = raw
for (const ch of line) {
if (ch === '(') depth++
else if (ch === ')') depth = Math.max(0, depth - 1)
}
buffer += (buffer ? ' ' : '') + line.replace(/[()]/g, ' ').trim()
if (depth === 0) {
out.push(buffer)
buffer = ''
}
}
if (buffer) out.push(buffer)
return out
}
// Join the quoted character-strings of a TXT record into its logical value.
// `"v=DKIM1; ..." "more"` → `v=DKIM1; ...more`.
function unquoteTxt(rest: string): string {
const parts = rest.match(/"((?:[^"\\]|\\.)*)"/g)
if (!parts) return rest.trim()
return parts.map((p) => p.slice(1, -1)).join('')
}
@@ -11,6 +11,7 @@ export type AuditResourceType =
| 'user'
| 'flag'
| 'subscription'
| 'domain'
| 'system'
export type AuditSource =
| 'platform-api'
@@ -60,7 +61,7 @@ export class AuditEvent {
@Prop({ enum: ['success', 'failure'], default: 'success', index: true })
outcome!: AuditOutcome
@Prop({ enum: ['tenant', 'partner', 'user', 'flag', 'subscription', 'system'] })
@Prop({ enum: ['tenant', 'partner', 'user', 'flag', 'subscription', 'domain', 'system'] })
resourceType?: AuditResourceType
// Free-form (slug, ObjectId-as-string, external id, etc.) since some
@@ -0,0 +1,114 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument, Types } from 'mongoose'
export type DomainDocument = HydratedDocument<Domain>
// Overall lifecycle of a customer email domain:
// pending — created in Stalwart, ownership TXT not yet seen
// verifying — ownership confirmed, mail records still propagating / failing
// active — every required record (MX/SPF/DKIM/DMARC) resolves correctly
// error — Stalwart provisioning failed (see stalwart.error)
export type DomainStatus = 'pending' | 'verifying' | 'active' | 'error'
// Per-record verification tone — mirrors the frontend DNS_FIX semantics exactly
// (ok / warn / bad), plus `pending` for "not checked yet".
export type RecordStatus = 'ok' | 'warn' | 'bad' | 'pending'
// Which DNS concern a record belongs to. `ownership` is the one-time TXT proving
// the customer controls the domain; the other four map to the UI's status slots.
export type RecordKind = 'ownership' | 'mx' | 'spf' | 'dkim' | 'dmarc'
export type DmarcPolicy = 'none' | 'quarantine' | 'reject'
// A single expected DNS record + the last observed result. `expected` is the
// authoritative value (from Stalwart's dnsZoneFile, except ownership which we
// mint). `host` is the relative name to paste at a DNS provider ('@' for apex);
// `fqdn` is the full name we actually query.
@Schema({ _id: false })
export class DomainRecord {
@Prop({ required: true, enum: ['ownership', 'mx', 'spf', 'dkim', 'dmarc'] })
kind!: RecordKind
@Prop({ required: true })
type!: string // DNS record type: 'TXT' | 'MX' | 'CNAME'
@Prop({ required: true })
host!: string // relative host, e.g. '@', '_dmarc', 'sel._domainkey'
@Prop({ required: true })
fqdn!: string // full name queried, e.g. '_dmarc.acme.dk'
@Prop({ required: true })
expected!: string
@Prop({ type: Number })
priority?: number // MX only
@Prop()
observed?: string
@Prop({ enum: ['ok', 'warn', 'bad', 'pending'], default: 'pending' })
status!: RecordStatus
@Prop()
checkedAt?: Date
}
export const DomainRecordSchema = SchemaFactory.createForClass(DomainRecord)
// A customer-owned email domain. Distinct from the loose `tenant.domains`
// string[] (kept as a denormalised primary-host convenience) — this collection
// holds the per-domain provisioning + DNS-verification state behind the
// customer-admin Domains page.
@Schema({ collection: 'domains', timestamps: true })
export class Domain {
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, index: true })
tenantId!: Types.ObjectId
@Prop({ required: true, lowercase: true, trim: true, index: true })
domain!: string
// First domain added for a tenant is primary. Display-only for now.
@Prop({ default: false })
isPrimary!: boolean
// Random token published as a `_dezky-verify.<domain>` TXT to prove ownership.
@Prop({ required: true })
verificationToken!: string
@Prop({ default: false })
ownershipVerified!: boolean
@Prop()
verifiedAt?: Date
// Customer's chosen DMARC enforcement level (wizard step 5). Drives the
// expected `_dmarc` value we verify against. Stalwart defaults to reject.
@Prop({ enum: ['none', 'quarantine', 'reject'], default: 'quarantine' })
dmarcPolicy!: DmarcPolicy
// Stalwart x:Domain handle + last provisioning error.
@Prop({ index: true, sparse: true })
stalwartId?: string
@Prop({ default: false })
stalwartProvisioned!: boolean
@Prop()
stalwartError?: string
// Snapshot of the last expected-vs-observed diff. Replaced wholesale on each
// recheck. Empty until the first check runs.
@Prop({ type: [DomainRecordSchema], default: [] })
records!: DomainRecord[]
@Prop({ enum: ['pending', 'verifying', 'active', 'error'], default: 'pending', index: true })
status!: DomainStatus
@Prop()
lastCheckedAt?: Date
}
export const DomainSchema = SchemaFactory.createForClass(Domain)
// A tenant can only register a given domain once.
DomainSchema.index({ tenantId: 1, domain: 1 }, { unique: true })
@@ -74,6 +74,29 @@ export class User {
// (MFA device list, group add/remove) work without an email lookup.
@Prop({ type: Number })
authentikUserPk?: number
// Mail + storage handles, filled by the tenant-member create flow. The mailbox
// address is the user's working email on the tenant's domain.
@Prop({ lowercase: true, trim: true })
mailboxAddress?: string
@Prop()
stalwartAccountId?: string
@Prop()
ocisUserId?: string
// Per-system provisioning outcome for this user. Authentik must succeed (the
// identity); stalwart/ocis are best-effort — 'skipped' means not attempted or
// deferred (e.g. OCIS auto-provisions on first sign-in).
@Prop({ type: Object, default: undefined })
provisioning?: {
authentik?: 'ok' | 'error'
stalwart?: 'ok' | 'error' | 'skipped'
ocis?: 'ok' | 'error' | 'skipped'
stalwartError?: string
ocisNote?: string
}
}
export const UserSchema = SchemaFactory.createForClass(User)
@@ -35,13 +35,15 @@ export class ProvisioningService {
tenant.authentikGroupId = String(group.pk)
})
// Stalwart + OCIS are stubbed — the upstream call no-ops and we record the
// honest 'skipped' state by returning it from the step.
// Stalwart provisioning is real when STALWART_PROVISIONING_ENABLED is on;
// otherwise we record the honest 'skipped' state. ensureDomain is idempotent
// and auto-generates the domain's DKIM keys.
await this.runStep(tenant, 'stalwart', async () => {
const domain = this.domainFor(tenant.slug)
if (!this.stalwart.configured) return 'skipped'
await this.stalwart.ensureDomain(domain, `Mail domain for tenant ${tenant.slug}`)
tenant.stalwartDomain = domain
return 'skipped'
// falls through to 'ok' — a real upstream call succeeded
})
await this.runStep(tenant, 'ocis', async () => {
@@ -0,0 +1,26 @@
import { IsIn, IsOptional, IsString, Matches, MaxLength, MinLength } from 'class-validator'
// Create a workspace member. The email is formed as `localPart@<domain>`, where
// domain defaults to the tenant's primary domain. One temp password provisions
// both their SSO login and their mailbox.
export class CreateTenantMemberDto {
@IsString()
@MinLength(1)
@MaxLength(120)
name!: string
@IsString()
@MaxLength(64)
@Matches(/^[a-zA-Z0-9._-]+$/, {
message: 'address prefix may only contain letters, numbers, dots, hyphens and underscores',
})
localPart!: string
@IsIn(['admin', 'member'])
role!: 'admin' | 'member'
// Optional explicit domain (must belong to the tenant); omitted = primary.
@IsOptional()
@IsString()
domain?: string
}
@@ -0,0 +1,166 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
HttpCode,
Param,
Post,
Req,
UseGuards,
} from '@nestjs/common'
import { ActorService } from '../auth/actor.service.js'
import { clientIp } from '../auth/client-ip.js'
import { CurrentUser } from '../auth/current-user.decorator.js'
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
import type { AuditActor } from '../audit/audit.service.js'
import { TenantsService } from '../tenants/tenants.service.js'
import { CreateTenantMemberDto } from './dto/create-tenant-member.dto.js'
import { UsersService } from './users.service.js'
function auditActor(
user: { _id: unknown; email: string },
req: Parameters<typeof clientIp>[0],
): AuditActor {
return { userId: String(user._id), email: user.email, ip: clientIp(req) }
}
// Create a workspace member (the Users & groups "Invite user" flow). Mounted
// under the tenant alongside GET /tenants/:slug/users (which lives in
// TenantsController); same membership gate as the other tenant-scoped resources.
// Lives in UsersModule because the cross-system provisioning is in UsersService,
// and TenantsModule can't import UsersModule (UsersModule already imports it).
@Controller('tenants/:slug/users')
@UseGuards(JwtAuthGuard)
export class TenantMembersController {
constructor(
private readonly users: UsersService,
private readonly tenants: TenantsService,
private readonly actor: ActorService,
) {}
@Post()
async create(
@Param('slug') slug: string,
@Body() dto: CreateTenantMemberDto,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const actor = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
return this.users.createTenantMember(
{ _id: tenant._id, slug: tenant.slug, authentikGroupId: tenant.authentikGroupId },
dto,
auditActor(actor, req),
)
}
// Remove a member and tear down their provisioned accounts. Self-removal is
// blocked so an admin can't lock themselves out of their own workspace.
@Delete(':userId')
@HttpCode(204)
async remove(
@Param('slug') slug: string,
@Param('userId') userId: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const actor = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
if (String(actor._id) === userId) {
throw new ForbiddenException('You cant remove your own account.')
}
await this.users.removeTenantMember(
{ _id: tenant._id, slug: tenant.slug, authentikGroupId: tenant.authentikGroupId },
userId,
auditActor(actor, req),
)
}
// Resolve the tenant + assert the caller belongs to it (or is a platform admin).
private async gate(slug: string, jwt: AuthentikJwtPayload) {
const actor = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
return { actor, tenant }
}
@Post(':userId/suspend')
@HttpCode(204)
async suspend(
@Param('slug') slug: string,
@Param('userId') userId: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const { actor, tenant } = await this.gate(slug, jwt)
if (String(actor._id) === userId) {
throw new ForbiddenException('You cant suspend your own account.')
}
await this.users.setMemberSuspended(
{ _id: tenant._id, slug: tenant.slug },
userId,
true,
auditActor(actor, req),
)
}
@Post(':userId/resume')
@HttpCode(204)
async resume(
@Param('slug') slug: string,
@Param('userId') userId: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const { actor, tenant } = await this.gate(slug, jwt)
await this.users.setMemberSuspended(
{ _id: tenant._id, slug: tenant.slug },
userId,
false,
auditActor(actor, req),
)
}
@Post(':userId/force-logout')
async forceLogout(
@Param('slug') slug: string,
@Param('userId') userId: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const { actor, tenant } = await this.gate(slug, jwt)
if (String(actor._id) === userId) {
throw new ForbiddenException('You cant force-logout your own session here.')
}
return this.users.forceLogoutMember(
{ _id: tenant._id, slug: tenant.slug },
userId,
auditActor(actor, req),
)
}
@Post(':userId/reset-password')
async resetPassword(
@Param('slug') slug: string,
@Param('userId') userId: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const { actor, tenant } = await this.gate(slug, jwt)
return this.users.resetMemberPassword(
{ _id: tenant._id, slug: tenant.slug },
userId,
auditActor(actor, req),
)
}
}
@@ -3,6 +3,7 @@ import { MongooseModule } from '@nestjs/mongoose'
import { AuditModule } from '../audit/audit.module.js'
import { AuthModule } from '../auth/auth.module.js'
import { IntegrationsModule } from '../integrations/integrations.module.js'
import { Domain, DomainSchema } from '../schemas/domain.schema.js'
import { Partner, PartnerSchema } from '../schemas/partner.schema.js'
import { Price, PriceSchema } from '../schemas/price.schema.js'
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
@@ -10,6 +11,7 @@ import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js'
import { TenantsModule } from '../tenants/tenants.module.js'
import { PlatformReportsController } from './platform-reports.controller.js'
import { TenantMembersController } from './tenant-members.controller.js'
import { UsersController } from './users.controller.js'
import { UsersService } from './users.service.js'
@@ -28,13 +30,16 @@ import { UsersService } from './users.service.js'
// easy to extend (prorating, multi-currency) later.
{ name: Subscription.name, schema: SubscriptionSchema },
{ name: Price.name, schema: PriceSchema },
// Domain — read by createTenantMember to resolve the tenant's primary
// mail domain + its Stalwart id for mailbox provisioning.
{ name: Domain.name, schema: DomainSchema },
]),
AuthModule,
AuditModule,
IntegrationsModule,
TenantsModule,
],
controllers: [UsersController, PlatformReportsController],
controllers: [UsersController, PlatformReportsController, TenantMembersController],
providers: [UsersService],
exports: [UsersService],
})
@@ -1,4 +1,5 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
@@ -11,6 +12,9 @@ import { Model, Types } from 'mongoose'
import type { AuditEventDocument } from '../schemas/audit-event.schema.js'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { AuthentikClient } from '../integrations/authentik.client.js'
import { OcisClient } from '../integrations/ocis.client.js'
import { StalwartClient } from '../integrations/stalwart.client.js'
import { Domain, DomainDocument } from '../schemas/domain.schema.js'
import { Partner, PartnerDocument } from '../schemas/partner.schema.js'
import { Price, PriceDocument } from '../schemas/price.schema.js'
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
@@ -46,8 +50,11 @@ export class UsersService {
@InjectModel(Partner.name) private readonly partnerModel: Model<PartnerDocument>,
@InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
@InjectModel(Price.name) private readonly priceModel: Model<PriceDocument>,
@InjectModel(Domain.name) private readonly domainModel: Model<DomainDocument>,
private readonly audit: AuditService,
private readonly authentik: AuthentikClient,
private readonly stalwart: StalwartClient,
private readonly ocis: OcisClient,
config: ConfigService,
) {
this.platformAdminGroup =
@@ -628,6 +635,345 @@ export class UsersService {
// swallowed — the wizard wants to show "admin invite failed: ..." in the
// done state so the operator can retry rather than silently shipping a
// tenant with no admin.
// Create a workspace member and provision them across systems: Authentik SSO
// (required), a Stalwart mailbox on the tenant's default domain (best-effort),
// and an OCIS account (best-effort; auto-provisions on first login otherwise).
// One temp password is set on BOTH Authentik and the mailbox, so the new user
// has a single credential — returned once for the admin to hand over.
async createTenantMember(
tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
dto: { name: string; localPart: string; role: 'admin' | 'member'; domain?: string },
actor?: AuditActor,
): Promise<{
email: string
tempPassword: string
provisioning: { authentik: 'ok'; stalwart: 'ok' | 'error' | 'skipped'; ocis: 'ok' | 'error' | 'skipped' }
stalwartError?: string
ocisNote?: string
}> {
if (!tenant.authentikGroupId) {
throw new BadRequestException(
`Workspace "${tenant.slug}" isn't fully provisioned (no identity group). Reconcile it first.`,
)
}
// Resolve the target domain — the named one, else the primary, else the oldest.
let domainDoc: DomainDocument | null
if (dto.domain) {
domainDoc = await this.domainModel
.findOne({ tenantId: tenant._id, domain: dto.domain.toLowerCase() })
.exec()
} else {
domainDoc = await this.domainModel.findOne({ tenantId: tenant._id, isPrimary: true }).exec()
if (!domainDoc) {
domainDoc = await this.domainModel
.findOne({ tenantId: tenant._id })
.sort({ createdAt: 1 })
.exec()
}
}
if (!domainDoc) {
throw new BadRequestException('Add a domain to this workspace before creating users.')
}
const localPart = dto.localPart.trim().toLowerCase()
if (!/^[a-z0-9._-]+$/.test(localPart)) {
throw new BadRequestException(
'The address prefix may only contain letters, numbers, dots, hyphens and underscores.',
)
}
const email = `${localPart}@${domainDoc.domain}`
const dupe = await this.userModel.findOne({ email, tenantIds: tenant._id }).exec()
if (dupe) throw new ConflictException(`${email} already exists in this workspace.`)
const role: 'admin' | 'member' = dto.role === 'admin' ? 'admin' : 'member'
const tempPassword = generateTempPassword()
// 1) Authentik SSO identity (required) — same temp password so one credential
// works for sign-in (and OCIS, which authenticates via Authentik).
const created = await this.authentik.createUser({
username: email,
email,
name: dto.name,
groupPks: [tenant.authentikGroupId],
attributes: {
tenantSlug: tenant.slug,
invitedBy: actor?.email,
invitedAt: new Date().toISOString(),
},
})
await this.authentik.setInitialPassword(created.pk, tempPassword)
const prov: { authentik: 'ok'; stalwart: 'ok' | 'error' | 'skipped'; ocis: 'ok' | 'error' | 'skipped' } = {
authentik: 'ok',
stalwart: 'skipped',
ocis: 'skipped',
}
// 2) Stalwart mailbox (best-effort) — the same temp password signs into webmail.
let stalwartAccountId: string | undefined
let stalwartError: string | undefined
if (this.stalwart.configured && domainDoc.stalwartId) {
try {
const mbx = await this.stalwart.createMailbox({
domainId: domainDoc.stalwartId,
localPart,
fullName: dto.name,
password: tempPassword,
})
stalwartAccountId = mbx.id
prov.stalwart = 'ok'
} catch (err) {
prov.stalwart = 'error'
stalwartError = (err as Error).message
this.logger.error(`Mailbox provisioning failed for ${email}: ${stalwartError}`)
}
}
// 3) OCIS account (best-effort; auto-provisions on first sign-in otherwise).
let ocisUserId: string | undefined
let ocisNote: string | undefined
const ocisRes = await this.ocis.ensureUser({ username: email, displayName: dto.name, mail: email })
if (ocisRes.deferred) {
prov.ocis = 'skipped'
ocisNote = 'auto-provisions on first sign-in'
} else {
prov.ocis = 'ok'
ocisUserId = ocisRes.id
}
// 4) Our User doc.
await this.userModel
.findOneAndUpdate(
{ authentikSubjectId: created.uid },
{
$set: {
email,
name: dto.name,
[`tenantRoles.${tenant._id}`]: role,
mailboxAddress: email,
stalwartAccountId,
ocisUserId,
authentikUserPk: created.pk,
provisioning: { ...prov, stalwartError, ocisNote },
},
$setOnInsert: { role, active: true, platformAdmin: false },
$addToSet: { tenantIds: tenant._id },
},
{ upsert: true, new: true, runValidators: true },
)
.exec()
void this.audit.record(
{
action: 'tenant.user_created',
resourceType: 'user',
resourceId: created.uid,
resourceName: email,
tenantSlug: tenant.slug,
metadata: { name: dto.name, role, mailbox: prov.stalwart, ocis: prov.ocis },
},
actor,
)
return { email, tempPassword, provisioning: prov, stalwartError, ocisNote }
}
// Remove a member from a workspace, tearing down their provisioned accounts.
// The mailbox lives on one of THIS tenant's domains, so it's deleted (only if
// it actually belongs here — a multi-tenant user's mailbox on another tenant is
// left alone). The SSO identity + OCIS account are global, so they're deleted
// only when the user belongs to no other tenant (and isn't partner staff / a
// platform admin); otherwise we just detach this tenant.
async removeTenantMember(
tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
userId: string,
actor?: AuditActor,
): Promise<void> {
let _id: Types.ObjectId
try {
_id = new Types.ObjectId(userId)
} catch {
throw new NotFoundException('User not found')
}
const user = await this.userModel.findById(_id).exec()
if (!user || !user.tenantIds.some((t) => t.equals(tenant._id))) {
throw new NotFoundException('User not found in this workspace')
}
// Is the user's mailbox on a domain owned by THIS tenant?
const mailboxDomain = user.mailboxAddress?.split('@')[1]?.toLowerCase()
const mailboxIsHere =
!!mailboxDomain &&
!!(await this.domainModel.exists({ tenantId: tenant._id, domain: mailboxDomain }))
// 1) Delete the mailbox (best-effort) when it belongs to this tenant.
if (this.stalwart.configured && user.stalwartAccountId && mailboxIsHere) {
await this.stalwart.deleteMailbox(user.stalwartAccountId).catch((err) => {
this.logger.error(`Mailbox delete failed for ${user.email}: ${(err as Error).message}`)
})
}
// 2) Remove from this tenant's Authentik group.
if (tenant.authentikGroupId && user.authentikUserPk) {
await this.authentik
.removeUserFromGroup(user.authentikUserPk, tenant.authentikGroupId)
.catch((err) => {
this.logger.error(`Authentik group remove failed for ${user.email}: ${(err as Error).message}`)
})
}
const remaining = user.tenantIds.filter((t) => !t.equals(tenant._id))
const fullyRemove = remaining.length === 0 && !user.partnerId && !user.platformAdmin
if (fullyRemove) {
if (user.ocisUserId) await this.ocis.deleteUser(user.ocisUserId)
if (user.authentikUserPk) {
await this.authentik.deleteUser(user.authentikUserPk).catch((err) => {
this.logger.error(`Authentik user delete failed for ${user.email}: ${(err as Error).message}`)
})
}
await this.userModel.deleteOne({ _id }).exec()
} else {
// Keep the global identity; detach this tenant. Drop the mailbox handle
// only if the mailbox we removed was actually this tenant's.
const unset: Record<string, ''> = { [`tenantRoles.${tenant._id}`]: '' }
if (mailboxIsHere) {
unset.mailboxAddress = ''
unset.stalwartAccountId = ''
}
await this.userModel
.updateOne({ _id }, { $pull: { tenantIds: tenant._id }, $unset: unset })
.exec()
}
void this.audit.record(
{
action: 'tenant.user_removed',
resourceType: 'user',
resourceId: user.authentikSubjectId,
resourceName: user.email,
tenantSlug: tenant.slug,
metadata: { fullyRemoved: fullyRemove },
},
actor,
)
}
// Shared lookup for the per-member lifecycle actions: the user doc (verified to
// belong to this tenant) + whether their mailbox is on one of this tenant's
// domains (so mailbox-side changes only touch mailboxes we own).
private async loadMember(
tenant: { _id: Types.ObjectId },
userId: string,
): Promise<{ user: UserDocument; mailboxIsHere: boolean }> {
let _id: Types.ObjectId
try {
_id = new Types.ObjectId(userId)
} catch {
throw new NotFoundException('User not found')
}
const user = await this.userModel.findById(_id).exec()
if (!user || !user.tenantIds.some((t) => t.equals(tenant._id))) {
throw new NotFoundException('User not found in this workspace')
}
const mailboxDomain = user.mailboxAddress?.split('@')[1]?.toLowerCase()
const mailboxIsHere =
!!mailboxDomain &&
!!(await this.domainModel.exists({ tenantId: tenant._id, domain: mailboxDomain }))
return { user, mailboxIsHere }
}
// Suspend or resume a member: toggles Authentik sign-in (is_active) and freezes
// / unfreezes the mailbox. Reversible — resume restores the original password.
async setMemberSuspended(
tenant: { _id: Types.ObjectId; slug: string },
userId: string,
suspended: boolean,
actor?: AuditActor,
): Promise<void> {
const { user, mailboxIsHere } = await this.loadMember(tenant, userId)
// Identity first — if this fails, abort before touching anything else.
if (user.authentikUserPk) {
await this.authentik.setUserActive(user.authentikUserPk, !suspended)
}
if (this.stalwart.configured && user.stalwartAccountId && mailboxIsHere) {
await this.stalwart.setMailboxSuspended(user.stalwartAccountId, suspended).catch((err) => {
this.logger.error(
`Mailbox ${suspended ? 'suspend' : 'resume'} failed for ${user.email}: ${(err as Error).message}`,
)
})
}
user.active = !suspended
await user.save()
void this.audit.record(
{
action: suspended ? 'tenant.user_suspended' : 'tenant.user_resumed',
resourceType: 'user',
resourceId: user.authentikSubjectId,
resourceName: user.email,
tenantSlug: tenant.slug,
},
actor,
)
}
// Force-logout: terminate the member's active SSO sessions.
async forceLogoutMember(
tenant: { _id: Types.ObjectId; slug: string },
userId: string,
actor?: AuditActor,
): Promise<{ sessions: number }> {
const { user } = await this.loadMember(tenant, userId)
let sessions = 0
if (user.authentikUserPk) {
sessions = await this.authentik.terminateSessions(user.authentikUserPk).catch(() => 0)
}
void this.audit.record(
{
action: 'tenant.user_logout_forced',
resourceType: 'user',
resourceId: user.authentikSubjectId,
resourceName: user.email,
tenantSlug: tenant.slug,
metadata: { sessions },
},
actor,
)
return { sessions }
}
// Reset a member's password: one fresh temp password set on both their SSO
// login and their mailbox, returned once for the admin to hand over.
async resetMemberPassword(
tenant: { _id: Types.ObjectId; slug: string },
userId: string,
actor?: AuditActor,
): Promise<{ email: string; tempPassword: string }> {
const { user, mailboxIsHere } = await this.loadMember(tenant, userId)
const tempPassword = generateTempPassword()
if (user.authentikUserPk) {
await this.authentik.setInitialPassword(user.authentikUserPk, tempPassword)
}
if (this.stalwart.configured && user.stalwartAccountId && mailboxIsHere) {
await this.stalwart.setMailboxPassword(user.stalwartAccountId, tempPassword).catch((err) => {
this.logger.error(`Mailbox password reset failed for ${user.email}: ${(err as Error).message}`)
})
}
void this.audit.record(
{
action: 'tenant.user_password_reset',
resourceType: 'user',
resourceId: user.authentikSubjectId,
resourceName: user.email,
tenantSlug: tenant.slug,
},
actor,
)
return { email: user.email, tempPassword }
}
async inviteTenantAdmin(
tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
dto: { name: string; email: string },