Files
dezky/apps/portal/pages/admin/domains.vue
T
Ronni Baslund 0bd4e5498e 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
2026-05-28 20:00:33 +02:00

320 lines
14 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">
// 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>