From 3288fde6935b07c11e5b33c6595eeb48932e71a9 Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Sun, 31 May 2026 00:19:34 +0200 Subject: [PATCH] feat(portal): customer-admin surface on real data + Stripe billing + session resilience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- apps/portal/components/AppLauncher.vue | 15 +- apps/portal/components/Modal.vue | 16 +- apps/portal/components/PaymentMethodModal.vue | 149 +++++ apps/portal/components/PortalSidebar.vue | 83 +-- .../partner/CustomerCreateWizard.vue | 4 +- apps/portal/composables/colorContrast.ts | 14 + apps/portal/composables/useApiFetch.ts | 63 ++ apps/portal/composables/useMe.ts | 46 +- apps/portal/composables/usePartnerMode.ts | 21 +- apps/portal/composables/useStripeJs.ts | 34 ++ apps/portal/composables/useTenant.ts | 84 +++ .../middleware/partner-routing.global.ts | 32 +- apps/portal/nuxt.config.ts | 7 +- apps/portal/pages/admin/billing.vue | 450 +++++++------- apps/portal/pages/admin/branding.vue | 554 +++++------------- apps/portal/pages/admin/index.vue | 211 ++++--- apps/portal/pages/admin/users.vue | 492 +++------------- apps/portal/pages/partner/branding.vue | 3 +- apps/portal/pages/partner/customers.vue | 5 +- apps/portal/pages/partner/reports.vue | 5 +- apps/portal/pages/partner/settings.vue | 3 +- apps/portal/pages/partner/team.vue | 5 +- .../server/api/tenants/[slug]/audit.get.ts | 20 + .../api/tenants/[slug]/billing-info.patch.ts | 21 + .../server/api/tenants/[slug]/branding.get.ts | 18 + .../server/api/tenants/[slug]/branding.put.ts | 20 + .../server/api/tenants/[slug]/invoices.get.ts | 18 + .../[slug]/payment-method/default.post.ts | 20 + .../[slug]/payment-method/index.get.ts | 17 + .../payment-method/setup-intent.post.ts | 18 + .../server/api/tenants/[slug]/users.get.ts | 18 + apps/portal/types/workspace.ts | 123 ++++ .../docker-compose/docker-compose.yml | 3 + .../src/billing/billing.module.ts | 8 +- .../src/billing/billing.service.ts | 104 +++- .../billing/customer-billing.controller.ts | 59 ++ .../src/integrations/stripe.client.ts | 57 ++ .../src/schemas/tenant-branding.schema.ts | 36 ++ .../tenants/dto/update-billing-info.dto.ts | 19 + .../tenants/dto/update-tenant-branding.dto.ts | 36 ++ .../src/tenants/tenant-branding.service.ts | 84 +++ .../src/tenants/tenants.controller.ts | 77 ++- .../src/tenants/tenants.module.ts | 5 +- .../src/tenants/tenants.service.ts | 34 ++ 44 files changed, 1874 insertions(+), 1237 deletions(-) create mode 100644 apps/portal/components/PaymentMethodModal.vue create mode 100644 apps/portal/composables/colorContrast.ts create mode 100644 apps/portal/composables/useApiFetch.ts create mode 100644 apps/portal/composables/useStripeJs.ts create mode 100644 apps/portal/composables/useTenant.ts create mode 100644 apps/portal/server/api/tenants/[slug]/audit.get.ts create mode 100644 apps/portal/server/api/tenants/[slug]/billing-info.patch.ts create mode 100644 apps/portal/server/api/tenants/[slug]/branding.get.ts create mode 100644 apps/portal/server/api/tenants/[slug]/branding.put.ts create mode 100644 apps/portal/server/api/tenants/[slug]/invoices.get.ts create mode 100644 apps/portal/server/api/tenants/[slug]/payment-method/default.post.ts create mode 100644 apps/portal/server/api/tenants/[slug]/payment-method/index.get.ts create mode 100644 apps/portal/server/api/tenants/[slug]/payment-method/setup-intent.post.ts create mode 100644 apps/portal/server/api/tenants/[slug]/users.get.ts create mode 100644 apps/portal/types/workspace.ts create mode 100644 services/platform-api/src/billing/customer-billing.controller.ts create mode 100644 services/platform-api/src/schemas/tenant-branding.schema.ts create mode 100644 services/platform-api/src/tenants/dto/update-billing-info.dto.ts create mode 100644 services/platform-api/src/tenants/dto/update-tenant-branding.dto.ts create mode 100644 services/platform-api/src/tenants/tenant-branding.service.ts diff --git a/apps/portal/components/AppLauncher.vue b/apps/portal/components/AppLauncher.vue index b7c0745..b322b1f 100644 --- a/apps/portal/components/AppLauncher.vue +++ b/apps/portal/components/AppLauncher.vue @@ -9,6 +9,7 @@ import type { IconName } from './UiIcon.vue' const launcher = useAppLauncher() const route = useRoute() const partnerMode = usePartnerMode() +const { isTenantAdmin } = useMe() interface Tile { key: string @@ -38,8 +39,15 @@ const tiles = computed(() => { { key: 'cal', name: 'Kalender', icon: 'calendar', ext: 'cal.dezky.com' }, { key: 'contacts', name: 'Kontakter', icon: 'users', ext: 'contacts.dezky.com' }, ] - if (isAdmin) { - base.push({ key: 'admin', name: 'Admin', icon: 'shield', ext: 'admin.dezky.com', current: !isPartner }) + // Admin tile is the entry point to the workspace-admin surface. Show it to any + // tenant admin/owner (so they can get TO /admin from the personal shell), not + // only when already on the admin section. Marked "HERE" when on /admin. Pair it + // with a Personal tile so the launcher is a clean two-way toggle between the + // admin and personal surfaces — clicking either crosses over, "HERE" shows + // which side you're on. + if (isAdmin || isTenantAdmin.value) { + base.push({ key: 'home', name: 'Personal', icon: 'home', ext: 'app.dezky.com', current: section.value === 'user' }) + base.push({ key: 'admin', name: 'Admin', icon: 'shield', ext: 'admin.dezky.com', current: isAdmin && !isPartner }) } if (isPartner) { base.push({ key: 'partner', name: 'Partner', icon: 'briefcase', ext: 'partner.nordicmsp.dk', current: true }) @@ -51,6 +59,7 @@ const tiles = computed(() => { const toast = useToast() function open(t: Tile) { launcher.hide() + if (t.key === 'home') return navigateTo('/') if (t.key === 'admin') return navigateTo('/admin') if (t.key === 'partner') return navigateTo('/partner') toast.info(`Opening ${t.name}…`, t.ext) @@ -73,7 +82,7 @@ onMounted(() => {
Apps -
Open in new tab
+
Jump to