From 98e49bfe34a232eab61bfb63cfe655fea609ba42 Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Sun, 7 Jun 2026 10:34:53 +0200 Subject: [PATCH] feat(admin/users): editable member drawer + mailbox & ownership management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild the /admin/users detail drawer from a read-only profile into an editable, Office 365-style panel with four sections: - Username & mail: read-only primary for mailbox users; editable sign-in (Authentik-only) for mailbox-less identities; "Create mailbox" provisions a Stalwart inbox for an external-login admin - Aliases: list/add/remove mailbox aliases (Stalwart), domain-scoped - Role: member/admin toggle with a primary-account lock (owner, mailbox-less bootstrap admin, self) and a last-admin guard - Contact information: display name, first/last name, phone, alternative email — mirrored best-effort to Authentik attributes + mailbox name Ownership transfer: "Make owner" (row menu + drawer) plus an owner-side "Transfer ownership" picker, gated to tenant admins / platform admins so a departed owner can be replaced; promotes the target and demotes the prior owner to admin. Backend (platform-api): contact fields on User; AuthentikClient.updateUser; StalwartClient.setMailboxName; UsersService updateTenantMember, changeMemberPrimaryEmail, list/add/removeMemberAlias, createMailboxForMember, transferOwnership; new DTOs and tenant-member routes. All mutations audited. Portal: Nuxt proxies for the new endpoints + extended TenantUserDoc. --- apps/portal/pages/admin/users.vue | 605 +++++++++++++++++- .../tenants/[slug]/users/[userId].patch.ts | 18 + .../[slug]/users/[userId]/aliases.delete.ts | 18 + .../[slug]/users/[userId]/aliases.get.ts | 15 + .../[slug]/users/[userId]/aliases.post.ts | 18 + .../[slug]/users/[userId]/mailbox.post.ts | 18 + .../[slug]/users/[userId]/make-owner.post.ts | 16 + .../users/[userId]/primary-email.patch.ts | 18 + apps/portal/types/workspace.ts | 17 + .../src/integrations/authentik.client.ts | 24 + .../src/integrations/stalwart.client.ts | 13 + .../platform-api/src/schemas/user.schema.ts | 21 + .../src/users/dto/change-primary-email.dto.ts | 10 + .../src/users/dto/create-mailbox.dto.ts | 19 + .../src/users/dto/member-alias.dto.ts | 23 + .../src/users/dto/update-tenant-member.dto.ts | 43 ++ .../src/users/tenant-members.controller.ts | 146 +++++ .../platform-api/src/users/users.service.ts | 414 +++++++++++- 18 files changed, 1444 insertions(+), 12 deletions(-) create mode 100644 apps/portal/server/api/tenants/[slug]/users/[userId].patch.ts create mode 100644 apps/portal/server/api/tenants/[slug]/users/[userId]/aliases.delete.ts create mode 100644 apps/portal/server/api/tenants/[slug]/users/[userId]/aliases.get.ts create mode 100644 apps/portal/server/api/tenants/[slug]/users/[userId]/aliases.post.ts create mode 100644 apps/portal/server/api/tenants/[slug]/users/[userId]/mailbox.post.ts create mode 100644 apps/portal/server/api/tenants/[slug]/users/[userId]/make-owner.post.ts create mode 100644 apps/portal/server/api/tenants/[slug]/users/[userId]/primary-email.patch.ts create mode 100644 services/platform-api/src/users/dto/change-primary-email.dto.ts create mode 100644 services/platform-api/src/users/dto/create-mailbox.dto.ts create mode 100644 services/platform-api/src/users/dto/member-alias.dto.ts create mode 100644 services/platform-api/src/users/dto/update-tenant-member.dto.ts diff --git a/apps/portal/pages/admin/users.vue b/apps/portal/pages/admin/users.vue index f4fe7f0..fc18da0 100644 --- a/apps/portal/pages/admin/users.vue +++ b/apps/portal/pages/admin/users.vue @@ -9,11 +9,11 @@ // user-write endpoints are operator-only today, so a customer admin can't // commit them yet. The data shown is real; the writes are not wired. -import type { TenantUserDoc } from '~/types/workspace' +import type { MemberAliases, TenantUserDoc } from '~/types/workspace' const toast = useToast() -const { fetchMe } = useMe() +const { profile, fetchMe, isPlatformAdmin, isTenantAdminOf } = useMe() await fetchMe() const { tenant } = useTenant() const slug = computed(() => tenant.value?.slug ?? '') @@ -48,6 +48,9 @@ const inviteDomain = computed(() => inviteForm.domain || primaryDomain.value?.do const userStatus = (u: TenantUserDoc): 'active' | 'suspended' => (u.active === false ? 'suspended' : 'active') const roleLabel = (r: string) => r.charAt(0).toUpperCase() + r.slice(1) +// Role for THIS workspace — prefer the per-tenant role the API resolves, fall +// back to the legacy global role for any consumer that predates tenantRole. +const effectiveRole = (u: TenantUserDoc): 'owner' | 'admin' | 'member' => u.tenantRole ?? u.role const filteredUsers = computed(() => (users.value ?? []).filter((u) => { @@ -163,12 +166,14 @@ function rowAction(u: TenantUserDoc, id: string) { if (id === 'open') openUser.value = u else if (id === 'reset') resetTarget.value = u else if (id === 'force') forceLogoutUser(u) + else if (id === 'make-owner') makeOwnerTarget.value = u else if (id === 'suspend') suspendTarget.value = u else if (id === 'resume') resumeUser(u) else if (id === 'delete') removeTarget.value = u } -// Menu varies per user: a suspended user shows Resume instead of Suspend. +// Menu varies per user: a suspended user shows Resume instead of Suspend, and +// admins get "Make owner" on anyone who isn't already the owner. function rowItems(u: TenantUserDoc) { const suspended = u.active === false return [ @@ -176,6 +181,9 @@ function rowItems(u: TenantUserDoc) { { id: 'reset', label: 'Send password reset', icon: 'key' as const }, { id: 'force', label: 'Force logout', icon: 'logout' as const }, { id: 'sep1', separator: true }, + ...(canManageOwnership.value && effectiveRole(u) !== 'owner' + ? [{ id: 'make-owner', label: 'Make owner', icon: 'refresh' as const }] + : []), suspended ? { id: 'resume', label: 'Resume user', icon: 'check' as const } : { id: 'suspend', label: 'Suspend user', icon: 'shield' as const, danger: true }, @@ -270,6 +278,294 @@ async function confirmReset() { resetBusy.value = false } } + +// ── User detail panel (editable, O365-style) ──────────────────────────────── +// The drawer edits four sections — username & mail, aliases, role, contact info. +// State seeds from the open user; aliases load lazily from the mailbox. Every +// save echoes the fresh user doc back into openUser so the panel + row stay in +// sync without a full reload. +const detailForm = reactive({ + name: '', firstName: '', lastName: '', phone: '', alternativeEmail: '', + role: 'member' as 'admin' | 'member', +}) +const detailBusy = reactive({ contact: false, role: false, primary: false, alias: false }) +const primaryEdit = ref(false) +const primaryDraft = ref('') +const aliasData = ref({ hasMailbox: false, primary: '', aliases: [] }) +const aliasLoading = ref(false) +const aliasForm = reactive({ localPart: '', domain: '' }) + +// A member with a Stalwart mailbox: primary is their inbox (read-only here) and +// aliases are available. Without one (SSO-only / bootstrap admin) the primary +// is editable and there are no aliases. +const hasMailbox = computed(() => !!(openUser.value?.stalwartAccountId || openUser.value?.mailboxAddress)) +const isOwner = computed(() => effectiveRole(openUser.value ?? ({} as TenantUserDoc)) === 'owner') +// You can't change your own role (matches the self-guards on suspend/remove). +const isSelf = computed(() => !!openUser.value && openUser.value._id === profile.value?._id) +// The workspace's primary account is its owner and the mailbox-less bootstrap +// admin (invited on a private email to set the workspace up) — both have an +// immutable role. Contact info stays editable; only the role section locks. +const roleLocked = computed(() => isOwner.value || !hasMailbox.value || isSelf.value) +const roleLockReason = computed(() => + isOwner.value + ? 'The owner role can’t be changed here.' + : !hasMailbox.value + ? 'This is the workspace’s primary account — its role can’t be changed.' + : 'You can’t change your own role.', +) +const provisionedDomains = computed(() => (domains.value ?? []).filter((d) => d.stalwartProvisioned)) +const roleChanged = computed( + () => !!openUser.value && detailForm.role !== effectiveRole(openUser.value), +) + +watch(openUser, (u) => { + if (!u) return + detailForm.name = u.name ?? '' + detailForm.firstName = u.firstName ?? '' + detailForm.lastName = u.lastName ?? '' + detailForm.phone = u.phone ?? '' + detailForm.alternativeEmail = u.alternativeEmail ?? '' + const r = effectiveRole(u) + detailForm.role = r === 'admin' ? 'admin' : 'member' + primaryEdit.value = false + primaryDraft.value = u.email + aliasData.value = { hasMailbox: false, primary: u.email, aliases: [] } + aliasForm.localPart = '' + aliasForm.domain = '' + void loadAliases(u) +}) + +async function loadAliases(u: TenantUserDoc) { + aliasLoading.value = true + try { + aliasData.value = await request(`/api/tenants/${slug.value}/users/${u._id}/aliases`) + const firstDomain = provisionedDomains.value[0] + if (!aliasForm.domain && firstDomain) { + aliasForm.domain = firstDomain.domain + } + } catch { + // Non-fatal — the rest of the panel still renders from the user doc. + } finally { + aliasLoading.value = false + } +} + +async function saveContact() { + const u = openUser.value + if (!u) return + detailBusy.contact = true + try { + const updated = await request(`/api/tenants/${slug.value}/users/${u._id}`, { + method: 'PATCH', + body: { + name: detailForm.name.trim(), + firstName: detailForm.firstName.trim(), + lastName: detailForm.lastName.trim(), + phone: detailForm.phone.trim(), + alternativeEmail: detailForm.alternativeEmail.trim(), + }, + }) + openUser.value = updated + await refreshNuxtData('admin-users') + toast.ok('Profile updated', updated.email) + } catch (err) { + toastErr(err, 'Could not update profile') + } finally { + detailBusy.contact = false + } +} + +async function saveRole() { + const u = openUser.value + if (!u || !roleChanged.value) return + detailBusy.role = true + try { + const updated = await request(`/api/tenants/${slug.value}/users/${u._id}`, { + method: 'PATCH', + body: { role: detailForm.role }, + }) + openUser.value = updated + await refreshNuxtData('admin-users') + toast.ok('Role updated', `${updated.name} is now ${detailForm.role}`) + } catch (err) { + toastErr(err, 'Could not update role') + } finally { + detailBusy.role = false + } +} + +async function savePrimary() { + const u = openUser.value + if (!u) return + const email = primaryDraft.value.trim().toLowerCase() + if (!email || email === u.email.toLowerCase()) { + primaryEdit.value = false + return + } + detailBusy.primary = true + try { + const updated = await request( + `/api/tenants/${slug.value}/users/${u._id}/primary-email`, + { method: 'PATCH', body: { email } }, + ) + openUser.value = updated + primaryEdit.value = false + await refreshNuxtData('admin-users') + toast.ok('Primary email changed', updated.email) + } catch (err) { + toastErr(err, 'Could not change primary email') + } finally { + detailBusy.primary = false + } +} + +async function addAlias() { + const u = openUser.value + if (!u || !aliasForm.localPart.trim() || !aliasForm.domain) return + detailBusy.alias = true + try { + aliasData.value = await request(`/api/tenants/${slug.value}/users/${u._id}/aliases`, { + method: 'POST', + body: { localPart: aliasForm.localPart.trim(), domain: aliasForm.domain }, + }) + aliasForm.localPart = '' + toast.ok('Alias added') + } catch (err) { + toastErr(err, 'Could not add alias') + } finally { + detailBusy.alias = false + } +} + +async function removeAlias(address: string) { + const u = openUser.value + if (!u) return + try { + aliasData.value = await request(`/api/tenants/${slug.value}/users/${u._id}/aliases`, { + method: 'DELETE', + body: { address }, + }) + toast.ok('Alias removed', address) + } catch (err) { + toastErr(err, 'Could not remove alias') + } +} + +// Create a mailbox for a member who signs in with an external email and has no +// inbox yet — gives them a workspace mailbox (and unlocks aliases). Their +// sign-in address is unchanged; we hand back a one-time mailbox password. +const createMailboxOpen = ref(false) +const mailboxBusy = ref(false) +const mailboxForm = reactive({ localPart: '', domain: '' }) +const mailboxResult = ref<{ email: string; tempPassword: string } | null>(null) +const mailboxDomain = computed( + () => mailboxForm.domain || provisionedDomains.value[0]?.domain || '', +) + +function openCreateMailbox() { + const u = openUser.value + if (!u) return + // Seed the local-part from their sign-in address (e.g. ronni@gmail.com → ronni). + mailboxForm.localPart = (u.email.split('@')[0] ?? '').toLowerCase() + mailboxForm.domain = provisionedDomains.value[0]?.domain ?? '' + createMailboxOpen.value = true +} + +// ── Transfer ownership ────────────────────────────────────────────────────── +// Available to admins / platform admins (not only the owner) so a departed +// owner can be replaced. The new owner is promoted; the previous owner is +// demoted to admin. +const transferOpen = ref(false) +const transferTarget = ref('') +const transferBusy = ref(false) +// Only the active tenant's admins / platform admins can transfer ownership. +const canManageOwnership = computed( + () => isPlatformAdmin.value || isTenantAdminOf(tenant.value?._id ?? ''), +) +// Candidates: every member who isn't already the owner. +const ownerCandidates = computed(() => + (users.value ?? []).filter((u) => effectiveRole(u) !== 'owner'), +) + +function openTransfer() { + transferTarget.value = '' + transferOpen.value = true +} + +async function confirmTransfer() { + const u = openUser.value + if (!transferTarget.value) return + transferBusy.value = true + try { + const res = await request<{ newOwner: TenantUserDoc; previousOwners: TenantUserDoc[] }>( + `/api/tenants/${slug.value}/users/${transferTarget.value}/make-owner`, + { method: 'POST' }, + ) + transferOpen.value = false + await refreshNuxtData('admin-users') + // The open drawer was the previous owner — reflect their demotion to admin. + const demoted = u ? res.previousOwners.find((p) => p._id === u._id) : undefined + openUser.value = demoted ?? null + toast.ok('Ownership transferred', `${res.newOwner.name} is now the owner`) + } catch (err) { + toastErr(err, 'Could not transfer ownership') + } finally { + transferBusy.value = false + } +} + +// Promote a specific member to owner (from a row menu or their drawer) — the +// discoverable path that works whether or not an owner currently exists. Same +// endpoint as the picker; the previous owner (if any) is demoted to admin. +const makeOwnerTarget = ref(null) +const makeOwnerBusy = ref(false) +async function confirmMakeOwner() { + const target = makeOwnerTarget.value + if (!target) return + makeOwnerBusy.value = true + try { + const res = await request<{ newOwner: TenantUserDoc; previousOwners: TenantUserDoc[] }>( + `/api/tenants/${slug.value}/users/${target._id}/make-owner`, + { method: 'POST' }, + ) + makeOwnerTarget.value = null + await refreshNuxtData('admin-users') + // Keep an open drawer in sync: it's either the new owner or a demoted owner. + if (openUser.value) { + if (openUser.value._id === res.newOwner._id) openUser.value = res.newOwner + else { + const demoted = res.previousOwners.find((p) => p._id === openUser.value!._id) + if (demoted) openUser.value = demoted + } + } + toast.ok('Ownership transferred', `${res.newOwner.name} is now the owner`) + } catch (err) { + toastErr(err, 'Could not transfer ownership') + } finally { + makeOwnerBusy.value = false + } +} + +async function submitCreateMailbox() { + const u = openUser.value + if (!u || !mailboxForm.localPart.trim() || !mailboxDomain.value) return + mailboxBusy.value = true + try { + const res = await request<{ email: string; tempPassword: string; user: TenantUserDoc }>( + `/api/tenants/${slug.value}/users/${u._id}/mailbox`, + { method: 'POST', body: { localPart: mailboxForm.localPart.trim(), domain: mailboxForm.domain || undefined } }, + ) + openUser.value = res.user // flips hasMailbox + re-triggers the panel watcher (reloads aliases) + createMailboxOpen.value = false + mailboxResult.value = { email: res.email, tempPassword: res.tempPassword } + await refreshNuxtData('admin-users') + toast.ok('Mailbox created', res.email) + } catch (err) { + toastErr(err, 'Could not create mailbox') + } finally { + mailboxBusy.value = false + } +} + + +
+ +
+
No one to transfer to
+
Add another member to this workspace first, then you can hand them ownership.
+
+
+
+

