47eb9502f8
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.
471 lines
17 KiB
Vue
471 lines
17 KiB
Vue
<script setup lang="ts">
|
|
// Portal sidebar. Faithful port of project/platform-app.jsx `Sidebar`. Always
|
|
// carbon. Workspace switcher button on top, nav in the middle, user footer at
|
|
// the bottom. Item sets vary by role:
|
|
//
|
|
// end-user → END_USER_NAV (flat list, no sections)
|
|
// customer admin → ADMIN_NAV (Workspace / Commercial / Other sections)
|
|
// partner admin → PARTNER_NAV (Commercial / Partner sections)
|
|
// partner-in-customer → ADMIN_NAV (acts-as), with "Exit partner view" chip
|
|
// immediately under the switcher
|
|
//
|
|
// Personal pages (profile, devices, security, notifications) are NOT in the
|
|
// admin/partner sidebar — they're reached via the topbar user menu in the
|
|
// source design.
|
|
|
|
import type { IconName } from './UiIcon.vue'
|
|
import type { PartnerTenantDoc } from '~/types/partner'
|
|
import type { TenantUserDoc } from '~/types/workspace'
|
|
|
|
interface NavItem {
|
|
id: string
|
|
label: string
|
|
icon: IconName
|
|
href: string
|
|
badge?: number | string
|
|
}
|
|
interface NavSection { sec: string }
|
|
type NavRow = NavItem | NavSection
|
|
const isSection = (r: NavRow): r is NavSection => 'sec' in r
|
|
|
|
const { state } = usePortalTweaks()
|
|
const { collapsed, toggle } = useSidebar()
|
|
const partnerMode = usePartnerMode()
|
|
const route = useRoute()
|
|
|
|
// Section context is derived from the URL prefix, not the role tweak. This
|
|
// keeps the shell self-consistent: visiting /partner always shows the partner
|
|
// sidebar, /admin always shows admin, everything else is the end-user surface.
|
|
// The role tweak in TweaksPanel is a "preview as" affordance — it navigates
|
|
// you to the right landing page on switch, but it doesn't override the shell.
|
|
type Section = 'partner' | 'admin' | 'user'
|
|
const section = computed<Section>(() => {
|
|
if (partnerMode.isActive.value) return 'admin' // partner acting-as a customer
|
|
if (route.path.startsWith('/partner')) return 'partner'
|
|
if (route.path.startsWith('/admin')) return 'admin'
|
|
return 'user'
|
|
})
|
|
|
|
// "My profile" lives in the topbar avatar menu, not the sidebar — keeps the
|
|
// sidebar focused on places (workspace apps + admin work) while personal
|
|
// settings are one consistent menu-click away from any screen.
|
|
const END_USER_NAV: NavRow[] = [
|
|
{ id: 'dashboard', label: 'Dashboard', icon: 'home', href: '/' },
|
|
{ id: 'devices', label: 'Devices & sessions', icon: 'device', href: '/devices' },
|
|
{ id: 'security', label: 'Security', icon: 'shield', href: '/security' },
|
|
{ id: 'support', label: 'Help & support', icon: 'help', href: '/help' },
|
|
]
|
|
|
|
const ADMIN_NAV: NavRow[] = [
|
|
{ id: 'dashboard', label: 'Dashboard', icon: 'home', href: '/admin' },
|
|
{ sec: 'Workspace' },
|
|
{ id: 'users', label: 'Users & groups', icon: 'users', href: '/admin/users' },
|
|
{ id: 'mail', label: 'Mail settings', icon: 'mail', href: '/admin/mail' },
|
|
{ id: 'meetings', label: 'Meetings', icon: 'video', href: '/admin/meetings' },
|
|
{ id: 'chat', label: 'Chat', icon: 'chat', href: '/admin/chat' },
|
|
{ id: 'domains', label: 'Domains', icon: 'globe', href: '/admin/domains' },
|
|
{ id: 'storage', label: 'Storage', icon: 'database', href: '/admin/storage' },
|
|
{ id: 'security', label: 'Security & audit', icon: 'shield', href: '/admin/security' },
|
|
{ sec: 'Commercial' },
|
|
{ id: 'billing', label: 'Billing', icon: 'card', href: '/admin/billing' },
|
|
{ id: 'branding', label: 'Branding', icon: 'brush', href: '/admin/branding' },
|
|
{ id: 'integrations', label: 'Integrations', icon: 'plug', href: '/admin/integrations' },
|
|
{ sec: 'Other' },
|
|
{ id: 'support', label: 'Help & support', icon: 'help', href: '/help' },
|
|
]
|
|
|
|
const PARTNER_NAV: NavRow[] = [
|
|
{ id: 'p_dashboard', label: 'Partner dashboard', icon: 'home', href: '/partner' },
|
|
{ id: 'p_customers', label: 'Customer orgs', icon: 'building', href: '/partner/customers' },
|
|
{ sec: 'Commercial' },
|
|
{ id: 'p_billing', label: 'Partner billing', icon: 'card', href: '/partner/billing' },
|
|
{ id: 'p_reports', label: 'Reports', icon: 'database', href: '/partner/reports' },
|
|
{ sec: 'Partner' },
|
|
{ id: 'p_branding', label: 'Branding defaults', icon: 'brush', href: '/partner/branding' },
|
|
{ id: 'p_team', label: 'Partner team', icon: 'users', href: '/partner/team' },
|
|
{ id: 'p_audit', label: 'Partner audit', icon: 'file', href: '/partner/audit' },
|
|
{ id: 'p_settings', label: 'Partner settings', icon: 'shield', href: '/partner/settings' },
|
|
]
|
|
|
|
const navItems = computed<NavRow[]>(() => {
|
|
if (section.value === 'partner') {
|
|
// Inject the live customer count onto the Customer orgs row. Undefined
|
|
// when the count is 0 so the badge hides rather than rendering "0".
|
|
return PARTNER_NAV.map((row) =>
|
|
'id' in row && row.id === 'p_customers'
|
|
? { ...row, badge: partnerCustomerCount.value || undefined }
|
|
: row,
|
|
)
|
|
}
|
|
if (section.value === 'admin') {
|
|
// Inject the count of domains needing attention onto the Domains row.
|
|
// Undefined when 0 so the badge hides rather than rendering "0".
|
|
return ADMIN_NAV.map((row) =>
|
|
'id' in row && row.id === 'domains'
|
|
? { ...row, badge: domainsNeedingAttention.value || undefined }
|
|
: row,
|
|
)
|
|
}
|
|
return END_USER_NAV
|
|
})
|
|
|
|
// Active row resolution by URL path. Specific paths first, then more general.
|
|
const currentId = computed(() => {
|
|
const p = route.path
|
|
if (p === '/') return 'dashboard'
|
|
if (p.startsWith('/profile')) return 'profile'
|
|
if (p.startsWith('/devices')) return 'devices'
|
|
if (p.startsWith('/security')) return 'security'
|
|
if (p.startsWith('/help')) return 'support'
|
|
if (p === '/admin') return 'dashboard'
|
|
if (p.startsWith('/admin/users')) return 'users'
|
|
if (p.startsWith('/admin/mail')) return 'mail'
|
|
if (p.startsWith('/admin/meetings')) return 'meetings'
|
|
if (p.startsWith('/admin/chat')) return 'chat'
|
|
if (p.startsWith('/admin/domains')) return 'domains'
|
|
if (p.startsWith('/admin/storage')) return 'storage'
|
|
if (p.startsWith('/admin/security')) return 'security'
|
|
if (p.startsWith('/admin/billing')) return 'billing'
|
|
if (p.startsWith('/admin/branding')) return 'branding'
|
|
if (p.startsWith('/admin/integrations')) return 'integrations'
|
|
if (p === '/partner') return 'p_dashboard'
|
|
if (p.startsWith('/partner/customers')) return 'p_customers'
|
|
if (p.startsWith('/partner/billing')) return 'p_billing'
|
|
if (p.startsWith('/partner/reports')) return 'p_reports'
|
|
if (p.startsWith('/partner/branding')) return 'p_branding'
|
|
if (p.startsWith('/partner/team')) return 'p_team'
|
|
if (p.startsWith('/partner/audit')) return 'p_audit'
|
|
if (p.startsWith('/partner/settings')) return 'p_settings'
|
|
return ''
|
|
})
|
|
|
|
// Workspace-switcher content matches the URL section.
|
|
type SwitcherKind = 'customer' | 'partner' | 'in-customer'
|
|
const switcherKind = computed<SwitcherKind>(() => {
|
|
if (partnerMode.isActive.value) return 'in-customer'
|
|
if (section.value === 'partner') return 'partner'
|
|
return 'customer'
|
|
})
|
|
|
|
const router = useRouter()
|
|
function exitCustomer() {
|
|
partnerMode.exit()
|
|
router.push('/partner/customers')
|
|
}
|
|
|
|
// Real partner identity + customer count. Only fetched for partner-staff
|
|
// users (gated via isPartnerStaff) — keeps the end-user / admin shells from
|
|
// hitting a 403 against the partner-scoped endpoint. useFetch with a stable
|
|
// key dedupes with the /partner/customers page's request.
|
|
const { partner, isPartnerStaff } = useMe()
|
|
const { data: partnerTenants } = await useFetch<PartnerTenantDoc[]>('/api/partner/tenants', {
|
|
key: 'partner-tenants',
|
|
default: () => [],
|
|
immediate: isPartnerStaff.value,
|
|
})
|
|
const partnerCustomerCount = computed(() => partnerTenants.value?.length ?? 0)
|
|
|
|
// The signed-in user's own workspace (for the customer switcher tile). Real
|
|
// name, plan and accent come from /api/me.
|
|
const { tenant: ownTenant, planLabel, seatLimit } = useTenant()
|
|
const ownSlug = computed(() => ownTenant.value?.slug ?? '')
|
|
|
|
// Seat usage for the switcher sub-line. Gated to non-partner members so the
|
|
// global shell never 403s the membership-scoped endpoint; shared key keeps it
|
|
// to one request.
|
|
const { data: ownUsers } = await useFetch<TenantUserDoc[]>(
|
|
() => `/api/tenants/${ownSlug.value}/users`,
|
|
{
|
|
key: 'sidebar-ws-users',
|
|
default: () => [],
|
|
immediate: !isPartnerStaff.value && !!ownSlug.value,
|
|
watch: [ownSlug],
|
|
},
|
|
)
|
|
const seatsUsed = computed(() => (ownUsers.value ?? []).filter((u) => u.active !== false).length)
|
|
|
|
// Domains needing attention (anything not fully verified) drive the Domains nav
|
|
// badge. Shares the 'admin-domains' fetch key with the Domains page, so adding
|
|
// or fixing a domain updates the badge live. Gated like the seat usage fetch.
|
|
const { data: sidebarDomains } = await useFetch<{ status: string }[]>(
|
|
() => `/api/tenants/${ownSlug.value}/domains`,
|
|
{
|
|
key: 'admin-domains',
|
|
default: () => [],
|
|
immediate: !isPartnerStaff.value && !!ownSlug.value,
|
|
watch: [ownSlug],
|
|
},
|
|
)
|
|
const domainsNeedingAttention = computed(
|
|
() => (sidebarDomains.value ?? []).filter((d) => d.status !== 'active').length,
|
|
)
|
|
|
|
// Workspace mark colours. Default to the signal accent when no brandColor is
|
|
// saved (matches the Branding preview); readableOn flips the initial light on
|
|
// dark accents so it stays legible for any chosen colour.
|
|
const DEFAULT_BRAND = '#D4FF3A'
|
|
const brandBg = computed(() => ownTenant.value?.brandColor || DEFAULT_BRAND)
|
|
const brandFg = computed(() => readableOn(brandBg.value))
|
|
|
|
// Customer currently being acted-as (partner-in-customer mode), resolved from
|
|
// the real tenant list by the _id stored in partner mode.
|
|
const activeCustomer = computed(() =>
|
|
(partnerTenants.value ?? []).find((c) => c._id === partnerMode.activeCustomerId.value) || null,
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<aside class="sidebar" :class="{ collapsed }">
|
|
<!-- Workspace switcher -->
|
|
<button class="switcher" :title="collapsed ? 'Workspace' : undefined">
|
|
<!-- Customer admin: brand-colour tile with the workspace initial,
|
|
matching the Branding live-preview mark. -->
|
|
<template v-if="switcherKind === 'customer'">
|
|
<span class="ws-tile brand" :style="{ background: brandBg, color: brandFg }">
|
|
{{ (ownTenant?.name?.[0] || 'a').toLowerCase() }}
|
|
</span>
|
|
<div v-if="!collapsed" class="ws-text">
|
|
<div class="ws-name">{{ ownTenant?.name || 'Workspace' }}</div>
|
|
<div class="ws-sub">{{ planLabel }}{{ seatLimit ? ` · ${seatsUsed}/${seatLimit}` : '' }}</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Partner admin (portfolio view): carbon tile with chartreuse 'n' -->
|
|
<template v-else-if="switcherKind === 'partner'">
|
|
<span class="ws-tile carbon">{{ (partner?.name ?? 'n').charAt(0).toLowerCase() }}</span>
|
|
<div v-if="!collapsed" class="ws-text">
|
|
<div class="ws-name">{{ partner?.name ?? '—' }}</div>
|
|
<div class="ws-sub">
|
|
Partner · {{ partnerCustomerCount }} {{ partnerCustomerCount === 1 ? 'customer' : 'customers' }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Partner-in-customer mode: customer brand color tile, "via NordicMSP" -->
|
|
<template v-else>
|
|
<span class="ws-tile" :style="{ background: activeCustomer?.brandColor || '#0A0A0A' }" />
|
|
<div v-if="!collapsed" class="ws-text">
|
|
<div class="ws-name">{{ activeCustomer?.name }}</div>
|
|
<div class="ws-sub mono">via {{ partner?.name ?? 'partner' }}</div>
|
|
</div>
|
|
</template>
|
|
|
|
<UiIcon v-if="!collapsed" name="chevUpDown" :size="14" stroke="var(--side-mute)" />
|
|
</button>
|
|
|
|
<!-- Exit partner view chip (when acting-as a customer) -->
|
|
<button v-if="partnerMode.isActive.value" class="exit-chip" @click="exitCustomer">
|
|
<UiIcon name="chevLeft" :size="13" />
|
|
<span v-if="!collapsed">Exit partner view</span>
|
|
</button>
|
|
|
|
<!-- Nav -->
|
|
<nav>
|
|
<template v-for="(item, i) in navItems" :key="i">
|
|
<div v-if="isSection(item) && !collapsed" class="section">{{ item.sec }}</div>
|
|
<div v-else-if="isSection(item)" class="section-spacer" />
|
|
<NuxtLink
|
|
v-else
|
|
:to="item.href"
|
|
:class="['row', { active: currentId === item.id }]"
|
|
:title="collapsed ? item.label : undefined"
|
|
>
|
|
<UiIcon :name="item.icon" :size="15" />
|
|
<span v-if="!collapsed" class="label">{{ item.label }}</span>
|
|
<span v-if="!collapsed && item.badge !== undefined" class="badge">{{ item.badge }}</span>
|
|
</NuxtLink>
|
|
</template>
|
|
</nav>
|
|
|
|
<!-- Footer: collapse toggle only. The user identity block lives in the
|
|
topbar avatar menu — no need to duplicate it here. -->
|
|
<div class="foot">
|
|
<button class="collapse" @click="toggle" :title="collapsed ? 'Expand · ⌘[' : 'Collapse · ⌘['">
|
|
<UiIcon :name="collapsed ? 'chevRight' : 'chevLeft'" :size="11" />
|
|
<span v-if="!collapsed">collapse · ⌘[</span>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.sidebar {
|
|
width: 232px;
|
|
flex-shrink: 0;
|
|
background: var(--side-bg);
|
|
color: var(--side-text);
|
|
border-right: 1px solid var(--side-border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-self: stretch;
|
|
transition: width 180ms ease;
|
|
position: sticky;
|
|
top: 0;
|
|
max-height: 100vh;
|
|
}
|
|
.sidebar.collapsed { width: 56px; }
|
|
|
|
/* Workspace switcher row */
|
|
.switcher {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 14px;
|
|
margin: 8px;
|
|
background: transparent;
|
|
border: 1px solid transparent;
|
|
border-radius: 8px;
|
|
color: inherit;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
min-height: 36px;
|
|
}
|
|
.switcher:hover { background: var(--side-hover); }
|
|
.sidebar.collapsed .switcher { padding: 8px; justify-content: center; margin: 8px 6px; }
|
|
|
|
.ws-tile {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 8px;
|
|
flex-shrink: 0;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.ws-tile.bone { background: #F4F3EE; }
|
|
.ws-tile.carbon {
|
|
background: #0A0A0A;
|
|
color: var(--signal);
|
|
font-family: var(--font-mono);
|
|
font-weight: 700;
|
|
font-size: 18px;
|
|
}
|
|
/* Customer workspace mark — brand colour bg + auto-contrast initial (bg + color
|
|
set inline; the initial flips light/dark by luminance). */
|
|
.ws-tile.brand {
|
|
font-family: var(--font-mono);
|
|
font-weight: 700;
|
|
font-size: 18px;
|
|
}
|
|
|
|
.ws-text { flex: 1; min-width: 0; }
|
|
.ws-name {
|
|
font-family: var(--font-mono);
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
line-height: 1.2;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.ws-sub {
|
|
font-size: 11px;
|
|
color: var(--side-mute);
|
|
margin-top: 2px;
|
|
}
|
|
.ws-sub.mono { font-family: var(--font-mono); font-size: 10px; }
|
|
|
|
/* Exit partner chip — sits between switcher and nav */
|
|
.exit-chip {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin: 0 8px 8px 8px;
|
|
padding: 8px 12px;
|
|
background: rgba(125, 160, 255, 0.14);
|
|
color: #A8C0FF;
|
|
border: 1px solid rgba(125, 160, 255, 0.18);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
font-size: 12px;
|
|
text-align: left;
|
|
}
|
|
.exit-chip:hover { background: rgba(125, 160, 255, 0.22); }
|
|
.sidebar.collapsed .exit-chip { justify-content: center; padding: 8px 0; }
|
|
|
|
/* Nav */
|
|
nav {
|
|
flex: 1;
|
|
padding: 4px 8px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.section {
|
|
padding: 14px 12px 6px 12px;
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
letter-spacing: 0.16em;
|
|
text-transform: uppercase;
|
|
color: var(--side-mute);
|
|
font-weight: 500;
|
|
}
|
|
.section-spacer { height: 12px; }
|
|
|
|
.row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
width: 100%;
|
|
padding: 8px 12px;
|
|
background: transparent;
|
|
color: var(--side-dim);
|
|
border: none;
|
|
border-radius: 6px;
|
|
text-decoration: none;
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
font-weight: 400;
|
|
margin-bottom: 1px;
|
|
transition: background 0.12s;
|
|
}
|
|
.sidebar.collapsed .row { padding: 8px 0; justify-content: center; }
|
|
.row:hover { background: var(--side-hover); color: var(--side-text); }
|
|
.row.active {
|
|
background: var(--side-active);
|
|
color: var(--side-text);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.label { flex: 1; min-width: 0; }
|
|
|
|
/* Source uses signal accent for badges */
|
|
.badge {
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
padding: 1px 6px;
|
|
border-radius: 3px;
|
|
background: var(--accent);
|
|
color: var(--accent-fg);
|
|
font-weight: 600;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
/* User footer */
|
|
.foot {
|
|
border-top: 1px solid var(--side-border);
|
|
padding: 8px;
|
|
}
|
|
|
|
/* Collapse toggle */
|
|
.collapse {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
width: 100%;
|
|
padding: 8px;
|
|
margin-top: 4px;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 6px;
|
|
color: var(--side-mute);
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
letter-spacing: 0.04em;
|
|
cursor: pointer;
|
|
}
|
|
.collapse:hover { background: var(--side-hover); color: var(--side-dim); }
|
|
.sidebar.collapsed .collapse { justify-content: center; padding: 8px 0; }
|
|
</style>
|