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:
@@ -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 & 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">
|
||||
|
||||
Reference in New Issue
Block a user