3288fde693
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).
76 lines
2.9 KiB
TypeScript
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 }
|
|
}
|