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
@@ -0,0 +1,194 @@
<script setup lang="ts">
// Edit modal for the partner's own brand identity. Includes a small live
// preview of how the partner topbar/header will look with the picked
// primary color + display name.
defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: [] }>()
const name = ref('NordicMSP')
const color = ref('#3F6BFF')
const supportEmail = ref('support@nordicmsp.dk')
const supportPhone = ref('+45 70 70 12 34')
const website = ref('nordicmsp.dk')
const replyTo = ref('no-reply@nordicmsp.dk')
const SWATCHES = ['#3F6BFF', '#0A2540', '#0066CC', '#5B8C5A', '#D4FF3A']
</script>
<template>
<Modal
:open="open"
eyebrow="Partner · identity"
title="Edit NordicMSP identity"
size="md"
@close="emit('close')"
>
<div class="form">
<div class="info">
<UiIcon name="shield" :size="14" />
<p>
This identity appears in the partner console and on emails sent by your team. It is
<b>not</b> what your customers see they see their own branding (or the defaults you set below).
</p>
</div>
<label class="field">
<Eyebrow>Display name</Eyebrow>
<input v-model="name" />
</label>
<div>
<Eyebrow>Logo &amp; mark</Eyebrow>
<div class="upload-grid">
<div class="upload-row">
<div class="upload-pv" :style="{ background: color }">{{ name[0]?.toLowerCase() || 'n' }}</div>
<div class="upload-meta">
<div class="upload-l">Full logo</div>
<Mono dim>nordic-logo.svg · 24 KB</Mono>
</div>
<UiButton size="sm" variant="ghost">Replace</UiButton>
</div>
<div class="upload-row">
<div class="upload-pv" :style="{ background: color }">{{ name[0]?.toLowerCase() || 'n' }}</div>
<div class="upload-meta">
<div class="upload-l">Square mark</div>
<Mono dim>nordic-mark.svg · 8 KB</Mono>
</div>
<UiButton size="sm" variant="ghost">Replace</UiButton>
</div>
</div>
</div>
<div class="field">
<Eyebrow>Primary color</Eyebrow>
<div class="color-row">
<div class="swatches">
<button
v-for="c in SWATCHES"
:key="c"
type="button"
class="sw"
:class="{ selected: color === c }"
:style="{ background: c }"
@click="color = c"
/>
</div>
<input v-model="color" class="hex" />
</div>
</div>
<div class="row-2">
<label class="field"><Eyebrow>Support email</Eyebrow><input v-model="supportEmail" /></label>
<label class="field"><Eyebrow>Support phone</Eyebrow><input v-model="supportPhone" /></label>
<label class="field"><Eyebrow>Website</Eyebrow><input v-model="website" /></label>
<label class="field"><Eyebrow>Reply-to address</Eyebrow><input v-model="replyTo" /></label>
</div>
<div class="preview">
<div class="pv-mark" :style="{ background: color }">{{ name[0]?.toLowerCase() || 'n' }}</div>
<div class="pv-meta">
<div class="pv-name">{{ name }}</div>
<Mono dim>preview · partner console header + email signature</Mono>
</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
<UiButton variant="primary" @click="emit('close')">
<template #leading><UiIcon name="check" :size="14" /></template>
Save identity
</UiButton>
</template>
</Modal>
</template>
<style scoped>
.form { display: flex; flex-direction: column; gap: 16px; }
.info {
display: flex;
gap: 10px;
padding: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.info p { font-size: 12px; color: var(--text-dim); margin: 0; line-height: 1.55; }
.info :deep(svg) { color: var(--text-mute); margin-top: 2px; flex-shrink: 0; }
.field { display: flex; flex-direction: column; gap: 6px; }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.field input, .hex {
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, .hex:focus { outline: none; border-color: var(--border-hi); }
.upload-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 8px; }
.upload-row {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.upload-pv {
width: 40px;
height: 40px;
border-radius: 6px;
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-weight: 700;
font-size: 16px;
}
.upload-meta { flex: 1; min-width: 0; }
.upload-l { font-size: 13px; font-weight: 500; }
.color-row { display: flex; align-items: center; gap: 10px; }
.swatches { display: flex; gap: 8px; }
.sw {
width: 32px;
height: 32px;
border-radius: 6px;
border: 1px solid var(--border);
cursor: pointer;
}
.sw.selected { border: 2px solid var(--text); }
.hex { flex: 1; font-family: var(--font-mono); }
.preview {
padding: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
display: flex;
align-items: center;
gap: 10px;
}
.pv-mark {
width: 28px;
height: 28px;
border-radius: 6px;
color: #fff;
font-family: var(--font-mono);
font-weight: 700;
font-size: 13px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.pv-name { font-size: 13px; font-weight: 500; }
</style>