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

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

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

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

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

Portal: Nuxt proxies for the new endpoints + extended TenantUserDoc.
This commit is contained in:
Ronni Baslund
2026-06-07 10:34:53 +02:00
parent 90e8a22de4
commit 98e49bfe34
18 changed files with 1444 additions and 12 deletions
@@ -94,6 +94,30 @@ export class AuthentikClient {
this.logger.log(`Set Authentik user ${userPk} is_active=${active}`)
}
// Patch a user's identity / profile fields. `username` + `email` are kept
// aligned by callers (our convention). `attributesMerge`, when given, is
// read-modify-written so we don't clobber unrelated attributes — Authentik
// PATCH replaces nested objects wholesale, so a naive `attributes: {...}`
// would wipe e.g. the passwordExpired flag. No-op if nothing to change.
async updateUser(
userPk: number,
patch: { username?: string; email?: string; name?: string; attributesMerge?: Record<string, unknown> },
): Promise<void> {
const body: Record<string, unknown> = {}
if (patch.username !== undefined) body.username = patch.username
if (patch.email !== undefined) body.email = patch.email
if (patch.name !== undefined) body.name = patch.name
if (patch.attributesMerge && Object.keys(patch.attributesMerge).length > 0) {
const user = await this.request<AuthentikUser & { attributes?: Record<string, unknown> }>(
`/core/users/${userPk}/`,
)
body.attributes = { ...(user.attributes ?? {}), ...patch.attributesMerge }
}
if (Object.keys(body).length === 0) return
await this.request(`/core/users/${userPk}/`, { method: 'PATCH', body: JSON.stringify(body) })
this.logger.log(`Updated Authentik user ${userPk} (${Object.keys(body).join(', ')})`)
}
// Force-logout: terminate the user's active sessions so they must sign in
// again. Returns how many were terminated. We pass the `?user=` filter AND
// re-filter client-side on the session's `user` pk — Authentik's endpoint
@@ -240,6 +240,19 @@ export class StalwartClient {
}
}
// Update a mailbox's display name (stored as the account `description`, which
// is what we set as `fullName` at create time). Best-effort cosmetic sync so
// the mailbox's display name tracks the directory profile.
async setMailboxName(accountId: string, fullName: string): Promise<void> {
const resp = await this.jmap([
['x:Account/set', { update: { [accountId]: { description: fullName } } }, '0'],
])
const notUpdated = resp[0][1].notUpdated?.[accountId]
if (notUpdated) {
throw new Error(`Stalwart mailbox name update failed (id=${accountId}): ${JSON.stringify(notUpdated)}`)
}
}
// Set a new mailbox password (replaces the primary credential).
async setMailboxPassword(accountId: string, password: string): Promise<void> {
const resp = await this.jmap([