feat(mail): tenant alias and distribution-list management via Stalwart
Customer-admin Mail settings backed by Stalwart JMAP: per-tenant aliases (extra addresses routing to a mailbox) and distribution lists (one address fanning out to many recipients). Adds StalwartClient x:Alias/x:MailingList methods, a tenant-scoped MailController/MailService, the portal Mail settings page and its proxy routes, and the mailboxAddress field on TenantUserDoc. Removes the old mock mail data now that the page reads live data.
This commit is contained in:
+296
-429
@@ -1,112 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
// Strict port of project/platform-admin.jsx `MailSettingsScreen` (lines 76-305).
|
||||
// 5 tabs: Aliases / Forwarding / Filters · anti-spam / Distribution lists /
|
||||
// Compliance · retention. Each uses the source's data and copy verbatim.
|
||||
// Mail settings. The per-tenant parts are real: Aliases (extra addresses that
|
||||
// route to a mailbox) and Distribution lists (one address → many recipients),
|
||||
// both backed by Stalwart via /api/tenants/:slug/mail/*. The other three tabs —
|
||||
// Forwarding, Filters, Compliance — are server-global in Stalwart (one shared
|
||||
// spam engine / retention policy), so they're operator-managed, not per-customer;
|
||||
// they render an honest "managed by Dezky" state rather than fake controls.
|
||||
|
||||
import type { TenantUserDoc } from '~/types/workspace'
|
||||
|
||||
import {
|
||||
orgAliases,
|
||||
forwardingRules,
|
||||
antiSpamFilters,
|
||||
distributionLists,
|
||||
} from '~/data/workspace'
|
||||
|
||||
const tab = ref<'aliases' | 'forwarding' | 'filters' | 'lists' | 'compliance'>('aliases')
|
||||
interface AliasView {
|
||||
address: string
|
||||
localPart: string
|
||||
domain: string
|
||||
destination: string
|
||||
accountId: string
|
||||
enabled: boolean
|
||||
}
|
||||
interface ListView {
|
||||
id: string
|
||||
name: string
|
||||
address: string
|
||||
domain: string
|
||||
recipients: string[]
|
||||
members: number
|
||||
description?: string
|
||||
}
|
||||
interface DomainLite { id: string; domain: string; isPrimary: boolean }
|
||||
|
||||
const toast = useToast()
|
||||
const { tenant } = useTenant()
|
||||
const slug = computed(() => tenant.value?.slug ?? '')
|
||||
const { request } = useApiFetch()
|
||||
|
||||
const addAliasOpen = ref(false)
|
||||
const ruleOpen = ref(false)
|
||||
const filterOpen = ref(false)
|
||||
const listOpen = ref(false)
|
||||
const holdOpen = ref(false)
|
||||
const openList = ref<typeof distributionLists[number] | null>(null)
|
||||
const deleteListOpen = ref(false)
|
||||
const tab = ref<'aliases' | 'lists' | 'forwarding' | 'filters' | 'compliance'>('aliases')
|
||||
|
||||
// Track each forwarding rule's enabled flag locally so the toggle visually flips.
|
||||
const ruleEnabled = reactive<Record<string, boolean>>(
|
||||
Object.fromEntries(forwardingRules.map((r) => [r.name, r.enabled])),
|
||||
const { data: aliases, refresh: refreshAliases } = await useFetch<AliasView[]>(
|
||||
() => `/api/tenants/${slug.value}/mail/aliases`,
|
||||
{ key: 'mail-aliases', default: () => [], immediate: !!slug.value, watch: [slug] },
|
||||
)
|
||||
const filterEnabled = reactive<Record<string, boolean>>(
|
||||
Object.fromEntries(antiSpamFilters.map((f) => [f.name, f.enabled])),
|
||||
const { data: lists, refresh: refreshLists } = await useFetch<ListView[]>(
|
||||
() => `/api/tenants/${slug.value}/mail/lists`,
|
||||
{ key: 'mail-lists', default: () => [], immediate: !!slug.value, watch: [slug] },
|
||||
)
|
||||
const { data: domains } = await useFetch<DomainLite[]>(
|
||||
() => `/api/tenants/${slug.value}/domains`,
|
||||
{ key: 'mail-domains', default: () => [], immediate: !!slug.value, watch: [slug] },
|
||||
)
|
||||
const { data: users } = await useFetch<TenantUserDoc[]>(
|
||||
() => `/api/tenants/${slug.value}/users`,
|
||||
{ key: 'mail-users', default: () => [], immediate: !!slug.value, watch: [slug] },
|
||||
)
|
||||
|
||||
async function copyAlias(alias: string) {
|
||||
const primaryDomain = computed(() => domains.value?.find((d) => d.isPrimary) ?? domains.value?.[0])
|
||||
// Only users with a real mailbox can be an alias destination.
|
||||
const mailboxUsers = computed(() => (users.value ?? []).filter((u) => !!u.mailboxAddress))
|
||||
|
||||
function toastErr(err: unknown, title: string) {
|
||||
const e = err as { data?: { message?: string | string[] }; message?: string }
|
||||
const m = e?.data?.message ?? e?.message ?? 'Unknown error'
|
||||
toast.bad(title, Array.isArray(m) ? m.join(', ') : m)
|
||||
}
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ value: 'aliases', label: 'Aliases', count: aliases.value?.length ?? 0 },
|
||||
{ value: 'lists', label: 'Distribution lists', count: lists.value?.length ?? 0 },
|
||||
{ value: 'forwarding', label: 'Forwarding' },
|
||||
{ value: 'filters', label: 'Filters · anti-spam' },
|
||||
{ value: 'compliance', label: 'Compliance · retention' },
|
||||
])
|
||||
|
||||
// ── Aliases ──
|
||||
const aliasOpen = ref(false)
|
||||
const aliasBusy = ref(false)
|
||||
const aliasForm = reactive({ localPart: '', domain: '', destinationUserId: '' })
|
||||
const aliasDeleteTarget = ref<AliasView | null>(null)
|
||||
const aliasDeleting = ref(false)
|
||||
|
||||
function openAlias() {
|
||||
aliasForm.localPart = ''
|
||||
aliasForm.domain = primaryDomain.value?.domain ?? ''
|
||||
aliasForm.destinationUserId = mailboxUsers.value[0]?._id ?? ''
|
||||
aliasOpen.value = true
|
||||
}
|
||||
async function submitAlias() {
|
||||
if (!aliasForm.localPart.trim() || !aliasForm.domain || !aliasForm.destinationUserId) return
|
||||
aliasBusy.value = true
|
||||
try {
|
||||
await navigator.clipboard.writeText(alias)
|
||||
toast.ok('Alias copied', alias)
|
||||
} catch {
|
||||
toast.warn('Copy failed', 'Select and copy manually')
|
||||
await request(`/api/tenants/${slug.value}/mail/aliases`, { method: 'POST', body: { ...aliasForm } })
|
||||
await refreshAliases()
|
||||
toast.ok('Alias created')
|
||||
aliasOpen.value = false
|
||||
} catch (err) {
|
||||
toastErr(err, 'Could not create alias')
|
||||
} finally {
|
||||
aliasBusy.value = false
|
||||
}
|
||||
}
|
||||
async function confirmDeleteAlias() {
|
||||
const a = aliasDeleteTarget.value
|
||||
if (!a) return
|
||||
aliasDeleting.value = true
|
||||
try {
|
||||
await request(`/api/tenants/${slug.value}/mail/aliases/${encodeURIComponent(a.address)}`, { method: 'DELETE' })
|
||||
await refreshAliases()
|
||||
toast.ok('Alias deleted', a.address)
|
||||
aliasDeleteTarget.value = null
|
||||
} catch (err) {
|
||||
toastErr(err, 'Could not delete alias')
|
||||
} finally {
|
||||
aliasDeleting.value = false
|
||||
}
|
||||
}
|
||||
function copyText(t: string) {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) navigator.clipboard.writeText(t)
|
||||
toast.ok('Copied to clipboard')
|
||||
}
|
||||
|
||||
// ── Distribution lists ──
|
||||
const listOpen = ref(false)
|
||||
const listBusy = ref(false)
|
||||
const listForm = reactive({ localPart: '', domain: '', description: '', recipientsText: '' })
|
||||
const openList = ref<ListView | null>(null)
|
||||
const manageText = ref('')
|
||||
const manageBusy = ref(false)
|
||||
const listDeleteTarget = ref<ListView | null>(null)
|
||||
const listDeleting = ref(false)
|
||||
|
||||
function parseRecipients(text: string): string[] {
|
||||
return [...new Set(text.split(/[\s,;]+/).map((s) => s.trim().toLowerCase()).filter(Boolean))]
|
||||
}
|
||||
function openListCreate() {
|
||||
listForm.localPart = ''
|
||||
listForm.domain = primaryDomain.value?.domain ?? ''
|
||||
listForm.description = ''
|
||||
listForm.recipientsText = ''
|
||||
listOpen.value = true
|
||||
}
|
||||
async function submitList() {
|
||||
if (!listForm.localPart.trim() || !listForm.domain) return
|
||||
listBusy.value = true
|
||||
try {
|
||||
await request(`/api/tenants/${slug.value}/mail/lists`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
localPart: listForm.localPart.trim(),
|
||||
domain: listForm.domain,
|
||||
description: listForm.description.trim() || undefined,
|
||||
recipients: parseRecipients(listForm.recipientsText),
|
||||
},
|
||||
})
|
||||
await refreshLists()
|
||||
toast.ok('List created')
|
||||
listOpen.value = false
|
||||
} catch (err) {
|
||||
toastErr(err, 'Could not create list')
|
||||
} finally {
|
||||
listBusy.value = false
|
||||
}
|
||||
}
|
||||
function manage(l: ListView) {
|
||||
openList.value = l
|
||||
manageText.value = l.recipients.join('\n')
|
||||
}
|
||||
async function saveList() {
|
||||
const l = openList.value
|
||||
if (!l) return
|
||||
manageBusy.value = true
|
||||
try {
|
||||
await request(`/api/tenants/${slug.value}/mail/lists/${l.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { recipients: parseRecipients(manageText.value) },
|
||||
})
|
||||
await refreshLists()
|
||||
toast.ok('Members saved', l.address)
|
||||
openList.value = null
|
||||
} catch (err) {
|
||||
toastErr(err, 'Could not save members')
|
||||
} finally {
|
||||
manageBusy.value = false
|
||||
}
|
||||
}
|
||||
async function confirmDeleteList() {
|
||||
const l = listDeleteTarget.value
|
||||
if (!l) return
|
||||
listDeleting.value = true
|
||||
try {
|
||||
await request(`/api/tenants/${slug.value}/mail/lists/${l.id}`, { method: 'DELETE' })
|
||||
await refreshLists()
|
||||
toast.ok('List deleted', l.address)
|
||||
listDeleteTarget.value = null
|
||||
openList.value = null
|
||||
} catch (err) {
|
||||
toastErr(err, 'Could not delete list')
|
||||
} finally {
|
||||
listDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function aliasAction(alias: string, id: string) {
|
||||
if (id === 'edit') addAliasOpen.value = true
|
||||
else if (id === 'copy') copyAlias(alias)
|
||||
else if (id === 'disable') toast.info(`${alias} disabled`)
|
||||
else if (id === 'delete') toast.bad(`${alias} deleted`)
|
||||
}
|
||||
|
||||
const aliasItems = [
|
||||
{ id: 'edit', label: 'Edit alias', icon: 'brush' as const },
|
||||
{ id: 'copy', label: 'Copy address', icon: 'copy' as const },
|
||||
{ id: 'disable', label: 'Disable alias', icon: 'x' as const },
|
||||
{ id: 'sep1', separator: true },
|
||||
{ id: 'delete', label: 'Delete alias', icon: 'trash' as const, danger: true },
|
||||
]
|
||||
|
||||
function ruleAction(name: string, id: string) {
|
||||
if (id === 'edit') ruleOpen.value = true
|
||||
else if (id === 'run') toast.info(`Running "${name}" once`)
|
||||
else if (id === 'duplicate') toast.ok(`"${name}" duplicated`)
|
||||
else if (id === 'delete') toast.bad(`"${name}" deleted`)
|
||||
}
|
||||
const ruleItems = [
|
||||
{ id: 'edit', label: 'Edit rule', icon: 'brush' as const },
|
||||
{ id: 'run', label: 'Run once now', icon: 'refresh' as const },
|
||||
{ id: 'duplicate', label: 'Duplicate', icon: 'copy' as const },
|
||||
{ id: 'sep1', separator: true },
|
||||
{ id: 'delete', label: 'Delete rule', icon: 'trash' as const, danger: true },
|
||||
]
|
||||
|
||||
function filterAction(name: string, id: string) {
|
||||
if (id === 'edit') filterOpen.value = true
|
||||
else if (id === 'duplicate') toast.ok(`"${name}" duplicated`)
|
||||
else if (id === 'delete') toast.bad(`"${name}" deleted`)
|
||||
}
|
||||
const filterItems = [
|
||||
{ id: 'edit', label: 'Edit filter', icon: 'brush' as const },
|
||||
{ id: 'duplicate', label: 'Duplicate', icon: 'copy' as const },
|
||||
{ id: 'sep1', separator: true },
|
||||
{ id: 'delete', label: 'Delete filter', icon: 'trash' as const, danger: true },
|
||||
]
|
||||
|
||||
function confirmDeleteList() {
|
||||
deleteListOpen.value = false
|
||||
const name = openList.value?.name
|
||||
openList.value = null
|
||||
toast.bad(`${name} deleted`)
|
||||
}
|
||||
|
||||
const retention = ref<'30d' | '1year' | '3year' | 'unlimited'>('3year')
|
||||
const retentionOptions = [
|
||||
{ v: '30d' as const, label: '30 days', d: 'Standard retention. Anything older is permanently deleted.' },
|
||||
{ v: '1year' as const, label: '1 year', d: 'Mid-term. Suitable for most non-regulated businesses.' },
|
||||
{ v: '3year' as const, label: '3 years · recommended', d: 'Danish bookkeeping retention compliant (5-year option also available).' },
|
||||
{ v: 'unlimited' as const, label: 'Unlimited', d: 'Required for regulated industries (legal, healthcare, public sector).' },
|
||||
]
|
||||
|
||||
const tabs = [
|
||||
{ value: 'aliases', label: 'Aliases', count: orgAliases.length },
|
||||
{ value: 'forwarding', label: 'Forwarding', count: forwardingRules.length },
|
||||
{ value: 'filters', label: 'Filters · anti-spam', count: antiSpamFilters.length },
|
||||
{ value: 'lists', label: 'Distribution lists', count: distributionLists.length },
|
||||
{ value: 'compliance', label: 'Compliance · retention' },
|
||||
]
|
||||
|
||||
function toneFor(action: string): 'bad' | 'warn' | 'info' {
|
||||
return action === 'reject' ? 'bad' : action === 'quarantine' ? 'warn' : 'info'
|
||||
}
|
||||
const hasDomain = computed(() => (domains.value?.length ?? 0) > 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -114,7 +205,7 @@ function toneFor(action: string): 'bad' | 'warn' | 'info' {
|
||||
<PageHeader
|
||||
eyebrow="Workspace"
|
||||
title="Mail settings"
|
||||
subtitle="Organization-level aliases, forwarding, content filters, and compliance policies."
|
||||
subtitle="Aliases and distribution lists for your domains. Spam filtering and retention are managed by Dezky."
|
||||
/>
|
||||
<div class="tab-wrap">
|
||||
<Tabs v-model="tab" :items="tabs" />
|
||||
@@ -124,25 +215,26 @@ function toneFor(action: string): 'bad' | 'warn' | 'info' {
|
||||
<!-- ALIASES -->
|
||||
<template v-if="tab === 'aliases'">
|
||||
<div class="row">
|
||||
<div class="lead">Aliases route mail to existing users or distribution lists. They count against your domain, not your seats.</div>
|
||||
<UiButton variant="primary" @click="addAliasOpen = true">
|
||||
<div class="lead">Aliases route mail to an existing mailbox. They count against your domain, not your seats.</div>
|
||||
<UiButton variant="primary" :disabled="!hasDomain || mailboxUsers.length === 0" @click="openAlias">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
Add alias
|
||||
</UiButton>
|
||||
</div>
|
||||
<Card :pad="0">
|
||||
<Card v-if="!hasDomain" class="notice"><UiIcon name="globe" :size="18" stroke="var(--text-mute)" /> Add a domain first to create aliases.</Card>
|
||||
<Card v-else-if="(aliases?.length ?? 0) === 0" class="notice"><UiIcon name="mail" :size="18" stroke="var(--text-mute)" /> No aliases yet. Add one to route extra addresses to a mailbox.</Card>
|
||||
<Card v-else :pad="0">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>Alias</th><th></th><th>Destination</th><th>State</th><th>Created</th><th></th></tr></thead>
|
||||
<thead><tr><th>Alias</th><th></th><th>Delivers to</th><th>State</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in orgAliases" :key="r.alias">
|
||||
<td><Mono style="font-weight: 500">{{ r.alias }}</Mono></td>
|
||||
<tr v-for="a in aliases" :key="a.address">
|
||||
<td><Mono style="font-weight: 500">{{ a.address }}</Mono></td>
|
||||
<td><UiIcon name="arrowRight" :size="12" stroke="var(--text-mute)" /></td>
|
||||
<td>{{ r.dest }}</td>
|
||||
<td><Badge :tone="r.active ? 'ok' : 'neutral'" dot>{{ r.active ? 'active' : 'paused' }}</Badge></td>
|
||||
<td><Mono dim>{{ r.created }}</Mono></td>
|
||||
<td><Mono dim>{{ a.destination }}</Mono></td>
|
||||
<td><Badge :tone="a.enabled ? 'ok' : 'neutral'" dot>{{ a.enabled ? 'active' : 'paused' }}</Badge></td>
|
||||
<td class="right">
|
||||
<UiButton size="sm" variant="ghost" @click="copyAlias(r.alias)"><UiIcon name="copy" :size="13" /></UiButton>
|
||||
<AdminKebabMenu :items="aliasItems" :icon-size="13" @select="(id) => aliasAction(r.alias, id)" />
|
||||
<UiButton size="sm" variant="ghost" @click="copyText(a.address)"><UiIcon name="copy" :size="13" /></UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="aliasDeleteTarget = a"><UiIcon name="trash" :size="13" /></UiButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -150,288 +242,148 @@ function toneFor(action: string): 'bad' | 'warn' | 'info' {
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<!-- FORWARDING -->
|
||||
<template v-else-if="tab === 'forwarding'">
|
||||
<div class="row">
|
||||
<div class="lead">Conditional rules applied to all incoming mail. Useful for routing customer inquiries or auto-escalating.</div>
|
||||
<UiButton variant="primary" @click="ruleOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
New rule
|
||||
</UiButton>
|
||||
</div>
|
||||
<div class="rules">
|
||||
<Card v-for="r in forwardingRules" :key="r.name" :pad="16">
|
||||
<div class="rule-row">
|
||||
<button class="toggle" :class="{ on: ruleEnabled[r.name] }" @click="ruleEnabled[r.name] = !ruleEnabled[r.name]"><span /></button>
|
||||
<div class="rule-meta">
|
||||
<div class="rule-name">{{ r.name }}</div>
|
||||
<div class="rule-line">
|
||||
<Mono dim>WHEN</Mono>
|
||||
<span class="rule-match">{{ r.match }}</span>
|
||||
<UiIcon name="arrowRight" :size="11" stroke="var(--text-mute)" />
|
||||
<Mono dim>FORWARD TO</Mono>
|
||||
<Mono>{{ r.fwd }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost" @click="ruleOpen = true">Edit</UiButton>
|
||||
<AdminKebabMenu :items="ruleItems" @select="(id) => ruleAction(r.name, id)" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- FILTERS -->
|
||||
<template v-else-if="tab === 'filters'">
|
||||
<div class="row">
|
||||
<div class="lead">Org-wide content filters apply <i>before</i> user-level rules. Stalwart's spam engine handles the rest automatically.</div>
|
||||
<UiButton variant="primary" @click="filterOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
New filter
|
||||
</UiButton>
|
||||
</div>
|
||||
<Card :pad="0">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>Filter</th><th>Match</th><th>Action</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="f in antiSpamFilters" :key="f.name">
|
||||
<td>
|
||||
<div class="filter-name">
|
||||
<button class="toggle" :class="{ on: filterEnabled[f.name] }" @click="filterEnabled[f.name] = !filterEnabled[f.name]"><span /></button>
|
||||
<span>{{ f.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><Mono dim>{{ f.match }}</Mono></td>
|
||||
<td><Badge :tone="toneFor(f.action)">{{ f.action }}</Badge></td>
|
||||
<td class="right"><AdminKebabMenu :items="filterItems" @select="(id) => filterAction(f.name, id)" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
<div class="builtin">
|
||||
<UiIcon name="shield" :size="16" stroke="var(--ok)" />
|
||||
<div>
|
||||
<div class="builtin-title">Built-in spam protection · enabled</div>
|
||||
<div class="builtin-sub">
|
||||
Stalwart's reputation engine and Bayesian filter block ~94% of spam at the edge. <Mono>last 7d: 12,840 blocked · 18 quarantined · 0 false positives reported</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- LISTS -->
|
||||
<!-- DISTRIBUTION LISTS -->
|
||||
<template v-else-if="tab === 'lists'">
|
||||
<div class="row">
|
||||
<div class="lead">Distribution lists send mail to many recipients via a single alias. Members can be internal users, groups, or external addresses.</div>
|
||||
<UiButton variant="primary" @click="listOpen = true">
|
||||
<div class="lead">Distribution lists send mail to many recipients via a single address. Members can be any email address.</div>
|
||||
<UiButton variant="primary" :disabled="!hasDomain" @click="openListCreate">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
New list
|
||||
</UiButton>
|
||||
</div>
|
||||
<div class="lists">
|
||||
<Card v-for="l in distributionLists" :key="l.alias">
|
||||
<Card v-if="!hasDomain" class="notice"><UiIcon name="globe" :size="18" stroke="var(--text-mute)" /> Add a domain first to create lists.</Card>
|
||||
<Card v-else-if="(lists?.length ?? 0) === 0" class="notice"><UiIcon name="users" :size="18" stroke="var(--text-mute)" /> No distribution lists yet.</Card>
|
||||
<div v-else class="lists">
|
||||
<Card v-for="l in lists" :key="l.id">
|
||||
<div class="list-head">
|
||||
<div>
|
||||
<div class="list-title">
|
||||
<UiIcon name="users" :size="16" stroke="var(--text-mute)" />
|
||||
<span class="list-name">{{ l.name }}</span>
|
||||
<Badge v-if="l.external" tone="warn">external members</Badge>
|
||||
</div>
|
||||
<Mono dim style="display: block; margin-top: 4px">{{ l.alias }}</Mono>
|
||||
<div class="list-title"><UiIcon name="users" :size="16" stroke="var(--text-mute)" /><span class="list-name">{{ l.name }}</span></div>
|
||||
<Mono dim style="display: block; margin-top: 4px">{{ l.address }}</Mono>
|
||||
</div>
|
||||
<Badge :tone="l.moderation === 'open' ? 'ok' : 'neutral'">{{ l.moderation }}</Badge>
|
||||
<Badge tone="neutral">{{ l.members }} member{{ l.members === 1 ? '' : 's' }}</Badge>
|
||||
</div>
|
||||
<div v-if="l.description" class="list-desc">{{ l.description }}</div>
|
||||
<div class="list-row">
|
||||
<div>
|
||||
<Eyebrow>Members</Eyebrow>
|
||||
<div class="list-num">{{ l.members }}</div>
|
||||
</div>
|
||||
<div class="list-owner">
|
||||
<Eyebrow>Owner</Eyebrow>
|
||||
<div>{{ l.owner }}</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="secondary" @click="openList = l">Manage</UiButton>
|
||||
<UiButton size="sm" variant="secondary" @click="manage(l)">Manage members</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="listDeleteTarget = l"><template #leading><UiIcon name="trash" :size="13" /></template>Delete</UiButton>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- COMPLIANCE -->
|
||||
<!-- FORWARDING / FILTERS / COMPLIANCE — platform-managed -->
|
||||
<template v-else>
|
||||
<div class="compliance">
|
||||
<Card>
|
||||
<div class="card-head">
|
||||
<Eyebrow>Retention</Eyebrow>
|
||||
<div class="card-title">Mail retention policy</div>
|
||||
<div class="card-sub">Applied org-wide. Compliance requirements override user-level deletion.</div>
|
||||
</div>
|
||||
<div class="radio-big">
|
||||
<label v-for="o in retentionOptions" :key="o.v" :class="{ active: retention === o.v }">
|
||||
<span class="radio-dot"><span v-if="retention === o.v" /></span>
|
||||
<input type="radio" :value="o.v" v-model="retention" />
|
||||
<div>
|
||||
<div class="radio-label">{{ o.label }}</div>
|
||||
<div class="radio-d">{{ o.d }}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head card-head-inline">
|
||||
<div>
|
||||
<Eyebrow>Journaling</Eyebrow>
|
||||
<div class="card-title">Mail journaling</div>
|
||||
<div class="card-sub">Copy every inbound and outbound mail to a journal mailbox for e-discovery.</div>
|
||||
</div>
|
||||
<button class="toggle"><span /></button>
|
||||
</div>
|
||||
<div class="muted">
|
||||
When enabled, journals are written to <Mono>journal@dezky.com</Mono>. Storage usage counts against your plan. Available on Business and Enterprise plans.
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="card-head card-head-inline">
|
||||
<div>
|
||||
<Eyebrow>Legal hold</Eyebrow>
|
||||
<div class="card-title">Legal hold cases</div>
|
||||
</div>
|
||||
<UiButton size="sm" variant="secondary" @click="holdOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="13" /></template>
|
||||
New hold
|
||||
</UiButton>
|
||||
</div>
|
||||
<div class="empty">
|
||||
<UiIcon name="shield" :size="28" stroke="var(--text-mute)" />
|
||||
<div class="empty-title">No active holds</div>
|
||||
<div class="empty-body">When legal review is needed, place a hold on specific users or date ranges to prevent deletion.</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card class="managed">
|
||||
<UiIcon name="shield" :size="28" stroke="var(--text-mute)" />
|
||||
<div class="managed-title">{{ tab === 'forwarding' ? 'Forwarding rules' : tab === 'filters' ? 'Spam & content filtering' : 'Compliance & retention' }} are managed by Dezky</div>
|
||||
<div class="managed-body">
|
||||
<template v-if="tab === 'forwarding'">
|
||||
Conditional forwarding runs on the shared mail platform. To route addresses today, use <strong>Aliases</strong> (one address → a mailbox) or <strong>Distribution lists</strong> (one address → many). Rule-based forwarding is on the roadmap.
|
||||
</template>
|
||||
<template v-else-if="tab === 'filters'">
|
||||
Inbound mail passes through Dezky's reputation engine and Bayesian spam filter — tuned and updated centrally for every workspace. Per-workspace filter rules aren't configurable here.
|
||||
</template>
|
||||
<template v-else>
|
||||
Mail retention and legal-hold policies are applied platform-wide for EU compliance. Contact Dezky to adjust retention for your organisation.
|
||||
</template>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add alias modal (stub) -->
|
||||
<Modal :open="addAliasOpen" eyebrow="Mail · aliases" title="Add alias" size="md" @close="addAliasOpen = false">
|
||||
<!-- Add alias modal -->
|
||||
<Modal :open="aliasOpen" eyebrow="Mail · aliases" title="Add alias" size="md" @close="aliasOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Alias address</Eyebrow>
|
||||
<div class="alias-row">
|
||||
<input class="input" value="marketing" placeholder="prefix" />
|
||||
<input class="input" v-model="aliasForm.localPart" placeholder="sales" />
|
||||
<span class="at">@</span>
|
||||
<select class="input"><option>dezky.com</option><option>baslund.dk</option></select>
|
||||
<select class="input" v-model="aliasForm.domain">
|
||||
<option v-for="d in domains" :key="d.id" :value="d.domain">{{ d.domain }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field"><Eyebrow>Route to user</Eyebrow><input class="input" value="frederik@dezky.com" /></label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="addAliasOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="addAliasOpen = false">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Create alias
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Forwarding rule modal -->
|
||||
<Modal :open="ruleOpen" eyebrow="Mail · forwarding" title="New forwarding rule" size="md" @close="ruleOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Rule name</Eyebrow><input class="input" placeholder="Out-of-hours to on-call" /></label>
|
||||
<label class="field"><Eyebrow>When</Eyebrow><input class="input" placeholder="subject: …" /></label>
|
||||
<label class="field"><Eyebrow>Forward to</Eyebrow><input class="input" placeholder="oncall@dezky.com" /></label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="ruleOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="ruleOpen = false">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Save rule
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- New filter modal -->
|
||||
<Modal :open="filterOpen" eyebrow="Mail · filters" title="New content filter" size="md" @close="filterOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Filter name</Eyebrow><input class="input" placeholder="Block executable attachments" /></label>
|
||||
<label class="field"><Eyebrow>Match expression</Eyebrow><input class="input" placeholder="attachment ext in (.exe, .scr, .bat)" /></label>
|
||||
<label class="field"><Eyebrow>Action</Eyebrow>
|
||||
<select class="input"><option>reject</option><option>quarantine</option><option>add tag</option></select>
|
||||
<label class="field"><Eyebrow>Delivers to mailbox</Eyebrow>
|
||||
<select class="input" v-model="aliasForm.destinationUserId">
|
||||
<option v-for="u in mailboxUsers" :key="u._id" :value="u._id">{{ u.name }} · {{ u.email }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="filterOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="filterOpen = false">Create filter</UiButton>
|
||||
<UiButton variant="ghost" @click="aliasOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" :disabled="aliasBusy || !aliasForm.localPart.trim() || !aliasForm.destinationUserId" @click="submitAlias">
|
||||
{{ aliasBusy ? 'Creating…' : 'Create alias' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="!!aliasDeleteTarget"
|
||||
eyebrow="Mail · aliases"
|
||||
:title="`Delete ${aliasDeleteTarget?.address}?`"
|
||||
confirm-label="Delete alias"
|
||||
tone="danger"
|
||||
:busy="aliasDeleting"
|
||||
@close="aliasDeleteTarget = null"
|
||||
@confirm="confirmDeleteAlias"
|
||||
>
|
||||
Mail to <strong>{{ aliasDeleteTarget?.address }}</strong> will stop being delivered. The destination mailbox is unaffected.
|
||||
</ConfirmDialog>
|
||||
|
||||
<!-- New list modal -->
|
||||
<Modal :open="listOpen" eyebrow="Mail · lists" title="New distribution list" size="md" @close="listOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>List name</Eyebrow><input class="input" placeholder="Engineering" /></label>
|
||||
<label class="field"><Eyebrow>Alias</Eyebrow><input class="input" placeholder="eng@dezky.com" /></label>
|
||||
<label class="field"><Eyebrow>Owner</Eyebrow><input class="input" value="Anne Baslund" /></label>
|
||||
<label class="field"><Eyebrow>List address</Eyebrow>
|
||||
<div class="alias-row">
|
||||
<input class="input" v-model="listForm.localPart" placeholder="team" />
|
||||
<span class="at">@</span>
|
||||
<select class="input" v-model="listForm.domain">
|
||||
<option v-for="d in domains" :key="d.id" :value="d.domain">{{ d.domain }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field"><Eyebrow>Description <span class="opt">optional</span></Eyebrow><input class="input" v-model="listForm.description" placeholder="Everyone in engineering" /></label>
|
||||
<label class="field"><Eyebrow>Members <span class="opt">one email per line</span></Eyebrow>
|
||||
<textarea class="input area" v-model="listForm.recipientsText" rows="4" placeholder="anne@acme.dk mikkel@acme.dk"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="listOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="listOpen = false">Create list</UiButton>
|
||||
<UiButton variant="primary" :disabled="listBusy || !listForm.localPart.trim()" @click="submitList">
|
||||
{{ listBusy ? 'Creating…' : 'Create list' }}
|
||||
</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Manage list side panel -->
|
||||
<SidePanel :open="!!openList" eyebrow="Distribution list" :title="openList?.name || ''" width="lg" @close="openList = null">
|
||||
<!-- Manage list members -->
|
||||
<SidePanel :open="!!openList" eyebrow="Distribution list" :title="openList?.address || ''" width="lg" @close="openList = null">
|
||||
<div v-if="openList" class="manage">
|
||||
<div class="manage-head">
|
||||
<div class="manage-icon"><UiIcon name="users" :size="20" /></div>
|
||||
<div class="manage-meta">
|
||||
<div class="manage-name">{{ openList.name }}</div>
|
||||
<Mono dim>{{ openList.alias }}</Mono>
|
||||
</div>
|
||||
<Badge :tone="openList.moderation === 'open' ? 'ok' : 'neutral'">{{ openList.moderation }}</Badge>
|
||||
</div>
|
||||
<div class="manage-stats">
|
||||
<div><Eyebrow>Members</Eyebrow><div class="ms-v">{{ openList.members }}</div></div>
|
||||
<div><Eyebrow>Owner</Eyebrow><div class="ms-v">{{ openList.owner }}</div></div>
|
||||
<div><Eyebrow>Posts this week</Eyebrow><div class="ms-v">{{ openList.members > 8 ? '142' : openList.members > 2 ? '38' : '6' }}</div></div>
|
||||
</div>
|
||||
<Eyebrow>Members <span class="opt">one email per line</span></Eyebrow>
|
||||
<textarea class="input area" v-model="manageText" rows="12" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="danger" @click="deleteListOpen = true">
|
||||
<UiButton variant="danger" @click="openList && (listDeleteTarget = openList)">
|
||||
<template #leading><UiIcon name="trash" :size="13" /></template>
|
||||
Delete list
|
||||
</UiButton>
|
||||
<div style="flex: 1" />
|
||||
<UiButton variant="secondary" @click="openList = null">Discard</UiButton>
|
||||
<UiButton variant="primary" @click="openList = null; toast.ok('List saved')">
|
||||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||||
Save changes
|
||||
</UiButton>
|
||||
<UiButton variant="secondary" @click="openList = null">Cancel</UiButton>
|
||||
<UiButton variant="primary" :disabled="manageBusy" @click="saveList">{{ manageBusy ? 'Saving…' : 'Save members' }}</UiButton>
|
||||
</template>
|
||||
</SidePanel>
|
||||
|
||||
<!-- Confirm delete list -->
|
||||
<ConfirmDialog
|
||||
:open="deleteListOpen"
|
||||
:open="!!listDeleteTarget"
|
||||
eyebrow="Distribution list"
|
||||
:title="`Delete ${openList?.name || ''}?`"
|
||||
:title="`Delete ${listDeleteTarget?.address}?`"
|
||||
confirm-label="Delete list"
|
||||
tone="danger"
|
||||
@close="deleteListOpen = false"
|
||||
:busy="listDeleting"
|
||||
@close="listDeleteTarget = null"
|
||||
@confirm="confirmDeleteList"
|
||||
>
|
||||
Mail sent to this list will start bouncing immediately. Existing replies in members' inboxes are unaffected.
|
||||
Mail to this list will start bouncing immediately. Members' existing inboxes are unaffected.
|
||||
</ConfirmDialog>
|
||||
|
||||
<!-- Legal hold modal -->
|
||||
<Modal :open="holdOpen" eyebrow="Compliance · legal hold" title="Place legal hold" size="md" @close="holdOpen = false">
|
||||
<div class="form-stack">
|
||||
<label class="field"><Eyebrow>Case name</Eyebrow><input class="input" placeholder="Case 2026-Q3-DPA-001" /></label>
|
||||
<label class="field"><Eyebrow>Scope · users</Eyebrow><input class="input" placeholder="anne@, mikkel@, frederik@" /></label>
|
||||
<label class="field"><Eyebrow>Date range</Eyebrow><input class="input" placeholder="2026-01-01 → 2026-12-31" /></label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="holdOpen = false">Cancel</UiButton>
|
||||
<UiButton variant="primary" @click="holdOpen = false">Place hold</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -439,121 +391,36 @@ function toneFor(action: string): 'bad' | 'warn' | 'info' {
|
||||
.tab-wrap { padding: 16px 40px 0 40px; }
|
||||
.content { padding: 20px 40px 64px 40px; }
|
||||
|
||||
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; gap: 16px; }
|
||||
.lead { font-size: 13px; color: var(--text-mute); max-width: 540px; line-height: 1.5; }
|
||||
|
||||
.notice { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--text-mute); }
|
||||
|
||||
.tbl { width: 100%; border-collapse: collapse; }
|
||||
.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;
|
||||
}
|
||||
.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; }
|
||||
.tbl td { padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
.tbl tr:last-child td { border-bottom: none; }
|
||||
.tbl .right { text-align: right; }
|
||||
|
||||
.rules { display: flex; flex-direction: column; gap: 10px; }
|
||||
.rule-row { display: flex; align-items: center; gap: 14px; }
|
||||
.rule-meta { flex: 1; }
|
||||
.rule-name { font-size: 14px; font-weight: 500; }
|
||||
.rule-line { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 12px; }
|
||||
.rule-match { font-family: var(--font-mono); color: var(--text-dim); }
|
||||
|
||||
.toggle {
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
border: none;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toggle span {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg);
|
||||
transition: left 120ms;
|
||||
}
|
||||
.toggle.on { background: var(--text); }
|
||||
.toggle.on span { left: 16px; background: var(--accent); }
|
||||
|
||||
.filter-name { display: flex; align-items: center; gap: 10px; }
|
||||
.builtin {
|
||||
margin-top: 16px;
|
||||
padding: 14px;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.builtin-title { font-size: 13px; font-weight: 600; }
|
||||
.builtin-sub { color: var(--text-mute); margin-top: 4px; font-size: 13px; }
|
||||
.tbl .right { text-align: right; white-space: nowrap; }
|
||||
|
||||
.lists { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.list-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.list-title { display: flex; align-items: center; gap: 8px; }
|
||||
.list-name { font-family: var(--font-display); font-weight: 600; font-size: 16px; }
|
||||
.list-row { display: flex; gap: 18px; margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); align-items: flex-end; }
|
||||
.list-num { font-family: var(--font-display); font-weight: 600; font-size: 20px; margin-top: 4px; }
|
||||
.list-owner { flex: 1; font-size: 13px; }
|
||||
.list-desc { font-size: 12px; color: var(--text-mute); margin-top: 10px; }
|
||||
.list-row { display: flex; gap: 8px; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border); }
|
||||
|
||||
.compliance { display: flex; flex-direction: column; gap: 16px; max-width: 900px; }
|
||||
.card-head { margin-bottom: 12px; }
|
||||
.card-head-inline { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.card-title { font-family: var(--font-display); font-weight: 600; font-size: 18px; letter-spacing: -0.01em; margin-top: 4px; }
|
||||
.card-sub { font-size: 13px; color: var(--text-mute); margin-top: 4px; }
|
||||
.muted { font-size: 13px; color: var(--text-mute); line-height: 1.6; }
|
||||
.radio-big { display: flex; flex-direction: column; gap: 8px; }
|
||||
.radio-big label { display: flex; gap: 12px; padding: 14px; border: 1px solid var(--border); border-radius: 6px; cursor: pointer; }
|
||||
.radio-big label.active { border-color: var(--text); background: var(--bg); }
|
||||
.radio-big input { display: none; }
|
||||
.radio-dot { width: 18px; height: 18px; border-radius: 999px; border: 2px solid var(--border); display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px; }
|
||||
.radio-big label.active .radio-dot { border-color: var(--text); }
|
||||
.radio-dot span { width: 8px; height: 8px; border-radius: 999px; background: var(--text); }
|
||||
.radio-label { font-size: 14px; font-weight: 500; }
|
||||
.radio-d { font-size: 12px; color: var(--text-mute); margin-top: 4px; }
|
||||
.managed { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 10px; padding: 48px 32px; max-width: 640px; margin: 0 auto; }
|
||||
.managed-title { font-family: var(--font-display); font-weight: 600; font-size: 17px; }
|
||||
.managed-body { font-size: 13px; color: var(--text-mute); line-height: 1.6; max-width: 480px; }
|
||||
|
||||
.empty { padding: 36px 24px; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||
.empty-title { font-family: var(--font-display); font-weight: 600; font-size: 15px; }
|
||||
.empty-body { font-size: 13px; color: var(--text-mute); max-width: 420px; line-height: 1.5; }
|
||||
|
||||
/* Modal forms */
|
||||
.form-stack { display: flex; flex-direction: column; gap: 14px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.opt { font-weight: 400; color: var(--text-mute); text-transform: none; letter-spacing: 0; }
|
||||
.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); }
|
||||
.area { height: auto; padding: 10px 12px; font-family: var(--font-mono); line-height: 1.5; resize: vertical; }
|
||||
.alias-row { display: grid; grid-template-columns: 1fr auto 1fr; gap: 8px; align-items: center; }
|
||||
.at { font-family: var(--font-mono); color: var(--text-mute); }
|
||||
|
||||
.manage { padding-bottom: 24px; }
|
||||
.manage-head { display: flex; align-items: center; gap: 14px; }
|
||||
.manage-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.manage-meta { flex: 1; min-width: 0; }
|
||||
.manage-name { font-family: var(--font-display); font-weight: 600; font-size: 18px; }
|
||||
.manage-stats { display: flex; gap: 24px; margin-top: 16px; }
|
||||
.ms-v { font-size: 13px; margin-top: 4px; font-weight: 500; }
|
||||
.manage { padding-bottom: 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user