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,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