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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user