Files
Ronni Baslund 3288fde693 feat(portal): customer-admin surface on real data + Stripe billing + session resilience
Access & navigation
- Gate partner-mode strictly to partner staff so admins/end-users never inherit
  leftover partner-view state; purge stale session entry on hydrate.
- Role-driven admin entry: useMe.isTenantAdmin, Admin/Personal tiles in the app
  launcher, and an /admin route guard in the global middleware (fail closed).
- Drop the duplicate user identity block from the sidebar footer.

Admin pages on real data
- New tenant-scoped, membership-gated endpoints: GET /tenants/:slug/{audit,users,
  invoices}; useTenant composable resolves the active workspace + subscription.
- Dashboard: real seats, spend (cycle-normalized + minor-units), plan, renewal,
  and recent audit; unbacked sections removed.
- Users & groups: real members; Groups/Invitations/Service accounts shown as
  honest "coming soon".
- Subscription & invoices: real plan hero, invoice history, and billing details.

Stripe payment method (Elements + SetupIntent)
- StripeClient: publishable key + getDefaultCard/createSetupIntent/setDefaultCard.
- CustomerBillingController + BillingService methods (ensure-customer on demand).
- Portal: PaymentMethodModal, useStripeJs (CDN load), proxies; hidePostalCode.

Editable billing details & whitelabel branding
- PATCH /tenants/:slug/billing-info (narrow: company/VAT/country/email).
- TenantBranding schema/service + GET/PUT /tenants/:slug/branding: real product
  name, accent colour, and per-tenant email-template overrides.
- Branding preview + sidebar workspace mark wired to real name/plan/seats/colour
  with YIQ auto-contrast (readableOn util).

Session resilience
- Request offline_access so Authentik issues a refresh token (automaticRefresh).
- Silent refresh + single retry on 401 for writes (useApiFetch, incl. partner
  pages) and reads (useMe.fetchMe) — no redirect, no lost input.
- Modal backdrop closes only on press+release on the backdrop (no more
  drag-select-to-close).
2026-05-31 00:19:34 +02:00

604 lines
19 KiB
Vue

