Files
dezky/apps/portal/pages/admin/users.vue
T
Ronni Baslund fc621cdf81
ci / changes (push) Successful in 3s
ci / tc_booking (push) Has been skipped
ci / tc_operator (push) Has been skipped
ci / tc_platform_api (push) Has been skipped
ci / test_platform_api (push) Has been skipped
ci / tc_website (push) Successful in 20s
ci / tc_portal (push) Successful in 26s
ci / build_booking (push) Has been skipped
ci / build_operator (push) Has been skipped
ci / build_platform_api (push) Has been skipped
ci / build_zpush (push) Successful in 11s
ci / build_portal (push) Successful in 41s
ci / deploy (push) Successful in 39s
fix(mail): drop contacts from the EAS bundle — Stalwart 404s empty addressbook homes
BackendCombined login is all-or-nothing, and Stalwart returns 404 for
/dav/card/<account>/ when the account's default address book was never
created (it doesn't auto-create on the gateway's PROPFIND the way the
calendar home worked) — so CardDAV killed every otherwise-successful
EAS login. Exchange accounts now bundle mail + calendar; contacts stay
on the Apple-profile CardDAV path. Re-enable BackendCardDAV once
platform-api provisions a default address book at mailbox creation.

Copy/docs aligned: portal hint, SERVICES.md, website FAQ (da+en).
2026-06-12 15:31:27 +02:00

