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
+14
View File
@@ -0,0 +1,14 @@
// Pick a legible foreground (#0A0A0A or #F4F3EE) for a given background colour
// using YIQ luminance. Used wherever we paint UI with a customer's brand
// colour (sidebar workspace mark, Branding live preview) so the text/marks
// stay readable for any accent — bright or dark. Auto-imported by Nuxt.
export function readableOn(hex: string): string {
let h = hex.replace('#', '').trim()
if (h.length === 3) h = h.split('').map((c) => c + c).join('')
if (h.length !== 6) return '#0A0A0A'
const r = parseInt(h.slice(0, 2), 16)
const g = parseInt(h.slice(2, 4), 16)
const b = parseInt(h.slice(4, 6), 16)
const yiq = (r * 299 + g * 587 + b * 114) / 1000
return yiq >= 140 ? '#0A0A0A' : '#F4F3EE'
}
+63
View File
@@ -0,0 +1,63 @@
// Wrapper around $fetch for authenticated WRITES from the client. The OIDC
// access token in the server-side session can lapse while a tab stays open;
// the next write then hits a proxy that finds no token and 401s (a page
// refresh "fixed" it only because navigation re-ran the session refresh).
//
// On a 401 we hit nuxt-oidc-auth's refresh endpoint DIRECTLY (POST
// /api/_auth/refresh) rather than useOidcAuth().refresh() — the composable
// falls back to a full login() *redirect* when the refresh token is missing or
// expired, which would navigate away mid-save and throw out the user's input.
// Here, a failed refresh just rejects: we surface a clear error and leave the
// user on the page with their form intact, so they can re-submit after signing
// in again. Sign-in stays an explicit, user-driven action.
//
// Call this in setup(); the returned `request` can be invoked later.
interface ApiOpts {
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
body?: unknown
query?: Record<string, unknown>
headers?: Record<string, string>
}
function isUnauthorized(err: unknown): boolean {
const e = err as { statusCode?: number; response?: { status?: number } }
return e?.statusCode === 401 || e?.response?.status === 401
}
export function useApiFetch() {
// Silent token refresh. Resolves true if the session now has a fresh access
// token, false if it couldn't be refreshed (no/expired refresh token).
async function refreshSession(): Promise<boolean> {
try {
await $fetch('/api/_auth/refresh', { method: 'POST', headers: { Accept: 'text/json' } })
return true
} catch {
return false
}
}
async function request<T>(url: string, opts: ApiOpts = {}): Promise<T> {
const fetchOpts = opts as Parameters<typeof $fetch>[1]
try {
return (await $fetch(url, fetchOpts)) as T
} catch (err) {
if (!isUnauthorized(err)) throw err
// Token lapsed mid-session — try a silent refresh, then retry once.
if (await refreshSession()) {
return (await $fetch(url, fetchOpts)) as T
}
// Refresh failed: the session is genuinely expired. Don't redirect (that
// would discard the user's input) — fail loudly so the caller keeps the
// form open and can show "sign in again to save".
throw createError({
statusCode: 401,
statusMessage: 'Session expired',
message: 'Your session expired. Please sign in again, then save your changes.',
data: { message: 'Your session expired. Please sign in again, then save your changes.' },
})
}
}
return { request }
}
+35 -11
View File
@@ -17,10 +17,12 @@ interface MeProfile {
lastLoginAt?: string
}
import type { TenantDoc, SubscriptionDoc } from '~/types/workspace'
interface MeResponse {
profile: MeProfile
tenants: unknown[]
subscriptions: unknown[]
tenants: TenantDoc[]
subscriptions: SubscriptionDoc[]
}
export function useMe() {
@@ -28,16 +30,33 @@ export function useMe() {
async function fetchMe(force = false): Promise<MeResponse | null> {
if (state.value && !force) return state.value
// useRequestFetch on SSR forwards the incoming request's headers
// (including the nuxt-oidc-auth session cookie) when calling the
// Nitro route. Bare $fetch on SSR has no cookie context, so /api/me
// would 401, the middleware would skip the redirect, and the end-user
// page would flash before client-side rehydration finally redirects.
const fetcher = useRequestFetch()
try {
// useRequestFetch on SSR forwards the incoming request's headers
// (including the nuxt-oidc-auth session cookie) when calling the
// Nitro route. Bare $fetch on SSR has no cookie context, so /api/me
// would 401, the middleware would skip the redirect, and the end-user
// page would flash before client-side rehydration finally redirects.
const fetcher = useRequestFetch()
state.value = await fetcher<MeResponse>('/api/me')
} catch {
state.value = null
} catch (err) {
// If the access token lapsed, the proxy forwards a stale token and
// platform-api 401s. On the client we can refresh the session silently
// and retry once (same self-heal as useApiFetch for writes). On the
// server we can't trigger the OIDC refresh, so fall through to null and
// let the auth middleware bounce to sign-in.
const status = (err as { statusCode?: number; response?: { status?: number } })
const is401 = status?.statusCode === 401 || status?.response?.status === 401
if (import.meta.client && is401) {
try {
await $fetch('/api/_auth/refresh', { method: 'POST', headers: { Accept: 'text/json' } })
state.value = await fetcher<MeResponse>('/api/me')
return state.value
} catch {
state.value = null
}
} else {
state.value = null
}
}
return state.value
}
@@ -46,6 +65,11 @@ export function useMe() {
const partner = computed(() => profile.value?.partner ?? null)
const isPartnerStaff = computed(() => !!profile.value?.partnerId)
const isPlatformAdmin = computed(() => !!profile.value?.platformAdmin)
// Customer admin of their own workspace — gates access to the /admin surface.
// `role` is 'owner' | 'admin' | 'member' from platform-api (User.role).
const isTenantAdmin = computed(
() => profile.value?.role === 'owner' || profile.value?.role === 'admin',
)
return { state, profile, partner, isPartnerStaff, isPlatformAdmin, fetchMe }
return { state, profile, partner, isPartnerStaff, isPlatformAdmin, isTenantAdmin, fetchMe }
}
+19 -2
View File
@@ -10,6 +10,13 @@
const activeCustomerId = ref<string | null>(null)
export const usePartnerMode = () => {
// Partner mode is only ever meaningful for partner staff. The active-customer
// id lives in sessionStorage, which is shared across whoever signs in on this
// device — so an admin or end-user could otherwise inherit a partner's leftover
// state and see partner-view chrome. We gate every read on isPartnerStaff so
// that can never happen, regardless of what's stored.
const { isPartnerStaff } = useMe()
function enter(customerId: string) {
activeCustomerId.value = customerId
if (import.meta.client) {
@@ -23,13 +30,23 @@ export const usePartnerMode = () => {
}
}
function hydrate() {
if (!import.meta.client || activeCustomerId.value) return
if (!import.meta.client) return
// Non-partner accounts must never be in partner mode. Purge any stale
// entry left by a previous partner session on this same device.
if (!isPartnerStaff.value) {
sessionStorage.removeItem('dezky-partner-active-customer')
activeCustomerId.value = null
return
}
if (activeCustomerId.value) return
const stored = sessionStorage.getItem('dezky-partner-active-customer')
if (stored) activeCustomerId.value = stored
}
return {
activeCustomerId,
isActive: computed(() => activeCustomerId.value !== null),
isActive: computed(
() => isPartnerStaff.value && activeCustomerId.value !== null,
),
enter,
exit,
hydrate,
+34
View File
@@ -0,0 +1,34 @@
// Loads Stripe.js from js.stripe.com on demand. Stripe requires the library be
// served from their CDN (not bundled) so card data never touches our origin —
// that's what keeps PCI scope minimal. We inject the <script> once and cache
// the promise; `window.Stripe` is the global constructor it exposes.
//
// Typed as `any`: we deliberately don't pull in @stripe/stripe-js just for its
// types. The surface we use (elements, confirmCardSetup) is small and stable.
let stripeJsPromise: Promise<unknown> | null = null
export function loadStripeJs(): Promise<unknown> {
if (!import.meta.client) return Promise.resolve(null)
const w = window as unknown as { Stripe?: unknown }
if (w.Stripe) return Promise.resolve(w.Stripe)
if (!stripeJsPromise) {
stripeJsPromise = new Promise((resolve, reject) => {
const src = 'https://js.stripe.com/v3/'
const existing = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`)
if (existing) {
existing.addEventListener('load', () => resolve(w.Stripe))
existing.addEventListener('error', () => reject(new Error('Failed to load Stripe.js')))
if (w.Stripe) resolve(w.Stripe)
return
}
const s = document.createElement('script')
s.src = src
s.async = true
s.onload = () => resolve(w.Stripe)
s.onerror = () => reject(new Error('Failed to load Stripe.js'))
document.head.appendChild(s)
})
}
return stripeJsPromise
}
+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,
}
}