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.
This commit is contained in:
@@ -29,9 +29,23 @@ 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 inviteStep = ref(1)
|
||||
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)
|
||||
|
||||
@@ -83,10 +97,47 @@ const changeRoleOpen = ref(false)
|
||||
const suspendOpen = ref(false)
|
||||
const roleChoice = ref<'member' | 'admin' | 'owner'>('member')
|
||||
|
||||
function sendInvite() {
|
||||
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
|
||||
inviteStep.value = 1
|
||||
toast.ok('Invitation sent to magnus@dezky.com')
|
||||
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() {
|
||||
@@ -110,20 +161,115 @@ function bulkExport() {
|
||||
// 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') toast.info(`Password reset link sent to ${u.email}`)
|
||||
else if (id === 'force') toast.info(`Forcing logout for ${u.name}`)
|
||||
else if (id === 'suspend') toast.warn(`${u.name} suspended`)
|
||||
else if (id === 'delete') toast.bad(`${u.name} deletion scheduled`)
|
||||
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
|
||||
}
|
||||
|
||||
const userRowItems = [
|
||||
{ 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 },
|
||||
{ id: 'suspend', label: 'Suspend user', icon: 'shield' as const, danger: true },
|
||||
{ id: 'delete', label: 'Delete user', icon: 'trash' as const, danger: true },
|
||||
]
|
||||
// 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>
|
||||
@@ -142,7 +288,7 @@ const userRowItems = [
|
||||
<template #leading><UiIcon name="download" :size="14" /></template>
|
||||
Export
|
||||
</UiButton>
|
||||
<UiButton variant="primary" @click="inviteOpen = true">
|
||||
<UiButton variant="primary" @click="openInvite">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
Invite user
|
||||
</UiButton>
|
||||
@@ -210,7 +356,7 @@ const userRowItems = [
|
||||
<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="userRowItems" :icon-size="16" @select="(id) => rowAction(u, id)" />
|
||||
<AdminKebabMenu :items="rowItems(u)" :icon-size="16" @select="(id) => rowAction(u, id)" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="filteredUsers.length === 0" class="no-hover">
|
||||
@@ -276,67 +422,159 @@ const userRowItems = [
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="danger" @click="openUser && rowAction(openUser, 'force')">
|
||||
<template #leading><UiIcon name="logout" :size="13" /></template>
|
||||
Force logout
|
||||
<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>
|
||||
|
||||
<!-- Invite user modal (3 steps) -->
|
||||
<Modal :open="inviteOpen" :title="'Invite user'" :eyebrow="`Step ${inviteStep} of 3`" size="md" @close="inviteOpen = false; inviteStep = 1">
|
||||
<div v-if="inviteStep === 1" class="form-stack">
|
||||
<label class="field"><Eyebrow>Full name</Eyebrow><input class="input" value="Magnus Eriksen" /></label>
|
||||
<label class="field"><Eyebrow>Email</Eyebrow><input class="input" value="magnus@dezky.com" /></label>
|
||||
<label class="field"><Eyebrow>Role</Eyebrow>
|
||||
<div class="radio-row">
|
||||
<button class="active">Member</button><button>Admin</button>
|
||||
<!-- 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 can’t 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"
|
||||
>
|
||||
They’ll 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>
|
||||
</label>
|
||||
<label class="field"><Eyebrow>License tier</Eyebrow>
|
||||
<div class="radio-row">
|
||||
<button>Basic</button><button class="active">Business</button>
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else-if="inviteStep === 2" class="form-stack">
|
||||
<div>
|
||||
<Eyebrow>Group memberships</Eyebrow>
|
||||
<div class="check-stack">
|
||||
<label v-for="(g, i) in ['Engineering', 'Design', 'Operations', 'Finance', 'Sales']" :key="g">
|
||||
<input type="checkbox" :checked="i === 0" /> {{ g }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Eyebrow>Apps</Eyebrow>
|
||||
<div class="check-stack">
|
||||
<label v-for="a in ['Mail', 'Drev', 'Møder', 'Chat']" :key="a">
|
||||
<input type="checkbox" checked /> {{ a }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="review-box">
|
||||
<dl class="def">
|
||||
<div><dt>Name</dt><dd>Magnus Eriksen</dd></div>
|
||||
<div><dt>Email</dt><dd>magnus@dezky.com</dd></div>
|
||||
<div><dt>Role</dt><dd>Member · Business</dd></div>
|
||||
<div><dt>Groups</dt><dd>Engineering</dd></div>
|
||||
<div><dt>Apps</dt><dd>Mail · Drev · Møder · Chat</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="muted">
|
||||
We'll provision the account across Authentik, Stalwart, OCIS, Jitsi and Zulip, then email Magnus an activation link valid for 7 days.
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="inviteOpen = false; inviteStep = 1">Cancel</UiButton>
|
||||
<UiButton v-if="inviteStep > 1" variant="secondary" @click="inviteStep--">Back</UiButton>
|
||||
<UiButton v-if="inviteStep < 3" variant="primary" @click="inviteStep++">Continue</UiButton>
|
||||
<UiButton v-else variant="primary" @click="sendInvite">Send invitation</UiButton>
|
||||
<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>
|
||||
|
||||
@@ -505,6 +743,40 @@ Mikkel Sørensen,mikkel@baslund.dk,admin,Engineering,business</pre>
|
||||
.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;
|
||||
|
||||
Reference in New Issue
Block a user