1444 lines
62 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
const mailHost = new URL(useRuntimeConfig().public.mailUrl as string).host
// One-click Apple Mail setup: downloads a .mobileconfig with the IMAP/SMTP
// settings prefilled (no password inside — Apple prompts on install).
function downloadAppleProfile(email: string, name?: string) {
const params = new URLSearchParams({ email, ...(name ? { name } : {}) })
window.location.href = `/api/apple-mailconfig?${params.toString()}`
}
// Users & groups. The Users tab is real — workspace members come from
// /api/tenants/:slug/users (platform-api UserDocument). The Groups,
// Invitations and Service-accounts tabs have no backend yet (no Group /
// Invitation / ServiceAccount schema exists), so they render honest
// "coming soon" states rather than fabricated rows.
//
// Mutations (invite, suspend, role change, CSV import) still toast-stub: the
// 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 { MemberAliases, TenantUserDoc } from '~/types/workspace'
const toast = useToast()
const { profile, fetchMe, isPlatformAdmin, isTenantAdminOf } = useMe()
await fetchMe()
const { tenant } = useTenant()
const slug = computed(() => tenant.value?.slug ?? '')
const { data: users } = await useFetch<TenantUserDoc[]>(
() => `/api/tenants/${slug.value}/users`,
{ key: 'admin-users', default: () => [], immediate: !!slug.value, watch: [slug] },
)
const tab = ref<'users' | 'groups' | 'invitations' | 'service'>('users')
const query = ref('')
const statusFilter = ref<'all' | 'active' | 'suspended'>('all')
const selected = ref<Set<string>>(new Set())
const openUser = ref<TenantUserDoc | null>(null)
const inviteOpen = ref(false)
const importOpen = ref(false)
// Real invite flow — creates a member provisioned across SSO + mailbox + storage.
const { request } = useApiFetch()
const { domains } = useDomains()
const primaryDomain = computed(() => domains.value?.find((d) => d.isPrimary) ?? domains.value?.[0])
const inviteBusy = ref(false)
const inviteForm = reactive({ name: '', localPart: '', role: 'member' as 'member' | 'admin', domain: '' })
const inviteResult = ref<{
email: string
tempPassword: string
provisioning: { authentik: string; stalwart: string; ocis: string }
stalwartError?: string
ocisNote?: string
} | null>(null)
const inviteDomain = computed(() => inviteForm.domain || primaryDomain.value?.domain || '')
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) => {
if (statusFilter.value !== 'all' && userStatus(u) !== statusFilter.value) return false
if (query.value && !`${u.name} ${u.email}`.toLowerCase().includes(query.value.toLowerCase())) return false
return true
}),
)
const statusTone = (s: string): 'ok' | 'warn' | 'bad' =>
s === 'active' ? 'ok' : s === 'suspended' ? 'bad' : 'warn'
function lastSeen(iso?: string): string {
if (!iso) return 'never'
const ms = Date.now() - new Date(iso).getTime()
const m = Math.floor(ms / 60_000)
if (m < 1) return 'just now'
if (m < 60) return `${m} min ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h} h ago`
const d = Math.floor(h / 24)
if (d < 30) return `${d} day${d === 1 ? '' : 's'} ago`
return new Date(iso).toLocaleDateString('da-DK', { day: '2-digit', month: 'short', year: 'numeric' })
}
function joinedDate(iso?: string): string {
return iso ? new Date(iso).toLocaleDateString('da-DK', { day: '2-digit', month: 'long', year: 'numeric' }) : '—'
}
function toggleSelect(id: string) {
const s = new Set(selected.value)
if (s.has(id)) s.delete(id)
else s.add(id)
selected.value = s
}
function clearSelection() { selected.value = new Set() }
// Filter chip
type ChipOption = { value: string; label: string }
const statusOptions: ChipOption[] = [
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'suspended', label: 'Suspended' },
]
// Bulk-action modals + confirm
const changeRoleOpen = ref(false)
const suspendOpen = ref(false)
const roleChoice = ref<'member' | 'admin' | 'owner'>('member')
function openInvite() {
inviteResult.value = null
inviteForm.name = ''
inviteForm.localPart = ''
inviteForm.role = 'member'
inviteForm.domain = primaryDomain.value?.domain ?? ''
inviteOpen.value = true
}
function closeInvite() {
inviteOpen.value = false
inviteResult.value = null
}
async function submitInvite() {
if (!inviteForm.name.trim() || !inviteForm.localPart.trim() || !inviteDomain.value) return
inviteBusy.value = true
try {
inviteResult.value = await request(`/api/tenants/${slug.value}/users`, {
method: 'POST',
body: {
name: inviteForm.name.trim(),
localPart: inviteForm.localPart.trim(),
role: inviteForm.role,
domain: inviteForm.domain || undefined,
},
})
await refreshNuxtData('admin-users')
toast.ok('User created', inviteResult.value?.email)
} catch (err) {
const e = err as { data?: { message?: string | string[] }; message?: string }
const m = e?.data?.message ?? e?.message ?? 'Unknown error'
toast.bad('Could not create user', Array.isArray(m) ? m.join(', ') : m)
} finally {
inviteBusy.value = false
}
}
function copyText(t: string) {
if (typeof navigator !== 'undefined' && navigator.clipboard) navigator.clipboard.writeText(t)
toast.ok('Copied to clipboard')
}
function provTone(s: string): 'ok' | 'warn' | 'bad' {
return s === 'ok' ? 'ok' : s === 'skipped' ? 'warn' : 'bad'
}
function applyBulkRole() {
const n = selected.value.size
changeRoleOpen.value = false
toast.ok(`${n} user${n === 1 ? '' : 's'} set to ${roleChoice.value}`)
}
function applyBulkSuspend() {
const n = selected.value.size
suspendOpen.value = false
toast.warn(`${n} user${n === 1 ? '' : 's'} suspended`, 'Sign-in blocked · data preserved')
selected.value = new Set()
}
function bulkExport() {
const n = selected.value.size
toast.info(`Exporting ${n} user${n === 1 ? '' : 's'}`, 'CSV with profile + access columns')
}
// Per-row kebab — open the user detail panel by default.
function rowAction(u: TenantUserDoc, id: string) {
if (id === 'open') openUser.value = u
else if (id === 'reset') resetTarget.value = u
else if (id === 'apple-profile' && u.mailboxAddress) downloadAppleProfile(u.mailboxAddress, u.name)
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, and
// admins get "Make owner" on anyone who isn't already the owner.
function rowItems(u: TenantUserDoc) {
const suspended = u.active === false
return [
{ id: 'open', label: 'Open profile', icon: 'external' as const },
{ id: 'reset', label: 'Send password reset', icon: 'key' as const },
...(u.mailboxAddress
? [{ id: 'apple-profile', label: 'Download Apple Mail profile', icon: 'download' 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 },
{ id: 'delete', label: 'Delete user', icon: 'trash' as const, danger: true },
]
}
function toastErr(err: unknown, title: string) {
const e = err as { data?: { message?: string }; message?: string }
toast.bad(title, e?.data?.message ?? e?.message ?? 'Unknown error')
}
// Remove-user flow — tears down mailbox + SSO + storage via the server.
const removeTarget = ref<TenantUserDoc | null>(null)
const removing = ref(false)
async function confirmRemoveUser() {
const u = removeTarget.value
if (!u) return
removing.value = true
try {
await request(`/api/tenants/${slug.value}/users/${u._id}`, { method: 'DELETE' })
await refreshNuxtData('admin-users')
toast.ok('User removed', u.email)
removeTarget.value = null
openUser.value = null
} catch (err) {
toastErr(err, 'Could not remove user')
} finally {
removing.value = false
}
}
// Suspend / resume.
const suspendTarget = ref<TenantUserDoc | null>(null)
const suspendBusy = ref(false)
async function confirmSuspend() {
const u = suspendTarget.value
if (!u) return
suspendBusy.value = true
try {
await request(`/api/tenants/${slug.value}/users/${u._id}/suspend`, { method: 'POST' })
await refreshNuxtData('admin-users')
toast.ok('User suspended', u.email)
suspendTarget.value = null
openUser.value = null
} catch (err) {
toastErr(err, 'Could not suspend user')
} finally {
suspendBusy.value = false
}
}
async function resumeUser(u: TenantUserDoc) {
try {
await request(`/api/tenants/${slug.value}/users/${u._id}/resume`, { method: 'POST' })
await refreshNuxtData('admin-users')
toast.ok('User resumed', u.email)
openUser.value = null
} catch (err) {
toastErr(err, 'Could not resume user')
}
}
// Force logout — low-risk, no confirm.
async function forceLogoutUser(u: TenantUserDoc) {
try {
const r = await request<{ sessions: number }>(
`/api/tenants/${slug.value}/users/${u._id}/force-logout`,
{ method: 'POST' },
)
toast.ok('Sessions ended', `${u.name} · ${r.sessions} session${r.sessions === 1 ? '' : 's'} terminated`)
} catch (err) {
toastErr(err, 'Could not force logout')
}
}
// Reset password — confirm, then show the new one-time password.
const resetTarget = ref<TenantUserDoc | null>(null)
const resetBusy = ref(false)
const resetResult = ref<{ email: string; tempPassword: string } | null>(null)
async function confirmReset() {
const u = resetTarget.value
if (!u) return
resetBusy.value = true
try {
resetResult.value = await request(`/api/tenants/${slug.value}/users/${u._id}/reset-password`, {
method: 'POST',
})
resetTarget.value = null
} catch (err) {
toastErr(err, 'Could not reset password')
} finally {
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>
<div>
<PageHeader
eyebrow="Identity"
title="Users & groups"
subtitle="Manage workspace members, their access, and group assignments."
>
<template #actions>
<UiButton variant="secondary" @click="importOpen = true">
<template #leading><UiIcon name="upload" :size="14" /></template>
Import CSV
</UiButton>
<UiButton variant="secondary">
<template #leading><UiIcon name="download" :size="14" /></template>
Export
</UiButton>
<UiButton variant="primary" @click="openInvite">
<template #leading><UiIcon name="plus" :size="14" /></template>
Invite user
</UiButton>
</template>
</PageHeader>
<div class="tab-wrap">
<Tabs
v-model="tab"
:items="[
{ value: 'users', label: 'Users', count: users.length },
{ value: 'groups', label: 'Groups' },
{ value: 'invitations', label: 'Invitations' },
{ value: 'service', label: 'Service accounts' },
]"
/>
</div>
<!-- USERS TAB -->
<div v-if="tab === 'users'" class="content">
<div class="toolbar">
<div class="input-search">
<UiIcon name="search" :size="14" stroke="var(--text-mute)" />
<input v-model="query" placeholder="Search by name or email…" />
</div>
<AdminFilterChip label="Status" :options="statusOptions" v-model="statusFilter" />
<div class="spacer" />
<Mono dim>{{ filteredUsers.length }} of {{ users.length }}</Mono>
</div>
<div v-if="selected.size > 0" class="bulk">
<Mono style="color: inherit">{{ selected.size }} selected</Mono>
<div class="spacer" />
<UiButton size="sm" variant="ghost" class="invert" @click="changeRoleOpen = true">Change role</UiButton>
<UiButton size="sm" variant="ghost" class="invert" @click="bulkExport">Export selected</UiButton>
<UiButton size="sm" variant="ghost" class="invert" @click="suspendOpen = true">Suspend</UiButton>
<UiButton size="sm" variant="ghost" class="invert" @click="clearSelection">Clear</UiButton>
</div>
<Card :pad="0">
<table class="users-tbl">
<thead>
<tr>
<th class="check">
<input type="checkbox" :checked="selected.size === filteredUsers.length && filteredUsers.length > 0" @change="(e) => (e.target as HTMLInputElement).checked ? (selected = new Set(filteredUsers.map(u => u._id))) : clearSelection()" />
</th>
<th>Name</th><th>Role</th><th>Status</th><th>Last seen</th><th />
</tr>
</thead>
<tbody>
<tr v-for="u in filteredUsers" :key="u._id" @click="openUser = u">
<td class="check" @click.stop>
<input type="checkbox" :checked="selected.has(u._id)" @change="toggleSelect(u._id)" />
</td>
<td>
<div class="name-cell">
<Avatar :name="u.name" :size="28" />
<div>
<div class="u-name">{{ u.name }}</div>
<Mono dim>{{ u.email }}</Mono>
</div>
</div>
</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>
<AdminKebabMenu :items="rowItems(u)" :icon-size="16" @select="(id) => rowAction(u, id)" />
</td>
</tr>
<tr v-if="filteredUsers.length === 0" class="no-hover">
<td colspan="6" class="empty-row">
<Mono dim>{{ users.length === 0 ? 'No members in this workspace yet.' : 'No users match your filters.' }}</Mono>
</td>
</tr>
</tbody>
</table>
</Card>
</div>
<!-- GROUPS TAB no backend yet -->
<div v-else-if="tab === 'groups'" class="content">
<div class="empty-card">
<UiIcon name="users" :size="28" stroke="var(--text-mute)" />
<div class="empty-title">Group management coming soon</div>
<div class="empty-body">Create teams, assign mail aliases and shared resources, and manage memberships in one place. We're wiring this up to your identity provider.</div>
</div>
</div>
<!-- INVITATIONS TAB — no backend yet -->
<div v-else-if="tab === 'invitations'" class="content">
<div class="empty-card">
<UiIcon name="mail" :size="28" stroke="var(--text-mute)" />
<div class="empty-title">Invitations coming soon</div>
<div class="empty-body">Track pending invites, copy activation links, and resend or revoke them here once user provisioning is wired to the workspace.</div>
</div>
</div>
<!-- SERVICE ACCOUNTS TAB — no backend yet -->
<div v-else class="content">
<div class="empty-card">
<UiIcon name="key" :size="28" stroke="var(--text-mute)" />
<div class="empty-title">Service accounts coming soon</div>
<div class="empty-body">Service accounts will let scripts and integrations authenticate to your workspace with scoped API tokens.</div>
</div>
</div>
<!-- User detail side panel — read-only profile from real data -->
<SidePanel :open="!!openUser" :eyebrow="openUser?.email || ''" :title="openUser?.name || ''" width="lg" @close="openUser = null">
<div v-if="openUser" class="user-detail">
<div class="ud-head">
<Avatar :name="openUser.name" :size="56" />
<div class="ud-meta">
<div class="ud-name">{{ openUser.name }}</div>
<Mono dim>{{ openUser.email }}</Mono>
<div class="ud-badges">
<Badge :tone="statusTone(userStatus(openUser))" dot>{{ userStatus(openUser) }}</Badge>
<Badge :tone="effectiveRole(openUser) === 'owner' ? 'invert' : 'neutral'">{{ roleLabel(effectiveRole(openUser)) }}</Badge>
</div>
</div>
</div>
<div class="ud-body">
<!-- 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">
<div class="kv-inline">
<Mono class="kv-v">{{ openUser.mailboxAddress }}</Mono>
<UiButton size="sm" variant="secondary" @click="downloadAppleProfile(openUser.mailboxAddress, openUser.name)">
<template #leading><UiIcon name="download" :size="13" /></template>
Apple Mail profile
</UiButton>
</div>
</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>
<p v-if="hasMailbox" class="ud-hint">
On iPhone and Android the mailbox can also be added as an <strong>Exchange</strong> account — mail and calendar in one go. Server <Mono>{{ mailHost }}</Mono>, username is the mailbox address. Works in the built-in mail apps (not the Outlook app); on Windows, Outlook syncs the calendar via the free CalDAV Synchronizer add-in.
</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>
</div>
</div>
<template #footer>
<UiButton variant="danger" @click="openUser && (removeTarget = openUser)">
<template #leading><UiIcon name="trash" :size="13" /></template>
Remove user
</UiButton>
<UiButton variant="secondary" @click="openUser && rowAction(openUser, 'reset')">Reset password</UiButton>
</template>
</SidePanel>
<!-- Remove user confirm -->
<ConfirmDialog
:open="!!removeTarget"
eyebrow="Users"
:title="`Remove ${removeTarget?.name || removeTarget?.email}?`"
confirm-label="Remove user"
tone="danger"
:busy="removing"
@close="removeTarget = null"
@confirm="confirmRemoveUser"
>
Their sign-in, mailbox <strong>{{ removeTarget?.email }}</strong> and storage are deleted from the
mail server and identity provider. Any mail in the mailbox is lost. This cant be undone.
</ConfirmDialog>
<!-- Suspend user confirm -->
<ConfirmDialog
:open="!!suspendTarget"
eyebrow="Users"
:title="`Suspend ${suspendTarget?.name || suspendTarget?.email}?`"
confirm-label="Suspend user"
tone="danger"
:busy="suspendBusy"
@close="suspendTarget = null"
@confirm="confirmSuspend"
>
Theyll be blocked from signing in and their mailbox <strong>{{ suspendTarget?.email }}</strong>
stops sending and receiving — until you resume them. Nothing is deleted.
</ConfirmDialog>
<!-- Reset password confirm -->
<ConfirmDialog
:open="!!resetTarget"
eyebrow="Users"
:title="`Reset password for ${resetTarget?.name || resetTarget?.email}?`"
confirm-label="Reset password"
tone="danger"
:busy="resetBusy"
@close="resetTarget = null"
@confirm="confirmReset"
>
A new one-time password is generated for both their sign-in and mailbox. Their current password
stops working immediately.
</ConfirmDialog>
<!-- New password result -->
<Modal :open="!!resetResult" title="New password" eyebrow="Users" size="md" @close="resetResult = null">
<div v-if="resetResult" class="invite-result">
<div class="ir-check"><UiIcon name="key" :size="20" /></div>
<div class="ir-title">Password reset</div>
<p class="ir-sub">Share this securely. It works for both sign-in and webmail at <Mono>{{ mailHost }}</Mono>.</p>
<div class="cred">
<div class="cred-row">
<span class="cred-k">Email</span><Mono class="cred-v">{{ resetResult.email }}</Mono>
<button class="copy" @click="copyText(resetResult.email)"><UiIcon name="copy" :size="13" /></button>
</div>
<div class="cred-row">
<span class="cred-k">New password</span><Mono class="cred-v">{{ resetResult.tempPassword }}</Mono>
<button class="copy" @click="copyText(resetResult.tempPassword)"><UiIcon name="copy" :size="13" /></button>
</div>
</div>
<div class="apple-row">
<UiButton variant="secondary" @click="downloadAppleProfile(resetResult.email, undefined)">
<template #leading><UiIcon name="download" :size="13" /></template>
Add to Apple Mail (.mobileconfig)
</UiButton>
</div>
</div>
<template #footer>
<div style="flex: 1" />
<UiButton variant="primary" @click="resetResult = null">Done</UiButton>
</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>{{ mailHost }}</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 class="apple-row">
<UiButton variant="secondary" @click="downloadAppleProfile(mailboxResult.email, undefined)">
<template #leading><UiIcon name="download" :size="13" /></template>
Add to Apple Mail (.mobileconfig)
</UiButton>
</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 -->
<div v-if="!primaryDomain" class="no-domain">
<UiIcon name="globe" :size="22" stroke="var(--text-mute)" />
<div class="nd-text">
<div class="nd-title">Add a domain first</div>
<div class="nd-sub">Users get an email address on your domain. Add one on the Domains page, then come back.</div>
</div>
<UiButton variant="primary" @click="closeInvite(); navigateTo('/admin/domains')">Go to Domains</UiButton>
</div>
<!-- Result: credentials + per-system status -->
<div v-else-if="inviteResult" class="invite-result">
<div class="ir-check"><UiIcon name="check" :size="22" :stroke-width="2.5" /></div>
<div class="ir-title">{{ inviteResult.email }} is ready</div>
<p class="ir-sub">Share these credentials securely. They sign in to the portal and to webmail at <Mono>{{ mailHost }}</Mono>.</p>
<div class="cred">
<div class="cred-row">
<span class="cred-k">Email</span><Mono class="cred-v">{{ inviteResult.email }}</Mono>
<button class="copy" @click="copyText(inviteResult.email)"><UiIcon name="copy" :size="13" /></button>
</div>
<div class="cred-row">
<span class="cred-k">Temp password</span><Mono class="cred-v">{{ inviteResult.tempPassword }}</Mono>
<button class="copy" @click="copyText(inviteResult.tempPassword)"><UiIcon name="copy" :size="13" /></button>
</div>
</div>
<div class="apple-row">
<UiButton variant="secondary" @click="downloadAppleProfile(inviteResult.email, undefined)">
<template #leading><UiIcon name="download" :size="13" /></template>
Add to Apple Mail (.mobileconfig)
</UiButton>
</div>
<div class="prov">
<Badge :tone="provTone(inviteResult.provisioning.authentik)" dot>SSO login</Badge>
<Badge :tone="provTone(inviteResult.provisioning.stalwart)" dot>Mailbox</Badge>
<Badge :tone="provTone(inviteResult.provisioning.ocis)" dot>Storage</Badge>
</div>
<div v-if="inviteResult.stalwartError" class="prov-note bad">Mailbox could not be created: {{ inviteResult.stalwartError }}</div>
<div v-else-if="inviteResult.ocisNote" class="prov-note">Storage {{ inviteResult.ocisNote }}.</div>
</div>
<!-- Form -->
<div v-else class="form-stack">
<label class="field"><Eyebrow>Full name</Eyebrow><input class="input" v-model="inviteForm.name" placeholder="Jane Doe" /></label>
<label class="field"><Eyebrow>Email address</Eyebrow>
<div class="alias-row">
<input class="input" v-model="inviteForm.localPart" placeholder="jane" />
<span class="at">@</span>
<select v-if="(domains?.length ?? 0) > 1" class="input" v-model="inviteForm.domain">
<option v-for="d in domains" :key="d.id" :value="d.domain">{{ d.domain }}</option>
</select>
<Mono v-else class="domain-fixed">{{ inviteDomain }}</Mono>
</div>
</label>
<label class="field"><Eyebrow>Role</Eyebrow>
<div class="radio-row">
<button type="button" :class="{ active: inviteForm.role === 'member' }" @click="inviteForm.role = 'member'">Member</button>
<button type="button" :class="{ active: inviteForm.role === 'admin' }" @click="inviteForm.role = 'admin'">Admin</button>
</div>
</label>
<div class="muted">
We'll create their SSO login, a mailbox at <Mono>{{ (inviteForm.localPart || 'name') + '@' + inviteDomain }}</Mono>, and OCIS storage then show you a one-time password.
</div>
</div>
<template #footer>
<template v-if="inviteResult">
<div style="flex: 1" />
<UiButton variant="primary" @click="closeInvite">Done</UiButton>
</template>
<template v-else-if="primaryDomain">
<UiButton variant="ghost" @click="closeInvite">Cancel</UiButton>
<div style="flex: 1" />
<UiButton variant="primary" :disabled="inviteBusy || !inviteForm.name.trim() || !inviteForm.localPart.trim()" @click="submitInvite">
<template #leading><UiIcon name="check" :size="13" /></template>
{{ inviteBusy ? 'Creating…' : 'Create user' }}
</UiButton>
</template>
<template v-else>
<div style="flex: 1" />
<UiButton variant="ghost" @click="closeInvite">Close</UiButton>
</template>
</template>
</Modal>
<!-- Bulk import modal -->
<Modal :open="importOpen" eyebrow="Users · bulk import" title="Import users from CSV" size="md" @close="importOpen = false">
<div class="import">
<div class="upload-stage">
<UiIcon name="upload" :size="28" stroke="var(--text-mute)" />
<div class="upload-text">
<div>Drop a CSV here, or click to browse</div>
<Mono dim>columns: name, email, role, group, license</Mono>
</div>
</div>
<div class="info-box">
<Mono dim>// example</Mono>
<pre class="csv-sample">name,email,role,group,license
Anne Hansen,anne@baslund.dk,owner,Leadership,business
Mikkel Sørensen,mikkel@baslund.dk,admin,Engineering,business</pre>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="importOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="importOpen = false; toast.ok('22 users imported · 2 skipped')">
<template #leading><UiIcon name="check" :size="13" /></template>
Import users
</UiButton>
</template>
</Modal>
<!-- Bulk · change role -->
<Modal :open="changeRoleOpen" :eyebrow="`${selected.size} selected`" title="Change role" size="md" @close="changeRoleOpen = false">
<div class="form-stack">
<Eyebrow>New role</Eyebrow>
<label v-for="r in ['member', 'admin', 'owner'] as const" :key="r" class="role-row" :class="{ active: roleChoice === r }">
<input type="radio" :value="r" v-model="roleChoice" />
<div>
<div class="role-name">{{ r.charAt(0).toUpperCase() + r.slice(1) }}</div>
<Mono dim>
{{ r === 'member' ? 'Standard access to apps' :
r === 'admin' ? 'Manage users, billing, and settings' :
'Full control — including billing and ownership' }}
</Mono>
</div>
</label>
</div>
<template #footer>
<UiButton variant="ghost" @click="changeRoleOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="applyBulkRole">Update role</UiButton>
</template>
</Modal>
<!-- Bulk · suspend -->
<ConfirmDialog
:open="suspendOpen"
:eyebrow="`${selected.size} selected`"
:title="`Suspend ${selected.size} user${selected.size === 1 ? '' : 's'}?`"
confirm-label="Suspend"
tone="danger"
@close="suspendOpen = false"
@confirm="applyBulkSuspend"
>
Sign-in will be blocked across mail, files, chat, and meetings. Data is preserved; you can re-enable any time.
</ConfirmDialog>
</div>
</template>
<style scoped>
.tab-wrap { padding: 16px 40px 0 40px; }
.content { padding: 16px 40px 64px 40px; }
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.input-search {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
height: 36px;
width: 320px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.input-search input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
.spacer { flex: 1; }
.bulk {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
margin-bottom: 12px;
background: var(--text);
color: var(--bg);
border-radius: 6px;
}
.bulk .invert :deep(button) { color: var(--bg) !important; }
.bulk :deep([data-variant='ghost']) { color: var(--bg); }
.bulk :deep([data-variant='ghost']:hover) { background: rgba(255, 255, 255, 0.06); }
.users-tbl { width: 100%; border-collapse: collapse; }
.users-tbl th {
text-align: left;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-weight: 500;
}
.users-tbl td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-size: 13px;
vertical-align: middle;
}
.users-tbl tr { cursor: pointer; }
.users-tbl tr:hover { background: var(--surface); }
.users-tbl tr:last-child td { border-bottom: none; }
.users-tbl .right { text-align: right; }
.users-tbl .check { width: 36px; }
.users-tbl tr.no-hover { cursor: default; }
.users-tbl tr.no-hover:hover { background: transparent; }
.empty-row { padding: 40px 16px; text-align: center; }
.name-cell { display: flex; align-items: center; gap: 12px; }
.u-name { font-weight: 500; font-size: 13px; }
.empty-card {
padding: 60px 24px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
}
.empty-title { font-family: var(--font-display); font-weight: 600; font-size: 17px; }
.empty-body { font-size: 13px; color: var(--text-mute); max-width: 420px; line-height: 1.5; }
/* User detail */
.user-detail { padding-bottom: 24px; margin: -22px -24px; }
.ud-head { padding: 24px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 16px; }
.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; 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; }
.input { height: 36px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-family: inherit; font-size: 13px; color: var(--text); outline: none; }
.input:focus { border-color: var(--text); }
.radio-row { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; padding: 2px; width: fit-content; }
.radio-row button { padding: 6px 14px; border: none; border-radius: 4px; background: transparent; color: var(--text); font-size: 12px; font-weight: 500; font-family: inherit; cursor: pointer; }
.radio-row button.active { background: var(--text); color: var(--bg); }
.check-stack { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; font-size: 13px; }
.check-stack label { display: flex; align-items: center; gap: 8px; }
.review-box { padding: 16px; background: var(--bg); border-radius: 6px; margin-bottom: 16px; }
.muted { font-size: 12px; color: var(--text-mute); line-height: 1.55; }
/* Invite modal — address row */
.alias-row { display: flex; align-items: center; gap: 8px; }
.alias-row .input:first-child { flex: 1; }
.at { font-family: var(--font-mono); color: var(--text-mute); }
.domain-fixed { font-size: 13px; color: var(--text-dim); white-space: nowrap; }
/* Invite modal — no-domain notice */
.no-domain { display: flex; align-items: center; gap: 14px; padding: 8px 0; }
.nd-text { flex: 1; }
.nd-title { font-weight: 600; font-size: 14px; }
.nd-sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; line-height: 1.5; }
/* Invite modal — result */
.invite-result { text-align: center; padding: 8px 0; }
.ir-check {
width: 48px; height: 48px; border-radius: 12px; margin: 0 auto 14px;
background: var(--accent); color: var(--accent-fg);
display: inline-flex; align-items: center; justify-content: center;
}
.ir-title { font-family: var(--font-display); font-weight: 600; font-size: 18px; }
.ir-sub { font-size: 13px; color: var(--text-mute); margin: 6px auto 16px; max-width: 380px; line-height: 1.55; }
.cred { display: flex; flex-direction: column; gap: 8px; text-align: left; }
.cred-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
}
.cred-k { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-mute); width: 100px; flex-shrink: 0; }
.cred-v { flex: 1; font-size: 13px; word-break: break-all; }
.copy { background: transparent; border: none; padding: 4px; border-radius: 4px; color: var(--text-mute); cursor: pointer; }
.copy:hover { background: var(--surface); }
.prov { display: flex; justify-content: center; gap: 10px; margin-top: 16px; }
.prov-note { font-size: 12px; color: var(--text-mute); margin-top: 12px; }
.prov-note.bad { color: var(--bad); }
.import { display: flex; flex-direction: column; gap: 14px; }
.upload-stage {
padding: 32px 24px;
background: var(--bg);
border: 2px dashed var(--border);
border-radius: 10px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
}
.upload-text { text-align: center; font-size: 13px; }
.info-box {
padding: 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 12px;
}
.csv-sample {
margin: 8px 0 0 0;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
white-space: pre-wrap;
}
/* Bulk · role picker */
.role-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
}
.role-row.active { border-color: var(--text); background: var(--bg); }
.role-name { font-size: 13px; font-weight: 500; }
.apple-row { margin-top: 10px; }
</style>