Files
dezky/apps/portal/pages/admin/users.vue
T
Ronni Baslund 47eb9502f8 feat(platform): real email domains, mailboxes & member lifecycle
Wire the mail/identity stack to real Stalwart/Authentik/OCIS provisioning,
replacing the mocked Domains and Users pages.

Domains (customer-admin):
- StalwartClient: real JMAP management (v0.16 dropped REST) — create/list/delete
  email domains via x:Domain at the internal http://stalwart:8080 listener;
  DKIM auto-generated; the records to publish are read from the domain's
  dnsZoneFile. Gated by STALWART_PROVISIONING_ENABLED.
- New Domain collection + DomainsModule: add/list/recheck/set-DMARC/remove,
  tenant-membership-gated and audited.
- DnsVerifierService: verifies MX/SPF/DKIM/DMARC/ownership against a public
  resolver (1.1.1.1/8.8.8.8) and diffs them against the expected records.
- Remove is guarded: refuses while accounts/aliases/mailing lists still use the
  domain (via Stalwart referential integrity).
- Domains page + add wizard on real data; sidebar badge counts domains needing
  attention.

Users & groups (customer-admin):
- Create a member provisioned across Authentik SSO, a Stalwart mailbox on the
  tenant's primary domain, and OCIS — returning a one-time password.
- Lifecycle: suspend/resume (Authentik is_active + freeze the mailbox via
  account permissions, original password preserved), force-logout (terminate
  sessions, filtered client-side so it can never end other users' sessions),
  reset password (new one-time password on SSO + mailbox), and remove (tear down
  mailbox + SSO identity + OCIS + doc; mailbox-in-use aware for multi-tenant
  users). Self-suspend / self-force-logout are blocked.

Infra: point platform-api at the internal Stalwart listener; document the new
STALWART_/provisioning vars in .env.example.
2026-06-01 21:19:42 +02:00

821 lines
33 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">
// 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 { TenantUserDoc } from '~/types/workspace'
const toast = useToast()
const { fetchMe } = 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)
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 === 'force') forceLogoutUser(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.
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 },
{ id: 'force', label: 'Force logout', icon: 'logout' as const },
{ id: 'sep1', separator: true },
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
}
}
</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="u.role === 'owner' ? 'invert' : 'neutral'">{{ roleLabel(u.role) }}</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="neutral">{{ roleLabel(openUser.role) }}</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>
<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>mail.dezky.local</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>
<template #footer>
<div style="flex: 1" />
<UiButton variant="primary" @click="resetResult = 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>mail.dezky.local</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="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; }
.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); }
/* 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; }
</style>