feat(admin/users): editable member drawer + mailbox & ownership management
ci / typecheck (map[dir:apps/booking name:booking]) (push) Has been cancelled
ci / typecheck (map[dir:apps/portal name:portal]) (push) Has been cancelled
ci / typecheck (map[dir:apps/website name:website]) (push) Has been cancelled
ci / typecheck (map[dir:services/platform-api name:platform-api]) (push) Has been cancelled
ci / test (push) Has been cancelled

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.
This commit is contained in:
Ronni Baslund
2026-06-07 10:34:53 +02:00
parent 90e8a22de4
commit 98e49bfe34
18 changed files with 1444 additions and 12 deletions
+594 -11
View File
@@ -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<MemberAliases>({ 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 cant be changed here.'
: !hasMailbox.value
? 'This is the workspaces primary account — its role cant be changed.'
: 'You cant 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<MemberAliases>(`/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<TenantUserDoc>(`/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<TenantUserDoc>(`/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<TenantUserDoc>(
`/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<MemberAliases>(`/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<MemberAliases>(`/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<TenantUserDoc | null>(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
}
}
</script>
<template>
@@ -352,7 +648,7 @@ async function confirmReset() {
</div>
</div>
</td>
<td><Badge :tone="u.role === 'owner' ? 'invert' : 'neutral'">{{ roleLabel(u.role) }}</Badge></td>
<td><Badge :tone="effectiveRole(u) === 'owner' ? 'invert' : 'neutral'">{{ roleLabel(effectiveRole(u)) }}</Badge></td>
<td><Badge :tone="statusTone(userStatus(u))" dot>{{ userStatus(u) }}</Badge></td>
<td><Mono dim>{{ lastSeen(u.lastLoginAt) }}</Mono></td>
<td class="right" @click.stop>
@@ -406,16 +702,142 @@ async function confirmReset() {
<Mono dim>{{ openUser.email }}</Mono>
<div class="ud-badges">
<Badge :tone="statusTone(userStatus(openUser))" dot>{{ userStatus(openUser) }}</Badge>
<Badge tone="neutral">{{ roleLabel(openUser.role) }}</Badge>
<Badge :tone="effectiveRole(openUser) === 'owner' ? 'invert' : 'neutral'">{{ roleLabel(effectiveRole(openUser)) }}</Badge>
</div>
</div>
</div>
<div class="ud-body">
<dl class="def">
<div><dt>Full name</dt><dd>{{ openUser.name }}</dd></div>
<div><dt>Email</dt><dd>{{ openUser.email }}</dd></div>
<div><dt>Role</dt><dd>{{ roleLabel(openUser.role) }}</dd></div>
<div><dt>Status</dt><dd>{{ userStatus(openUser) }}</dd></div>
<!-- 1 · Username & mail -->
<section class="ud-sec">
<div class="ud-sec-h"><UiIcon name="mail" :size="14" stroke="var(--text-mute)" /><span>Username &amp; mail</span></div>
<div class="ud-sec-body">
<!-- Sign-in address (editable only for mailbox-less identities) -->
<div class="kv">
<span class="kv-k">Sign-in address</span>
<template v-if="hasMailbox">
<Mono class="kv-v">{{ openUser.email }}</Mono>
</template>
<template v-else>
<div v-if="!primaryEdit" class="kv-inline">
<Mono class="kv-v">{{ openUser.email }}</Mono>
<button class="link-btn" @click="primaryEdit = true; primaryDraft = openUser.email">Edit</button>
</div>
<div v-else class="kv-edit">
<input class="input" type="email" v-model="primaryDraft" placeholder="name@example.com" />
<UiButton size="sm" variant="primary" :disabled="detailBusy.primary" @click="savePrimary">{{ detailBusy.primary ? 'Saving' : 'Save' }}</UiButton>
<UiButton size="sm" variant="ghost" @click="primaryEdit = false">Cancel</UiButton>
</div>
</template>
</div>
<!-- Mailbox: the address itself, or a CTA to create one -->
<div class="kv">
<span class="kv-k">Mailbox</span>
<template v-if="openUser.mailboxAddress">
<Mono class="kv-v">{{ openUser.mailboxAddress }}</Mono>
</template>
<div v-else class="kv-inline">
<span class="kv-v muted-v">No mailbox — sign-in only</span>
<UiButton size="sm" variant="secondary" @click="openCreateMailbox">
<template #leading><UiIcon name="plus" :size="13" /></template>
Create mailbox
</UiButton>
</div>
</div>
<p class="ud-hint">
<template v-if="hasMailbox">This is the members workspace mailbox. To add another address that delivers to the same inbox, add an alias below.</template>
<template v-else>This member signs in with an external address and has no inbox. Create a mailbox on your domain to give them one — and to enable aliases.</template>
</p>
</div>
</section>
<!-- 2 · Aliases -->
<section class="ud-sec">
<div class="ud-sec-h"><UiIcon name="copy" :size="14" stroke="var(--text-mute)" /><span>Aliases</span></div>
<div class="ud-sec-body">
<template v-if="hasMailbox">
<div v-if="aliasLoading" class="ud-hint">Loading…</div>
<ul v-else-if="aliasData.aliases.length" class="alias-list">
<li v-for="a in aliasData.aliases" :key="a">
<Mono>{{ a }}</Mono>
<button class="icon-btn" title="Remove alias" @click="removeAlias(a)"><UiIcon name="trash" :size="13" /></button>
</li>
</ul>
<div v-else class="ud-hint">No aliases yet.</div>
<div class="alias-add">
<input class="input" v-model="aliasForm.localPart" placeholder="alias" />
<span class="at">@</span>
<select class="input" v-model="aliasForm.domain">
<option v-for="d in provisionedDomains" :key="d.id" :value="d.domain">{{ d.domain }}</option>
</select>
<UiButton size="sm" variant="secondary" :disabled="detailBusy.alias || !aliasForm.localPart.trim() || !aliasForm.domain" @click="addAlias">
{{ detailBusy.alias ? 'Adding' : 'Add' }}
</UiButton>
</div>
</template>
<p v-else class="ud-hint">Aliases need a mailbox. This member uses SSO sign-in only, so theres no inbox to route extra addresses to.</p>
</div>
</section>
<!-- 3 · Role -->
<section class="ud-sec">
<div class="ud-sec-h"><UiIcon name="shield" :size="14" stroke="var(--text-mute)" /><span>Role</span></div>
<div class="ud-sec-body">
<template v-if="roleLocked">
<div class="kv"><span class="kv-k">Workspace role</span><Badge :tone="effectiveRole(openUser) === 'owner' ? 'invert' : 'neutral'">{{ roleLabel(effectiveRole(openUser)) }}</Badge></div>
<p class="ud-hint">{{ roleLockReason }}</p>
</template>
<template v-else>
<div class="role-pick">
<button type="button" :class="{ active: detailForm.role === 'member' }" @click="detailForm.role = 'member'">Member</button>
<button type="button" :class="{ active: detailForm.role === 'admin' }" @click="detailForm.role = 'admin'">Admin</button>
</div>
<p class="ud-hint">{{ detailForm.role === 'admin' ? 'Can manage users, billing, and workspace settings.' : 'Standard access to apps no admin controls.' }}</p>
<div class="ud-actions">
<UiButton size="sm" variant="primary" :disabled="detailBusy.role || !roleChanged" @click="saveRole">{{ detailBusy.role ? 'Saving' : 'Update role' }}</UiButton>
</div>
</template>
<!-- Ownership: transfer away (owner) or claim/assign (anyone else) -->
<div v-if="canManageOwnership" class="owner-row">
<template v-if="isOwner">
<span class="ud-hint">Hand the workspace owner role to another member.</span>
<UiButton size="sm" variant="secondary" @click="openTransfer">
<template #leading><UiIcon name="refresh" :size="13" /></template>
Transfer ownership
</UiButton>
</template>
<template v-else>
<span class="ud-hint">Make this member the workspace owner.</span>
<UiButton size="sm" variant="secondary" @click="makeOwnerTarget = openUser">
<template #leading><UiIcon name="refresh" :size="13" /></template>
Make owner
</UiButton>
</template>
</div>
</div>
</section>
<!-- 4 · Contact information -->
<section class="ud-sec">
<div class="ud-sec-h"><UiIcon name="users" :size="14" stroke="var(--text-mute)" /><span>Contact information</span></div>
<div class="ud-sec-body">
<div class="grid2">
<label class="field"><Eyebrow>Display name</Eyebrow><input class="input" v-model="detailForm.name" /></label>
<label class="field"><Eyebrow>Phone</Eyebrow><input class="input" v-model="detailForm.phone" placeholder="+45 …" /></label>
<label class="field"><Eyebrow>First name</Eyebrow><input class="input" v-model="detailForm.firstName" /></label>
<label class="field"><Eyebrow>Last name</Eyebrow><input class="input" v-model="detailForm.lastName" /></label>
<label class="field span2"><Eyebrow>Alternative email</Eyebrow><input class="input" type="email" v-model="detailForm.alternativeEmail" placeholder="recovery@example.com" /></label>
</div>
<div class="ud-actions">
<UiButton size="sm" variant="primary" :disabled="detailBusy.contact || !detailForm.name.trim()" @click="saveContact">{{ detailBusy.contact ? 'Saving' : 'Save changes' }}</UiButton>
</div>
</div>
</section>
<dl class="def ud-meta-def">
<div><dt>Joined</dt><dd>{{ joinedDate(openUser.createdAt) }}</dd></div>
<div><dt>Last sign-in</dt><dd>{{ lastSeen(openUser.lastLoginAt) }}</dd></div>
</dl>
@@ -498,6 +920,115 @@ async function confirmReset() {
</template>
</Modal>
<!-- Transfer ownership -->
<Modal :open="transferOpen" eyebrow="Users · ownership" title="Transfer ownership" size="md" @close="transferOpen = false">
<div v-if="!ownerCandidates.length" class="no-domain">
<UiIcon name="users" :size="22" stroke="var(--text-mute)" />
<div class="nd-text">
<div class="nd-title">No one to transfer to</div>
<div class="nd-sub">Add another member to this workspace first, then you can hand them ownership.</div>
</div>
</div>
<div v-else class="form-stack">
<p class="muted">
The new owner gets full control of <strong>{{ tenant?.name }}</strong>, including billing and ownership.
<template v-if="openUser">The current owner (<Mono>{{ openUser.email }}</Mono>) becomes an admin — nothing is deleted.</template>
</p>
<label class="field"><Eyebrow>New owner</Eyebrow>
<select class="input" v-model="transferTarget">
<option value="" disabled>Select a member…</option>
<option v-for="c in ownerCandidates" :key="c._id" :value="c._id">{{ c.name }} · {{ c.email }}</option>
</select>
</label>
</div>
<template #footer>
<UiButton variant="ghost" @click="transferOpen = false">Cancel</UiButton>
<div style="flex: 1" />
<UiButton v-if="ownerCandidates.length" variant="primary" :disabled="transferBusy || !transferTarget" @click="confirmTransfer">
<template #leading><UiIcon name="refresh" :size="13" /></template>
{{ transferBusy ? 'Transferring' : 'Transfer ownership' }}
</UiButton>
</template>
</Modal>
<!-- Make owner — single-target promote (row menu / drawer) -->
<ConfirmDialog
:open="!!makeOwnerTarget"
eyebrow="Users · ownership"
:title="`Make ${makeOwnerTarget?.name || makeOwnerTarget?.email} the owner?`"
confirm-label="Make owner"
:busy="makeOwnerBusy"
@close="makeOwnerTarget = null"
@confirm="confirmMakeOwner"
>
They get full control of this workspace — including billing and ownership. The current owner (if any)
becomes an admin. Nothing is deleted.
</ConfirmDialog>
<!-- Create mailbox for a mailbox-less member -->
<Modal :open="createMailboxOpen" eyebrow="Users" title="Create mailbox" size="md" @close="createMailboxOpen = false">
<div v-if="!provisionedDomains.length" class="no-domain">
<UiIcon name="globe" :size="22" stroke="var(--text-mute)" />
<div class="nd-text">
<div class="nd-title">No mail domain yet</div>
<div class="nd-sub">A mailbox needs a verified, provisioned domain. Add one on the Domains page, then come back.</div>
</div>
<UiButton variant="primary" @click="createMailboxOpen = false; navigateTo('/admin/domains')">Go to Domains</UiButton>
</div>
<div v-else class="form-stack">
<label class="field"><Eyebrow>Mailbox address</Eyebrow>
<div class="alias-row">
<input class="input" v-model="mailboxForm.localPart" placeholder="name" />
<span class="at">@</span>
<select v-if="provisionedDomains.length > 1" class="input" v-model="mailboxForm.domain">
<option v-for="d in provisionedDomains" :key="d.id" :value="d.domain">{{ d.domain }}</option>
</select>
<Mono v-else class="domain-fixed">{{ mailboxDomain }}</Mono>
</div>
</label>
<div class="muted">
Well create a mailbox at <Mono>{{ (mailboxForm.localPart || 'name') + '@' + mailboxDomain }}</Mono> and show you a one-time password for webmail/IMAP. Their sign-in address (<Mono>{{ openUser?.email }}</Mono>) doesnt change.
</div>
</div>
<template #footer>
<template v-if="provisionedDomains.length">
<UiButton variant="ghost" @click="createMailboxOpen = false">Cancel</UiButton>
<div style="flex: 1" />
<UiButton variant="primary" :disabled="mailboxBusy || !mailboxForm.localPart.trim() || !mailboxDomain" @click="submitCreateMailbox">
<template #leading><UiIcon name="check" :size="13" /></template>
{{ mailboxBusy ? 'Creating' : 'Create mailbox' }}
</UiButton>
</template>
<template v-else>
<div style="flex: 1" />
<UiButton variant="ghost" @click="createMailboxOpen = false">Close</UiButton>
</template>
</template>
</Modal>
<!-- Mailbox created — one-time password -->
<Modal :open="!!mailboxResult" title="Mailbox created" eyebrow="Users" size="md" @close="mailboxResult = null">
<div v-if="mailboxResult" class="invite-result">
<div class="ir-check"><UiIcon name="check" :size="22" :stroke-width="2.5" /></div>
<div class="ir-title">{{ mailboxResult.email }} is ready</div>
<p class="ir-sub">Share these securely. They sign in to webmail at <Mono>mail.dezky.local</Mono> with this password — their portal sign-in is unchanged.</p>
<div class="cred">
<div class="cred-row">
<span class="cred-k">Mailbox</span><Mono class="cred-v">{{ mailboxResult.email }}</Mono>
<button class="copy" @click="copyText(mailboxResult.email)"><UiIcon name="copy" :size="13" /></button>
</div>
<div class="cred-row">
<span class="cred-k">Password</span><Mono class="cred-v">{{ mailboxResult.tempPassword }}</Mono>
<button class="copy" @click="copyText(mailboxResult.tempPassword)"><UiIcon name="copy" :size="13" /></button>
</div>
</div>
</div>
<template #footer>
<div style="flex: 1" />
<UiButton variant="primary" @click="mailboxResult = 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 -->
@@ -724,12 +1255,64 @@ Mikkel Sørensen,mikkel@baslund.dk,admin,Engineering,business</pre>
.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; }
@@ -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,
})
})
@@ -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,
})
})
@@ -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}` },
})
})
@@ -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,
})
})
@@ -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,
})
})
@@ -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}` },
})
})
@@ -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,
})
})
+17
View File
@@ -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
@@ -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<string, unknown> },
): Promise<void> {
const body: Record<string, unknown> = {}
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<AuthentikUser & { attributes?: Record<string, unknown> }>(
`/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
@@ -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<void> {
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<void> {
const resp = await this.jmap([
@@ -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.
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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'
}
@@ -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<typeof clientIp>[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<typeof clientIp>[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<typeof clientIp>[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<typeof clientIp>[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<typeof clientIp>[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<typeof clientIp>[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),
)
}
}
@@ -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<UserDocument> {
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 accounts role cant be changed.')
}
if (actor?.userId && actor.userId === String(user._id)) {
throw new ForbiddenException('You cant 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('Cant remove the last admin of this workspace.')
}
}
}
const set: Record<string, unknown> = {}
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<string, unknown> = {}
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<UserDocument> {
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 cant be changed here. Add an alias instead.',
)
}
if (!user.authentikUserPk) {
throw new BadRequestException('This member isnt 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} isnt 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} isnt 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 — cant 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} isnt 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 },