0bd4e5498e
- portal: new admin/ and partner/ surfaces with full component library (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables, layouts, partner-routing middleware, and supporting server APIs - pricing: Price schema/module with operator CRUD, pricing.vue catalog UI, Subscription extended with cycle/currency/perSeatAmount/seats snapshots for stable MRR aggregation - partner staff: User.partnerId, invite-partner-user DTO and flow, /partners/:slug/users endpoints, InvitePartnerUserModal, shared dezky-partner-staff Authentik group - /me: partner-aware endpoint returning user + partner context so portal can route between end-user and partner-admin surfaces - tenant: seats field for portfolio displays and future MRR calculations - operator: pricing page, signed-out page, useMe/useToast composables, ToastStack
445 lines
15 KiB
Vue
445 lines
15 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 { customers as fixtureCustomers } from '~/data/customers'
|
|
|
|
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', badge: 1 },
|
|
{ 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') return ADMIN_NAV
|
|
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 ''
|
|
})
|
|
|
|
// Customer currently being acted-as (partner-in-customer mode)
|
|
const activeCustomer = computed(() =>
|
|
fixtureCustomers.find((c) => c.id === partnerMode.activeCustomerId.value) || null,
|
|
)
|
|
|
|
// 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<unknown[]>('/api/partner/tenants', {
|
|
key: 'partner-tenants',
|
|
default: () => [],
|
|
immediate: isPartnerStaff.value,
|
|
})
|
|
const partnerCustomerCount = computed(() => partnerTenants.value?.length ?? 0)
|
|
</script>
|
|
|
|
<template>
|
|
<aside class="sidebar" :class="{ collapsed }">
|
|
<!-- Workspace switcher -->
|
|
<button class="switcher" :title="collapsed ? 'Workspace' : undefined">
|
|
<!-- Customer admin: bone tile with node-mark -->
|
|
<template v-if="switcherKind === 'customer'">
|
|
<span class="ws-tile bone">
|
|
<NodeMark :size="28" fg="#0A0A0A" accent="var(--signal)" />
|
|
</span>
|
|
<div v-if="!collapsed" class="ws-text">
|
|
<div class="ws-name">baslund</div>
|
|
<div class="ws-sub">Business · 11/25</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 NordicMSP</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>
|
|
|
|
<!-- User footer -->
|
|
<div class="foot">
|
|
<button class="user" :title="collapsed ? 'Anne Hansen' : undefined">
|
|
<Avatar name="Anne Hansen" :size="26" />
|
|
<div v-if="!collapsed" class="user-text">
|
|
<div class="user-name">Anne Hansen</div>
|
|
<div class="user-role">
|
|
{{ section === 'partner' ? 'partner admin' : section === 'admin' ? 'admin' : 'user' }}
|
|
</div>
|
|
</div>
|
|
<UiIcon v-if="!collapsed" name="chevUpDown" :size="12" stroke="var(--side-mute)" />
|
|
</button>
|
|
<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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
.user {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
width: 100%;
|
|
padding: 8px;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 6px;
|
|
color: var(--side-dim);
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
}
|
|
.user:hover { background: var(--side-hover); }
|
|
.user-text { flex: 1; min-width: 0; }
|
|
.user-name { font-size: 12px; color: var(--side-text); font-weight: 500; }
|
|
.user-role {
|
|
font-size: 10px;
|
|
color: var(--side-mute);
|
|
font-family: var(--font-mono);
|
|
margin-top: 1px;
|
|
}
|
|
.sidebar.collapsed .user { justify-content: center; padding: 8px 0; }
|
|
|
|
/* 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>
|