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:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')])
|
||||
|
||||
Reference in New Issue
Block a user