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).
This commit is contained in:
@@ -1,20 +1,26 @@
|
||||
// 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 surface shares the dezky-portal OAuth client with ordinary
|
||||
// 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 — this redirect plus the
|
||||
// platform-api's per-endpoint partnerId checks are the whole defense. Because
|
||||
// of that, /partner/* must fail CLOSED: if we can't positively confirm the
|
||||
// caller is partner staff (e.g. /api/me errored transiently, so `me` is null),
|
||||
// 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 a non-partner.
|
||||
// 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.
|
||||
|
||||
@@ -22,15 +28,16 @@ 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 } = useMe()
|
||||
const { fetchMe, isPartnerStaff, isTenantAdmin } = useMe()
|
||||
const me = await fetchMe()
|
||||
|
||||
// Couldn't resolve identity. For non-partner routes, defer to the OIDC
|
||||
// middleware's bounce. For partner routes, fail closed — unconfirmed is
|
||||
// not-partner.
|
||||
// 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 ? navigateTo('/') : undefined
|
||||
return onPartnerSurface || onAdminSurface ? navigateTo('/') : undefined
|
||||
}
|
||||
|
||||
if (to.path === '/' && isPartnerStaff.value) {
|
||||
@@ -39,4 +46,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
if (onPartnerSurface && !isPartnerStaff.value) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
if (onAdminSurface && !isTenantAdmin.value && !isPartnerStaff.value) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user