Files
dezky/apps/portal/pages/admin/domains/add.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

431 lines
17 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 platform-flows.jsx `DomainSetupWizard` (lines 134-176) +
// step components 178-369. 6-step full-page route: Domain · Verify · Mail ·
// DKIM · DMARC · Done. Same step rail at the top, same DNS record rows and
// per-step copy.
const router = useRouter()
const step = ref(1)
const domain = ref('lyngby-biler.dk')
const policy = ref<'none' | 'quarantine' | 'reject'>('quarantine')
const steps = ['Domain', 'Verify', 'Mail', 'DKIM', 'DMARC', 'Done']
const dmarcValue = computed(() => `v=DMARC1; p=${policy.value}; rua=mailto:dmarc@${domain.value}; pct=100; adkim=s; aspf=s`)
const policyOptions = [
{ v: 'none' as const, l: 'none · monitor only', d: 'Reports failures but never blocks. Use only for the first 2 weeks while you confirm legitimate mail flows.' },
{ v: 'quarantine' as const, l: 'quarantine · recommended', d: 'Suspicious mail goes to spam. Catches almost all spoofing without breaking legitimate edge cases.' },
{ v: 'reject' as const, l: 'reject · strictest', d: "Suspicious mail is bounced. Use after you've been at quarantine for 30+ days with no surprises." },
]
function cancel() {
router.push('/admin/domains')
}
function done() {
router.push('/admin/domains')
}
</script>
<template>
<div class="wizard">
<div class="flow-head">
<div class="row top">
<div class="left">
<button v-if="step > 1 && step < 6" class="back" @click="step--">
<UiIcon name="chevLeft" :size="12" /> back
</button>
<Eyebrow>Add domain</Eyebrow>
</div>
<button class="cancel" @click="cancel">
<UiIcon name="x" :size="14" /> cancel
</button>
</div>
<div class="row title-row">
<h1>{{ step < 6 ? 'Verify and configure your domain' : `${domain} is ready` }}</h1>
<Mono dim>Step {{ step }} of 6</Mono>
</div>
<div class="rail">
<div v-for="(s, i) in steps" :key="s" class="rail-cell">
<div
class="bar"
:class="i + 1 < step ? 'done' : i + 1 === step ? 'active' : 'todo'"
/>
<div class="rail-label">
<Mono dim>0{{ i + 1 }}</Mono>
<span :class="i + 1 === step ? 'is-active' : i + 1 < step ? 'is-done' : 'is-todo'">{{ s }}</span>
</div>
</div>
</div>
</div>
<div class="body">
<!-- Step 1: Domain -->
<div v-if="step === 1" class="step1">
<p class="lead">
Enter the domain you'll use for mail and identity. You'll need to add a few DNS records to prove you own it and route mail correctly.
</p>
<label class="field">
<Eyebrow>Domain</Eyebrow>
<div class="input-wrap">
<UiIcon name="globe" :size="14" stroke="var(--text-mute)" />
<input v-model="domain" placeholder="acme.dk" />
</div>
</label>
<div class="info-box">
<Eyebrow>Need to know</Eyebrow>
<div class="info-body">
DNS changes typically propagate in 530 minutes<br />
You'll need access to your domain's DNS provider (Cloudflare, GoDaddy, etc.)<br />
For Danish .dk domains, you'll work with <Mono>DK-Hostmaster</Mono> or your registrar
</div>
</div>
</div>
<!-- Step 2: Verify -->
<div v-else-if="step === 2" class="step2">
<p class="lead">
Add this TXT record to <Mono>{{ domain }}</Mono>. We check every 30 seconds until it appears.
</p>
<div class="dns-rows">
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">TXT</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">_dezky-verify.{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">dezky-verify=8a3f9c2e-4b7d-4e1a-9c8f-2d6e1a3b5c7e</div></div>
<div class="dns-right">
<Badge tone="warn" dot>pending</Badge>
<button class="copy-btn"><UiIcon name="copy" :size="11" stroke="var(--text-mute)" /> COPY</button>
</div>
</div>
</div>
<div class="banner warn">
<UiIcon name="refresh" :size="14" stroke="var(--warn)" />
<div class="banner-body">
<div class="banner-title">Last check · 14:42:08 · still waiting</div>
<div class="banner-text">
We saw <Mono>NS · ns1.gratisdns.dk</Mono> but no TXT record at <Mono>_dezky-verify.{{ domain }}</Mono> yet. Add the record above and click verify, or wait — we'll check every 30 seconds.
</div>
</div>
<UiButton size="sm" variant="primary">Verify now</UiButton>
</div>
</div>
<!-- Step 3: Mail -->
<div v-else-if="step === 3" class="step3">
<p class="lead">
Add these records so mail to <Mono>@{{ domain }}</Mono> reaches dezky and outgoing mail is trusted.
</p>
<Eyebrow style="display: block; margin-top: 20px; margin-bottom: 10px">MX · inbound</Eyebrow>
<div class="dns-rows">
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">MX</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">10 inbound.mx.dezky.com</div></div>
<div class="dns-right"><Badge tone="ok" dot>verified</Badge></div>
</div>
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">MX</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">20 inbound-backup.mx.dezky.com</div></div>
<div class="dns-right"><Badge tone="ok" dot>verified</Badge></div>
</div>
</div>
<Eyebrow style="display: block; margin-top: 24px; margin-bottom: 10px">SPF · sender policy</Eyebrow>
<div class="dns-rows">
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">TXT</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">v=spf1 include:_spf.dezky.com -all</div></div>
<div class="dns-right"><Badge tone="ok" dot>verified</Badge></div>
</div>
</div>
<div class="banner ok">
<UiIcon name="check" :size="14" stroke="var(--ok)" :stroke-width="2.5" />
<div class="banner-body">
<div class="banner-title">Mail routing verified</div>
<div class="banner-text">All MX and SPF records resolve correctly. Test by sending mail to <Mono>postmaster@{{ domain }}</Mono>.</div>
</div>
</div>
</div>
<!-- Step 4: DKIM -->
<div v-else-if="step === 4" class="step4">
<p class="lead">
DKIM signs every outgoing email so Gmail and Outlook trust it. Two records, then we'll rotate the keys for you automatically every 90 days.
</p>
<Eyebrow style="display: block; margin-top: 20px; margin-bottom: 10px">DKIM · selector 1</Eyebrow>
<div class="dns-rows">
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">CNAME</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">dezky1._domainkey.{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">dezky1.dkim.dezky.com</div></div>
<div class="dns-right"><Badge tone="ok" dot>verified</Badge></div>
</div>
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">CNAME</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">dezky2._domainkey.{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">dezky2.dkim.dezky.com</div></div>
<div class="dns-right"><Badge tone="ok" dot>verified</Badge></div>
</div>
</div>
<div class="banner ok">
<UiIcon name="check" :size="14" stroke="var(--ok)" :stroke-width="2.5" />
<div class="banner-body">
<div class="banner-title">DKIM is signing</div>
<div class="banner-text">Selectors verified · key rotation enabled · next rotation 14 Aug 2026.</div>
</div>
</div>
</div>
<!-- Step 5: DMARC -->
<div v-else-if="step === 5" class="step5">
<p class="lead">
DMARC tells receiving servers what to do with email that fails authentication. We strongly recommend at least <Mono>quarantine</Mono>.
</p>
<Eyebrow style="display: block; margin-top: 20px; margin-bottom: 10px">Choose policy</Eyebrow>
<div class="policy-list">
<label v-for="p in policyOptions" :key="p.v" :class="{ active: policy === p.v }">
<span class="radio-dot"><span v-if="policy === p.v" /></span>
<input type="radio" :value="p.v" v-model="policy" />
<div>
<div class="policy-label">{{ p.l }}</div>
<div class="policy-d">{{ p.d }}</div>
</div>
</label>
</div>
<Eyebrow style="display: block; margin-top: 24px; margin-bottom: 10px">Add this record</Eyebrow>
<div class="dns-rows">
<div class="dns-row">
<div><Mono dim>TYPE</Mono><div class="dns-val">TXT</div></div>
<div><Mono dim>HOST</Mono><div class="dns-val">_dmarc.{{ domain }}</div></div>
<div><Mono dim>VALUE</Mono><div class="dns-val dim">{{ dmarcValue }}</div></div>
<div class="dns-right"><button class="copy-btn"><UiIcon name="copy" :size="11" stroke="var(--text-mute)" /> COPY</button></div>
</div>
</div>
</div>
<!-- Step 6: Done -->
<div v-else class="step6">
<div class="check-badge">
<UiIcon name="check" :size="36" :stroke-width="2.5" />
</div>
<h2>{{ domain }} is connected.</h2>
<p class="lead-center">
Mail is routing. DKIM is signing. DMARC is enforcing. You can now invite users on this domain and they'll receive working email immediately.
</p>
<div class="summary-grid">
<div v-for="k in ['MX', 'SPF', 'DKIM', 'DMARC']" :key="k" class="summary-cell">
<Badge tone="ok" dot>verified</Badge>
<Mono>{{ k }}</Mono>
</div>
</div>
</div>
</div>
<div class="footer">
<template v-if="step < 6">
<UiButton variant="ghost" @click="cancel">Save and exit</UiButton>
<div class="spacer" />
<UiButton v-if="step === 5" variant="secondary" @click="step = 6">Skip DMARC for now</UiButton>
<UiButton variant="primary" @click="step++">
<template v-if="step === 5" #leading><UiIcon name="check" :size="13" /></template>
{{ step === 1 ? 'Continue' : step === 5 ? 'Add DMARC & finish' : 'Verified · continue' }}
<template v-if="step < 5" #trailing><UiIcon name="arrowRight" :size="13" /></template>
</UiButton>
</template>
<template v-else>
<div class="spacer" />
<UiButton variant="secondary" @click="done">
<template #leading><UiIcon name="users" :size="13" /></template>
Invite users on this domain
</UiButton>
<UiButton variant="primary" @click="done">Back to domains</UiButton>
</template>
</div>
</div>
</template>
<style scoped>
.wizard { display: flex; flex-direction: column; min-height: 100%; }
.flow-head { border-bottom: 1px solid var(--border); }
.row { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
.row.top { padding: 14px 32px; }
.row.title-row { padding: 0 32px 18px 32px; align-items: flex-end; }
.left { display: flex; align-items: center; gap: 14px; }
.back, .cancel {
background: transparent;
border: none;
padding: 0;
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-mute);
font-size: 12px;
cursor: pointer;
font-family: var(--font-mono);
}
.cancel { padding: 6px; font-family: inherit; }
.row.title-row h1 {
font-family: var(--font-display);
font-weight: 600;
font-size: 28px;
letter-spacing: -0.025em;
margin: 0;
line-height: 1.05;
}
.rail { padding: 0 32px 18px 32px; display: flex; gap: 6px; }
.rail-cell { flex: 1; display: flex; flex-direction: column; gap: 6px; }
.bar { height: 3px; border-radius: 2px; }
.bar.done { background: var(--text); }
.bar.active { background: var(--accent); }
.bar.todo { background: var(--border); }
.rail-label { display: flex; align-items: center; gap: 6px; font-size: 12px; }
.is-active { font-weight: 600; color: var(--text); }
.is-done { color: var(--text); }
.is-todo { color: var(--text-mute); }
.body { flex: 1; padding: 24px 32px; max-width: 920px; margin: 0 auto; width: 100%; }
.lead { color: var(--text-dim); font-size: 14px; line-height: 1.6; margin-top: 0; }
.lead-center { color: var(--text-dim); font-size: 15px; line-height: 1.6; margin-top: 12px; max-width: 500px; margin-inline: auto; }
.field { display: flex; flex-direction: column; gap: 6px; max-width: 520px; }
.input-wrap {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
height: 36px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.input-wrap input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
.info-box {
margin-top: 18px;
padding: 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
max-width: 520px;
}
.info-body { margin-top: 10px; font-size: 13px; color: var(--text-dim); line-height: 1.65; }
.dns-rows { display: flex; flex-direction: column; gap: 8px; }
.dns-row {
display: grid;
grid-template-columns: 80px 220px 1fr 90px;
gap: 12px;
align-items: center;
padding: 12px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
}
.dns-val { font-family: var(--font-mono); font-size: 13px; font-weight: 600; margin-top: 2px; }
.dns-val.dim { color: var(--text-dim); font-weight: 400; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dns-right { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.copy-btn {
background: transparent;
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-dim);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
}
.banner {
margin-top: 16px;
padding: 14px;
border-radius: 6px;
display: flex;
align-items: flex-start;
gap: 12px;
}
.banner.warn { background: rgba(232, 154, 31, 0.06); border: 1px solid rgba(232, 154, 31, 0.24); border-left: 3px solid var(--warn); }
.banner.ok { background: rgba(31, 138, 91, 0.06); border: 1px solid rgba(31, 138, 91, 0.24); border-left: 3px solid var(--ok); }
.banner-body { flex: 1; font-size: 13px; }
.banner-title { font-weight: 600; }
.banner-text { color: var(--text-dim); margin-top: 4px; line-height: 1.5; }
.policy-list { display: flex; flex-direction: column; gap: 8px; }
.policy-list label {
display: flex;
gap: 14px;
padding: 14px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
cursor: pointer;
}
.policy-list label.active { background: var(--bg); border-color: var(--text); }
.policy-list input { display: none; }
.radio-dot {
width: 16px;
height: 16px;
border-radius: 999px;
border: 2px solid var(--border-hi, var(--border));
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2px;
}
.policy-list label.active .radio-dot { border-color: var(--text); }
.radio-dot span { width: 7px; height: 7px; border-radius: 999px; background: var(--text); }
.policy-label { font-size: 13px; font-weight: 600; }
.policy-d { font-size: 12px; color: var(--text-mute); margin-top: 4px; line-height: 1.5; }
.step6 { max-width: 680px; text-align: center; padding: 60px 0; margin: 0 auto; }
.check-badge {
width: 72px;
height: 72px;
border-radius: 16px;
background: var(--accent);
color: var(--accent-fg);
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
}
.step6 h2 {
font-family: var(--font-display);
font-weight: 600;
font-size: 36px;
letter-spacing: -0.025em;
margin: 0;
line-height: 1.05;
}
.summary-grid {
display: inline-grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-top: 36px;
padding: 16px 24px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
text-align: left;
}
.summary-cell { display: flex; align-items: center; gap: 6px; }
.footer {
border-top: 1px solid var(--border);
padding: 14px 32px;
display: flex;
align-items: center;
gap: 8px;
background: var(--surface);
position: sticky;
bottom: 0;
}
.spacer { flex: 1; }
</style>