Files
dezky/apps/portal/middleware/partner-routing.global.ts
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

53 lines
2.3 KiB
TypeScript

// Routes signed-in users to the surface that matches their role:
// - partner staff (User.partnerId set) on '/' → /partner
// - non-partner-staff hitting /partner/* → /
// - non-admins hitting /admin/* → /
//
// Runs after the OIDC global middleware (00.auth.global from nuxt-oidc-auth)
// so we know the user is authenticated by the time we get here. /me is
// fetched lazily via useMe() and cached in useState — first nav after sign-in
// pays one round-trip, subsequent navs read from cache.
//
// The partner + admin surfaces share the dezky-portal OAuth client with ordinary
// tenant users (a tenant admin authenticates here legitimately), so there is
// no IdP-level gate the way the operator app has — these redirects plus the
// platform-api's per-endpoint role/partnerId checks are the whole defense. Because
// of that, /partner/* and /admin/* must fail CLOSED: if we can't positively
// confirm the caller's role (e.g. /api/me errored transiently, so `me` is null),
// we keep them out rather than letting the page shell render. Data is always
// backend-guarded, but the shell shouldn't show to the wrong role.
//
// /admin is reachable by tenant admins/owners AND by partner staff (who act as
// a customer admin via partner-in-customer mode). We gate on the profile (role /
// partnerId), not partner-mode session state, since that isn't resolvable on a
// fresh SSR load.
//
// Auth pages (/auth/*, /signed-out) are skipped because they're public.
export default defineNuxtRouteMiddleware(async (to) => {
if (to.path.startsWith('/auth/') || to.path === '/signed-out') return
const onPartnerSurface = to.path.startsWith('/partner')
const onAdminSurface = to.path.startsWith('/admin')
const { fetchMe, isPartnerStaff, isTenantAdmin } = useMe()
const me = await fetchMe()
// Couldn't resolve identity. For non-gated routes, defer to the OIDC
// middleware's bounce. For partner/admin routes, fail closed — unconfirmed
// is not-authorized.
if (!me) {
return onPartnerSurface || onAdminSurface ? navigateTo('/') : undefined
}
if (to.path === '/' && isPartnerStaff.value) {
return navigateTo('/partner')
}
if (onPartnerSurface && !isPartnerStaff.value) {
return navigateTo('/')
}
if (onAdminSurface && !isTenantAdmin.value && !isPartnerStaff.value) {
return navigateTo('/')
}
})