47eb9502f8
Wire the mail/identity stack to real Stalwart/Authentik/OCIS provisioning, replacing the mocked Domains and Users pages. Domains (customer-admin): - StalwartClient: real JMAP management (v0.16 dropped REST) — create/list/delete email domains via x:Domain at the internal http://stalwart:8080 listener; DKIM auto-generated; the records to publish are read from the domain's dnsZoneFile. Gated by STALWART_PROVISIONING_ENABLED. - New Domain collection + DomainsModule: add/list/recheck/set-DMARC/remove, tenant-membership-gated and audited. - DnsVerifierService: verifies MX/SPF/DKIM/DMARC/ownership against a public resolver (1.1.1.1/8.8.8.8) and diffs them against the expected records. - Remove is guarded: refuses while accounts/aliases/mailing lists still use the domain (via Stalwart referential integrity). - Domains page + add wizard on real data; sidebar badge counts domains needing attention. Users & groups (customer-admin): - Create a member provisioned across Authentik SSO, a Stalwart mailbox on the tenant's primary domain, and OCIS — returning a one-time password. - Lifecycle: suspend/resume (Authentik is_active + freeze the mailbox via account permissions, original password preserved), force-logout (terminate sessions, filtered client-side so it can never end other users' sessions), reset password (new one-time password on SSO + mailbox), and remove (tear down mailbox + SSO identity + OCIS + doc; mailbox-in-use aware for multi-tenant users). Self-suspend / self-force-logout are blocked. Infra: point platform-api at the internal Stalwart listener; document the new STALWART_/provisioning vars in .env.example.
492 lines
20 KiB
Vue
492 lines
20 KiB
Vue
<script setup lang="ts">
|
||
// Add-domain wizard, wired to platform-api. 6 full-page steps:
|
||
// 1 Domain — POST the domain (provisions it in Stalwart, which auto-creates
|
||
// DKIM keys and returns the records to publish + an ownership token)
|
||
// 2 Verify — poll the ownership TXT until it resolves
|
||
// 3 Mail — show + re-check the MX/SPF records
|
||
// 4 DKIM — show + re-check the DKIM record(s)
|
||
// 5 DMARC — pick a policy (PATCH) and re-check
|
||
// 6 Done — summary of live status
|
||
// All record values come from the server; only the guidance copy is static.
|
||
|
||
import type { DmarcPolicy, DomainRecordView, DomainView, RecordKind, RecordStatus } from '~/composables/useDomains'
|
||
|
||
const router = useRouter()
|
||
const toast = useToast()
|
||
const { add, recheck, setDmarcPolicy } = useDomains()
|
||
|
||
const step = ref(1)
|
||
const domainInput = ref('')
|
||
const dv = ref<DomainView | null>(null)
|
||
const busy = ref(false)
|
||
const policy = ref<DmarcPolicy>('quarantine')
|
||
const steps = ['Domain', 'Verify', 'Mail', 'DKIM', 'DMARC', 'Done']
|
||
|
||
const domainName = computed(() => dv.value?.domain ?? domainInput.value)
|
||
|
||
type Tone = 'ok' | 'warn' | 'bad'
|
||
function tone(status: RecordStatus): Tone {
|
||
return status === 'ok' ? 'ok' : status === 'warn' ? 'warn' : 'bad'
|
||
}
|
||
function recordsOfKind(kind: RecordKind): DomainRecordView[] {
|
||
return dv.value?.records.filter((r) => r.kind === kind) ?? []
|
||
}
|
||
const ownershipRecord = computed(() => recordsOfKind('ownership')[0])
|
||
const ownershipOk = computed(() => dv.value?.checks.ownership === 'ok')
|
||
|
||
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 toastError(err: unknown, title: string) {
|
||
const e = err as { data?: { message?: string | string[] }; message?: string }
|
||
const msg = e?.data?.message ?? e?.message ?? 'Unknown error'
|
||
toast.bad(title, Array.isArray(msg) ? msg.join(', ') : msg)
|
||
}
|
||
|
||
// Step 1 → create the domain.
|
||
async function createDomain() {
|
||
const name = domainInput.value.trim().toLowerCase()
|
||
if (!name) return
|
||
busy.value = true
|
||
try {
|
||
dv.value = await add(name)
|
||
step.value = 2
|
||
} catch (err) {
|
||
toastError(err, 'Could not add domain')
|
||
} finally {
|
||
busy.value = false
|
||
}
|
||
}
|
||
|
||
// Re-run DNS checks and refresh the wizard's domain snapshot.
|
||
async function recheckNow() {
|
||
if (!dv.value) return
|
||
busy.value = true
|
||
try {
|
||
dv.value = await recheck(dv.value.domain)
|
||
} catch (err) {
|
||
toastError(err, 'Could not re-check')
|
||
} finally {
|
||
busy.value = false
|
||
}
|
||
}
|
||
|
||
// Step 5 → persist the DMARC policy, then finish.
|
||
async function finishWithDmarc() {
|
||
if (!dv.value) { step.value = 6; return }
|
||
busy.value = true
|
||
try {
|
||
dv.value = await setDmarcPolicy(dv.value.domain, policy.value)
|
||
step.value = 6
|
||
} catch (err) {
|
||
toastError(err, 'Could not set DMARC policy')
|
||
} finally {
|
||
busy.value = false
|
||
}
|
||
}
|
||
|
||
// While on the Verify step, poll for the ownership TXT every 10s until it lands.
|
||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||
function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null } }
|
||
watch([step, ownershipOk], ([s, ok]) => {
|
||
stopPoll()
|
||
if (s === 2 && !ok) {
|
||
pollTimer = setInterval(() => { if (!busy.value) recheckNow() }, 10000)
|
||
}
|
||
})
|
||
onBeforeUnmount(stopPoll)
|
||
|
||
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' : `${domainName} 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="domainInput" placeholder="acme.dk" @keyup.enter="createDomain" />
|
||
</div>
|
||
</label>
|
||
<div class="info-box">
|
||
<Eyebrow>Need to know</Eyebrow>
|
||
<div class="info-body">
|
||
• DNS changes typically propagate in 5–30 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 ownership -->
|
||
<div v-else-if="step === 2" class="step2">
|
||
<p class="lead">
|
||
Add this TXT record to <Mono>{{ domainName }}</Mono>. We check every 10 seconds until it appears.
|
||
</p>
|
||
<div v-if="ownershipRecord" class="dns-rows">
|
||
<div class="dns-row">
|
||
<div><Mono dim>TYPE</Mono><div class="dns-val">{{ ownershipRecord.type }}</div></div>
|
||
<div><Mono dim>HOST</Mono><div class="dns-val">{{ ownershipRecord.fqdn }}</div></div>
|
||
<div><Mono dim>VALUE</Mono><div class="dns-val dim">{{ ownershipRecord.expected }}</div></div>
|
||
<div class="dns-right">
|
||
<Badge :tone="ownershipOk ? 'ok' : 'warn'" dot>{{ ownershipOk ? 'verified' : 'pending' }}</Badge>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="banner" :class="ownershipOk ? 'ok' : 'warn'">
|
||
<UiIcon :name="ownershipOk ? 'check' : 'refresh'" :size="14" :stroke="ownershipOk ? 'var(--ok)' : 'var(--warn)'" />
|
||
<div class="banner-body">
|
||
<div class="banner-title">{{ ownershipOk ? 'Ownership verified' : 'Waiting for the TXT record' }}</div>
|
||
<div class="banner-text">
|
||
{{ ownershipOk
|
||
? 'We found the verification record. Continue to set up mail.'
|
||
: 'Add the record above, then click verify — or wait, we re-check automatically every 10 seconds.' }}
|
||
</div>
|
||
</div>
|
||
<UiButton size="sm" variant="primary" :disabled="busy" @click="recheckNow">{{ busy ? 'Checking…' : 'Verify now' }}</UiButton>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 3: Mail (MX + SPF) -->
|
||
<div v-else-if="step === 3" class="step3">
|
||
<p class="lead">
|
||
Add these records so mail to <Mono>@{{ domainName }}</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">
|
||
<RecordRow v-for="(r, i) in recordsOfKind('mx')" :key="'mx' + i" :rec="r" />
|
||
<div v-if="!recordsOfKind('mx').length" class="empty-note">No MX record yet — re-check after mail provisioning completes.</div>
|
||
</div>
|
||
<Eyebrow style="display: block; margin-top: 24px; margin-bottom: 10px">SPF · sender policy</Eyebrow>
|
||
<div class="dns-rows">
|
||
<RecordRow v-for="(r, i) in recordsOfKind('spf')" :key="'spf' + i" :rec="r" />
|
||
</div>
|
||
<div class="banner" :class="dv && dv.checks.mx === 'ok' && dv.checks.spf === 'ok' ? 'ok' : 'warn'">
|
||
<UiIcon :name="dv && dv.checks.mx === 'ok' && dv.checks.spf === 'ok' ? 'check' : 'refresh'" :size="14" :stroke="dv && dv.checks.mx === 'ok' && dv.checks.spf === 'ok' ? 'var(--ok)' : 'var(--warn)'" />
|
||
<div class="banner-body">
|
||
<div class="banner-title">{{ dv && dv.checks.mx === 'ok' && dv.checks.spf === 'ok' ? 'Mail routing verified' : 'Waiting for MX / SPF' }}</div>
|
||
<div class="banner-text">Add the records above. You can continue now and re-check from the Domains page later.</div>
|
||
</div>
|
||
<UiButton size="sm" variant="secondary" :disabled="busy" @click="recheckNow">{{ busy ? 'Checking…' : 'Re-check' }}</UiButton>
|
||
</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. We rotate the keys for you automatically.
|
||
</p>
|
||
<Eyebrow style="display: block; margin-top: 20px; margin-bottom: 10px">DKIM · message signing</Eyebrow>
|
||
<div class="dns-rows">
|
||
<RecordRow v-for="(r, i) in recordsOfKind('dkim')" :key="'dkim' + i" :rec="r" />
|
||
<div v-if="!recordsOfKind('dkim').length" class="empty-note">No DKIM record yet — re-check after mail provisioning completes.</div>
|
||
</div>
|
||
<div class="banner" :class="dv && dv.checks.dkim === 'ok' ? 'ok' : 'warn'">
|
||
<UiIcon :name="dv && dv.checks.dkim === 'ok' ? 'check' : 'refresh'" :size="14" :stroke="dv && dv.checks.dkim === 'ok' ? 'var(--ok)' : 'var(--warn)'" />
|
||
<div class="banner-body">
|
||
<div class="banner-title">{{ dv && dv.checks.dkim === 'ok' ? 'DKIM is signing' : 'Waiting for DKIM' }}</div>
|
||
<div class="banner-text">Publish the record(s) above. Both selectors must be present for full coverage.</div>
|
||
</div>
|
||
<UiButton size="sm" variant="secondary" :disabled="busy" @click="recheckNow">{{ busy ? 'Checking…' : 'Re-check' }}</UiButton>
|
||
</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.{{ domainName }}</div></div>
|
||
<div><Mono dim>VALUE</Mono><div class="dns-val dim">v=DMARC1; p={{ policy }}; rua=mailto:postmaster@{{ domainName }}</div></div>
|
||
<div class="dns-right">
|
||
<Badge :tone="dv && dv.checks.dmarc === 'ok' ? 'ok' : 'warn'" dot>{{ dv ? dv.checks.dmarc : 'pending' }}</Badge>
|
||
</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>{{ domainName }} is connected.</h2>
|
||
<p class="lead-center">
|
||
The domain is provisioned. Publish any remaining DNS records and they'll go green automatically — you can track status from the Domains page.
|
||
</p>
|
||
<div v-if="dv" class="summary-grid">
|
||
<div v-for="k in (['mx','spf','dkim','dmarc'] as const)" :key="k" class="summary-cell">
|
||
<Badge :tone="dv.checks[k] === 'ok' ? 'ok' : 'warn'" dot>{{ dv.checks[k] }}</Badge>
|
||
<Mono>{{ k.toUpperCase() }}</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" :disabled="busy" @click="step = 6">Skip DMARC for now</UiButton>
|
||
<UiButton
|
||
v-if="step === 1"
|
||
variant="primary"
|
||
:disabled="busy || !domainInput.trim()"
|
||
@click="createDomain"
|
||
>
|
||
{{ busy ? 'Adding…' : 'Continue' }}
|
||
<template #trailing><UiIcon name="arrowRight" :size="13" /></template>
|
||
</UiButton>
|
||
<UiButton
|
||
v-else-if="step === 2"
|
||
variant="primary"
|
||
:disabled="!ownershipOk"
|
||
@click="step = 3"
|
||
>
|
||
{{ ownershipOk ? 'Verified · continue' : 'Waiting for verification' }}
|
||
<template #trailing><UiIcon name="arrowRight" :size="13" /></template>
|
||
</UiButton>
|
||
<UiButton v-else-if="step === 5" variant="primary" :disabled="busy" @click="finishWithDmarc">
|
||
<template #leading><UiIcon name="check" :size="13" /></template>
|
||
{{ busy ? 'Saving…' : 'Add DMARC & finish' }}
|
||
</UiButton>
|
||
<UiButton v-else variant="primary" @click="step++">
|
||
Continue
|
||
<template #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; }
|
||
.empty-note { font-size: 12px; color: var(--text-mute); padding: 10px 2px; font-family: var(--font-mono); }
|
||
|
||
.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>
|