+ The new owner gets full control of {{ tenant?.name }}, including billing and ownership. + +

+ +
+ +
+ + + + They get full control of this workspace — including billing and ownership. The current owner (if any) + becomes an admin. Nothing is deleted. + + + + +
+ +
+
No mail domain yet
+
A mailbox needs a verified, provisioned domain. Add one on the Domains page, then come back.
+
+ Go to Domains +
+
+ +
+ We’ll create a mailbox at {{ (mailboxForm.localPart || 'name') + '@' + mailboxDomain }} and show you a one-time password for webmail/IMAP. Their sign-in address ({{ openUser?.email }}) doesn’t change. +
+
+ +
+ + + +
+
+
{{ mailboxResult.email }} is ready
+

Share these securely. They sign in to webmail at mail.dezky.local with this password — their portal sign-in is unchanged.

+
+
+ Mailbox{{ mailboxResult.email }} + +
+
+ Password{{ mailboxResult.tempPassword }} + +
+
+
+ +
+ @@ -724,12 +1255,64 @@ Mikkel Sørensen,mikkel@baslund.dk,admin,Engineering,business .ud-meta { flex: 1; } .ud-name { font-size: 17px; font-weight: 600; font-family: var(--font-display); } .ud-badges { display: flex; gap: 6px; margin-top: 8px; } -.ud-body { padding: 24px; } +.ud-body { padding: 24px; display: flex; flex-direction: column; gap: 8px; } .def { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 12px; column-gap: 16px; } .def > div { display: contents; } .def dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); } .def dd { margin: 0; font-size: 13px; color: var(--text); } +/* User detail — editable sections */ +.ud-sec { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; } +.ud-sec-h { + display: flex; align-items: center; gap: 8px; + padding: 11px 14px; background: var(--surface); border-bottom: 1px solid var(--border); + font-size: 11px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-mute); +} +.ud-sec-body { padding: 16px 14px; display: flex; flex-direction: column; gap: 12px; } + +.kv { display: flex; flex-direction: column; gap: 6px; } +.kv-k { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); } +.kv-v { font-size: 13px; word-break: break-all; } +.muted-v { color: var(--text-mute); } +.kv-inline { display: flex; align-items: center; gap: 10px; } +.kv-edit { display: flex; align-items: center; gap: 8px; } +.kv-edit .input { flex: 1; } +.link-btn { + display: inline-flex; align-items: center; gap: 4px; + background: var(--surface); border: 1px solid var(--border); border-radius: 5px; + padding: 3px 9px; color: var(--text); font: inherit; font-size: 12px; font-weight: 500; + cursor: pointer; +} +.link-btn:hover { border-color: var(--text); background: var(--bg); } + +.ud-hint { font-size: 12px; color: var(--text-mute); line-height: 1.5; margin: 0; } + +.alias-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } +.alias-list li { + display: flex; align-items: center; justify-content: space-between; gap: 10px; + padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; font-size: 13px; +} +.icon-btn { background: none; border: none; padding: 4px; border-radius: 4px; color: var(--text-mute); cursor: pointer; } +.icon-btn:hover { background: var(--surface); color: var(--bad); } +.alias-add { display: flex; align-items: center; gap: 8px; } +.alias-add .input:first-child { flex: 1; } +.alias-add select.input { flex: 1; max-width: 180px; } + +.role-pick { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; padding: 2px; width: fit-content; } +.role-pick button { padding: 6px 16px; border: none; border-radius: 4px; background: transparent; color: var(--text); font-size: 12px; font-weight: 500; font-family: inherit; cursor: pointer; } +.role-pick button.active { background: var(--text); color: var(--bg); } + +.owner-row { + display: flex; align-items: center; justify-content: space-between; gap: 12px; + margin-top: 4px; padding-top: 12px; border-top: 1px dashed var(--border); +} +.owner-row .ud-hint { flex: 1; } + +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.grid2 .span2 { grid-column: 1 / -1; } +.ud-actions { display: flex; justify-content: flex-end; } +.ud-meta-def { padding: 4px 2px; } + /* Invite modal */ .form-stack { display: flex; flex-direction: column; gap: 14px; } .field { display: flex; flex-direction: column; gap: 6px; } diff --git a/apps/portal/server/api/tenants/[slug]/users/[userId].patch.ts b/apps/portal/server/api/tenants/[slug]/users/[userId].patch.ts new file mode 100644 index 0000000..159e10b --- /dev/null +++ b/apps/portal/server/api/tenants/[slug]/users/[userId].patch.ts @@ -0,0 +1,18 @@ +// Update a member's directory profile + in-tenant role. Proxies +// PATCH /tenants/:slug/users/:userId and returns the fresh user doc (with tenantRole). +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 body = await readBody(event) + const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001' + return $fetch(`${base}/tenants/${slug}/users/${userId}`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${accessToken}` }, + body, + }) +}) diff --git a/apps/portal/server/api/tenants/[slug]/users/[userId]/aliases.delete.ts b/apps/portal/server/api/tenants/[slug]/users/[userId]/aliases.delete.ts new file mode 100644 index 0000000..f16720f --- /dev/null +++ b/apps/portal/server/api/tenants/[slug]/users/[userId]/aliases.delete.ts @@ -0,0 +1,18 @@ +// Remove a mailbox alias. Proxies DELETE /tenants/:slug/users/:userId/aliases +// (address in body) and returns the refreshed alias view. +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 body = await readBody(event) + const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001' + return $fetch(`${base}/tenants/${slug}/users/${userId}/aliases`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${accessToken}` }, + body, + }) +}) diff --git a/apps/portal/server/api/tenants/[slug]/users/[userId]/aliases.get.ts b/apps/portal/server/api/tenants/[slug]/users/[userId]/aliases.get.ts new file mode 100644 index 0000000..71c7e8f --- /dev/null +++ b/apps/portal/server/api/tenants/[slug]/users/[userId]/aliases.get.ts @@ -0,0 +1,15 @@ +// List a member's mailbox aliases. Proxies GET /tenants/:slug/users/:userId/aliases +// → { hasMailbox, primary, aliases }. +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}/aliases`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) +}) diff --git a/apps/portal/server/api/tenants/[slug]/users/[userId]/aliases.post.ts b/apps/portal/server/api/tenants/[slug]/users/[userId]/aliases.post.ts new file mode 100644 index 0000000..d24e983 --- /dev/null +++ b/apps/portal/server/api/tenants/[slug]/users/[userId]/aliases.post.ts @@ -0,0 +1,18 @@ +// Add a mailbox alias. Proxies POST /tenants/:slug/users/:userId/aliases and +// returns the refreshed alias view. +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 body = await readBody(event) + const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001' + return $fetch(`${base}/tenants/${slug}/users/${userId}/aliases`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}` }, + body, + }) +}) diff --git a/apps/portal/server/api/tenants/[slug]/users/[userId]/mailbox.post.ts b/apps/portal/server/api/tenants/[slug]/users/[userId]/mailbox.post.ts new file mode 100644 index 0000000..1d30d05 --- /dev/null +++ b/apps/portal/server/api/tenants/[slug]/users/[userId]/mailbox.post.ts @@ -0,0 +1,18 @@ +// Provision a mailbox for a member who has none. Proxies +// POST /tenants/:slug/users/:userId/mailbox → { email, tempPassword, user }. +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 body = await readBody(event) + const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001' + return $fetch(`${base}/tenants/${slug}/users/${userId}/mailbox`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}` }, + body, + }) +}) diff --git a/apps/portal/server/api/tenants/[slug]/users/[userId]/make-owner.post.ts b/apps/portal/server/api/tenants/[slug]/users/[userId]/make-owner.post.ts new file mode 100644 index 0000000..c9d69c3 --- /dev/null +++ b/apps/portal/server/api/tenants/[slug]/users/[userId]/make-owner.post.ts @@ -0,0 +1,16 @@ +// Transfer workspace ownership to this member. Proxies +// POST /tenants/:slug/users/:userId/make-owner → { newOwner, previousOwners }. +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}/make-owner`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}` }, + }) +}) diff --git a/apps/portal/server/api/tenants/[slug]/users/[userId]/primary-email.patch.ts b/apps/portal/server/api/tenants/[slug]/users/[userId]/primary-email.patch.ts new file mode 100644 index 0000000..c9b6e24 --- /dev/null +++ b/apps/portal/server/api/tenants/[slug]/users/[userId]/primary-email.patch.ts @@ -0,0 +1,18 @@ +// Change a mailbox-less member's primary email. Proxies +// PATCH /tenants/:slug/users/:userId/primary-email and returns the fresh user doc. +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 body = await readBody(event) + const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001' + return $fetch(`${base}/tenants/${slug}/users/${userId}/primary-email`, { + method: 'PATCH', + headers: { Authorization: `Bearer ${accessToken}` }, + body, + }) +}) diff --git a/apps/portal/types/workspace.ts b/apps/portal/types/workspace.ts index 0ffb219..554fdea 100644 --- a/apps/portal/types/workspace.ts +++ b/apps/portal/types/workspace.ts @@ -96,13 +96,30 @@ export interface TenantUserDoc { _id: string email: string name: string + // Legacy global role. Prefer `tenantRole` (the role for THIS workspace) when + // present — GET /tenants/:slug/users and the member PATCH both return it. role: 'owner' | 'admin' | 'member' + tenantRole?: 'owner' | 'admin' | 'member' active: boolean lastLoginAt?: string createdAt?: string // Present when the user has a provisioned Stalwart mailbox (i.e. can receive // mail / be an alias destination). Absent for SSO-only users. mailboxAddress?: string + // Set alongside mailboxAddress when a Stalwart account backs the mailbox. + stalwartAccountId?: string + // Directory profile (O365-style "Kontaktoplysninger"), editable in the drawer. + firstName?: string + lastName?: string + phone?: string + alternativeEmail?: string +} + +// GET/POST/DELETE /tenants/:slug/users/:userId/aliases response. +export interface MemberAliases { + hasMailbox: boolean + primary: string + aliases: string[] } // The card on file, as returned by GET /tenants/:slug/payment-method (null when diff --git a/services/platform-api/src/integrations/authentik.client.ts b/services/platform-api/src/integrations/authentik.client.ts index f65b230..c6af6d0 100644 --- a/services/platform-api/src/integrations/authentik.client.ts +++ b/services/platform-api/src/integrations/authentik.client.ts @@ -94,6 +94,30 @@ export class AuthentikClient { this.logger.log(`Set Authentik user ${userPk} is_active=${active}`) } + // Patch a user's identity / profile fields. `username` + `email` are kept + // aligned by callers (our convention). `attributesMerge`, when given, is + // read-modify-written so we don't clobber unrelated attributes — Authentik + // PATCH replaces nested objects wholesale, so a naive `attributes: {...}` + // would wipe e.g. the passwordExpired flag. No-op if nothing to change. + async updateUser( + userPk: number, + patch: { username?: string; email?: string; name?: string; attributesMerge?: Record }, + ): Promise { + const body: Record = {} + if (patch.username !== undefined) body.username = patch.username + if (patch.email !== undefined) body.email = patch.email + if (patch.name !== undefined) body.name = patch.name + if (patch.attributesMerge && Object.keys(patch.attributesMerge).length > 0) { + const user = await this.request }>( + `/core/users/${userPk}/`, + ) + body.attributes = { ...(user.attributes ?? {}), ...patch.attributesMerge } + } + if (Object.keys(body).length === 0) return + await this.request(`/core/users/${userPk}/`, { method: 'PATCH', body: JSON.stringify(body) }) + this.logger.log(`Updated Authentik user ${userPk} (${Object.keys(body).join(', ')})`) + } + // 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 diff --git a/services/platform-api/src/integrations/stalwart.client.ts b/services/platform-api/src/integrations/stalwart.client.ts index 23278e3..c1cad9e 100644 --- a/services/platform-api/src/integrations/stalwart.client.ts +++ b/services/platform-api/src/integrations/stalwart.client.ts @@ -240,6 +240,19 @@ export class StalwartClient { } } + // Update a mailbox's display name (stored as the account `description`, which + // is what we set as `fullName` at create time). Best-effort cosmetic sync so + // the mailbox's display name tracks the directory profile. + async setMailboxName(accountId: string, fullName: string): Promise { + const resp = await this.jmap([ + ['x:Account/set', { update: { [accountId]: { description: fullName } } }, '0'], + ]) + const notUpdated = resp[0][1].notUpdated?.[accountId] + if (notUpdated) { + throw new Error(`Stalwart mailbox name update failed (id=${accountId}): ${JSON.stringify(notUpdated)}`) + } + } + // Set a new mailbox password (replaces the primary credential). async setMailboxPassword(accountId: string, password: string): Promise { const resp = await this.jmap([ diff --git a/services/platform-api/src/schemas/user.schema.ts b/services/platform-api/src/schemas/user.schema.ts index 1241b35..f8b4b6a 100644 --- a/services/platform-api/src/schemas/user.schema.ts +++ b/services/platform-api/src/schemas/user.schema.ts @@ -64,6 +64,27 @@ export class User { @Prop() lastLoginAt?: Date + // ── Contact information (directory profile) ────────────────────────────── + // Editable from the customer-admin user drawer. `name` above stays the + // display name (what shows in lists and as the mailbox description); these + // are the structured extras O365's "Kontaktoplysninger" surfaces. Mirrored + // to Authentik attributes (best-effort) so the IdP profile agrees, but the + // DB is the source of truth. + @Prop({ trim: true }) + firstName?: string + + @Prop({ trim: true }) + lastName?: string + + @Prop({ trim: true }) + phone?: string + + // A recovery/secondary address for reaching the user OUTSIDE their workspace + // mailbox (e.g. a private email). Not a mail alias — it routes nowhere in + // Stalwart; it's contact metadata only. + @Prop({ lowercase: true, trim: true }) + alternativeEmail?: string + // Partner-staff only: explicit subset of the partner's tenants this user may // access. Absent/empty = full portfolio ("all") — backward compatible with // existing staff. Lets a partner scope e.g. a sales rep to specific customers. diff --git a/services/platform-api/src/users/dto/change-primary-email.dto.ts b/services/platform-api/src/users/dto/change-primary-email.dto.ts new file mode 100644 index 0000000..23989db --- /dev/null +++ b/services/platform-api/src/users/dto/change-primary-email.dto.ts @@ -0,0 +1,10 @@ +import { IsEmail, MaxLength } from 'class-validator' + +// Change a mailbox-less member's primary email (their Authentik sign-in +// identity + our User.email). Refused server-side for members who have a +// Stalwart mailbox — there the primary address IS the inbox. +export class ChangePrimaryEmailDto { + @IsEmail() + @MaxLength(200) + email!: string +} diff --git a/services/platform-api/src/users/dto/create-mailbox.dto.ts b/services/platform-api/src/users/dto/create-mailbox.dto.ts new file mode 100644 index 0000000..80e666d --- /dev/null +++ b/services/platform-api/src/users/dto/create-mailbox.dto.ts @@ -0,0 +1,19 @@ +import { IsOptional, IsString, Matches, MaxLength } from 'class-validator' + +// Provision a Stalwart mailbox for an existing member who doesn't have one yet +// (e.g. the bootstrap admin who signs in with an external email). The mailbox +// is `localPart@domain` on one of the tenant's provisioned domains; the +// member's sign-in identity is left untouched. +export class CreateMailboxDto { + @IsString() + @MaxLength(64) + @Matches(/^[a-zA-Z0-9._-]+$/, { + message: 'address prefix may only contain letters, numbers, dots, hyphens and underscores', + }) + localPart!: string + + // Optional explicit domain (must belong to the tenant); omitted = primary. + @IsOptional() + @IsString() + domain?: string +} diff --git a/services/platform-api/src/users/dto/member-alias.dto.ts b/services/platform-api/src/users/dto/member-alias.dto.ts new file mode 100644 index 0000000..341cb96 --- /dev/null +++ b/services/platform-api/src/users/dto/member-alias.dto.ts @@ -0,0 +1,23 @@ +import { IsEmail, IsString, Matches, MaxLength } from 'class-validator' + +// Add an alias address (localPart@domain) that delivers to the member's +// mailbox. The domain must be one of this tenant's provisioned mail domains. +export class AddAliasDto { + @IsString() + @MaxLength(64) + @Matches(/^[a-zA-Z0-9._-]+$/, { + message: 'alias prefix may only contain letters, numbers, dots, hyphens and underscores', + }) + localPart!: string + + @IsString() + @MaxLength(255) + domain!: string +} + +// Remove an alias by its full address. +export class RemoveAliasDto { + @IsEmail() + @MaxLength(320) + address!: string +} diff --git a/services/platform-api/src/users/dto/update-tenant-member.dto.ts b/services/platform-api/src/users/dto/update-tenant-member.dto.ts new file mode 100644 index 0000000..801a7f2 --- /dev/null +++ b/services/platform-api/src/users/dto/update-tenant-member.dto.ts @@ -0,0 +1,43 @@ +import { IsEmail, IsIn, IsOptional, IsString, MaxLength, MinLength, ValidateIf } from 'class-validator' + +// Patch a workspace member's directory profile + in-tenant role. Every field is +// optional — the drawer saves contact info and role independently, so a request +// may carry just one section's worth. Empty strings are allowed (and clear the +// field) for the free-text fields; alternativeEmail must be a valid email when +// non-empty. +export class UpdateTenantMemberDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(120) + name?: string + + @IsOptional() + @IsString() + @MaxLength(80) + firstName?: string + + @IsOptional() + @IsString() + @MaxLength(80) + lastName?: string + + @IsOptional() + @IsString() + @MaxLength(40) + phone?: string + + // '' clears it; any other value must look like an email. + @IsOptional() + @ValidateIf((o) => o.alternativeEmail !== '') + @IsEmail() + @MaxLength(200) + alternativeEmail?: string + + // In-tenant role only. Owner is intentionally excluded — ownership transfer + // is a separate, guarded flow; this section never promotes to / demotes from + // owner. + @IsOptional() + @IsIn(['admin', 'member']) + role?: 'admin' | 'member' +} diff --git a/services/platform-api/src/users/tenant-members.controller.ts b/services/platform-api/src/users/tenant-members.controller.ts index abe9c02..f216ea8 100644 --- a/services/platform-api/src/users/tenant-members.controller.ts +++ b/services/platform-api/src/users/tenant-members.controller.ts @@ -3,8 +3,10 @@ import { Controller, Delete, ForbiddenException, + Get, HttpCode, Param, + Patch, Post, Req, UseGuards, @@ -15,8 +17,13 @@ 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 { roleForTenant } from '../schemas/user.schema.js' import { TenantsService } from '../tenants/tenants.service.js' +import { ChangePrimaryEmailDto } from './dto/change-primary-email.dto.js' +import { CreateMailboxDto } from './dto/create-mailbox.dto.js' import { CreateTenantMemberDto } from './dto/create-tenant-member.dto.js' +import { AddAliasDto, RemoveAliasDto } from './dto/member-alias.dto.js' +import { UpdateTenantMemberDto } from './dto/update-tenant-member.dto.js' import { UsersService } from './users.service.js' function auditActor( @@ -163,4 +170,143 @@ export class TenantMembersController { auditActor(actor, req), ) } + + // Update a member's directory profile (display name + contact info) and/or + // in-tenant role. Returns the fresh user doc with the tenant-scoped role, + // mirroring GET :slug/users so the UI can swap the row in place. + @Patch(':userId') + async update( + @Param('slug') slug: string, + @Param('userId') userId: string, + @Body() dto: UpdateTenantMemberDto, + @CurrentUser() jwt: AuthentikJwtPayload, + @Req() req: Parameters[0], + ) { + const { actor, tenant } = await this.gate(slug, jwt) + const updated = await this.users.updateTenantMember( + { _id: tenant._id, slug: tenant.slug }, + userId, + dto, + auditActor(actor, req), + ) + return { ...updated.toObject(), tenantRole: roleForTenant(updated, tenant._id) } + } + + // Change a mailbox-less member's primary email (Authentik identity only). + @Patch(':userId/primary-email') + async changePrimaryEmail( + @Param('slug') slug: string, + @Param('userId') userId: string, + @Body() dto: ChangePrimaryEmailDto, + @CurrentUser() jwt: AuthentikJwtPayload, + @Req() req: Parameters[0], + ) { + const { actor, tenant } = await this.gate(slug, jwt) + const updated = await this.users.changeMemberPrimaryEmail( + { _id: tenant._id, slug: tenant.slug }, + userId, + dto.email, + auditActor(actor, req), + ) + return { ...updated.toObject(), tenantRole: roleForTenant(updated, tenant._id) } + } + + // Transfer workspace ownership to this member. Gated to tenant admins / the + // current owner / platform admins — NOT only the owner, since the common case + // is the owner having left. Demotes the previous owner to admin. + @Post(':userId/make-owner') + async makeOwner( + @Param('slug') slug: string, + @Param('userId') userId: string, + @CurrentUser() jwt: AuthentikJwtPayload, + @Req() req: Parameters[0], + ) { + const { actor, tenant } = await this.gate(slug, jwt) + const actorRole = roleForTenant(actor, tenant._id) + if (!actor.platformAdmin && actorRole !== 'admin' && actorRole !== 'owner') { + throw new ForbiddenException('Only an admin or the owner can transfer ownership.') + } + const res = await this.users.transferOwnership( + { _id: tenant._id, slug: tenant.slug }, + userId, + auditActor(actor, req), + ) + return { + newOwner: { ...res.newOwner.toObject(), tenantRole: roleForTenant(res.newOwner, tenant._id) }, + previousOwners: res.previousOwners.map((u) => ({ + ...u.toObject(), + tenantRole: roleForTenant(u, tenant._id), + })), + } + } + + // Provision a mailbox for a member who has none (e.g. the bootstrap admin who + // signs in with an external email). Returns the new address + a one-time + // password, plus the refreshed user doc so the UI can flip the panel. + @Post(':userId/mailbox') + async createMailbox( + @Param('slug') slug: string, + @Param('userId') userId: string, + @Body() dto: CreateMailboxDto, + @CurrentUser() jwt: AuthentikJwtPayload, + @Req() req: Parameters[0], + ) { + const { actor, tenant } = await this.gate(slug, jwt) + const res = await this.users.createMailboxForMember( + { _id: tenant._id, slug: tenant.slug }, + userId, + dto, + auditActor(actor, req), + ) + return { + email: res.email, + tempPassword: res.tempPassword, + user: { ...res.user.toObject(), tenantRole: roleForTenant(res.user, tenant._id) }, + } + } + + // Mailbox aliases (extra addresses that deliver to the member's inbox). + @Get(':userId/aliases') + async listAliases( + @Param('slug') slug: string, + @Param('userId') userId: string, + @CurrentUser() jwt: AuthentikJwtPayload, + ) { + const { tenant } = await this.gate(slug, jwt) + return this.users.listMemberAliases({ _id: tenant._id, slug: tenant.slug }, userId) + } + + @Post(':userId/aliases') + async addAlias( + @Param('slug') slug: string, + @Param('userId') userId: string, + @Body() dto: AddAliasDto, + @CurrentUser() jwt: AuthentikJwtPayload, + @Req() req: Parameters[0], + ) { + const { actor, tenant } = await this.gate(slug, jwt) + return this.users.addMemberAlias( + { _id: tenant._id, slug: tenant.slug }, + userId, + dto, + auditActor(actor, req), + ) + } + + @Delete(':userId/aliases') + async removeAlias( + @Param('slug') slug: string, + @Param('userId') userId: string, + @Body() dto: RemoveAliasDto, + @CurrentUser() jwt: AuthentikJwtPayload, + @Req() req: Parameters[0], + ) { + const { actor, tenant } = await this.gate(slug, jwt) + return this.users.removeMemberAlias( + { _id: tenant._id, slug: tenant.slug }, + userId, + dto.address, + auditActor(actor, req), + ) + } } diff --git a/services/platform-api/src/users/users.service.ts b/services/platform-api/src/users/users.service.ts index f9fd585..6c482b7 100644 --- a/services/platform-api/src/users/users.service.ts +++ b/services/platform-api/src/users/users.service.ts @@ -19,7 +19,7 @@ import { Partner, PartnerDocument } from '../schemas/partner.schema.js' import { Price, PriceDocument } from '../schemas/price.schema.js' import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js' import { Tenant, TenantDocument } from '../schemas/tenant.schema.js' -import { User, UserDocument } from '../schemas/user.schema.js' +import { User, UserDocument, roleForTenant } from '../schemas/user.schema.js' import type { CreateUserDto } from './dto/create-user.dto.js' import type { InviteOperatorDto } from './dto/invite-operator.dto.js' import type { InvitePartnerUserDto } from './dto/invite-partner-user.dto.js' @@ -974,6 +974,418 @@ export class UsersService { return { email: user.email, tempPassword } } + // Update a member's directory profile (display name + contact info) and/or + // their in-tenant role. Contact info is mirrored to Authentik attributes and + // the mailbox display name on a best-effort basis — the DB is authoritative, + // so an unreachable Authentik/Stalwart never blocks the local save. Returns + // the updated doc so the caller can echo the fresh state back to the UI. + async updateTenantMember( + tenant: { _id: Types.ObjectId; slug: string }, + userId: string, + dto: { + name?: string + firstName?: string + lastName?: string + phone?: string + alternativeEmail?: string + role?: 'admin' | 'member' + }, + actor?: AuditActor, + ): Promise { + const { user } = await this.loadMember(tenant, userId) + + // Role change (in-tenant). The workspace's primary account — its owner and + // the mailbox-less bootstrap admin (invited on a private email to set the + // workspace up) — has an immutable role; so does the actor's own account + // (no self-demotion). Contact-info edits on these accounts are still + // allowed — only the role is frozen. We also never strip the last + // admin/owner so a workspace can't lock itself out. + if (dto.role !== undefined) { + const current = roleForTenant(user, tenant._id) + const isPrimary = current === 'owner' || (!user.stalwartAccountId && !user.mailboxAddress) + if (isPrimary) { + throw new ForbiddenException('The primary account’s role can’t be changed.') + } + if (actor?.userId && actor.userId === String(user._id)) { + throw new ForbiddenException('You can’t change your own role.') + } + if (dto.role === 'member' && current === 'admin') { + const tenantUsers = await this.userModel.find({ tenantIds: tenant._id }).exec() + const otherAdmins = tenantUsers.filter( + (u) => + !u._id.equals(user._id) && + ['admin', 'owner'].includes(roleForTenant(u, tenant._id)), + ) + if (otherAdmins.length === 0) { + throw new ConflictException('Can’t remove the last admin of this workspace.') + } + } + } + + const set: Record = {} + if (dto.name !== undefined) set.name = dto.name + if (dto.firstName !== undefined) set.firstName = dto.firstName + if (dto.lastName !== undefined) set.lastName = dto.lastName + if (dto.phone !== undefined) set.phone = dto.phone + if (dto.alternativeEmail !== undefined) set.alternativeEmail = dto.alternativeEmail + if (dto.role !== undefined) set[`tenantRoles.${tenant._id}`] = dto.role + + if (Object.keys(set).length === 0) return user + + const updated = await this.userModel + .findOneAndUpdate({ _id: user._id }, { $set: set }, { new: true, runValidators: true }) + .exec() + if (!updated) throw new NotFoundException('User not found') + + // Best-effort Authentik sync: display name + contact attributes. + const attrs: Record = {} + if (dto.firstName !== undefined) attrs.firstName = dto.firstName + if (dto.lastName !== undefined) attrs.lastName = dto.lastName + if (dto.phone !== undefined) attrs.phone = dto.phone + if (dto.alternativeEmail !== undefined) attrs.alternativeEmail = dto.alternativeEmail + if (user.authentikUserPk && (dto.name !== undefined || Object.keys(attrs).length > 0)) { + await this.authentik + .updateUser(user.authentikUserPk, { + name: dto.name, + attributesMerge: Object.keys(attrs).length > 0 ? attrs : undefined, + }) + .catch((err) => + this.logger.warn( + `Authentik profile sync failed for ${user.email}: ${(err as Error).message}`, + ), + ) + } + + // Best-effort mailbox display-name sync. + if (dto.name !== undefined && this.stalwart.configured && user.stalwartAccountId) { + await this.stalwart + .setMailboxName(user.stalwartAccountId, dto.name) + .catch((err) => + this.logger.warn( + `Mailbox name sync failed for ${user.email}: ${(err as Error).message}`, + ), + ) + } + + void this.audit.record( + { + action: 'tenant.user_updated', + resourceType: 'user', + resourceId: updated.authentikSubjectId, + resourceName: updated.email, + tenantSlug: tenant.slug, + metadata: { + fields: Object.keys(set), + ...(dto.role !== undefined ? { role: dto.role } : {}), + }, + }, + actor, + ) + + return updated + } + + // Change a mailbox-less member's primary email — their Authentik sign-in + // username + email, kept aligned, plus our User.email. This is ONLY for + // identities with no Stalwart mailbox (e.g. the bootstrap admin invited on a + // private email): for a real mailbox, the primary address IS the inbox, so + // moving it would split sign-in from delivery and we refuse. Authentik's + // stable `uid` (our authentikSubjectId) is unaffected by an email change, so + // the user's identity survives the rename. + async changeMemberPrimaryEmail( + tenant: { _id: Types.ObjectId; slug: string }, + userId: string, + newEmailRaw: string, + actor?: AuditActor, + ): Promise { + const { user } = await this.loadMember(tenant, userId) + + if (user.stalwartAccountId || user.mailboxAddress) { + throw new BadRequestException( + 'This member has a mailbox — their primary address is their inbox and can’t be changed here. Add an alias instead.', + ) + } + if (!user.authentikUserPk) { + throw new BadRequestException('This member isn’t linked to an identity yet.') + } + + const newEmail = newEmailRaw.trim().toLowerCase() + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) { + throw new BadRequestException('Enter a valid email address.') + } + if (newEmail === user.email.toLowerCase()) return user + + // Not already taken — in Authentik or our own collection. + const existingAk = await this.authentik.findUserByEmail(newEmail) + if (existingAk && existingAk.uid !== user.authentikSubjectId) { + throw new ConflictException(`${newEmail} is already in use.`) + } + const existingLocal = await this.userModel + .findOne({ email: newEmail, _id: { $ne: user._id } }) + .exec() + if (existingLocal) throw new ConflictException(`${newEmail} is already in use.`) + + // Identity source first — if Authentik rejects, abort before our record so + // the two never diverge. + await this.authentik.updateUser(user.authentikUserPk, { username: newEmail, email: newEmail }) + + const prevEmail = user.email + user.email = newEmail + await user.save() + + void this.audit.record( + { + action: 'tenant.user_primary_email_changed', + resourceType: 'user', + resourceId: user.authentikSubjectId, + resourceName: newEmail, + tenantSlug: tenant.slug, + metadata: { from: prevEmail, to: newEmail }, + }, + actor, + ) + + return user + } + + // List a member's mailbox aliases as full addresses. Aliases live in Stalwart + // as {name, domainId}; we resolve domainId → domain name via this tenant's + // domains so the UI gets `info@acme.dk`, not an opaque id. Members without a + // mailbox (SSO-only) come back with hasMailbox=false and no aliases. + async listMemberAliases( + tenant: { _id: Types.ObjectId; slug: string }, + userId: string, + ): Promise<{ hasMailbox: boolean; primary: string; aliases: string[] }> { + const { user } = await this.loadMember(tenant, userId) + const primary = user.mailboxAddress ?? user.email + if (!this.stalwart.configured || !user.stalwartAccountId) { + return { hasMailbox: false, primary, aliases: [] } + } + const [accounts, domains] = await Promise.all([ + this.stalwart.listAccountsWithAliases(), + this.domainModel.find({ tenantId: tenant._id }, { domain: 1, stalwartId: 1 }).exec(), + ]) + const acct = accounts.find((a) => a.id === user.stalwartAccountId) + if (!acct) return { hasMailbox: true, primary, aliases: [] } + const domainById = new Map( + domains.filter((d) => d.stalwartId).map((d) => [d.stalwartId as string, d.domain]), + ) + const aliases = acct.aliases + .map((a) => { + const dom = domainById.get(a.domainId) + return dom ? `${a.name}@${dom}` : null + }) + .filter((x): x is string => x !== null) + return { hasMailbox: true, primary: acct.emailAddress || primary, aliases } + } + + // Add an alias to a member's mailbox. The alias domain must be one of this + // tenant's provisioned mail domains, and the address must be free. Returns + // the refreshed alias view. + async addMemberAlias( + tenant: { _id: Types.ObjectId; slug: string }, + userId: string, + dto: { localPart: string; domain: string }, + actor?: AuditActor, + ): Promise<{ hasMailbox: boolean; primary: string; aliases: string[] }> { + const { user } = await this.loadMember(tenant, userId) + if (!this.stalwart.configured || !user.stalwartAccountId) { + throw new BadRequestException('This member has no mailbox to attach an alias to.') + } + const localPart = dto.localPart.trim().toLowerCase() + if (!/^[a-z0-9._-]+$/.test(localPart)) { + throw new BadRequestException( + 'The alias prefix may only contain letters, numbers, dots, hyphens and underscores.', + ) + } + const domain = dto.domain.trim().toLowerCase() + const domainDoc = await this.domainModel.findOne({ tenantId: tenant._id, domain }).exec() + if (!domainDoc?.stalwartId) { + throw new BadRequestException(`${domain} isn’t a provisioned mail domain for this workspace.`) + } + const address = `${localPart}@${domain}` + const taken = await this.stalwart.findAccountIdByEmail(address) + if (taken) throw new ConflictException(`${address} is already in use.`) + + await this.stalwart.addAlias(user.stalwartAccountId, localPart, domainDoc.stalwartId) + void this.audit.record( + { + action: 'tenant.user_alias_added', + resourceType: 'user', + resourceId: user.authentikSubjectId, + resourceName: user.email, + tenantSlug: tenant.slug, + metadata: { alias: address }, + }, + actor, + ) + return this.listMemberAliases(tenant, userId) + } + + // Remove an alias (by full address) from a member's mailbox. + async removeMemberAlias( + tenant: { _id: Types.ObjectId; slug: string }, + userId: string, + addressRaw: string, + actor?: AuditActor, + ): Promise<{ hasMailbox: boolean; primary: string; aliases: string[] }> { + const { user } = await this.loadMember(tenant, userId) + if (!this.stalwart.configured || !user.stalwartAccountId) { + throw new BadRequestException('This member has no mailbox.') + } + const [localPart, domain] = addressRaw.trim().toLowerCase().split('@') + if (!localPart || !domain) throw new BadRequestException('Invalid alias address.') + const domainDoc = await this.domainModel.findOne({ tenantId: tenant._id, domain }).exec() + if (!domainDoc?.stalwartId) { + throw new BadRequestException(`${domain} isn’t a mail domain for this workspace.`) + } + await this.stalwart.removeAlias(user.stalwartAccountId, localPart, domainDoc.stalwartId) + void this.audit.record( + { + action: 'tenant.user_alias_removed', + resourceType: 'user', + resourceId: user.authentikSubjectId, + resourceName: user.email, + tenantSlug: tenant.slug, + metadata: { alias: `${localPart}@${domain}` }, + }, + actor, + ) + return this.listMemberAliases(tenant, userId) + } + + // Provision a Stalwart mailbox for a member who doesn't have one yet — the + // bootstrap admin who signs in with an external email, typically. Their + // sign-in identity (Authentik username/email + User.email) is left untouched; + // we only attach a mailbox on a tenant domain and store its handles, so they + // can use webmail/IMAP and (the point of it) receive aliases. A fresh temp + // password is set on the mailbox and returned once for hand-off. + async createMailboxForMember( + tenant: { _id: Types.ObjectId; slug: string }, + userId: string, + dto: { localPart: string; domain?: string }, + actor?: AuditActor, + ): Promise<{ email: string; tempPassword: string; user: UserDocument }> { + const { user } = await this.loadMember(tenant, userId) + if (user.stalwartAccountId || user.mailboxAddress) { + throw new ConflictException('This member already has a mailbox.') + } + if (!this.stalwart.configured) { + throw new BadRequestException('Mail provisioning is disabled — can’t create a mailbox right now.') + } + + // Resolve the target domain — named, else primary, else oldest (mirrors + // createTenantMember). + 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 a mailbox.') + } + if (!domainDoc.stalwartId) { + throw new BadRequestException(`${domainDoc.domain} isn’t a provisioned mail domain for this workspace.`) + } + + 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 taken = await this.stalwart.findAccountIdByEmail(email) + if (taken) throw new ConflictException(`${email} is already in use.`) + + const tempPassword = generateTempPassword() + const mbx = await this.stalwart.createMailbox({ + domainId: domainDoc.stalwartId, + localPart, + fullName: user.name, + password: tempPassword, + }) + + user.mailboxAddress = email + user.stalwartAccountId = mbx.id + user.provisioning = { ...(user.provisioning ?? {}), stalwart: 'ok' } + await user.save() + + void this.audit.record( + { + action: 'tenant.user_mailbox_created', + resourceType: 'user', + resourceId: user.authentikSubjectId, + resourceName: email, + tenantSlug: tenant.slug, + metadata: { mailbox: email, signIn: user.email }, + }, + actor, + ) + + return { email, tempPassword, user } + } + + // Transfer workspace ownership to another member. The named member becomes + // 'owner' for this tenant; any existing owner is demoted to 'admin' (never + // removed — they keep access until an admin decides otherwise). Built for the + // "owner left the company" case, so the actor doesn't have to BE the owner — + // the controller gates it to tenant admins / platform admins. Only the + // per-tenant role moves; the legacy global `role` and every other tenant's + // role are untouched. + async transferOwnership( + tenant: { _id: Types.ObjectId; slug: string }, + newOwnerUserId: string, + actor?: AuditActor, + ): Promise<{ newOwner: UserDocument; previousOwners: UserDocument[] }> { + const { user: newOwner } = await this.loadMember(tenant, newOwnerUserId) + if (roleForTenant(newOwner, tenant._id) === 'owner') { + throw new ConflictException('This member is already the owner.') + } + + const tenantUsers = await this.userModel.find({ tenantIds: tenant._id }).exec() + const previousOwners = tenantUsers.filter( + (u) => roleForTenant(u, tenant._id) === 'owner' && !u._id.equals(newOwner._id), + ) + + await this.userModel + .updateOne({ _id: newOwner._id }, { $set: { [`tenantRoles.${tenant._id}`]: 'owner' } }) + .exec() + for (const prev of previousOwners) { + await this.userModel + .updateOne({ _id: prev._id }, { $set: { [`tenantRoles.${tenant._id}`]: 'admin' } }) + .exec() + } + + void this.audit.record( + { + action: 'tenant.ownership_transferred', + resourceType: 'user', + resourceId: newOwner.authentikSubjectId, + resourceName: newOwner.email, + tenantSlug: tenant.slug, + metadata: { to: newOwner.email, from: previousOwners.map((p) => p.email) }, + }, + actor, + ) + + // Return fresh docs so the caller's roleForTenant() reflects the new state. + const freshNewOwner = await this.userModel.findById(newOwner._id).exec() + const freshPrev = previousOwners.length + ? await this.userModel.find({ _id: { $in: previousOwners.map((p) => p._id) } }).exec() + : [] + return { newOwner: freshNewOwner ?? newOwner, previousOwners: freshPrev } + } + async inviteTenantAdmin( tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string }, dto: { name: string; email: string },