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

Rebuild the /admin/users detail drawer from a read-only profile into an
editable, Office 365-style panel with four sections:

- Username & mail: read-only primary for mailbox users; editable sign-in
  (Authentik-only) for mailbox-less identities; "Create mailbox" provisions
  a Stalwart inbox for an external-login admin
- Aliases: list/add/remove mailbox aliases (Stalwart), domain-scoped
- Role: member/admin toggle with a primary-account lock (owner, mailbox-less
  bootstrap admin, self) and a last-admin guard
- Contact information: display name, first/last name, phone, alternative
  email — mirrored best-effort to Authentik attributes + mailbox name

Ownership transfer: "Make owner" (row menu + drawer) plus an owner-side
"Transfer ownership" picker, gated to tenant admins / platform admins so a
departed owner can be replaced; promotes the target and demotes the prior
owner to admin.

Backend (platform-api): contact fields on User; AuthentikClient.updateUser;
StalwartClient.setMailboxName; UsersService updateTenantMember,
changeMemberPrimaryEmail, list/add/removeMemberAlias, createMailboxForMember,
transferOwnership; new DTOs and tenant-member routes. All mutations audited.

Portal: Nuxt proxies for the new endpoints + extended TenantUserDoc.
This commit is contained in:
Ronni Baslund
2026-06-07 10:34:53 +02:00
parent 90e8a22de4
commit 98e49bfe34
18 changed files with 1444 additions and 12 deletions
@@ -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'
}