Files
Ronni Baslund 3288fde693 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).
2026-05-31 00:19:34 +02:00

413 lines
14 KiB
Vue

<script setup lang="ts">
// Partner settings. Strict port of PartnerSettingsScreen
// (platform-partner-depth.jsx lines 858-1037). Four tabs:
// • Agreement — active reseller agreement + DefLists + documents
// • Contact info — NordicMSP company info form
// • Tax — tax/invoicing DefList + payout method card
// • Notifications — partner-level event rows with cadence + channels
const toast = useToast()
const { request } = useApiFetch()
const tab = ref<'agreement' | 'contact' | 'tax' | 'notifications'>('agreement')
const tabs = [
{ value: 'agreement', label: 'Agreement' },
{ value: 'contact', label: 'Contact info' },
{ value: 'tax', label: 'Tax' },
{ value: 'notifications', label: 'Notifications' },
]
interface NotificationPref {
event: string
cadence: string
channels: string[]
}
interface PartnerSettings {
name?: string
domain?: string
marginPct?: number
contactInfo?: { primaryName?: string; primaryEmail?: string; billingEmail?: string }
billingInfo?: { companyName?: string; vatId?: string; country?: string; contactEmail?: string }
profile?: {
legalName?: string
tradingName?: string
address?: string
country?: string
primaryEmail?: string
primaryPhone?: string
supportHotline?: string
website?: string
}
notificationPrefs?: NotificationPref[]
agreement?: {
tier?: string
payoutCadence?: string
effectiveAt?: string
termMonths?: number
noticePeriodDays?: number
liabilityCap?: string
governingLaw?: string
signedBy?: string
}
documents?: Array<{ name: string; url?: string; kind?: string; size?: string; uploadedAt?: string }>
}
const { data: settings, refresh } = await useFetch<PartnerSettings>('/api/partner/settings', {
key: 'partner-settings',
default: () => ({}),
})
// Editable copy of the contact profile, seeded from the fetched settings
// (falling back to the partner's name/domain/contactInfo where the dedicated
// profile fields aren't set yet).
const contact = reactive({
legalName: '',
tradingName: '',
address: '',
country: 'DK',
primaryEmail: '',
primaryPhone: '',
supportHotline: '',
website: '',
})
const savingContact = ref(false)
function syncContact() {
// Don't clobber the form mid-save — the post-save refetch would otherwise
// reset fields the user may have kept editing.
if (savingContact.value) return
const s = settings.value ?? {}
const p = s.profile ?? {}
contact.legalName = p.legalName ?? s.billingInfo?.companyName ?? s.name ?? ''
contact.tradingName = p.tradingName ?? s.name ?? ''
contact.address = p.address ?? ''
contact.country = p.country ?? s.billingInfo?.country ?? 'DK'
contact.primaryEmail = p.primaryEmail ?? s.contactInfo?.primaryEmail ?? ''
contact.primaryPhone = p.primaryPhone ?? ''
contact.supportHotline = p.supportHotline ?? ''
contact.website = p.website ?? s.domain ?? ''
}
syncContact()
watch(settings, syncContact)
async function saveContact() {
savingContact.value = true
try {
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) {
const err = e as { data?: { message?: string }; statusMessage?: string }
toast.bad('Save failed', err.data?.message || err.statusMessage || 'Could not save contact info')
} finally {
savingContact.value = false
}
}
const marginPct = computed(() => settings.value?.marginPct ?? 0)
// Documents — operator-managed; empty until uploaded.
const docs = computed(() =>
(settings.value?.documents ?? []).map((d) => ({
n: d.name,
size: d.size ?? '',
date: d.uploadedAt ? new Date(d.uploadedAt).toLocaleDateString('da-DK') : '',
})),
)
// Notifications — real prefs, falling back to the standard event set when the
// partner hasn't customised them yet.
const DEFAULT_NOTIFICATIONS: NotificationPref[] = [
{ event: 'New customer signed up', cadence: 'immediate', channels: ['email', 'chat'] },
{ event: 'Customer past-due invoice', cadence: 'immediate', channels: ['email', 'in-app'] },
{ event: 'Customer approaching limit', cadence: 'daily', channels: ['email'] },
{ event: 'Customer downgrade or churn', cadence: 'immediate', channels: ['email', 'chat', 'in-app'] },
{ event: 'Payout processed', cadence: 'immediate', channels: ['email'] },
{ event: 'New ticket from a customer', cadence: 'immediate', channels: ['chat'] },
{ event: 'Dezky agreement change', cadence: 'immediate', channels: ['email'] },
]
const events = computed(() =>
(settings.value?.notificationPrefs?.length
? settings.value.notificationPrefs
: DEFAULT_NOTIFICATIONS
).map((n) => ({ event: n.event, when: n.cadence, channels: n.channels.join(' · ') })),
)
</script>
<template>
<div>
<PageHeader
eyebrow="Partner"
title="Partner settings"
subtitle="Agreement terms, business details, tax setup, and partner-level notifications."
/>
<div class="tabs-wrap">
<Tabs v-model="tab" :items="tabs" />
</div>
<div class="content">
<!-- AGREEMENT -->
<template v-if="tab === 'agreement'">
<Card>
<div class="card-head">
<div>
<Eyebrow>Reseller agreement</Eyebrow>
<div class="card-title">Active · v2025.11</div>
<p class="sub">Effective since 14 Jan 2024 · auto-renews 14 Jan 2027</p>
</div>
<div class="head-actions">
<UiButton size="sm" variant="secondary" @click="toast.ok('Downloading', 'Reseller agreement v2025.11')">
<template #leading><UiIcon name="download" :size="13" /></template>
Download PDF
</UiButton>
<UiButton size="sm" variant="ghost" @click="toast.info('Version history', 'Showing all 3 versions')">View history</UiButton>
</div>
</div>
<div class="agree-grid">
<dl class="def">
<div><dt>Tier</dt><dd>Tier 2 · Established</dd></div>
<div><dt>Default margin</dt><dd>{{ marginPct }}% on all plans</dd></div>
<div><dt>Enterprise margin</dt><dd>Negotiated · 15% baseline</dd></div>
<div><dt>Volume rebate</dt><dd>+2% over 200 active seats · qualifies</dd></div>
<div><dt>Payout cadence</dt><dd>Monthly · 3rd business day</dd></div>
<div><dt>Min commitment</dt><dd>5 active customers</dd></div>
</dl>
<dl class="def">
<div><dt>Effective</dt><dd>14 Jan 2024</dd></div>
<div><dt>Term</dt><dd>36 months · auto-renew</dd></div>
<div><dt>Notice period</dt><dd>90 days written</dd></div>
<div><dt>Liability cap</dt><dd>12 months of fees</dd></div>
<div><dt>Governing law</dt><dd>Denmark · Copenhagen</dd></div>
<div><dt>Signed by</dt><dd>Anne Baslund · NordicMSP</dd></div>
</dl>
</div>
</Card>
<Card>
<Eyebrow>Documents</Eyebrow>
<div class="card-title">Related files</div>
<div class="doc-list">
<Mono v-if="docs.length === 0" dim>// no documents uploaded yet</Mono>
<button
v-for="d in docs"
:key="d.n"
class="doc-row"
@click="toast.info('Downloading', d.n)"
>
<UiIcon name="file" :size="14" />
<span class="dr-name">{{ d.n }}</span>
<Mono dim>{{ d.size }} · {{ d.date }}</Mono>
<UiIcon name="download" :size="13" />
</button>
</div>
</Card>
</template>
<!-- CONTACT INFO -->
<template v-if="tab === 'contact'">
<Card>
<div class="card-head">
<div>
<Eyebrow>Business</Eyebrow>
<div class="card-title">NordicMSP company info</div>
</div>
<UiButton size="sm" variant="primary" :disabled="savingContact" @click="saveContact">{{ savingContact ? 'Saving' : 'Save changes' }}</UiButton>
</div>
<div class="contact-grid">
<div class="col">
<label class="field"><Eyebrow>Legal name</Eyebrow><input v-model="contact.legalName" /></label>
<label class="field"><Eyebrow>Trading name</Eyebrow><input v-model="contact.tradingName" /></label>
<label class="field"><Eyebrow>Address</Eyebrow><input v-model="contact.address" /></label>
<label class="field"><Eyebrow>Country</Eyebrow><CountrySelect v-model="contact.country" /></label>
</div>
<div class="col">
<label class="field"><Eyebrow>Primary contact · email</Eyebrow><input v-model="contact.primaryEmail" /></label>
<label class="field"><Eyebrow>Primary contact · phone</Eyebrow><input v-model="contact.primaryPhone" /></label>
<label class="field"><Eyebrow>Support hotline</Eyebrow><input v-model="contact.supportHotline" /></label>
<label class="field"><Eyebrow>Public website</Eyebrow><input v-model="contact.website" /></label>
</div>
</div>
</Card>
</template>
<!-- TAX -->
<template v-if="tab === 'tax'">
<Card>
<Eyebrow>Identification</Eyebrow>
<div class="card-title">Tax &amp; invoicing</div>
<div class="tax-grid">
<dl class="def">
<div><dt>Country</dt><dd>Denmark</dd></div>
<div><dt>CVR</dt><dd>41 88 22 04</dd></div>
<div><dt>VAT number</dt><dd>DK 41 88 22 04</dd></div>
<div><dt>VAT rate</dt><dd>25% · standard DK</dd></div>
</dl>
<dl class="def">
<div><dt>Currency</dt><dd>DKK · EUR available</dd></div>
<div><dt>Invoicing</dt><dd>OIOUBL · NemHandel</dd></div>
<div><dt>EAN/GLN</dt><dd>5790000123456</dd></div>
<div><dt>Tax exempt</dt><dd>No</dd></div>
</dl>
</div>
</Card>
<Card>
<div class="card-head">
<div>
<Eyebrow>Payout method</Eyebrow>
<div class="card-title">Where Dezky pays your margin</div>
</div>
<UiButton size="sm" variant="ghost" @click="toast.info('Change payout method', 'Contact partner success to switch')">Change</UiButton>
</div>
<div class="payout-row">
<div class="payout-icon"><UiIcon name="card" :size="20" /></div>
<div class="payout-meta">
<div class="payout-bank">Danske Bank · ApS account</div>
<Mono dim>IBAN DK 1820 · BIC DABADKKK</Mono>
</div>
<Badge tone="ok" dot>verified</Badge>
</div>
</Card>
</template>
<!-- NOTIFICATIONS -->
<template v-if="tab === 'notifications'">
<Card :pad="0">
<div class="card-head pad">
<div>
<Eyebrow>Partner-level events</Eyebrow>
<div class="card-title">Where to send each event</div>
</div>
</div>
<div class="notif-list">
<div
v-for="(row, i) in events"
:key="row.event"
class="notif-row"
:class="{ last: i === events.length - 1 }"
>
<span class="notif-event">{{ row.event }}</span>
<Mono>{{ row.when }}</Mono>
<Mono dim>{{ row.channels }}</Mono>
<UiButton size="sm" variant="ghost" @click="toast.info('Edit notification', row.event)">Edit</UiButton>
</div>
</div>
</Card>
</template>
</div>
</div>
</template>
<style scoped>
.tabs-wrap { padding: 0 40px; margin-top: 16px; }
.content { padding: 20px 40px 64px; display: flex; flex-direction: column; gap: 16px; max-width: 1000px; }
.card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.card-head.pad {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
margin-bottom: 0;
}
.card-title {
font-family: var(--font-display);
font-weight: 600;
font-size: 17px;
margin-top: 4px;
}
.sub { font-size: 13px; color: var(--text-mute); margin: 6px 0 0; line-height: 1.5; }
.head-actions { display: flex; gap: 6px; flex-shrink: 0; }
.agree-grid, .tax-grid, .contact-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
/* DefList */
.def { display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; }
.def div { display: grid; grid-template-columns: 160px 1fr; gap: 12px; font-size: 13px; align-items: center; }
.def dt { color: var(--text-mute); font-family: var(--font-mono); font-size: 11px; }
.def dd { margin: 0; }
/* Documents */
.doc-list { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; }
.doc-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 6px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-family: inherit;
font-size: 13px;
cursor: pointer;
text-align: left;
}
.doc-row:hover { background: var(--row-hover); }
.doc-row :deep(svg) { color: var(--text-mute); flex-shrink: 0; }
.dr-name { flex: 1; font-weight: 500; }
/* Contact form */
.contact-grid .col { display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field input {
padding: 9px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
color: var(--text);
}
.field input:focus { outline: none; border-color: var(--border-hi); }
/* Payout */
.payout-row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px;
background: var(--bg);
border-radius: 6px;
border: 1px solid var(--border);
margin-top: 8px;
}
.payout-icon {
width: 44px;
height: 44px;
border-radius: 8px;
background: var(--text);
color: var(--bg);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.payout-meta { flex: 1; min-width: 0; }
.payout-bank { font-size: 14px; font-weight: 500; }
/* Notifications */
.notif-list { padding: 8px; }
.notif-row {
display: grid;
grid-template-columns: 1fr 120px 220px 80px;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.notif-row.last { border-bottom: none; }
.notif-event { font-size: 13px; font-weight: 500; }
</style>