From 47eb9502f8020e6b882acc6c7bf7614d64bf7083 Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Mon, 1 Jun 2026 21:19:42 +0200 Subject: [PATCH] feat(platform): real email domains, mailboxes & member lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 9 + apps/portal/components/PortalSidebar.vue | 28 +- apps/portal/components/RecordRow.vue | 65 +++ apps/portal/composables/useDomains.ts | 68 +++ apps/portal/data/workspace.ts | 47 -- apps/portal/pages/admin/domains.vue | 319 ------------ apps/portal/pages/admin/domains/add.vue | 259 ++++++---- apps/portal/pages/admin/domains/index.vue | 436 +++++++++++++++++ apps/portal/pages/admin/users.vue | 410 +++++++++++++--- .../[slug]/domains/[domain]/dmarc.patch.ts | 22 + .../[slug]/domains/[domain]/index.delete.ts | 20 + .../[slug]/domains/[domain]/index.get.ts | 18 + .../[slug]/domains/[domain]/recheck.post.ts | 20 + .../api/tenants/[slug]/domains/index.get.ts | 18 + .../api/tenants/[slug]/domains/index.post.ts | 21 + .../server/api/tenants/[slug]/users.post.ts | 21 + .../tenants/[slug]/users/[userId].delete.ts | 21 + .../users/[userId]/force-logout.post.ts | 16 + .../users/[userId]/reset-password.post.ts | 16 + .../[slug]/users/[userId]/resume.post.ts | 16 + .../[slug]/users/[userId]/suspend.post.ts | 17 + .../docker-compose/docker-compose.yml | 7 +- services/platform-api/src/app.module.ts | 2 + .../src/domains/dns-verifier.service.ts | 131 +++++ .../src/domains/domains.controller.ts | 118 +++++ .../src/domains/domains.module.ts | 30 ++ .../src/domains/domains.service.ts | 463 ++++++++++++++++++ .../src/domains/dto/add-domain.dto.ts | 13 + .../src/domains/dto/set-dmarc-policy.dto.ts | 9 + .../src/integrations/authentik.client.ts | 46 ++ .../src/integrations/ocis.client.ts | 65 +++ .../src/integrations/stalwart.client.ts | 345 ++++++++++++- .../src/schemas/audit-event.schema.ts | 3 +- .../platform-api/src/schemas/domain.schema.ts | 114 +++++ .../platform-api/src/schemas/user.schema.ts | 23 + .../src/tenants/provisioning.service.ts | 8 +- .../src/users/dto/create-tenant-member.dto.ts | 26 + .../src/users/tenant-members.controller.ts | 166 +++++++ .../platform-api/src/users/users.module.ts | 7 +- .../platform-api/src/users/users.service.ts | 346 +++++++++++++ 40 files changed, 3235 insertions(+), 554 deletions(-) create mode 100644 apps/portal/components/RecordRow.vue create mode 100644 apps/portal/composables/useDomains.ts delete mode 100644 apps/portal/pages/admin/domains.vue create mode 100644 apps/portal/pages/admin/domains/index.vue create mode 100644 apps/portal/server/api/tenants/[slug]/domains/[domain]/dmarc.patch.ts create mode 100644 apps/portal/server/api/tenants/[slug]/domains/[domain]/index.delete.ts create mode 100644 apps/portal/server/api/tenants/[slug]/domains/[domain]/index.get.ts create mode 100644 apps/portal/server/api/tenants/[slug]/domains/[domain]/recheck.post.ts create mode 100644 apps/portal/server/api/tenants/[slug]/domains/index.get.ts create mode 100644 apps/portal/server/api/tenants/[slug]/domains/index.post.ts create mode 100644 apps/portal/server/api/tenants/[slug]/users.post.ts create mode 100644 apps/portal/server/api/tenants/[slug]/users/[userId].delete.ts create mode 100644 apps/portal/server/api/tenants/[slug]/users/[userId]/force-logout.post.ts create mode 100644 apps/portal/server/api/tenants/[slug]/users/[userId]/reset-password.post.ts create mode 100644 apps/portal/server/api/tenants/[slug]/users/[userId]/resume.post.ts create mode 100644 apps/portal/server/api/tenants/[slug]/users/[userId]/suspend.post.ts create mode 100644 services/platform-api/src/domains/dns-verifier.service.ts create mode 100644 services/platform-api/src/domains/domains.controller.ts create mode 100644 services/platform-api/src/domains/domains.module.ts create mode 100644 services/platform-api/src/domains/domains.service.ts create mode 100644 services/platform-api/src/domains/dto/add-domain.dto.ts create mode 100644 services/platform-api/src/domains/dto/set-dmarc-policy.dto.ts create mode 100644 services/platform-api/src/schemas/domain.schema.ts create mode 100644 services/platform-api/src/users/dto/create-tenant-member.dto.ts create mode 100644 services/platform-api/src/users/tenant-members.controller.ts diff --git a/.env.example b/.env.example index f57a036..86cb9e8 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/apps/portal/components/PortalSidebar.vue b/apps/portal/components/PortalSidebar.vue index 3bc3557..f3aa377 100644 --- a/apps/portal/components/PortalSidebar.vue +++ b/apps/portal/components/PortalSidebar.vue @@ -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(() => { : 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( ) 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. diff --git a/apps/portal/components/RecordRow.vue b/apps/portal/components/RecordRow.vue new file mode 100644 index 0000000..736b109 --- /dev/null +++ b/apps/portal/components/RecordRow.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/apps/portal/composables/useDomains.ts b/apps/portal/composables/useDomains.ts new file mode 100644 index 0000000..afa989f --- /dev/null +++ b/apps/portal/composables/useDomains.ts @@ -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(base, { + key: 'admin-domains', + default: () => [], + immediate: !!slug.value, + watch: [slug], + }) + + const add = (domain: string) => + request(base(), { method: 'POST', body: { domain } }) + + const getOne = (domain: string) => request(one(domain)) + + const recheck = (domain: string) => + request(`${one(domain)}/recheck`, { method: 'POST' }) + + const setDmarcPolicy = (domain: string, dmarcPolicy: DmarcPolicy) => + request(`${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 } +} diff --git a/apps/portal/data/workspace.ts b/apps/portal/data/workspace.ts index 3679ed8..a917d67 100644 --- a/apps/portal/data/workspace.ts +++ b/apps/portal/data/workspace.ts @@ -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 }, diff --git a/apps/portal/pages/admin/domains.vue b/apps/portal/pages/admin/domains.vue deleted file mode 100644 index 0b09409..0000000 --- a/apps/portal/pages/admin/domains.vue +++ /dev/null @@ -1,319 +0,0 @@ - - - - - diff --git a/apps/portal/pages/admin/domains/add.vue b/apps/portal/pages/admin/domains/add.vue index 39e2282..9f5f4eb 100644 --- a/apps/portal/pages/admin/domains/add.vue +++ b/apps/portal/pages/admin/domains/add.vue @@ -1,18 +1,38 @@