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
+23 -12
View File
@@ -3,7 +3,6 @@ import type { Tenant } from '~/types/tenant'
import type { Partner } from '~/types/partner'
import type { PlatformUser } from '~/types/user'
import type { AuditEvent } from '~/types/audit'
import { SERVICES, INCIDENT } from '~/data/fixtures'
const { data: tenants, pending: tp, refresh: rT } = await useFetch<Tenant[]>('/api/tenants', { default: () => [] })
const { data: partners, pending: pp, refresh: rP } = await useFetch<Partner[]>('/api/partners', { default: () => [] })
@@ -13,19 +12,30 @@ const { data: auditEvents, refresh: rA } = await useFetch<AuditEvent[]>('/api/au
query: { limit: 8 },
})
// Real service health from platform-api probes (same source as the
// infrastructure page) — replaces the old SERVICES/INCIDENT fixtures.
interface ProbeResult {
id: string
name: string
status: 'ok' | 'warn' | 'bad'
}
const { data: probes, refresh: rH } = await useFetch<ProbeResult[]>('/api/health/platform', {
default: () => [],
})
function fmtClock(iso: string) {
return new Date(iso).toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' })
}
const { open: openIncident } = useIncidentModal()
const pending = computed(() => tp.value || pp.value || up.value)
async function refresh() {
await Promise.all([rT(), rP(), rU(), rA()])
await Promise.all([rT(), rP(), rU(), rA(), rH()])
}
const degradedCount = computed(() => SERVICES.filter((s) => s.status !== 'ok').length)
const totalServices = computed(() => (probes.value ?? []).length)
const degradedServices = computed(() => (probes.value ?? []).filter((p) => p.status !== 'ok'))
const degradedCount = computed(() => degradedServices.value.length)
const incidentActive = computed(() => degradedCount.value > 0)
const stats = computed(() => ({
@@ -74,18 +84,18 @@ function fmtDate(d: string) {
</PageHeader>
<div class="stage">
<button v-if="incidentActive" class="incident" type="button" @click="openIncident">
<NuxtLink v-if="incidentActive" class="incident" to="/infrastructure">
<span class="pill">
<span class="dot" />
{{ INCIDENT.severity }} · ACTIVE
DEGRADED
</span>
<div class="incident-body">
<div class="incident-title">{{ INCIDENT.title }}</div>
<div class="incident-sub">Started {{ INCIDENT.started }} · {{ INCIDENT.duration }} duration · {{ INCIDENT.affected }}</div>
<div class="incident-title">{{ degradedCount }} service{{ degradedCount === 1 ? '' : 's' }} reporting non-OK status</div>
<div class="incident-sub">{{ degradedServices.map((s) => s.name).join(', ') }}</div>
</div>
<Mono>IC: {{ INCIDENT.ic }}</Mono>
<Mono>view infrastructure</Mono>
<UiIcon name="chevRight" :size="14" />
</button>
</NuxtLink>
<div class="vitals">
<NuxtLink to="/tenants" class="vital">
@@ -102,7 +112,7 @@ function fmtDate(d: string) {
label="Services"
:value="incidentActive ? `${degradedCount} degraded` : 'all green'"
:delta-tone="incidentActive ? 'down' : 'up'"
:hint="incidentActive ? 'P2 · authentik' : `${SERVICES.length} / ${SERVICES.length} healthy`"
:hint="`${totalServices - degradedCount} / ${totalServices} healthy`"
/>
</NuxtLink>
</div>
@@ -249,6 +259,7 @@ function fmtDate(d: string) {
cursor: pointer;
color: var(--text);
font-family: inherit;
text-decoration: none;
}
.pill {
display: inline-flex;
+4 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { INCIDENT, PLANNED_SERVICES } from '~/data/fixtures'
import { PLANNED_SERVICES } from '~/data/fixtures'
// Shape returned by /api/health/platform on platform-api.
interface ProbeResult {
@@ -72,13 +72,12 @@ function label(p: ProbeResult) {
<div v-if="incidentActive" class="incident">
<span class="pill">
<span class="dot" />
{{ INCIDENT.severity }} · ACTIVE
DEGRADED
</span>
<div class="body">
<div class="title">{{ INCIDENT.title }}</div>
<div class="sub">{{ degradedCount }} service(s) reporting non-ok status · IC: {{ INCIDENT.ic }}</div>
<div class="title">{{ degradedCount }} service(s) reporting non-ok status</div>
<div class="sub">{{ (probes ?? []).filter((p) => p.status !== 'ok').map((p) => p.name).join(', ') }}</div>
</div>
<UiButton variant="primary" disabled>Open incident</UiButton>
</div>
<Eyebrow class="section-head">Live · {{ totalCount }} services</Eyebrow>