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

76 lines
2.9 KiB
TypeScript

// Cached fetch of the signed-in user's profile from platform-api (proxied
// via /api/me on the portal server). One state slot per render so all
// callers share the same payload — middleware fetches once, pages read
// from cache, no per-component re-fetch.
interface MeProfile {
_id: string
authentikSubjectId: string
email: string
name: string
role: string
active: boolean
platformAdmin: boolean
tenantIds: string[]
partnerId?: string
partner?: { _id: string; slug: string; name: string; status: string }
lastLoginAt?: string
}
import type { TenantDoc, SubscriptionDoc } from '~/types/workspace'
interface MeResponse {
profile: MeProfile
tenants: TenantDoc[]
subscriptions: SubscriptionDoc[]
}
export function useMe() {
const state = useState<MeResponse | null>('portal-me', () => null)
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 {
state.value = await fetcher<MeResponse>('/api/me')
} 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
}
const profile = computed<MeProfile | null>(() => state.value?.profile ?? null)
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, isTenantAdmin, fetchMe }
}