Menu
Public repository / 1c2217cda20adcbcf6a4d0b6e8aa22d39f062555
AkurAI-Mail / src/routes/(admin)/domains/+page.svelte
Latest change 7e5943688916eea977777e3b59ed3ae7ac708f3b - feat: add domain autopilot controls by Ólafur Búi Ólafsson
<script lang="ts">
import { invalidateAll } from "$app/navigation";
import { Bot, Plus, RefreshCcw, SearchCheck, ServerCog, Trash2, X } from "lucide-svelte";
let { data } = $props();
let domains = $derived(data.domainData.domains ?? []);
let status = $derived(data.status);
let newDomain = $state("");
let result = $state<any>(null);
let busy = $state(false);
let checking = $state<string | null>(null);
let configuring = $state<string | null>(null);
let checkResult = $state<any>(null);
async function refresh() {
await invalidateAll();
}
async function action(payload: Record<string, any>) {
busy = true;
result = null;
try {
const response = await fetch("/api/actions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload)
});
result = await response.json();
if (result.ok) await refresh();
} finally {
busy = false;
}
}
async function checkDns(domain: string) {
checking = domain;
checkResult = null;
try {
const response = await fetch("/api/actions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action: "domain-check", domain })
});
checkResult = await response.json();
} finally {
checking = null;
}
}
async function runAutopilot(domain: string) {
configuring = domain;
result = null;
try {
const response = await fetch("/api/actions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action: "domain-autopilot", domain })
});
result = await response.json();
if (result.ok) await refresh();
} finally {
configuring = null;
}
}
function checkText(check: any) {
return (check.values ?? []).join(", ") || check.detail || check.error || "no answer";
}
function resultTitle(value: any) {
if (value?.verified) return "Domain verified";
if (value?.configured) return "Domain configured";
return "Domain needs attention";
}
</script>
<svelte:head><title>Domains - AkurAI Mail Admin</title></svelte:head>
<header class="topbar">
<div>
<p class="kicker">Domain management</p>
<h1>Mail domains</h1>
<p>Configure domains hosted on {status.hostname}</p>
</div>
<button class="primary" onclick={refresh} disabled={busy}>
<RefreshCcw size={17} aria-hidden="true" />
<span>Refresh</span>
</button>
</header>
{#if result?.ok && result?.dnsRecords}
<section class="notice success" aria-live="polite">
<strong>{resultTitle(result)}</strong>
<span>{result.domain}: {result.status}. Publish any missing DNS records shown below.</span>
</section>
{:else if result?.ok && result?.removed}
<section class="notice success" aria-live="polite">
<strong>Domain removed</strong>
<span>{result.domain} has been removed</span>
</section>
{:else if result?.error}
<section class="notice error" role="alert">
<strong>Action failed</strong>
<span>{result.error}</span>
</section>
{/if}
{#if checkResult}
<section class="panel check-result">
<div class="panel-head">
<div>
<h2>DNS check: {checkResult.domain}</h2>
<p>Status: <strong class:verified={checkResult.status === "verified"} class:configured={checkResult.status === "configured"} class:attention={checkResult.status === "attention"}>{checkResult.status}</strong></p>
</div>
<button class="secondary" onclick={() => { checkResult = null; }}>
<X size={16} aria-hidden="true" />
<span>Dismiss</span>
</button>
</div>
<h3>Server configuration</h3>
<div class="health-list">
{#each checkResult.localChecks ?? [] as check}
<div>
<b class:ok={check.ok}>{check.ok ? "OK" : "Fix"}</b>
<span>{check.name}</span>
<code>{checkText(check)}</code>
</div>
{/each}
</div>
<h3>DNS verification</h3>
<div class="health-list">
{#each checkResult.dnsChecks ?? [] as check}
<div>
<b class:ok={check.ok}>{check.ok ? "OK" : "Missing"}</b>
<span>{check.type} {check.name}</span>
<code>{checkText(check)}</code>
</div>
{/each}
</div>
</section>
{/if}
<section class="panel">
<div class="panel-head">
<div>
<h2>Add domain</h2>
<p>Register a domain, repair local mail-server config, generate DKIM, and verify public DNS.</p>
</div>
</div>
<form onsubmit={(e) => { e.preventDefault(); action({ action: "domain-autopilot", domain: newDomain }); newDomain = ""; }}>
<label>
<span>Domain name</span>
<input bind:value={newDomain} type="text" placeholder="example.com" required pattern="^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)+$" />
</label>
<button class="primary" disabled={busy}>
<Plus size={17} aria-hidden="true" />
<span>Configure domain</span>
</button>
</form>
</section>
{#each domains as d}
<section class="panel domain-panel">
<div class="panel-head">
<div>
<h2>{d.domain}</h2>
<p>
<span class="status-badge" class:verified={d.status === "verified"} class:configured={d.status === "configured"} class:attention={d.status === "attention"}>{d.status}</span>
{#if d.addedAt}
Added {new Date(d.addedAt * 1000).toLocaleDateString()}
{:else}
Primary domain
{/if}
</p>
</div>
<div class="domain-actions">
<button class="primary compact" onclick={() => runAutopilot(d.domain)} disabled={configuring === d.domain}>
<Bot size={16} aria-hidden="true" />
{configuring === d.domain ? "Configuring..." : "Configure & verify"}
</button>
<button class="secondary" onclick={() => checkDns(d.domain)} disabled={checking === d.domain}>
<SearchCheck size={16} aria-hidden="true" />
{checking === d.domain ? "Checking..." : "Verify DNS"}
</button>
{#if d.domain !== status.domain}
<button class="danger" onclick={() => action({ action: "domain-remove", domain: d.domain })} disabled={busy}>
<Trash2 size={16} aria-hidden="true" />
<span>Remove</span>
</button>
{/if}
</div>
</div>
<div class="dns-section">
<h3><ServerCog size={16} aria-hidden="true" /> Server configuration</h3>
<div class="health-list">
{#each d.localChecks ?? [] as check}
<div>
<b class:ok={check.ok}>{check.ok ? "OK" : "Fix"}</b>
<span>{check.name}</span>
<code>{checkText(check)}</code>
</div>
{/each}
</div>
</div>
<div class="dns-section">
<h3>Required DNS records</h3>
<p class="dns-hint">Add these records at your domain registrar's DNS settings. All records are for the domain <strong>{d.domain}</strong>.</p>
<div class="records">
{#each d.dnsRecords ?? [] as record}
<div>
<b>{record.type}</b>
<span>{record.name}</span>
<code>{record.value}</code>
</div>
{/each}
</div>
</div>
</section>
{:else}
<section class="panel">
<p class="empty">No domains configured. Add your first domain above.</p>
</section>
{/each}
<style>
.topbar{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;min-width:0}
.kicker{margin:0 0 6px;color:#126b5a;font-size:13px;font-weight:800}.topbar h1{margin:0;font-size:30px;line-height:1.15;text-wrap:balance}.topbar p:last-child{margin:6px 0 0;color:#61716c;overflow-wrap:anywhere}
.primary,.secondary,.danger{display:inline-flex;align-items:center;justify-content:center;gap:7px;white-space:nowrap}
.primary{border:0;border-radius:6px;background:#126b5a;color:#fff;min-height:44px;padding:10px 15px;font-weight:800;cursor:pointer}.primary:hover{background:#0e594b}.primary:disabled{opacity:.55;cursor:not-allowed}
.primary.compact{min-height:38px;padding:8px 11px}
.secondary{border:1px solid #cbd8d4;border-radius:6px;background:#fff;color:#126b5a;min-height:38px;padding:8px 11px;font-weight:800;cursor:pointer}.secondary:hover{background:#eef7f4}.secondary:disabled{opacity:.55;cursor:not-allowed}
.danger{border:1px solid #e8b4ae;border-radius:6px;background:#fff;color:#9a473a;min-height:38px;padding:8px 11px;font-weight:800;cursor:pointer}.danger:hover{background:#fef2f0}.danger:disabled{opacity:.55;cursor:not-allowed}
.panel,.notice{background:#fff;border:1px solid #dce4e1;border-radius:8px;min-width:0}
.panel{padding:18px}.panel-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:15px}.panel h2{margin:0;font-size:18px}.panel p{margin:4px 0 0;color:#61716c}
.domain-actions{display:flex;gap:8px;flex-shrink:0}
.dns-section{border-top:1px solid #edf1ef;padding-top:14px;margin-top:4px}
.dns-section h3{margin:0 0 6px;font-size:14px;color:#33413d;display:flex;align-items:center;gap:6px}.dns-hint{margin:0 0 12px;color:#61716c;font-size:13px}.dns-hint strong{color:#151d1b}
form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;margin-bottom:14px}label{display:grid;gap:6px;min-width:0}label span{font-size:13px;font-weight:800;color:#33413d}input{width:100%;min-width:0;border:1px solid #cbd8d4;border-radius:6px;padding:11px;background:#fff;color:#151d1b}input:focus{outline:3px solid #b7e7da;border-color:#126b5a}
.records{display:grid;gap:8px}.records div{display:grid;align-items:center;gap:10px;border-top:1px solid #edf1ef;padding-top:10px;min-width:0;grid-template-columns:72px minmax(150px,260px) minmax(0,1fr)}
.records span,.records code{min-width:0;overflow-wrap:anywhere}.records code{background:#eef4f1;border-radius:5px;padding:5px 7px;white-space:pre-wrap;word-break:break-word}
.health-list{display:grid;gap:8px}.health-list div{display:grid;grid-template-columns:72px minmax(150px,260px) minmax(0,1fr);gap:10px;align-items:center}.health-list b{color:#9a473a}.health-list b.ok{color:#126b5a}
.health-list span,.health-list code{min-width:0;overflow-wrap:anywhere}.health-list code{background:#eef4f1;border-radius:5px;padding:5px 7px;white-space:pre-wrap;word-break:break-word}
.status-badge{display:inline-block;font-size:12px;font-weight:800;border-radius:999px;padding:3px 8px}.status-badge.verified{background:#e9f6f2;color:#126b5a}.status-badge.configured{background:#e9f1fb;color:#24577a}.status-badge.attention{background:#fef2f0;color:#9a473a}
.verified{color:#126b5a}.configured{color:#24577a}.attention{color:#9a473a}
.notice{display:flex;align-items:center;gap:12px;padding:14px 16px}.notice.success{border-color:#a9d9c9}.notice.error{border-color:#d49a91}.empty{color:#61716c}
.check-result{border-color:#b7e7da}
@media(max-width:760px){.topbar{display:grid}.topbar h1{font-size:25px}.primary{width:100%}.records div{grid-template-columns:minmax(0,1fr)}.health-list div{grid-template-columns:minmax(0,1fr)}form{grid-template-columns:minmax(0,1fr)}.domain-actions{flex-direction:column}}
@media(max-width:420px){.panel{padding:15px}.topbar h1{font-size:23px}}
</style>