// 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 // Per-tenant role overrides keyed by tenantId; absent keys fall back to // `role`. Serialized from platform-api's User.tenantRoles Map. tenantRoles?: Record 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('portal-me', () => null) async function fetchMe(force = false): Promise { 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('/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('/api/me') return state.value } catch { state.value = null } } else { state.value = null } } return state.value } const profile = computed(() => state.value?.profile ?? null) const partner = computed(() => profile.value?.partner ?? null) const isPartnerStaff = computed(() => !!profile.value?.partnerId) const isPlatformAdmin = computed(() => !!profile.value?.platformAdmin) const isAdminRole = (r: string | undefined) => r === 'owner' || r === 'admin' // Effective role for a specific tenant — mirrors platform-api roleForTenant(): // a per-tenant entry wins, else the legacy global `role`, else 'member'. function roleForTenant(tenantId: string): 'owner' | 'admin' | 'member' { const p = profile.value return p?.tenantRoles?.[tenantId] ?? (p?.role as 'owner' | 'admin' | 'member') ?? 'member' } function isTenantAdminOf(tenantId: string): boolean { return isAdminRole(roleForTenant(tenantId)) } // Gates the /admin surface: true if the user administers AT LEAST ONE of // their tenants. Per-tenant enforcement of *which* workspace they may admin // happens once a tenant is in context (backend membership + roleForTenant). // For existing single-role data this is identical to the old global check. const isTenantAdmin = computed(() => { const p = profile.value if (!p) return false if (p.tenantIds.length) return p.tenantIds.some((t) => isTenantAdminOf(t)) return isAdminRole(p.role) }) return { state, profile, partner, isPartnerStaff, isPlatformAdmin, isTenantAdmin, roleForTenant, isTenantAdminOf, fetchMe, } }