3288fde693
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).
413 lines
14 KiB
Vue
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 & 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>
|