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:
@@ -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: `You’ve 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: `You’ve 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"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user