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
+113 -28
View File
@@ -18,36 +18,120 @@ const tabs = [
{ value: 'notifications', label: 'Notifications' },
]
// Contact info (editable but kept simple — strict port focuses on layout)
const contact = reactive({
legalName: 'NordicMSP ApS',
tradingName: 'NordicMSP',
address: 'Vesterport 12, 1620 København V',
country: 'DK',
primaryEmail: 'partners@nordicmsp.dk',
primaryPhone: '+45 70 70 12 34',
supportHotline: '+45 70 70 12 35',
website: 'nordicmsp.dk',
interface NotificationPref {
event: string
cadence: string
channels: string[]
}
interface PartnerSettings {
name?: string
domain?: string
marginPct?: number
contactInfo?: { primaryName?: string; primaryEmail?: string; billingEmail?: string }
billingInfo?: { companyName?: string; vatId?: string; country?: string; contactEmail?: string }
profile?: {
legalName?: string
tradingName?: string
address?: string
country?: string
primaryEmail?: string
primaryPhone?: string
supportHotline?: string
website?: string
}
notificationPrefs?: NotificationPref[]
agreement?: {
tier?: string
payoutCadence?: string
effectiveAt?: string
termMonths?: number
noticePeriodDays?: number
liabilityCap?: string
governingLaw?: string
signedBy?: string
}
documents?: Array<{ name: string; url?: string; kind?: string; size?: string; uploadedAt?: string }>
}
const { data: settings, refresh } = await useFetch<PartnerSettings>('/api/partner/settings', {
key: 'partner-settings',
default: () => ({}),
})
// Documents · platform-partner-depth.jsx:922-927
const docs = [
{ n: 'Reseller agreement · v2025.11.pdf', size: '184 KB', date: '14 Nov 2025' },
{ n: 'DPA · Data Processing Addendum.pdf', size: '92 KB', date: '14 Jan 2024' },
{ n: 'Service Level Agreement.pdf', size: '64 KB', date: '14 Jan 2024' },
{ n: 'Margin schedule · v2025.11.xlsx', size: '24 KB', date: '14 Nov 2025' },
]
// Editable copy of the contact profile, seeded from the fetched settings
// (falling back to the partner's name/domain/contactInfo where the dedicated
// profile fields aren't set yet).
const contact = reactive({
legalName: '',
tradingName: '',
address: '',
country: 'DK',
primaryEmail: '',
primaryPhone: '',
supportHotline: '',
website: '',
})
const savingContact = ref(false)
function syncContact() {
// Don't clobber the form mid-save — the post-save refetch would otherwise
// reset fields the user may have kept editing.
if (savingContact.value) return
const s = settings.value ?? {}
const p = s.profile ?? {}
contact.legalName = p.legalName ?? s.billingInfo?.companyName ?? s.name ?? ''
contact.tradingName = p.tradingName ?? s.name ?? ''
contact.address = p.address ?? ''
contact.country = p.country ?? s.billingInfo?.country ?? 'DK'
contact.primaryEmail = p.primaryEmail ?? s.contactInfo?.primaryEmail ?? ''
contact.primaryPhone = p.primaryPhone ?? ''
contact.supportHotline = p.supportHotline ?? ''
contact.website = p.website ?? s.domain ?? ''
}
syncContact()
watch(settings, syncContact)
// Notifications · platform-partner-depth.jsx:1013-1020
const events = [
{ event: 'New customer signed up', when: 'immediate', channels: 'email · chat' },
{ event: 'Customer past-due invoice', when: 'immediate', channels: 'email · in-app' },
{ event: 'Customer approaching limit', when: 'daily', channels: 'email' },
{ event: 'Customer downgrade or churn', when: 'immediate', channels: 'email · chat · in-app' },
{ event: 'Payout processed', when: 'immediate', channels: 'email' },
{ event: 'New ticket from a customer', when: 'immediate', channels: 'chat' },
{ event: 'Dezky agreement change', when: 'immediate', channels: 'email' },
async function saveContact() {
savingContact.value = true
try {
await $fetch('/api/partner/settings', { method: 'PATCH', body: { profile: { ...contact } } })
toast.ok('Saved', 'Contact info updated')
await Promise.all([refresh(), refreshNuxtData('partner-settings')])
} catch (e: unknown) {
const err = e as { data?: { message?: string }; statusMessage?: string }
toast.bad('Save failed', err.data?.message || err.statusMessage || 'Could not save contact info')
} finally {
savingContact.value = false
}
}
const marginPct = computed(() => settings.value?.marginPct ?? 0)
// Documents — operator-managed; empty until uploaded.
const docs = computed(() =>
(settings.value?.documents ?? []).map((d) => ({
n: d.name,
size: d.size ?? '',
date: d.uploadedAt ? new Date(d.uploadedAt).toLocaleDateString('da-DK') : '',
})),
)
// Notifications — real prefs, falling back to the standard event set when the
// partner hasn't customised them yet.
const DEFAULT_NOTIFICATIONS: NotificationPref[] = [
{ event: 'New customer signed up', cadence: 'immediate', channels: ['email', 'chat'] },
{ event: 'Customer past-due invoice', cadence: 'immediate', channels: ['email', 'in-app'] },
{ event: 'Customer approaching limit', cadence: 'daily', channels: ['email'] },
{ event: 'Customer downgrade or churn', cadence: 'immediate', channels: ['email', 'chat', 'in-app'] },
{ event: 'Payout processed', cadence: 'immediate', channels: ['email'] },
{ event: 'New ticket from a customer', cadence: 'immediate', channels: ['chat'] },
{ event: 'Dezky agreement change', cadence: 'immediate', channels: ['email'] },
]
const events = computed(() =>
(settings.value?.notificationPrefs?.length
? settings.value.notificationPrefs
: DEFAULT_NOTIFICATIONS
).map((n) => ({ event: n.event, when: n.cadence, channels: n.channels.join(' · ') })),
)
</script>
<template>
@@ -83,7 +167,7 @@ const events = [
<div class="agree-grid">
<dl class="def">
<div><dt>Tier</dt><dd>Tier 2 · Established</dd></div>
<div><dt>Default margin</dt><dd>20% on Starter &amp; Business</dd></div>
<div><dt>Default margin</dt><dd>{{ marginPct }}% on all plans</dd></div>
<div><dt>Enterprise margin</dt><dd>Negotiated · 15% baseline</dd></div>
<div><dt>Volume rebate</dt><dd>+2% over 200 active seats · qualifies</dd></div>
<div><dt>Payout cadence</dt><dd>Monthly · 3rd business day</dd></div>
@@ -104,6 +188,7 @@ const events = [
<Eyebrow>Documents</Eyebrow>
<div class="card-title">Related files</div>
<div class="doc-list">
<Mono v-if="docs.length === 0" dim>// no documents uploaded yet</Mono>
<button
v-for="d in docs"
:key="d.n"
@@ -127,7 +212,7 @@ const events = [
<Eyebrow>Business</Eyebrow>
<div class="card-title">NordicMSP company info</div>
</div>
<UiButton size="sm" variant="ghost" @click="toast.ok('Saved', 'Contact info updated')">Edit</UiButton>
<UiButton size="sm" variant="primary" :disabled="savingContact" @click="saveContact">{{ savingContact ? 'Saving' : 'Save changes' }}</UiButton>
</div>
<div class="contact-grid">
<div class="col">