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