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:
Ronni Baslund
2026-05-31 00:19:34 +02:00
parent db26dafc64
commit 3288fde693
44 changed files with 1874 additions and 1237 deletions
+2 -1
View File
@@ -11,6 +11,7 @@ import type { EmailTemplate } from '~/components/partner/EmailTemplateEditor.vue
import type { BrandIdentity } from '~/components/partner/EditIdentityModal.vue'
const toast = useToast()
const { request } = useApiFetch()
const identityOpen = ref(false)
const editing = ref<EmailTemplate | null>(null)
@@ -74,7 +75,7 @@ watch(branding, syncBranding)
async function putBranding(): Promise<boolean> {
try {
await $fetch('/api/partner/branding', {
await request('/api/partner/branding', {
method: 'PUT',
body: {
identity: identity.value,
+3 -2
View File
@@ -9,6 +9,7 @@ import type { CustomerOrg, CustomerStatus, PartnerTenantDoc } from '~/types/part
const toast = useToast()
const router = useRouter()
const { request } = useApiFetch()
const partnerMode = usePartnerMode()
const view = ref<'table' | 'cards'>('table')
@@ -181,7 +182,7 @@ async function saveEdit() {
}
savingEdit.value = true
try {
await $fetch(`/api/partner/tenants/${editCustomer.value.slug}`, {
await request(`/api/partner/tenants/${editCustomer.value.slug}`, {
method: 'PATCH',
body: {
name: editForm.name,
@@ -204,7 +205,7 @@ async function saveEdit() {
async function toggleSuspend(c: CustomerRow) {
const action = c.status === 'suspended' ? 'resume' : 'suspend'
try {
await $fetch(`/api/partner/tenants/${c.slug}/${action}`, { method: 'POST' })
await request(`/api/partner/tenants/${c.slug}/${action}`, { method: 'POST' })
toast.ok(action === 'suspend' ? 'Suspended' : 'Resumed', c.name)
editCustomer.value = null
await refreshAll()
+3 -2
View File
@@ -12,6 +12,7 @@ import type { CustomerOrg, CustomerStatus } from '~/types/partner'
import type { TaskContext } from '~/components/partner/CustomerTaskPanel.vue'
const toast = useToast()
const { request } = useApiFetch()
// Decorative MRR sparkline shape only — historical MRR isn't stored yet (a
// daily-snapshot job lands later; see useMrrTrendline). The live numbers
@@ -262,7 +263,7 @@ async function deleteReport() {
return
}
try {
await $fetch(`/api/partner/reports/saved/${r.id}`, { method: 'DELETE' })
await request(`/api/partner/reports/saved/${r.id}`, { method: 'DELETE' })
toast.bad('Report deleted', r.name)
confirmDeleteId.value = null
await Promise.all([refreshSaved(), refreshNuxtData('partner-reports-saved')])
@@ -284,7 +285,7 @@ async function onCreated(payload: {
format: string
}) {
try {
await $fetch('/api/partner/reports/saved', {
await request('/api/partner/reports/saved', {
method: 'POST',
body: {
name: payload.name,
+2 -1
View File
@@ -9,6 +9,7 @@
const toast = useToast()
const { request } = useApiFetch()
const tab = ref<'agreement' | 'contact' | 'tax' | 'notifications'>('agreement')
const tabs = [
@@ -93,7 +94,7 @@ watch(settings, syncContact)
async function saveContact() {
savingContact.value = true
try {
await $fetch('/api/partner/settings', { method: 'PATCH', body: { profile: { ...contact } } })
await request('/api/partner/settings', { method: 'PATCH', body: { profile: { ...contact } } })
toast.ok('Saved', 'Contact info updated')
await Promise.all([refresh(), refreshNuxtData('partner-settings')])
} catch (e: unknown) {
+3 -2
View File
@@ -8,6 +8,7 @@
import type { TeamMember } from '~/components/partner/TeammatePanel.vue'
const toast = useToast()
const { request } = useApiFetch()
const inviteOpen = ref(false)
const openMember = ref<TeamMember | null>(null)
@@ -72,7 +73,7 @@ function accessLabel(m: TeamMember) {
async function onSent(payload: { name: string; email: string; role: string }) {
try {
await $fetch('/api/partner/users', {
await request('/api/partner/users', {
method: 'POST',
body: { name: payload.name, email: payload.email },
})
@@ -86,7 +87,7 @@ async function onSent(payload: { name: string; email: string; role: string }) {
async function removeMember(m: TeamMember) {
try {
await $fetch(`/api/partner/users/${m.id}`, { method: 'DELETE' })
await request(`/api/partner/users/${m.id}`, { method: 'DELETE' })
toast.ok('Removed', `${m.name} removed from the team`)
openMember.value = null
await Promise.all([refresh(), refreshNuxtData('partner-users')])