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
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:
@@ -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: 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; }
|
||||
|
||||
Reference in New Issue
Block a user