Kaynağa Gözat

feat: add multi-user control panel

Codex 1 ay önce
ebeveyn
işleme
176fe97781
7 değiştirilmiş dosya ile 593 ekleme ve 309 silme
  1. 368 278
      public/app.js
  2. 82 12
      public/index.html
  3. 27 1
      public/login.css
  4. 33 10
      public/login.html
  5. 37 6
      public/login.js
  6. 36 0
      public/styles.css
  7. 10 2
      src/server.js

+ 368 - 278
public/app.js

@@ -1,8 +1,13 @@
 const state = {
+  me: null,
   config: null,
   smtpCredential: null,
+  dnsCredentials: [],
+  apiTokens: [],
   domains: [],
   events: [],
+  users: [],
+  settings: null,
   selectedId: null,
   busy: false
 };
@@ -10,19 +15,29 @@ const state = {
 const els = {
   runtimeLine: document.querySelector('#runtimeLine'),
   securityNotice: document.querySelector('#securityNotice'),
+  accountBox: document.querySelector('#accountBox'),
+  userRole: document.querySelector('#userRole'),
   addDomainForm: document.querySelector('#addDomainForm'),
+  domainDnsCredential: document.querySelector('#domainDnsCredential'),
   smtpCredentialForm: document.querySelector('#smtpCredentialForm'),
   smtpCredentialState: document.querySelector('#smtpCredentialState'),
   smtpCredentialCopy: document.querySelector('#smtpCredentialCopy'),
   smtpUsername: document.querySelector('#smtpUsername'),
   smtpPassword: document.querySelector('#smtpPassword'),
   generateSmtpPassword: document.querySelector('#generateSmtpPassword'),
+  dnsCredentialForm: document.querySelector('#dnsCredentialForm'),
+  dnsCredentialList: document.querySelector('#dnsCredentialList'),
+  dnsCredentialCount: document.querySelector('#dnsCredentialCount'),
+  apiTokenForm: document.querySelector('#apiTokenForm'),
+  apiTokenList: document.querySelector('#apiTokenList'),
+  apiTokenCount: document.querySelector('#apiTokenCount'),
   defaultSenderHost: document.querySelector('#defaultSenderHost'),
   defaultSendingIp: document.querySelector('#defaultSendingIp'),
   defaultSpfExtra: document.querySelector('#defaultSpfExtra'),
   domainList: document.querySelector('#domainList'),
   domainCount: document.querySelector('#domainCount'),
   detailPanel: document.querySelector('#detailPanel'),
+  adminPanel: document.querySelector('#adminPanel'),
   refreshButton: document.querySelector('#refreshButton'),
   logoutButton: document.querySelector('#logoutButton')
 };
@@ -37,34 +52,54 @@ async function init() {
 function bindEvents() {
   els.refreshButton.addEventListener('click', refreshAll);
   els.logoutButton.addEventListener('click', logout);
-  document.addEventListener('click', handleCopyClick);
+  document.addEventListener('click', handleGlobalClick);
   els.addDomainForm.addEventListener('submit', addDomain);
   els.smtpCredentialForm.addEventListener('submit', saveSmtpCredential);
   els.generateSmtpPassword.addEventListener('click', generateSmtpPassword);
-  els.domainList.addEventListener('click', async (event) => {
+  els.dnsCredentialForm.addEventListener('submit', saveDnsCredential);
+  els.apiTokenForm.addEventListener('submit', createApiToken);
+  els.domainList.addEventListener('click', (event) => {
     const item = event.target.closest('[data-domain-id]');
     if (!item) return;
     state.selectedId = Number(item.dataset.domainId);
     render();
   });
-  els.detailPanel.addEventListener('click', handleDetailClick);
   els.detailPanel.addEventListener('submit', handleDetailSubmit);
+  els.adminPanel.addEventListener('submit', handleDetailSubmit);
 }
 
 async function refreshAll() {
   setBusy(true);
   try {
-    const [config, domains, events, smtpCredential] = await Promise.all([
+    const me = await api('/api/me');
+    state.me = me.user;
+    const baseCalls = [
       api('/api/config'),
       api('/api/domains'),
       api('/api/events'),
-      api('/api/smtp-credential')
-    ]);
+      api('/api/smtp-credential'),
+      api('/api/dns-credentials'),
+      api('/api/api-tokens')
+    ];
+    const [config, domains, events, smtpCredential, dnsCredentials, apiTokens] = await Promise.all(baseCalls);
     state.config = config;
     state.domains = domains.domains || [];
     state.events = events.events || [];
     state.smtpCredential = smtpCredential.credential || null;
+    state.dnsCredentials = dnsCredentials.credentials || [];
+    state.apiTokens = apiTokens.tokens || [];
+    if (state.me?.role === 'admin') {
+      const [settings, users] = await Promise.all([
+        api('/api/admin/settings'),
+        api('/api/admin/users')
+      ]);
+      state.settings = settings.settings || null;
+      state.users = users.users || [];
+    }
     if (!state.selectedId && state.domains.length) state.selectedId = state.domains[0].id;
+    if (state.selectedId && !state.domains.some((domain) => domain.id === state.selectedId)) {
+      state.selectedId = state.domains[0]?.id || null;
+    }
     renderDefaults();
     render();
   } catch (error) {
@@ -83,15 +118,42 @@ function renderDefaults() {
   els.smtpUsername.value = state.smtpCredential?.username || state.config.submission?.username || '';
   els.smtpCredentialState.textContent = state.smtpCredential?.passwordSet ? '已配置' : '未配置';
   els.smtpCredentialState.className = `badge ${state.smtpCredential?.passwordSet ? 'ok' : 'warn'}`;
+  els.userRole.textContent = state.me?.role === 'admin' ? '管理员' : '用户';
+  els.userRole.className = `badge ${state.me?.role === 'admin' ? 'ok' : 'idle'}`;
+  els.accountBox.innerHTML = `
+    <div class="live-row"><span>Username</span><code>${escapeHtml(state.me?.username || '')}</code></div>
+    <div class="live-row"><span>Email</span><code>${escapeHtml(state.me?.email || '')}</code></div>
+  `;
+  renderDnsOptions();
   renderSmtpCredentialCopy();
+  renderDnsCredentials();
+  renderApiTokens();
+  renderAdminPanel();
   els.securityNotice.classList.toggle('hidden', !state.config.usingDefaultAdminPassword);
   els.securityNotice.textContent = state.config.usingDefaultAdminPassword
     ? '当前仍在使用默认管理密码,请修改 .env 后重启服务。'
     : '';
 }
 
+function render() {
+  els.domainCount.textContent = String(state.domains.length);
+  renderDomainList();
+  renderDetail();
+}
+
+function renderDnsOptions(selectedId = '') {
+  const options = [
+    '<option value="">不绑定,手动配置</option>',
+    ...state.dnsCredentials.map((credential) => `
+      <option value="${credential.id}" ${String(selectedId) === String(credential.id) ? 'selected' : ''}>
+        ${escapeHtml(credential.name)} · ${providerLabel(credential.provider)}
+      </option>
+    `)
+  ].join('');
+  els.domainDnsCredential.innerHTML = options;
+}
+
 function renderSmtpCredentialCopy() {
-  if (!els.smtpCredentialCopy) return;
   const credential = state.smtpCredential;
   if (!credential?.username && !credential?.passwordSet) {
     els.smtpCredentialCopy.innerHTML = '<p class="muted">保存后会在这里显示可复制的 SMTP 用户名和密码。</p>';
@@ -102,7 +164,7 @@ function renderSmtpCredentialCopy() {
     <div class="copy-row">
       <span>Username</span>
       <code>${escapeHtml(credential.username || '')}</code>
-      <button class="small-button" data-copy="${escapeAttr(credential.username || '')}" type="button" ${credential.username ? '' : 'disabled'}>复制</button>
+      <button class="small-button" data-copy="${escapeAttr(credential.username || '')}" type="button">复制</button>
     </div>
     <div class="copy-row">
       <span>Password</span>
@@ -113,10 +175,91 @@ function renderSmtpCredentialCopy() {
   `;
 }
 
-function render() {
-  els.domainCount.textContent = String(state.domains.length);
-  renderDomainList();
-  renderDetail();
+function renderDnsCredentials() {
+  els.dnsCredentialCount.textContent = String(state.dnsCredentials.length);
+  if (!state.dnsCredentials.length) {
+    els.dnsCredentialList.innerHTML = '<p class="muted">暂无 DNS API 凭据</p>';
+    return;
+  }
+  els.dnsCredentialList.innerHTML = state.dnsCredentials.map((credential) => `
+    <div class="mini-row">
+      <div>
+        <strong>${escapeHtml(credential.name)}</strong>
+        <span class="muted">${providerLabel(credential.provider)} · ${escapeHtml(credential.zoneName || '未设置 Zone')}</span>
+      </div>
+      <div class="button-row">
+        <button class="small-button" data-action="test-dns" data-id="${credential.id}" type="button">测试</button>
+        <button class="danger-button small-danger" data-action="delete-dns" data-id="${credential.id}" type="button">删除</button>
+      </div>
+    </div>
+  `).join('');
+}
+
+function renderApiTokens() {
+  els.apiTokenCount.textContent = String(state.apiTokens.length);
+  if (!state.apiTokens.length) {
+    els.apiTokenList.innerHTML = '<p class="muted">暂无发送 Token</p>';
+    return;
+  }
+  els.apiTokenList.innerHTML = state.apiTokens.map((token) => `
+    <div class="mini-row">
+      <div>
+        <strong>${escapeHtml(token.name)}</strong>
+        <span class="muted">${escapeHtml(token.tokenPrefix)}... · ${escapeHtml(formatDate(token.createdAt))}</span>
+      </div>
+      <button class="danger-button small-danger" data-action="delete-token" data-id="${token.id}" type="button">删除</button>
+    </div>
+  `).join('');
+}
+
+function renderAdminPanel() {
+  if (state.me?.role !== 'admin') {
+    els.adminPanel.classList.add('hidden');
+    els.adminPanel.innerHTML = '';
+    return;
+  }
+  const settings = state.settings || state.config || {};
+  els.adminPanel.classList.remove('hidden');
+  els.adminPanel.innerHTML = `
+    <form class="compact-form" data-form="admin-settings">
+      <div class="section-head">
+        <h2>系统设置</h2>
+        <span class="count">${state.users.length}</span>
+      </div>
+      <label>APP Base URL<input name="appBaseUrl" value="${escapeAttr(settings.appBaseUrl || '')}"></label>
+      <label>发信主机<input name="mailHostname" value="${escapeAttr(settings.mailHostname || '')}"></label>
+      <label>发信 IP<input name="sendingIp" value="${escapeAttr(settings.sendingIp || '')}"></label>
+      <label>默认 SPF<textarea name="defaultSpfMechanisms" rows="2">${escapeHtml(settings.defaultSpfMechanisms || '')}</textarea></label>
+      <div class="form-grid">
+        <label>DMARC
+          <select name="dmarcPolicy">
+            ${['none', 'quarantine', 'reject'].map((value) => `<option value="${value}" ${settings.dmarcPolicy === value ? 'selected' : ''}>${value}</option>`).join('')}
+          </select>
+        </label>
+        <label>验证后发信
+          <select name="sendRequiresVerified">
+            <option value="false" ${!settings.sendRequiresVerified ? 'selected' : ''}>否</option>
+            <option value="true" ${settings.sendRequiresVerified ? 'selected' : ''}>是</option>
+          </select>
+        </label>
+      </div>
+      <label>DMARC rua<input name="dmarcRua" value="${escapeAttr(settings.dmarcRua || '')}"></label>
+      <button class="primary-button" type="submit">保存系统设置</button>
+    </form>
+    <div class="mini-list user-mini-list">
+      ${state.users.slice(0, 8).map((user) => `
+        <div class="mini-row">
+          <div>
+            <strong>${escapeHtml(user.username)}</strong>
+            <span class="muted">${escapeHtml(user.email)} · ${escapeHtml(user.role)} · ${escapeHtml(user.status)}</span>
+          </div>
+          <button class="${user.status === 'active' ? 'danger-button' : 'small-button'} small-danger" data-action="toggle-user" data-id="${user.id}" data-status="${user.status}" type="button">
+            ${user.status === 'active' ? '禁用' : '启用'}
+          </button>
+        </div>
+      `).join('')}
+    </div>
+  `;
 }
 
 function renderDomainList() {
@@ -164,13 +307,15 @@ function renderDetail() {
         </div>
       </div>
       <div class="detail-actions">
-        <button class="primary-button" data-action="check" type="button">立即检查</button>
+        <button class="primary-button" data-action="apply-dns" type="button">一键配置 DNS</button>
+        <button class="secondary-button" data-action="check" type="button">立即检查</button>
         <button class="secondary-button" data-action="rotate-dkim" type="button">轮换 DKIM</button>
         <button class="danger-button" data-action="delete-domain" type="button">删除</button>
       </div>
     </div>
     <div class="detail-body">
       ${renderSetupOverview(domain, guide, records)}
+      ${guide.apply ? renderApplyResult(guide.apply) : ''}
       ${warnings.length ? renderWarnings(warnings) : ''}
       <div class="grid-two">
         <section class="subpanel">
@@ -218,49 +363,54 @@ function renderDetail() {
 }
 
 function renderSetupOverview(domain, guide, records) {
-  const important = [
-    ['verification', '域名验证'],
-    ['dkim', 'DKIM'],
-    ['spf', 'SPF'],
-    ['dmarc', 'DMARC'],
-    ['ptr', 'PTR']
-  ];
+  const important = [['verification', '域名验证'], ['dkim', 'DKIM'], ['spf', 'SPF'], ['dmarc', 'DMARC'], ['ptr', 'PTR']];
   const cards = important.map(([key, label]) => {
     const record = records.find((item) => item.key === key);
     const meta = statusMeta(record || {});
-    return `
-      <div class="metric-card">
-        <span>${escapeHtml(label)}</span>
-        <strong>${record ? escapeHtml(meta.label) : '待生成'}</strong>
-      </div>
-    `;
+    return `<div class="metric-card"><span>${escapeHtml(label)}</span><strong>${record ? escapeHtml(meta.label) : '待生成'}</strong></div>`;
   }).join('');
+  const credential = state.dnsCredentials.find((item) => item.id === domain.dnsCredentialId);
   return `
     <section class="guide-hero">
       <div>
         <span class="eyebrow">Sending domain</span>
         <h3>${escapeHtml(domain.domain)}</h3>
         <p>${escapeHtml(domain.senderHost)} / ${escapeHtml(domain.sendingIp)}</p>
+        <p>${credential ? `DNS API: ${escapeHtml(credential.name)}` : 'DNS API: 未绑定'}</p>
       </div>
       <div class="summary-grid">${cards}</div>
     </section>
   `;
 }
 
+function renderApplyResult(apply) {
+  const rows = apply.results || [];
+  return `
+    <section class="subpanel">
+      <div class="subpanel-head">
+        <h3>一键配置结果</h3>
+        <span class="badge ${apply.ok ? 'ok' : 'warn'}">${apply.ok ? '完成' : '部分失败'}</span>
+      </div>
+      <div class="mini-list">
+        ${rows.map((row) => `
+          <div class="mini-row">
+            <div>
+              <strong>${escapeHtml(row.key)} · ${escapeHtml(row.type)}</strong>
+              <span class="muted">${escapeHtml(row.host)} · ${escapeHtml(row.detail || row.error || '')}</span>
+            </div>
+            ${badge({ className: row.ok ? 'ok' : 'failed', label: row.ok ? '成功' : '失败' })}
+          </div>
+        `).join('')}
+      </div>
+    </section>
+  `;
+}
+
 function renderDnsGuide(records) {
   if (!records.length) {
-    return `
-      <div class="empty-guide">
-        <h4>尚未生成检查结果</h4>
-        <p>点击“立即检查”后会生成域名验证、DKIM、SPF、DMARC 和 PTR 引导。</p>
-      </div>
-    `;
+    return `<div class="empty-guide"><h4>尚未生成检查结果</h4><p>点击“立即检查”后会生成域名验证、DKIM、SPF、DMARC 和 PTR 引导。</p></div>`;
   }
-  return `
-    <div class="record-steps">
-      ${records.map((record, index) => renderRecordCard(record, index + 1)).join('')}
-    </div>
-  `;
+  return `<div class="record-steps">${records.map((record, index) => renderRecordCard(record, index + 1)).join('')}</div>`;
 }
 
 function renderRecordCard(record, index) {
@@ -269,15 +419,10 @@ function renderRecordCard(record, index) {
   const warnings = record.warnings || [];
   return `
     <article class="record-card ${meta.className}">
-      <div class="record-step">
-        <span>${index}</span>
-      </div>
+      <div class="record-step"><span>${index}</span></div>
       <div class="record-card-body">
         <div class="record-card-title">
-          <div>
-            <h4>${escapeHtml(record.label)}</h4>
-            <p>${escapeHtml(record.type)} · ${escapeHtml(record.host)}</p>
-          </div>
+          <div><h4>${escapeHtml(record.label)}</h4><p>${escapeHtml(record.type)} · ${escapeHtml(record.host)}</p></div>
           ${badge(meta)}
         </div>
         <div class="dns-value">
@@ -285,42 +430,20 @@ function renderRecordCard(record, index) {
           <code>${escapeHtml(record.value || '')}</code>
           <button class="small-button" data-copy="${escapeAttr(record.value || '')}" type="button">复制值</button>
         </div>
-        ${current.length ? `
-          <div class="dns-current">
-            <span>当前值</span>
-            ${current.map((value) => `<code>${escapeHtml(value)}</code>`).join('')}
-          </div>
-        ` : ''}
-        ${warnings.length ? `
-          <ul class="inline-warnings">
-            ${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}
-          </ul>
-        ` : ''}
+        ${current.length ? `<div class="dns-current"><span>当前值</span>${current.map((value) => `<code>${escapeHtml(value)}</code>`).join('')}</div>` : ''}
+        ${warnings.length ? `<ul class="inline-warnings">${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}</ul>` : ''}
       </div>
     </article>
   `;
 }
 
 function renderRecordTable(records) {
-  if (!records.length) {
-    return '<p class="muted">点击“立即检查”生成 SPF、DKIM、DMARC 和 PTR 检查结果。</p>';
-  }
+  if (!records.length) return '<p class="muted">点击“立即检查”生成 SPF、DKIM、DMARC 和 PTR 检查结果。</p>';
   return `
     <div class="record-table-wrap">
       <table>
-        <thead>
-          <tr>
-            <th>项目</th>
-            <th>主机</th>
-            <th>类型</th>
-            <th>目标值</th>
-            <th>状态</th>
-            <th></th>
-          </tr>
-        </thead>
-        <tbody>
-          ${records.map(renderRecordRow).join('')}
-        </tbody>
+        <thead><tr><th>项目</th><th>主机</th><th>类型</th><th>目标值</th><th>状态</th><th></th></tr></thead>
+        <tbody>${records.map(renderRecordRow).join('')}</tbody>
       </table>
     </div>
   `;
@@ -341,36 +464,23 @@ function renderRecordRow(record) {
 }
 
 function renderSettingsForm(domain) {
+  const dnsOptions = [
+    '<option value="">不绑定</option>',
+    ...state.dnsCredentials.map((credential) => `<option value="${credential.id}" ${domain.dnsCredentialId === credential.id ? 'selected' : ''}>${escapeHtml(credential.name)} · ${providerLabel(credential.provider)}</option>`)
+  ].join('');
   return `
     <form class="compact-form" data-form="settings">
+      <label>DNS API<select name="dnsCredentialId">${dnsOptions}</select></label>
       <div class="form-grid">
-        <label>
-          DKIM selector
-          <input name="selector" value="${escapeAttr(domain.selector)}">
-        </label>
-        <label>
-          DMARC 策略
-          <select name="dmarcPolicy">
-            ${['none', 'quarantine', 'reject'].map((value) => `<option value="${value}" ${domain.dmarcPolicy === value ? 'selected' : ''}>${value}</option>`).join('')}
-          </select>
+        <label>DKIM selector<input name="selector" value="${escapeAttr(domain.selector)}"></label>
+        <label>DMARC 策略
+          <select name="dmarcPolicy">${['none', 'quarantine', 'reject'].map((value) => `<option value="${value}" ${domain.dmarcPolicy === value ? 'selected' : ''}>${value}</option>`).join('')}</select>
         </label>
       </div>
-      <label>
-        发信主机
-        <input name="senderHost" value="${escapeAttr(domain.senderHost)}">
-      </label>
-      <label>
-        发信 IP
-        <input name="sendingIp" value="${escapeAttr(domain.sendingIp)}">
-      </label>
-      <label>
-        兼容第三方 SPF
-        <textarea name="spfExtra" rows="3">${escapeHtml(domain.spfExtra || '')}</textarea>
-      </label>
-      <label>
-        DMARC rua
-        <input name="dmarcRua" value="${escapeAttr(domain.dmarcRua || '')}" placeholder="mailto:dmarc@example.com">
-      </label>
+      <label>发信主机<input name="senderHost" value="${escapeAttr(domain.senderHost)}"></label>
+      <label>发信 IP<input name="sendingIp" value="${escapeAttr(domain.sendingIp)}"></label>
+      <label>兼容第三方 SPF<textarea name="spfExtra" rows="3">${escapeHtml(domain.spfExtra || '')}</textarea></label>
+      <label>DMARC rua<input name="dmarcRua" value="${escapeAttr(domain.dmarcRua || '')}" placeholder="mailto:dmarc@example.com"></label>
       <button class="secondary-button" type="submit">保存设置</button>
     </form>
   `;
@@ -378,45 +488,17 @@ function renderSettingsForm(domain) {
 
 function renderLiveDns(live) {
   if (!live) return '<p class="muted">暂无检查数据</p>';
-  const rows = [
-    ['根域 TXT', live.rootTxt],
-    ['验证 TXT', live.verificationTxt],
-    ['DKIM TXT', live.dkimTxt],
-    ['DMARC TXT', live.dmarcTxt],
-    ['发信主机 A', live.senderA],
-    ['发信 IP PTR', live.ptr]
-  ];
-  return `
-    <div class="live-list">
-      ${rows.map(([label, values]) => `
-        <div class="live-row">
-          <span>${label}</span>
-          ${(values && values.length) ? values.map((value) => `<code>${escapeHtml(value)}</code>`).join('') : '<p class="muted">未发现</p>'}
-        </div>
-      `).join('')}
-    </div>
-  `;
+  const rows = [['根域 TXT', live.rootTxt], ['验证 TXT', live.verificationTxt], ['DKIM TXT', live.dkimTxt], ['DMARC TXT', live.dmarcTxt], ['发信主机 A', live.senderA], ['发信 IP PTR', live.ptr]];
+  return `<div class="live-list">${rows.map(([label, values]) => `<div class="live-row"><span>${label}</span>${(values && values.length) ? values.map((value) => `<code>${escapeHtml(value)}</code>`).join('') : '<p class="muted">未发现</p>'}</div>`).join('')}</div>`;
 }
 
 function renderSendForm(domain) {
   return `
     <form class="send-form" data-form="send">
-      <label>
-        From
-        <input name="from" value="noreply@${escapeAttr(domain.domain)}">
-      </label>
-      <label>
-        To
-        <input name="to" placeholder="user@example.com" required>
-      </label>
-      <label>
-        Subject
-        <input name="subject" value="MailHub test for ${escapeAttr(domain.domain)}">
-      </label>
-      <label>
-        Text
-        <textarea name="text" rows="5">This is a MailHub test message from ${escapeHtml(domain.domain)}.</textarea>
-      </label>
+      <label>From<input name="from" value="noreply@${escapeAttr(domain.domain)}"></label>
+      <label>To<input name="to" placeholder="user@example.com" required></label>
+      <label>Subject<input name="subject" value="MailHub test for ${escapeAttr(domain.domain)}"></label>
+      <label>Text<textarea name="text" rows="5">This is a MailHub test message from ${escapeHtml(domain.domain)}.</textarea></label>
       <button class="primary-button" type="submit">发送测试</button>
     </form>
   `;
@@ -425,60 +507,22 @@ function renderSendForm(domain) {
 function renderEvents(domain) {
   const events = state.events.filter((event) => event.domain === domain.domain).slice(0, 8);
   if (!events.length) return '<p class="muted">暂无发送记录</p>';
-  return `
-    <div class="event-list">
-      ${events.map((event) => `
-        <div class="event-row">
-          <div class="item-line">
-            <strong>${escapeHtml(event.subject)}</strong>
-            ${badge({ className: event.status === 'queued' ? 'ok' : 'failed', label: event.status })}
-          </div>
-          <span class="muted">${escapeHtml(event.sender)} -> ${escapeHtml((event.recipients || []).join(', '))}</span>
-          <span class="muted">${escapeHtml(formatDate(event.createdAt))}</span>
-        </div>
-      `).join('')}
-    </div>
-  `;
+  return `<div class="event-list">${events.map((event) => `<div class="event-row"><div class="item-line"><strong>${escapeHtml(event.subject)}</strong>${badge({ className: event.status === 'queued' ? 'ok' : 'failed', label: event.status })}</div><span class="muted">${escapeHtml(event.sender)} -> ${escapeHtml((event.recipients || []).join(', '))}</span><span class="muted">${escapeHtml(formatDate(event.createdAt))}</span></div>`).join('')}</div>`;
 }
 
 function renderSmtpBox(domain) {
   const submission = state.config?.submission;
   const credential = state.smtpCredential;
   const password = credential?.password || '';
-  if (!submission?.enabled) {
-    return '<p class="muted">SMTP Submission 未启用。</p>';
-  }
+  if (!submission?.enabled) return '<p class="muted">SMTP Submission 未启用。</p>';
   return `
     <div class="smtp-grid">
-      <div class="smtp-row">
-        <span>Host</span>
-        <code>${escapeHtml(submission.host)}</code>
-      </div>
-      <div class="smtp-row">
-        <span>Ports</span>
-        ${(submission.ports || []).map((item) => `<code>${escapeHtml(item.port)} · ${escapeHtml(item.protocol)}</code>`).join('')}
-      </div>
-      <div class="smtp-row">
-        <span>Username</span>
-        <div class="copy-row inline">
-          <code>${escapeHtml(credential?.username || submission.username || '')}</code>
-          <button class="small-button" data-copy="${escapeAttr(credential?.username || submission.username || '')}" type="button">复制</button>
-        </div>
-      </div>
-      <div class="smtp-row">
-        <span>Password</span>
-        ${password
-          ? `<div class="copy-row inline"><code>${escapeHtml(password)}</code><button class="small-button" data-copy="${escapeAttr(password)}" type="button">复制</button></div>`
-          : '<p class="muted">旧密码无法回显,请在左侧重新设置一次新密码后复制。</p>'}
-      </div>
-      <div class="smtp-row">
-        <span>AUTH</span>
-        <code>${submission.requireTlsForAuth ? '需要 TLS 后认证' : '允许明文认证'}</code>
-      </div>
-      <div class="smtp-row">
-        <span>From</span>
-        <code>noreply@${escapeHtml(domain.domain)}</code>
-      </div>
+      <div class="smtp-row"><span>Host</span><code>${escapeHtml(submission.host)}</code></div>
+      <div class="smtp-row"><span>Ports</span>${(submission.ports || []).map((item) => `<code>${escapeHtml(item.port)} · ${escapeHtml(item.protocol)}</code>`).join('')}</div>
+      <div class="smtp-row"><span>Username</span><div class="copy-row inline"><code>${escapeHtml(credential?.username || submission.username || '')}</code><button class="small-button" data-copy="${escapeAttr(credential?.username || submission.username || '')}" type="button">复制</button></div></div>
+      <div class="smtp-row"><span>Password</span>${password ? `<div class="copy-row inline"><code>${escapeHtml(password)}</code><button class="small-button" data-copy="${escapeAttr(password)}" type="button">复制</button></div>` : '<p class="muted">旧密码无法回显,请在左侧重新设置一次新密码后复制。</p>'}</div>
+      <div class="smtp-row"><span>AUTH</span><code>${submission.requireTlsForAuth ? '需要 TLS 后认证' : '允许明文认证'}</code></div>
+      <div class="smtp-row"><span>From</span><code>noreply@${escapeHtml(domain.domain)}</code></div>
     </div>
   `;
 }
@@ -489,56 +533,41 @@ function renderOptionalRecords(records) {
 }
 
 function renderWarnings(warnings) {
-  return `
-    <ul class="warning-list">
-      ${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}
-    </ul>
-  `;
+  return `<ul class="warning-list">${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}</ul>`;
 }
 
 function apiExample(domain) {
+  const token = state.apiTokens[0] ? `${state.apiTokens[0].tokenPrefix}...` : '<USER_API_TOKEN>';
   return `curl -X POST ${state.config?.appBaseUrl || 'https://mail.ss5.xyz'}/api/send \\
-  -H 'Authorization: Bearer <API_TOKEN>' \\
+  -H 'Authorization: Bearer ${token}' \\
   -H 'Content-Type: application/json' \\
   -d '{
     "from": "noreply@${domain.domain}",
     "to": "user@example.com",
     "subject": "Hello from MailHub",
-    "text": "Signed with DKIM and queued by Postfix."
+    "text": "Signed with DKIM and queued by MailHub."
   }'`;
 }
 
 async function addDomain(event) {
   event.preventDefault();
-  const data = Object.fromEntries(new FormData(event.target).entries());
-  setBusy(true);
-  try {
-    const result = await api('/api/domains', {
-      method: 'POST',
-      body: JSON.stringify(data)
-    });
+  await mutate(async () => {
+    const data = Object.fromEntries(new FormData(event.target).entries());
+    const result = await api('/api/domains', { method: 'POST', body: JSON.stringify(data) });
     state.domains.unshift(result.domain);
     state.selectedId = result.domain.id;
     event.target.reset();
     renderDefaults();
     render();
     await checkSelected();
-  } catch (error) {
-    toast(error.message);
-  } finally {
-    setBusy(false);
-  }
+  });
 }
 
 async function saveSmtpCredential(event) {
   event.preventDefault();
-  const data = Object.fromEntries(new FormData(event.target).entries());
-  setBusy(true);
-  try {
-    const result = await api('/api/smtp-credential', {
-      method: 'PUT',
-      body: JSON.stringify(data)
-    });
+  await mutate(async () => {
+    const data = Object.fromEntries(new FormData(event.target).entries());
+    const result = await api('/api/smtp-credential', { method: 'PUT', body: JSON.stringify(data) });
     state.smtpCredential = result.credential;
     if (state.config?.submission) {
       state.config.submission.username = result.credential.username;
@@ -548,42 +577,68 @@ async function saveSmtpCredential(event) {
     renderDefaults();
     render();
     toast('SMTP 凭据已保存');
-  } catch (error) {
-    toast(error.message);
-  } finally {
-    setBusy(false);
-  }
+  });
+}
+
+async function saveDnsCredential(event) {
+  event.preventDefault();
+  await mutate(async () => {
+    const data = Object.fromEntries(new FormData(event.target).entries());
+    const result = await api('/api/dns-credentials', { method: 'POST', body: JSON.stringify(data) });
+    state.dnsCredentials.unshift(result.credential);
+    event.target.reset();
+    renderDefaults();
+    render();
+    toast('DNS 凭据已保存');
+  });
+}
+
+async function createApiToken(event) {
+  event.preventDefault();
+  await mutate(async () => {
+    const data = Object.fromEntries(new FormData(event.target).entries());
+    const result = await api('/api/api-tokens', { method: 'POST', body: JSON.stringify(data) });
+    state.apiTokens.unshift(result.token);
+    event.target.reset();
+    renderDefaults();
+    render();
+    await navigator.clipboard.writeText(result.token.token);
+    toast('Token 已生成并复制,请立即保存');
+  });
 }
 
 function generateSmtpPassword() {
   const bytes = new Uint8Array(24);
   crypto.getRandomValues(bytes);
-  const password = btoa(String.fromCharCode(...bytes))
-    .replace(/[+/=]/g, '')
-    .slice(0, 28);
+  const password = btoa(String.fromCharCode(...bytes)).replace(/[+/=]/g, '').slice(0, 28);
   els.smtpPassword.type = 'text';
   els.smtpPassword.value = password;
   els.smtpPassword.focus();
   els.smtpPassword.select();
 }
 
-async function handleDetailClick(event) {
+async function handleGlobalClick(event) {
+  const copyButton = event.target.closest('[data-copy]');
+  if (copyButton) {
+    event.preventDefault();
+    const value = copyButton.dataset.copy || '';
+    if (!value) return;
+    await navigator.clipboard.writeText(value);
+    toast('已复制');
+    return;
+  }
   const action = event.target.closest('[data-action]')?.dataset.action;
   if (!action) return;
+  const id = Number(event.target.closest('[data-id]')?.dataset.id || 0);
   if (action === 'check') return checkSelected();
+  if (action === 'apply-dns') return applyDnsSelected();
   if (action === 'copy-all-dns') return copyAllDns();
   if (action === 'rotate-dkim') return rotateDkim();
   if (action === 'delete-domain') return deleteSelected();
-}
-
-async function handleCopyClick(event) {
-  const button = event.target.closest('[data-copy]');
-  if (!button) return;
-  event.preventDefault();
-  const value = button.dataset.copy || '';
-  if (!value) return;
-  await navigator.clipboard.writeText(value);
-  toast('已复制');
+  if (action === 'test-dns') return testDnsCredential(id);
+  if (action === 'delete-dns') return deleteDnsCredential(id);
+  if (action === 'delete-token') return deleteApiToken(id);
+  if (action === 'toggle-user') return toggleUser(id, event.target.closest('[data-status]')?.dataset.status);
 }
 
 async function handleDetailSubmit(event) {
@@ -592,102 +647,130 @@ async function handleDetailSubmit(event) {
   event.preventDefault();
   if (form.dataset.form === 'settings') return saveSettings(form);
   if (form.dataset.form === 'send') return sendTest(form);
+  if (form.dataset.form === 'admin-settings') return saveAdminSettings(form);
 }
 
 async function checkSelected() {
   const id = state.selectedId;
   if (!id) return;
-  setBusy(true);
-  try {
+  await mutate(async () => {
     const result = await api(`/api/domains/${id}/check`, { method: 'POST' });
     replaceDomain(result.domain);
     render();
-  } catch (error) {
-    toast(error.message);
-  } finally {
-    setBusy(false);
-  }
+  });
+}
+
+async function applyDnsSelected() {
+  const id = state.selectedId;
+  if (!id) return;
+  await mutate(async () => {
+    const result = await api(`/api/domains/${id}/apply-dns`, { method: 'POST' });
+    replaceDomain(result.domain);
+    render();
+    toast(result.apply?.ok ? 'DNS 配置完成' : 'DNS 配置部分失败');
+  });
 }
 
 async function saveSettings(form) {
   const id = state.selectedId;
   const data = Object.fromEntries(new FormData(form).entries());
-  setBusy(true);
-  try {
-    const result = await api(`/api/domains/${id}`, {
-      method: 'PATCH',
-      body: JSON.stringify(data)
-    });
+  await mutate(async () => {
+    const result = await api(`/api/domains/${id}`, { method: 'PATCH', body: JSON.stringify(data) });
     replaceDomain(result.domain);
     render();
     await checkSelected();
-  } catch (error) {
-    toast(error.message);
-  } finally {
-    setBusy(false);
-  }
+  });
 }
 
 async function sendTest(form) {
   const id = state.selectedId;
   const data = Object.fromEntries(new FormData(form).entries());
-  setBusy(true);
-  try {
-    await api(`/api/domains/${id}/test-send`, {
-      method: 'POST',
-      body: JSON.stringify(data)
-    });
+  await mutate(async () => {
+    await api(`/api/domains/${id}/test-send`, { method: 'POST', body: JSON.stringify(data) });
     const events = await api('/api/events');
     state.events = events.events || [];
     render();
-    toast('已提交到 Postfix 队列');
-  } catch (error) {
-    toast(error.message);
-  } finally {
-    setBusy(false);
-  }
+    toast('已提交到发信队列');
+  });
+}
+
+async function saveAdminSettings(form) {
+  const data = Object.fromEntries(new FormData(form).entries());
+  await mutate(async () => {
+    const result = await api('/api/admin/settings', { method: 'PATCH', body: JSON.stringify(data) });
+    state.settings = result.settings;
+    state.config = { ...state.config, ...result.settings };
+    renderDefaults();
+    render();
+    toast('系统设置已保存');
+  });
 }
 
 async function rotateDkim() {
   const id = state.selectedId;
   if (!confirm('轮换 DKIM 后需要更新 DNS TXT 记录。继续?')) return;
-  setBusy(true);
-  try {
+  await mutate(async () => {
     const result = await api(`/api/domains/${id}/rotate-dkim`, { method: 'POST' });
     replaceDomain(result.domain);
     render();
     await checkSelected();
-  } catch (error) {
-    toast(error.message);
-  } finally {
-    setBusy(false);
-  }
+  });
 }
 
 async function deleteSelected() {
   const id = state.selectedId;
   const domain = state.domains.find((item) => item.id === id);
   if (!confirm(`删除 ${domain?.domain || '该域名'}?`)) return;
-  setBusy(true);
-  try {
+  await mutate(async () => {
     await api(`/api/domains/${id}`, { method: 'DELETE' });
     state.domains = state.domains.filter((item) => item.id !== id);
     state.selectedId = state.domains[0]?.id || null;
     render();
-  } catch (error) {
-    toast(error.message);
-  } finally {
-    setBusy(false);
-  }
+  });
+}
+
+async function testDnsCredential(id) {
+  await mutate(async () => {
+    const result = await api(`/api/dns-credentials/${id}/test`, { method: 'POST' });
+    toast(result.ok ? 'DNS API 连接成功' : result.error || 'DNS API 连接失败');
+  });
+}
+
+async function deleteDnsCredential(id) {
+  if (!confirm('删除该 DNS API 凭据?')) return;
+  await mutate(async () => {
+    await api(`/api/dns-credentials/${id}`, { method: 'DELETE' });
+    state.dnsCredentials = state.dnsCredentials.filter((item) => item.id !== id);
+    state.domains = state.domains.map((domain) => domain.dnsCredentialId === id ? { ...domain, dnsCredentialId: null } : domain);
+    renderDefaults();
+    render();
+  });
+}
+
+async function deleteApiToken(id) {
+  if (!confirm('删除该 API Token?')) return;
+  await mutate(async () => {
+    await api(`/api/api-tokens/${id}`, { method: 'DELETE' });
+    state.apiTokens = state.apiTokens.filter((item) => item.id !== id);
+    renderDefaults();
+    render();
+  });
+}
+
+async function toggleUser(id, status) {
+  const next = status === 'active' ? 'disabled' : 'active';
+  await mutate(async () => {
+    const result = await api(`/api/admin/users/${id}`, { method: 'PATCH', body: JSON.stringify({ status: next }) });
+    state.users = state.users.map((user) => user.id === id ? result.user : user);
+    renderDefaults();
+  });
 }
 
 async function copyAllDns() {
   const domain = state.domains.find((item) => item.id === state.selectedId);
   const records = domain?.status?.records || [];
   if (!records.length) return toast('暂无 DNS 记录');
-  const text = records
-    .map((record) => `${record.host}\t${record.type}\t${record.value || ''}`)
-    .join('\n');
+  const text = records.map((record) => `${record.host}\t${record.type}\t${record.value || ''}`).join('\n');
   await navigator.clipboard.writeText(text);
   toast('已复制全部 DNS 记录');
 }
@@ -701,13 +784,21 @@ async function logout() {
   }
 }
 
+async function mutate(fn) {
+  setBusy(true);
+  try {
+    await fn();
+  } catch (error) {
+    toast(error.message);
+  } finally {
+    setBusy(false);
+  }
+}
+
 async function api(path, options = {}) {
   const response = await fetch(path, {
     ...options,
-    headers: {
-      'Content-Type': 'application/json',
-      ...(options.headers || {})
-    }
+    headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
   });
   const text = await response.text();
   const payload = text ? JSON.parse(text) : {};
@@ -737,6 +828,10 @@ function badge(meta) {
   return `<span class="badge ${meta.className}">${escapeHtml(meta.label)}</span>`;
 }
 
+function providerLabel(provider) {
+  return { cloudflare: 'Cloudflare', aliyun: '阿里云 DNS', dnspod: '腾讯云 DNSPod' }[provider] || provider;
+}
+
 function setBusy(value) {
   state.busy = value;
   document.body.classList.toggle('busy', value);
@@ -757,12 +852,7 @@ function toast(message) {
 
 function formatDate(value) {
   if (!value) return '';
-  return new Intl.DateTimeFormat('zh-CN', {
-    month: '2-digit',
-    day: '2-digit',
-    hour: '2-digit',
-    minute: '2-digit'
-  }).format(new Date(value));
+  return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(value));
 }
 
 function escapeHtml(value) {

+ 82 - 12
public/index.html

@@ -23,12 +23,24 @@
 
       <div class="workspace">
         <aside class="sidebar">
+          <section class="panel">
+            <div class="section-head">
+              <h2>账号</h2>
+              <span class="badge idle" id="userRole">加载中</span>
+            </div>
+            <div class="live-list" id="accountBox"></div>
+          </section>
+
           <form class="panel compact-form" id="addDomainForm">
             <h2>添加发信域名</h2>
             <label>
               域名
               <input name="domain" placeholder="example.com" autocomplete="off" required>
             </label>
+            <label>
+              DNS API
+              <select name="dnsCredentialId" id="domainDnsCredential"></select>
+            </label>
             <div class="form-grid">
               <label>
                 DKIM selector
@@ -76,7 +88,74 @@
               <button class="primary-button" type="submit">保存凭据</button>
             </div>
             <div class="credential-copy" id="smtpCredentialCopy"></div>
-            <p class="muted">保存后立即生效,SMTP 客户端下一次认证会使用新凭据。</p>
+          </form>
+
+          <form class="panel compact-form" id="dnsCredentialForm">
+            <div class="section-head">
+              <h2>DNS API</h2>
+              <span class="count" id="dnsCredentialCount">0</span>
+            </div>
+            <label>
+              名称
+              <input name="name" placeholder="Cloudflare 主账号" required>
+            </label>
+            <div class="form-grid">
+              <label>
+                服务商
+                <select name="provider" required>
+                  <option value="cloudflare">Cloudflare</option>
+                  <option value="aliyun">阿里云 DNS</option>
+                  <option value="dnspod">腾讯云 DNSPod</option>
+                </select>
+              </label>
+              <label>
+                TTL
+                <input name="defaultTtl" type="number" min="60" max="86400" value="600">
+              </label>
+            </div>
+            <label>
+              Zone / 根域名
+              <input name="zoneName" placeholder="example.com" autocomplete="off" required>
+            </label>
+            <label>
+              Cloudflare API Token
+              <input name="apiToken" autocomplete="off">
+            </label>
+            <label>
+              Cloudflare Zone ID
+              <input name="zoneId" autocomplete="off">
+            </label>
+            <label>
+              阿里云 AccessKeyId
+              <input name="accessKeyId" autocomplete="off">
+            </label>
+            <label>
+              阿里云 AccessKeySecret
+              <input name="accessKeySecret" autocomplete="off">
+            </label>
+            <label>
+              腾讯云 SecretId
+              <input name="secretId" autocomplete="off">
+            </label>
+            <label>
+              腾讯云 SecretKey
+              <input name="secretKey" autocomplete="off">
+            </label>
+            <button class="primary-button" type="submit">保存 DNS 凭据</button>
+            <div class="mini-list" id="dnsCredentialList"></div>
+          </form>
+
+          <form class="panel compact-form" id="apiTokenForm">
+            <div class="section-head">
+              <h2>发送 API Token</h2>
+              <span class="count" id="apiTokenCount">0</span>
+            </div>
+            <label>
+              名称
+              <input name="name" placeholder="Production sender" required>
+            </label>
+            <button class="primary-button" type="submit">生成 Token</button>
+            <div class="mini-list" id="apiTokenList"></div>
           </form>
 
           <section class="panel domain-panel">
@@ -86,6 +165,8 @@
             </div>
             <div class="domain-list" id="domainList"></div>
           </section>
+
+          <section class="panel hidden" id="adminPanel"></section>
         </aside>
 
         <section class="main-panel" id="detailPanel">
@@ -97,17 +178,6 @@
       </div>
     </main>
 
-    <template id="recordRowTemplate">
-      <tr>
-        <td class="record-label"></td>
-        <td class="mono record-host"></td>
-        <td class="record-type"></td>
-        <td><code class="record-value"></code></td>
-        <td class="record-status"></td>
-        <td><button class="small-button copy-button" type="button">复制</button></td>
-      </tr>
-    </template>
-
     <script src="/app.js" type="module"></script>
   </body>
 </html>

+ 27 - 1
public/login.css

@@ -126,7 +126,7 @@ label,
 .login-card {
   align-content: center;
   display: grid;
-  gap: 28px;
+  gap: 22px;
   padding: 48px;
 }
 
@@ -147,6 +147,28 @@ label,
   gap: 16px;
 }
 
+.auth-tabs {
+  background: #eef3fb;
+  border: 1px solid #d4deed;
+  border-radius: 8px;
+  display: grid;
+  gap: 4px;
+  grid-template-columns: 1fr 1fr;
+  padding: 4px;
+}
+
+.auth-tabs button {
+  background: transparent;
+  color: #38516f;
+  min-height: 38px;
+}
+
+.auth-tabs button.active {
+  background: #fff;
+  box-shadow: 0 1px 4px rgba(24, 33, 43, 0.08);
+  color: var(--ink);
+}
+
 label {
   display: grid;
   font-size: 13px;
@@ -195,6 +217,10 @@ button:disabled {
   color: #b3261e;
 }
 
+.hidden {
+  display: none;
+}
+
 @media (max-width: 780px) {
   body {
     padding: 14px;

+ 33 - 10
public/login.html

@@ -16,28 +16,34 @@
         </div>
         <div class="signal-grid">
           <div>
-            <span>HELO</span>
-            <strong>in.ss5.xyz</strong>
+            <span>SMTP</span>
+            <strong>AUTH</strong>
           </div>
           <div>
-            <span>API</span>
-            <strong>/api/send</strong>
+            <span>DNS</span>
+            <strong>API 自动配置</strong>
           </div>
           <div>
-            <span>DNS</span>
-            <strong>SPF DKIM DMARC</strong>
+            <span>Auth</span>
+            <strong>多用户隔离</strong>
           </div>
         </div>
       </section>
 
       <section class="login-card">
         <div class="login-heading">
-          <span class="eyebrow">Admin</span>
-          <h2>登录控制台</h2>
+          <span class="eyebrow" id="modeEyebrow">Sign in</span>
+          <h2 id="modeTitle">登录控制台</h2>
         </div>
+
+        <div class="auth-tabs">
+          <button class="active" data-mode="login" type="button">登录</button>
+          <button data-mode="register" type="button">注册</button>
+        </div>
+
         <form id="loginForm" class="login-form">
           <label>
-            用户名
+            用户名或邮箱
             <input name="username" autocomplete="username" required autofocus>
           </label>
           <label>
@@ -45,8 +51,25 @@
             <input name="password" type="password" autocomplete="current-password" required>
           </label>
           <button type="submit">登录</button>
-          <p id="loginMessage" class="login-message" role="alert"></p>
         </form>
+
+        <form id="registerForm" class="login-form hidden">
+          <label>
+            用户名
+            <input name="username" autocomplete="username" minlength="3" required>
+          </label>
+          <label>
+            邮箱
+            <input name="email" type="email" autocomplete="email" required>
+          </label>
+          <label>
+            密码
+            <input name="password" type="password" autocomplete="new-password" minlength="8" required>
+          </label>
+          <button type="submit">注册并进入</button>
+        </form>
+
+        <p id="loginMessage" class="login-message" role="alert"></p>
       </section>
     </main>
     <script src="/login.js" type="module"></script>

+ 37 - 6
public/login.js

@@ -1,21 +1,52 @@
-const form = document.querySelector('#loginForm');
+const loginForm = document.querySelector('#loginForm');
+const registerForm = document.querySelector('#registerForm');
 const message = document.querySelector('#loginMessage');
+const modeTitle = document.querySelector('#modeTitle');
+const modeEyebrow = document.querySelector('#modeEyebrow');
+const tabs = document.querySelectorAll('[data-mode]');
 
-form.addEventListener('submit', async (event) => {
+for (const tab of tabs) {
+  tab.addEventListener('click', () => setMode(tab.dataset.mode));
+}
+
+loginForm.addEventListener('submit', async (event) => {
+  event.preventDefault();
+  await submitAuth('/api/login', loginForm, loginForm.querySelector('button'));
+});
+
+registerForm.addEventListener('submit', async (event) => {
   event.preventDefault();
+  await submitAuth('/api/register', registerForm, registerForm.querySelector('button'));
+});
+
+if (window.location.pathname === '/register') setMode('register');
+
+function setMode(mode) {
+  const registering = mode === 'register';
+  loginForm.classList.toggle('hidden', registering);
+  registerForm.classList.toggle('hidden', !registering);
+  modeTitle.textContent = registering ? '注册账号' : '登录控制台';
+  modeEyebrow.textContent = registering ? 'Register' : 'Sign in';
+  for (const tab of tabs) tab.classList.toggle('active', tab.dataset.mode === mode);
+  message.textContent = '';
+  message.className = 'login-message';
+  const input = (registering ? registerForm : loginForm).querySelector('input');
+  input?.focus();
+}
+
+async function submitAuth(path, form, button) {
   message.textContent = '';
   message.className = 'login-message';
-  const button = form.querySelector('button');
   button.disabled = true;
   try {
     const payload = Object.fromEntries(new FormData(form).entries());
-    const response = await fetch('/api/login', {
+    const response = await fetch(path, {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify(payload)
     });
     const data = await response.json().catch(() => ({}));
-    if (!response.ok) throw new Error(data.error || '登录失败。');
+    if (!response.ok) throw new Error(data.error || '请求失败。');
     window.location.href = '/';
   } catch (error) {
     message.textContent = error.message;
@@ -23,4 +54,4 @@ form.addEventListener('submit', async (event) => {
   } finally {
     button.disabled = false;
   }
-});
+}

+ 36 - 0
public/styles.css

@@ -623,6 +623,42 @@ code {
   padding-top: 7px;
 }
 
+.mini-list {
+  display: grid;
+  gap: 8px;
+}
+
+.mini-row {
+  align-items: start;
+  background: #fbfcfe;
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  display: flex;
+  gap: 8px;
+  justify-content: space-between;
+  padding: 9px;
+}
+
+.mini-row > div:first-child {
+  display: grid;
+  gap: 3px;
+  min-width: 0;
+}
+
+.mini-row strong,
+.mini-row span {
+  overflow-wrap: anywhere;
+}
+
+.small-danger {
+  min-height: 30px;
+  padding: 0 9px;
+}
+
+body.busy button {
+  cursor: wait;
+}
+
 .notice {
   background: #fff5f4;
   border: 1px solid #f0b6b1;

+ 10 - 2
src/server.js

@@ -172,12 +172,16 @@ async function handleApi(req, res, url, user) {
     if (!domain) return sendJson(res, 400, { error: '域名格式不正确。' });
     const selector = normalizeSelector(body.selector || defaultSelector());
     if (!selector) return sendJson(res, 400, { error: 'DKIM selector 格式不正确。' });
+    const dnsCredentialId = Number(body.dnsCredentialId || 0) || null;
+    if (dnsCredentialId && !getDnsCredential(dnsCredentialId, user.id)) {
+      return sendJson(res, 400, { error: 'DNS 凭据不存在。' });
+    }
     const keys = createDkimKeyPair();
     try {
       const row = createDomain(user.id, {
         domain,
         selector,
-        dnsCredentialId: Number(body.dnsCredentialId || 0) || null,
+        dnsCredentialId,
         verificationToken: crypto.randomBytes(18).toString('hex'),
         dkimPublic: keys.publicKey,
         dkimPrivate: keys.privateKey,
@@ -274,9 +278,13 @@ async function handleApi(req, res, url, user) {
     }
     if (method === 'PATCH' && !action) {
       const body = await readJson(req);
+      const dnsCredentialId = body.dnsCredentialId !== undefined ? Number(body.dnsCredentialId || 0) || null : undefined;
+      if (dnsCredentialId && !getDnsCredential(dnsCredentialId, user.id)) {
+        return sendJson(res, 400, { error: 'DNS 凭据不存在。' });
+      }
       const row = updateDomain(id, user.id, {
         selector: body.selector ? normalizeSelector(body.selector) : undefined,
-        dnsCredentialId: body.dnsCredentialId !== undefined ? Number(body.dnsCredentialId || 0) || null : undefined,
+        dnsCredentialId,
         senderHost: body.senderHost ? normalizeHostname(body.senderHost) : undefined,
         sendingIp: body.sendingIp !== undefined ? String(body.sendingIp).trim() : undefined,
         spfExtra: body.spfExtra !== undefined ? String(body.spfExtra).trim() : undefined,