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).
53 lines
2.3 KiB
TypeScript
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('/')
|
|
}
|
|
})
|