Files
dezky/services/platform-api/src/users/users.service.ts
T
Ronni Baslund 98e49bfe34
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
feat(admin/users): editable member drawer + mailbox & ownership management
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.
2026-06-07 10:34:53 +02:00

1807 lines
69 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose'
import type { AuditEventDocument } from '../schemas/audit-event.schema.js'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { AuthentikClient } from '../integrations/authentik.client.js'
import { OcisClient } from '../integrations/ocis.client.js'
import { StalwartClient } from '../integrations/stalwart.client.js'
import { Domain, DomainDocument } from '../schemas/domain.schema.js'
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, 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'
import type { UpdateUserDto } from './dto/update-user.dto.js'
// Authentik group every partner-staff invite gets added to. We use ONE
// group across all partners (instead of `dezky-partner-{slug}` per partner)
// because partner scoping is enforced server-side via User.partnerId — the
// group claim just marks "this user is partner staff, route them to the
// partner portal." Simpler than reconciling groups on partner rename.
const PARTNER_STAFF_GROUP = 'dezky-partner-staff'
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name)
private readonly platformAdminGroup: string
// Cached after first successful lookup. The dezky-platform-admins group is
// created once during Authentik bootstrap and never moves; no need to look
// it up every invite.
private platformAdminGroupId: string | null = null
// Same caching for the partner-staff group. Created lazily on first
// invitePartnerUser call via ensureGroup (idempotent).
private partnerStaffGroupId: string | null = null
constructor(
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
@InjectModel(Partner.name) private readonly partnerModel: Model<PartnerDocument>,
@InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
@InjectModel(Price.name) private readonly priceModel: Model<PriceDocument>,
@InjectModel(Domain.name) private readonly domainModel: Model<DomainDocument>,
private readonly audit: AuditService,
private readonly authentik: AuthentikClient,
private readonly stalwart: StalwartClient,
private readonly ocis: OcisClient,
config: ConfigService,
) {
this.platformAdminGroup =
config.get<string>('PLATFORM_ADMIN_BOOTSTRAP_GROUP') ?? 'dezky-platform-admins'
}
async create(dto: CreateUserDto): Promise<UserDocument> {
const exists = await this.userModel.exists({ authentikSubjectId: dto.authentikSubjectId })
if (exists) throw new ConflictException(`User ${dto.authentikSubjectId} already exists`)
const tenantIds = await this.resolveTenantIds(dto.tenantSlugs ?? [])
const role = dto.role ?? 'member'
// Seed the per-tenant role for every tenant this user is created into, so
// their effective role is explicit from the start rather than relying on
// the global-role fallback. Omitted when there are no tenants.
const tenantRoles = tenantIds.length
? Object.fromEntries(tenantIds.map((id) => [String(id), role]))
: undefined
return this.userModel.create({
authentikSubjectId: dto.authentikSubjectId,
email: dto.email,
name: dto.name,
role,
tenantIds,
tenantRoles,
})
}
async findAllForTenants(tenantIds: Types.ObjectId[]): Promise<UserDocument[]> {
return this.userModel.find({ tenantIds: { $in: tenantIds } }).sort({ createdAt: -1 }).exec()
}
async findAll(): Promise<UserDocument[]> {
return this.userModel.find().sort({ createdAt: -1 }).exec()
}
async findOneBySubject(subject: string): Promise<UserDocument> {
const user = await this.userModel.findOne({ authentikSubjectId: subject }).exec()
if (!user) throw new NotFoundException(`User ${subject} not found`)
return user
}
async update(subject: string, dto: UpdateUserDto): Promise<UserDocument> {
const patch: Record<string, unknown> = { ...dto }
if (dto.tenantSlugs !== undefined) {
patch.tenantIds = await this.resolveTenantIds(dto.tenantSlugs)
delete patch.tenantSlugs
}
const user = await this.userModel
.findOneAndUpdate({ authentikSubjectId: subject }, patch, { new: true, runValidators: true })
.exec()
if (!user) throw new NotFoundException(`User ${subject} not found`)
return user
}
async deactivate(subject: string, actor?: AuditActor): Promise<void> {
const user = await this.userModel
.findOneAndUpdate({ authentikSubjectId: subject }, { active: false }, { new: true })
.exec()
if (!user) throw new NotFoundException(`User ${subject} not found`)
void this.audit.record(
{
action: 'user.deactivated',
resourceType: 'user',
resourceId: String(user._id),
resourceName: user.email,
},
actor,
)
}
// Called on every authenticated request from /users/me. The JWT's groups claim
// is treated as a hint for first-time membership sync — the DB is the source of
// truth for all subsequent authorization decisions.
// What /users/me returns — the user doc plus the partner object when
// User.partnerId is set. Frontends use the presence of `partner` to decide
// whether to render the partner-admin surface vs. the end-user surface.
async meWithPartner(payload: {
subject: string
email: string
name: string
tenantSlugs: string[]
platformAdmin: boolean
}): Promise<UserDocument & { partner?: { _id: string; slug: string; name: string; status: string } }> {
const user = await this.upsertFromAuthentik(payload)
if (!user.partnerId) {
return user as UserDocument & { partner?: never }
}
const partner = await this.partnerModel
.findById(user.partnerId, { slug: 1, name: 1, status: 1 })
.exec()
if (!partner) {
// Partner was deleted out from under the user. Don't fail the whole
// /me call — just omit the partner field; the frontend will treat
// them as a regular end-user.
return user as UserDocument & { partner?: never }
}
const userObj = user.toObject() as UserDocument & {
partner?: { _id: string; slug: string; name: string; status: string }
}
userObj.partner = {
_id: String(partner._id),
slug: partner.slug,
name: partner.name,
status: partner.status,
}
return userObj
}
async upsertFromAuthentik(payload: {
subject: string
email: string
name: string
tenantSlugs: string[]
platformAdmin: boolean
}): Promise<UserDocument> {
const tenantIds = await this.resolveTenantIds(payload.tenantSlugs)
return this.userModel
.findOneAndUpdate(
{ authentikSubjectId: payload.subject },
{
$set: {
email: payload.email,
name: payload.name,
tenantIds,
platformAdmin: payload.platformAdmin,
lastLoginAt: new Date(),
},
$setOnInsert: { role: 'member', active: true },
},
{ new: true, upsert: true, runValidators: true },
)
.exec()
}
private async resolveTenantIds(slugs: string[]): Promise<Types.ObjectId[]> {
if (slugs.length === 0) return []
const tenants = await this.tenantModel.find({ slug: { $in: slugs } }, { _id: 1 }).exec()
return tenants.map((t) => t._id)
}
// Invite a new platform admin. Creates the user in Authentik, adds them to
// the dezky-platform-admins group, pre-creates the local User doc so they
// appear in the operator-team list immediately, and returns whichever
// credential-handoff path Authentik supports for our brand:
// - `link` — single-use recovery URL (preferred; requires a recovery
// flow configured in Authentik. User clicks → sets password
// + enrolls MFA themselves.)
// - `tempPassword` — random 16-char password we set on the user. Used
// when no recovery flow exists; operator hands this
// to the new user out-of-band and they change it on
// first login via Authentik's password-change flow.
// On their first login, upsertFromAuthentik() patches lastLoginAt +
// reconciles group state from the JWT — no further work needed.
//
// Email delivery is the operator's job for now (we return the credential);
// when outbound SMTP is wired (Phase 5/6), Authentik can email directly.
async inviteOperator(
dto: InviteOperatorDto,
actor?: AuditActor,
): Promise<{
subject: string
userId: string
link?: string
tempPassword?: string
}> {
// Prevent duplicate by email — Authentik will 400 but its error message
// isn't friendly. Check up front for a clean conflict.
const existing = await this.authentik.findUserByEmail(dto.email)
if (existing) {
throw new ConflictException(
`User with email ${dto.email} already exists in Authentik (uid=${existing.uid})`,
)
}
const groupPk = await this.resolvePlatformAdminGroupId()
const username = dto.email // Authentik convention — keep email + username aligned
const created = await this.authentik.createUser({
username,
email: dto.email,
name: dto.name,
groupPks: [groupPk],
attributes: { invitedBy: actor?.email, invitedAt: new Date().toISOString() },
})
// Pre-create the local User doc so the operator-team list reflects the
// invite immediately. On their first login /users/me will upsert and
// reconcile lastLoginAt + platformAdmin from the JWT.
await this.userModel
.findOneAndUpdate(
{ authentikSubjectId: created.uid },
{
$set: {
email: dto.email,
name: dto.name,
platformAdmin: true,
},
$setOnInsert: { role: 'admin', active: true, tenantIds: [] },
},
{ upsert: true, new: true, runValidators: true },
)
.exec()
// Try the preferred recovery-link path first. If Authentik has no
// recovery flow configured (returns undefined), fall back to setting a
// generated temp password — and mark it as expired so Authentik forces
// a change on the user's first login (defense-in-depth against the
// operator's plaintext-handling window).
let link: string | undefined
let tempPassword: string | undefined
link = await this.authentik.recoveryLink(created.pk)
if (!link) {
tempPassword = generateTempPassword()
await this.authentik.setInitialPassword(created.pk, tempPassword)
await this.authentik.markPasswordExpired(created.pk)
}
void this.audit.record(
{
action: 'platform.user_invited',
resourceType: 'user',
resourceId: created.uid,
resourceName: dto.email,
metadata: {
role: 'platform-admin',
name: dto.name,
handoff: link ? 'recovery-link' : 'temp-password',
},
},
actor,
)
return { subject: created.uid, userId: String(created.pk), link, tempPassword }
}
// Invite a user that works at a partner organization. Same shape as
// inviteOperator but adds the dezky-partner-staff group, sets
// User.partnerId, and records a partner-scoped audit event. The caller
// (PartnersController) already resolved the slug → partner, so we receive
// the partnerId directly.
async invitePartnerUser(
dto: InvitePartnerUserDto,
partner: { _id: Types.ObjectId; slug: string },
actor?: AuditActor,
): Promise<{
subject: string
userId: string
// True if we attached an existing Authentik user instead of creating one.
// When attached, link/tempPassword are omitted (the user already has a
// password) and the UI shows a simpler success view.
attached?: boolean
link?: string
tempPassword?: string
}> {
const groupPk = await this.resolvePartnerStaffGroupId()
// ── Attach path: user already exists in Authentik ────────────────────
// Common when:
// - Operator promotes an existing platform admin to also work for a
// partner (e.g. internal staff cross-referenced as a partner contact).
// - User was created via a different invite path (operator team) and
// should now also be visible under a partner.
// Refuse only if their local User doc already points at a DIFFERENT
// partner — silently moving them would erase the prior relationship.
const existing = await this.authentik.findUserByEmail(dto.email)
if (existing) {
const localUser = await this.userModel.findOne({ authentikSubjectId: existing.uid }).exec()
if (
localUser?.partnerId &&
String(localUser.partnerId) !== String(partner._id)
) {
throw new ConflictException(
`User ${dto.email} already belongs to partner ${String(localUser.partnerId)} — detach them from that partner first.`,
)
}
await this.authentik.addUserToGroup(existing.pk, groupPk)
// Upsert local User. Existing docs get partnerId set; missing docs
// (Authentik-only users like akadmin pre-/users/me) are created so the
// partner team list shows them immediately instead of waiting for first
// login to materialize the doc.
await this.userModel
.findOneAndUpdate(
{ authentikSubjectId: existing.uid },
{
$set: {
email: existing.email,
// Don't clobber the local name if we have one (e.g. they
// already logged in and set it from the JWT); only seed on insert.
partnerId: partner._id,
authentikUserPk: existing.pk,
},
$setOnInsert: {
name: existing.name || dto.name,
role: 'member',
active: true,
tenantIds: [],
platformAdmin: false,
},
},
{ upsert: true, new: true, runValidators: true },
)
.exec()
void this.audit.record(
{
action: 'partner.user_attached',
resourceType: 'user',
resourceId: existing.uid,
resourceName: existing.email,
partnerSlug: partner.slug,
metadata: { name: existing.name || dto.name, role: 'partner-staff' },
},
actor,
)
return { subject: existing.uid, userId: String(existing.pk), attached: true }
}
// ── Create path: brand-new user in Authentik ─────────────────────────
const created = await this.authentik.createUser({
username: dto.email,
email: dto.email,
name: dto.name,
groupPks: [groupPk],
attributes: {
partnerSlug: partner.slug,
invitedBy: actor?.email,
invitedAt: new Date().toISOString(),
},
})
// Pre-create the local User doc with partnerId so the partner team list
// reflects the invite immediately. On first login, /users/me upserts
// and reconciles email/name/lastLoginAt from the JWT (partnerId is
// preserved — see upsertFromAuthentik).
await this.userModel
.findOneAndUpdate(
{ authentikSubjectId: created.uid },
{
$set: {
email: dto.email,
name: dto.name,
partnerId: partner._id,
authentikUserPk: created.pk,
},
$setOnInsert: { role: 'member', active: true, tenantIds: [], platformAdmin: false },
},
{ upsert: true, new: true, runValidators: true },
)
.exec()
let link: string | undefined
let tempPassword: string | undefined
link = await this.authentik.recoveryLink(created.pk)
if (!link) {
tempPassword = generateTempPassword()
await this.authentik.setInitialPassword(created.pk, tempPassword)
await this.authentik.markPasswordExpired(created.pk)
}
void this.audit.record(
{
action: 'partner.user_invited',
resourceType: 'user',
resourceId: created.uid,
resourceName: dto.email,
partnerSlug: partner.slug,
metadata: {
role: 'partner-staff',
name: dto.name,
handoff: link ? 'recovery-link' : 'temp-password',
},
},
actor,
)
return { subject: created.uid, userId: String(created.pk), link, tempPassword }
}
// List users belonging to a partner. Called by the operator partner-detail
// page's Team section.
async listPartnerUsers(partnerId: Types.ObjectId): Promise<UserDocument[]> {
return this.userModel.find({ partnerId }).sort({ createdAt: -1 }).exec()
}
// Partner-portal team list. Same set as listPartnerUsers, enriched with:
// - accessLevel/accessCount derived from partnerTenantAccess (absent = all)
// - mfaEnabled from a live Authentik authenticator-count lookup
// MFA lookups run in bounded parallel and degrade to null on any error so a
// flaky/unavailable Authentik never breaks the team list. Kept separate from
// listPartnerUsers (operator path) so that path stays a pure DB query with
// no external coupling.
async listPartnerUsersEnriched(partnerId: Types.ObjectId): Promise<
Array<
UserDocument & {
mfaEnabled: boolean | null
accessLevel: 'all' | 'scoped'
accessCount: number | null
}
>
> {
const users = await this.userModel.find({ partnerId }).sort({ createdAt: -1 }).exec()
if (users.length === 0) return []
const mfa = await Promise.all(
users.map(async (u) => {
if (!u.authentikUserPk) return null
try {
return (await this.authentik.countAuthenticators(u.authentikUserPk)) > 0
} catch {
return null
}
}),
)
return users.map((u, i) => {
const obj = u.toObject() as UserDocument & {
mfaEnabled: boolean | null
accessLevel: 'all' | 'scoped'
accessCount: number | null
}
const access = u.partnerTenantAccess
const scoped = !!access && access.length > 0
obj.accessLevel = scoped ? 'scoped' : 'all'
obj.accessCount = scoped ? access!.length : null
obj.mfaEnabled = mfa[i] ?? null
return obj
})
}
// Remove a partner-staff user from the partner: unset partnerId +
// partnerTenantAccess and drop them from the dezky-partner-staff Authentik
// group. Ownership-guarded (must belong to the caller's partner) and refuses
// to strip the last partner admin/owner so a partner can't lock itself out.
async removePartnerUser(
subject: string,
partnerId: Types.ObjectId,
actor?: AuditActor,
): Promise<{ removed: boolean }> {
const user = await this.userModel.findOne({ authentikSubjectId: subject }).exec()
if (!user) throw new NotFoundException(`User ${subject} not found`)
if (!user.partnerId || String(user.partnerId) !== String(partnerId)) {
throw new ForbiddenException('User is not part of your partner organization')
}
if (user.role === 'owner' || user.role === 'admin') {
const admins = await this.userModel
.countDocuments({ partnerId, role: { $in: ['owner', 'admin'] } })
.exec()
if (admins <= 1) {
throw new ConflictException('Cannot remove the last partner admin')
}
}
// Best-effort Authentik group removal — never block the local detach on it.
try {
const groupPk = await this.resolvePartnerStaffGroupId()
const pk = user.authentikUserPk ?? (await this.authentik.findUserByEmail(user.email))?.pk
if (pk) await this.authentik.removeUserFromGroup(pk, groupPk)
} catch (err) {
this.logger.warn(
`Failed to remove ${user.email} from partner-staff group: ${
err instanceof Error ? err.message : String(err)
}`,
)
}
await this.userModel
.updateOne({ _id: user._id }, { $unset: { partnerId: '', partnerTenantAccess: '' } })
.exec()
void this.audit.record(
{
action: 'partner.user_removed',
resourceType: 'user',
resourceId: subject,
resourceName: user.email,
metadata: { partnerId: String(partnerId) },
},
actor,
)
return { removed: true }
}
// List tenants attached to a partner. Used by the partner-portal's
// /partner/customers page (via /users/me/partner/tenants) and could be
// reused for operator surfaces that want partner-scoped tenant queries.
// Each tenant carries a userCount (admins + members) so the seat "used"
// column can render N/M without a second round-trip from the client.
async listPartnerTenants(
partnerId: Types.ObjectId,
): Promise<
Array<
TenantDocument & {
userCount: number
newUserCount30d: number
healthScore: number
healthBand: 'healthy' | 'watch' | 'at-risk'
}
>
> {
const tenants = await this.tenantModel.find({ partnerId }).sort({ createdAt: -1 }).exec()
if (tenants.length === 0) return []
const since30d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
// Single aggregation across all of the partner's tenants. We compute
// both the total active user count and the subset created in the last
// 30 days using $cond — one pass over the collection regardless of
// how many tenants the partner has.
const counts = await this.userModel.aggregate<{
_id: Types.ObjectId
n: number
new30d: number
}>([
{ $match: { tenantIds: { $in: tenants.map((t) => t._id) }, active: true } },
{ $unwind: '$tenantIds' },
{ $match: { tenantIds: { $in: tenants.map((t) => t._id) } } },
{
$group: {
_id: '$tenantIds',
n: { $sum: 1 },
new30d: { $sum: { $cond: [{ $gte: ['$createdAt', since30d] }, 1, 0] } },
},
},
])
const countMap = new Map(counts.map((c) => [String(c._id), c]))
return tenants.map((t) => {
const obj = t.toObject() as TenantDocument & {
userCount: number
newUserCount30d: number
healthScore: number
healthBand: 'healthy' | 'watch' | 'at-risk'
}
const c = countMap.get(String(t._id))
obj.userCount = c?.n ?? 0
obj.newUserCount30d = c?.new30d ?? 0
const health = this.tenantHealth(t, obj.userCount)
obj.healthScore = health.healthScore
obj.healthBand = health.healthBand
return obj
})
}
// Portfolio-health heuristic for a tenant, 0100, computed (never stored).
// Penalises non-active status, poor seat adoption, and failed/pending
// provisioning. Band: >=80 healthy, 6079 watch, <60 at-risk.
private tenantHealth(
t: TenantDocument,
userCount: number,
): { healthScore: number; healthBand: 'healthy' | 'watch' | 'at-risk' } {
let score = 100
if (t.status === 'pending') score -= 15
else if (t.status === 'suspended' || t.status === 'deleted') score -= 60
const seats = t.seats ?? 0
if (seats > 0) {
const u = userCount / seats
if (u < 0.25) score -= 25
else if (u < 0.5) score -= 10
else if (u > 1.0) score -= 5
}
const ps = t.provisioningStatus
for (const s of [ps?.authentik, ps?.stalwart, ps?.ocis]) {
if (s === 'error') score -= 10
else if (s === 'pending') score -= 5
}
score = Math.max(0, Math.min(100, score))
const healthBand: 'healthy' | 'watch' | 'at-risk' =
score >= 80 ? 'healthy' : score >= 60 ? 'watch' : 'at-risk'
return { healthScore: score, healthBand }
}
// Create (or attach) the first admin user for a freshly-provisioned
// tenant. Same shape as invitePartnerUser but adds the user to the
// tenant's Authentik group (created during provisioning) instead of
// dezky-partner-staff, sets User.tenantIds to include this tenant, and
// audits with tenantSlug.
//
// Failures are surfaced to the caller (TenantsController) rather than
// swallowed — the wizard wants to show "admin invite failed: ..." in the
// done state so the operator can retry rather than silently shipping a
// tenant with no admin.
// Create a workspace member and provision them across systems: Authentik SSO
// (required), a Stalwart mailbox on the tenant's default domain (best-effort),
// and an OCIS account (best-effort; auto-provisions on first login otherwise).
// One temp password is set on BOTH Authentik and the mailbox, so the new user
// has a single credential — returned once for the admin to hand over.
async createTenantMember(
tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
dto: { name: string; localPart: string; role: 'admin' | 'member'; domain?: string },
actor?: AuditActor,
): Promise<{
email: string
tempPassword: string
provisioning: { authentik: 'ok'; stalwart: 'ok' | 'error' | 'skipped'; ocis: 'ok' | 'error' | 'skipped' }
stalwartError?: string
ocisNote?: string
}> {
if (!tenant.authentikGroupId) {
throw new BadRequestException(
`Workspace "${tenant.slug}" isn't fully provisioned (no identity group). Reconcile it first.`,
)
}
// Resolve the target domain — the named one, else the primary, else the oldest.
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 users.')
}
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 dupe = await this.userModel.findOne({ email, tenantIds: tenant._id }).exec()
if (dupe) throw new ConflictException(`${email} already exists in this workspace.`)
const role: 'admin' | 'member' = dto.role === 'admin' ? 'admin' : 'member'
const tempPassword = generateTempPassword()
// 1) Authentik SSO identity (required) — same temp password so one credential
// works for sign-in (and OCIS, which authenticates via Authentik).
const created = await this.authentik.createUser({
username: email,
email,
name: dto.name,
groupPks: [tenant.authentikGroupId],
attributes: {
tenantSlug: tenant.slug,
invitedBy: actor?.email,
invitedAt: new Date().toISOString(),
},
})
await this.authentik.setInitialPassword(created.pk, tempPassword)
const prov: { authentik: 'ok'; stalwart: 'ok' | 'error' | 'skipped'; ocis: 'ok' | 'error' | 'skipped' } = {
authentik: 'ok',
stalwart: 'skipped',
ocis: 'skipped',
}
// 2) Stalwart mailbox (best-effort) — the same temp password signs into webmail.
let stalwartAccountId: string | undefined
let stalwartError: string | undefined
if (this.stalwart.configured && domainDoc.stalwartId) {
try {
const mbx = await this.stalwart.createMailbox({
domainId: domainDoc.stalwartId,
localPart,
fullName: dto.name,
password: tempPassword,
})
stalwartAccountId = mbx.id
prov.stalwart = 'ok'
} catch (err) {
prov.stalwart = 'error'
stalwartError = (err as Error).message
this.logger.error(`Mailbox provisioning failed for ${email}: ${stalwartError}`)
}
}
// 3) OCIS account (best-effort; auto-provisions on first sign-in otherwise).
let ocisUserId: string | undefined
let ocisNote: string | undefined
const ocisRes = await this.ocis.ensureUser({ username: email, displayName: dto.name, mail: email })
if (ocisRes.deferred) {
prov.ocis = 'skipped'
ocisNote = 'auto-provisions on first sign-in'
} else {
prov.ocis = 'ok'
ocisUserId = ocisRes.id
}
// 4) Our User doc.
await this.userModel
.findOneAndUpdate(
{ authentikSubjectId: created.uid },
{
$set: {
email,
name: dto.name,
[`tenantRoles.${tenant._id}`]: role,
mailboxAddress: email,
stalwartAccountId,
ocisUserId,
authentikUserPk: created.pk,
provisioning: { ...prov, stalwartError, ocisNote },
},
$setOnInsert: { role, active: true, platformAdmin: false },
$addToSet: { tenantIds: tenant._id },
},
{ upsert: true, new: true, runValidators: true },
)
.exec()
void this.audit.record(
{
action: 'tenant.user_created',
resourceType: 'user',
resourceId: created.uid,
resourceName: email,
tenantSlug: tenant.slug,
metadata: { name: dto.name, role, mailbox: prov.stalwart, ocis: prov.ocis },
},
actor,
)
return { email, tempPassword, provisioning: prov, stalwartError, ocisNote }
}
// Remove a member from a workspace, tearing down their provisioned accounts.
// The mailbox lives on one of THIS tenant's domains, so it's deleted (only if
// it actually belongs here — a multi-tenant user's mailbox on another tenant is
// left alone). The SSO identity + OCIS account are global, so they're deleted
// only when the user belongs to no other tenant (and isn't partner staff / a
// platform admin); otherwise we just detach this tenant.
async removeTenantMember(
tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
userId: string,
actor?: AuditActor,
): Promise<void> {
let _id: Types.ObjectId
try {
_id = new Types.ObjectId(userId)
} catch {
throw new NotFoundException('User not found')
}
const user = await this.userModel.findById(_id).exec()
if (!user || !user.tenantIds.some((t) => t.equals(tenant._id))) {
throw new NotFoundException('User not found in this workspace')
}
// Is the user's mailbox on a domain owned by THIS tenant?
const mailboxDomain = user.mailboxAddress?.split('@')[1]?.toLowerCase()
const mailboxIsHere =
!!mailboxDomain &&
!!(await this.domainModel.exists({ tenantId: tenant._id, domain: mailboxDomain }))
// 1) Delete the mailbox (best-effort) when it belongs to this tenant.
if (this.stalwart.configured && user.stalwartAccountId && mailboxIsHere) {
await this.stalwart.deleteMailbox(user.stalwartAccountId).catch((err) => {
this.logger.error(`Mailbox delete failed for ${user.email}: ${(err as Error).message}`)
})
}
// 2) Remove from this tenant's Authentik group.
if (tenant.authentikGroupId && user.authentikUserPk) {
await this.authentik
.removeUserFromGroup(user.authentikUserPk, tenant.authentikGroupId)
.catch((err) => {
this.logger.error(`Authentik group remove failed for ${user.email}: ${(err as Error).message}`)
})
}
const remaining = user.tenantIds.filter((t) => !t.equals(tenant._id))
const fullyRemove = remaining.length === 0 && !user.partnerId && !user.platformAdmin
if (fullyRemove) {
if (user.ocisUserId) await this.ocis.deleteUser(user.ocisUserId)
if (user.authentikUserPk) {
await this.authentik.deleteUser(user.authentikUserPk).catch((err) => {
this.logger.error(`Authentik user delete failed for ${user.email}: ${(err as Error).message}`)
})
}
await this.userModel.deleteOne({ _id }).exec()
} else {
// Keep the global identity; detach this tenant. Drop the mailbox handle
// only if the mailbox we removed was actually this tenant's.
const unset: Record<string, ''> = { [`tenantRoles.${tenant._id}`]: '' }
if (mailboxIsHere) {
unset.mailboxAddress = ''
unset.stalwartAccountId = ''
}
await this.userModel
.updateOne({ _id }, { $pull: { tenantIds: tenant._id }, $unset: unset })
.exec()
}
void this.audit.record(
{
action: 'tenant.user_removed',
resourceType: 'user',
resourceId: user.authentikSubjectId,
resourceName: user.email,
tenantSlug: tenant.slug,
metadata: { fullyRemoved: fullyRemove },
},
actor,
)
}
// Shared lookup for the per-member lifecycle actions: the user doc (verified to
// belong to this tenant) + whether their mailbox is on one of this tenant's
// domains (so mailbox-side changes only touch mailboxes we own).
private async loadMember(
tenant: { _id: Types.ObjectId },
userId: string,
): Promise<{ user: UserDocument; mailboxIsHere: boolean }> {
let _id: Types.ObjectId
try {
_id = new Types.ObjectId(userId)
} catch {
throw new NotFoundException('User not found')
}
const user = await this.userModel.findById(_id).exec()
if (!user || !user.tenantIds.some((t) => t.equals(tenant._id))) {
throw new NotFoundException('User not found in this workspace')
}
const mailboxDomain = user.mailboxAddress?.split('@')[1]?.toLowerCase()
const mailboxIsHere =
!!mailboxDomain &&
!!(await this.domainModel.exists({ tenantId: tenant._id, domain: mailboxDomain }))
return { user, mailboxIsHere }
}
// Suspend or resume a member: toggles Authentik sign-in (is_active) and freezes
// / unfreezes the mailbox. Reversible — resume restores the original password.
async setMemberSuspended(
tenant: { _id: Types.ObjectId; slug: string },
userId: string,
suspended: boolean,
actor?: AuditActor,
): Promise<void> {
const { user, mailboxIsHere } = await this.loadMember(tenant, userId)
// Identity first — if this fails, abort before touching anything else.
if (user.authentikUserPk) {
await this.authentik.setUserActive(user.authentikUserPk, !suspended)
}
if (this.stalwart.configured && user.stalwartAccountId && mailboxIsHere) {
await this.stalwart.setMailboxSuspended(user.stalwartAccountId, suspended).catch((err) => {
this.logger.error(
`Mailbox ${suspended ? 'suspend' : 'resume'} failed for ${user.email}: ${(err as Error).message}`,
)
})
}
user.active = !suspended
await user.save()
void this.audit.record(
{
action: suspended ? 'tenant.user_suspended' : 'tenant.user_resumed',
resourceType: 'user',
resourceId: user.authentikSubjectId,
resourceName: user.email,
tenantSlug: tenant.slug,
},
actor,
)
}
// Force-logout: terminate the member's active SSO sessions.
async forceLogoutMember(
tenant: { _id: Types.ObjectId; slug: string },
userId: string,
actor?: AuditActor,
): Promise<{ sessions: number }> {
const { user } = await this.loadMember(tenant, userId)
let sessions = 0
if (user.authentikUserPk) {
sessions = await this.authentik.terminateSessions(user.authentikUserPk).catch(() => 0)
}
void this.audit.record(
{
action: 'tenant.user_logout_forced',
resourceType: 'user',
resourceId: user.authentikSubjectId,
resourceName: user.email,
tenantSlug: tenant.slug,
metadata: { sessions },
},
actor,
)
return { sessions }
}
// Reset a member's password: one fresh temp password set on both their SSO
// login and their mailbox, returned once for the admin to hand over.
async resetMemberPassword(
tenant: { _id: Types.ObjectId; slug: string },
userId: string,
actor?: AuditActor,
): Promise<{ email: string; tempPassword: string }> {
const { user, mailboxIsHere } = await this.loadMember(tenant, userId)
const tempPassword = generateTempPassword()
if (user.authentikUserPk) {
await this.authentik.setInitialPassword(user.authentikUserPk, tempPassword)
}
if (this.stalwart.configured && user.stalwartAccountId && mailboxIsHere) {
await this.stalwart.setMailboxPassword(user.stalwartAccountId, tempPassword).catch((err) => {
this.logger.error(`Mailbox password reset failed for ${user.email}: ${(err as Error).message}`)
})
}
void this.audit.record(
{
action: 'tenant.user_password_reset',
resourceType: 'user',
resourceId: user.authentikSubjectId,
resourceName: user.email,
tenantSlug: tenant.slug,
},
actor,
)
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 },
actor?: AuditActor,
): Promise<{
subject: string
userId: string
attached?: boolean
link?: string
tempPassword?: string
}> {
if (!tenant.authentikGroupId) {
throw new Error(
`Tenant ${tenant.slug} has no authentikGroupId — provisioning didn't complete. Retry /tenants/${tenant.slug}/reconcile and re-invite.`,
)
}
// ── Attach existing Authentik user ─────────────────────────────────
const existing = await this.authentik.findUserByEmail(dto.email)
if (existing) {
await this.authentik.addUserToGroup(existing.pk, tenant.authentikGroupId)
await this.userModel
.findOneAndUpdate(
{ authentikSubjectId: existing.uid },
{
// tenantRoles via $set (not $setOnInsert) so an EXISTING user
// invited as admin to this tenant actually becomes admin here,
// without disturbing their role in other tenants. The global
// `role` stays $setOnInsert as the legacy/first-tenant fallback.
$set: { email: existing.email, [`tenantRoles.${tenant._id}`]: 'admin' },
$setOnInsert: {
name: existing.name || dto.name,
role: 'admin',
active: true,
platformAdmin: false,
},
$addToSet: { tenantIds: tenant._id },
},
{ upsert: true, new: true, runValidators: true },
)
.exec()
void this.audit.record(
{
action: 'tenant.admin_attached',
resourceType: 'user',
resourceId: existing.uid,
resourceName: existing.email,
tenantSlug: tenant.slug,
metadata: { name: existing.name || dto.name, role: 'admin' },
},
actor,
)
return { subject: existing.uid, userId: String(existing.pk), attached: true }
}
// ── Create new Authentik user ──────────────────────────────────────
const created = await this.authentik.createUser({
username: dto.email,
email: dto.email,
name: dto.name,
groupPks: [tenant.authentikGroupId],
attributes: {
tenantSlug: tenant.slug,
invitedBy: actor?.email,
invitedAt: new Date().toISOString(),
},
})
await this.userModel
.findOneAndUpdate(
{ authentikSubjectId: created.uid },
{
// Per-tenant admin role via $set (see attach branch above).
$set: { email: dto.email, name: dto.name, [`tenantRoles.${tenant._id}`]: 'admin' },
$setOnInsert: { role: 'admin', active: true, platformAdmin: false },
$addToSet: { tenantIds: tenant._id },
},
{ upsert: true, new: true, runValidators: true },
)
.exec()
let link: string | undefined
let tempPassword: string | undefined
link = await this.authentik.recoveryLink(created.pk)
if (!link) {
tempPassword = generateTempPassword()
await this.authentik.setInitialPassword(created.pk, tempPassword)
await this.authentik.markPasswordExpired(created.pk)
}
void this.audit.record(
{
action: 'tenant.admin_invited',
resourceType: 'user',
resourceId: created.uid,
resourceName: dto.email,
tenantSlug: tenant.slug,
metadata: {
name: dto.name,
role: 'admin',
handoff: link ? 'recovery-link' : 'temp-password',
},
},
actor,
)
return { subject: created.uid, userId: String(created.pk), link, tempPassword }
}
// Recent audit events scoped to a partner — events whose partnerSlug
// matches OR whose tenantSlug belongs to one of the partner's tenants.
// Used by the partner dashboard's Activity card.
async partnerActivity(
partnerId: Types.ObjectId,
partnerSlug: string,
opts: { limit?: number; before?: Date } = {},
): Promise<AuditEventDocument[]> {
const tenants = await this.tenantModel.find({ partnerId }, { slug: 1 }).exec()
return this.audit.listForPartner({
partnerSlug,
tenantSlugs: tenants.map((t) => t.slug),
limit: opts.limit,
before: opts.before,
})
}
// MRR aggregation for a partner, grouped by currency. Each subscription
// contributes to the bucket for its currency — no FX conversion, since
// the partner gets paid in whatever currency the customer was billed in.
//
// Per-seat amount is read from Subscription.perSeatAmount (the snapshot
// taken at provision time) instead of the live Price doc, so historical
// MRR is stable even if the operator edits the catalog later.
async partnerMrr(partnerId: Types.ObjectId): Promise<{
totals: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
breakdown: Array<{
tenantId: string
tenantSlug: string
tenantName: string
plan: 'mvp' | 'pro' | 'enterprise'
cycle: 'monthly' | 'quarterly' | 'yearly'
currency: 'DKK' | 'EUR' | 'USD'
seats: number
monthlyMinor: number
custom: boolean // true when the sub has no priced amount (Enterprise / pre-catalog)
}>
}> {
const tenants = await this.tenantModel.find({ partnerId }).exec()
if (tenants.length === 0) {
return { totals: [], breakdown: [] }
}
const tenantIds = tenants.map((t) => t._id)
const subs = await this.subModel.find({ tenantId: { $in: tenantIds }, status: 'active' }).exec()
const tenantById = new Map(tenants.map((t) => [String(t._id), t]))
const breakdown = subs.map((s) => {
const tenant = tenantById.get(String(s.tenantId))!
const monthlyMinor = normalizeToMonthly(s.perSeatAmount * s.seats, s.cycle)
return {
tenantId: String(tenant._id),
tenantSlug: tenant.slug,
tenantName: tenant.name,
plan: s.plan,
cycle: s.cycle,
currency: s.currency,
seats: s.seats,
monthlyMinor,
custom: s.perSeatAmount === 0,
}
})
// Group by currency. Use a Map to preserve insertion-on-first-seen
// ordering — but emit totals in a stable order regardless: DKK, EUR, USD.
const byCurrency = new Map<string, number>()
for (const row of breakdown) {
byCurrency.set(row.currency, (byCurrency.get(row.currency) ?? 0) + row.monthlyMinor)
}
const ORDER: Array<'DKK' | 'EUR' | 'USD'> = ['DKK', 'EUR', 'USD']
const totals = ORDER.filter((c) => byCurrency.has(c)).map((c) => ({
currency: c,
monthlyMinor: byCurrency.get(c)!,
}))
return { totals, breakdown }
}
// Analytics for the partner reports page. Reuses listPartnerTenants (health)
// and partnerMrr (revenue) plus a signup-cohort pass over the tenants. Churn
// retention is APPROXIMATE for v1 — "retained" = currently active, since we
// don't track cancellation dates until billing (Phase 3) lands.
async partnerReports(partnerId: Types.ObjectId): Promise<{
health: { healthy: number; watch: number; atRisk: number; total: number; avgScore: number }
revenueByPlan: Array<{
plan: 'mvp' | 'pro' | 'enterprise'
currency: 'DKK' | 'EUR' | 'USD'
monthlyMinor: number
count: number
}>
topCustomers: Array<{
tenantId: string
tenantName: string
currency: 'DKK' | 'EUR' | 'USD'
monthlyMinor: number
custom: boolean
}>
churnCohorts: Array<{ month: string; total: number; retained: number; retentionPct: number }>
totals: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
marginPct: number
}> {
const [tenants, mrr, partner] = await Promise.all([
this.listPartnerTenants(partnerId),
this.partnerMrr(partnerId),
this.partnerModel.findById(partnerId, { marginPct: 1 }).exec(),
])
// Health cohorts (exclude soft-deleted from the cohort).
const live = tenants.filter((t) => t.status !== 'deleted')
const health = { healthy: 0, watch: 0, atRisk: 0, total: live.length, avgScore: 0 }
let scoreSum = 0
for (const t of live) {
scoreSum += t.healthScore
if (t.healthBand === 'healthy') health.healthy++
else if (t.healthBand === 'watch') health.watch++
else health.atRisk++
}
health.avgScore = live.length ? Math.round(scoreSum / live.length) : 0
// Revenue grouped by plan × currency.
const planMap = new Map<
string,
{ plan: 'mvp' | 'pro' | 'enterprise'; currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number; count: number }
>()
for (const row of mrr.breakdown) {
const key = `${row.plan}|${row.currency}`
const e = planMap.get(key) ?? { plan: row.plan, currency: row.currency, monthlyMinor: 0, count: 0 }
e.monthlyMinor += row.monthlyMinor
e.count++
planMap.set(key, e)
}
const revenueByPlan = [...planMap.values()].sort((a, b) => b.monthlyMinor - a.monthlyMinor)
// Top customers by MRR.
const topCustomers = [...mrr.breakdown]
.sort((a, b) => b.monthlyMinor - a.monthlyMinor)
.slice(0, 10)
.map((r) => ({
tenantId: r.tenantId,
tenantName: r.tenantName,
currency: r.currency,
monthlyMinor: r.monthlyMinor,
custom: r.custom,
}))
// Signup cohorts (approximate retention).
const cohortMap = new Map<string, { total: number; retained: number }>()
for (const t of tenants) {
const created = (t as { createdAt?: string | Date }).createdAt
if (!created) continue
const month = new Date(created).toISOString().slice(0, 7) // YYYY-MM
const e = cohortMap.get(month) ?? { total: 0, retained: 0 }
e.total++
if (t.status === 'active') e.retained++
cohortMap.set(month, e)
}
const churnCohorts = [...cohortMap.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([month, v]) => ({
month,
total: v.total,
retained: v.retained,
retentionPct: v.total ? Math.round((v.retained / v.total) * 100) : 0,
}))
return {
health,
revenueByPlan,
topCustomers,
churnCohorts,
totals: mrr.totals,
marginPct: partner?.marginPct ?? 0,
}
}
// Platform-wide analytics for the operator reports page (all tenants/subs,
// not partner-scoped). Status distribution + revenue by plan + top tenants +
// signup growth.
async platformReports(): Promise<{
tenants: { active: number; pending: number; suspended: number; deleted: number; total: number }
revenueByPlan: Array<{
plan: 'mvp' | 'pro' | 'enterprise'
currency: 'DKK' | 'EUR' | 'USD'
monthlyMinor: number
count: number
}>
totals: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
topTenants: Array<{ tenantId: string; tenantName: string; currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
growth: Array<{ month: string; count: number }>
}> {
const [tenants, subs] = await Promise.all([
this.tenantModel.find().exec(),
this.subModel.find({ status: 'active' }).exec(),
])
const tenantById = new Map(tenants.map((t) => [String(t._id), t]))
const statusCounts = { active: 0, pending: 0, suspended: 0, deleted: 0, total: tenants.length }
for (const t of tenants) statusCounts[t.status] += 1
const breakdown = subs.map((s) => {
const t = tenantById.get(String(s.tenantId))
return {
tenantId: String(s.tenantId),
tenantName: t?.name ?? String(s.tenantId),
plan: s.plan,
currency: s.currency,
monthlyMinor: normalizeToMonthly(s.perSeatAmount * s.seats, s.cycle),
}
})
const planMap = new Map<
string,
{ plan: 'mvp' | 'pro' | 'enterprise'; currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number; count: number }
>()
const byCurrency = new Map<'DKK' | 'EUR' | 'USD', number>()
for (const r of breakdown) {
const key = `${r.plan}|${r.currency}`
const e = planMap.get(key) ?? { plan: r.plan, currency: r.currency, monthlyMinor: 0, count: 0 }
e.monthlyMinor += r.monthlyMinor
e.count++
planMap.set(key, e)
byCurrency.set(r.currency, (byCurrency.get(r.currency) ?? 0) + r.monthlyMinor)
}
const ORDER: Array<'DKK' | 'EUR' | 'USD'> = ['DKK', 'EUR', 'USD']
const totals = ORDER.filter((c) => byCurrency.has(c)).map((c) => ({ currency: c, monthlyMinor: byCurrency.get(c)! }))
const topTenants = [...breakdown].sort((a, b) => b.monthlyMinor - a.monthlyMinor).slice(0, 10)
const growthMap = new Map<string, number>()
for (const t of tenants) {
const created = (t as { createdAt?: string | Date }).createdAt
if (!created) continue
const month = new Date(created).toISOString().slice(0, 7)
growthMap.set(month, (growthMap.get(month) ?? 0) + 1)
}
const growth = [...growthMap.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([month, count]) => ({ month, count }))
return {
tenants: statusCounts,
revenueByPlan: [...planMap.values()].sort((a, b) => b.monthlyMinor - a.monthlyMinor),
totals,
topTenants,
growth,
}
}
// Resolve + cache the dezky-platform-admins group ID. The group is created
// by Authentik bootstrap so it's reliably present; ensureGroup is
// idempotent so the worst case is a no-op extra API call on cold start.
private async resolvePlatformAdminGroupId(): Promise<string> {
if (this.platformAdminGroupId) return this.platformAdminGroupId
const group = await this.authentik.ensureGroup(this.platformAdminGroup, {
role: 'platform-admin',
})
this.platformAdminGroupId = group.pk
return group.pk
}
// Same caching pattern for the partner-staff group. Created lazily the
// first time a partner invite runs — by then Authentik is past bootstrap
// and ensureGroup will either find or create it.
private async resolvePartnerStaffGroupId(): Promise<string> {
if (this.partnerStaffGroupId) return this.partnerStaffGroupId
const group = await this.authentik.ensureGroup(PARTNER_STAFF_GROUP, {
role: 'partner-staff',
})
this.partnerStaffGroupId = group.pk
return group.pk
}
}
// Generates a 16-character random password with mixed character classes.
// Authentik's default password policy requires length + complexity; this
// generator clears every reasonable policy. The new user changes it on
// first login.
function generateTempPassword(): string {
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ' // omit I + O (visually confusable)
const lower = 'abcdefghijkmnpqrstuvwxyz' // omit l (confusable with 1)
const digit = '23456789' // omit 0 + 1
const symbol = '!@#$%&*+-='
const all = upper + lower + digit + symbol
// Pick at least one from each class; fill the rest from `all`; shuffle.
const out: string[] = [
upper[Math.floor(Math.random() * upper.length)],
lower[Math.floor(Math.random() * lower.length)],
digit[Math.floor(Math.random() * digit.length)],
symbol[Math.floor(Math.random() * symbol.length)],
]
while (out.length < 16) {
out.push(all[Math.floor(Math.random() * all.length)])
}
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[out[i], out[j]] = [out[j], out[i]]
}
return out.join('')
}
// Convert a per-cycle subscription total (in minor units) to its monthly
// equivalent. Used by MRR aggregation. Integer math throughout — final
// rounding happens once at the partner-total level so per-row drift can't
// accumulate visibly.
function normalizeToMonthly(perCycleMinor: number, cycle: 'monthly' | 'quarterly' | 'yearly'): number {
if (cycle === 'monthly') return perCycleMinor
if (cycle === 'quarterly') return Math.round(perCycleMinor / 3)
return Math.round(perCycleMinor / 12)
}