feat: portal redesign, pricing catalog, partner-staff invites

- portal: new admin/ and partner/ surfaces with full component library
  (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables,
  layouts, partner-routing middleware, and supporting server APIs
- pricing: Price schema/module with operator CRUD, pricing.vue catalog UI,
  Subscription extended with cycle/currency/perSeatAmount/seats snapshots
  for stable MRR aggregation
- partner staff: User.partnerId, invite-partner-user DTO and flow,
  /partners/:slug/users endpoints, InvitePartnerUserModal, shared
  dezky-partner-staff Authentik group
- /me: partner-aware endpoint returning user + partner context so portal
  can route between end-user and partner-admin surfaces
- tenant: seats field for portfolio displays and future MRR calculations
- operator: pricing page, signed-out page, useMe/useToast composables,
  ToastStack
This commit is contained in:
Ronni Baslund
2026-05-28 20:00:33 +02:00
parent be430179d9
commit 0bd4e5498e
144 changed files with 22110 additions and 209 deletions
+250
View File
@@ -0,0 +1,250 @@
<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'
const toast = useToast()
const identityOpen = ref(false)
const editing = ref<EmailTemplate | null>(null)
// Customer defaults · partner-screens.jsx line 872-878
const defaults = ref([
{ l: 'Accent color', d: 'Cobalt #3F6BFF', on: true },
{ l: 'Product name pattern', d: '"{Customer} Workspace" e.g. Acme Workspace', on: true },
{ l: 'Custom subdomain', d: 'workspace.{customer-domain}', on: true },
{ l: 'Login screen', d: 'NordicMSP co-brand + customer logo', on: true },
{ l: 'Email templates', d: '5 templates · NordicMSP voice', on: true },
{ l: 'Allow customer override', d: 'Business plans and above', on: true },
{ l: 'Lock typography', d: 'Inter Tight + JetBrains Mono · brand-locked', on: false },
])
// Source mustache literals. Constructed in JS to avoid Vue parser eating
// nested {{ }} (see CRITICAL note in task brief).
const TAG_WORKSPACE = '{' + '{workspace.name}' + '}'
const TAG_INVOICE = '{' + '{invoice.id}' + '}'
const TAG_PLAN = '{' + '{plan.name}' + '}'
const templates = ref<EmailTemplate[]>([
{ id: 'welcome', name: 'Customer welcome email', subject: `Welcome to ${TAG_WORKSPACE} — managed by NordicMSP`, body: '', edited: '5 days ago' },
{ id: 'invitation', name: 'User invitation', subject: `Youve been invited to ${TAG_WORKSPACE}`, body: '', edited: '3 days ago' },
{ 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: `Your NordicMSP invoice ${TAG_INVOICE}`, body: '', edited: '2 weeks ago' },
])
function saveTemplate(t: EmailTemplate) {
templates.value = templates.value.map((x) => (x.id === t.id ? { ...t, edited: 'just now' } : x))
editing.value = null
toast.ok('Template saved', t.name)
}
</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>NordicMSP</dd></div>
<div><dt>Logo</dt><dd>nordic-logo.svg · 4:1 horizontal</dd></div>
<div><dt>Mark</dt><dd>nordic-mark.svg · 1:1</dd></div>
<div>
<dt>Primary color</dt>
<dd>
<div class="color-row">
<div class="color-swatch" style="background:#3F6BFF" />
<Mono>#3F6BFF</Mono>
</div>
</dd>
</div>
</dl>
<dl class="def">
<div><dt>Support email</dt><dd>support@nordicmsp.dk</dd></div>
<div><dt>Support phone</dt><dd>+45 70 70 12 34</dd></div>
<div><dt>Website</dt><dd>nordicmsp.dk</dd></div>
<div><dt>Reply-to</dt><dd>no-reply@nordicmsp.dk</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.l"
class="def-row"
:class="{ last: i === defaults.length - 1 }"
>
<div class="dr-meta">
<div class="dr-label">{{ row.l }}</div>
<div class="dr-detail">{{ row.d }}</div>
</div>
<button class="switch" :class="{ on: row.on }" @click="row.on = !row.on">
<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" @close="identityOpen = false" />
<PartnerEmailTemplateEditor
:template="editing"
brand-color="#3F6BFF"
brand-name="NordicMSP"
@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>