feat: portal redesign, pricing catalog, partner-staff invites
- portal: new admin/ and partner/ surfaces with full component library (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables, layouts, partner-routing middleware, and supporting server APIs - pricing: Price schema/module with operator CRUD, pricing.vue catalog UI, Subscription extended with cycle/currency/perSeatAmount/seats snapshots for stable MRR aggregation - partner staff: User.partnerId, invite-partner-user DTO and flow, /partners/:slug/users endpoints, InvitePartnerUserModal, shared dezky-partner-staff Authentik group - /me: partner-aware endpoint returning user + partner context so portal can route between end-user and partner-admin surfaces - tenant: seats field for portfolio displays and future MRR calculations - operator: pricing page, signed-out page, useMe/useToast composables, ToastStack
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
<script setup lang="ts">
|
||||
// Partner team. Strict port of PartnerTeamScreen + PartnerTeammateRowActions
|
||||
// (partner-screens.jsx lines 1054-1099 + 1431-1524). Owner row (Anne Baslund)
|
||||
// has destructive actions (Suspend, Remove) disabled with "owner" mono tag.
|
||||
|
||||
|
||||
|
||||
import { customers } from '~/data/customers'
|
||||
import type { TeamMember } from '~/components/partner/TeammatePanel.vue'
|
||||
|
||||
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.
|
||||
interface PartnerUserDoc {
|
||||
_id: string
|
||||
authentikSubjectId: string
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
active: boolean
|
||||
lastLoginAt?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
const { data: rawTeam } = await useFetch<PartnerUserDoc[]>('/api/partner/users', {
|
||||
default: () => [],
|
||||
})
|
||||
|
||||
function lastSeenLabel(iso?: string): string {
|
||||
if (!iso) return 'never'
|
||||
const ms = Date.now() - new Date(iso).getTime()
|
||||
if (ms < 60_000) return 'just now'
|
||||
const m = Math.floor(ms / 60_000)
|
||||
if (m < 60) return `${m} min ago`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h} h ago`
|
||||
const d = Math.floor(h / 24)
|
||||
return `${d} d ago`
|
||||
}
|
||||
|
||||
const members = computed<TeamMember[]>(() =>
|
||||
(rawTeam.value ?? []).map((u) => ({
|
||||
id: u.authentikSubjectId,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
role: u.role === 'admin' ? 'Partner admin' : u.role === 'owner' ? 'Owner' : 'Partner staff',
|
||||
access: 'all',
|
||||
mfa: '—',
|
||||
lastSeen: lastSeenLabel(u.lastLoginAt),
|
||||
isOwner: u.role === 'owner',
|
||||
})),
|
||||
)
|
||||
|
||||
function accessLabel(m: TeamMember) {
|
||||
if (m.access === 'all') return `all (${customers.length})`
|
||||
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}`
|
||||
}
|
||||
|
||||
function onSent(payload: { email: string; role: string }) {
|
||||
toast.ok('Invitation sent', `${payload.role} invite to ${payload.email}`)
|
||||
}
|
||||
|
||||
// Row actions popover · mirrors PartnerTeammateRowActions (lines 1431-1524).
|
||||
const menuFor = ref<string | null>(null)
|
||||
const menuPos = ref<{ top: number; right: number }>({ top: 0, right: 0 })
|
||||
|
||||
function openMenu(m: TeamMember, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
const btn = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
menuPos.value = { top: btn.bottom + 4, right: window.innerWidth - btn.right }
|
||||
menuFor.value = menuFor.value === m.id ? null : m.id
|
||||
}
|
||||
|
||||
function actionsFor(m: TeamMember) {
|
||||
return [
|
||||
{ i: 'users', l: 'View details', fn: () => openMember.value = m },
|
||||
{ i: 'brush', l: 'Change role…', fn: () => openMember.value = m },
|
||||
{ i: 'building', l: 'Customer access…', fn: () => openMember.value = m },
|
||||
{ sep: true },
|
||||
{ i: 'refresh', l: 'Resend invitation', fn: () => toast.info('Invitation resent', m.email) },
|
||||
{ i: 'shield', l: 'Reset MFA', fn: () => toast.warn('MFA reset', `${m.name} must enrol again on next sign-in`) },
|
||||
{ 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 },
|
||||
]
|
||||
}
|
||||
|
||||
function closeMenu() { menuFor.value = null }
|
||||
|
||||
onMounted(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeMenu() }
|
||||
const onScroll = () => closeMenu()
|
||||
document.addEventListener('keydown', onKey)
|
||||
document.addEventListener('click', closeMenu)
|
||||
window.addEventListener('scroll', onScroll, true)
|
||||
window.addEventListener('resize', onScroll)
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.removeEventListener('click', closeMenu)
|
||||
window.removeEventListener('scroll', onScroll, true)
|
||||
window.removeEventListener('resize', onScroll)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
eyebrow="People"
|
||||
title="Partner team"
|
||||
subtitle="People at NordicMSP with access to the partner console and your customers."
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="primary" @click="inviteOpen = true">
|
||||
<template #leading><UiIcon name="plus" :size="14" /></template>
|
||||
Invite teammate
|
||||
</UiButton>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="content">
|
||||
<Card :pad="0">
|
||||
<table class="dtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>Customer access</th>
|
||||
<th>MFA</th>
|
||||
<th>Last seen</th>
|
||||
<th class="action-col" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="m in members"
|
||||
:key="m.id"
|
||||
@click="openMember = m"
|
||||
>
|
||||
<td>
|
||||
<div class="user-cell">
|
||||
<Avatar :name="m.name" :size="28" />
|
||||
<div>
|
||||
<div class="user-name">{{ m.name }}</div>
|
||||
<Mono dim>{{ m.email }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<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><Mono dim>{{ m.lastSeen }}</Mono></td>
|
||||
<td class="action-col" @click.stop>
|
||||
<button class="kebab" @click="openMenu(m, $event)">
|
||||
<UiIcon name="more" :size="14" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Portaled action menu -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="menuFor"
|
||||
class="menu"
|
||||
:style="{ top: menuPos.top + 'px', right: menuPos.right + 'px' }"
|
||||
@click.stop
|
||||
>
|
||||
<template v-for="(it, i) in actionsFor(members.find(m => m.id === menuFor)!)" :key="i">
|
||||
<div v-if="it.sep" class="menu-sep" />
|
||||
<button
|
||||
v-else
|
||||
class="menu-item"
|
||||
:class="{ danger: it.danger, disabled: it.disabled }"
|
||||
:disabled="it.disabled"
|
||||
@click="(it.fn?.(), closeMenu())"
|
||||
>
|
||||
<UiIcon :name="(it.i as any)" :size="13" />
|
||||
<span>{{ it.l }}</span>
|
||||
<Mono
|
||||
v-if="members.find(m => m.id === menuFor)?.isOwner && (it.l?.startsWith('Suspend') || it.l?.startsWith('Remove'))"
|
||||
dim
|
||||
class="owner-tag"
|
||||
>owner</Mono>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<PartnerInviteTeammateModal :open="inviteOpen" @close="inviteOpen = false" @sent="onSent" />
|
||||
<PartnerTeammatePanel :member="openMember" @close="openMember = null" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content { padding: 24px 40px 64px; }
|
||||
|
||||
.dtable { width: 100%; border-collapse: collapse; }
|
||||
.dtable th {
|
||||
text-align: left;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
font-weight: 500;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.dtable th.action-col { width: 40px; }
|
||||
.dtable td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dtable tbody tr { cursor: pointer; transition: background 80ms; }
|
||||
.dtable tbody tr:hover { background: var(--row-hover); }
|
||||
|
||||
.user-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.user-name { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.kebab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-mute);
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.kebab:hover { background: var(--row-hover); color: var(--text); }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Portaled menu — global because Teleport-ed to body */
|
||||
.menu {
|
||||
position: fixed;
|
||||
min-width: 220px;
|
||||
padding: 4px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
|
||||
z-index: 100;
|
||||
}
|
||||
.menu .menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
}
|
||||
.menu .menu-item:hover:not(:disabled) { background: var(--row-hover); }
|
||||
.menu .menu-item.danger { color: var(--bad); }
|
||||
.menu .menu-item.disabled, .menu .menu-item:disabled { color: var(--text-mute); cursor: not-allowed; opacity: 0.5; }
|
||||
.menu .menu-item svg { color: var(--text-mute); flex-shrink: 0; }
|
||||
.menu .menu-item.danger svg { color: var(--bad); }
|
||||
.menu .menu-item span { flex: 1; }
|
||||
.menu .menu-sep { height: 1px; background: var(--border); margin: 4px 0; }
|
||||
.menu .owner-tag { font-size: 9px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user