feat: partner enrichment, mutations, settings & branding + operator quick-wins

Backend (platform-api): computed tenant health plus industry/brandColor; partner-scoped tenant update/suspend/resume guarded by assertPartnerOwnsTenant; enriched partner users (MFA + access level) with invite/remove; partner settings and whitelabel branding persistence; Authentik authenticator counting and group removal. Audit on every mutation.

Frontend (portal): all five partner pages on real data — dashboard alerts, customers edit/suspend, team MFA/access with invite/remove, editable settings, branding fetch/save.

Operator: dashboard and infrastructure service health driven by real liveness probes; fabricated uptime/p95/error-rate removed.
This commit is contained in:
Ronni Baslund
2026-05-30 08:03:07 +02:00
parent a51dc9a732
commit 89691626f4
33 changed files with 1753 additions and 198 deletions
+43 -18
View File
@@ -5,7 +5,6 @@
import { customers } from '~/data/customers'
import type { TeamMember } from '~/components/partner/TeammatePanel.vue'
const toast = useToast()
@@ -13,11 +12,9 @@ const toast = useToast()
const inviteOpen = ref(false)
const openMember = ref<TeamMember | null>(null)
// Real partner team from platform-api (proxied via /api/partner/users).
// Falls back to an empty list while the request is in flight. Each row's
// access/mfa fields are placeholders until per-user access controls and
// Authentik MFA introspection land — the underlying User doc only stores
// identity + tenantIds + partnerId today.
// Real partner team from platform-api (proxied via /api/partner/users). The
// enriched response adds mfaEnabled (live Authentik lookup), accessLevel, and
// accessCount per user.
interface PartnerUserDoc {
_id: string
authentikSubjectId: string
@@ -27,12 +24,19 @@ interface PartnerUserDoc {
active: boolean
lastLoginAt?: string
createdAt?: string
mfaEnabled?: boolean | null
accessLevel?: 'all' | 'scoped'
accessCount?: number | null
}
const { data: rawTeam } = await useFetch<PartnerUserDoc[]>('/api/partner/users', {
const { data: rawTeam, refresh } = await useFetch<PartnerUserDoc[]>('/api/partner/users', {
key: 'partner-users',
default: () => [],
})
// Real customer count for the "all (N)" / "N of M" access labels.
const { tenants } = usePartnerTenants()
function lastSeenLabel(iso?: string): string {
if (!iso) return 'never'
const ms = Date.now() - new Date(iso).getTime()
@@ -51,24 +55,45 @@ const members = computed<TeamMember[]>(() =>
name: u.name,
email: u.email,
role: u.role === 'admin' ? 'Partner admin' : u.role === 'owner' ? 'Owner' : 'Partner staff',
access: 'all',
mfa: '—',
access: u.accessLevel === 'scoped' ? 'specific' : 'all',
accessCount: u.accessCount ?? null,
mfa: u.mfaEnabled === true ? 'enabled' : u.mfaEnabled === false ? 'disabled' : 'unknown',
lastSeen: lastSeenLabel(u.lastLoginAt),
isOwner: u.role === 'owner',
})),
)
function accessLabel(m: TeamMember) {
if (m.access === 'all') return `all (${customers.length})`
const total = tenants.value?.length ?? 0
if (m.access === 'none') return 'no access'
// Specific count for fixtures: Mikkel = 6, Oliver = 3
if (m.email === 'mikkel@nordicmsp.dk') return `6 of ${customers.length}`
if (m.email === 'oliver@nordicmsp.dk') return `3 of ${customers.length}`
return `${customers.length - 5} of ${customers.length}`
if (m.access === 'all') return `all (${total})`
return `${m.accessCount ?? 0} of ${total}`
}
function onSent(payload: { email: string; role: string }) {
toast.ok('Invitation sent', `${payload.role} invite to ${payload.email}`)
async function onSent(payload: { name: string; email: string; role: string }) {
try {
await $fetch('/api/partner/users', {
method: 'POST',
body: { name: payload.name, email: payload.email },
})
toast.ok('Invitation sent', `Invite sent to ${payload.email}`)
await Promise.all([refresh(), refreshNuxtData('partner-users')])
} catch (e: unknown) {
const err = e as { data?: { message?: string }; statusMessage?: string }
toast.bad('Invite failed', err.data?.message || err.statusMessage || 'Could not send invitation')
}
}
async function removeMember(m: TeamMember) {
try {
await $fetch(`/api/partner/users/${m.id}`, { method: 'DELETE' })
toast.ok('Removed', `${m.name} removed from the team`)
openMember.value = null
await Promise.all([refresh(), refreshNuxtData('partner-users')])
} catch (e: unknown) {
const err = e as { data?: { message?: string }; statusMessage?: string }
toast.bad('Remove failed', err.data?.message || err.statusMessage || 'Could not remove teammate')
}
}
// Row actions popover · mirrors PartnerTeammateRowActions (lines 1431-1524).
@@ -93,7 +118,7 @@ function actionsFor(m: TeamMember) {
{ i: 'key', l: 'Reset password', fn: () => toast.info('Password reset', `Email sent to ${m.email}`) },
{ sep: true },
{ i: 'x', l: 'Suspend account', fn: () => toast.warn('Account suspended', m.name), disabled: m.isOwner },
{ i: 'trash', l: 'Remove from team', danger: true, fn: () => toast.bad('Removal pending', `${m.name} will be removed`), disabled: m.isOwner },
{ i: 'trash', l: 'Remove from team', danger: true, fn: () => removeMember(m), disabled: m.isOwner },
]
}
@@ -162,7 +187,7 @@ onMounted(() => {
<Badge :tone="m.role === 'Partner admin' ? 'invert' : 'neutral'">{{ m.role }}</Badge>
</td>
<td><Mono>{{ accessLabel(m) }}</Mono></td>
<td><Badge tone="ok" dot>enabled</Badge></td>
<td><Badge :tone="m.mfa === 'enabled' ? 'ok' : m.mfa === 'disabled' ? 'warn' : 'neutral'" dot>{{ m.mfa }}</Badge></td>
<td><Mono dim>{{ m.lastSeen }}</Mono></td>
<td class="action-col" @click.stop>
<button class="kebab" @click="openMenu(m, $event)">