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
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:
@@ -9,11 +9,11 @@
|
|||||||
// user-write endpoints are operator-only today, so a customer admin can't
|
// 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.
|
// 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 toast = useToast()
|
||||||
|
|
||||||
const { fetchMe } = useMe()
|
const { profile, fetchMe, isPlatformAdmin, isTenantAdminOf } = useMe()
|
||||||
await fetchMe()
|
await fetchMe()
|
||||||
const { tenant } = useTenant()
|
const { tenant } = useTenant()
|
||||||
const slug = computed(() => tenant.value?.slug ?? '')
|
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 userStatus = (u: TenantUserDoc): 'active' | 'suspended' => (u.active === false ? 'suspended' : 'active')
|
||||||
const roleLabel = (r: string) => r.charAt(0).toUpperCase() + r.slice(1)
|
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(() =>
|
const filteredUsers = computed(() =>
|
||||||
(users.value ?? []).filter((u) => {
|
(users.value ?? []).filter((u) => {
|
||||||
@@ -163,12 +166,14 @@ function rowAction(u: TenantUserDoc, id: string) {
|
|||||||
if (id === 'open') openUser.value = u
|
if (id === 'open') openUser.value = u
|
||||||
else if (id === 'reset') resetTarget.value = u
|
else if (id === 'reset') resetTarget.value = u
|
||||||
else if (id === 'force') forceLogoutUser(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 === 'suspend') suspendTarget.value = u
|
||||||
else if (id === 'resume') resumeUser(u)
|
else if (id === 'resume') resumeUser(u)
|
||||||
else if (id === 'delete') removeTarget.value = 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) {
|
function rowItems(u: TenantUserDoc) {
|
||||||
const suspended = u.active === false
|
const suspended = u.active === false
|
||||||
return [
|
return [
|
||||||
@@ -176,6 +181,9 @@ function rowItems(u: TenantUserDoc) {
|
|||||||
{ id: 'reset', label: 'Send password reset', icon: 'key' as const },
|
{ id: 'reset', label: 'Send password reset', icon: 'key' as const },
|
||||||
{ id: 'force', label: 'Force logout', icon: 'logout' as const },
|
{ id: 'force', label: 'Force logout', icon: 'logout' as const },
|
||||||
{ id: 'sep1', separator: true },
|
{ id: 'sep1', separator: true },
|
||||||
|
...(canManageOwnership.value && effectiveRole(u) !== 'owner'
|
||||||
|
? [{ id: 'make-owner', label: 'Make owner', icon: 'refresh' as const }]
|
||||||
|
: []),
|
||||||
suspended
|
suspended
|
||||||
? { id: 'resume', label: 'Resume user', icon: 'check' as const }
|
? { id: 'resume', label: 'Resume user', icon: 'check' as const }
|
||||||
: { id: 'suspend', label: 'Suspend user', icon: 'shield' as const, danger: true },
|
: { id: 'suspend', label: 'Suspend user', icon: 'shield' as const, danger: true },
|
||||||
@@ -270,6 +278,294 @@ async function confirmReset() {
|
|||||||
resetBusy.value = false
|
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 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<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -352,7 +648,7 @@ async function confirmReset() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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><Badge :tone="statusTone(userStatus(u))" dot>{{ userStatus(u) }}</Badge></td>
|
||||||
<td><Mono dim>{{ lastSeen(u.lastLoginAt) }}</Mono></td>
|
<td><Mono dim>{{ lastSeen(u.lastLoginAt) }}</Mono></td>
|
||||||
<td class="right" @click.stop>
|
<td class="right" @click.stop>
|
||||||
@@ -406,16 +702,142 @@ async function confirmReset() {
|
|||||||
<Mono dim>{{ openUser.email }}</Mono>
|
<Mono dim>{{ openUser.email }}</Mono>
|
||||||
<div class="ud-badges">
|
<div class="ud-badges">
|
||||||
<Badge :tone="statusTone(userStatus(openUser))" dot>{{ userStatus(openUser) }}</Badge>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ud-body">
|
<div class="ud-body">
|
||||||
<dl class="def">
|
<!-- 1 · Username & mail -->
|
||||||
<div><dt>Full name</dt><dd>{{ openUser.name }}</dd></div>
|
<section class="ud-sec">
|
||||||
<div><dt>Email</dt><dd>{{ openUser.email }}</dd></div>
|
<div class="ud-sec-h"><UiIcon name="mail" :size="14" stroke="var(--text-mute)" /><span>Username & mail</span></div>
|
||||||
<div><dt>Role</dt><dd>{{ roleLabel(openUser.role) }}</dd></div>
|
<div class="ud-sec-body">
|
||||||
<div><dt>Status</dt><dd>{{ userStatus(openUser) }}</dd></div>
|
<!-- 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 member’s 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 there’s 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>Joined</dt><dd>{{ joinedDate(openUser.createdAt) }}</dd></div>
|
||||||
<div><dt>Last sign-in</dt><dd>{{ lastSeen(openUser.lastLoginAt) }}</dd></div>
|
<div><dt>Last sign-in</dt><dd>{{ lastSeen(openUser.lastLoginAt) }}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -498,6 +920,115 @@ async function confirmReset() {
|
|||||||
</template>
|
</template>
|
||||||
</Modal>
|
</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">
|
||||||
|
We’ll 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>) doesn’t 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) -->
|
<!-- Invite user modal (3 steps) -->
|
||||||
<Modal :open="inviteOpen" title="Invite user" eyebrow="Users" size="md" @close="closeInvite">
|
<Modal :open="inviteOpen" title="Invite user" eyebrow="Users" size="md" @close="closeInvite">
|
||||||
<!-- No domain yet -->
|
<!-- No domain yet -->
|
||||||
@@ -724,12 +1255,64 @@ Mikkel Sørensen,mikkel@baslund.dk,admin,Engineering,business</pre>
|
|||||||
.ud-meta { flex: 1; }
|
.ud-meta { flex: 1; }
|
||||||
.ud-name { font-size: 17px; font-weight: 600; font-family: var(--font-display); }
|
.ud-name { font-size: 17px; font-weight: 600; font-family: var(--font-display); }
|
||||||
.ud-badges { display: flex; gap: 6px; margin-top: 8px; }
|
.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 { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 12px; column-gap: 16px; }
|
||||||
.def > div { display: contents; }
|
.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 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); }
|
.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 */
|
/* Invite modal */
|
||||||
.form-stack { display: flex; flex-direction: column; gap: 14px; }
|
.form-stack { display: flex; flex-direction: column; gap: 14px; }
|
||||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
.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,
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -96,13 +96,30 @@ export interface TenantUserDoc {
|
|||||||
_id: string
|
_id: string
|
||||||
email: string
|
email: string
|
||||||
name: 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'
|
role: 'owner' | 'admin' | 'member'
|
||||||
|
tenantRole?: 'owner' | 'admin' | 'member'
|
||||||
active: boolean
|
active: boolean
|
||||||
lastLoginAt?: string
|
lastLoginAt?: string
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
// Present when the user has a provisioned Stalwart mailbox (i.e. can receive
|
// Present when the user has a provisioned Stalwart mailbox (i.e. can receive
|
||||||
// mail / be an alias destination). Absent for SSO-only users.
|
// mail / be an alias destination). Absent for SSO-only users.
|
||||||
mailboxAddress?: string
|
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
|
// 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}`)
|
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
|
// 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
|
// 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
|
// 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).
|
// Set a new mailbox password (replaces the primary credential).
|
||||||
async setMailboxPassword(accountId: string, password: string): Promise<void> {
|
async setMailboxPassword(accountId: string, password: string): Promise<void> {
|
||||||
const resp = await this.jmap([
|
const resp = await this.jmap([
|
||||||
|
|||||||
@@ -64,6 +64,27 @@ export class User {
|
|||||||
@Prop()
|
@Prop()
|
||||||
lastLoginAt?: Date
|
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
|
// Partner-staff only: explicit subset of the partner's tenants this user may
|
||||||
// access. Absent/empty = full portfolio ("all") — backward compatible with
|
// access. Absent/empty = full portfolio ("all") — backward compatible with
|
||||||
// existing staff. Lets a partner scope e.g. a sales rep to specific customers.
|
// 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,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
Param,
|
Param,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@@ -15,8 +17,13 @@ import { CurrentUser } from '../auth/current-user.decorator.js'
|
|||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
|
||||||
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
|
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
|
||||||
import type { AuditActor } from '../audit/audit.service.js'
|
import type { AuditActor } from '../audit/audit.service.js'
|
||||||
|
import { roleForTenant } from '../schemas/user.schema.js'
|
||||||
import { TenantsService } from '../tenants/tenants.service.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 { 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'
|
import { UsersService } from './users.service.js'
|
||||||
|
|
||||||
function auditActor(
|
function auditActor(
|
||||||
@@ -163,4 +170,143 @@ export class TenantMembersController {
|
|||||||
auditActor(actor, req),
|
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 { Price, PriceDocument } from '../schemas/price.schema.js'
|
||||||
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
|
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
|
||||||
import { Tenant, TenantDocument } from '../schemas/tenant.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 { CreateUserDto } from './dto/create-user.dto.js'
|
||||||
import type { InviteOperatorDto } from './dto/invite-operator.dto.js'
|
import type { InviteOperatorDto } from './dto/invite-operator.dto.js'
|
||||||
import type { InvitePartnerUserDto } from './dto/invite-partner-user.dto.js'
|
import type { InvitePartnerUserDto } from './dto/invite-partner-user.dto.js'
|
||||||
@@ -974,6 +974,418 @@ export class UsersService {
|
|||||||
return { email: user.email, tempPassword }
|
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 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<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 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(
|
async inviteTenantAdmin(
|
||||||
tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
|
tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
|
||||||
dto: { name: string; email: string },
|
dto: { name: string; email: string },
|
||||||
|
|||||||
Reference in New Issue
Block a user