Files
dezky/apps/portal/pages/admin/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

533 lines
22 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">
// Customer whitelabel branding. Real data: product name + accent colour (on the
// Tenant doc) and email-template overrides (TenantBranding), saved together via
// PUT /api/tenants/:slug/branding. No backend yet for logo upload or custom-
// domain verification, so those show honest "coming soon" states.
//
// Edit model: name/colour + the open template editor mutate LOCAL state; the
// header "Save changes" persists everything in one PUT. The per-template
// "Apply" just commits that template into local overrides.
import type { TenantBrandingView } from '~/types/workspace'
const toast = useToast()
const { request } = useApiFetch()
const { fetchMe } = useMe()
await fetchMe()
const { tenant } = useTenant()
const slug = computed(() => tenant.value?.slug ?? '')
const DEFAULT_COLOR = '#D4FF3A'
const colorPalette = ['#D4FF3A', '#3F6BFF', '#FF6B4A', '#5B8C5A', '#9B59B6']
const { data: branding } = await useFetch<TenantBrandingView>(
() => `/api/tenants/${slug.value}/branding`,
{ key: 'admin-branding', default: () => ({ name: '', emailTemplates: [] }), immediate: !!slug.value, watch: [slug] },
)
// Local editable state, seeded from the fetched branding.
const name = ref('')
const color = ref(DEFAULT_COLOR)
const overrides = reactive<Record<string, { subject: string; body: string }>>({})
function seed() {
const b = branding.value
name.value = b?.name ?? ''
color.value = b?.brandColor || DEFAULT_COLOR
for (const k of Object.keys(overrides)) delete overrides[k]
for (const t of b?.emailTemplates ?? []) overrides[t.key] = { subject: t.subject, body: t.body }
}
seed()
watch(branding, seed)
const primaryDomain = computed(() => branding.value?.primaryDomain ?? '')
// Auto-contrast foreground for surfaces painted in the accent colour, so the
// preview stays legible whether the accent is bright or dark.
const accentFg = computed(() => readableOn(color.value))
const accentBtnText = computed(() => readableOn(accentFg.value))
// Canonical template defaults. Overrides (saved per tenant) win over these.
const TEMPLATES = [
{ id: 'invitation', name: 'User invitation', subject: 'Youve been invited to {{workspace.name}}', desc: 'sent when an admin invites a new user' },
{ id: 'reset', name: 'Password reset', subject: 'Reset your {{workspace.name}} password', desc: 'sent on forgot-password requests' },
{ id: 'digest', name: 'Notification digest', subject: 'Your weekly summary from {{workspace.name}}', desc: 'sent weekly to users opted-in for digests' },
{ id: 'trial', name: 'Trial expiring', subject: 'Your trial ends in {{trial.days_left}} days', desc: 'sent 7 / 3 / 1 days before trial expiry' },
] as const
const TEMPLATE_BODIES: Record<string, string> = {
invitation: `Hi {{user.first_name}},
{{inviter.name}} has invited you to join {{workspace.name}}.
Click below to set up your account — the link expires in 7 days.
→ {{invite.url}}
If you have any questions, reply to this email and we'll help out.
— The {{workspace.name}} team`,
reset: `Hi {{user.first_name}},
Someone (hopefully you) asked to reset your {{workspace.name}} password.
Click the link below within the next 60 minutes to choose a new one:
→ {{reset.url}}
If you didn't request this, you can safely ignore this email.
— {{workspace.name}} security`,
digest: `Hi {{user.first_name}},
Here's what happened in {{workspace.name}} this week:
· {{stats.messages}} new messages across your channels
· {{stats.files}} files shared
· {{stats.meetings}} meetings recorded
→ Open dashboard: {{workspace.url}}
Manage how often you receive these from your profile.`,
trial: `Hi {{user.first_name}},
Your {{workspace.name}} trial ends in {{trial.days_left}} days.
You've added {{stats.users}} users and uploaded {{stats.gb}} GB of files. To keep everything running smoothly, upgrade to Business or Enterprise.
→ Choose a plan: {{billing.url}}
— {{workspace.name}}`,
}
const TEMPLATE_MERGE_TAGS: Record<string, string[]> = {
invitation: ['user.first_name', 'user.email', 'inviter.name', 'workspace.name', 'invite.url', 'invite.expires_at'],
reset: ['user.first_name', 'workspace.name', 'reset.url', 'reset.expires_at', 'security.ip'],
digest: ['user.first_name', 'workspace.name', 'workspace.url', 'stats.messages', 'stats.files', 'stats.meetings'],
trial: ['user.first_name', 'workspace.name', 'trial.days_left', 'stats.users', 'stats.gb', 'billing.url'],
}
function isEdited(id: string): boolean {
return !!overrides[id]
}
// ── Template editor (side panel) ─────────────────────────────────────────
const editTemplate = ref<typeof TEMPLATES[number] | null>(null)
const subject = ref('')
const body = ref('')
const testSent = ref(false)
function openTemplate(t: typeof TEMPLATES[number]) {
editTemplate.value = t
subject.value = overrides[t.id]?.subject ?? t.subject
body.value = overrides[t.id]?.body ?? (TEMPLATE_BODIES[t.id] || '')
testSent.value = false
}
// Wrap a merge-tag name in mustaches via JS so the template never nests
// `{{ ... }}` inside `{{ ... }}` (Vue's parser scans positionally and breaks).
function wrapTag(tag: string) {
return '{' + '{' + tag + '}' + '}'
}
function insertTag(tag: string) {
body.value += wrapTag(tag)
}
// Commit the open template into local overrides (header Save persists).
function applyTemplate() {
if (!editTemplate.value) return
overrides[editTemplate.value.id] = { subject: subject.value, body: body.value }
editTemplate.value = null
}
// Drop the override → revert to the canonical default (persisted on Save).
function resetTemplate() {
if (!editTemplate.value) return
delete overrides[editTemplate.value.id]
subject.value = editTemplate.value.subject
body.value = TEMPLATE_BODIES[editTemplate.value.id] || ''
toast.info('Reverted to default', 'Save changes to apply')
}
function sendTest() {
testSent.value = true
setTimeout(() => (testSent.value = false), 2500)
}
const renderedSubject = computed(() =>
subject.value
.replace(/\{\{workspace\.name\}\}/g, name.value)
.replace(/\{\{user\.first_name\}\}/g, 'Anne')
.replace(/\{\{trial\.days_left\}\}/g, '3'),
)
const renderedBody = computed(() =>
body.value
.replace(/\{\{workspace\.name\}\}/g, name.value)
.replace(/\{\{workspace\.url\}\}/g, primaryDomain.value || 'your-workspace')
.replace(/\{\{user\.first_name\}\}/g, 'Anne')
.replace(/\{\{user\.email\}\}/g, 'anne@example.com')
.replace(/\{\{inviter\.name\}\}/g, 'Mikkel Nørgaard')
.replace(/\{\{invite\.url\}\}/g, `${primaryDomain.value || 'your-workspace'}/accept/x9k2a`)
.replace(/\{\{reset\.url\}\}/g, `${primaryDomain.value || 'your-workspace'}/reset/p2b7c`)
.replace(/\{\{billing\.url\}\}/g, `${primaryDomain.value || 'your-workspace'}/billing`)
.replace(/\{\{trial\.days_left\}\}/g, '3')
.replace(/\{\{stats\.messages\}\}/g, '1.840')
.replace(/\{\{stats\.files\}\}/g, '24')
.replace(/\{\{stats\.meetings\}\}/g, '6')
.replace(/\{\{stats\.users\}\}/g, '8')
.replace(/\{\{stats\.gb\}\}/g, '14'),
)
// ── Save ─────────────────────────────────────────────────────────────────
const saving = ref(false)
async function save() {
if (!slug.value) return
saving.value = true
try {
const payload = {
name: name.value,
brandColor: color.value,
emailTemplates: Object.entries(overrides).map(([key, v]) => ({ key, subject: v.subject, body: v.body })),
}
branding.value = await request<TenantBrandingView>(`/api/tenants/${slug.value}/branding`, {
method: 'PUT',
body: payload,
})
await fetchMe(true) // name lives on the tenant → refresh dashboard/sidebar identity
toast.ok('Branding saved')
} catch (e: unknown) {
const msg = (e as { data?: { message?: string | string[] } })?.data?.message
toast.bad('Could not save branding', Array.isArray(msg) ? msg.join(', ') : msg)
} finally {
saving.value = false
}
}
</script>
<template>
<div>
<PageHeader
eyebrow="Whitelabel"
title="Branding"
subtitle="Give your workspace its own name, accent colour, and email copy."
>
<template #actions>
<UiButton variant="primary" :disabled="saving || !slug" @click="save">
<template #leading><UiIcon name="check" :size="14" /></template>
{{ saving ? 'Saving…' : 'Save changes' }}
</UiButton>
</template>
</PageHeader>
<div class="content">
<!-- Controls -->
<div class="controls">
<Card>
<div class="card-head"><Eyebrow>Identity</Eyebrow><div class="card-title">Product identity</div></div>
<label class="field"><Eyebrow>Product name (shown to users)</Eyebrow><input class="input" v-model="name" /></label>
<label class="field"><Eyebrow>Custom domain</Eyebrow>
<div v-if="primaryDomain" class="input-row">
<input :value="primaryDomain" readonly />
</div>
<div v-else class="soon-inline">
<Mono dim>Custom domains coming soon</Mono>
</div>
</label>
</Card>
<Card>
<div class="card-head">
<Eyebrow>Color</Eyebrow>
<div class="card-title">Primary accent</div>
<div class="card-sub">Propagates to buttons, links, focus rings, and active states.</div>
</div>
<div class="swatches">
<button v-for="c in colorPalette" :key="c" :style="{ background: c, borderColor: color === c ? 'var(--text)' : 'var(--border)', borderWidth: color === c ? '2px' : '1px' }" @click="color = c" />
</div>
<div class="input-row">
<input v-model="color" />
<div class="color-preview" :style="{ background: color }" />
</div>
</Card>
<Card>
<div class="card-head"><Eyebrow>Assets</Eyebrow><div class="card-title">Logo upload</div></div>
<div class="soon-box">
<UiIcon name="upload" :size="16" stroke="var(--text-mute)" />
<div>Logo, square mark and favicon upload is coming soon. For now your accent colour and product name drive the workspace look.</div>
</div>
</Card>
<Card>
<div class="card-head"><Eyebrow>Templates</Eyebrow><div class="card-title">Email templates</div></div>
<div class="templates">
<button v-for="t in TEMPLATES" :key="t.id" class="tmpl-row" @click="openTemplate(t)">
<div class="tmpl-meta">
<div class="tmpl-name-row">
<span class="tmpl-name">{{ t.name }}</span>
<Badge :tone="isEdited(t.id) ? 'info' : 'neutral'">{{ isEdited(t.id) ? 'edited' : 'default' }}</Badge>
</div>
<Mono dim>{{ t.desc }}</Mono>
</div>
<UiIcon name="chevRight" :size="14" stroke="var(--text-mute)" />
</button>
</div>
</Card>
</div>
<!-- Preview -->
<div class="preview-col">
<div class="preview-head">
<Eyebrow>Live preview</Eyebrow>
<Mono dim>{{ primaryDomain || slug }}</Mono>
</div>
<div class="preview-frame">
<div class="frame-topbar">
<div class="frame-mark" :style="{ background: color, color: accentFg }">{{ name[0]?.toLowerCase() || 'a' }}</div>
<div class="frame-brand">{{ name.toLowerCase() }}</div>
<div class="frame-spacer" />
<div class="frame-user">{{ primaryDomain ? `you@${primaryDomain}` : 'your team' }}</div>
</div>
<div class="frame-hero">
<div class="frame-eyebrow">Dashboard</div>
<div class="frame-title">Good morning.</div>
<div class="frame-tiles">
<div v-for="n in ['Mail', 'Drev', 'Møder', 'Chat']" :key="n" class="frame-tile">
<div class="frame-tile-icon">{{ n[0] }}</div>
<div class="frame-tile-name">{{ n }}</div>
</div>
</div>
<div class="frame-cta" :style="{ background: color }">
<div>
<div class="frame-cta-title" :style="{ color: accentFg }">Welcome to {{ name || 'your workspace' }}.</div>
<div class="frame-cta-sub" :style="{ color: accentFg, opacity: 0.75 }">Your team's workspace is ready.</div>
</div>
<button class="frame-cta-btn" :style="{ background: accentFg, color: accentBtnText }">Get started</button>
</div>
</div>
<div class="frame-foot">
<span>powered by dezky</span>
<span>v1.0 · light</span>
</div>
</div>
</div>
</div>
<!-- Edit email template side panel -->
<SidePanel :open="!!editTemplate" :eyebrow="'Email template'" :title="editTemplate?.name || ''" width="lg" @close="editTemplate = null">
<div v-if="editTemplate" class="tmpl-edit">
<div class="tmpl-col">
<label class="field"><Eyebrow>Subject</Eyebrow><input class="input" v-model="subject" /></label>
<div>
<Eyebrow>Body</Eyebrow>
<textarea v-model="body" class="body-area" />
</div>
<div>
<Eyebrow>Merge tags · click to insert</Eyebrow>
<div class="merge-tags">
<button v-for="tag in (TEMPLATE_MERGE_TAGS[editTemplate.id] || [])" :key="tag" @click="insertTag(tag)">{{ wrapTag(tag) }}</button>
</div>
</div>
</div>
<div class="tmpl-prev">
<Eyebrow>Preview</Eyebrow>
<div class="email-frame">
<div class="email-head">
<div class="from-row">
<div class="from-mark" :style="{ background: '#0A0A0A', color }">{{ name[0]?.toLowerCase() || 'a' }}</div>
<Mono dim>From: {{ (name || 'workspace').toLowerCase().replace(/\s+/g, '-') }}</Mono>
</div>
<div class="email-subj">{{ renderedSubject }}</div>
</div>
<div class="email-body">{{ renderedBody }}</div>
<div class="email-foot" :style="{ background: color, color: accentFg }">{{ name || 'Your workspace' }}{{ primaryDomain ? ` · ${primaryDomain}` : '' }}</div>
</div>
<Mono dim style="text-align: center; display: block;">preview substitutes sample data · real send uses recipient's data</Mono>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="resetTemplate">
<template #leading><UiIcon name="refresh" :size="13" /></template>
Reset to default
</UiButton>
<div style="flex: 1" />
<UiButton variant="secondary" @click="sendTest">
<template #leading><UiIcon name="mail" :size="13" /></template>
{{ testSent ? 'Test queued ✓' : 'Send test to me' }}
</UiButton>
<UiButton variant="primary" @click="applyTemplate">
<template #leading><UiIcon name="check" :size="13" /></template>
Apply
</UiButton>
</template>
</SidePanel>
</div>
</template>
<style scoped>
.content { padding: 24px 40px 64px 40px; display: grid; grid-template-columns: 420px 1fr; gap: 24px; }
.controls { display: flex; flex-direction: column; gap: 16px; }
.card-head { margin-bottom: 14px; }
.card-title { font-family: var(--font-display); font-weight: 600; font-size: 18px; letter-spacing: -0.01em; margin-top: 4px; }
.card-sub { font-size: 13px; color: var(--text-mute); margin-top: 4px; }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.field:last-child { margin-bottom: 0; }
.input { height: 36px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-family: inherit; font-size: 13px; color: var(--text); outline: none; }
.input:focus { border-color: var(--text); }
.input-row {
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.input-row input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
.color-preview { width: 36px; height: 36px; border-radius: 6px; border: 1px solid var(--border); flex-shrink: 0; }
.soon-inline { padding: 8px 0; }
.soon-box {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 14px;
background: var(--bg);
border: 1px dashed var(--border-hi, var(--border));
border-radius: 6px;
font-size: 12px;
color: var(--text-dim);
line-height: 1.5;
}
.swatches { display: flex; gap: 10px; margin-bottom: 14px; }
.swatches button { width: 38px; height: 38px; border-radius: 6px; cursor: pointer; }
.templates { display: flex; flex-direction: column; }
.tmpl-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border); background: transparent; border-left: none; border-right: none; border-top: none; text-align: left; color: var(--text); font-family: inherit; font-size: 13px; cursor: pointer; }
.tmpl-row:last-child { border-bottom: none; }
.tmpl-meta { flex: 1; min-width: 0; }
.tmpl-name-row { display: flex; align-items: center; gap: 8px; }
.tmpl-name { font-weight: 500; }
.preview-col { min-width: 0; }
.preview-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.preview-frame {
background: #FAFAF7;
color: #0A0A0A;
border-radius: 10px;
border: 1px solid var(--border);
overflow: hidden;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
font-family: 'Inter', sans-serif;
}
.frame-topbar {
height: 52px;
background: #0A0A0A;
color: #F4F3EE;
display: flex;
align-items: center;
padding: 0 20px;
gap: 16px;
}
.frame-mark {
width: 24px;
height: 24px;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-weight: 700;
font-size: 13px;
color: #0A0A0A;
}
.frame-brand { font-family: var(--font-mono); font-size: 13px; font-weight: 600; }
.frame-spacer { flex: 1; }
.frame-user { font-size: 11px; font-family: var(--font-mono); opacity: 0.6; }
.frame-hero { padding: 36px 32px 24px 32px; }
.frame-eyebrow { font-family: var(--font-mono); font-size: 10px; color: #5A5A55; letter-spacing: 0.12em; text-transform: uppercase; }
.frame-title { font-family: var(--font-display); font-size: 28px; font-weight: 600; letter-spacing: -0.02em; margin-top: 8px; }
.frame-tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 20px; }
.frame-tile { background: #fff; border: 1px solid #E6E4DC; border-radius: 6px; padding: 14px; }
.frame-tile-icon {
width: 24px;
height: 24px;
border-radius: 5px;
background: #0A0A0A;
color: #F4F3EE;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: 11px;
}
.frame-tile-name { font-family: var(--font-display); font-weight: 600; font-size: 14px; margin-top: 12px; }
.frame-cta { margin-top: 24px; padding: 18px 20px; border-radius: 6px; display: flex; align-items: center; justify-content: space-between; }
.frame-cta-title { font-family: var(--font-display); font-weight: 600; font-size: 15px; color: #0A0A0A; }
.frame-cta-sub { font-size: 12px; color: rgba(10, 10, 10, 0.7); margin-top: 4px; }
.frame-cta-btn { height: 32px; padding: 0 14px; border-radius: 5px; border: none; background: #0A0A0A; color: #F4F3EE; font-weight: 600; font-size: 12px; cursor: pointer; }
.frame-foot { padding: 12px 32px; border-top: 1px solid #E6E4DC; background: #F4F3EE; font-size: 11px; color: #5A5A55; font-family: var(--font-mono); display: flex; justify-content: space-between; }
/* Email template editor */
.tmpl-edit { display: grid; grid-template-columns: 1fr 1fr; min-height: 0; height: 100%; }
.tmpl-col { padding: 24px; border-right: 1px solid var(--border); display: flex; flex-direction: column; gap: 14px; }
.tmpl-prev { padding: 24px; background: var(--bg); display: flex; flex-direction: column; gap: 12px; }
.body-area {
width: 100%;
min-height: 320px;
padding: 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
color: var(--text);
font-family: var(--font-mono);
line-height: 1.6;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.merge-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.merge-tags button {
padding: 4px 8px;
border-radius: 4px;
background: var(--surface);
border: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text);
cursor: pointer;
}
.email-frame {
background: #fff;
border-radius: 8px;
overflow: hidden;
border: 1px solid #E6E4DC;
color: #0A0A0A;
font-family: 'Inter', sans-serif;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
flex: 1;
display: flex;
flex-direction: column;
}
.email-head { padding: 16px 20px; border-bottom: 1px solid #E6E4DC; background: #FAFAF7; }
.from-row { display: flex; align-items: center; gap: 8px; }
.from-mark {
width: 22px;
height: 22px;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-weight: 700;
font-size: 11px;
}
.email-subj { font-family: var(--font-display); font-weight: 600; font-size: 16px; margin-top: 10px; color: #0A0A0A; }
.email-body { padding: 20px; font-size: 13px; line-height: 1.65; color: #3A3A35; white-space: pre-wrap; flex: 1; overflow-y: auto; }
.email-foot { padding: 14px 20px; border-top: 1px solid #E6E4DC; color: #0A0A0A; font-size: 11px; font-family: var(--font-mono); text-align: center; }
</style>