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,18 @@
// Update a member's directory profile + in-tenant role. Proxies
// PATCH /tenants/:slug/users/:userId and returns the fresh user doc (with tenantRole).
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
const slug = getRouterParam(event, 'slug')
const userId = getRouterParam(event, 'userId')
const body = await readBody(event)
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/users/${userId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${accessToken}` },
body,
})
})
@@ -0,0 +1,18 @@
// Remove a mailbox alias. Proxies DELETE /tenants/:slug/users/:userId/aliases
// (address in body) and returns the refreshed alias view.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
const slug = getRouterParam(event, 'slug')
const userId = getRouterParam(event, 'userId')
const body = await readBody(event)
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/users/${userId}/aliases`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
body,
})
})
@@ -0,0 +1,15 @@
// List a member's mailbox aliases. Proxies GET /tenants/:slug/users/:userId/aliases
// → { hasMailbox, primary, aliases }.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
const slug = getRouterParam(event, 'slug')
const userId = getRouterParam(event, 'userId')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/users/${userId}/aliases`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
})
@@ -0,0 +1,18 @@
// Add a mailbox alias. Proxies POST /tenants/:slug/users/:userId/aliases and
// returns the refreshed alias view.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
const slug = getRouterParam(event, 'slug')
const userId = getRouterParam(event, 'userId')
const body = await readBody(event)
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/users/${userId}/aliases`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
body,
})
})
@@ -0,0 +1,18 @@
// Provision a mailbox for a member who has none. Proxies
// POST /tenants/:slug/users/:userId/mailbox → { email, tempPassword, user }.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
const slug = getRouterParam(event, 'slug')
const userId = getRouterParam(event, 'userId')
const body = await readBody(event)
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/users/${userId}/mailbox`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
body,
})
})
@@ -0,0 +1,16 @@
// Transfer workspace ownership to this member. Proxies
// POST /tenants/:slug/users/:userId/make-owner → { newOwner, previousOwners }.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
const slug = getRouterParam(event, 'slug')
const userId = getRouterParam(event, 'userId')
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/users/${userId}/make-owner`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
})
})
@@ -0,0 +1,18 @@
// Change a mailbox-less member's primary email. Proxies
// PATCH /tenants/:slug/users/:userId/primary-email and returns the fresh user doc.
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
export default defineEventHandler(async (event) => {
const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken
if (!accessToken) throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
const slug = getRouterParam(event, 'slug')
const userId = getRouterParam(event, 'userId')
const body = await readBody(event)
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
return $fetch(`${base}/tenants/${slug}/users/${userId}/primary-email`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${accessToken}` },
body,
})
})