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
+319
View File
@@ -0,0 +1,319 @@
<script setup lang="ts">
// Strict port of project/platform-app.jsx `DomainsScreen` (lines 440-585) +
// `DomainCard` (502) + `DomainRecordDetail` (586). Each domain card shows
// monospace name, status badge, "X records to fix" hint, Re-check button,
// and a 4-record grid (MX/SPF/DKIM/DMARC) clickable to expand inline detail.
import { sampleDomainsFlat } from '~/data/workspace'
const router = useRouter()
const toast = useToast()
type Tone = 'ok' | 'warn' | 'bad'
type RecordKey = 'mx' | 'spf' | 'dkim' | 'dmarc'
// DNS_FIX (platform-app.jsx line 459) — copy strings, record values, per-status headlines.
const DNS_FIX: Record<RecordKey, {
label: string
purpose: string
record: { type: string; host: string; value: string; priority?: number; ttl: number }
states: Record<Tone, { headline: string; body: string }>
}> = {
mx: {
label: 'MX · mail exchange',
purpose: 'Routes inbound mail for this domain to dezky.',
record: { type: 'MX', host: '@', value: 'mx.dezky.com', priority: 10, ttl: 3600 },
states: {
ok: { headline: 'Mail routing healthy', body: 'Inbound mail flows to dezky correctly. Verified 4 minutes ago.' },
warn: { headline: 'Lower-priority MX detected', body: 'A secondary MX outside of dezky was found. This is allowed for failover but make sure it forwards back to mx.dezky.com.' },
bad: { headline: 'No MX record found', body: 'Mail to this domain will not reach dezky. Add the record below at your DNS provider.' },
},
},
spf: {
label: 'SPF · sender policy',
purpose: 'Tells receiving servers which IPs are allowed to send for this domain.',
record: { type: 'TXT', host: '@', value: 'v=spf1 include:_spf.dezky.com -all', ttl: 3600 },
states: {
ok: { headline: 'SPF aligned', body: 'Your SPF record correctly authorises dezky as a sender. Verified 4 minutes ago.' },
warn: { headline: 'SPF includes dezky but ends with ~all (softfail)', body: 'Receiving mail servers may still accept spoofed mail. Change the trailing mechanism to -all (hardfail) for stronger protection.' },
bad: { headline: 'No SPF record', body: 'Mail sent from this domain via dezky will fail Gmail/Outlook authentication.' },
},
},
dkim: {
label: 'DKIM · message signing',
purpose: 'Cryptographic signature proving the message was not altered in transit.',
record: { type: 'CNAME', host: 'dezky._domainkey', value: 'dkim.dezky.com', ttl: 3600 },
states: {
ok: { headline: 'DKIM signing live', body: 'Outbound mail is signed with selector dezky. Verified 4 minutes ago.' },
warn: { headline: 'DKIM CNAME points somewhere else', body: 'A DKIM record exists but does not delegate to dezky. Replace it with the CNAME below.' },
bad: { headline: 'No DKIM record', body: 'Outbound mail will be signed but receiving servers cannot verify the signature.' },
},
},
dmarc: {
label: 'DMARC · policy enforcement',
purpose: 'Tells receiving servers what to do with mail that fails SPF or DKIM.',
record: { type: 'TXT', host: '_dmarc', value: 'v=DMARC1; p=quarantine; rua=mailto:dmarc@dezky.com; pct=100; adkim=s; aspf=s', ttl: 3600 },
states: {
ok: { headline: 'DMARC at quarantine', body: 'Spoofed mail will be sent to spam at Gmail/Outlook. Aggregate reports flowing.' },
warn: { headline: 'DMARC at p=none', body: 'Youre collecting reports but not enforcing. Raise to quarantine once your SPF/DKIM look stable for a week.' },
bad: { headline: 'No DMARC record', body: 'Anyone can spoof this domain. Mail from this domain may fail Gmail / Outlook spam checks.' },
},
},
}
const expanded = reactive<Record<string, RecordKey | null>>({})
const copied = ref<string | null>(null)
function toggle(domain: string, key: RecordKey) {
expanded[domain] = expanded[domain] === key ? null : key
}
function copyValue(text: string) {
if (typeof navigator !== 'undefined' && navigator.clipboard) navigator.clipboard.writeText(text)
copied.value = text
setTimeout(() => { if (copied.value === text) copied.value = null }, 1400)
toast.ok('Copied to clipboard')
}
function issuesFor(d: typeof sampleDomainsFlat[number]) {
return (['mx', 'spf', 'dkim', 'dmarc'] as const).filter((k) => d[k] !== 'ok')
}
function statusIcon(tone: Tone): 'check' | 'shield' | 'x' {
return tone === 'ok' ? 'check' : tone === 'warn' ? 'shield' : 'x'
}
function recordTint(tone: Tone) {
return tone === 'bad' ? 'rgba(226,48,48,0.12)'
: tone === 'warn' ? 'rgba(232,154,31,0.12)'
: 'rgba(91,140,90,0.12)'
}
</script>
<template>
<div>
<PageHeader
eyebrow="Identity"
title="Domains"
subtitle="Your verified domains for mail, SSO, and user provisioning."
>
<template #actions>
<UiButton variant="primary" @click="router.push('/admin/domains/add')">
<template #leading><UiIcon name="plus" :size="14" /></template>
Add domain
</UiButton>
</template>
</PageHeader>
<div class="content">
<Card v-for="d in sampleDomainsFlat" :key="d.domain">
<div class="head">
<UiIcon name="globe" :size="20" stroke="var(--text-mute)" />
<div class="title">
<div class="domain-name">{{ d.domain }}</div>
<div class="domain-sub">
{{ d.users }} mailboxes
<template v-if="issuesFor(d).length">
· <span class="warn">{{ issuesFor(d).length }} record{{ issuesFor(d).length === 1 ? '' : 's' }} to fix</span>
</template>
</div>
</div>
<UiButton v-if="issuesFor(d).length" size="sm" variant="secondary" @click.stop="toast.ok('Re-checking ' + d.domain)">
<template #leading><UiIcon name="refresh" :size="12" /></template>
Re-check now
</UiButton>
<Badge :tone="d.status === 'ok' ? 'ok' : 'warn'" dot>{{ d.status === 'ok' ? 'verified' : 'attention' }}</Badge>
</div>
<div class="records">
<button
v-for="k in (['mx', 'spf', 'dkim', 'dmarc'] as RecordKey[])"
:key="k"
class="rec"
:class="{ active: expanded[d.domain] === k }"
@click="toggle(d.domain, k)"
>
<Mono>{{ k.toUpperCase() }}</Mono>
<div class="rec-right">
<Badge :tone="d[k]" dot>{{ d[k] }}</Badge>
<UiIcon :name="expanded[d.domain] === k ? 'chevDown' : 'chevRight'" :size="11" stroke="var(--text-mute)" />
</div>
</button>
</div>
<div v-if="expanded[d.domain]" class="detail" :data-tone="d[expanded[d.domain]!]">
<div class="detail-head">
<div class="detail-icon" :style="{ background: recordTint(d[expanded[d.domain]!] as Tone), color: `var(--${d[expanded[d.domain]!]})` }">
<UiIcon :name="statusIcon(d[expanded[d.domain]!] as Tone)" :size="14" :stroke-width="d[expanded[d.domain]!] === 'ok' ? 2.5 : 2" />
</div>
<div class="detail-body">
<div class="detail-title">
{{ DNS_FIX[expanded[d.domain]!].states[d[expanded[d.domain]!] as Tone].headline }}
<Mono dim>{{ DNS_FIX[expanded[d.domain]!].label }}</Mono>
</div>
<div class="detail-text">{{ DNS_FIX[expanded[d.domain]!].states[d[expanded[d.domain]!] as Tone].body }}</div>
<Mono dim style="display: block; margin-top: 10px">{{ DNS_FIX[expanded[d.domain]!].purpose }}</Mono>
</div>
<button class="detail-close" @click="expanded[d.domain] = null"><UiIcon name="x" :size="14" /></button>
</div>
<template v-if="d[expanded[d.domain]!] !== 'ok'">
<div class="rec-action">
<Eyebrow>Add this record at your DNS provider</Eyebrow>
<div class="rec-grid">
<div class="rec-grid-label">Type</div>
<div class="rec-grid-val">{{ DNS_FIX[expanded[d.domain]!].record.type }}</div>
<div class="rec-grid-ttl">TTL {{ DNS_FIX[expanded[d.domain]!].record.ttl }}</div>
<div class="rec-grid-label sep">Host</div>
<div class="rec-grid-span sep">
<span>{{ DNS_FIX[expanded[d.domain]!].record.host }} <span class="muted">· resolves to {{ DNS_FIX[expanded[d.domain]!].record.host === '@' ? d.domain : `${DNS_FIX[expanded[d.domain]!].record.host}.${d.domain}` }}</span></span>
<button class="copy" @click="copyValue(DNS_FIX[expanded[d.domain]!].record.host)"><UiIcon name="copy" :size="12" /></button>
</div>
<div class="rec-grid-label sep">Value</div>
<div class="rec-grid-span sep">
<span class="break">{{ DNS_FIX[expanded[d.domain]!].record.value }}</span>
<button class="copy" @click="copyValue(DNS_FIX[expanded[d.domain]!].record.value)"><UiIcon name="copy" :size="12" /></button>
</div>
<template v-if="DNS_FIX[expanded[d.domain]!].record.priority !== undefined">
<div class="rec-grid-label sep">Priority</div>
<div class="rec-grid-span sep">{{ DNS_FIX[expanded[d.domain]!].record.priority }}</div>
</template>
</div>
<div class="rec-actions-row">
<UiButton size="sm" variant="primary" @click="copyValue(DNS_FIX[expanded[d.domain]!].record.value)">
<template #leading><UiIcon name="copy" :size="13" /></template>
{{ copied === DNS_FIX[expanded[d.domain]!].record.value ? 'Copied · paste at your DNS provider' : 'Copy record value' }}
</UiButton>
<UiButton size="sm" variant="secondary" @click="toast.info('Opening DNS provider guide…')">
<template #leading><UiIcon name="external" :size="13" /></template>
Open DNS guide
</UiButton>
<UiButton size="sm" variant="ghost" @click="toast.ok('Re-checking record')">
<template #leading><UiIcon name="refresh" :size="13" /></template>
Re-check this record
</UiButton>
<div class="spacer" />
<Mono dim>changes can take up to 24h to propagate</Mono>
</div>
</div>
</template>
<template v-else>
<div class="currently-set">
<Eyebrow>Currently set</Eyebrow>
<div class="set-value">{{ DNS_FIX[expanded[d.domain]!].record.value }}</div>
</div>
</template>
</div>
</Card>
</div>
</div>
</template>
<style scoped>
.content { padding: 24px 40px 64px 40px; display: flex; flex-direction: column; gap: 12px; }
.head { display: flex; align-items: center; gap: 16px; }
.title { flex: 1; min-width: 0; }
.domain-name { font-family: var(--font-mono); font-size: 16px; font-weight: 600; }
.domain-sub { font-size: 12px; color: var(--text-mute); margin-top: 2px; }
.warn { color: var(--warn); }
.records {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
.rec {
padding: 10px 12px;
background: var(--bg);
border-radius: 6px;
cursor: pointer;
border: 1px solid transparent;
display: flex;
align-items: center;
justify-content: space-between;
font-family: inherit;
text-align: left;
transition: background 120ms, border-color 120ms;
}
.rec:hover { background: var(--surface); }
.rec.active { background: var(--surface); border-color: var(--text); }
.rec-right { display: flex; align-items: center; gap: 6px; }
.detail {
margin-top: 16px;
padding: 16px;
background: var(--bg);
border-radius: 6px;
border: 1px solid var(--border);
border-left: 3px solid var(--border);
}
.detail[data-tone='ok'] { border-left-color: var(--ok); }
.detail[data-tone='warn'] { border-left-color: var(--warn); }
.detail[data-tone='bad'] { border-left-color: var(--bad); }
.detail-head { display: flex; align-items: flex-start; gap: 12px; }
.detail-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.detail-body { flex: 1; }
.detail-title {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-family: var(--font-display);
font-weight: 600;
font-size: 15px;
}
.detail-text { font-size: 13px; color: var(--text-dim); margin-top: 6px; line-height: 1.55; }
.detail-close { background: transparent; border: none; padding: 4px; border-radius: 4px; color: var(--text-mute); cursor: pointer; }
.rec-action { margin-top: 16px; }
.rec-grid {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
display: grid;
grid-template-columns: 70px 1fr 80px;
font-family: var(--font-mono);
font-size: 12px;
overflow: hidden;
margin-top: 8px;
}
.rec-grid-label { padding: 10px 12px; color: var(--text-mute); border-right: 1px solid var(--border); }
.rec-grid-label.sep { border-top: 1px solid var(--border); }
.rec-grid-val { padding: 10px 12px; border-right: 1px solid var(--border); }
.rec-grid-ttl { padding: 10px 12px; color: var(--text-mute); }
.rec-grid-span {
padding: 10px 12px;
grid-column: 2 / 4;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.rec-grid-span.sep { border-top: 1px solid var(--border); }
.break { word-break: break-all; }
.muted { color: var(--text-mute); }
.copy { background: transparent; border: none; padding: 4px; border-radius: 4px; color: var(--text-mute); cursor: pointer; }
.copy:hover { background: var(--bg); }
.rec-actions-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
.spacer { flex: 1; }
.currently-set { margin-top: 12px; padding: 12px; background: var(--surface); border-radius: 6px; border: 1px solid var(--border); }
.set-value { font-family: var(--font-mono); font-size: 12px; color: var(--text-dim); word-break: break-all; margin-top: 4px; }
</style>