Files
dezky/apps/portal/components/partner/TeammatePanel.vue
T
Ronni Baslund 7720e4be83 refactor(portal): partner-mode customer switcher on real tenants
Migrate the partner-mode customer switcher, in-customer banner, sidebar tile and the team invite/teammate panels off the data/customers fixture onto the real /api/partner/tenants list (shared key, gated to partner-staff so the global shell doesn't 403 for other users). Active customer resolves by tenant _id (the key the customers page already passes to partnerMode.enter); partner-identity labels now use the real partner name from useMe. Removes the now-unused customers + CustomerOrg-list fixture export and the dead setCustomer helper. Verified in UI: switcher + enter/exit show real Baslund Test / Baslund Research ApS.
2026-05-30 14:51:14 +02:00

362 lines
12 KiB
Vue

<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
const { tenants } = usePartnerTenants()
const PLAN_LABEL: Record<string, string> = { mvp: 'Starter', pro: 'Business', enterprise: 'Enterprise' }
export interface TeamMember {
id: string
name: string
email: string
role: string
access: 'all' | 'specific' | 'none' | string
// Number of customers when access is scoped; null when access is 'all'.
accessCount?: number | null
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 ''
const total = tenants.value?.length ?? 0
if (props.member.access === 'all') return `all (${total})`
if (props.member.access === 'none') return 'no access'
return `${props.member.accessCount ?? 0} of ${total}`
})
</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 tenants.slice(0, member.access === 'all' ? tenants.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 || '#0A0A0A' }" />
<span class="cust-name">{{ c.name }}</span>
<Mono dim>{{ PLAN_LABEL[c.plan ?? 'pro'] }}</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>