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
+84
View File
@@ -0,0 +1,84 @@
// Resolves the customer admin's active workspace (tenant) + its subscription
// from the cached /api/me payload, and derives the license/billing figures the
// /admin surface needs. One round-trip (shared with useMe's cache) backs the
// whole admin shell.
//
// Scope note: this resolves the signed-in user's OWN tenant (tenants[0]). The
// partner "acting-as a customer" path uses the partner-scoped endpoints +
// usePartnerMode().activeCustomer instead, so it isn't handled here.
import type { SubscriptionDoc, TenantDoc } from '~/types/workspace'
const PLAN_LABEL: Record<string, string> = {
mvp: 'Starter',
pro: 'Business',
enterprise: 'Enterprise',
}
// Subscription amounts are stored in MINOR units (øre/cents) and per BILLING
// CYCLE, not per month. This mirrors platform-api's normalizeToMonthly (used by
// the MRR/partner-billing aggregations) so the portal shows the same figures.
function cycleToMonthlyMinor(perCycleMinor: number, cycle: string): number {
if (cycle === 'quarterly') return Math.round(perCycleMinor / 3)
if (cycle === 'yearly') return Math.round(perCycleMinor / 12)
return perCycleMinor
}
export function useTenant() {
const { state, fetchMe } = useMe()
// A user can technically belong to several tenants; the admin surface acts on
// the first. Refine to an explicit picker if multi-tenant admins land later.
const tenant = computed<TenantDoc | null>(() => state.value?.tenants?.[0] ?? null)
const subscription = computed<SubscriptionDoc | null>(() => {
const t = tenant.value
const subs = state.value?.subscriptions ?? []
if (!t) return subs[0] ?? null
return subs.find((s) => s.tenantId === t._id) ?? subs[0] ?? null
})
const planKey = computed(() => subscription.value?.plan ?? tenant.value?.plan ?? 'mvp')
const planLabel = computed(() => PLAN_LABEL[planKey.value] ?? planKey.value)
const currency = computed(() => subscription.value?.currency ?? 'DKK')
// Billed seat limit (license cap). Falls back to the tenant's seat count.
const seatLimit = computed(() => subscription.value?.seats ?? tenant.value?.seats ?? 0)
// Per-seat cost normalized to monthly, in MAJOR units (e.g. DKK). Drives the
// add-seats modal math.
const perSeatMonthly = computed(() => {
const sub = subscription.value
if (!sub?.perSeatAmount) return 0
return cycleToMonthlyMinor(sub.perSeatAmount, sub.cycle) / 100
})
// Monthly recurring spend = per-seat × billed seats, cycle-normalized to
// monthly, converted minor → major. In `currency`.
const monthlySpend = computed(() => {
const sub = subscription.value
if (!sub?.perSeatAmount || !sub.seats) return 0
return cycleToMonthlyMinor(sub.perSeatAmount * sub.seats, sub.cycle) / 100
})
const primaryDomain = computed(() => tenant.value?.domains?.[0] ?? null)
const renewsAt = computed(() =>
subscription.value?.currentPeriodEnd
? new Date(subscription.value.currentPeriodEnd)
: null,
)
return {
tenant,
subscription,
fetchMe,
planKey,
planLabel,
currency,
seatLimit,
perSeatMonthly,
monthlySpend,
primaryDomain,
renewsAt,
}
}