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:
+101
-110
@@ -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 haven’t 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);
|
||||
|
||||
Reference in New Issue
Block a user