AkurAI Build
Menu

Public repository / 1b41bcdd9e7d715e0117aa1edf8d3712789587b6

AkurAI-Mail / src/routes/domains/+page.svelte

Latest change 1b41bcdd9e7d715e0117aa1edf8d3712789587b6 - feat: add domain management admin UI by Ólafur Búi Ólafsson

<script lang="ts">
  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 checkResult = $state<any>(null);

  async function refresh() {
    const response = await fetch(location.href, { headers: { accept: "text/html" } });
    if (response.ok) location.reload();
  }

  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;
    }
  }
</script>

<svelte:head><title>Domains - AkurAI Mail Admin</title></svelte:head>

<main class="shell">
  <aside class="rail">
    <a class="brand" href="/" aria-label="AkurAI Mail admin home">
      <span class="mark">A</span>
      <span><strong>AkurAI Mail</strong><small>{status.hostname}</small></span>
    </a>
    <nav aria-label="Primary">
      <a href="/">Admin</a>
      <a class="active" href="/domains">Domains</a>
      <a href="/webmail">Webmail</a>
    </nav>
    <a class="signout" href="/logout">Sign out</a>
  </aside>

  <section class="workspace">
    <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}>Refresh</button>
    </header>

    {#if result?.ok && result?.dnsRecords}
      <section class="notice success" aria-live="polite">
        <strong>Domain added</strong>
        <span>Configure the DNS records shown below for {result.domain}</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:pending={checkResult.status === "pending"}>{checkResult.status}</strong></p>
          </div>
          <button class="secondary" onclick={() => { checkResult = null; }}>Dismiss</button>
        </div>
        <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>{(check.values ?? []).join(", ") || check.error || "no answer"}</code>
            </div>
          {/each}
        </div>
      </section>
    {/if}

    <section class="panel">
      <div class="panel-head">
        <div>
          <h2>Add domain</h2>
          <p>Register a new domain for mail hosting. You will need to configure DNS records at your registrar.</p>
        </div>
      </div>
      <form onsubmit={(e) => { e.preventDefault(); action({ action: "domain-add", 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}>Add domain</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:pending={d.status === "pending"}>{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="secondary" onclick={() => checkDns(d.domain)} disabled={checking === d.domain}>
              {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}>Remove</button>
            {/if}
          </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}
  </section>
</main>

<style>
  :global(*){box-sizing:border-box}
  :global(html),:global(body){margin:0;overflow-x:hidden}
  :global(body){font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f4f6f5;color:#151d1b}
  button,input{font:inherit}
  .shell{min-height:100dvh;display:grid;grid-template-columns:248px minmax(0,1fr)}
  .rail{position:sticky;top:0;height:100dvh;background:#0d1b18;color:#edf7f3;padding:20px;display:flex;flex-direction:column;gap:20px}
  .brand{display:flex;gap:12px;align-items:center;color:inherit;text-decoration:none;min-width:0}
  .brand span:last-child{min-width:0}.brand strong,.brand small{display:block;overflow-wrap:anywhere}.brand small{margin-top:2px;color:#96aaa3;font-size:12px}
  .mark{display:grid;place-items:center;flex:0 0 auto;width:40px;height:40px;border-radius:8px;background:#c8f05a;color:#0d1b18;font-weight:900}
  nav{display:grid;gap:4px}nav a,.signout{color:#dce9e4;text-decoration:none;border-radius:6px;padding:10px 11px;min-height:44px;display:flex;align-items:center}
  nav a:hover,.signout:hover,nav a.active{background:#1c332e}.signout{margin-top:auto;color:#b8c9c3}
  .workspace{padding:26px;display:grid;gap:18px;min-width:0;align-content:start}
  .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{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}
  .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}.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.pending{background:#fef6e6;color:#8a6d2b}
  .verified{color:#126b5a}.pending{color:#8a6d2b}
  .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){.shell{grid-template-columns:minmax(0,1fr)}.rail{position:static;height:auto;padding:14px 16px;gap:12px}.brand small{font-size:12px}nav{display:flex;overflow-x:auto;gap:6px;scrollbar-width:none}nav::-webkit-scrollbar{display:none}nav a{white-space:nowrap;background:rgba(255,255,255,.06)}.signout{margin-top:0}.workspace{padding:16px}.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>