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
@@ -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 },