<script setup lang="ts">
// Full portfolio list. Strict port of CustomersScreen in partner-screens.jsx
// (lines 366-494). Table + cards view toggle, status/plan filters, search.
// Click a row → confirm enter customer modal → partnerMode.enter() + /admin.
import type { CustomerOrg, CustomerStatus, PartnerTenantDoc } from '~/types/partner'
const toast = useToast()
const router = useRouter()
const { request } = useApiFetch()
const partnerMode = usePartnerMode()
const view = ref<'table' | 'cards'>('table')
const query = ref('')
const statusFilter = ref<'all' | CustomerStatus>('all')
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 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()
// 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.
// Best-effort map: deleted/suspended → suspended badge, pending → trial,
// active stays active (rendered as 'healthy').
if (s === 'active') return 'healthy'
if (s === 'pending') return 'trial'
return 'suspended'
}
// Backend plan enum (mvp/pro/enterprise) → fixture-friendly slug + label.
// Same mapping mirrored in CustomerCreateWizard.vue; keep both in sync if
// the backend enum ever changes.
const PLAN_INFO: Record<
'mvp' | 'pro' | 'enterprise',
{ slug: CustomerOrg['plan']; label: CustomerOrg['planLabel'] }
> = {
mvp: { slug: 'starter', label: 'Starter' },
pro: { slug: 'business', label: 'Business' },
enterprise: { slug: 'enterprise', label: 'Enterprise' },
}
// Type extension on the row so the table can show the currency next to
// 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
}
const customers = computed<CustomerRow[]>(() =>
(rawTenants.value ?? []).map((t) => {
const info = PLAN_INFO[t.plan ?? 'pro']
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,
planLabel: info.label,
// 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: 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: t.brandColor || '#D4FF3A',
industry: t.industry ?? '—',
createdOn: t.createdAt ?? '',
since: t.createdAt ?? '',
}
}),
)
const filtered = computed(() => {
return customers.value.filter((c) => {
if (statusFilter.value !== 'all' && c.status !== statusFilter.value) return false
if (planFilter.value !== 'all' && c.plan !== planFilter.value) return false
if (query.value) {
const q = query.value.toLowerCase()
if (!(c.name + ' ' + c.domain).toLowerCase().includes(q)) return false
}
return true
})
})
const statusOpts = [
{ value: 'all', label: 'All' },
{ value: 'healthy', label: 'Healthy' },
{ value: 'attention', label: 'Attention' },
{ value: 'past_due', label: 'Past due' },
{ value: 'trial', label: 'Trial' },
] as const
const planOpts = [
{ value: 'all', label: 'All' },
{ value: 'starter', label: 'Starter' },
{ value: 'business', label: 'Business' },
{ value: 'enterprise', label: 'Enterprise' },
] as const
function statusBadge(s: CustomerStatus): { tone: 'ok' | 'warn' | 'bad' | 'info' | 'neutral'; label: string } {
switch (s) {
case 'healthy': return { tone: 'ok', label: 'healthy' }
case 'attention': return { tone: 'warn', label: 'attention' }
case 'past_due': return { tone: 'bad', label: 'past-due' }
case 'trial': return { tone: 'info', label: 'trial' }
case 'suspended': return { tone: 'neutral', label: 'suspended' }
}
}
function startEnter(c: CustomerOrg) {
entryCustomer.value = c
}
async function onProvisioned() {
toast.ok('Customer provisioned', 'Tenant created — provisioning runs in the background')
// Refetch so the new tenant + its subscription's MRR show up without a
// full reload. refreshNuxtData also nudges the dashboard's cached
// MRR card the next time the user navigates back to /partner.
await Promise.all([
refreshTenants(),
refreshMrr(),
refreshNuxtData('partner-tenants'),
refreshNuxtData('partner-mrr'),
])
}
function confirmEnter(reason: string) {
if (!entryCustomer.value) return
const c = entryCustomer.value
partnerMode.enter(c.id)
entryCustomer.value = null
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 request(`/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 request(`/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>
<div>
<PageHeader
eyebrow="Portfolio"
title="Customer organizations"
subtitle="Every customer org under your reseller agreement. Click to manage as partner."
>
<template #actions>
<UiButton variant="secondary" @click="toast.ok('Exporting CSV', `${customers.length} customers`)">
<template #leading><UiIcon name="download" :size="14" /></template>
Export CSV
</UiButton>
<UiButton variant="primary" @click="wizardOpen = true">
<template #leading><UiIcon name="plus" :size="14" /></template>
New customer
</UiButton>
</template>
</PageHeader>
<div class="content">
<!-- Filter bar -->
<div class="filters">
<div class="search">
<UiIcon name="search" :size="14" />
<input v-model="query" placeholder="Search customer or domain…" />
</div>
<div class="seg">
<span class="seg-label">Status</span>
<select v-model="statusFilter">
<option v-for="o in statusOpts" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
</div>
<div class="seg">
<span class="seg-label">Plan</span>
<select v-model="planFilter">
<option v-for="o in planOpts" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
</div>
<div class="spacer" />
<Mono dim>{{ filtered.length }} of {{ customers.length }}</Mono>
<div class="view-toggle">
<button
v-for="v in (['table','cards'] as const)"
:key="v"
:class="{ active: view === v }"
@click="view = v"
>{{ v }}</button>
</div>
</div>
<!-- Table view -->
<Card v-if="view === 'table'" :pad="0">
<div class="table-wrap">
<table class="dtable">
<thead>
<tr>
<th class="sortable">Customer <UiIcon name="chevUpDown" :size="11" /></th>
<th class="sortable">Plan <UiIcon name="chevUpDown" :size="11" /></th>
<th class="sortable">Seats <UiIcon name="chevUpDown" :size="11" /></th>
<th class="sortable num">MRR <UiIcon name="chevUpDown" :size="11" /></th>
<th class="sortable">Status <UiIcon name="chevUpDown" :size="11" /></th>
<th>Customer since</th>
<th class="action-col" />
</tr>
</thead>
<tbody>
<tr v-for="c in filtered" :key="c.id" @click="startEnter(c)">
<td>
<div class="cust-cell">
<div class="cust-swatch" :style="{ background: c.brandColor }" />
<div>
<div class="cust-name">{{ c.name }}</div>
<Mono dim>{{ c.domain }}</Mono>
</div>
</div>
</td>
<td>
<Badge :tone="c.plan === 'enterprise' ? 'invert' : 'neutral'">{{ c.planLabel }}</Badge>
</td>
<td>
<Mono>{{ c.seats.used }}/{{ c.seats.total }}</Mono>
</td>
<td class="num">
<span class="mrr">{{ c.mrrDkk > 0 ? c.mrrDkk.toLocaleString('da-DK') + ' ' + c.mrrCurrency : (c.mrrCustom ? 'custom' : '—') }}</span>
</td>
<td>
<Badge :tone="statusBadge(c.status).tone" dot>{{ statusBadge(c.status).label }}</Badge>
</td>
<td><Mono dim>{{ c.since }}</Mono></td>
<td class="action-col" @click.stop>
<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">
<td colspan="7" class="empty">No customers match these filters.</td>
</tr>
</tbody>
</table>
</div>
</Card>
<!-- Cards view -->
<div v-else class="cards-grid">
<button
v-for="c in filtered"
:key="c.id"
class="ccard"
@click="startEnter(c)"
>
<div class="ccard-stripe" :style="{ background: c.brandColor }" />
<div class="ccard-body">
<div class="ccard-head">
<div class="ccard-id">
<div class="ccard-name">{{ c.name }}</div>
<Mono dim>{{ c.domain }}</Mono>
</div>
<Badge :tone="statusBadge(c.status).tone" dot>{{ statusBadge(c.status).label }}</Badge>
</div>
<div class="ccard-meta">
<div>
<Eyebrow>Plan</Eyebrow>
<div class="cm-val">{{ c.planLabel }}</div>
</div>
<div>
<Eyebrow>Seats</Eyebrow>
<div class="cm-val mono">{{ c.seats.used }} / {{ c.seats.total }}</div>
</div>
<div>
<Eyebrow>MRR</Eyebrow>
<div class="cm-val mono">{{ c.mrrDkk > 0 ? c.mrrDkk.toLocaleString('da-DK') + ' ' + c.mrrCurrency : (c.mrrCustom ? 'custom' : '—') }}</div>
</div>
<div>
<Eyebrow>Since</Eyebrow>
<div class="cm-val">{{ c.since }}</div>
</div>
</div>
</div>
</button>
</div>
</div>
<PartnerCustomerCreateWizard
:open="wizardOpen"
@close="wizardOpen = false"
@done="onProvisioned"
/>
<PartnerEnterCustomerConfirmModal
:customer="entryCustomer"
@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>
<style scoped>
.content { padding: 16px 40px 64px; display: flex; flex-direction: column; gap: 16px; }
.filters {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.search {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
width: 320px;
color: var(--text-mute);
}
.search input {
flex: 1;
border: none;
background: transparent;
font-family: inherit;
font-size: 13px;
padding: 9px 0;
color: var(--text);
}
.search input:focus { outline: none; }
.seg {
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.seg-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
}
.seg select {
border: none;
background: transparent;
font-family: inherit;
font-size: 13px;
color: var(--text);
padding: 8px 4px;
cursor: pointer;
}
.seg select:focus { outline: none; }
.spacer { flex: 1; }
.view-toggle {
display: flex;
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px;
background: var(--surface);
}
.view-toggle button {
padding: 6px 12px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-mute);
font-family: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.view-toggle button.active { background: var(--text); color: var(--bg); }
/* Table */
.table-wrap { overflow-x: auto; }
.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.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: 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;
border-bottom: 1px solid var(--border);
font-size: 13px;
color: var(--text);
vertical-align: middle;
}
.dtable tbody tr { cursor: pointer; transition: background 80ms; }
.dtable tbody tr:hover { background: var(--row-hover); }
.cust-cell { display: flex; align-items: center; gap: 12px; }
.cust-swatch { width: 28px; height: 28px; border-radius: 6px; flex-shrink: 0; }
.cust-name { font-size: 13px; font-weight: 500; }
.mrr {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 500;
}
.empty {
text-align: center;
color: var(--text-mute);
font-size: 13px;
padding: 48px 0;
}
/* Cards */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.ccard {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
text-align: left;
cursor: pointer;
color: inherit;
font: inherit;
padding: 0;
transition: border-color 120ms;
display: flex;
flex-direction: column;
}
.ccard:hover { border-color: var(--text); }
.ccard-stripe { height: 6px; }
.ccard-body { padding: 16px; }
.ccard-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.ccard-id { min-width: 0; }
.ccard-name {
font-family: var(--font-display);
font-size: 16px;
font-weight: 600;
letter-spacing: -0.015em;
}
.ccard-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid var(--border);
}
.cm-val {
font-size: 13px;
font-weight: 500;
margin-top: 4px;
}
.cm-val.mono { font-family: var(--font-mono); }
</style>