refactor(portal): partner-mode customer switcher on real tenants

Migrate the partner-mode customer switcher, in-customer banner, sidebar tile and the team invite/teammate panels off the data/customers fixture onto the real /api/partner/tenants list (shared key, gated to partner-staff so the global shell doesn't 403 for other users). Active customer resolves by tenant _id (the key the customers page already passes to partnerMode.enter); partner-identity labels now use the real partner name from useMe. Removes the now-unused customers + CustomerOrg-list fixture export and the dead setCustomer helper. Verified in UI: switcher + enter/exit show real Baslund Test / Baslund Research ApS.
This commit is contained in:
Ronni Baslund
2026-05-30 14:51:14 +02:00
parent 60e0b2286c
commit 7720e4be83
7 changed files with 67 additions and 63 deletions
+12 -3
View File
@@ -3,13 +3,22 @@
// specific customer org. Distinct color (indigo — partner mode is normal
// operating mode, not danger). Persistent until partner exits.
import { customers } from '~/data/customers'
import type { PartnerTenantDoc } from '~/types/partner'
const partnerMode = usePartnerMode()
const router = useRouter()
const { partner, isPartnerStaff } = useMe()
// Real tenant list (shared key; gated to partner-staff so the layout-level
// banner doesn't 403 the partner endpoint for other users).
const { data: tenants } = useFetch<PartnerTenantDoc[]>('/api/partner/tenants', {
key: 'partner-tenants',
default: () => [],
immediate: isPartnerStaff.value,
})
const activeCustomer = computed(() =>
customers.find((c) => c.id === partnerMode.activeCustomerId.value) || null,
(tenants.value ?? []).find((c) => c._id === partnerMode.activeCustomerId.value) || null,
)
onMounted(() => partnerMode.hydrate())
@@ -25,7 +34,7 @@ function exit() {
<span class="dot" />
<div class="meta">
<Mono>Partner view</Mono>
<span class="text">managing <strong>{{ activeCustomer.name }}</strong> · actions are attributed to NordicMSP in the customer's audit log</span>
<span class="text">managing <strong>{{ activeCustomer.name }}</strong> · actions are attributed to {{ partner?.name ?? 'your partner org' }} in the customer's audit log</span>
</div>
<button class="exit" @click="exit">
<UiIcon name="logout" :size="12" />
+9 -8
View File
@@ -14,7 +14,7 @@
// source design.
import type { IconName } from './UiIcon.vue'
import { customers as fixtureCustomers } from '~/data/customers'
import type { PartnerTenantDoc } from '~/types/partner'
interface NavItem {
id: string
@@ -130,11 +130,6 @@ const currentId = computed(() => {
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>(() => {
@@ -154,12 +149,18 @@ function exitCustomer() {
// 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', {
const { data: partnerTenants } = await useFetch<PartnerTenantDoc[]>('/api/partner/tenants', {
key: 'partner-tenants',
default: () => [],
immediate: isPartnerStaff.value,
})
const partnerCustomerCount = computed(() => partnerTenants.value?.length ?? 0)
// 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>
@@ -193,7 +194,7 @@ const partnerCustomerCount = computed(() => partnerTenants.value?.length ?? 0)
<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 class="ws-sub mono">via {{ partner?.name ?? 'partner' }}</div>
</div>
</template>
+23 -11
View File
@@ -2,13 +2,25 @@
// Portal topbar: workspace label, optional org switcher (partner admins), global
// search, app launcher, notifications, profile menu.
import { customers } from '~/data/customers'
import type { PartnerTenantDoc } from '~/types/partner'
const launcher = useAppLauncher()
const drawer = useNotificationDrawer()
const partnerMode = usePartnerMode()
const router = useRouter()
const route = useRoute()
const { partner, isPartnerStaff } = useMe()
// Real customer tenants drive the org switcher. Shared 'partner-tenants' key
// dedupes with the sidebar + customers page; gated to partner-staff so the
// global shell doesn't 403 the partner-scoped endpoint for end-users/admins.
const { data: tenants } = useFetch<PartnerTenantDoc[]>('/api/partner/tenants', {
key: 'partner-tenants',
default: () => [],
immediate: isPartnerStaff.value,
})
const PLAN_LABEL: Record<string, string> = { mvp: 'Starter', pro: 'Business', enterprise: 'Enterprise' }
const partnerLabel = computed(() => partner.value?.name ?? 'Partner')
// Section context is URL-driven (same rule as the sidebar). The org switcher
// only appears in the partner section or when acting-as a customer.
@@ -24,7 +36,7 @@ const showOrgSwitcher = computed(() =>
)
const activeCustomer = computed(() =>
customers.find((c) => c.id === partnerMode.activeCustomerId.value) || null,
(tenants.value ?? []).find((c) => c._id === partnerMode.activeCustomerId.value) || null,
)
const orgSwitcherOpen = ref(false)
@@ -57,7 +69,7 @@ const searchValue = ref('')
>
{{ (activeCustomer?.name || 'NordicMSP').slice(0, 1) }}
</span>
<span class="org-name">{{ activeCustomer?.name || 'NordicMSP · Partner view' }}</span>
<span class="org-name">{{ activeCustomer?.name || `${partnerLabel} · Partner view` }}</span>
<UiIcon name="chevDown" :size="12" />
</button>
@@ -89,10 +101,10 @@ const searchValue = ref('')
<div v-if="orgSwitcherOpen" class="org-drop-scrim" @click="orgSwitcherOpen = false" />
<div v-if="orgSwitcherOpen" class="org-drop">
<div class="org-drop-head">
<Eyebrow>NordicMSP · {{ customers.length }} customers</Eyebrow>
<Eyebrow>{{ partnerLabel }} · {{ (tenants?.length ?? 0) }} customers</Eyebrow>
</div>
<button class="org-drop-row" :class="{ on: !partnerMode.isActive.value }" @click="leaveCustomerMode">
<span class="org-drop-chip" style="background: #0A0A0A">N</span>
<span class="org-drop-chip" style="background: #0A0A0A">{{ partnerLabel.charAt(0).toUpperCase() }}</span>
<div class="org-drop-meta">
<div class="org-drop-name">Partner view</div>
<Mono dim>portfolio overview</Mono>
@@ -100,16 +112,16 @@ const searchValue = ref('')
</button>
<div class="org-drop-divider" />
<button
v-for="c in customers"
:key="c.id"
v-for="c in tenants"
:key="c._id"
class="org-drop-row"
:class="{ on: partnerMode.activeCustomerId.value === c.id }"
@click="pickCustomer(c.id)"
:class="{ on: partnerMode.activeCustomerId.value === c._id }"
@click="pickCustomer(c._id)"
>
<span class="org-drop-chip" :style="{ background: c.brandColor }">{{ c.name.slice(0, 1) }}</span>
<span class="org-drop-chip" :style="{ background: c.brandColor || '#0A0A0A' }">{{ c.name.slice(0, 1) }}</span>
<div class="org-drop-meta">
<div class="org-drop-name">{{ c.name }}</div>
<Mono dim>{{ c.domain }} · {{ c.plan }}</Mono>
<Mono dim>{{ c.domains?.[0] || c.slug }} · {{ PLAN_LABEL[c.plan ?? 'pro'] }}</Mono>
</div>
</button>
</div>
@@ -3,7 +3,8 @@
// scoping + require-MFA toggle + optional personal note. Invitations expire
// after 7 days — the design surfaces that explicitly.
import { customers } from '~/data/customers'
const { tenants } = usePartnerTenants()
const PLAN_LABEL: Record<string, string> = { mvp: 'Starter', pro: 'Business', enterprise: 'Enterprise' }
defineProps<{ open: boolean }>()
const emit = defineEmits<{
@@ -115,18 +116,18 @@ function planBadgeTone(p: string) {
<div v-if="access === 'specific'" class="picker">
<div class="picker-head">
<Mono dim>{{ specific.length }} of {{ customers.length }} selected</Mono>
<Mono dim>{{ specific.length }} of {{ tenants.length }} selected</Mono>
</div>
<div class="picker-list">
<label v-for="c in customers" :key="c.id" class="picker-row">
<label v-for="c in tenants" :key="c._id" class="picker-row">
<input
type="checkbox"
:checked="specific.includes(c.id)"
@change="toggleCustomer(c.id)"
:checked="specific.includes(c.slug)"
@change="toggleCustomer(c.slug)"
/>
<div class="cust-swatch" :style="{ background: c.brandColor }" />
<div class="cust-swatch" :style="{ background: c.brandColor || '#0A0A0A' }" />
<span class="cust-name">{{ c.name }}</span>
<Badge :tone="planBadgeTone(c.plan)">{{ c.planLabel }}</Badge>
<Badge :tone="planBadgeTone(c.plan ?? 'pro')">{{ PLAN_LABEL[c.plan ?? 'pro'] }}</Badge>
</label>
</div>
</div>
@@ -4,7 +4,8 @@
// • Activity — last 5 partner actions with timestamps + IPs
// • Security — MFA card, active sessions, API tokens, suspend callout
import { customers } from '~/data/customers'
const { tenants } = usePartnerTenants()
const PLAN_LABEL: Record<string, string> = { mvp: 'Starter', pro: 'Business', enterprise: 'Enterprise' }
export interface TeamMember {
id: string
@@ -59,10 +60,10 @@ const isOwner = computed(() => !!props.member?.isOwner)
const accessText = computed(() => {
if (!props.member) return ''
if (props.member.access === 'all') return `all (${customers.length})`
const total = tenants.value?.length ?? 0
if (props.member.access === 'all') return `all (${total})`
if (props.member.access === 'none') return 'no access'
// Specific: just say first N customers
return `${customers.length - 5} of ${customers.length}`
return `${props.member.accessCount ?? 0} of ${total}`
})
</script>
@@ -131,14 +132,14 @@ const accessText = computed(() => {
</div>
<div class="ac-list">
<div
v-for="c in customers.slice(0, member.access === 'all' ? customers.length : 3)"
:key="c.id"
v-for="c in tenants.slice(0, member.access === 'all' ? tenants.length : 3)"
:key="c._id"
class="ac-row"
>
<UiIcon name="check" :size="11" :stroke-width="2.5" />
<div class="cust-swatch" :style="{ background: c.brandColor }" />
<div class="cust-swatch" :style="{ background: c.brandColor || '#0A0A0A' }" />
<span class="cust-name">{{ c.name }}</span>
<Mono dim>{{ c.planLabel }}</Mono>
<Mono dim>{{ PLAN_LABEL[c.plan ?? 'pro'] }}</Mono>
</div>
</div>
</div>