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:
Ronni Baslund
2026-05-31 00:19:34 +02:00
parent db26dafc64
commit 3288fde693
44 changed files with 1874 additions and 1237 deletions
+149 -405
View File
@@ -1,49 +1,63 @@
<script setup lang="ts">
// Strict port of project/platform-screens.jsx `BrandingScreen` (lines 1542-1668)
// with BrandingPreview (1669), UploadAssetModal (1733), EditEmailTemplatePanel
// (1903), PublishBrandingModal (2031) and ResetBrandingModal (2148). Two-column
// layout — controls on the left (420px), live preview on the right.
// 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 color = ref('#D4FF3A')
const name = ref('Acme Workspace')
const DEFAULT_COLOR = '#D4FF3A'
const colorPalette = ['#D4FF3A', '#3F6BFF', '#FF6B4A', '#5B8C5A', '#9B59B6']
const uploadAsset = ref<typeof ASSETS[number] | null>(null)
const uploaded = ref(false)
const dragOver = ref(false)
const { data: branding } = await useFetch<TenantBrandingView>(
() => `/api/tenants/${slug.value}/branding`,
{ key: 'admin-branding', default: () => ({ name: '', emailTemplates: [] }), immediate: !!slug.value, watch: [slug] },
)
const editTemplate = ref<typeof TEMPLATES[number] | null>(null)
const subject = ref('')
const body = ref('')
const testSent = ref(false)
function sendTest() {
testSent.value = true
setTimeout(() => (testSent.value = false), 2500)
// 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 publishOpen = ref(false)
const publishState = ref<'confirm' | 'publishing' | 'done'>('confirm')
const resetOpen = ref(false)
const primaryDomain = computed(() => branding.value?.primaryDomain ?? '')
const ASSETS = [
{ id: 'full', l: 'Full logo', d: 'horizontal · 4:1 · png/svg', ratio: '4:1', formats: 'png · svg', maxKb: 400, current: false, currentName: '', currentSize: '' },
{ id: 'mark', l: 'Square mark', d: '1:1 · transparent · png/svg', ratio: '1:1', formats: 'png · svg', maxKb: 200, current: true, currentName: 'acme-mark.svg', currentSize: '12 KB' },
{ id: 'favicon', l: 'Favicon', d: '32×32 · ico/png', ratio: '1:1', formats: 'ico · png', maxKb: 50, current: true, currentName: 'favicon.ico', currentSize: '4 KB' },
] as const
// 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', edited: '3 days ago' },
{ id: 'reset', name: 'Password reset', subject: 'Reset your {{workspace.name}} password', desc: 'sent on forgot-password requests', edited: 'default' },
{ id: 'digest', name: 'Notification digest', subject: 'Your weekly summary from {{workspace.name}}', desc: 'sent weekly to users opted-in for digests', edited: '2 weeks ago' },
{ id: 'trial', name: 'Trial expiring', subject: 'Your trial ends in {{trial.days_left}} days', desc: 'sent 7 / 3 / 1 days before trial expiry', edited: 'default' },
{ 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}} on dezky.
{{inviter.name}} has invited you to join {{workspace.name}}.
Click below to set up your account — the link expires in 7 days.
@@ -92,42 +106,51 @@ const TEMPLATE_MERGE_TAGS: Record<string, string[]> = {
trial: ['user.first_name', 'workspace.name', 'trial.days_left', 'stats.users', 'stats.gb', 'billing.url'],
}
const colorPalette = ['#D4FF3A', '#3F6BFF', '#FF6B4A', '#5B8C5A', '#9B59B6']
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 = t.subject
body.value = TEMPLATE_BODIES[t.id] || ''
subject.value = overrides[t.id]?.subject ?? t.subject
body.value = overrides[t.id]?.body ?? (TEMPLATE_BODIES[t.id] || '')
testSent.value = false
}
function insertTag(tag: string) {
body.value += `{{${tag}}}`
}
// Reset the currently-open template's subject + body to the canonical default.
function resetTemplate() {
if (!editTemplate.value) return
subject.value = editTemplate.value.subject
body.value = TEMPLATE_BODIES[editTemplate.value.id] || ''
toast.info('Template reset to default')
}
// Wrap a merge-tag name in mustaches via JS so the template doesn't have to
// nest `{{ ... }}` inside `{{ ... }}` (which Vue's parser scans positionally
// and breaks on).
// 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 startPublish() {
publishState.value = 'publishing'
setTimeout(() => { publishState.value = 'done' }, 1800)
function insertTag(tag: string) {
body.value += wrapTag(tag)
}
function openPublish() {
publishOpen.value = true
publishState.value = 'confirm'
// 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(() =>
@@ -139,13 +162,13 @@ const renderedSubject = computed(() =>
const renderedBody = computed(() =>
body.value
.replace(/\{\{workspace\.name\}\}/g, name.value)
.replace(/\{\{workspace\.url\}\}/g, 'workspace.acme.dk')
.replace(/\{\{workspace\.url\}\}/g, primaryDomain.value || 'your-workspace')
.replace(/\{\{user\.first_name\}\}/g, 'Anne')
.replace(/\{\{user\.email\}\}/g, 'anne@acme.dk')
.replace(/\{\{user\.email\}\}/g, 'anne@example.com')
.replace(/\{\{inviter\.name\}\}/g, 'Mikkel Nørgaard')
.replace(/\{\{invite\.url\}\}/g, 'workspace.acme.dk/accept/x9k2a')
.replace(/\{\{reset\.url\}\}/g, 'workspace.acme.dk/reset/p2b7c')
.replace(/\{\{billing\.url\}\}/g, 'workspace.acme.dk/billing')
.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')
@@ -153,6 +176,31 @@ const renderedBody = computed(() =>
.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>
@@ -160,11 +208,13 @@ const renderedBody = computed(() =>
<PageHeader
eyebrow="Whitelabel"
title="Branding"
subtitle="Replace the dezky shell with your own logo, color, and product name. Changes propagate everywhere."
subtitle="Give your workspace its own name, accent colour, and email copy."
>
<template #actions>
<UiButton variant="ghost" @click="resetOpen = true">Reset</UiButton>
<UiButton variant="primary" @click="openPublish">Publish</UiButton>
<UiButton variant="primary" :disabled="saving || !slug" @click="save">
<template #leading><UiIcon name="check" :size="14" /></template>
{{ saving ? 'Saving…' : 'Save changes' }}
</UiButton>
</template>
</PageHeader>
@@ -175,9 +225,11 @@ const renderedBody = computed(() =>
<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 class="input-row">
<input value="workspace.acme.dk" readonly />
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
<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>
@@ -199,32 +251,22 @@ const renderedBody = computed(() =>
<Card>
<div class="card-head"><Eyebrow>Assets</Eyebrow><div class="card-title">Logo upload</div></div>
<div class="assets">
<div v-for="a in ASSETS" :key="a.id" class="asset" :class="{ has: a.current }">
<div class="asset-icon" :style="{ color: a.current ? 'var(--ok)' : 'var(--text-mute)' }">
<UiIcon :name="a.current ? 'check' : 'upload'" :size="16" :stroke-width="a.current ? 2.5 : 2" />
</div>
<div class="asset-meta">
<div class="asset-l">{{ a.l }}</div>
<Mono dim>{{ a.current ? `${a.currentName} · ${a.currentSize}` : a.d }}</Mono>
</div>
<UiButton size="sm" :variant="a.current ? 'ghost' : 'secondary'" @click="uploadAsset = a as any; uploaded = false">
{{ a.current ? 'Replace' : 'Upload' }}
</UiButton>
</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 as any)">
<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="t.edited === 'default' ? 'neutral' : 'info'">{{ t.edited === 'default' ? 'default' : 'edited' }}</Badge>
<Badge :tone="isEdited(t.id) ? 'info' : 'neutral'">{{ isEdited(t.id) ? 'edited' : 'default' }}</Badge>
</div>
<Mono dim>edited {{ t.edited }}</Mono>
<Mono dim>{{ t.desc }}</Mono>
</div>
<UiIcon name="chevRight" :size="14" stroke="var(--text-mute)" />
</button>
@@ -236,18 +278,18 @@ const renderedBody = computed(() =>
<div class="preview-col">
<div class="preview-head">
<Eyebrow>Live preview</Eyebrow>
<Mono dim>workspace.acme.dk</Mono>
<Mono dim>{{ primaryDomain || slug }}</Mono>
</div>
<div class="preview-frame">
<div class="frame-topbar">
<div class="frame-mark" :style="{ background: color }">{{ name[0]?.toLowerCase() || 'a' }}</div>
<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">anne@acme.dk</div>
<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, Anne.</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>
@@ -256,10 +298,10 @@ const renderedBody = computed(() =>
</div>
<div class="frame-cta" :style="{ background: color }">
<div>
<div class="frame-cta-title">Welcome to {{ name }}.</div>
<div class="frame-cta-sub">Your team's workspace is ready.</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">Get started</button>
<button class="frame-cta-btn" :style="{ background: accentFg, color: accentBtnText }">Get started</button>
</div>
</div>
<div class="frame-foot">
@@ -270,86 +312,6 @@ const renderedBody = computed(() =>
</div>
</div>
<!-- Upload asset modal -->
<Modal :open="!!uploadAsset" :eyebrow="uploadAsset ? `Branding · ${uploadAsset.l.toLowerCase()}` : ''" :title="uploadAsset ? `Upload ${uploadAsset.l.toLowerCase()}` : ''" size="md" @close="uploadAsset = null">
<div v-if="uploadAsset" class="upload">
<button v-if="!uploaded" class="dropzone" :class="{ over: dragOver }"
@dragover.prevent="dragOver = true"
@dragleave="dragOver = false"
@drop.prevent="dragOver = false; uploaded = true"
@click="uploaded = true">
<UiIcon name="upload" :size="28" stroke="var(--text-mute)" />
<div class="drop-text">
<div class="drop-title">Drop {{ uploadAsset.l.toLowerCase() }} here, or click to browse</div>
<Mono dim>{{ uploadAsset.formats }} · {{ uploadAsset.ratio }} ratio · up to {{ uploadAsset.maxKb }} KB</Mono>
</div>
</button>
<template v-if="uploaded">
<div class="upload-preview">
<div class="upload-mark" :style="{ width: uploadAsset.id === 'full' ? '96px' : '56px' }">
{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}
</div>
<div class="upload-meta">
<div class="upload-name">{{ uploadAsset.id === 'favicon' ? 'favicon-new.png' : uploadAsset.id === 'mark' ? 'acme-mark-v2.svg' : 'acme-logo.svg' }}</div>
<Mono dim>{{ uploadAsset.id === 'favicon' ? '6 KB' : uploadAsset.id === 'mark' ? '14 KB' : '38 KB' }} · {{ uploadAsset.id === 'favicon' ? '32×32' : uploadAsset.id === 'mark' ? '512×512' : '1200×300' }} · clean alpha</Mono>
</div>
<UiButton size="sm" variant="ghost" @click="uploaded = false">Replace</UiButton>
</div>
<Eyebrow>Looks good</Eyebrow>
<div class="check-list">
<div class="check-row">
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
<Mono dim>Format</Mono>
<span>{{ uploadAsset.formats.split(' · ')[0] }} ✓</span>
</div>
<div class="check-row">
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
<Mono dim>Dimensions</Mono>
<span>{{ uploadAsset.id === 'favicon' ? '32×32 ' : uploadAsset.ratio + ' ' }}</span>
</div>
<div class="check-row">
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
<Mono dim>Size</Mono>
<span>{{ uploadAsset.id === 'favicon' ? '6 KB' : uploadAsset.id === 'mark' ? '14 KB' : '38 KB' }} (under {{ uploadAsset.maxKb }} KB)</span>
</div>
<div class="check-row">
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
<Mono dim>Transparency</Mono>
<span>{{ uploadAsset.id === 'favicon' ? 'opaque background OK' : 'transparent background ' }}</span>
</div>
</div>
<div class="ld-preview">
<Eyebrow>Preview · on light + dark</Eyebrow>
<div class="ld-grid">
<div class="ld-light">
<div class="ld-mark dark" :style="{ width: uploadAsset.id === 'full' ? '80px' : '32px' }">{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}</div>
</div>
<div class="ld-dark">
<div class="ld-mark light" :style="{ width: uploadAsset.id === 'full' ? '80px' : '32px' }">{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}</div>
</div>
</div>
</div>
</template>
<div class="req-box">
<Mono dim>// requirements</Mono>
<div class="req-body">
<template v-if="uploadAsset.id === 'full'">Used in the top navigation bar, login screen, and email headers. Roughly 200×50 displayed — supply at 2× minimum.</template>
<template v-else-if="uploadAsset.id === 'mark'">Used as the app icon, favicon fallback, and any compact context (PWA install, notifications). Must read at 24×24.</template>
<template v-else>Browser tab icon and bookmark badge. 32×32 is the standard size — modern browsers use the same file at 16×16.</template>
</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="uploadAsset = null">Cancel</UiButton>
<UiButton variant="primary" :disabled="!uploaded" @click="uploadAsset = null">
<template #leading><UiIcon name="check" :size="13" /></template>
{{ uploaded ? 'Use this asset' : 'Select a file to continue' }}
</UiButton>
</template>
</Modal>
<!-- 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">
@@ -372,12 +334,12 @@ const renderedBody = computed(() =>
<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.toLowerCase().replace(/\s+/g, '-') }}@dezky.com</Mono>
<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 }">{{ name }} · workspace.acme.dk</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>
@@ -390,123 +352,14 @@ const renderedBody = computed(() =>
<div style="flex: 1" />
<UiButton variant="secondary" @click="sendTest">
<template #leading><UiIcon name="mail" :size="13" /></template>
{{ testSent ? 'Sent to anne@dezky.com ✓' : 'Send test to me' }}
{{ testSent ? 'Test queued ✓' : 'Send test to me' }}
</UiButton>
<UiButton variant="primary" @click="editTemplate = null">
<UiButton variant="primary" @click="applyTemplate">
<template #leading><UiIcon name="check" :size="13" /></template>
Save template
Apply
</UiButton>
</template>
</SidePanel>
<!-- Publish modal -->
<Modal :open="publishOpen" eyebrow="Branding · publish" :title="publishState === 'done' ? 'Branding published' : 'Publish branding changes?'" size="md" @close="publishState !== 'publishing' ? (publishOpen = false) : null">
<template v-if="publishState === 'confirm'">
<div class="publish-intro">These changes will replace dezky's branding for everyone in your workspace within ~30 seconds.</div>
<Eyebrow>Will go live</Eyebrow>
<div class="publish-summary">
<div class="ps-row"><Mono dim>Product name</Mono><span>{{ name }}</span></div>
<div class="ps-row">
<Mono dim>Primary color</Mono>
<span class="color-line">
<span class="color-chip" :style="{ background: color }" />
<Mono>{{ color }}</Mono>
</span>
</div>
<div class="ps-row">
<Mono dim>Custom domain</Mono>
<Mono>workspace.acme.dk</Mono>
<Badge tone="ok" dot>verified</Badge>
</div>
</div>
<Eyebrow>Propagates to</Eyebrow>
<div class="prop-grid">
<div v-for="[k, t] in [
['Web app · workspace shell', '~10s'],
['Login + auth pages', '~10s'],
['Outbound email templates', '~30s'],
['Mobile app · next session', 'on next launch'],
['Status page', '~30s'],
['PDF invoices', 'next billing cycle'],
]" :key="k" class="prop-cell">
<UiIcon name="check" :size="11" stroke="var(--ok)" :stroke-width="2.5" />
<span>{{ k }}</span>
<Mono dim>{{ t }}</Mono>
</div>
</div>
<div class="publish-warn">
<UiIcon name="shield" :size="14" stroke="var(--warn)" />
<div>Users may need to hard-refresh to see the new branding immediately. You can revert with one click for the next 7 days.</div>
</div>
</template>
<template v-else-if="publishState === 'publishing'">
<div class="publishing">
<div class="spinner" />
<div class="publish-title">Publishing across services…</div>
<Mono dim>web shell · auth · mail templates · CDN</Mono>
</div>
</template>
<template v-else>
<div class="done-head">
<div class="done-badge" :style="{ background: color }">
<UiIcon name="check" :size="20" :stroke-width="2.5" />
</div>
<div>
<div class="publish-title">{{ name }} branding is live</div>
<Mono dim>5 services updated · 1 queued for next cycle</Mono>
</div>
</div>
<div class="done-list">
<dl class="def">
<div><dt>Web app + auth</dt><dd>live · 8 seconds</dd></div>
<div><dt>Email templates</dt><dd>live · 18 seconds</dd></div>
<div><dt>Mobile · status · CDN</dt><dd>queued · ~30s</dd></div>
<div><dt>PDF invoices</dt><dd>starts 01 Jun 2026</dd></div>
</dl>
</div>
</template>
<template #footer>
<template v-if="publishState === 'confirm'">
<UiButton variant="ghost" @click="publishOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="startPublish">
<template #leading><UiIcon name="external" :size="13" /></template>
Publish now
</UiButton>
</template>
<template v-else-if="publishState === 'publishing'">
<UiButton variant="ghost" disabled>Publishing…</UiButton>
</template>
<template v-else>
<UiButton variant="primary" @click="publishOpen = false">Done</UiButton>
</template>
</template>
</Modal>
<!-- Reset branding modal -->
<Modal :open="resetOpen" eyebrow="Destructive · reverts to defaults" title="Reset branding to dezky defaults?" size="sm" @close="resetOpen = false">
<div class="reset-box bad">
<UiIcon name="shield" :size="16" stroke="var(--bad)" />
<div>Reverts product name, colors, logos, and email templates to dezky defaults. Your custom domain stays connected. Edits made today are kept for 7 days and can be restored from your audit log.</div>
</div>
<div class="reset-list">
<dl class="def">
<div><dt>Product name</dt><dd>Acme Workspace → dezky</dd></div>
<div><dt>Primary color</dt><dd>#D4FF3A → #D4FF3A (default)</dd></div>
<div><dt>Full logo</dt><dd>will be removed</dd></div>
<div><dt>Square mark</dt><dd>will be removed</dd></div>
<div><dt>Favicon</dt><dd>will be removed</dd></div>
<div><dt>Email templates</dt><dd>2 edited templates → defaults</dd></div>
<div><dt>Custom domain</dt><dd>workspace.acme.dk · kept</dd></div>
</dl>
</div>
<template #footer>
<UiButton variant="ghost" @click="resetOpen = false">Cancel</UiButton>
<UiButton variant="danger" @click="resetOpen = false">
<template #leading><UiIcon name="refresh" :size="13" /></template>
Reset everything
</UiButton>
</template>
</Modal>
</div>
</template>
@@ -534,24 +387,24 @@ const renderedBody = computed(() =>
}
.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; }
.assets { display: flex; flex-direction: column; gap: 10px; }
.asset {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px dashed var(--border);
border-radius: 6px;
}
.asset.has { background: var(--surface); border-style: solid; }
.asset-icon { width: 40px; height: 40px; border-radius: 6px; background: var(--bg); display: inline-flex; align-items: center; justify-content: center; }
.asset-meta { flex: 1; min-width: 0; }
.asset-l { font-size: 13px; font-weight: 500; }
.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; }
@@ -618,70 +471,6 @@ const renderedBody = computed(() =>
.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; }
/* Upload modal */
.upload { display: flex; flex-direction: column; gap: 14px; }
.dropzone {
padding: 48px 24px;
background: var(--bg);
border: 2px dashed var(--border);
border-radius: 10px;
cursor: pointer;
font-family: inherit;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.dropzone.over { background: var(--surface); border-color: var(--text); }
.drop-text { text-align: center; }
.drop-title { font-size: 14px; font-weight: 500; color: var(--text); }
.upload-preview {
padding: 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
align-items: center;
gap: 14px;
}
.upload-mark {
height: 56px;
background: var(--text);
color: var(--bg);
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-weight: 700;
font-size: 18px;
flex-shrink: 0;
}
.upload-meta { flex: 1; min-width: 0; }
.upload-name { font-size: 13px; font-weight: 500; }
.check-list { display: flex; flex-direction: column; gap: 6px; }
.check-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); font-size: 12px; }
.check-row > :first-of-type { flex-shrink: 0; }
.ld-preview { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); }
.ld-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
.ld-light, .ld-dark { border-radius: 6px; padding: 18px; display: flex; align-items: center; justify-content: center; }
.ld-light { background: #FAFAF7; }
.ld-dark { background: #0A0A0A; }
.ld-mark {
height: 32px;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-weight: 700;
font-size: 12px;
}
.ld-mark.dark { background: #0A0A0A; color: #F4F3EE; }
.ld-mark.light { background: #F4F3EE; color: #0A0A0A; }
.req-box { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); font-size: 12px; color: var(--text-mute); line-height: 1.55; }
.req-body { margin-top: 6px; }
/* 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; }
@@ -740,49 +529,4 @@ const renderedBody = computed(() =>
.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; }
/* Publish modal */
.publish-intro { font-size: 13px; color: var(--text-dim); line-height: 1.55; margin-bottom: 14px; }
.publish-summary { padding: 14px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; margin-top: 8px; margin-bottom: 14px; }
.ps-row { display: flex; align-items: center; gap: 12px; }
.ps-row > :first-child { width: 100px; }
.color-line { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; }
.color-chip { width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--border); }
.prop-grid { padding: 14px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); display: grid; grid-template-columns: 1fr 1fr; gap: 10px; font-size: 12px; margin-top: 8px; margin-bottom: 14px; }
.prop-cell { display: flex; align-items: center; gap: 8px; }
.prop-cell span:first-of-type { flex: 1; }
.publish-warn { padding: 12px; background: rgba(232, 154, 31, 0.06); border-radius: 6px; border: 1px solid rgba(232, 154, 31, 0.2); font-size: 12px; color: var(--text-dim); line-height: 1.55; display: flex; gap: 10px; }
.publishing { padding: 32px 0; text-align: center; }
.spinner {
width: 56px;
height: 56px;
margin: 0 auto 18px auto;
border-radius: 999px;
border: 3px solid var(--border);
border-top-color: var(--accent);
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg) } }
.publish-title { font-family: var(--font-display); font-size: 20px; font-weight: 600; }
.done-head { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
.done-badge {
width: 44px;
height: 44px;
border-radius: 10px;
color: #0A0A0A;
display: inline-flex;
align-items: center;
justify-content: center;
}
.done-list { padding: 14px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); }
.def { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 12px; column-gap: 16px; }
.def > div { display: contents; }
.def dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); }
.def dd { margin: 0; font-size: 13px; color: var(--text); }
/* Reset modal */
.reset-box { padding: 14px; border-radius: 6px; display: flex; gap: 10px; align-items: flex-start; margin-bottom: 14px; }
.reset-box.bad { background: rgba(226, 48, 48, 0.06); border: 1px solid rgba(226, 48, 48, 0.2); }
.reset-box > div { font-size: 13px; color: var(--text-dim); line-height: 1.5; }
.reset-list { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); }
</style>