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).
This commit is contained in:
Ronni Baslund
2026-05-31 00:19:34 +02:00
parent db26dafc64
commit 3288fde693
44 changed files with 1874 additions and 1237 deletions
+101 -110
View File
@@ -1,61 +1,77 @@
<script setup lang="ts">
// Strict port of project/platform-screens.jsx `AdminDashboard` (lines 447-605).
// Keep spacing tokens (24px 40px 64px 40px content, 16 gaps), the 4-column
// stat strip in a single Card with per-column borders, the two-up
// 1.4fr / 1fr blocks (License + Recent admin events; Issues + Quick actions),
// the source's exact issue rows, audit slice, and quick-action buttons.
// Customer-admin dashboard. Layout descends from project/platform-screens.jsx
// `AdminDashboard`, but the data is real: workspace identity, seats, spend,
// plan and recent admin events all come from /api/me + /api/tenants/:slug/*.
//
// Sections without a real backend source yet (storage usage, mail-flow health,
// "open issues" like DMARC/failed-login heuristics) were removed rather than
// faked — they return when their backends (OCIS metrics, Stalwart metrics, a
// domain-health checker) exist.
import type { IconName } from '~/components/UiIcon.vue'
import { sampleAudit } from '~/data/workspace'
import type { AuditEventDoc, TenantUserDoc } from '~/types/workspace'
const toast = useToast()
const router = useRouter()
const { fetchMe } = useMe()
await fetchMe()
const { tenant, subscription, planLabel, currency, seatLimit, perSeatMonthly, monthlySpend, primaryDomain, renewsAt } = useTenant()
const slug = computed(() => tenant.value?.slug ?? '')
// Workspace users (seat usage) + recent audit, both tenant-scoped. Gated on a
// resolved slug so we don't fire against /api/tenants//... before /me lands.
const { data: users } = await useFetch<TenantUserDoc[]>(
() => `/api/tenants/${slug.value}/users`,
{ key: 'admin-dash-users', default: () => [], immediate: !!slug.value, watch: [slug] },
)
const { data: auditRaw } = await useFetch<AuditEventDoc[]>(
() => `/api/tenants/${slug.value}/audit?limit=6`,
{ key: 'admin-dash-audit', default: () => [], immediate: !!slug.value, watch: [slug] },
)
const seatsUsed = computed(() => (users.value ?? []).filter((u) => u.active !== false).length)
const seatsAvailable = computed(() => Math.max(0, seatLimit.value - seatsUsed.value))
const seatPct = computed(() =>
seatLimit.value ? Math.min(100, Math.round((seatsUsed.value / seatLimit.value) * 100)) : 0,
)
const moneyFmt = computed(
() => new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency.value, maximumFractionDigits: 0 }),
)
function fmtDate(d: Date | null): string {
return d ? d.toLocaleDateString('da-DK', { day: '2-digit', month: 'long', year: 'numeric' }) : '—'
}
const statusLabel = computed(() => {
const s = tenant.value?.status ?? 'pending'
return s.charAt(0).toUpperCase() + s.slice(1)
})
const stats = computed<Array<{ label: string; value: string; hint: string }>>(() => [
{ label: 'Seats used', value: `${seatsUsed.value} / ${seatLimit.value}`, hint: `${seatsAvailable.value} available` },
{ label: 'Monthly spend', value: moneyFmt.value.format(monthlySpend.value), hint: renewsAt.value ? `renews ${fmtDate(renewsAt.value)}` : '' },
{ label: 'Plan', value: planLabel.value, hint: subscription.value?.cycle ?? '' },
{ label: 'Status', value: statusLabel.value, hint: `${tenant.value?.domains?.length ?? 0} domain${(tenant.value?.domains?.length ?? 0) === 1 ? '' : 's'}` },
])
// Map raw audit events onto the row shape the activity list renders. Tone is
// derived from outcome (failed actions read red); everything else is neutral.
const recent = computed(() =>
(auditRaw.value ?? []).map((e) => ({
id: e._id,
when: new Date(e.at).toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' }),
actor: e.actorType === 'system' ? 'system' : e.actorEmail ?? '—',
action: e.action,
target: e.resourceName ?? e.resourceId ?? '',
tone: e.outcome === 'failure' ? 'bad' : 'info',
})),
)
const inviteOpen = ref(false)
const inviteStep = ref(1)
const seatsOpen = ref(false)
const seatsExtra = ref(5)
const stats: Array<{
label: string
value: string
delta?: string
deltaTone?: 'up' | 'down'
hint: string
}> = [
{ label: 'Seats used', value: '11 / 25', delta: '+2 this week', deltaTone: 'up', hint: '' },
{ label: 'Storage', value: '1.4 TB', delta: '64% of 2.2 TB', hint: '' },
{ label: 'Mail flow', value: 'Healthy', hint: '99.98% · last 7d' },
{ label: 'Monthly spend', value: '1.940 DKK', hint: 'next invoice 01 Jun' },
]
const recent = sampleAudit.slice(0, 6)
const issues = [
{
tone: 'warn' as const,
title: 'DMARC record missing on baslund.dk',
body: 'Mail from this domain may fail Gmail / Outlook spam checks.',
action: 'Fix record',
onAction: () => router.push('/admin/domains'),
},
{
tone: 'bad' as const,
title: 'Failed login attempts from 203.0.113.4',
body: '3 attempts on oliver@dezky.com in the last hour. Consider IP blocklist.',
action: 'Review',
onAction: () => router.push('/admin/security'),
},
{
tone: 'info' as const,
title: '2 invitations pending',
body: 'Magnus Eriksen and Emma Skov havent accepted yet.',
action: 'Resend',
onAction: () => toast.ok('Invitation resent to Magnus and Emma'),
},
]
const quickActions: { icon: IconName; label: string; onClick: () => void }[] = [
{ icon: 'users', label: 'Invite user', onClick: () => { inviteOpen.value = true } },
{ icon: 'globe', label: 'Verify domain', onClick: () => router.push('/admin/domains') },
@@ -71,16 +87,24 @@ function sendInvite() {
toast.ok('Invitation sent to magnus@dezky.com')
}
const pricePerSeat = 78
const daysUntilRenewal = 96
const monthly = computed(() => seatsExtra.value * pricePerSeat)
const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 30)))
// Add-seats modal math, fed by the real subscription. The seat-change mutation
// itself isn't wired yet (subscription PATCH is operator-only), so confirming
// still toasts — but the figures shown are the customer's real numbers.
// perSeatMonthly is already cycle-normalized + in major units.
const pricePerSeat = computed(() => perSeatMonthly.value)
const daysUntilRenewal = computed(() => {
if (!renewsAt.value) return 30
const ms = renewsAt.value.getTime() - Date.now()
return Math.max(0, Math.round(ms / 86_400_000))
})
const monthly = computed(() => seatsExtra.value * pricePerSeat.value)
const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal.value / 30)))
</script>
<template>
<div>
<PageHeader
eyebrow="Acme Workspace · dezky.com"
:eyebrow="tenant ? `${tenant.name}${primaryDomain ? ` · ${primaryDomain}` : ''}` : 'Workspace'"
title="Dashboard"
subtitle="Health, activity, and quick actions across your workspace."
>
@@ -104,8 +128,6 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
<Stat
:label="s.label"
:value="s.value"
:delta="s.delta"
:delta-tone="s.deltaTone"
:hint="s.hint"
/>
</div>
@@ -118,17 +140,19 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
<div class="card-head card-head-inline">
<div>
<Eyebrow>Plan</Eyebrow>
<div class="card-title">Business · 25 seats</div>
<div class="card-sub">Renewing 28 August 2026 · 1.940 DKK / month</div>
<div class="card-title">{{ planLabel }} · {{ seatLimit }} seats</div>
<div class="card-sub">
<template v-if="renewsAt">Renewing {{ fmtDate(renewsAt) }} · </template>{{ moneyFmt.format(monthlySpend) }} / month
</div>
</div>
<UiButton size="sm" variant="secondary" @click="router.push('/admin/billing')">Manage plan</UiButton>
</div>
<div class="progress-block">
<div class="progress-bar"><span style="width: 44%" /></div>
<div class="progress-bar"><span :style="{ width: `${seatPct}%` }" /></div>
<div class="progress-legend">
<span>11 active</span>
<span>14 available</span>
<span>{{ seatsUsed }} active</span>
<span>{{ seatsAvailable }} available</span>
</div>
</div>
@@ -160,31 +184,15 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
</div>
<Mono dim>{{ a.when }}</Mono>
</div>
<div v-if="recent.length === 0" class="audit-empty">
<Mono dim>No recent activity yet.</Mono>
</div>
</div>
</Card>
</div>
<!-- Open issues + Quick actions -->
<div class="row two-col-11">
<Card>
<div class="card-head card-head-inline">
<div>
<Eyebrow>Health</Eyebrow>
<div class="card-title">Open issues</div>
</div>
<Badge tone="warn">2 to review</Badge>
</div>
<div class="issues">
<div v-for="it in issues" :key="it.title" class="issue" :data-tone="it.tone">
<div class="issue-body">
<div class="issue-title">{{ it.title }}</div>
<div class="issue-sub">{{ it.body }}</div>
</div>
<UiButton size="sm" variant="secondary" @click="it.onAction()">{{ it.action }}</UiButton>
</div>
</div>
</Card>
<!-- Quick actions -->
<div class="row">
<Card>
<div class="card-head card-head-inline">
<div>
@@ -192,7 +200,7 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
<div class="card-title">Common tasks</div>
</div>
</div>
<div class="qa-grid">
<div class="qa-grid qa-grid-wide">
<button v-for="a in quickActions" :key="a.label" class="qa" @click="a.onClick">
<UiIcon :name="a.icon" :size="15" stroke="var(--text-mute)" />
{{ a.label }}
@@ -262,9 +270,9 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
<Modal :open="seatsOpen" title="Add seats" eyebrow="Billing · seats" size="md" @close="seatsOpen = false">
<div class="seats">
<div class="seats-grid">
<div class="seats-cell"><Eyebrow>Active users</Eyebrow><div class="seats-big">11</div></div>
<div class="seats-cell"><Eyebrow>Current seats</Eyebrow><div class="seats-big">25</div></div>
<div class="seats-cell"><Eyebrow>After change</Eyebrow><div class="seats-big ok">{{ 25 + seatsExtra }}</div></div>
<div class="seats-cell"><Eyebrow>Active users</Eyebrow><div class="seats-big">{{ seatsUsed }}</div></div>
<div class="seats-cell"><Eyebrow>Current seats</Eyebrow><div class="seats-big">{{ seatLimit }}</div></div>
<div class="seats-cell"><Eyebrow>After change</Eyebrow><div class="seats-big ok">{{ seatLimit + seatsExtra }}</div></div>
</div>
<div>
<Eyebrow>How many seats to add</Eyebrow>
@@ -279,19 +287,19 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
</div>
<div class="charge-summary">
<Eyebrow>What you'll pay</Eyebrow>
<div class="charge-row"><span>{{ seatsExtra }} new seat{{ seatsExtra === 1 ? '' : 's' }} × {{ pricePerSeat }} DKK / month</span><Mono>{{ monthly.toLocaleString('da-DK') }} DKK / mo</Mono></div>
<div class="charge-row sep"><span class="muted">Prorated for current cycle ({{ daysUntilRenewal }} days until renewal)</span><Mono dim>{{ prorated.toLocaleString('da-DK') }} DKK</Mono></div>
<div class="charge-row total"><span>Charged today</span><span class="big">{{ prorated.toLocaleString('da-DK') }} DKK</span></div>
<div class="charge-row"><span class="muted">Next invoice on 01 Jun 2026</span><Mono dim>{{ (1940 + monthly).toLocaleString('da-DK') }} DKK</Mono></div>
<div class="charge-row"><span>{{ seatsExtra }} new seat{{ seatsExtra === 1 ? '' : 's' }} × {{ moneyFmt.format(pricePerSeat) }} / month</span><Mono>{{ moneyFmt.format(monthly) }} / mo</Mono></div>
<div class="charge-row sep"><span class="muted">Prorated for current cycle ({{ daysUntilRenewal }} days until renewal)</span><Mono dim>{{ moneyFmt.format(prorated) }}</Mono></div>
<div class="charge-row total"><span>Charged today</span><span class="big">{{ moneyFmt.format(prorated) }}</span></div>
<div class="charge-row"><span class="muted"><template v-if="renewsAt">Next invoice on {{ fmtDate(renewsAt) }}</template><template v-else>Next invoice</template></span><Mono dim>{{ moneyFmt.format(monthlySpend + monthly) }}</Mono></div>
</div>
<div class="info-strip">
<UiIcon name="card" :size="14" stroke="var(--text-mute)" />
<span>Charged to <Mono>Visa 4242</Mono>. Seats are added instantly invitations can be sent right away.</span>
<span>Seats are added instantly invitations can be sent right away.</span>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="seatsOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="() => { seatsOpen = false; toast.ok(`${seatsExtra} seats added · charged ${prorated.toLocaleString('da-DK')} DKK`) }">
<UiButton variant="primary" @click="() => { seatsOpen = false; toast.ok(`${seatsExtra} seats added · charged ${moneyFmt.format(prorated)}`) }">
<template #leading><UiIcon name="plus" :size="13" /></template>
Add {{ seatsExtra }} seat{{ seatsExtra === 1 ? '' : 's' }}
</UiButton>
@@ -304,7 +312,6 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
.content { padding: 24px 40px 64px 40px; }
.row { display: grid; gap: 16px; margin-top: 16px; }
.two-col-14 { grid-template-columns: 1.4fr 1fr; }
.two-col-11 { grid-template-columns: 1fr 1fr; }
.strip { margin-bottom: 16px; }
.strip-grid { display: grid; grid-template-columns: repeat(4, 1fr); }
@@ -370,28 +377,12 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal / 3
.audit-content { flex: 1; min-width: 0; }
.audit-line { display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; }
.audit-actor { font-weight: 500; }
.audit-empty { padding: 24px 16px; text-align: center; }
/* Issues — strict bg with left tone border */
.issues { display: flex; flex-direction: column; gap: 10px; }
.issue {
padding: 14px;
background: var(--bg);
border-radius: 6px;
display: flex;
align-items: center;
gap: 12px;
border-left: 2px solid var(--border);
}
.issue[data-tone='ok'] { border-left-color: var(--ok); }
.issue[data-tone='warn'] { border-left-color: var(--warn); }
.issue[data-tone='bad'] { border-left-color: var(--bad); }
.issue[data-tone='info'] { border-left-color: var(--info); }
.issue-body { flex: 1; min-width: 0; }
.issue-title { font-size: 13px; font-weight: 500; }
.issue-sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
/* Quick actions — 2-col grid of "tiles" */
/* Quick actions — grid of "tiles" */
.qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
/* Full-width card → 3 columns so the six actions sit in two tidy rows. */
.qa-grid-wide { grid-template-columns: repeat(3, 1fr); }
.qa {
background: var(--surface);
border: 1px solid var(--border);