@@ -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 can’ t be changed here.'
: ! hasMailbox . value
? 'This is the workspace’ s primary account — its role can’ t be changed.'
: 'You can’ t 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 & 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 member’ s 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 there’ s 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">
We’ ll 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>) doesn’ t 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 : 17 px ; font - weight : 600 ; font - family : var ( -- font - display ) ; }
. ud - badges { display : flex ; gap : 6 px ; margin - top : 8 px ; }
. ud - body { padding : 24 px ; }
. ud - body { padding : 24 px ; display : flex ; flex - direction : column ; gap : 8 px ; }
. def { margin : 0 ; display : grid ; grid - template - columns : 140 px 1 fr ; row - gap : 12 px ; column - gap : 16 px ; }
. def > div { display : contents ; }
. def dt { font - family : var ( -- font - mono ) ; font - size : 10 px ; letter - spacing : 0.12 em ; text - transform : uppercase ; color : var ( -- text - mute ) ; }
. def dd { margin : 0 ; font - size : 13 px ; color : var ( -- text ) ; }
/* User detail — editable sections */
. ud - sec { border : 1 px solid var ( -- border ) ; border - radius : 8 px ; overflow : hidden ; }
. ud - sec - h {
display : flex ; align - items : center ; gap : 8 px ;
padding : 11 px 14 px ; background : var ( -- surface ) ; border - bottom : 1 px solid var ( -- border ) ;
font - size : 11 px ; font - weight : 600 ; letter - spacing : 0.04 em ; text - transform : uppercase ; color : var ( -- text - mute ) ;
}
. ud - sec - body { padding : 16 px 14 px ; display : flex ; flex - direction : column ; gap : 12 px ; }
. kv { display : flex ; flex - direction : column ; gap : 6 px ; }
. kv - k { font - family : var ( -- font - mono ) ; font - size : 10 px ; letter - spacing : 0.12 em ; text - transform : uppercase ; color : var ( -- text - mute ) ; }
. kv - v { font - size : 13 px ; word - break : break - all ; }
. muted - v { color : var ( -- text - mute ) ; }
. kv - inline { display : flex ; align - items : center ; gap : 10 px ; }
. kv - edit { display : flex ; align - items : center ; gap : 8 px ; }
. kv - edit . input { flex : 1 ; }
. link - btn {
display : inline - flex ; align - items : center ; gap : 4 px ;
background : var ( -- surface ) ; border : 1 px solid var ( -- border ) ; border - radius : 5 px ;
padding : 3 px 9 px ; color : var ( -- text ) ; font : inherit ; font - size : 12 px ; font - weight : 500 ;
cursor : pointer ;
}
. link - btn : hover { border - color : var ( -- text ) ; background : var ( -- bg ) ; }
. ud - hint { font - size : 12 px ; 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 : 6 px ; }
. alias - list li {
display : flex ; align - items : center ; justify - content : space - between ; gap : 10 px ;
padding : 8 px 10 px ; background : var ( -- bg ) ; border : 1 px solid var ( -- border ) ; border - radius : 6 px ; font - size : 13 px ;
}
. icon - btn { background : none ; border : none ; padding : 4 px ; border - radius : 4 px ; color : var ( -- text - mute ) ; cursor : pointer ; }
. icon - btn : hover { background : var ( -- surface ) ; color : var ( -- bad ) ; }
. alias - add { display : flex ; align - items : center ; gap : 8 px ; }
. alias - add . input : first - child { flex : 1 ; }
. alias - add select . input { flex : 1 ; max - width : 180 px ; }
. role - pick { display : inline - flex ; border : 1 px solid var ( -- border ) ; border - radius : 6 px ; padding : 2 px ; width : fit - content ; }
. role - pick button { padding : 6 px 16 px ; border : none ; border - radius : 4 px ; background : transparent ; color : var ( -- text ) ; font - size : 12 px ; 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 : 12 px ;
margin - top : 4 px ; padding - top : 12 px ; border - top : 1 px dashed var ( -- border ) ;
}
. owner - row . ud - hint { flex : 1 ; }
. grid2 { display : grid ; grid - template - columns : 1 fr 1 fr ; gap : 12 px ; }
. grid2 . span2 { grid - column : 1 / - 1 ; }
. ud - actions { display : flex ; justify - content : flex - end ; }
. ud - meta - def { padding : 4 px 2 px ; }
/* Invite modal */
. form - stack { display : flex ; flex - direction : column ; gap : 14 px ; }
. field { display : flex ; flex - direction : column ; gap : 6 px ; }