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
+54 -27
View File
@@ -6,8 +6,8 @@
import { customers, partnerMrrSparkline, partner as fixturePartner } from '~/data/customers'
import type { CustomerOrg } from '~/data/customers'
import { partnerMrrSparkline, partner as fixturePartner } from '~/data/customers'
import type { CustomerOrg } from '~/types/partner'
const toast = useToast()
const router = useRouter()
@@ -109,22 +109,50 @@ const sparkline = partnerMrrSparkline
const sparkLast = sparkline[sparkline.length - 1]
const sparkTrendPct = '18.2' // matches source label
// Attention list · partner-screens.jsx line 207-212
const alerts = [
{ id: 'a-bygherre', tone: 'bad' as const, cust: 'Bygherre Cloud', msg: 'Invoice 21 days past due · 2.940 DKK', action: 'Review', custId: 'c-bygherre' },
{ id: 'a-henriksen', tone: 'warn' as const, cust: 'Henriksen Revision', msg: 'SPF record missing on h-revision.dk', action: 'Fix DNS', custId: 'c-henriksen' },
{ id: 'a-aalborg', tone: 'warn' as const, cust: 'Aalborg Logistik', msg: 'Approaching seat limit · 87/100 used', action: 'Upsell', custId: 'c-aalborg' },
{ id: 'a-norrebro', tone: 'info' as const, cust: 'Nørrebro Studio', msg: 'Trial ends in 7 days', action: 'Follow up', custId: 'c-norrebro' },
]
// Recent activity · partner-screens.jsx line 332-336
const activity = [
{ when: '14:02', cust: 'Acme Workspace', who: 'Anne Baslund', action: 'invited 3 users', tone: 'info' as const },
{ when: '12:18', cust: 'Bygherre Cloud', who: 'system', action: 'invoice marked past-due', tone: 'bad' as const },
{ when: '11:44', cust: 'Aalborg Logistik', who: 'Sofie Lindberg', action: 'upgraded to Enterprise', tone: 'ok' as const },
{ when: '10:08', cust: 'Nørrebro Studio', who: 'NordicMSP', action: 'created new customer org', tone: 'info' as const },
{ when: '09:34', cust: 'Henriksen Revision', who: 'system', action: 'DNS health alert · SPF', tone: 'warn' as const },
]
// Attention list — derived from real tenant state (no fixtures). Surfaces
// suspended customers, provisioning errors, seat pressure, and pending/trial
// tenants. Each links to /partner/customers.
interface DashAlert {
id: string
tone: 'bad' | 'warn' | 'info'
cust: string
msg: string
action: string
slug: string
}
const derivedAlerts = computed<DashAlert[]>(() => {
const out: DashAlert[] = []
for (const t of tenants.value ?? []) {
if (t.status === 'suspended') {
out.push({ id: `susp-${t._id}`, tone: 'bad', cust: t.name, msg: 'Customer suspended', action: 'Review', slug: t.slug })
continue
}
const errored = Object.entries(t.provisioningStatus ?? {})
.filter(([, s]) => s === 'error')
.map(([k]) => k)
if (errored.length) {
out.push({ id: `prov-${t._id}`, tone: 'bad', cust: t.name, msg: `Provisioning error · ${errored.join(', ')}`, action: 'Reconcile', slug: t.slug })
}
const seats = t.seats ?? 0
const used = t.userCount ?? 0
if (seats > 0 && used / seats > 0.85) {
out.push({ id: `seat-${t._id}`, tone: 'warn', cust: t.name, msg: `Approaching seat limit · ${used}/${seats} used`, action: 'Upsell', slug: t.slug })
}
if (t.status === 'pending') {
out.push({ id: `pend-${t._id}`, tone: 'info', cust: t.name, msg: 'Awaiting provisioning', action: 'Follow up', slug: t.slug })
}
}
return out
})
const alertCounts = computed(() => ({
bad: derivedAlerts.value.filter((a) => a.tone === 'bad').length,
warn: derivedAlerts.value.filter((a) => a.tone === 'warn').length,
}))
const issuesHint = computed(() => {
const { bad, warn } = alertCounts.value
if (bad === 0 && warn === 0) return 'all clear'
return `${bad} critical · ${warn} warning`
})
function statusBadge(s: string): { tone: 'ok' | 'warn' | 'bad' | 'info' | 'neutral'; label: string } {
switch (s) {
@@ -148,12 +176,8 @@ function confirmEnter(reason: string) {
router.push('/admin')
}
function onAlert(a: typeof alerts[number]) {
toast.ok(`${a.action}: ${a.cust}`, 'Workflow stub fired')
}
function activitySwatch(name: string) {
return customers.find((c) => c.name === name)?.brandColor || 'var(--text-mute)'
function onAlert(_a: DashAlert) {
router.push('/partner/customers')
}
// ── Real health + activity (replace fixture cards) ───────────────────────
@@ -295,7 +319,7 @@ function provisioned() {
<Stat label="End users" :value="totalUsers" :delta="usersDelta" delta-tone="up" />
</Card>
<Card>
<Stat label="Issues" :value="alerts.length" hint="1 critical · 2 warning" />
<Stat label="Issues" :value="derivedAlerts.length" :hint="issuesHint" />
</Card>
</div>
@@ -336,9 +360,12 @@ function provisioned() {
<div class="card-title">What needs your attention</div>
</div>
</div>
<div class="attn-list">
<div v-if="derivedAlerts.length === 0" class="empty-state">
<Mono dim>// nothing needs attention right now</Mono>
</div>
<div v-else class="attn-list">
<div
v-for="a in alerts"
v-for="a in derivedAlerts"
:key="a.id"
class="attn-row"
:style="{ borderLeftColor: `var(--${a.tone})` }"