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
+594 -11
View File
@@ -9,11 +9,11 @@
// user-write endpoints are operator-only today, so a customer admin can't
// commit them yet. The data shown is real; the writes are not wired.
import type { TenantUserDoc } from '~/types/workspace'
import type { MemberAliases, TenantUserDoc } from '~/types/workspace'
const toast = useToast()
const { fetchMe } = useMe()
const { profile, fetchMe, isPlatformAdmin, isTenantAdminOf } = useMe()
await fetchMe()
const { tenant } = useTenant()
const slug = computed(() => tenant.value?.slug ?? '')
@@ -48,6 +48,9 @@ const inviteDomain = computed(() => inviteForm.domain || primaryDomain.value?.do
const userStatus = (u: TenantUserDoc): 'active' | 'suspended' => (u.active === false ? 'suspended' : 'active')
const roleLabel = (r: string) => r.charAt(0).toUpperCase() + r.slice(1)
// Role for THIS workspace — prefer the per-tenant role the API resolves, fall
// back to the legacy global role for any consumer that predates tenantRole.
const effectiveRole = (u: TenantUserDoc): 'owner' | 'admin' | 'member' => u.tenantRole ?? u.role
const filteredUsers = computed(() =>
(users.value ?? []).filter((u) => {
@@ -163,12 +166,14 @@ function rowAction(u: TenantUserDoc, id: string) {
if (id === 'open') openUser.value = u
else if (id === 'reset') resetTarget.value = u
else if (id === 'force') forceLogoutUser(u)
else if (id === 'make-owner') makeOwnerTarget.value = u
else if (id === 'suspend') suspendTarget.value = u
else if (id === 'resume') resumeUser(u)
else if (id === 'delete') removeTarget.value = u
}
// Menu varies per user: a suspended user shows Resume instead of Suspend.
// Menu varies per user: a suspended user shows Resume instead of Suspend, and
// admins get "Make owner" on anyone who isn't already the owner.
function rowItems(u: TenantUserDoc) {
const suspended = u.active === false
return [
@@ -176,6 +181,9 @@ function rowItems(u: TenantUserDoc) {
{ id: 'reset', label: 'Send password reset', icon: 'key' as const },
{ id: 'force', label: 'Force logout', icon: 'logout' as const },
{ id: 'sep1', separator: true },
...(canManageOwnership.value && effectiveRole(u) !== 'owner'
? [{ id: 'make-owner', label: 'Make owner', icon: 'refresh' as const }]
: []),
suspended
? { id: 'resume', label: 'Resume user', icon: 'check' as const }
: { id: 'suspend', label: 'Suspend user', icon: 'shield' as const, danger: true },
@@ -270,6 +278,294 @@ async function confirmReset() {
resetBusy.value = false
}
}
// ── User detail panel (editable, O365-style) ────────────────────────────────
// The drawer edits four sections — username & mail, aliases, role, contact info.
// State seeds from the open user; aliases load lazily from the mailbox. Every
// save echoes the fresh user doc back into openUser so the panel + row stay in
// sync without a full reload.
const detailForm = reactive({
name: '', firstName: '', lastName: '', phone: '', alternativeEmail: '',
role: 'member' as 'admin' | 'member',
})
const detailBusy = reactive({ contact: false, role: false, primary: false, alias: false })
const primaryEdit = ref(false)
const primaryDraft = ref('')
const aliasData = ref<MemberAliases>({ hasMailbox: false, primary: '', aliases: [] })
const aliasLoading = ref(false)
const aliasForm = reactive({ localPart: '', domain: '' })
// A member with a Stalwart mailbox: primary is their inbox (read-only here) and
// aliases are available. Without one (SSO-only / bootstrap admin) the primary
// is editable and there are no aliases.
const hasMailbox = computed(() => !!(openUser.value?.stalwartAccountId || openUser.value?.mailboxAddress))
const isOwner = computed(() => effectiveRole(openUser.value ?? ({} as TenantUserDoc)) === 'owner')
// You can't change your own role (matches the self-guards on suspend/remove).
const isSelf = computed(() => !!openUser.value && openUser.value._id === profile.value?._id)
// The workspace's primary account is its owner and the mailbox-less bootstrap
// admin (invited on a private email to set the workspace up) — both have an
// immutable role. Contact info stays editable; only the role section locks.
const roleLocked = computed(() => isOwner.value || !hasMailbox.value || isSelf.value)
const roleLockReason = computed(() =>
isOwner.value
? 'The owner role cant be changed here.'
: !hasMailbox.value
? 'This is the workspaces primary account — its role cant be changed.'
: 'You cant change your own role.',
)
const provisionedDomains = computed(() => (domains.value ?? []).filter((d) => d.stalwartProvisioned))
const roleChanged = computed(
() => !!openUser.value && detailForm.role !== effectiveRole(openUser.value),
)
watch(openUser, (u) => {
if (!u) return
detailForm.name = u.name ?? ''
detailForm.firstName = u.firstName ?? ''
detailForm.lastName = u.lastName ?? ''
detailForm.phone = u.phone ?? ''
detailForm.alternativeEmail = u.alternativeEmail ?? ''
const r = effectiveRole(u)
detailForm.role = r === 'admin' ? 'admin' : 'member'
primaryEdit.value = false
primaryDraft.value = u.email
aliasData.value = { hasMailbox: false, primary: u.email, aliases: [] }
aliasForm.localPart = ''
aliasForm.domain = ''
void loadAliases(u)
})
async function loadAliases(u: TenantUserDoc) {
aliasLoading.value = true
try {
aliasData.value = await request<MemberAliases>(`/api/tenants/${slug.value}/users/${u._id}/aliases`)
const firstDomain = provisionedDomains.value[0]
if (!aliasForm.domain && firstDomain) {
aliasForm.domain = firstDomain.domain
}
} catch {
// Non-fatal — the rest of the panel still renders from the user doc.
} finally {
aliasLoading.value = false
}
}
async function saveContact() {
const u = openUser.value
if (!u) return
detailBusy.contact = true
try {
const updated = await request<TenantUserDoc>(`/api/tenants/${slug.value}/users/${u._id}`, {
method: 'PATCH',
body: {
name: detailForm.name.trim(),
firstName: detailForm.firstName.trim(),
lastName: detailForm.lastName.trim(),
phone: detailForm.phone.trim(),
alternativeEmail: detailForm.alternativeEmail.trim(),
},
})
openUser.value = updated
await refreshNuxtData('admin-users')
toast.ok('Profile updated', updated.email)
} catch (err) {
toastErr(err, 'Could not update profile')
} finally {
detailBusy.contact = false
}
}
async function saveRole() {
const u = openUser.value
if (!u || !roleChanged.value) return
detailBusy.role = true
try {
const updated = await request<TenantUserDoc>(`/api/tenants/${slug.value}/users/${u._id}`, {
method: 'PATCH',
body: { role: detailForm.role },
})
openUser.value = updated
await refreshNuxtData('admin-users')
toast.ok('Role updated', `${updated.name} is now ${detailForm.role}`)
} catch (err) {
toastErr(err, 'Could not update role')
} finally {
detailBusy.role = false
}
}
async function savePrimary() {
const u = openUser.value
if (!u) return
const email = primaryDraft.value.trim().toLowerCase()
if (!email || email === u.email.toLowerCase()) {
primaryEdit.value = false
return
}
detailBusy.primary = true
try {
const updated = await request<TenantUserDoc>(
`/api/tenants/${slug.value}/users/${u._id}/primary-email`,
{ method: 'PATCH', body: { email } },
)
openUser.value = updated
primaryEdit.value = false
await refreshNuxtData('admin-users')
toast.ok('Primary email changed', updated.email)
} catch (err) {
toastErr(err, 'Could not change primary email')
} finally {
detailBusy.primary = false
}
}
async function addAlias() {
const u = openUser.value
if (!u || !aliasForm.localPart.trim() || !aliasForm.domain) return
detailBusy.alias = true
try {
aliasData.value = await request<MemberAliases>(`/api/tenants/${slug.value}/users/${u._id}/aliases`, {
method: 'POST',
body: { localPart: aliasForm.localPart.trim(), domain: aliasForm.domain },
})
aliasForm.localPart = ''
toast.ok('Alias added')
} catch (err) {
toastErr(err, 'Could not add alias')
} finally {
detailBusy.alias = false
}
}
async function removeAlias(address: string) {
const u = openUser.value
if (!u) return
try {
aliasData.value = await request<MemberAliases>(`/api/tenants/${slug.value}/users/${u._id}/aliases`, {
method: 'DELETE',
body: { address },
})
toast.ok('Alias removed', address)
} catch (err) {
toastErr(err, 'Could not remove alias')
}
}
// Create a mailbox for a member who signs in with an external email and has no
// inbox yet — gives them a workspace mailbox (and unlocks aliases). Their
// sign-in address is unchanged; we hand back a one-time mailbox password.
const createMailboxOpen = ref(false)
const mailboxBusy = ref(false)
const mailboxForm = reactive({ localPart: '', domain: '' })
const mailboxResult = ref<{ email: string; tempPassword: string } | null>(null)
const mailboxDomain = computed(
() => mailboxForm.domain || provisionedDomains.value[0]?.domain || '',
)
function openCreateMailbox() {
const u = openUser.value
if (!u) return
// Seed the local-part from their sign-in address (e.g. ronni@gmail.com → ronni).
mailboxForm.localPart = (u.email.split('@')[0] ?? '').toLowerCase()
mailboxForm.domain = provisionedDomains.value[0]?.domain ?? ''
createMailboxOpen.value = true
}
// ── Transfer ownership ──────────────────────────────────────────────────────
// Available to admins / platform admins (not only the owner) so a departed
// owner can be replaced. The new owner is promoted; the previous owner is
// demoted to admin.
const transferOpen = ref(false)
const transferTarget = ref('')
const transferBusy = ref(false)
// Only the active tenant's admins / platform admins can transfer ownership.
const canManageOwnership = computed(
() => isPlatformAdmin.value || isTenantAdminOf(tenant.value?._id ?? ''),
)
// Candidates: every member who isn't already the owner.
const ownerCandidates = computed(() =>
(users.value ?? []).filter((u) => effectiveRole(u) !== 'owner'),
)
function openTransfer() {
transferTarget.value = ''
transferOpen.value = true
}
async function confirmTransfer() {
const u = openUser.value
if (!transferTarget.value) return
transferBusy.value = true
try {
const res = await request<{ newOwner: TenantUserDoc; previousOwners: TenantUserDoc[] }>(
`/api/tenants/${slug.value}/users/${transferTarget.value}/make-owner`,
{ method: 'POST' },
)
transferOpen.value = false
await refreshNuxtData('admin-users')
// The open drawer was the previous owner — reflect their demotion to admin.
const demoted = u ? res.previousOwners.find((p) => p._id === u._id) : undefined
openUser.value = demoted ?? null
toast.ok('Ownership transferred', `${res.newOwner.name} is now the owner`)
} catch (err) {
toastErr(err, 'Could not transfer ownership')
} finally {
transferBusy.value = false
}
}
// Promote a specific member to owner (from a row menu or their drawer) — the
// discoverable path that works whether or not an owner currently exists. Same
// endpoint as the picker; the previous owner (if any) is demoted to admin.
const makeOwnerTarget = ref<TenantUserDoc | null>(null)
const makeOwnerBusy = ref(false)
async function confirmMakeOwner() {
const target = makeOwnerTarget.value
if (!target) return
makeOwnerBusy.value = true
try {
const res = await request<{ newOwner: TenantUserDoc; previousOwners: TenantUserDoc[] }>(
`/api/tenants/${slug.value}/users/${target._id}/make-owner`,
{ method: 'POST' },
)
makeOwnerTarget.value = null
await refreshNuxtData('admin-users')
// Keep an open drawer in sync: it's either the new owner or a demoted owner.
if (openUser.value) {
if (openUser.value._id === res.newOwner._id) openUser.value = res.newOwner
else {
const demoted = res.previousOwners.find((p) => p._id === openUser.value!._id)
if (demoted) openUser.value = demoted
}
}
toast.ok('Ownership transferred', `${res.newOwner.name} is now the owner`)
} catch (err) {
toastErr(err, 'Could not transfer ownership')
} finally {
makeOwnerBusy.value = false
}
}
async function submitCreateMailbox() {
const u = openUser.value
if (!u || !mailboxForm.localPart.trim() || !mailboxDomain.value) return
mailboxBusy.value = true
try {
const res = await request<{ email: string; tempPassword: string; user: TenantUserDoc }>(
`/api/tenants/${slug.value}/users/${u._id}/mailbox`,
{ method: 'POST', body: { localPart: mailboxForm.localPart.trim(), domain: mailboxForm.domain || undefined } },
)
openUser.value = res.user // flips hasMailbox + re-triggers the panel watcher (reloads aliases)
createMailboxOpen.value = false
mailboxResult.value = { email: res.email, tempPassword: res.tempPassword }
await refreshNuxtData('admin-users')
toast.ok('Mailbox created', res.email)
} catch (err) {
toastErr(err, 'Could not create mailbox')
} finally {
mailboxBusy.value = false
}
}
</script>
<template>
@@ -352,7 +648,7 @@ async function confirmReset() {
</div>
</div>
</td>
<td><Badge :tone="u.role === 'owner' ? 'invert' : 'neutral'">{{ roleLabel(u.role) }}</Badge></td>
<td><Badge :tone="effectiveRole(u) === 'owner' ? 'invert' : 'neutral'">{{ roleLabel(effectiveRole(u)) }}</Badge></td>
<td><Badge :tone="statusTone(userStatus(u))" dot>{{ userStatus(u) }}</Badge></td>
<td><Mono dim>{{ lastSeen(u.lastLoginAt) }}</Mono></td>
<td class="right" @click.stop>
@@ -406,16 +702,142 @@ async function confirmReset() {
<Mono dim>{{ openUser.email }}</Mono>
<div class="ud-badges">
<Badge :tone="statusTone(userStatus(openUser))" dot>{{ userStatus(openUser) }}</Badge>
<Badge tone="neutral">{{ roleLabel(openUser.role) }}</Badge>
<Badge :tone="effectiveRole(openUser) === 'owner' ? 'invert' : 'neutral'">{{ roleLabel(effectiveRole(openUser)) }}</Badge>
</div>
</div>
</div>
<div class="ud-body">
<dl class="def">
<div><dt>Full name</dt><dd>{{ openUser.name }}</dd></div>
<div><dt>Email</dt><dd>{{ openUser.email }}</dd></div>
<div><dt>Role</dt><dd>{{ roleLabel(openUser.role) }}</dd></div>
<div><dt>Status</dt><dd>{{ userStatus(openUser) }}</dd></div>
<!-- 1 · Username & mail -->
<section class="ud-sec">
<div class="ud-sec-h"><UiIcon name="mail" :size="14" stroke="var(--text-mute)" /><span>Username &amp; mail</span></div>
<div class="ud-sec-body">
<!-- Sign-in address (editable only for mailbox-less identities) -->
<div class="kv">
<span class="kv-k">Sign-in address</span>
<template v-if="hasMailbox">
<Mono class="kv-v">{{ openUser.email }}</Mono>
</template>
<template v-else>
<div v-if="!primaryEdit" class="kv-inline">
<Mono class="kv-v">{{ openUser.email }}</Mono>
<button class="link-btn" @click="primaryEdit = true; primaryDraft = openUser.email">Edit</button>
</div>
<div v-else class="kv-edit">
<input class="input" type="email" v-model="primaryDraft" placeholder="name@example.com" />
<UiButton size="sm" variant="primary" :disabled="detailBusy.primary" @click="savePrimary">{{ detailBusy.primary ? 'Saving' : 'Save' }}</UiButton>
<UiButton size="sm" variant="ghost" @click="primaryEdit = false">Cancel</UiButton>
</div>
</template>
</div>
<!-- Mailbox: the address itself, or a CTA to create one -->
<div class="kv">
<span class="kv-k">Mailbox</span>
<template v-if="openUser.mailboxAddress">
<Mono class="kv-v">{{ openUser.mailboxAddress }}</Mono>
</template>
<div v-else class="kv-inline">
<span class="kv-v muted-v">No mailbox — sign-in only</span>
<UiButton size="sm" variant="secondary" @click="openCreateMailbox">
<template #leading><UiIcon name="plus" :size="13" /></template>
Create mailbox
</UiButton>
</div>
</div>
<p class="ud-hint">
<template v-if="hasMailbox">This is the members workspace mailbox. To add another address that delivers to the same inbox, add an alias below.</template>
<template v-else>This member signs in with an external address and has no inbox. Create a mailbox on your domain to give them one — and to enable aliases.</template>
</p>
</div>
</section>
<!-- 2 · Aliases -->
<section class="ud-sec">
<div class="ud-sec-h"><UiIcon name="copy" :size="14" stroke="var(--text-mute)" /><span>Aliases</span></div>
<div class="ud-sec-body">
<template v-if="hasMailbox">
<div v-if="aliasLoading" class="ud-hint">Loading…</div>
<ul v-else-if="aliasData.aliases.length" class="alias-list">
<li v-for="a in aliasData.aliases" :key="a">
<Mono>{{ a }}</Mono>
<button class="icon-btn" title="Remove alias" @click="removeAlias(a)"><UiIcon name="trash" :size="13" /></button>
</li>
</ul>
<div v-else class="ud-hint">No aliases yet.</div>
<div class="alias-add">
<input class="input" v-model="aliasForm.localPart" placeholder="alias" />
<span class="at">@</span>
<select class="input" v-model="aliasForm.domain">
<option v-for="d in provisionedDomains" :key="d.id" :value="d.domain">{{ d.domain }}</option>
</select>
<UiButton size="sm" variant="secondary" :disabled="detailBusy.alias || !aliasForm.localPart.trim() || !aliasForm.domain" @click="addAlias">
{{ detailBusy.alias ? 'Adding' : 'Add' }}
</UiButton>
</div>
</template>
<p v-else class="ud-hint">Aliases need a mailbox. This member uses SSO sign-in only, so theres no inbox to route extra addresses to.</p>
</div>
</section>
<!-- 3 · Role -->
<section class="ud-sec">
<div class="ud-sec-h"><UiIcon name="shield" :size="14" stroke="var(--text-mute)" /><span>Role</span></div>
<div class="ud-sec-body">
<template v-if="roleLocked">
<div class="kv"><span class="kv-k">Workspace role</span><Badge :tone="effectiveRole(openUser) === 'owner' ? 'invert' : 'neutral'">{{ roleLabel(effectiveRole(openUser)) }}</Badge></div>
<p class="ud-hint">{{ roleLockReason }}</p>
</template>
<template v-else>
<div class="role-pick">
<button type="button" :class="{ active: detailForm.role === 'member' }" @click="detailForm.role = 'member'">Member</button>
<button type="button" :class="{ active: detailForm.role === 'admin' }" @click="detailForm.role = 'admin'">Admin</button>
</div>
<p class="ud-hint">{{ detailForm.role === 'admin' ? 'Can manage users, billing, and workspace settings.' : 'Standard access to apps no admin controls.' }}</p>
<div class="ud-actions">
<UiButton size="sm" variant="primary" :disabled="detailBusy.role || !roleChanged" @click="saveRole">{{ detailBusy.role ? 'Saving' : 'Update role' }}</UiButton>
</div>
</template>
<!-- Ownership: transfer away (owner) or claim/assign (anyone else) -->
<div v-if="canManageOwnership" class="owner-row">
<template v-if="isOwner">
<span class="ud-hint">Hand the workspace owner role to another member.</span>
<UiButton size="sm" variant="secondary" @click="openTransfer">
<template #leading><UiIcon name="refresh" :size="13" /></template>
Transfer ownership
</UiButton>
</template>
<template v-else>
<span class="ud-hint">Make this member the workspace owner.</span>
<UiButton size="sm" variant="secondary" @click="makeOwnerTarget = openUser">
<template #leading><UiIcon name="refresh" :size="13" /></template>
Make owner
</UiButton>
</template>
</div>
</div>
</section>
<!-- 4 · Contact information -->
<section class="ud-sec">
<div class="ud-sec-h"><UiIcon name="users" :size="14" stroke="var(--text-mute)" /><span>Contact information</span></div>
<div class="ud-sec-body">
<div class="grid2">
<label class="field"><Eyebrow>Display name</Eyebrow><input class="input" v-model="detailForm.name" /></label>
<label class="field"><Eyebrow>Phone</Eyebrow><input class="input" v-model="detailForm.phone" placeholder="+45 …" /></label>
<label class="field"><Eyebrow>First name</Eyebrow><input class="input" v-model="detailForm.firstName" /></label>
<label class="field"><Eyebrow>Last name</Eyebrow><input class="input" v-model="detailForm.lastName" /></label>
<label class="field span2"><Eyebrow>Alternative email</Eyebrow><input class="input" type="email" v-model="detailForm.alternativeEmail" placeholder="recovery@example.com" /></label>
</div>
<div class="ud-actions">
<UiButton size="sm" variant="primary" :disabled="detailBusy.contact || !detailForm.name.trim()" @click="saveContact">{{ detailBusy.contact ? 'Saving' : 'Save changes' }}</UiButton>
</div>
</div>
</section>
<dl class="def ud-meta-def">
<div><dt>Joined</dt><dd>{{ joinedDate(openUser.createdAt) }}</dd></div>
<div><dt>Last sign-in</dt><dd>{{ lastSeen(openUser.lastLoginAt) }}</dd></div>
</dl>
@@ -498,6 +920,115 @@ async function confirmReset() {
</template>
</Modal>
<!-- Transfer ownership -->
<Modal :open="transferOpen" eyebrow="Users · ownership" title="Transfer ownership" size="md" @close="transferOpen = false">
<div v-if="!ownerCandidates.length" class="no-domain">
<UiIcon name="users" :size="22" stroke="var(--text-mute)" />
<div class="nd-text">
<div class="nd-title">No one to transfer to</div>
<div class="nd-sub">Add another member to this workspace first, then you can hand them ownership.</div>
</div>
</div>
<div v-else class="form-stack">
<p class="muted">
The new owner gets full control of <strong>{{ tenant?.name }}</strong>, including billing and ownership.
<template v-if="openUser">The current owner (<Mono>{{ openUser.email }}</Mono>) becomes an admin — nothing is deleted.</template>
</p>
<label class="field"><Eyebrow>New owner</Eyebrow>
<select class="input" v-model="transferTarget">
<option value="" disabled>Select a member…</option>
<option v-for="c in ownerCandidates" :key="c._id" :value="c._id">{{ c.name }} · {{ c.email }}</option>
</select>
</label>
</div>
<template #footer>
<UiButton variant="ghost" @click="transferOpen = false">Cancel</UiButton>
<div style="flex: 1" />
<UiButton v-if="ownerCandidates.length" variant="primary" :disabled="transferBusy || !transferTarget" @click="confirmTransfer">
<template #leading><UiIcon name="refresh" :size="13" /></template>
{{ transferBusy ? 'Transferring' : 'Transfer ownership' }}
</UiButton>
</template>
</Modal>
<!-- Make owner — single-target promote (row menu / drawer) -->
<ConfirmDialog
:open="!!makeOwnerTarget"
eyebrow="Users · ownership"
:title="`Make ${makeOwnerTarget?.name || makeOwnerTarget?.email} the owner?`"
confirm-label="Make owner"
:busy="makeOwnerBusy"
@close="makeOwnerTarget = null"
@confirm="confirmMakeOwner"
>
They get full control of this workspace — including billing and ownership. The current owner (if any)
becomes an admin. Nothing is deleted.
</ConfirmDialog>
<!-- Create mailbox for a mailbox-less member -->
<Modal :open="createMailboxOpen" eyebrow="Users" title="Create mailbox" size="md" @close="createMailboxOpen = false">
<div v-if="!provisionedDomains.length" class="no-domain">
<UiIcon name="globe" :size="22" stroke="var(--text-mute)" />
<div class="nd-text">
<div class="nd-title">No mail domain yet</div>
<div class="nd-sub">A mailbox needs a verified, provisioned domain. Add one on the Domains page, then come back.</div>
</div>
<UiButton variant="primary" @click="createMailboxOpen = false; navigateTo('/admin/domains')">Go to Domains</UiButton>
</div>
<div v-else class="form-stack">
<label class="field"><Eyebrow>Mailbox address</Eyebrow>
<div class="alias-row">
<input class="input" v-model="mailboxForm.localPart" placeholder="name" />
<span class="at">@</span>
<select v-if="provisionedDomains.length > 1" class="input" v-model="mailboxForm.domain">
<option v-for="d in provisionedDomains" :key="d.id" :value="d.domain">{{ d.domain }}</option>
</select>
<Mono v-else class="domain-fixed">{{ mailboxDomain }}</Mono>
</div>
</label>
<div class="muted">
Well create a mailbox at <Mono>{{ (mailboxForm.localPart || 'name') + '@' + mailboxDomain }}</Mono> and show you a one-time password for webmail/IMAP. Their sign-in address (<Mono>{{ openUser?.email }}</Mono>) doesnt change.
</div>
</div>
<template #footer>
<template v-if="provisionedDomains.length">
<UiButton variant="ghost" @click="createMailboxOpen = false">Cancel</UiButton>
<div style="flex: 1" />
<UiButton variant="primary" :disabled="mailboxBusy || !mailboxForm.localPart.trim() || !mailboxDomain" @click="submitCreateMailbox">
<template #leading><UiIcon name="check" :size="13" /></template>
{{ mailboxBusy ? 'Creating' : 'Create mailbox' }}
</UiButton>
</template>
<template v-else>
<div style="flex: 1" />
<UiButton variant="ghost" @click="createMailboxOpen = false">Close</UiButton>
</template>
</template>
</Modal>
<!-- Mailbox created — one-time password -->
<Modal :open="!!mailboxResult" title="Mailbox created" eyebrow="Users" size="md" @close="mailboxResult = null">
<div v-if="mailboxResult" class="invite-result">
<div class="ir-check"><UiIcon name="check" :size="22" :stroke-width="2.5" /></div>
<div class="ir-title">{{ mailboxResult.email }} is ready</div>
<p class="ir-sub">Share these securely. They sign in to webmail at <Mono>mail.dezky.local</Mono> with this password — their portal sign-in is unchanged.</p>
<div class="cred">
<div class="cred-row">
<span class="cred-k">Mailbox</span><Mono class="cred-v">{{ mailboxResult.email }}</Mono>
<button class="copy" @click="copyText(mailboxResult.email)"><UiIcon name="copy" :size="13" /></button>
</div>
<div class="cred-row">
<span class="cred-k">Password</span><Mono class="cred-v">{{ mailboxResult.tempPassword }}</Mono>
<button class="copy" @click="copyText(mailboxResult.tempPassword)"><UiIcon name="copy" :size="13" /></button>
</div>
</div>
</div>
<template #footer>
<div style="flex: 1" />
<UiButton variant="primary" @click="mailboxResult = null">Done</UiButton>
</template>
</Modal>
<!-- Invite user modal (3 steps) -->
<Modal :open="inviteOpen" title="Invite user" eyebrow="Users" size="md" @close="closeInvite">
<!-- No domain yet -->
@@ -724,12 +1255,64 @@ Mikkel Sørensen,mikkel@baslund.dk,admin,Engineering,business</pre>
.ud-meta { flex: 1; }
.ud-name { font-size: 17px; font-weight: 600; font-family: var(--font-display); }
.ud-badges { display: flex; gap: 6px; margin-top: 8px; }
.ud-body { padding: 24px; }
.ud-body { padding: 24px; display: flex; flex-direction: column; gap: 8px; }
.def { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 12px; column-gap: 16px; }
.def > div { display: contents; }
.def dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); }
.def dd { margin: 0; font-size: 13px; color: var(--text); }
/* User detail — editable sections */
.ud-sec { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.ud-sec-h {
display: flex; align-items: center; gap: 8px;
padding: 11px 14px; background: var(--surface); border-bottom: 1px solid var(--border);
font-size: 11px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-mute);
}
.ud-sec-body { padding: 16px 14px; display: flex; flex-direction: column; gap: 12px; }
.kv { display: flex; flex-direction: column; gap: 6px; }
.kv-k { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); }
.kv-v { font-size: 13px; word-break: break-all; }
.muted-v { color: var(--text-mute); }
.kv-inline { display: flex; align-items: center; gap: 10px; }
.kv-edit { display: flex; align-items: center; gap: 8px; }
.kv-edit .input { flex: 1; }
.link-btn {
display: inline-flex; align-items: center; gap: 4px;
background: var(--surface); border: 1px solid var(--border); border-radius: 5px;
padding: 3px 9px; color: var(--text); font: inherit; font-size: 12px; font-weight: 500;
cursor: pointer;
}
.link-btn:hover { border-color: var(--text); background: var(--bg); }
.ud-hint { font-size: 12px; color: var(--text-mute); line-height: 1.5; margin: 0; }
.alias-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.alias-list li {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; font-size: 13px;
}
.icon-btn { background: none; border: none; padding: 4px; border-radius: 4px; color: var(--text-mute); cursor: pointer; }
.icon-btn:hover { background: var(--surface); color: var(--bad); }
.alias-add { display: flex; align-items: center; gap: 8px; }
.alias-add .input:first-child { flex: 1; }
.alias-add select.input { flex: 1; max-width: 180px; }
.role-pick { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; padding: 2px; width: fit-content; }
.role-pick button { padding: 6px 16px; border: none; border-radius: 4px; background: transparent; color: var(--text); font-size: 12px; font-weight: 500; font-family: inherit; cursor: pointer; }
.role-pick button.active { background: var(--text); color: var(--bg); }
.owner-row {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
margin-top: 4px; padding-top: 12px; border-top: 1px dashed var(--border);
}
.owner-row .ud-hint { flex: 1; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.grid2 .span2 { grid-column: 1 / -1; }
.ud-actions { display: flex; justify-content: flex-end; }
.ud-meta-def { padding: 4px 2px; }
/* Invite modal */
.form-stack { display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 6px; }