Files
dezky/apps/portal/pages/admin/index.vue
T
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

459 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// 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 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 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') },
{ icon: 'card', label: 'Upgrade plan', onClick: () => router.push('/admin/billing') },
{ icon: 'shield', label: 'Enforce MFA', onClick: () => router.push('/admin/security') },
{ icon: 'brush', label: 'Edit branding', onClick: () => router.push('/admin/branding') },
{ icon: 'download', label: 'Export audit log', onClick: () => toast.ok('Audit log export queued · well email you when ready') },
]
function sendInvite() {
inviteOpen.value = false
inviteStep.value = 1
toast.ok('Invitation sent to magnus@dezky.com')
}
// 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="tenant ? `${tenant.name}${primaryDomain ? ` · ${primaryDomain}` : ''}` : 'Workspace'"
title="Dashboard"
subtitle="Health, activity, and quick actions across your workspace."
>
<template #actions>
<UiButton variant="secondary" @click="inviteOpen = true">
<template #leading><UiIcon name="users" :size="14" /></template>
Invite user
</UiButton>
<UiButton variant="primary" @click="router.push('/admin/domains')">
<template #leading><UiIcon name="plus" :size="14" /></template>
Add domain
</UiButton>
</template>
</PageHeader>
<div class="content">
<!-- Stat strip single Card pad=0 with 4-col grid + inner right borders -->
<Card :pad="0" class="strip">
<div class="strip-grid">
<div v-for="(s, i) in stats" :key="s.label" class="strip-cell" :class="{ noborder: i === stats.length - 1 }">
<Stat
:label="s.label"
:value="s.value"
:hint="s.hint"
/>
</div>
</div>
</Card>
<!-- License usage + Recent admin events -->
<div class="row two-col-14">
<Card>
<div class="card-head card-head-inline">
<div>
<Eyebrow>Plan</Eyebrow>
<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: `${seatPct}%` }" /></div>
<div class="progress-legend">
<span>{{ seatsUsed }} active</span>
<span>{{ seatsAvailable }} available</span>
</div>
</div>
<div class="seats-cta">
<div class="seats-cta-text">
Approaching limit? You can add seats in single increments billed prorated.
</div>
<UiButton size="sm" variant="dark" @click="seatsOpen = true">
<template #leading><UiIcon name="plus" :size="13" /></template>
Add seats
</UiButton>
</div>
</Card>
<Card :pad="0">
<div class="card-block-head">
<Eyebrow>Activity</Eyebrow>
<div class="card-title">Recent admin events</div>
</div>
<div class="audit-list">
<div v-for="a in recent" :key="a.id" class="audit-row">
<StatusDot :color="`var(--${a.tone})`" :size="7" :glow="false" />
<div class="audit-content">
<div class="audit-line">
<span class="audit-actor">{{ a.actor }}</span>
<Mono dim>{{ a.action }}</Mono>
</div>
<Mono dim>{{ a.target }}</Mono>
</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>
<!-- Quick actions -->
<div class="row">
<Card>
<div class="card-head card-head-inline">
<div>
<Eyebrow>Quick actions</Eyebrow>
<div class="card-title">Common tasks</div>
</div>
</div>
<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 }}
</button>
</div>
</Card>
</div>
</div>
<!-- Invite user · 3-step modal (stubbed: step 1 fields only, but with stepper text) -->
<Modal :open="inviteOpen" :title="'Invite user'" :eyebrow="`Step ${inviteStep} of 3`" size="md" @close="inviteOpen = false; inviteStep = 1">
<div v-if="inviteStep === 1" class="form-stack">
<label class="field"><Eyebrow>Full name</Eyebrow><input class="input" value="Magnus Eriksen" /></label>
<label class="field"><Eyebrow>Email</Eyebrow><input class="input" value="magnus@dezky.com" /></label>
<label class="field"><Eyebrow>Role</Eyebrow>
<div class="radio-row">
<button class="active">Member</button><button>Admin</button>
</div>
</label>
<label class="field"><Eyebrow>License tier</Eyebrow>
<div class="radio-row">
<button>Basic</button><button class="active">Business</button>
</div>
</label>
</div>
<div v-else-if="inviteStep === 2" class="form-stack">
<div>
<Eyebrow>Group memberships</Eyebrow>
<div class="check-stack">
<label v-for="(g, i) in ['Engineering', 'Design', 'Operations', 'Finance', 'Sales']" :key="g">
<input type="checkbox" :checked="i === 0" /> {{ g }}
</label>
</div>
</div>
<div>
<Eyebrow>Apps</Eyebrow>
<div class="check-stack">
<label v-for="a in ['Mail', 'Drev', 'Møder', 'Chat']" :key="a">
<input type="checkbox" checked /> {{ a }}
</label>
</div>
</div>
</div>
<div v-else>
<div class="review-box">
<dl class="def">
<div><dt>Name</dt><dd>Magnus Eriksen</dd></div>
<div><dt>Email</dt><dd>magnus@dezky.com</dd></div>
<div><dt>Role</dt><dd>Member · Business</dd></div>
<div><dt>Groups</dt><dd>Engineering</dd></div>
<div><dt>Apps</dt><dd>Mail · Drev · Møder · Chat</dd></div>
</dl>
</div>
<div class="muted">
We'll provision the account across Authentik, Stalwart, OCIS, Jitsi and Zulip, then email Magnus an activation link valid for 7 days.
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="inviteOpen = false; inviteStep = 1">Cancel</UiButton>
<UiButton v-if="inviteStep > 1" variant="secondary" @click="inviteStep--">Back</UiButton>
<UiButton v-if="inviteStep < 3" variant="primary" @click="inviteStep++">Continue</UiButton>
<UiButton v-else variant="primary" @click="sendInvite">Send invitation</UiButton>
</template>
</Modal>
<!-- Add seats — strict port of AddSeatsModal -->
<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">{{ 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>
<div class="stepper-row">
<button class="step-btn" @click="seatsExtra = Math.max(1, seatsExtra - 1)"></button>
<input type="number" :value="seatsExtra" @input="(e) => (seatsExtra = Math.max(1, Math.min(500, parseInt((e.target as HTMLInputElement).value || '0') || 1)))" class="step-num" />
<button class="step-btn" @click="seatsExtra = Math.min(500, seatsExtra + 1)">+</button>
</div>
<div class="quick-amounts">
<button v-for="n in [5, 10, 25, 50]" :key="n" :class="{ active: seatsExtra === n }" @click="seatsExtra = n">+{{ n }}</button>
</div>
</div>
<div class="charge-summary">
<Eyebrow>What you'll pay</Eyebrow>
<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>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 ${moneyFmt.format(prorated)}`) }">
<template #leading><UiIcon name="plus" :size="13" /></template>
Add {{ seatsExtra }} seat{{ seatsExtra === 1 ? '' : 's' }}
</UiButton>
</template>
</Modal>
</div>
</template>
<style scoped>
.content { padding: 24px 40px 64px 40px; }
.row { display: grid; gap: 16px; margin-top: 16px; }
.two-col-14 { grid-template-columns: 1.4fr 1fr; }
.strip { margin-bottom: 16px; }
.strip-grid { display: grid; grid-template-columns: repeat(4, 1fr); }
.strip-cell { padding: 24px; border-right: 1px solid var(--border); }
.strip-cell.noborder { border-right: none; }
.card-head {
padding: 20px 24px 16px 24px;
border-bottom: 1px solid var(--border);
}
.card-head-inline { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.card-block-head { padding: 20px 24px 12px 24px; }
.card-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 18px;
letter-spacing: -0.01em;
margin-top: 4px;
}
.card-sub { font-size: 13px; color: var(--text-mute); margin-top: 4px; }
/* License progress */
.progress-block { margin-bottom: 16px; }
.progress-bar {
height: 8px;
background: var(--bg);
border-radius: 999px;
overflow: hidden;
}
.progress-bar span { display: block; height: 100%; background: var(--text); }
.progress-legend {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-mute);
}
/* Add-seats CTA box (dashed) */
.seats-cta {
padding: 16px;
background: var(--bg);
border-radius: 6px;
border: 1px dashed var(--border-hi, var(--border));
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.seats-cta-text { font-size: 13px; color: var(--text-dim); }
/* Audit list */
.audit-list { padding: 0 8px 8px 8px; }
.audit-row {
padding: 10px 16px;
display: flex;
align-items: center;
gap: 12px;
border-radius: 6px;
font-size: 13px;
}
.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; }
/* 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);
padding: 14px 16px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 500;
color: var(--text);
font-family: inherit;
text-align: left;
}
.qa:hover { background: var(--elevated, var(--row-hover, var(--surface))); }
/* Invite modal helpers */
.form-stack { display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.input {
height: 36px;
padding: 0 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
color: var(--text);
outline: none;
}
.input:focus { border-color: var(--text); }
.radio-row { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; padding: 2px; width: fit-content; }
.radio-row button { padding: 6px 14px; border: none; border-radius: 4px; background: transparent; color: var(--text); font-size: 12px; font-weight: 500; font-family: inherit; cursor: pointer; }
.radio-row button.active { background: var(--text); color: var(--bg); }
.check-stack { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; font-size: 13px; }
.check-stack label { display: flex; align-items: center; gap: 8px; }
.review-box { padding: 16px; background: var(--bg); border-radius: 6px; margin-bottom: 16px; }
.def { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 12px; column-gap: 16px; }
.def > div { display: contents; }
.def dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); }
.def dd { margin: 0; font-size: 13px; color: var(--text); }
.muted { font-size: 12px; color: var(--text-mute); line-height: 1.55; }
/* Add seats modal */
.seats { display: flex; flex-direction: column; gap: 18px; }
.seats-grid {
padding: 16px;
background: var(--bg);
border-radius: 8px;
border: 1px solid var(--border);
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.seats-cell { padding: 0 12px; border-right: 1px solid var(--border); }
.seats-cell:first-child { padding-left: 0; }
.seats-cell:last-child { border-right: none; padding-right: 0; }
.seats-big { font-family: var(--font-display); font-weight: 600; font-size: 24px; margin-top: 4px; }
.seats-big.ok { color: var(--ok); }
.stepper-row { display: flex; align-items: center; gap: 12px; margin: 12px 0 10px; }
.step-btn { width: 36px; height: 36px; border-radius: 6px; background: var(--surface); border: 1px solid var(--border); cursor: pointer; font-family: inherit; font-size: 16px; color: var(--text); }
.step-num { flex: 1; height: 56px; padding: 0 16px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; font-family: var(--font-display); font-size: 32px; font-weight: 600; color: var(--text); text-align: center; outline: none; }
.quick-amounts { display: flex; gap: 6px; flex-wrap: wrap; }
.quick-amounts button { padding: 4px 10px; border-radius: 4px; cursor: pointer; background: var(--surface); color: var(--text); border: 1px solid var(--border); font-family: var(--font-mono); font-size: 11px; }
.quick-amounts button.active { background: var(--text); color: var(--bg); border-color: var(--text); }
.charge-summary { padding: 16px; background: var(--surface); border-radius: 8px; border: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px; }
.charge-row { display: flex; justify-content: space-between; font-size: 13px; align-items: baseline; }
.charge-row.sep { padding-bottom: 8px; border-bottom: 1px solid var(--border); }
.charge-row.total { font-weight: 600; }
.charge-row .big { font-family: var(--font-display); font-size: 18px; letter-spacing: -0.01em; }
.charge-row .muted { color: var(--text-mute); font-weight: 400; }
.info-strip { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); display: flex; gap: 10px; align-items: flex-start; font-size: 12px; color: var(--text-dim); line-height: 1.55; }
</style>