Files
dezky/apps/portal/pages/partner/branding.vue
T
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

325 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// Partner branding. Strict port of PartnerBrandingScreen
// (partner-screens.jsx lines 839-942). Three cards:
// • Your brand · NordicMSP identity
// • Customer defaults · what gets pushed to new customers (7 toggles)
// • Email templates · 2-col grid of 5 templates
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)
interface CustomerDefault {
label: string
detail: string
on: boolean
}
interface PartnerBranding {
identity: BrandIdentity
customerDefaults: CustomerDefault[]
emailTemplates: EmailTemplate[]
}
const { data: branding, refresh } = await useFetch<PartnerBranding>('/api/partner/branding', {
key: 'partner-branding',
default: () => ({ identity: {}, customerDefaults: [], emailTemplates: [] }),
})
// Source mustache literals. Constructed in JS to avoid the Vue parser eating
// nested {{ }} (see the Vue nested-mustache note).
const TAG_WORKSPACE = '{' + '{workspace.name}' + '}'
const TAG_INVOICE = '{' + '{invoice.id}' + '}'
const TAG_PLAN = '{' + '{plan.name}' + '}'
// Product defaults shown the first time a partner visits (before they save).
const DEFAULT_TOGGLES: CustomerDefault[] = [
{ label: 'Accent color', detail: 'Apply your brand accent to customer workspaces', on: true },
{ label: 'Product name pattern', detail: '"{Customer} Workspace"', on: true },
{ label: 'Custom subdomain', detail: 'workspace.{customer-domain}', on: true },
{ label: 'Login screen', detail: 'Partner co-brand + customer logo', on: true },
{ label: 'Email templates', detail: 'Your branded templates', on: true },
{ label: 'Allow customer override', detail: 'Business plans and above', on: true },
{ label: 'Lock typography', detail: 'Brand-locked fonts', on: false },
]
const DEFAULT_TEMPLATES: EmailTemplate[] = [
{ id: 'welcome', name: 'Customer welcome email', subject: `Welcome to ${TAG_WORKSPACE}`, body: '', edited: 'default' },
{ id: 'invitation', name: 'User invitation', subject: `Youve been invited to ${TAG_WORKSPACE}`, body: '', edited: 'default' },
{ id: 'reset', name: 'Password reset', subject: `Reset your ${TAG_WORKSPACE} password`, body: '', edited: 'default' },
{ id: 'plan', name: 'Plan change confirmation', subject: `Your plan changed to ${TAG_PLAN}`, body: '', edited: 'default' },
{ id: 'invoice', name: 'Invoice notification', subject: `Invoice ${TAG_INVOICE}`, body: '', edited: 'default' },
]
// Editable working copies seeded from the fetched branding (falling back to the
// product defaults so the page is never blank on first visit).
const identity = ref<BrandIdentity>({})
const defaults = ref<CustomerDefault[]>([])
const templates = ref<EmailTemplate[]>([])
function clone<T>(v: T): T {
return JSON.parse(JSON.stringify(v)) as T
}
function syncBranding() {
const b = branding.value
identity.value = { ...(b?.identity ?? {}) }
defaults.value = b?.customerDefaults?.length ? clone(b.customerDefaults) : clone(DEFAULT_TOGGLES)
templates.value = b?.emailTemplates?.length ? clone(b.emailTemplates) : clone(DEFAULT_TEMPLATES)
}
syncBranding()
watch(branding, syncBranding)
async function putBranding(): Promise<boolean> {
try {
await request('/api/partner/branding', {
method: 'PUT',
body: {
identity: identity.value,
customerDefaults: defaults.value,
emailTemplates: templates.value,
},
})
await Promise.all([refresh(), refreshNuxtData('partner-branding')])
return true
} catch (e: unknown) {
const err = e as { data?: { message?: string }; statusMessage?: string }
toast.bad('Save failed', err.data?.message || err.statusMessage || 'Could not save branding')
return false
}
}
async function toggleDefault(i: number) {
const row = defaults.value[i]
if (!row) return
row.on = !row.on
if (await putBranding()) toast.ok('Saved', 'Customer defaults updated')
}
async function saveTemplate(t: EmailTemplate) {
templates.value = templates.value.map((x) => (x.id === t.id ? { ...t, edited: 'just now' } : x))
editing.value = null
if (await putBranding()) toast.ok('Template saved', t.name)
}
async function saveIdentity(payload: BrandIdentity) {
identity.value = { ...identity.value, ...payload }
// Close only on success so a failed save keeps the modal open (with its toast).
if (await putBranding()) {
identityOpen.value = false
toast.ok('Saved', 'Brand identity updated')
}
}
</script>
<template>
<div>
<PageHeader
eyebrow="Whitelabel"
title="Partner branding"
subtitle="Your own brand identity, plus the defaults pushed to every customer you provision."
/>
<div class="content">
<!-- Your brand · identity card -->
<Card>
<div class="card-head">
<div>
<Eyebrow>Your brand</Eyebrow>
<div class="card-title">NordicMSP identity</div>
<p class="sub">Shown in the partner console and on emails sent by your team.</p>
</div>
<UiButton size="sm" variant="ghost" @click="identityOpen = true">Edit</UiButton>
</div>
<div class="id-grid">
<dl class="def">
<div><dt>Display name</dt><dd>{{ identity.displayName || '—' }}</dd></div>
<div><dt>Logo</dt><dd>{{ identity.logoUrl || 'not set' }}</dd></div>
<div><dt>Mark</dt><dd>{{ identity.markUrl || 'not set' }}</dd></div>
<div>
<dt>Primary color</dt>
<dd>
<div class="color-row">
<div class="color-swatch" :style="{ background: identity.primaryColor || '#3F6BFF' }" />
<Mono>{{ identity.primaryColor || '#3F6BFF' }}</Mono>
</div>
</dd>
</div>
</dl>
<dl class="def">
<div><dt>Support email</dt><dd>{{ identity.supportEmail || '—' }}</dd></div>
<div><dt>Support phone</dt><dd>{{ identity.supportPhone || '—' }}</dd></div>
<div><dt>Website</dt><dd>{{ identity.website || '—' }}</dd></div>
<div><dt>Reply-to</dt><dd>{{ identity.replyTo || '—' }}</dd></div>
</dl>
</div>
</Card>
<!-- Customer defaults · toggle list -->
<Card>
<div class="card-head">
<div>
<Eyebrow>Customer defaults</Eyebrow>
<div class="card-title">What gets pushed to new customers</div>
<p class="sub">Applied at provisioning. Customers can override per their tier entitlements.</p>
</div>
</div>
<div class="defaults-list">
<div
v-for="(row, i) in defaults"
:key="row.label"
class="def-row"
:class="{ last: i === defaults.length - 1 }"
>
<div class="dr-meta">
<div class="dr-label">{{ row.label }}</div>
<div class="dr-detail">{{ row.detail }}</div>
</div>
<button class="switch" :class="{ on: row.on }" @click="toggleDefault(i)">
<span class="thumb" />
</button>
</div>
</div>
</Card>
<!-- Email templates · 2-col grid -->
<Card>
<Eyebrow>Templates</Eyebrow>
<div class="card-title">Email templates · NordicMSP defaults</div>
<div class="tpl-grid">
<button
v-for="t in templates"
:key="t.id"
class="tpl-row"
@click="editing = t"
>
<UiIcon name="mail" :size="14" />
<div class="tpl-meta">
<div class="tpl-top">
<span class="tpl-name">{{ t.name }}</span>
<Badge :tone="t.edited === 'default' ? 'neutral' : 'info'">{{ t.edited === 'default' ? 'default' : 'edited' }}</Badge>
</div>
<Mono dim>edited {{ t.edited }}</Mono>
</div>
<UiIcon name="chevRight" :size="14" />
</button>
</div>
</Card>
</div>
<PartnerEditIdentityModal
:open="identityOpen"
:identity="identity"
@close="identityOpen = false"
@save="saveIdentity"
/>
<PartnerEmailTemplateEditor
:template="editing"
:brand-color="identity.primaryColor || '#3F6BFF'"
:brand-name="identity.displayName || 'Your brand'"
@close="editing = null"
@save="saveTemplate"
/>
</div>
</template>
<style scoped>
.content { padding: 24px 40px 64px; display: flex; flex-direction: column; gap: 16px; max-width: 1100px; }
.card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.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; max-width: 580px; line-height: 1.5; }
/* Identity DefList grid */
.id-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.def { display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; }
.def div { display: grid; grid-template-columns: 140px 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; }
.color-row { display: flex; align-items: center; gap: 8px; }
.color-swatch { width: 14px; height: 14px; border-radius: 3px; }
/* Defaults toggle list */
.defaults-list { display: flex; flex-direction: column; }
.def-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.def-row.last { border-bottom: none; }
.dr-meta { flex: 1; min-width: 0; }
.dr-label { font-size: 13px; font-weight: 500; }
.dr-detail { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
.switch {
width: 36px;
height: 20px;
border-radius: 999px;
background: var(--border);
border: none;
padding: 2px;
display: inline-flex;
align-items: center;
cursor: pointer;
transition: background 150ms;
flex-shrink: 0;
}
.switch.on { background: var(--text); }
.thumb {
width: 16px;
height: 16px;
border-radius: 999px;
background: var(--bg);
transition: transform 150ms;
}
.switch.on .thumb { transform: translateX(16px); }
/* Templates grid */
.tpl-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-top: 12px;
}
.tpl-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 6px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
font-family: inherit;
font-size: 13px;
cursor: pointer;
text-align: left;
}
.tpl-row:hover { background: var(--row-hover); }
.tpl-row :deep(svg) { color: var(--text-mute); flex-shrink: 0; }
.tpl-meta { flex: 1; min-width: 0; }
.tpl-top { display: flex; align-items: center; gap: 8px; }
.tpl-name { font-weight: 500; }
</style>