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,358 @@
|
||||
<script setup lang="ts">
|
||||
// Right-side panel with full detail on a partner teammate. Three tabs:
|
||||
// • Access & role — what they can do, which customers they can enter
|
||||
// • Activity — last 5 partner actions with timestamps + IPs
|
||||
// • Security — MFA card, active sessions, API tokens, suspend callout
|
||||
|
||||
import { customers } from '~/data/customers'
|
||||
|
||||
export interface TeamMember {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
access: 'all' | 'specific' | 'none' | string
|
||||
mfa: string
|
||||
lastSeen: string
|
||||
isOwner?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{ member: TeamMember | null }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const tab = ref<'access' | 'activity' | 'security'>('access')
|
||||
|
||||
watch(
|
||||
() => props.member?.id,
|
||||
() => { tab.value = 'access' },
|
||||
)
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ value: 'access', label: 'Access & role' },
|
||||
{ value: 'activity', label: 'Activity', count: 5 },
|
||||
{ value: 'security', label: 'Security' },
|
||||
])
|
||||
|
||||
const recentActions = [
|
||||
{ when: '12 min ago', action: 'entered customer', target: 'Acme Industries', ip: '92.43.118.4 · København' },
|
||||
{ when: '1 h ago', action: 'invited user', target: 'magnus@acme.dk', ip: '92.43.118.4 · København' },
|
||||
{ when: 'Yesterday', action: 'changed plan', target: 'Bygherre · Business → Business+', ip: '92.43.118.4 · København' },
|
||||
{ when: '3 days ago', action: 'signed in', target: 'partner console', ip: '78.32.4.91 · København' },
|
||||
{ when: '1 week ago', action: 'provisioned', target: 'Henriksen Revision · new customer', ip: '92.43.118.4 · København' },
|
||||
]
|
||||
|
||||
function permissionsFor(role: string) {
|
||||
return [
|
||||
{ l: 'View customer dashboards', allowed: true },
|
||||
{ l: 'Enter customer as partner', allowed: role !== 'Billing' },
|
||||
{ l: 'Provision new customers', allowed: role === 'Partner admin' || role === 'Sales' },
|
||||
{ l: 'Change customer plans', allowed: role === 'Partner admin' || role === 'Sales' },
|
||||
{ l: 'Manage partner billing', allowed: role === 'Partner admin' || role === 'Billing' },
|
||||
{ l: 'Manage partner team', allowed: role === 'Partner admin' },
|
||||
{ l: 'Edit partner branding', allowed: role === 'Partner admin' },
|
||||
]
|
||||
}
|
||||
|
||||
const isOwner = computed(() => !!props.member?.isOwner)
|
||||
|
||||
const accessText = computed(() => {
|
||||
if (!props.member) return ''
|
||||
if (props.member.access === 'all') return `all (${customers.length})`
|
||||
if (props.member.access === 'none') return 'no access'
|
||||
// Specific: just say first N customers
|
||||
return `${customers.length - 5} of ${customers.length}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidePanel
|
||||
:open="!!member"
|
||||
width="lg"
|
||||
eyebrow="Partner teammate"
|
||||
:title="member?.name || ''"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<template #header>
|
||||
<!-- header handled by SidePanel slot defaults -->
|
||||
</template>
|
||||
|
||||
<div v-if="member" class="profile-head">
|
||||
<Avatar :name="member.name" :size="48" />
|
||||
<div class="ph-meta">
|
||||
<div class="ph-name">{{ member.name }}</div>
|
||||
<Mono dim>{{ member.email }}</Mono>
|
||||
</div>
|
||||
<Badge :tone="member.role === 'Partner admin' ? 'invert' : 'neutral'">{{ member.role }}</Badge>
|
||||
</div>
|
||||
|
||||
<div v-if="member" class="profile-stats">
|
||||
<div>
|
||||
<Eyebrow>Customer access</Eyebrow>
|
||||
<div class="ps-val">{{ accessText }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Eyebrow>MFA</Eyebrow>
|
||||
<div class="ps-val"><Badge tone="ok" dot>enabled</Badge></div>
|
||||
</div>
|
||||
<div>
|
||||
<Eyebrow>Last seen</Eyebrow>
|
||||
<div class="ps-val">{{ member.lastSeen }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs-wrap">
|
||||
<Tabs v-model="tab" :items="tabs" />
|
||||
</div>
|
||||
|
||||
<div v-if="member && tab === 'access'" class="tab-body">
|
||||
<div class="field">
|
||||
<Eyebrow>Role</Eyebrow>
|
||||
<div class="role-grid">
|
||||
<div
|
||||
v-for="r in ['Partner admin', 'Sales', 'Support', 'Billing']"
|
||||
:key="r"
|
||||
class="role-card"
|
||||
:class="{ selected: member.role === r }"
|
||||
>
|
||||
<span>{{ r }}</span>
|
||||
<Badge v-if="member.role === r" tone="invert">current</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Eyebrow>Customer access</Eyebrow>
|
||||
<div class="access-card">
|
||||
<div class="ac-head">
|
||||
<Mono dim>{{ accessText }}</Mono>
|
||||
<UiButton size="sm" variant="ghost">Change</UiButton>
|
||||
</div>
|
||||
<div class="ac-list">
|
||||
<div
|
||||
v-for="c in customers.slice(0, member.access === 'all' ? customers.length : 3)"
|
||||
:key="c.id"
|
||||
class="ac-row"
|
||||
>
|
||||
<UiIcon name="check" :size="11" :stroke-width="2.5" />
|
||||
<div class="cust-swatch" :style="{ background: c.brandColor }" />
|
||||
<span class="cust-name">{{ c.name }}</span>
|
||||
<Mono dim>{{ c.planLabel }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Eyebrow>Permissions in {{ member.role }}</Eyebrow>
|
||||
<div class="perm-list">
|
||||
<div v-for="p in permissionsFor(member.role)" :key="p.l" class="perm-row">
|
||||
<UiIcon :name="p.allowed ? 'check' : 'x'" :size="12" :stroke-width="p.allowed ? 2.5 : 2" />
|
||||
<span :class="{ muted: !p.allowed }">{{ p.l }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="member && tab === 'activity'" class="tab-body">
|
||||
<div class="activity-list">
|
||||
<div v-for="(a, i) in recentActions" :key="i" class="activity-row">
|
||||
<div class="activity-icon">
|
||||
<UiIcon
|
||||
:name="a.action.startsWith('signed') ? 'shield' : a.action.startsWith('entered') ? 'arrowRight' : a.action.startsWith('invited') ? 'users' : a.action.startsWith('provisioned') ? 'plus' : 'brush'"
|
||||
:size="12"
|
||||
/>
|
||||
</div>
|
||||
<div class="activity-meta">
|
||||
<div class="ar-top">
|
||||
<Mono dim>{{ a.action }}</Mono>
|
||||
<span>{{ a.target }}</span>
|
||||
</div>
|
||||
<Mono dim>{{ a.ip }}</Mono>
|
||||
</div>
|
||||
<Mono dim>{{ a.when }}</Mono>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="member && tab === 'security'" class="tab-body">
|
||||
<div class="sec-row">
|
||||
<UiIcon name="shield" :size="16" />
|
||||
<div class="sec-meta">
|
||||
<div class="sec-label">MFA enabled</div>
|
||||
<Mono dim>TOTP · enrolled 12 Jan 2026</Mono>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost">Reset</UiButton>
|
||||
</div>
|
||||
<div class="sec-row">
|
||||
<UiIcon name="device" :size="16" />
|
||||
<div class="sec-meta">
|
||||
<div class="sec-label">3 active sessions</div>
|
||||
<Mono dim>Chrome · macOS · København</Mono>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost">View · sign out</UiButton>
|
||||
</div>
|
||||
<div class="sec-row">
|
||||
<UiIcon name="key" :size="16" />
|
||||
<div class="sec-meta">
|
||||
<div class="sec-label">API tokens</div>
|
||||
<Mono dim>1 personal token · last used 2 d ago</Mono>
|
||||
</div>
|
||||
<UiButton size="sm" variant="ghost">Manage</UiButton>
|
||||
</div>
|
||||
|
||||
<div class="danger-callout">
|
||||
<UiIcon name="shield" :size="14" />
|
||||
<div class="dc-meta">
|
||||
<div class="dc-label">Suspend account</div>
|
||||
<p>Immediately revoke access. Sessions are terminated and the teammate cannot sign back in. Reversible.</p>
|
||||
</div>
|
||||
<UiButton size="sm" variant="secondary" :disabled="isOwner">Suspend</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<UiButton variant="danger" :disabled="isOwner">
|
||||
<template #leading><UiIcon name="trash" :size="14" /></template>
|
||||
Remove from team
|
||||
</UiButton>
|
||||
<div style="flex:1" />
|
||||
<UiButton variant="secondary">
|
||||
<template #leading><UiIcon name="refresh" :size="14" /></template>
|
||||
Reset password
|
||||
</UiButton>
|
||||
<UiButton variant="primary" @click="emit('close')">
|
||||
<template #leading><UiIcon name="check" :size="14" /></template>
|
||||
Save
|
||||
</UiButton>
|
||||
</template>
|
||||
</SidePanel>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ph-meta { flex: 1; min-width: 0; }
|
||||
.ph-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.profile-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
padding-bottom: 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.ps-val { font-size: 13px; font-weight: 500; margin-top: 4px; }
|
||||
|
||||
.tabs-wrap { margin: -2px -24px 0; padding: 0 24px; border-bottom: 1px solid var(--border); }
|
||||
|
||||
.tab-body { padding-top: 22px; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.role-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.role-card {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.role-card.selected { border-color: var(--text); background: var(--bg); }
|
||||
|
||||
.access-card {
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.ac-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.ac-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.ac-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ac-row :deep(svg) { color: var(--ok); }
|
||||
.cust-swatch { width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; }
|
||||
.cust-name { flex: 1; }
|
||||
|
||||
.perm-list { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; }
|
||||
.perm-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.perm-row :deep(svg) { color: var(--ok); }
|
||||
.perm-row .muted { color: var(--text-mute); }
|
||||
.perm-row :deep(svg.muted) { color: var(--text-mute); }
|
||||
|
||||
.activity-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.activity-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.activity-icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-mute);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.activity-meta { flex: 1; min-width: 0; }
|
||||
.ar-top { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
|
||||
.ar-top span { font-size: 13px; }
|
||||
|
||||
.sec-row {
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.sec-row :deep(svg) { color: var(--text-mute); flex-shrink: 0; }
|
||||
.sec-meta { flex: 1; }
|
||||
.sec-label { font-size: 13px; font-weight: 500; }
|
||||
|
||||
.danger-callout {
|
||||
margin-top: 8px;
|
||||
padding: 14px;
|
||||
background: rgba(226, 48, 48, 0.06);
|
||||
border: 1px solid rgba(226, 48, 48, 0.22);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.danger-callout :deep(svg) { color: var(--bad); margin-top: 2px; flex-shrink: 0; }
|
||||
.dc-meta { flex: 1; }
|
||||
.dc-label { font-size: 13px; font-weight: 600; color: var(--bad); }
|
||||
.dc-meta p { font-size: 12px; color: var(--text-dim); line-height: 1.5; margin: 4px 0 0; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user