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