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