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:
@@ -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'
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user