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:
@@ -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 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(
|
||||
tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
|
||||
dto: { name: string; email: string },
|
||||
|
||||
Reference in New Issue
Block a user