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
+110 -37
View File
@@ -8,41 +8,109 @@
import type { EmailTemplate } from '~/components/partner/EmailTemplateEditor.vue'
import type { BrandIdentity } from '~/components/partner/EditIdentityModal.vue'
const toast = useToast()
const identityOpen = ref(false)
const editing = ref<EmailTemplate | null>(null)
// Customer defaults · partner-screens.jsx line 872-878
const defaults = ref([
{ l: 'Accent color', d: 'Cobalt #3F6BFF', on: true },
{ l: 'Product name pattern', d: '"{Customer} Workspace" e.g. Acme Workspace', on: true },
{ l: 'Custom subdomain', d: 'workspace.{customer-domain}', on: true },
{ l: 'Login screen', d: 'NordicMSP co-brand + customer logo', on: true },
{ l: 'Email templates', d: '5 templates · NordicMSP voice', on: true },
{ l: 'Allow customer override', d: 'Business plans and above', on: true },
{ l: 'Lock typography', d: 'Inter Tight + JetBrains Mono · brand-locked', on: false },
])
interface CustomerDefault {
label: string
detail: string
on: boolean
}
interface PartnerBranding {
identity: BrandIdentity
customerDefaults: CustomerDefault[]
emailTemplates: EmailTemplate[]
}
// Source mustache literals. Constructed in JS to avoid Vue parser eating
// nested {{ }} (see CRITICAL note in task brief).
const { data: branding, refresh } = await useFetch<PartnerBranding>('/api/partner/branding', {
key: 'partner-branding',
default: () => ({ identity: {}, customerDefaults: [], emailTemplates: [] }),
})
// Source mustache literals. Constructed in JS to avoid the Vue parser eating
// nested {{ }} (see the Vue nested-mustache note).
const TAG_WORKSPACE = '{' + '{workspace.name}' + '}'
const TAG_INVOICE = '{' + '{invoice.id}' + '}'
const TAG_PLAN = '{' + '{plan.name}' + '}'
const templates = ref<EmailTemplate[]>([
{ id: 'welcome', name: 'Customer welcome email', subject: `Welcome to ${TAG_WORKSPACE} — managed by NordicMSP`, body: '', edited: '5 days ago' },
{ id: 'invitation', name: 'User invitation', subject: `Youve been invited to ${TAG_WORKSPACE}`, body: '', edited: '3 days ago' },
{ id: 'reset', name: 'Password reset', subject: `Reset your ${TAG_WORKSPACE} password`, body: '', edited: 'default' },
{ id: 'plan', name: 'Plan change confirmation', subject: `Your plan changed to ${TAG_PLAN}`, body: '', edited: 'default' },
{ id: 'invoice', name: 'Invoice notification', subject: `Your NordicMSP invoice ${TAG_INVOICE}`, body: '', edited: '2 weeks ago' },
])
// Product defaults shown the first time a partner visits (before they save).
const DEFAULT_TOGGLES: CustomerDefault[] = [
{ label: 'Accent color', detail: 'Apply your brand accent to customer workspaces', on: true },
{ label: 'Product name pattern', detail: '"{Customer} Workspace"', on: true },
{ label: 'Custom subdomain', detail: 'workspace.{customer-domain}', on: true },
{ label: 'Login screen', detail: 'Partner co-brand + customer logo', on: true },
{ label: 'Email templates', detail: 'Your branded templates', on: true },
{ label: 'Allow customer override', detail: 'Business plans and above', on: true },
{ label: 'Lock typography', detail: 'Brand-locked fonts', on: false },
]
const DEFAULT_TEMPLATES: EmailTemplate[] = [
{ id: 'welcome', name: 'Customer welcome email', subject: `Welcome to ${TAG_WORKSPACE}`, body: '', edited: 'default' },
{ id: 'invitation', name: 'User invitation', subject: `Youve been invited to ${TAG_WORKSPACE}`, body: '', edited: 'default' },
{ id: 'reset', name: 'Password reset', subject: `Reset your ${TAG_WORKSPACE} password`, body: '', edited: 'default' },
{ id: 'plan', name: 'Plan change confirmation', subject: `Your plan changed to ${TAG_PLAN}`, body: '', edited: 'default' },
{ id: 'invoice', name: 'Invoice notification', subject: `Invoice ${TAG_INVOICE}`, body: '', edited: 'default' },
]
function saveTemplate(t: EmailTemplate) {
// Editable working copies seeded from the fetched branding (falling back to the
// product defaults so the page is never blank on first visit).
const identity = ref<BrandIdentity>({})
const defaults = ref<CustomerDefault[]>([])
const templates = ref<EmailTemplate[]>([])
function clone<T>(v: T): T {
return JSON.parse(JSON.stringify(v)) as T
}
function syncBranding() {
const b = branding.value
identity.value = { ...(b?.identity ?? {}) }
defaults.value = b?.customerDefaults?.length ? clone(b.customerDefaults) : clone(DEFAULT_TOGGLES)
templates.value = b?.emailTemplates?.length ? clone(b.emailTemplates) : clone(DEFAULT_TEMPLATES)
}
syncBranding()
watch(branding, syncBranding)
async function putBranding(): Promise<boolean> {
try {
await $fetch('/api/partner/branding', {
method: 'PUT',
body: {
identity: identity.value,
customerDefaults: defaults.value,
emailTemplates: templates.value,
},
})
await Promise.all([refresh(), refreshNuxtData('partner-branding')])
return true
} catch (e: unknown) {
const err = e as { data?: { message?: string }; statusMessage?: string }
toast.bad('Save failed', err.data?.message || err.statusMessage || 'Could not save branding')
return false
}
}
async function toggleDefault(i: number) {
const row = defaults.value[i]
if (!row) return
row.on = !row.on
if (await putBranding()) toast.ok('Saved', 'Customer defaults updated')
}
async function saveTemplate(t: EmailTemplate) {
templates.value = templates.value.map((x) => (x.id === t.id ? { ...t, edited: 'just now' } : x))
editing.value = null
toast.ok('Template saved', t.name)
if (await putBranding()) toast.ok('Template saved', t.name)
}
async function saveIdentity(payload: BrandIdentity) {
identity.value = { ...identity.value, ...payload }
// Close only on success so a failed save keeps the modal open (with its toast).
if (await putBranding()) {
identityOpen.value = false
toast.ok('Saved', 'Brand identity updated')
}
}
</script>
@@ -67,24 +135,24 @@ function saveTemplate(t: EmailTemplate) {
</div>
<div class="id-grid">
<dl class="def">
<div><dt>Display name</dt><dd>NordicMSP</dd></div>
<div><dt>Logo</dt><dd>nordic-logo.svg · 4:1 horizontal</dd></div>
<div><dt>Mark</dt><dd>nordic-mark.svg · 1:1</dd></div>
<div><dt>Display name</dt><dd>{{ identity.displayName || '—' }}</dd></div>
<div><dt>Logo</dt><dd>{{ identity.logoUrl || 'not set' }}</dd></div>
<div><dt>Mark</dt><dd>{{ identity.markUrl || 'not set' }}</dd></div>
<div>
<dt>Primary color</dt>
<dd>
<div class="color-row">
<div class="color-swatch" style="background:#3F6BFF" />
<Mono>#3F6BFF</Mono>
<div class="color-swatch" :style="{ background: identity.primaryColor || '#3F6BFF' }" />
<Mono>{{ identity.primaryColor || '#3F6BFF' }}</Mono>
</div>
</dd>
</div>
</dl>
<dl class="def">
<div><dt>Support email</dt><dd>support@nordicmsp.dk</dd></div>
<div><dt>Support phone</dt><dd>+45 70 70 12 34</dd></div>
<div><dt>Website</dt><dd>nordicmsp.dk</dd></div>
<div><dt>Reply-to</dt><dd>no-reply@nordicmsp.dk</dd></div>
<div><dt>Support email</dt><dd>{{ identity.supportEmail || '—' }}</dd></div>
<div><dt>Support phone</dt><dd>{{ identity.supportPhone || '—' }}</dd></div>
<div><dt>Website</dt><dd>{{ identity.website || '—' }}</dd></div>
<div><dt>Reply-to</dt><dd>{{ identity.replyTo || '—' }}</dd></div>
</dl>
</div>
</Card>
@@ -101,15 +169,15 @@ function saveTemplate(t: EmailTemplate) {
<div class="defaults-list">
<div
v-for="(row, i) in defaults"
:key="row.l"
:key="row.label"
class="def-row"
:class="{ last: i === defaults.length - 1 }"
>
<div class="dr-meta">
<div class="dr-label">{{ row.l }}</div>
<div class="dr-detail">{{ row.d }}</div>
<div class="dr-label">{{ row.label }}</div>
<div class="dr-detail">{{ row.detail }}</div>
</div>
<button class="switch" :class="{ on: row.on }" @click="row.on = !row.on">
<button class="switch" :class="{ on: row.on }" @click="toggleDefault(i)">
<span class="thumb" />
</button>
</div>
@@ -141,12 +209,17 @@ function saveTemplate(t: EmailTemplate) {
</Card>
</div>
<PartnerEditIdentityModal :open="identityOpen" @close="identityOpen = false" />
<PartnerEditIdentityModal
:open="identityOpen"
:identity="identity"
@close="identityOpen = false"
@save="saveIdentity"
/>
<PartnerEmailTemplateEditor
:template="editing"
brand-color="#3F6BFF"
brand-name="NordicMSP"
:brand-color="identity.primaryColor || '#3F6BFF'"
:brand-name="identity.displayName || 'Your brand'"
@close="editing = null"
@save="saveTemplate"
/>