Files
dezky/apps/portal/composables/useTenant.ts
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

85 lines
3.1 KiB
TypeScript
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.
// 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,
}
}