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"
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
|
||||
|
||||
import type { CustomerOrg, CustomerStatus } from '~/data/customers'
|
||||
import type { CustomerOrg, CustomerStatus, PartnerTenantDoc } from '~/types/partner'
|
||||
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
@@ -19,53 +19,15 @@ const planFilter = ref<'all' | 'starter' | 'business' | 'enterprise'>('all')
|
||||
const wizardOpen = ref(false)
|
||||
const entryCustomer = ref<CustomerOrg | null>(null)
|
||||
|
||||
// Real tenants attached to this partner (via /api/partner/tenants → platform-api
|
||||
// /users/me/partner/tenants). The backend doesn't yet store health-score,
|
||||
// MRR, or industry, so those render as placeholders. Plan + seats now come
|
||||
// from real fields.
|
||||
interface PartnerTenantDoc {
|
||||
_id: string
|
||||
slug: string
|
||||
name: string
|
||||
status: 'active' | 'pending' | 'suspended' | 'deleted'
|
||||
plan?: 'mvp' | 'pro' | 'enterprise'
|
||||
seats?: number
|
||||
// Active User docs whose tenantIds include this tenant. Comes from
|
||||
// /api/partner/tenants (server-side aggregation), so the column can show
|
||||
// "used / total" without a second client round-trip.
|
||||
userCount?: number
|
||||
domains?: string[]
|
||||
createdAt?: string
|
||||
}
|
||||
// Real tenants attached to this partner, via the shared composable which owns
|
||||
// the canonical PartnerTenantDoc and the 'partner-tenants' cache key (dashboard
|
||||
// + customers reuse one fetch). Health score, industry, and brand colour now
|
||||
// come from real fields on the response.
|
||||
const { tenants: rawTenants, refresh: refreshTenants } = usePartnerTenants()
|
||||
|
||||
const { data: rawTenants, refresh: refreshTenants } = await useFetch<PartnerTenantDoc[]>(
|
||||
'/api/partner/tenants',
|
||||
{ key: 'partner-tenants', default: () => [] },
|
||||
)
|
||||
|
||||
// Per-tenant MRR comes from the same aggregation that powers the dashboard
|
||||
// MRR card. We reuse the cached response (same key) instead of issuing a
|
||||
// second fetch; the customers page just reads the breakdown to fill the MRR
|
||||
// column per row.
|
||||
interface MrrBreakdownRow {
|
||||
tenantId: string
|
||||
currency: 'DKK' | 'EUR' | 'USD'
|
||||
monthlyMinor: number
|
||||
custom: boolean
|
||||
}
|
||||
interface MrrResponse {
|
||||
totals: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
|
||||
breakdown: MrrBreakdownRow[]
|
||||
}
|
||||
const { data: mrr, refresh: refreshMrr } = await useFetch<MrrResponse>('/api/partner/mrr', {
|
||||
key: 'partner-mrr',
|
||||
default: () => ({ totals: [], breakdown: [] }),
|
||||
})
|
||||
const mrrByTenant = computed(() => {
|
||||
const m = new Map<string, MrrBreakdownRow>()
|
||||
for (const row of mrr.value?.breakdown ?? []) m.set(row.tenantId, row)
|
||||
return m
|
||||
})
|
||||
// Per-tenant MRR from the shared composable (same 'partner-mrr' cache key as the
|
||||
// dashboard). mrrByTenant is the tenantId → breakdown-row lookup the table reads.
|
||||
const { mrrByTenant, refresh: refreshMrr } = usePartnerMrr()
|
||||
|
||||
function mapTenantStatus(s: PartnerTenantDoc['status']): CustomerStatus {
|
||||
// Tenant.status values overlap partially with the fixture's CustomerStatus.
|
||||
@@ -92,6 +54,7 @@ const PLAN_INFO: Record<
|
||||
// the amount (the original fixture only had a DKK number, but real data
|
||||
// can be in DKK/EUR/USD).
|
||||
interface CustomerRow extends CustomerOrg {
|
||||
slug: string
|
||||
mrrCurrency: 'DKK' | 'EUR' | 'USD'
|
||||
mrrCustom: boolean
|
||||
}
|
||||
@@ -102,6 +65,7 @@ const customers = computed<CustomerRow[]>(() =>
|
||||
const subMrr = mrrByTenant.value.get(t._id)
|
||||
return {
|
||||
id: t._id,
|
||||
slug: t.slug,
|
||||
name: t.name,
|
||||
domain: t.domains?.[0] ?? `${t.slug}.dezky.com`,
|
||||
plan: info.slug,
|
||||
@@ -109,15 +73,15 @@ const customers = computed<CustomerRow[]>(() =>
|
||||
// Real seat utilisation: count of active User docs attached to this
|
||||
// tenant vs the contractual seat total from provisioning.
|
||||
seats: { used: t.userCount ?? 0, total: t.seats ?? 0 },
|
||||
health: 100,
|
||||
health: t.healthScore ?? 100,
|
||||
status: mapTenantStatus(t.status),
|
||||
// MRR for this tenant, in major units. From /api/partner/mrr; 0 when
|
||||
// the sub has no priced amount (Enterprise / pre-catalog tenants).
|
||||
mrrDkk: subMrr ? Math.round(subMrr.monthlyMinor / 100) : 0,
|
||||
mrrCurrency: subMrr?.currency ?? 'DKK',
|
||||
mrrCustom: subMrr?.custom ?? false,
|
||||
brandColor: '#D4FF3A',
|
||||
industry: '—',
|
||||
brandColor: t.brandColor || '#D4FF3A',
|
||||
industry: t.industry ?? '—',
|
||||
createdOn: t.createdAt ?? '',
|
||||
since: t.createdAt ?? '',
|
||||
}
|
||||
@@ -186,6 +150,69 @@ function confirmEnter(reason: string) {
|
||||
toast.info(`Entered ${c.name}`, reason ? `Reason: ${reason}` : 'No reason captured')
|
||||
router.push('/admin')
|
||||
}
|
||||
|
||||
// ── Edit / suspend a customer ─────────────────────────────────────────────
|
||||
const editCustomer = ref<CustomerRow | null>(null)
|
||||
const editForm = reactive({ name: '', industry: '', brandColor: '#D4FF3A', seats: 0 })
|
||||
const savingEdit = ref(false)
|
||||
|
||||
function startEdit(c: CustomerRow) {
|
||||
editCustomer.value = c
|
||||
editForm.name = c.name
|
||||
editForm.industry = c.industry === '—' ? '' : c.industry
|
||||
editForm.brandColor = c.brandColor
|
||||
editForm.seats = c.seats.total
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([
|
||||
refreshTenants(),
|
||||
refreshMrr(),
|
||||
refreshNuxtData('partner-tenants'),
|
||||
refreshNuxtData('partner-mrr'),
|
||||
])
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editCustomer.value) return
|
||||
if (!editForm.name.trim()) {
|
||||
toast.bad('Name required', 'Customer name cannot be empty')
|
||||
return
|
||||
}
|
||||
savingEdit.value = true
|
||||
try {
|
||||
await $fetch(`/api/partner/tenants/${editCustomer.value.slug}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
name: editForm.name,
|
||||
industry: editForm.industry,
|
||||
brandColor: editForm.brandColor,
|
||||
seats: editForm.seats,
|
||||
},
|
||||
})
|
||||
toast.ok('Saved', `${editForm.name} updated`)
|
||||
editCustomer.value = null
|
||||
await refreshAll()
|
||||
} catch (e: unknown) {
|
||||
const err = e as { data?: { message?: string }; statusMessage?: string }
|
||||
toast.bad('Save failed', err.data?.message || err.statusMessage || 'Could not save customer')
|
||||
} finally {
|
||||
savingEdit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSuspend(c: CustomerRow) {
|
||||
const action = c.status === 'suspended' ? 'resume' : 'suspend'
|
||||
try {
|
||||
await $fetch(`/api/partner/tenants/${c.slug}/${action}`, { method: 'POST' })
|
||||
toast.ok(action === 'suspend' ? 'Suspended' : 'Resumed', c.name)
|
||||
editCustomer.value = null
|
||||
await refreshAll()
|
||||
} catch (e: unknown) {
|
||||
const err = e as { data?: { message?: string }; statusMessage?: string }
|
||||
toast.bad('Action failed', err.data?.message || err.statusMessage || 'Could not update customer')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -283,10 +310,13 @@ function confirmEnter(reason: string) {
|
||||
</td>
|
||||
<td><Mono dim>{{ c.since }}</Mono></td>
|
||||
<td class="action-col" @click.stop>
|
||||
<UiButton size="sm" variant="secondary" @click="startEnter(c)">
|
||||
Manage
|
||||
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
|
||||
</UiButton>
|
||||
<div class="row-actions">
|
||||
<UiButton size="sm" variant="ghost" @click="startEdit(c)">Edit</UiButton>
|
||||
<UiButton size="sm" variant="secondary" @click="startEnter(c)">
|
||||
Manage
|
||||
<template #trailing><UiIcon name="chevRight" :size="12" /></template>
|
||||
</UiButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="filtered.length === 0">
|
||||
@@ -347,6 +377,33 @@ function confirmEnter(reason: string) {
|
||||
@close="entryCustomer = null"
|
||||
@confirm="confirmEnter"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
:open="!!editCustomer"
|
||||
eyebrow="Customer"
|
||||
:title="`Edit ${editCustomer?.name ?? ''}`"
|
||||
size="md"
|
||||
@close="editCustomer = null"
|
||||
>
|
||||
<div class="edit-form">
|
||||
<label class="field"><Eyebrow>Name</Eyebrow><input v-model="editForm.name" /></label>
|
||||
<label class="field"><Eyebrow>Industry</Eyebrow><input v-model="editForm.industry" placeholder="e.g. Logistics" /></label>
|
||||
<div class="row-2">
|
||||
<label class="field"><Eyebrow>Brand color</Eyebrow><input v-model="editForm.brandColor" placeholder="#3F6BFF" /></label>
|
||||
<label class="field"><Eyebrow>Seats</Eyebrow><input v-model.number="editForm.seats" type="number" min="0" /></label>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton
|
||||
v-if="editCustomer"
|
||||
:variant="editCustomer.status === 'suspended' ? 'secondary' : 'danger'"
|
||||
@click="toggleSuspend(editCustomer)"
|
||||
>{{ editCustomer.status === 'suspended' ? 'Resume customer' : 'Suspend customer' }}</UiButton>
|
||||
<div style="flex: 1" />
|
||||
<UiButton variant="ghost" @click="editCustomer = null">Cancel</UiButton>
|
||||
<UiButton variant="primary" :disabled="savingEdit" @click="saveEdit">{{ savingEdit ? 'Saving…' : 'Save changes' }}</UiButton>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -447,7 +504,22 @@ function confirmEnter(reason: string) {
|
||||
}
|
||||
.dtable th.sortable :deep(svg) { opacity: 0.5; margin-left: 4px; vertical-align: middle; }
|
||||
.dtable th.num, .dtable td.num { text-align: right; }
|
||||
.dtable th.action-col, .dtable td.action-col { width: 120px; text-align: right; }
|
||||
.dtable th.action-col, .dtable td.action-col { width: 170px; text-align: right; }
|
||||
.row-actions { display: flex; gap: 6px; justify-content: flex-end; align-items: center; }
|
||||
|
||||
.edit-form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.edit-form .field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.edit-form .row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.edit-form input {
|
||||
padding: 9px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
.edit-form input:focus { outline: none; border-color: var(--border-hi); }
|
||||
|
||||
.dtable td {
|
||||
padding: 12px 16px;
|
||||
|
||||
@@ -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})` }"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
|
||||
|
||||
import { customers } from '~/data/customers'
|
||||
import type { TeamMember } from '~/components/partner/TeammatePanel.vue'
|
||||
|
||||
const toast = useToast()
|
||||
@@ -13,11 +12,9 @@ const toast = useToast()
|
||||
const inviteOpen = ref(false)
|
||||
const openMember = ref<TeamMember | null>(null)
|
||||
|
||||
// Real partner team from platform-api (proxied via /api/partner/users).
|
||||
// Falls back to an empty list while the request is in flight. Each row's
|
||||
// access/mfa fields are placeholders until per-user access controls and
|
||||
// Authentik MFA introspection land — the underlying User doc only stores
|
||||
// identity + tenantIds + partnerId today.
|
||||
// Real partner team from platform-api (proxied via /api/partner/users). The
|
||||
// enriched response adds mfaEnabled (live Authentik lookup), accessLevel, and
|
||||
// accessCount per user.
|
||||
interface PartnerUserDoc {
|
||||
_id: string
|
||||
authentikSubjectId: string
|
||||
@@ -27,12 +24,19 @@ interface PartnerUserDoc {
|
||||
active: boolean
|
||||
lastLoginAt?: string
|
||||
createdAt?: string
|
||||
mfaEnabled?: boolean | null
|
||||
accessLevel?: 'all' | 'scoped'
|
||||
accessCount?: number | null
|
||||
}
|
||||
|
||||
const { data: rawTeam } = await useFetch<PartnerUserDoc[]>('/api/partner/users', {
|
||||
const { data: rawTeam, refresh } = await useFetch<PartnerUserDoc[]>('/api/partner/users', {
|
||||
key: 'partner-users',
|
||||
default: () => [],
|
||||
})
|
||||
|
||||
// Real customer count for the "all (N)" / "N of M" access labels.
|
||||
const { tenants } = usePartnerTenants()
|
||||
|
||||
function lastSeenLabel(iso?: string): string {
|
||||
if (!iso) return 'never'
|
||||
const ms = Date.now() - new Date(iso).getTime()
|
||||
@@ -51,24 +55,45 @@ const members = computed<TeamMember[]>(() =>
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
role: u.role === 'admin' ? 'Partner admin' : u.role === 'owner' ? 'Owner' : 'Partner staff',
|
||||
access: 'all',
|
||||
mfa: '—',
|
||||
access: u.accessLevel === 'scoped' ? 'specific' : 'all',
|
||||
accessCount: u.accessCount ?? null,
|
||||
mfa: u.mfaEnabled === true ? 'enabled' : u.mfaEnabled === false ? 'disabled' : 'unknown',
|
||||
lastSeen: lastSeenLabel(u.lastLoginAt),
|
||||
isOwner: u.role === 'owner',
|
||||
})),
|
||||
)
|
||||
|
||||
function accessLabel(m: TeamMember) {
|
||||
if (m.access === 'all') return `all (${customers.length})`
|
||||
const total = tenants.value?.length ?? 0
|
||||
if (m.access === 'none') return 'no access'
|
||||
// Specific count for fixtures: Mikkel = 6, Oliver = 3
|
||||
if (m.email === 'mikkel@nordicmsp.dk') return `6 of ${customers.length}`
|
||||
if (m.email === 'oliver@nordicmsp.dk') return `3 of ${customers.length}`
|
||||
return `${customers.length - 5} of ${customers.length}`
|
||||
if (m.access === 'all') return `all (${total})`
|
||||
return `${m.accessCount ?? 0} of ${total}`
|
||||
}
|
||||
|
||||
function onSent(payload: { email: string; role: string }) {
|
||||
toast.ok('Invitation sent', `${payload.role} invite to ${payload.email}`)
|
||||
async function onSent(payload: { name: string; email: string; role: string }) {
|
||||
try {
|
||||
await $fetch('/api/partner/users', {
|
||||
method: 'POST',
|
||||
body: { name: payload.name, email: payload.email },
|
||||
})
|
||||
toast.ok('Invitation sent', `Invite sent to ${payload.email}`)
|
||||
await Promise.all([refresh(), refreshNuxtData('partner-users')])
|
||||
} catch (e: unknown) {
|
||||
const err = e as { data?: { message?: string }; statusMessage?: string }
|
||||
toast.bad('Invite failed', err.data?.message || err.statusMessage || 'Could not send invitation')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMember(m: TeamMember) {
|
||||
try {
|
||||
await $fetch(`/api/partner/users/${m.id}`, { method: 'DELETE' })
|
||||
toast.ok('Removed', `${m.name} removed from the team`)
|
||||
openMember.value = null
|
||||
await Promise.all([refresh(), refreshNuxtData('partner-users')])
|
||||
} catch (e: unknown) {
|
||||
const err = e as { data?: { message?: string }; statusMessage?: string }
|
||||
toast.bad('Remove failed', err.data?.message || err.statusMessage || 'Could not remove teammate')
|
||||
}
|
||||
}
|
||||
|
||||
// Row actions popover · mirrors PartnerTeammateRowActions (lines 1431-1524).
|
||||
@@ -93,7 +118,7 @@ function actionsFor(m: TeamMember) {
|
||||
{ i: 'key', l: 'Reset password', fn: () => toast.info('Password reset', `Email sent to ${m.email}`) },
|
||||
{ sep: true },
|
||||
{ i: 'x', l: 'Suspend account', fn: () => toast.warn('Account suspended', m.name), disabled: m.isOwner },
|
||||
{ i: 'trash', l: 'Remove from team', danger: true, fn: () => toast.bad('Removal pending', `${m.name} will be removed`), disabled: m.isOwner },
|
||||
{ i: 'trash', l: 'Remove from team', danger: true, fn: () => removeMember(m), disabled: m.isOwner },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -162,7 +187,7 @@ onMounted(() => {
|
||||
<Badge :tone="m.role === 'Partner admin' ? 'invert' : 'neutral'">{{ m.role }}</Badge>
|
||||
</td>
|
||||
<td><Mono>{{ accessLabel(m) }}</Mono></td>
|
||||
<td><Badge tone="ok" dot>enabled</Badge></td>
|
||||
<td><Badge :tone="m.mfa === 'enabled' ? 'ok' : m.mfa === 'disabled' ? 'warn' : 'neutral'" dot>{{ m.mfa }}</Badge></td>
|
||||
<td><Mono dim>{{ m.lastSeen }}</Mono></td>
|
||||
<td class="action-col" @click.stop>
|
||||
<button class="kebab" @click="openMenu(m, $event)">
|
||||
|
||||
Reference in New Issue
Block a user