Files
AriWeb-Suite/modules/webmaker/public/domains-ui.js
T

743 lines
82 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ============================================================================
* 빌더 "도메인" 탭 (v1.4.0 → v1.6.35 프로세스 개편 → v1.6.40 세 화면 → v1.7.2 역할 분리: 도메인 = 구매·갱신·네임서버 이전, DNS = 사이트↔도메인 연결 + 레코드, 메일 = 계정·접속 주소·초기 관리자 + MX)
*
* 원칙(v1.6.35 요구 1): 자기 홈페이지 주소는 사용자가 스스로 마련하고 관리한다. 관리자는 접속 정보·플랫폼 도메인·서브 도메인 허용 여부만 정한다.
*
* 계정 종류(관리자가 정함)에 따라 보이는 기능이 다르다.
* - 서브 도메인 계정: 플랫폼 도메인(예: ibit.kr) 아래 이름(mycompany.ibit.kr)을 검색·등록·해제하고 사이트에 연결한다. (관리자가 서브 도메인 등록을 금지하면 이유가 보인다)
* - 자체 도메인 계정: "도메인 준비" 카드에서 세 가지 길 중 하나를 고른다.
* ① 구매 : 검색(가격 포함) → 장바구니에 담기 → 장바구니에서 최종 선택해 구매(금액을 한 번 더 확인). 가격은 장바구니를 열 때마다 다시 받아 바뀐 값을 표시한다.
* ② 네임서버 이전 : 다른 곳에서 산 도메인. 쓰고 있는 하위 이름을 적으면 지금 DNS 레코드를 읽어 이 서비스의 DNS 영역에 옮기고, 바꿀 네임서버를 보여 준다.
* 사용자가 "네임서버 변경했습니다" 를 누르면 실제로 바뀌었는지 확인하고, 확인될 때까지 "DNS 변경 진행 중" 으로 표시해 레코드 수정을 잠근다(이중 수정 방지).
* ③ 직접 DNS 관리 : 다른 곳에서 산 도메인을 자기 DNS 에서 관리. 배포·메일에 필요한 레코드를 보여 주고, "설정 완료 — 확인" 으로 실제 반영 여부를 확인한다.
* 확인되지 않으면 "DNS 변경이 완료되지 않았거나 반영되지 않아 배포가 되지 않는다" 고 알린다.
* ④ 자동 설정 : 사이트에 연결 → 발행 때 웹서버 설정·SSL 적용, 메일(MX) 자동 설정. "도메인 상태" 가 어느 단계에서 막혔는지(배포가 안 되는 이유·메일을 못 쓰는 이유)를 보인다.
* - 두 종류 모두: "메일(MX) 자동 설정" 으로 받는 메일 MX·SPF·DMARC 와 Resend 보내기 레코드(DKIM 등)를 자동으로 만든다(직접 DNS 관리 도메인은 넣을 레코드를 안내).
* 변경이 있는 모든 입력(연결·레코드·등록자 정보 등)은 그 자리마다 실행 버튼을 둔다.
* 전역 의존: $, api, T, current, openSite, krwFmt (app.js)
* ========================================================================== */
(() => {
let data = null; // GET /api/domains 응답
let way = 'buy'; // 도메인 준비 카드에서 고른 길: buy | ns | manual
/**
* v1.6.40 (요구 1): 도메인 탭이 세 화면으로 나뉘었다 — view 에 따라 그리는 상자와 내용이 다르다.
* - 'domains' (#domainsBody): 도메인 준비(서브 도메인 등록 / 검색·구매 / 이미 가진 도메인 등록) + 내 도메인(연결·확인·메일) — 1.6.39 까지의 도메인 탭
* - 'dns' (#dnsBody) : 도메인별 DNS 레코드·전파·직접 관리 도메인의 필요한 설정 확인 — 배포·메일이 의존하는 중간 단계
* - 'mail' (#mailBody) : 도메인별 메일(MX) 자동 설정·상태, 현재 사이트의 메일 시스템·웹메일
*/
let view = 'domains';
const boxOf = () => $(view === 'dns' ? 'dnsBody' : view === 'mail' ? 'mailBody' : 'domainsBody');
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = text; return e; };
const btn = (text, cls, onclick) => { const b = el('button', cls || 'ghost small-btn', text); b.type = 'button'; b.onclick = onclick; return b; };
const won = (n) => krwFmt(Number(n) || 0); // v1.4.1 캐시 표시 단위(관리자 설정)를 따른다 — app.js krwFmt
const dt = (s) => (s ? new Date(s).toLocaleDateString() : '-');
const dtm = (s) => (s ? new Date(s).toLocaleString() : '-');
/** 결과·오류 한 줄 표시 (ok=false 면 빨간 글씨) */
const say = (node, text, ok = true) => { node.textContent = text || ''; node.style.color = ok ? '' : '#b91c1c'; };
/** 노란 안내 상자(전파 대기·진행 중) 표시/숨김 */
const wait = (node, text) => { if (text) { node.textContent = text; node.classList.remove('hidden'); } else { node.textContent = ''; node.classList.add('hidden'); } };
/** 캐시 잔액 (응답의 credit.balance) */
const balance = () => (data && data.credit ? Number(data.credit.balance) || 0 : 0);
/** 도메인 종류 표시 이름 */
const kindLabel = (d) => d.type === 'subdomain' ? '서브 도메인' : d.type === 'registered' ? '구입 도메인' : d.mode === 'ns' ? '네임서버 이전' : '직접 DNS 관리';
/**
* 화면을 연다. @param {'domains'|'dns'|'mail'} [v] 그릴 화면 (기본 도메인)
*/
async function load(v) {
view = ['domains', 'dns', 'mail'].includes(v) ? v : 'domains';
const box = boxOf(); if (!box) return;
box.innerHTML = ''; box.appendChild(el('p', 'muted small', '불러오는 중…'));
try { data = await api('/api/domains'); } catch (e) { box.innerHTML = ''; box.appendChild(el('p', 'muted small', '도메인 정보를 불러오지 못했습니다: ' + e.message)); return; }
render();
}
async function refreshList() { try { data = await api('/api/domains'); } catch (_) { return; } render(); if (window.FlowUI) FlowUI.invalidateDomains(); } // v1.6.40: 레일 배지·홈 지도도 새 자료로
/** "내 도메인" 카드(마지막 카드)만 새로 그린다 — 위쪽 폼의 결과 안내(옮긴 레코드·네임서버)를 지우지 않기 위해 (DNS·메일 화면은 전체를 다시 그린다) */
async function refreshMyDomains() { try { data = await api('/api/domains'); } catch (_) { return; } if (view !== 'domains') { render(); return; } const cards = boxOf().querySelectorAll('section.card'); const last = cards[cards.length - 1]; if (last) last.replaceWith(myDomainsCard()); if (window.FlowUI) FlowUI.invalidateDomains(); }
function render() {
const box = boxOf(); if (!box) return; box.innerHTML = '';
if (view === 'dns') return renderDns(box);
if (view === 'mail') return renderMail(box);
const sub = data.accountType === 'subdomain';
// v1.7.2 (요구 1): 도메인 화면은 "구매·갱신·네임서버 이전" 만 다룬다. 사이트 연결·레코드·메일은 DNS·메일 화면으로 옮겼다
const head = el('section', 'card');
head.appendChild(el('h2', null, '도메인'));
head.appendChild(el('p', 'muted small', sub
? `서브 도메인 계정입니다. ${data.platform.domain || '플랫폼 도메인'} 아래 이름(예: mycompany.${data.platform.domain || 'ibit.kr'})을 무료로 등록합니다. 등록한 이름을 사이트에 연결하는 일은 "DNS" 화면에서 합니다. 직접 도메인을 사려면 관리자에게 계정 종류 변경을 요청하세요.`
: '여기서는 도메인을 사고(구매·갱신) 다른 곳에서 산 도메인을 네임서버 이전으로 가져오는 일만 다룹니다. 등록 업체(기관) 이전은 지원하지 않습니다. 사이트를 어느 도메인에 어떤 이름으로 올릴지는 "DNS" 화면에서, 메일은 "메일" 화면에서 정합니다.'));
head.appendChild(el('p', 'muted small', `캐시 잔액 ${won(balance())}`));
box.appendChild(head);
if (sub) box.appendChild(subdomainCard()); else box.appendChild(prepareCard());
box.appendChild(myDomainsCard());
}
// ---------- 서브 도메인 등록 ----------
function subdomainCard() {
const c = el('section', 'card'); c.appendChild(el('h2', null, '서브 도메인 등록'));
if (!data.platform.domain) { c.appendChild(el('p', 'muted small', '플랫폼 도메인이 아직 설정되지 않았습니다. 관리자에게 문의하세요.')); return c; }
// v1.6.35 (요구 1-1): 관리자가 서브 도메인 등록을 금지하면 이유를 보이고 입력을 막는다 (이미 등록된 것은 아래 "내 도메인" 에 그대로)
if (data.platform.allowSubdomain === false) { c.appendChild(el('p', 'small dom-wait', '이 서비스는 서브 도메인 등록을 받지 않습니다 (관리자 설정). 이미 등록한 서브 도메인은 그대로 쓸 수 있습니다. 자기 도메인을 쓰려면 관리자에게 계정 종류 변경을 요청하세요.')); return c; }
const row = el('div', 'row dom-sub-row'); const inp = el('input'); inp.placeholder = 'mycompany'; inp.maxLength = 32; inp.autocomplete = 'off';
row.appendChild(inp); row.appendChild(el('span', 'dom-suffix', '.' + data.platform.domain));
const msg = el('p', 'muted small');
const bCheck = btn('중복 확인', 'ghost small-btn', async () => {
say(msg, '확인 중…'); try { const r = await api('/api/domains/subdomain/check', { method: 'POST', body: JSON.stringify({ name: inp.value.trim() }) }); say(msg, r.available ? `${r.fqdn} — 등록할 수 있습니다` : `${r.fqdn} — ${r.reason}`, r.available); } catch (e) { say(msg, e.message, false); }
});
const bReg = btn('등록', 'primary small-btn', async () => {
say(msg, '등록 중…'); try { const r = await api('/api/domains/subdomain', { method: 'POST', body: JSON.stringify({ name: inp.value.trim() }) }); say(msg, `${r.name} 를 등록했습니다. 아래 "내 도메인" 에서 사이트에 연결하세요`); inp.value = ''; await refreshList(); } catch (e) { say(msg, e.message, false); }
});
row.appendChild(bCheck); row.appendChild(bReg); c.appendChild(row); c.appendChild(msg);
c.appendChild(el('p', 'muted small', '영문 소문자·숫자·하이픈 1~32자. 등록은 무료이며 계정당 10개까지입니다.'));
return c;
}
// ---------- ① 도메인 준비 (구매 / 네임서버 이전 / 직접 DNS 관리) ----------
/** 세 가지 길을 탭처럼 고르는 카드 — 고른 길에 따라 아래 내용이 바뀐다 */
function prepareCard() {
const c = el('section', 'card'); c.appendChild(el('h2', null, '① 도메인 준비'));
const tabs = el('div', 'row dom-ways');
const WAYS = [['buy', '도메인 검색·구매', '새 도메인을 검색해 장바구니에 담고 구매합니다'], ['ns', '이미 가진 도메인 — 네임서버 이전', '다른 곳에서 산 도메인의 네임서버를 이 서비스로 바꿔 DNS 를 여기서 관리합니다'], ['manual', '이미 가진 도메인 — 직접 DNS 관리', '다른 곳에서 산 도메인의 DNS 를 그곳에서 직접 관리합니다 (필요한 레코드를 안내)']];
const body = el('div');
const draw = () => {
tabs.innerHTML = ''; for (const [id, label, title] of WAYS) { const b = btn(label, (way === id ? 'primary' : 'ghost') + ' small-btn', () => { way = id; draw(); }); b.title = title; tabs.appendChild(b); }
body.innerHTML = ''; body.appendChild(el('p', 'muted small', WAYS.find((w) => w[0] === way)[2]));
if (way === 'buy') { body.appendChild(searchBlock()); body.appendChild(cartBlock()); if (data.providerReady) body.appendChild(contactCard()); } // 도메인 서비스 준비 전에는 등록자 정보를 저장할 곳(공급사)이 없어 숨김
else body.appendChild(externalForm(way));
};
c.appendChild(tabs); c.appendChild(body); draw();
return c;
}
// ---------- ①-구매: 검색 → 장바구니 ----------
function searchBlock() {
const c = el('div', 'dom-block'); c.appendChild(el('h3', null, '도메인 검색'));
if (!data.providerReady) { c.appendChild(el('p', 'muted small', '도메인 서비스가 아직 준비되지 않았습니다(등록 업체 접속 정보 없음). 관리자에게 문의하세요. 이미 가진 도메인은 "네임서버 이전" 또는 "직접 DNS 관리" 로 등록할 수 있습니다.')); return c; }
const row = el('div', 'row dom-search'); const inp = el('input'); inp.placeholder = 'mycompany 또는 mycompany.com'; inp.autocomplete = 'off'; row.appendChild(inp);
const tl = el('div', 'dom-tlds'); const checks = [];
for (const t of data.tlds) { const lb = el('label', 'inline'); const cb = el('input'); cb.type = 'checkbox'; cb.value = t; cb.checked = ['com', 'net', 'co'].includes(t) || data.tlds.length <= 3; checks.push(cb); lb.appendChild(cb); lb.appendChild(document.createTextNode(' .' + t)); tl.appendChild(lb); }
const msg = el('p', 'muted small'); const out = el('div', 'dom-results');
const go = async () => {
say(msg, '검색 중…'); out.innerHTML = '';
try {
const r = await api('/api/domains/search', { method: 'POST', body: JSON.stringify({ name: inp.value.trim(), tlds: checks.filter((x) => x.checked).map((x) => x.value) }) });
say(msg, '가격은 1년 기준이며 구매 때 캐시에서 차감됩니다. 쓸 도메인을 장바구니에 담고, 장바구니에서 최종 선택해 구매하세요.');
const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const h of ['도메인', '상태', '가격 (1년)', '']) hr.appendChild(el('th', null, h)); tb.appendChild(hr);
for (const x of r.results) {
const tr = el('tr'); tr.appendChild(el('td', null, x.domain));
tr.appendChild(el('td', null, x.available ? (x.premium ? '구매 가능 (프리미엄)' : '구매 가능') : '사용 중'));
tr.appendChild(el('td', null, x.quote ? won(x.quote.costKrw) : '-'));
const td = el('td');
if (x.available && x.quote) {
const inCart = (data.cart || []).some((i) => i.domain === x.domain);
const b = btn(inCart ? '장바구니에 있음' : '장바구니에 담기', 'primary small-btn', async () => { b.disabled = true; try { const rr = await api('/api/domains/cart', { method: 'POST', body: JSON.stringify({ domain: x.domain }) }); data.cart = rr.items; b.textContent = '장바구니에 있음'; const cb = $('domCartBody'); if (cb) drawCart(cb, data.cart); } catch (e) { b.disabled = false; say(msg, e.message, false); } });
if (inCart) b.disabled = true; td.appendChild(b);
}
tr.appendChild(td); tb.appendChild(tr);
}
out.appendChild(tb);
} catch (e) { say(msg, e.message, false); }
};
inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') go(); });
row.appendChild(btn('검색', 'primary small-btn', go));
c.appendChild(row); c.appendChild(tl); c.appendChild(msg); c.appendChild(out);
return c;
}
/** 장바구니(보관 목록): 열 때 가격·등록 가능 여부를 다시 받아 바뀐 값을 표시. 여기서 최종 선택해 구매한다 */
function cartBlock() {
const c = el('div', 'dom-block'); const h = el('h3', null, `장바구니 (${(data.cart || []).length})`); c.appendChild(h);
if (!data.providerReady) return c;
const body = el('div'); body.id = 'domCartBody'; c.appendChild(body);
drawCart(body, data.cart || []);
if ((data.cart || []).length) { body.classList.add('dom-refreshing'); api('/api/domains/cart').then((r) => { data.cart = r.items; drawCart(body, data.cart); h.textContent = `장바구니 (${data.cart.length})`; }).catch(() => {}).finally(() => body.classList.remove('dom-refreshing')); }
return c;
}
function drawCart(body, items) {
body.innerHTML = '';
if (!items.length) { body.appendChild(el('p', 'muted small', '장바구니가 비어 있습니다. 위에서 검색해 쓸 도메인을 담으세요.')); return; }
const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const hh of ['도메인', '상태', '가격 (1년)', '']) hr.appendChild(el('th', null, hh)); tb.appendChild(hr);
for (const i of items) {
const tr = el('tr'); tr.appendChild(el('td', null, i.domain));
tr.appendChild(el('td', null, i.refreshError ? '가격 확인 실패' : i.available ? (i.premium ? '구매 가능 (프리미엄)' : '구매 가능') : '사용 중 (다른 사람이 등록)'));
const price = el('td', null, i.quote ? won(i.quote.costKrw) : '-'); if (i.priceChanged && i.prevKrw != null) { price.appendChild(el('span', 'muted small', ` (담을 때 ${won(i.prevKrw)})`)); price.style.fontWeight = '600'; } tr.appendChild(price);
const td = el('td'); if (i.available && i.quote) td.appendChild(btn('구매', 'primary small-btn', () => buyDialog(i.domain, i.quote)));
td.appendChild(btn('빼기', 'ghost small-btn', async () => { try { const r = await api('/api/domains/cart', { method: 'DELETE', body: JSON.stringify({ domain: i.domain }) }); data.cart = r.items; drawCart(body, data.cart); } catch (e) { alert(e.message); } }));
tr.appendChild(td); tb.appendChild(tr);
}
const wrap = el('div', 'dom-table-wrap'); wrap.appendChild(tb); body.appendChild(wrap);
body.appendChild(el('p', 'muted small', `가격은 장바구니를 열 때마다 다시 확인합니다 (마지막 확인 ${dtm(items[0].checkedAt)}). 구매 버튼을 누르면 금액을 한 번 더 확인합니다.`));
}
/**
* 구매 확인 창: 기간(년)을 바꾸면 견적을 다시 받는다. 확인을 누를 때 본 금액(expectedKrw)을 함께 보내
* 그 사이 가격이 오르면 서버가 막는다(409 + 새 견적).
*/
function buyDialog(domain, firstQuote) {
const dlg = $('domDialog'); const body = $('domDialogBody'); body.innerHTML = ''; $('domDialogTitle').textContent = '도메인 구매 확인';
let q = firstQuote; let period = 1;
const info = el('div'); const msg = el('p', 'muted small');
const sel = el('select'); for (let y = 1; y <= 5; y++) { const o = el('option', null, `${y}년`); o.value = y; sel.appendChild(o); }
const draw = () => {
info.innerHTML = '';
info.appendChild(el('p', null, domain));
const after = balance() - q.costKrw;
info.appendChild(el('p', 'dom-cost', `결제 금액 ${won(q.costKrw)} (${period}년)`));
info.appendChild(el('p', 'muted small', `공급사 가격 ${q.base.amount} ${q.base.currency} × 요율 ${q.marginPct}% · 캐시 잔액 ${won(balance())} → 구매 후 ${won(after)}`));
if (after < 0) info.appendChild(el('p', 'small', '캐시가 부족합니다. 캐시를 충전한 뒤 구매하세요.'));
if (!data.contact) info.appendChild(el('p', 'small', '먼저 "등록자 정보" 를 저장해야 구매할 수 있습니다 (도메인 검색·구매 아래).'));
};
sel.onchange = async () => {
period = Number(sel.value); say(msg, '가격 확인 중…');
try { q = await api('/api/domains/quote', { method: 'POST', body: JSON.stringify({ domain, operation: 'create', period }) }); say(msg, ''); draw(); } catch (e) { say(msg, e.message, false); }
};
const lb = el('label', null, '등록 기간'); lb.appendChild(sel);
body.appendChild(lb); body.appendChild(info); body.appendChild(msg);
const row = el('div', 'row');
row.appendChild(btn('구매', 'primary small-btn', async () => {
say(msg, '등록 중… (최대 1분)');
try {
const r = await api('/api/domains/purchase', { method: 'POST', body: JSON.stringify({ domain, period, expectedKrw: q.costKrw }) });
say(msg, `${r.name} 를 등록했습니다.`); dlg.close(); await load();
} catch (e) {
if (e.body && e.body.quote) { q = e.body.quote; draw(); } // 가격이 바뀜 — 새 금액을 보여 주고 다시 확인받는다
say(msg, e.message, false);
}
}));
row.appendChild(btn('취소', 'ghost small-btn', () => dlg.close()));
body.appendChild(row); draw(); dlg.showModal();
}
// ---------- ①-이미 가진 도메인: 네임서버 이전(ns) / 직접 DNS 관리(manual) ----------
/**
* 등록 폼. 공통: 도메인 이름 + "이미 구매(소유)했습니다" 확인(필수 — 서버도 owned 없이는 진행하지 않음).
* ns: 쓰고 있는 하위 이름을 받아 레코드를 옮긴다 → 결과로 옮긴 레코드와 바꿀 네임서버, "네임서버 변경했습니다" 버튼.
* manual: 결과로 필요한 DNS 설정 표와 "설정 완료 — 확인" 버튼.
*/
function externalForm(mode) {
const c = el('div', 'dom-block');
if (mode === 'ns') {
c.appendChild(el('p', 'muted small', '도메인의 등록 업체(기관) 이전은 지원하지 않습니다. 대신 등록 업체에서 네임서버만 이 서비스로 바꾸면 DNS 레코드를 여기서 관리하고 사이트·메일을 자동으로 설정할 수 있습니다. 진행 순서: 지금 쓰는 DNS 레코드를 읽어 새 영역에 옮김 → 바꿀 네임서버 안내 → 등록 업체에서 네임서버 변경 → "네임서버 변경했습니다" 로 확인 → 전파 확인(최대 48시간) → 사용. 전파 확인 전까지는 "DNS 변경 진행 중" 으로 표시되고 레코드 수정이 잠깁니다(이중 수정 방지).'));
if (!data.dnsReady) c.appendChild(el('p', 'small dom-wait', '이 서비스의 DNS 공급자가 아직 준비되지 않아 네임서버 이전은 지금 쓸 수 없습니다. "직접 DNS 관리" 로 등록하거나 관리자에게 문의하세요.'));
} else c.appendChild(el('p', 'muted small', '도메인을 이미 구매했고 DNS 레코드를 그 업체(또는 다른 DNS 서비스)에서 직접 관리하는 경우입니다. 등록하면 배포와 메일 서비스에 필요한 DNS 레코드를 보여 드립니다. 그곳에 넣은 뒤 "설정 완료 — 확인" 을 누르면 실제로 반영됐는지 확인해 사용 가능 여부를 보여 줍니다.'));
const grid = el('div', 'dom-grid');
const lbD = el('label', null, '도메인'); const inp = el('input'); inp.placeholder = 'mycompany.com'; inp.autocomplete = 'off'; lbD.appendChild(inp); grid.appendChild(lbD);
let subs = null;
if (mode === 'ns') { const lbS = el('label', null, '쓰고 있는 하위 이름 (쉼표로 구분, 선택)'); subs = el('input'); subs.placeholder = 'www, blog, shop'; lbS.appendChild(subs); grid.appendChild(lbS); }
c.appendChild(grid);
const own = el('label', 'inline'); const cb = el('input'); cb.type = 'checkbox'; own.appendChild(cb); own.appendChild(document.createTextNode(' 이 도메인을 이미 구매(소유)했습니다 — 아직 없으면 "도메인 검색·구매" 로 구매하세요')); c.appendChild(own);
const msg = el('p', 'muted small'); const out = el('div');
const row = el('div', 'row');
const b = btn(mode === 'ns' ? '레코드 옮기고 네임서버 받기' : '등록하고 필요한 DNS 설정 보기', 'primary small-btn', async () => {
if (!inp.value.trim()) return say(msg, '도메인을 입력하세요', false);
if (!cb.checked) return say(msg, '도메인을 이미 구매(소유)했는지 확인해 주세요', false);
say(msg, mode === 'ns' ? '공용 DNS 에서 지금 레코드를 읽고 영역을 만드는 중… (최대 1분)' : '도메인을 확인하는 중…'); out.innerHTML = ''; b.disabled = true;
try {
const r = await api('/api/domains/external', { method: 'POST', body: JSON.stringify({ domain: inp.value.trim(), mode, subdomains: subs ? subs.value : '', owned: true }) });
say(msg, r.message);
if (mode === 'ns') {
if (r.imported && r.imported.length) { out.appendChild(el('p', 'small', `옮긴 레코드 ${r.imported.length}개:`)); out.appendChild(recordTable(r.imported.map((x) => ({ type: x.type, name: x.name, value: x.value, prio: x.prio })), null)); }
else out.appendChild(el('p', 'small', '공용 DNS 에서 읽은 레코드가 없습니다(zone 파일 구성을 찾을 수 없음) — 빈 영역을 만들었습니다. 사이트에 연결하면 사이트 레코드가 만들어지고, 필요한 레코드는 "DNS 레코드" 에서 직접 넣을 수 있습니다.'));
if (r.next === 'ns-change') out.appendChild(nsChangeBox(r.domain, r.nameServers, r.currentNs));
} else out.appendChild(el('p', 'small', '아래 "내 도메인" 에서 "필요한 DNS 설정" 을 열어 레코드를 넣고 확인하세요.'));
inp.value = ''; cb.checked = false; b.disabled = false;
await refreshMyDomains(); // 결과 안내는 그대로 두고 아래 "내 도메인" 만 새로 그린다
} catch (e) { say(msg, e.message, false); b.disabled = false; }
});
row.appendChild(b); c.appendChild(row); c.appendChild(msg); c.appendChild(out);
return c;
}
/**
* 바꿀 네임서버 안내 상자 — v1.6.38 (요구 3): 네임서버 목록은 "네임서버 정보" 팝업(복사 가능)으로 보이고, 본문에는 요약과 "네임서버 변경했습니다" 버튼만 둔다.
* 네임서버가 여러 개라 화면이 복잡해지는 것을 막고, 팝업 안에서는 값마다 Ctrl-C·복사 버튼으로 가져갈 수 있다.
*/
function nsChangeBox(d, nameServers, currentNs) {
const box = el('div', 'dom-ns');
box.appendChild(el('p', 'small', `등록 업체(${d.name} 을 구매한 곳)의 도메인 설정에서 네임서버를 이 서비스의 네임서버 ${nameServers.length}개로 바꿔야 합니다${currentNs && currentNs.length ? ` (지금: ${currentNs.join(', ')})` : ''}. 어디서 어떻게 바꾸는지와 복사할 값은 "네임서버 정보" 에 있습니다.`));
const row = el('div', 'row'); const msg = el('p', 'muted small');
row.appendChild(btn('네임서버 정보', 'ghost small-btn', () => nsInfoDialog(d, nameServers, currentNs)));
row.appendChild(btn('네임서버 변경했습니다', 'primary small-btn', async () => {
if (!confirm(`등록 업체에서 ${d.name} 의 네임서버를 "네임서버 정보" 의 목록으로 바꾸어 저장하셨나요?\n확인을 누르면 공용 DNS 에서 실제로 바뀌었는지 확인합니다.`)) return;
say(msg, '확인 중…'); try { const r = await api(`/api/domains/${d.id}/transfer-check`, { method: 'POST', body: JSON.stringify({ confirmed: true }) }); say(msg, r.message, r.done || r.step === 'propagating'); if (r.done) await refreshList(); } catch (e) { say(msg, e.message, false); }
}));
box.appendChild(row); box.appendChild(msg);
return box;
}
// ---------- v1.6.38 (요구 3): 네임서버 정보 · DNS 레코드 정보 팝업 (복사 가능) ----------
/** 클립보드 복사 — https 가 아니거나 막히면 false (그때는 값을 직접 선택해 Ctrl-C) */
async function copyText(text) { try { await navigator.clipboard.writeText(text); return true; } catch (_) { return false; } }
/** 복사 버튼: 누르면 복사하고 잠시 "복사됨" 으로 바뀐다 */
function copyBtn(text, label) {
const b = btn(label || '복사', 'ghost small-btn dom-copy-btn', async () => { const ok = await copyText(text); const old = b.textContent; b.textContent = ok ? '복사됨' : '직접 선택해 Ctrl-C'; setTimeout(() => { b.textContent = old; }, 1500); });
b.title = '클립보드에 복사'; return b;
}
/** 값 칸: 클릭하면 값 전체가 선택되어 Ctrl-C 로 복사되고(user-select: all), 옆의 작은 버튼으로도 복사된다 */
function copyCell(value) {
const td = el('td', 'dom-val'); const v = String(value == null ? '' : value);
const code = el('code', 'dom-copy', v); code.title = '클릭하면 전체 선택 — Ctrl-C 로 복사'; code.onclick = () => { try { const r = document.createRange(); r.selectNodeContents(code); const sl = window.getSelection(); sl.removeAllRanges(); sl.addRange(r); } catch (_) {} };
td.appendChild(code); if (v) td.appendChild(copyBtn(v)); return td;
}
/**
* 안내 팝업 열기 (index.html 의 #domDialog 재사용). build(body) 가 내용을 채운다.
* 팝업 안의 글은 모두 선택·복사할 수 있다.
*/
function infoDialog(title, build) {
const dlg = $('domDialog'); const body = $('domDialogBody'); if (!dlg || !body) return;
body.innerHTML = ''; $('domDialogTitle').textContent = title; dlg.classList.add('wide');
const inner = el('div', 'dom-dialog-body'); build(inner); body.appendChild(inner);
const row = el('div', 'row dom-dialog-foot'); row.appendChild(btn('닫기', 'ghost', () => { dlg.classList.remove('wide'); dlg.close(); })); body.appendChild(row);
dlg.showModal();
}
/** 단계 안내 목록 (ol) */
function steps(items) { const ol = el('ol', 'dom-guide'); for (const t of items) ol.appendChild(el('li', null, t)); return ol; }
/**
* "네임서버 정보" 팝업 — 어디를 눌러 네임서버를 바꾸는지 + 바꿀 네임서버 목록(값마다 복사, 전체 복사).
* 네임서버 이전 도메인(mode ns)이 아니면(직접 DNS 관리) 네임서버 이전으로 바꾸는 방법만 안내한다 — 이 서비스의 네임서버는 영역을 만들 때 정해지기 때문.
*/
function nsInfoDialog(d, nameServers, currentNs) {
infoDialog(`네임서버 정보 — ${d.name}`, (box) => {
const prov = data.dnsProviderLabel ? `이 서비스의 DNS 공급자: ${data.dnsProviderLabel}` : '';
if (!nameServers || !nameServers.length) {
box.appendChild(el('p', 'small', `${d.name} 은 "직접 DNS 관리" 로 등록되어 있어 레코드를 도메인 업체(또는 쓰고 있는 DNS 서비스)에서 직접 관리합니다. 이 서비스가 DNS 를 맡게 하려면(네임서버 이전) 아래 순서로 바꾸세요. 레코드만 넣으려면 "DNS 레코드 정보" 를 누르세요.`));
box.appendChild(steps(['"내 도메인" 에서 이 도메인을 "해제" 합니다 (사이트 연결은 다시 하면 됩니다).', '"도메인 준비" 카드에서 "네임서버 이전" 을 고르고 도메인과 쓰고 있는 하위 이름을 적어 "레코드 옮기고 네임서버 받기" 를 누릅니다.', '그때 만들어지는 네임서버 목록을 등록 업체의 네임서버(DNS 서버) 설정에 넣고 "네임서버 변경했습니다" 를 누릅니다.']));
if (!data.dnsReady) box.appendChild(el('p', 'small dom-wait', '지금은 이 서비스의 DNS 공급자가 준비되지 않아 네임서버 이전을 쓸 수 없습니다. 관리자에게 문의하세요.'));
if (prov) box.appendChild(el('p', 'muted small', prov));
return;
}
box.appendChild(el('p', 'small', `도메인을 구매한 등록 업체(가비아·후이즈·GoDaddy·Namecheap 등)에서 ${d.name} 의 네임서버를 아래 ${nameServers.length}개로 바꾸면 이 서비스가 DNS 를 맡아 사이트·메일 레코드를 자동으로 넣습니다.${currentNs && currentNs.length ? ` 지금 네임서버: ${currentNs.join(', ')}` : ''}`));
box.appendChild(el('p', 'small', '어디를 누르나요?'));
box.appendChild(steps(['등록 업체에 로그인 → "도메인 관리"(My 도메인·Domain List) → ' + d.name + ' 선택', '"네임서버 설정"(네임서버 변경 · DNS 서버 · Nameservers) 메뉴를 엽니다 — "DNS 레코드 관리" 가 아닙니다', '"사용자 네임서버(Custom nameservers)" 를 고르고 기존 값을 지운 뒤 아래 목록을 1차·2차·3차·4차 순서로 붙여 넣습니다 (값 옆 "복사" 또는 값을 클릭해 Ctrl-C)', '저장한 뒤 이 화면으로 돌아와 "네임서버 변경했습니다" 를 누릅니다. 전파에는 몇 분~최대 48시간이 걸리고, 그동안 "DNS 변경 진행 중" 으로 표시됩니다.']));
const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const h of ['순서', '네임서버']) hr.appendChild(el('th', null, h)); tb.appendChild(hr);
nameServers.forEach((ns, i) => { const tr = el('tr'); tr.appendChild(el('td', null, `${i + 1}차`)); tr.appendChild(copyCell(ns)); tb.appendChild(tr); });
const tw = el('div', 'dom-table-wrap'); tw.appendChild(tb); box.appendChild(tw);
const row = el('div', 'row'); row.appendChild(copyBtn(nameServers.join('\n'), '네임서버 전체 복사 (줄바꿈)')); row.appendChild(copyBtn(nameServers.join(', '), '전체 복사 (쉼표)')); box.appendChild(row);
if (prov) box.appendChild(el('p', 'muted small', prov));
});
}
/** 레코드 목록 → 텍스트 (복사용). "이름 종류 [우선순위] 값" 한 줄씩 */
const recordsText = (recs) => recs.map((r) => [r.host || r.name, r.type, r.priority != null ? String(r.priority) : '', r.value].filter((x) => x !== '').join('\t')).join('\n');
/**
* "DNS 레코드 정보" 팝업 — 도메인 업체(또는 쓰는 DNS 서비스)의 DNS 레코드 관리에서 넣을 값. 사이트용(A)·메일용(MX·SPF·DMARC)·보내기 인증(DKIM 등)으로 나누고
* 값마다 복사·Ctrl-C 가 되게 한다. MX 는 우선순위까지 따로 강조한다.
*/
function dnsRecordsDialog(d, siteId) {
infoDialog(`DNS 레코드 정보 — ${d.name}`, async (box) => {
box.appendChild(el('p', 'muted small', '필요한 레코드를 계산하는 중…'));
let r; try { r = await api(`/api/domains/${d.id}/required?${siteId ? 'siteId=' + encodeURIComponent(siteId) + '&' : ''}mail=1`); } catch (e) { box.innerHTML = ''; box.appendChild(el('p', 'small', e.message)); return; }
box.innerHTML = '';
const recs = r.records || []; const siteRecs = recs.filter((x) => x.group === 'site'); const mailRecs = recs.filter((x) => x.group !== 'site' && x.purpose !== 'send'); const sendRecs = recs.filter((x) => x.purpose === 'send');
box.appendChild(el('p', 'small', `${d.name} 의 DNS 레코드를 직접 관리하는 경우(도메인 업체 또는 쓰고 있는 DNS 서비스) 아래 값을 그곳에 넣습니다. 사이트 IP: ${r.ip}`));
box.appendChild(el('p', 'small', '어디를 누르나요?'));
box.appendChild(steps(['도메인 업체(또는 DNS 서비스)에 로그인 → "도메인 관리" → ' + d.name + ' → "DNS 레코드 관리"(DNS 설정 · DNS 관리 · Manage DNS) 를 엽니다 — "네임서버 설정" 이 아닙니다', '레코드마다 "추가" 를 눌러 종류(A·MX·TXT…)·이름(호스트: @ 는 도메인 자체, www 등은 하위 이름)·값·우선순위(MX 만)를 아래 표대로 넣습니다. 값은 클릭하면 전체 선택되고 "복사" 버튼으로도 복사됩니다', 'TXT 값(SPF·DMARC·DKIM)은 따옴표 없이 그대로 붙여 넣습니다. 이미 v=spf1 레코드가 있으면 새로 만들지 말고 기존 값에 mx 를 더합니다', '저장 뒤 이 화면의 "필요한 DNS 설정" → "설정 완료 — 확인" 을 누르면 실제 반영을 확인합니다 (반영까지 몇 분~최대 48시간)']));
const table = (list, title, note) => {
if (!list.length) return;
box.appendChild(el('h3', 'dom-dialog-h', title)); if (note) box.appendChild(el('p', 'muted small', note));
const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const h of ['종류', '이름(호스트)', '값', '우선순위']) hr.appendChild(el('th', null, h)); tb.appendChild(hr);
for (const x of list) { const tr = el('tr'); const t = el('td', null, x.type); t.title = x.why || ''; tr.appendChild(t); tr.appendChild(copyCell(x.host || x.name)); tr.appendChild(copyCell(x.value)); tr.appendChild(x.priority != null ? copyCell(String(x.priority)) : el('td', null, '')); tb.appendChild(tr); }
const tw = el('div', 'dom-table-wrap'); tw.appendChild(tb); box.appendChild(tw);
const row = el('div', 'row'); row.appendChild(copyBtn(recordsText(list), `${title} 전체 복사`)); box.appendChild(row);
};
table(siteRecs, '사이트용 (A 레코드)', '홈페이지가 이 서비스의 서버로 열리게 하는 레코드. @ 와 www 둘 다 넣습니다.');
const mx = mailRecs.filter((x) => x.type === 'MX'); const txt = mailRecs.filter((x) => x.type !== 'MX');
table(mx, '메일용 — MX (받는 메일)', '받는 메일을 이 서비스의 메일 서버로 보내는 레코드. "우선순위(priority)" 칸이 따로 있는 업체가 많고, 없으면 값 앞에 "10 " 을 붙여 넣습니다. 값 끝의 점(.)을 요구하는 업체도 있습니다.');
table(txt, '메일용 — TXT (SPF · DMARC)', 'SPF 는 이름 @ 에, DMARC 는 이름 _dmarc 에 TXT 로 넣습니다.');
table(sendRecs, '보내기 인증 (DKIM 등)', r.mailNote || '보내는 메일이 스팸으로 분류되지 않게 하는 레코드. 메일 발송 서비스가 확인합니다.');
if (!mailRecs.length && !sendRecs.length) box.appendChild(el('p', 'muted small', r.mailNote || '메일용 레코드는 메일 서버가 준비되면 표시됩니다.'));
const all = el('div', 'row'); all.appendChild(copyBtn(recordsText(recs), '모든 레코드 복사 (이름·종류·우선순위·값, 탭 구분)')); box.appendChild(all);
});
}
/**
* 직접 구매한(또는 다른 곳에서 관리하는) 도메인의 사전 안내 줄 — 어떤 경우에 어느 버튼을 누르는지. v1.6.38 (요구 3)
* @param {object} d 도메인
* @param {() => string} siteIdOf 선택된 사이트 id (레코드 계산용)
*/
function externalGuide(d, siteIdOf) {
const g = el('div', 'dom-guide-box');
const ns = d.mode === 'ns';
g.appendChild(el('p', 'small', ns
? '이 도메인은 다른 곳에서 구매해 네임서버를 이 서비스로 옮기는 중이거나 옮긴 도메인입니다. 등록 업체에서 네임서버를 바꿔야 하면 "네임서버 정보" 를, 네임서버를 옮기지 않고 그 업체의 DNS 에 레코드를 직접 넣으려면 "DNS 레코드 정보" 를 누르세요. 표시되는 값은 모두 클릭해 Ctrl-C 로 복사할 수 있습니다.'
: '이 도메인은 직접 구매해 DNS 를 다른 곳에서 관리하는 도메인입니다. 그 업체의 DNS 레코드 관리에 넣을 값(사이트 A · 메일 MX/SPF/DMARC · 보내기 인증)은 "DNS 레코드 정보" 에, 네임서버를 이 서비스로 옮기는 방법은 "네임서버 정보" 에 있습니다. 표시되는 값은 모두 클릭해 Ctrl-C 로 복사할 수 있습니다.'));
const row = el('div', 'row');
row.appendChild(btn('네임서버 정보', 'ghost small-btn', () => nsInfoDialog(d, ns ? (d.nameServers || []) : [], d.currentNs)));
row.appendChild(btn('DNS 레코드 정보', 'ghost small-btn', () => dnsRecordsDialog(d, siteIdOf())));
g.appendChild(row);
return g;
}
// ---------- 등록자 정보 ----------
const CONTACT = [['firstName', '이름 (영문)', 'Gildong'], ['lastName', '성 (영문)', 'Hong'], ['company', '회사명 (선택)', ''], ['email', '이메일', 'you@example.com'], ['phoneCountry', '국가 번호', '+82'], ['phoneArea', '지역 번호 (선택)', '2'], ['phoneNumber', '전화번호', '12345678'], ['street', '도로명 (영문)', 'Teheran-ro'], ['number', '건물 번호', '123'], ['zipcode', '우편번호', '06123'], ['city', '도시 (영문)', 'Seoul'], ['state', '시·도 (선택)', ''], ['country', '국가 코드', 'KR']];
function contactCard() {
const c = el('details', 'dom-block dom-contact'); const sm = el('summary', null, data.contact ? '등록자 정보 (저장됨)' : '등록자 정보 (도메인 구매 전에 필요)'); c.appendChild(sm);
if (!data.contact) c.open = true;
c.appendChild(el('p', 'muted small', '도메인 소유자로 등록되는 정보입니다. 국제 등록 기관 규칙에 따라 영문으로 입력하세요. 공개 WHOIS 에는 가려집니다.'));
const grid = el('div', 'dom-grid'); const ins = {};
for (const [k, label, ph] of CONTACT) { const lb = el('label', null, label); const i = el('input'); i.placeholder = ph; i.value = (data.contact && data.contact[k]) || (k === 'phoneCountry' ? '+82' : k === 'country' ? 'KR' : ''); ins[k] = i; lb.appendChild(i); grid.appendChild(lb); }
const msg = el('span', 'muted small'); const row = el('div', 'row');
row.appendChild(btn('등록자 정보 저장', 'primary small-btn', async () => {
say(msg, '저장 중…'); const b = {}; for (const k of Object.keys(ins)) b[k] = ins[k].value.trim();
try { data.contact = await api('/api/domains/contact', { method: 'PUT', body: JSON.stringify(b) }); say(msg, '저장했습니다'); sm.textContent = '등록자 정보 (저장됨)'; } catch (e) { say(msg, e.message, false); }
}));
row.appendChild(msg); c.appendChild(grid); c.appendChild(row);
return c;
}
// ---------- ②~④ 내 도메인 ----------
// ---------- ② 내 도메인 — 구매·갱신·네임서버 (v1.7.2) ----------
/** 도메인 종류별 짧은 상태 배지 */
function domainStatePill(d) {
if (d.type === 'registered') { if (d.status === 'PENDING') return el('span', 'pill small waiting', '등록 처리 중'); if (d.status === 'FAILED') return el('span', 'pill small error', '등록 실패'); if (d.pending) return el('span', 'pill small waiting', 'DNS 전파 대기'); return el('span', 'pill small done', '사용 중'); }
if (d.type === 'external' && d.mode === 'ns') { const st = d.transfer ? d.transfer.step : 'ns-change'; return el('span', 'pill small ' + (st === 'done' ? 'done' : 'waiting'), st === 'done' ? '네임서버 이전 완료' : st === 'propagating' ? '네임서버 전파 중' : '네임서버 변경 필요'); }
if (d.type === 'external') { const m = d.manual || {}; return el('span', 'pill small ' + (m.siteOk ? 'done' : 'idle'), m.siteOk ? 'DNS 확인됨' : 'DNS 직접 관리'); }
return el('span', 'pill small done', '등록됨');
}
function myDomainsCard() {
const c = el('section', 'card'); c.appendChild(el('h2', null, data.accountType === 'subdomain' ? '내 서브 도메인' : '② 내 도메인 — 구매 · 갱신 · 네임서버'));
if (!data.domains.length) { c.appendChild(el('p', 'muted small', '아직 등록한 도메인이 없습니다.')); return c; }
c.appendChild(el('p', 'muted small', '사이트 연결·DNS 레코드는 "DNS" 화면, 메일(MX)은 "메일" 화면에서 다룹니다.'));
for (const d of data.domains) c.appendChild(domainRow(d));
return c;
}
/**
* 도메인 한 줄: 이름 · 종류 · 상태 + 종류별 관리 항목 — 구입 도메인은 만료·갱신, 네임서버 이전 도메인은 단계 표시(nsStepper), 직접 관리 도메인은 DNS 화면 안내.
* 사이트 연결 정보는 요약 한 줄로만 보이고 바꾸는 일은 DNS 화면에서 한다.
*/
function domainRow(d) {
const sub = d.type === 'subdomain'; const ext = d.type === 'external';
const it = el('div', 'dom-item');
const top = el('div', 'dom-top'); top.appendChild(el('b', null, d.name)); top.appendChild(el('span', 'pill idle small', kindLabel(d))); top.appendChild(domainStatePill(d));
if (d.type === 'registered' && d.status !== 'PENDING') top.appendChild(el('span', 'muted small', `만료 ${dt(d.expiresAt)}`));
it.appendChild(top);
const site = data.sites.find((s0) => s0.id === d.siteId);
const link = el('p', 'muted small'); link.appendChild(document.createTextNode(site ? `연결된 사이트: ${site.title} → ${(d.siteNames || []).join(', ') || d.name} · ` : '아직 사이트에 연결되지 않았습니다 · '));
const go = el('a', null, 'DNS 화면에서 연결·레코드 관리'); go.href = '#'; go.onclick = (e) => { e.preventDefault(); window.showTab('dns'); }; link.appendChild(go); it.appendChild(link);
const waitBox = el('p', 'small dom-wait hidden'); it.appendChild(waitBox); if (d.pending && !(ext && d.mode === 'ns')) checkPropagation(d, waitBox);
const msg = el('p', 'muted small');
if (ext && d.mode === 'ns') it.appendChild(nsStepper(d, msg));
if (ext && d.mode === 'manual') it.appendChild(el('p', 'small', 'DNS 를 직접 관리하는 도메인입니다. 사이트·메일에 필요한 레코드와 "설정 완료 — 확인" 은 DNS 화면의 이 도메인 항목에 있습니다.'));
if (d.type === 'registered' && d.status === 'PENDING') it.appendChild(el('p', 'small dom-wait', '등록 업체가 아직 등록을 처리하고 있습니다. 끝나면 DNS 영역이 자동으로 만들어집니다 (이 화면을 다시 열면 확인합니다).'));
const row = el('div', 'row dom-row');
if (d.type === 'registered') row.appendChild(btn('갱신 (1년)', 'primary small-btn', () => renewFlow(d, msg)));
if (sub || ext) row.appendChild(btn('해제', 'ghost small-btn danger', async () => {
if (!confirm(`${d.name} 를 이 서비스에서 해제할까요? 연결된 사이트는 이 주소로 열리지 않게 됩니다.${ext && d.mode === 'ns' ? '\n(DNS 영역은 공급자에 남습니다 — 네임서버를 다른 곳으로 옮긴 뒤 관리자에게 정리를 요청하세요)' : ''}`)) return;
try { const r = await api(`/api/domains/${d.id}`, { method: 'DELETE' }); if (r.note) alert(r.note); await refreshList(); } catch (e) { say(msg, e.message, false); }
}));
if (row.children.length) it.appendChild(row); it.appendChild(msg);
return it;
}
/**
* v1.7.2 (요구 1): 네임서버 이전 진행 단계 — ① 영역 준비·레코드 옮김 → ② 등록 업체에서 네임서버 변경 → ③ 전파 확인 → ④ 완료.
* 지금 단계는 transfer.step(ns-change | propagating | done | error)과 userConfirmedAt 으로 정한다. ③ 에서는 기대 네임서버와 지금 공용 DNS 에 보이는 네임서버를 나란히 ✓/✗ 로 보인다.
*/
function nsStepper(d, msg) {
const t = d.transfer || { step: 'ns-change' }; const step = t.step === 'done' ? 4 : (t.step === 'propagating' || t.userConfirmedAt ? 3 : 2);
const wrap = el('div', 'ns-stepper');
const STEPS = [['영역 준비', `레코드 ${d.imported || 0}개를 옮겨 둠`], ['네임서버 변경', '등록 업체에서 바꾸기'], ['전파 확인', '최대 48시간'], ['완료', 'DNS 를 이 서비스가 관리']];
const line = el('div', 'ns-steps');
STEPS.forEach(([title, sub], i) => { const n = i + 1; const s0 = el('div', 'ns-step ' + (n < step ? 'done' : n === step ? (step === 4 ? 'done' : 'now') : 'todo')); s0.appendChild(el('span', 'ns-dot', n < step || step === 4 ? '✓' : String(n))); const tx = el('div'); tx.appendChild(el('div', 'ns-t', title)); tx.appendChild(el('div', 'ns-s', sub)); s0.appendChild(tx); line.appendChild(s0); });
wrap.appendChild(line);
const detail = el('div', 'ns-detail');
if (step === 2) {
detail.appendChild(el('p', 'small', `등록 업체(${d.name} 을 구매한 곳)의 네임서버 설정을 이 서비스의 네임서버 ${(d.nameServers || []).length}개로 바꾼 뒤 "네임서버 변경했습니다" 를 누르세요.${d.currentNs && d.currentNs.length ? ` 지금 네임서버: ${d.currentNs.join(', ')}` : ''}`));
const row = el('div', 'row'); row.appendChild(btn('네임서버 정보 (복사)', 'ghost small-btn', () => nsInfoDialog(d, d.nameServers || [], d.currentNs)));
row.appendChild(btn('네임서버 변경했습니다', 'primary small-btn', async () => { if (!confirm(`등록 업체에서 ${d.name} 의 네임서버를 "네임서버 정보" 의 목록으로 바꾸어 저장하셨나요?\n확인을 누르면 공용 DNS 에서 실제로 바뀌었는지 확인합니다.`)) return; say(msg, '확인 중…'); try { const r = await api(`/api/domains/${d.id}/transfer-check`, { method: 'POST', body: JSON.stringify({ confirmed: true }) }); say(msg, r.message, r.done || r.step === 'propagating'); await refreshList(); } catch (e) { say(msg, e.message, false); } }));
detail.appendChild(row);
} else if (step === 3) {
detail.appendChild(el('p', 'small', `네임서버 변경을 ${dtm(t.userConfirmedAt)} 에 확인했습니다 — 공용 DNS 에 새 네임서버가 보일 때까지 기다리는 중입니다 (보통 수 분~수 시간, 최대 48시간). 그동안 이 도메인의 레코드 수정은 잠깁니다.`));
const expect = d.nameServers || []; const got = (d.currentNs || []).map((x) => String(x).toLowerCase());
const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const h of ['바꿀 네임서버', '공용 DNS 에 보이는 값']) hr.appendChild(el('th', null, h)); tb.appendChild(hr);
for (const ns of expect) { const tr = el('tr'); tr.appendChild(copyCell(ns)); const okCell = el('td', null, got.includes(String(ns).toLowerCase()) ? '✓ 보임' : '✕ 아직'); okCell.style.color = got.includes(String(ns).toLowerCase()) ? '#15803d' : '#b91c1c'; tr.appendChild(okCell); tb.appendChild(tr); }
for (const ns of got.filter((x) => !expect.map((e) => String(e).toLowerCase()).includes(x))) { const tr = el('tr'); tr.appendChild(el('td', 'muted small', '(옛 네임서버)')); tr.appendChild(el('td', 'muted small', ns)); tb.appendChild(tr); }
const tw = el('div', 'dom-table-wrap'); tw.appendChild(tb); detail.appendChild(tw);
const row = el('div', 'row'); row.appendChild(btn('지금 다시 확인', 'primary small-btn', async () => { say(msg, '확인 중…'); try { const r = await api(`/api/domains/${d.id}/transfer-check`, { method: 'POST', body: JSON.stringify({}) }); say(msg, r.message, r.done || r.step === 'propagating'); await refreshList(); } catch (e) { say(msg, e.message, false); } }));
row.appendChild(btn('네임서버 정보', 'ghost small-btn', () => nsInfoDialog(d, expect, d.currentNs))); detail.appendChild(row);
} else detail.appendChild(el('p', 'small', `${t.completedAt ? dtm(t.completedAt) + ' 에 ' : ''}네임서버 이전이 확인되었습니다. 이 도메인의 DNS 레코드를 이 서비스에서 관리합니다 — 사이트 연결·레코드는 DNS 화면에서.`));
wrap.appendChild(detail);
return wrap;
}
/** 전파 상태 확인 → 진행 중이면 "약 N분 후에 다시 이용"·"DNS 변경 진행 중" 안내 */
async function checkPropagation(d, node) {
try { const p = await api(`/api/domains/${d.id}/propagation`); wait(node, p.pending ? p.message : ''); } catch (_) {}
}
/** 갱신: 견적을 먼저 받아 금액을 보여 주고 확인받는다 */
async function renewFlow(d, msg) {
say(msg, '갱신 가격 확인 중…');
try {
let q = await api('/api/domains/quote', { method: 'POST', body: JSON.stringify({ domain: d.name, operation: 'renew', period: 1 }) }); say(msg, '');
for (let i = 0; i < 2; i++) {
if (!confirm(`${d.name} 를 1년 갱신합니다.\n결제 금액 ${won(q.costKrw)} (캐시 잔액 ${won(balance())} → ${won(balance() - q.costKrw)})\n진행할까요?`)) return;
try { await api(`/api/domains/${d.id}/renew`, { method: 'POST', body: JSON.stringify({ period: 1, expectedKrw: q.costKrw }) }); say(msg, '갱신했습니다'); await refreshList(); return; }
catch (e) { if (e.body && e.body.quote) { q = e.body.quote; say(msg, e.message, false); continue; } throw e; }
}
} catch (e) { say(msg, e.message, false); }
}
// ---------- ③ 직접 DNS 관리: 필요한 DNS 설정 · 설정 완료 확인 ----------
/** 필요한 레코드 표(사이트용 + 선택적으로 메일용)와 확인 줄 */
async function loadRequired(d, sel, box, withMail) {
box.innerHTML = ''; box.appendChild(el('p', 'muted small', '필요한 레코드를 계산하는 중…'));
try {
const r = await api(`/api/domains/${d.id}/required?${sel.value ? 'siteId=' + encodeURIComponent(sel.value) + '&' : ''}${withMail ? 'mail=1' : ''}`);
box.innerHTML = '';
box.appendChild(el('p', 'small', `${d.name} 의 DNS 서비스(도메인을 구매한 곳 또는 쓰고 있는 DNS)에 아래 레코드를 넣으세요. 사이트 IP: ${r.ip}`));
box.appendChild(requiredTable(r.records));
if (r.mailNote) box.appendChild(el('p', 'muted small', r.mailNote));
if (r.verified) box.appendChild(el('p', r.verified.siteOk ? 'muted small' : 'small dom-wait', r.verified.siteOk ? `사이트용 레코드 확인됨 (${dtm(r.verified.at)})${r.verified.mailOk === true ? ' · 메일용 레코드 확인됨' : r.verified.mailOk === false ? ' · 메일용 레코드는 아직 확인되지 않음' : ''}` : r.verified.lastCheck ? `마지막 확인 ${dtm(r.verified.lastCheck)}: DNS 변경이 완료되지 않았거나 아직 반영되지 않았습니다 — 이 상태로는 배포가 정상적으로 되지 않습니다` : '아직 확인하지 않았습니다 — 레코드를 넣은 뒤 "설정 완료 — 확인" 을 누르세요'));
box.appendChild(verifyRow(d, sel, box, !!r.wantMail));
} catch (e) { box.innerHTML = ''; box.appendChild(el('p', 'small', e.message)); }
}
/** "설정 완료 — 확인" 줄: 사용자 확인(confirmed) → 공용 DNS 조회 → 레코드별 결과 표와 사용 가능 여부 */
function verifyRow(d, sel, box, withMail) {
const row = el('div', 'row dom-row'); const msg = el('p', 'small');
const mailCb = el('input'); mailCb.type = 'checkbox'; mailCb.checked = !!withMail; const lb = el('label', 'inline'); lb.appendChild(mailCb); lb.appendChild(document.createTextNode(' 메일용 레코드 포함'));
row.appendChild(lb);
row.appendChild(btn('설정 완료 — 확인', 'primary small-btn', async () => {
if (!confirm(`${d.name} 의 DNS 서비스에 위 레코드를 모두 넣고 저장하셨나요?\n확인을 누르면 공용 DNS 에서 실제로 반영됐는지 확인합니다 (반영까지 몇 분~최대 48시간).`)) return;
say(msg, '공용 DNS 에서 확인 중…');
try {
const r = await api(`/api/domains/${d.id}/verify`, { method: 'POST', body: JSON.stringify({ siteId: sel.value || undefined, confirmed: true, mail: mailCb.checked }) });
say(msg, r.message, r.ok); const old = box.querySelector('.dom-verify-result'); if (old) old.remove();
const res = el('div', 'dom-verify-result'); res.appendChild(requiredTable(r.records, true)); box.appendChild(res);
if (r.ok) await refreshList();
} catch (e) { say(msg, e.message, false); }
}));
row.appendChild(btn('필요한 설정 다시 보기', 'ghost small-btn', () => loadRequired(d, sel, box, mailCb.checked)));
const wrap = el('div'); wrap.appendChild(row); wrap.appendChild(msg); return wrap;
}
/** 필요한 레코드 표. withResult 면 확인 결과(지금 값·확인) 열을 더한다 */
function requiredTable(recs, withResult) {
const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const h of ['용도', '종류', '이름', '값', '우선순위', ...(withResult ? ['지금 값', '확인'] : [])]) hr.appendChild(el('th', null, h)); tb.appendChild(hr);
for (const r of recs) {
const tr = el('tr'); const g = el('td', null, r.group === 'site' ? '사이트' : (r.purpose === 'send' ? '메일(보내기)' : '메일')); g.title = r.why || ''; tr.appendChild(g);
tr.appendChild(el('td', null, r.type)); tr.appendChild(el('td', null, r.host || r.name)); tr.appendChild(copyCell(r.value)); tr.appendChild(el('td', null, r.priority != null ? String(r.priority) : '')); // v1.6.38: 값은 클릭 선택·복사 버튼
if (withResult) { tr.appendChild(el('td', 'dom-val', r.providerChecked ? '(공급자가 확인)' : (r.found || []).join(', ') || '없음')); const ok = el('td', null, r.providerChecked ? '-' : r.ok ? '✓ 확인됨' : (r.required ? '✕ 아직' : '– 선택')); ok.style.color = r.providerChecked ? '' : r.ok ? '#15803d' : (r.required ? '#b91c1c' : ''); tr.appendChild(ok); }
tb.appendChild(tr);
}
const wrap = el('div', 'dom-table-wrap'); wrap.appendChild(tb); return wrap;
}
/** ④ 도메인 상태 표 — 단계별 ✓/✕/⏳ 와 설명, 요약 */
function statusTable(s) {
const wrap = el('div', 'dom-status');
wrap.appendChild(el('p', s.ok ? 'small' : 'small dom-wait', s.summary));
const ICON = { ok: '✓', fail: '✕', wait: '⏳', warn: '△', na: '–' }; const COLOR = { ok: '#15803d', fail: '#b91c1c', wait: '#a16207', warn: '#a16207', na: '' };
const tb = el('table', 'q-table dom-table');
for (const st of s.steps) { const tr = el('tr'); const ic = el('td', 'dom-st-icon', ICON[st.state] || ''); ic.style.color = COLOR[st.state] || ''; tr.appendChild(ic); tr.appendChild(el('td', 'dom-st-label', st.label)); tr.appendChild(el('td', 'dom-st-detail', st.detail)); tb.appendChild(tr); }
const tw = el('div', 'dom-table-wrap'); tw.appendChild(tb); wrap.appendChild(tw);
return wrap;
}
/**
* "메일(MX) 자동 설정" 버튼 (v1.6.40: 도메인 화면의 도메인 항목과 메일 화면이 같이 쓴다).
* 받는 메일 MX·SPF·DMARC 와 Resend 보내기 레코드(DKIM 등)를 자동으로 만든다. 직접 DNS 관리 도메인은 넣을 레코드를 안내한다.
* @param {object} d 도메인 @param {HTMLSelectElement|{value:string}} sel 메일을 쓸 사이트 선택 @param msg 결과 줄 @param detail 레코드 표가 열리는 상자 @param waitBox 전파 안내 상자 @param {boolean} manual 직접 DNS 관리 도메인인지
*/
function mailSetupBtn(d, sel, msg, detail, waitBox, manual, cls) {
return btn('메일(MX) 자동 설정', cls || 'primary small-btn', async () => {
if (!sel.value) return say(msg, '메일을 쓸 사이트를 고르세요', false);
say(msg, '메일 레코드를 만드는 중…');
try {
const r = await api(`/api/domains/${d.id}/mail`, { method: 'POST', body: JSON.stringify({ siteId: sel.value }) });
if (r.mode === 'manual') { say(msg, manual ? '이 도메인의 DNS 는 직접 관리하므로 아래 레코드를 도메인의 DNS 서비스에 넣은 뒤 "설정 완료 — 확인" 을 누르세요' : '이 도메인은 DNS 를 자동으로 바꿀 수 없어 아래 레코드를 직접 넣어야 합니다'); detail.innerHTML = ''; detail.appendChild(recordTable(r.records.map((x) => ({ name: x.host || x.name, type: x.type, value: x.value, prio: x.priority })), null)); detail.appendChild(btn('DNS 레코드 정보 (복사 가능)', 'ghost small-btn', () => dnsRecordsDialog(d, sel.value))); if (manual) detail.appendChild(verifyRow(d, sel, detail, true)); }
else say(msg, `메일 레코드 ${r.records.length}개를 만들었습니다${r.mailNote ? ' · ' + r.mailNote : ''}`);
if (r.propagation && r.propagation.pending) wait(waitBox, r.propagation.message);
} catch (e) { say(msg, e.message, false); }
});
}
// ---------- DNS 레코드 (구입 도메인 · 네임서버 이전 도메인) ----------
/** DNS 레코드 표 (readonly 면 지우기·추가 없음 — 서브 도메인, 또는 네임서버 이전 전파 중 잠금) */
async function loadRecords(d, box, readonly) {
box.innerHTML = ''; box.appendChild(el('p', 'muted small', '레코드 불러오는 중…'));
let recs; try { recs = (await api(`/api/domains/${d.id}/records`)).records; } catch (e) { box.innerHTML = ''; box.appendChild(el('p', 'muted small', e.message)); return; }
box.innerHTML = '';
if (d.dnsLocked) box.appendChild(el('p', 'small dom-wait', 'DNS 변경 진행 중 — 네임서버 이전이 확인될 때까지 레코드를 수정할 수 없습니다 (이중 수정 방지). 아래는 새 영역에 옮겨 둔 레코드입니다.'));
box.appendChild(recordTable(recs, readonly ? null : d, box));
if (!readonly) box.appendChild(addRecordRow(d, box));
}
function recordTable(recs, d, box) {
const tb = el('table', 'q-table dom-table'); const hr = el('tr'); for (const h of ['종류', '이름', '값', '우선순위', '']) hr.appendChild(el('th', null, h)); tb.appendChild(hr);
if (!recs.length) { const tr = el('tr'); const td = el('td', 'muted small', '레코드가 없습니다'); td.colSpan = 5; tr.appendChild(td); tb.appendChild(tr); }
for (const r of recs) {
const tr = el('tr'); tr.appendChild(el('td', null, r.type)); tr.appendChild(el('td', null, r.name || '@')); const v = el('td', 'dom-val', r.value); tr.appendChild(v); tr.appendChild(el('td', null, r.prio !== undefined && r.prio !== null ? String(r.prio) : ''));
const td = el('td');
if (d && !['NS', 'SOA'].includes(r.type)) td.appendChild(btn('삭제', 'ghost small-btn danger', async () => {
if (!confirm(`${r.type} ${r.name || '@'} → ${r.value} 레코드를 지울까요?`)) return;
try { await api(`/api/domains/${d.id}/records`, { method: 'DELETE', body: JSON.stringify(r) }); await loadRecords(d, box); } catch (e) { alert(e.message); }
}));
tr.appendChild(td); tb.appendChild(tr);
}
const wrap = el('div', 'dom-table-wrap'); wrap.appendChild(tb); return wrap;
}
function addRecordRow(d, box) {
const row = el('div', 'row dom-add'); const type = el('select'); for (const t of ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'CAA', 'SRV']) { const o = el('option', null, t); o.value = t; type.appendChild(o); }
const name = el('input'); name.placeholder = '이름 (비우면 @)'; const value = el('input'); value.placeholder = '값'; const prio = el('input'); prio.placeholder = '우선순위'; prio.type = 'number'; prio.className = 'dom-prio';
const msg = el('span', 'muted small');
row.appendChild(type); row.appendChild(name); row.appendChild(value); row.appendChild(prio);
row.appendChild(btn('레코드 추가', 'primary small-btn', async () => {
say(msg, '추가 중…');
try { await api(`/api/domains/${d.id}/records`, { method: 'POST', body: JSON.stringify({ type: type.value, name: name.value.trim(), value: value.value.trim(), prio: prio.value }) }); await loadRecords(d, box); } catch (e) { say(msg, e.message, false); }
}));
row.appendChild(msg); return row;
}
// ---------- v1.7.2 DNS 화면: 사이트 ↔ 도메인 연결이 먼저, 레코드 관리는 그 아래 ----------
/** 도메인의 짧은 준비 상태(연결 표용) */
function readyPill(d) {
if (d.dnsLocked || (d.transfer && d.transfer.step && d.transfer.step !== 'done')) return el('span', 'pill small waiting', '네임서버 전파 대기');
if (d.pending) return el('span', 'pill small waiting', 'DNS 전파 대기');
if (d.type === 'external' && d.mode === 'manual') return el('span', 'pill small ' + (d.manual && d.manual.siteOk ? 'done' : 'error'), d.manual && d.manual.siteOk ? 'DNS 확인됨' : 'DNS 확인 필요');
if (d.type === 'registered' && d.status === 'PENDING') return el('span', 'pill small waiting', '등록 처리 중');
return el('span', 'pill small done', '준비됨');
}
function renderDns(box) {
const head = el('section', 'card'); head.appendChild(el('h2', null, 'DNS — 사이트를 어느 주소로 열까요?'));
head.appendChild(el('p', 'muted small', '먼저 사이트와 도메인의 연결 관계를 정합니다: 어떤 사이트를 어느 도메인의 어떤 이름(기본 @·www, 또는 shop 같은 하위 이름)으로 열지. 연결하면 그 이름의 A 레코드가 자동으로 만들어지고(직접 관리 도메인은 안내), 다음 발행 때 웹서버 설정·SSL 이 적용됩니다. 도메인별 DNS 레코드 관리는 아래 "도메인별 DNS 레코드" 에서 그대로 할 수 있습니다.'));
if (data.dnsProviderLabel) head.appendChild(el('p', 'muted small', `DNS 공급자: ${data.dnsProviderLabel}${data.targetIp ? ` · 사이트 IP: ${data.targetIp}` : ''}`));
box.appendChild(head);
if (!data.domains.length) { const c = el('section', 'card'); c.appendChild(el('p', 'muted small', '아직 등록한 도메인이 없습니다. 먼저 "도메인" 화면에서 도메인을 준비하세요.')); const r = el('div', 'row'); r.appendChild(btn('도메인 화면으로', 'primary small-btn', () => window.showTab('domains'))); c.appendChild(r); box.appendChild(c); return; }
box.appendChild(connectionsCard());
const h = el('section', 'card'); h.appendChild(el('h2', null, '도메인별 DNS 레코드')); h.appendChild(el('p', 'muted small', '구매한 도메인과 네임서버를 이전한 도메인은 레코드를 직접 추가·삭제할 수 있고, 서브 도메인은 보기만, 직접 DNS 관리 도메인은 필요한 레코드를 안내하고 반영 여부를 확인합니다.')); box.appendChild(h);
for (const d of data.domains) box.appendChild(dnsItem(d));
}
/**
* 사이트 ↔ 도메인 연결 표 (v1.7.2 요구 1의 핵심). 사이트마다 한 줄: 열리는 주소(연결된 도메인의 이름들) · 준비 상태 · 연결 바꾸기(도메인 선택 + 하위 이름) · 연결 끊기.
* 도메인 하나에는 사이트 하나만 연결된다(다른 사이트에 연결하면 이전 연결은 풀린다). 연결되지 않은 도메인은 표 아래에 따로 보인다.
*/
function connectionsCard() {
const c = el('section', 'card'); c.appendChild(el('h2', null, '사이트 ↔ 도메인 연결'));
if (!data.sites.length) { c.appendChild(el('p', 'muted small', '아직 사이트가 없습니다. "만들기" 에서 사이트를 만든 뒤 여기서 도메인에 연결하세요.')); return c; }
const msg = el('p', 'muted small');
const cur = typeof current !== 'undefined' ? current : null;
// 사이트마다 한 블록(좁은 작업 칸에서도 읽히게 표 대신 세로 블록): 제목 → 열리는 주소 + 상태 → 연결 바꾸기 → 연결 끊기
for (const s0 of data.sites) {
const mine = data.domains.filter((d) => d.siteId === s0.id);
const blk = el('div', 'conn-site' + (cur && cur.id === s0.id ? ' on' : ''));
const head = el('div', 'conn-head'); head.appendChild(el('b', null, s0.title)); if (cur && cur.id === s0.id) head.appendChild(el('span', 'pill small running', '지금 보는 사이트')); blk.appendChild(head);
const addr = el('div', 'conn-addr');
if (!mine.length) { addr.appendChild(el('span', 'pill small idle', '미연결')); addr.appendChild(el('span', 'muted small', '연결된 도메인 없음 — 서버 주소로만 열립니다')); }
else for (const d of mine) { const line = el('div', 'conn-line'); line.appendChild(el('span', 'conn-arrow', '→')); (d.siteNames || [d.name]).forEach((n, i) => { if (i) line.appendChild(document.createTextNode(', ')); const a = el('a', null, n); a.href = 'https://' + n + '/'; a.target = '_blank'; a.rel = 'noopener'; line.appendChild(a); }); line.appendChild(readyPill(d)); line.appendChild(btn('연결 끊기', 'ghost small-btn danger', async () => { if (!confirm(`${s0.title} 에서 ${d.name} 연결을 끊을까요? DNS 레코드는 남고, 다음 발행 때 웹서버 설정에서 빠집니다.`)) return; try { await api(`/api/domains/${d.id}/disconnect`, { method: 'POST', body: JSON.stringify({ siteId: s0.id }) }); say(msg, `${d.name} 연결을 끊었습니다`); if (cur && cur.id === s0.id) openSite(s0.id, { tab: 'dns' }).catch(() => {}); await refreshList(); } catch (e) { say(msg, e.message, false); } })); addr.appendChild(line); }
blk.appendChild(addr);
const sel = el('select'); const o0 = el('option', null, mine.length ? '다른 도메인으로 바꾸기…' : '연결할 도메인 선택'); o0.value = ''; sel.appendChild(o0);
for (const d of data.domains) { if (d.siteId === s0.id) continue; const o = el('option', null, d.name + (d.siteId ? ` (다른 사이트: ${(data.sites.find((x) => x.id === d.siteId) || {}).title || ''})` : '')); o.value = d.id; sel.appendChild(o); }
const host = el('input', 'dom-host hidden'); host.placeholder = '하위 이름 (비우면 @ · www)'; host.maxLength = 32; host.autocomplete = 'off';
const bConn = btn('연결', 'primary small-btn', async () => {
if (!sel.value) return say(msg, `${s0.title}: 연결할 도메인을 고르세요`, false);
const d = data.domains.find((x) => x.id === sel.value); const h = host.classList.contains('hidden') ? '' : host.value.trim();
if (d.siteId && d.siteId !== s0.id && !confirm(`${d.name} 은 이미 다른 사이트에 연결되어 있습니다. ${s0.title} 로 바꿀까요? (이전 사이트의 주소 목록에서 빠집니다)`)) return;
say(msg, '연결 중…'); bConn.disabled = true;
try { const r = await api(`/api/domains/${d.id}/connect`, { method: 'POST', body: JSON.stringify({ siteId: s0.id, host: h }) }); say(msg, `연결했습니다: ${s0.title} → ${r.domains.join(', ')} — 다음 발행·재배포 때 웹서버 설정과 SSL 이 적용됩니다${r.guide ? ' · ' + r.guide : ''}`); if (cur && cur.id === s0.id) openSite(s0.id, { tab: 'dns' }).catch(() => {}); await refreshList(); } catch (e) { say(msg, e.message, false); bConn.disabled = false; }
});
bConn.disabled = true;
sel.onchange = () => { const d = data.domains.find((x) => x.id === sel.value); host.classList.toggle('hidden', !d || d.type === 'subdomain'); bConn.disabled = !sel.value; };
const row = el('div', 'row dom-conn-row'); row.appendChild(sel); row.appendChild(host); row.appendChild(bConn); blk.appendChild(row);
c.appendChild(blk);
}
c.appendChild(msg);
const free = data.domains.filter((d) => !d.siteId);
c.appendChild(el('p', 'muted small', free.length ? `연결되지 않은 도메인: ${free.map((d) => d.name).join(', ')} — 위에서 사이트를 골라 연결하세요.` : '모든 도메인이 사이트에 연결되어 있습니다.'));
c.appendChild(el('p', 'muted small', '하위 이름을 적으면 그 이름 하나(예: shop.example.com)로, 비우면 도메인 자신과 www 로 엽니다. 도메인 하나에는 사이트 하나만 연결됩니다.'));
return c;
}
function dnsItem(d) {
const sub = d.type === 'subdomain'; const ext = d.type === 'external'; const manual = ext && d.mode === 'manual';
const c = el('section', 'card');
const top = el('div', 'dom-head-row'); top.appendChild(el('h2', null, d.name)); top.appendChild(el('span', 'pill idle small', kindLabel(d))); top.appendChild(readyPill(d));
const site = data.sites.find((s0) => s0.id === d.siteId); top.appendChild(el('span', 'muted small', site ? `→ ${site.title} (${(d.siteNames || []).join(', ') || d.name})` : '사이트에 연결되지 않음'));
c.appendChild(top);
const waitBox = el('p', 'small dom-wait hidden'); c.appendChild(waitBox); if (d.pending) checkPropagation(d, waitBox);
const msg = el('p', 'muted small'); const detail = el('div');
if (ext && d.mode === 'ns' && d.transfer && d.transfer.step !== 'done') c.appendChild(el('p', 'small dom-wait', '네임서버 이전이 아직 끝나지 않았습니다 — 진행 단계와 "네임서버 변경했습니다" 는 도메인 화면에 있습니다. 전파가 확인될 때까지 레코드 수정은 잠깁니다.'));
if (ext) c.appendChild(externalGuide(d, () => d.siteId || ''));
const row = el('div', 'row dom-row');
row.appendChild(btn('도메인 상태', 'ghost small-btn', async () => { detail.innerHTML = ''; detail.appendChild(el('p', 'muted small', '확인 중…')); try { const s0 = await api(`/api/domains/${d.id}/status${d.siteId ? '?siteId=' + encodeURIComponent(d.siteId) : ''}`); detail.innerHTML = ''; detail.appendChild(statusTable(s0)); } catch (e) { detail.innerHTML = ''; detail.appendChild(el('p', 'small', e.message)); } }));
const recBox = el('div', 'dom-recs');
if (manual) { const selStub = { value: d.siteId || '' }; row.appendChild(btn('필요한 DNS 설정 다시 보기', 'ghost small-btn', () => loadRequired(d, selStub, recBox))); loadRequired(d, selStub, recBox); }
else { const open = el('details', 'dom-rec-details'); const sm = el('summary', null, 'DNS 레코드 보기 · 관리'); open.appendChild(sm); open.appendChild(recBox); open.ontoggle = () => { if (open.open && !recBox.dataset.loaded) { recBox.dataset.loaded = '1'; loadRecords(d, recBox, sub || d.dnsLocked); } }; c.appendChild(row); c.appendChild(msg); c.appendChild(detail); c.appendChild(open); return c; }
c.appendChild(row); c.appendChild(msg); c.appendChild(detail); c.appendChild(recBox);
return c;
}
// ---------- v1.7.2 메일 화면: 계정 형식 · 접속 주소 · 초기 관리자를 한눈에 ----------
function renderMail(box) {
const head = el('section', 'card'); head.appendChild(el('h2', null, '메일'));
head.appendChild(el('p', 'muted small', '순서: ① 도메인의 DNS 준비 → ② 아래에서 도메인에 "메일(MX) 자동 설정" → ③ 운영팀이 사이트 메일 시스템을 켬 → ④ 사이트 관리자 페이지 › 회원에서 "메일 사용 켜기" → ⑤ 웹메일 또는 메일 프로그램으로 접속. 메일은 사이트 제작·배포와 무관하게 준비할 수 있지만 DNS 가 준비된 도메인에서만 동작합니다.'));
box.appendChild(head);
const cur = typeof current !== 'undefined' ? current : null;
if (cur) box.appendChild(mailGlanceCard(cur));
else box.appendChild(el('p', 'muted small', '왼쪽 레일에서 사이트를 고르면 그 사이트의 메일 계정 형식·접속 주소·초기 관리자 계정이 여기에 보입니다.'));
if (!data.domains.length) { const c = el('section', 'card'); c.appendChild(el('p', 'muted small', '아직 등록한 도메인이 없습니다. 메일을 쓰려면 먼저 "도메인" 화면에서 도메인을 준비하세요.')); const r = el('div', 'row'); r.appendChild(btn('도메인 화면으로', 'primary small-btn', () => window.showTab('domains'))); c.appendChild(r); box.appendChild(c); return; }
const h = el('section', 'card'); h.appendChild(el('h2', null, '도메인별 메일(MX) 설정')); h.appendChild(el('p', 'muted small', `받는 메일 MX·SPF·DMARC 와 보내는 메일 DKIM 레코드를 자동으로 만듭니다.${data.mailHost ? ` 받는 메일 서버(MX): ${data.mailHost}` : ''}`)); box.appendChild(h);
for (const d of data.domains) box.appendChild(mailItem(d));
}
/**
* 한눈에 보기: 이 사이트의 메일 — 상태, 계정 형식(아이디@도메인), 접속 주소(웹메일 · 메일 서버 웹메일 · IMAP/POP/SMTP), 초기 관리자(사이트 관리자 계정과 메일 상태).
* 값은 app.js current.mail (publicView 의 mailAtAGlance) 에서 온다. 비밀번호는 보이지 않는다 — 메일 비밀번호는 사이트 관리자 비밀번호와 같다(1.6.39 동기화).
*/
function mailGlanceCard(site) {
const m = site.mail || {}; const c = el('section', 'card'); c.appendChild(el('h2', null, `이 사이트의 메일 — ${site.title}`));
const line = el('div', 'dom-mailstat');
if (!m || m.level === 'off') { line.appendChild(el('span', 'pill small idle', '꺼짐')); line.appendChild(el('span', 'muted small', '이 사이트의 메일 시스템은 아직 켜지지 않았습니다. 아래에서 도메인에 메일(MX)을 설정한 뒤 운영팀에 메일 시스템 사용을 요청하세요.')); c.appendChild(line); return c; }
line.appendChild(el('span', 'pill small ' + (m.level === 'setting-up' ? 'waiting' : 'done'), m.label)); line.appendChild(el('span', 'muted small', m.message || '')); c.appendChild(line);
const kv = el('div', 'mail-glance');
const row = (k, node) => { const r = el('div', 'mg-row'); r.appendChild(el('div', 'mg-k', k)); const v = el('div', 'mg-v'); if (typeof node === 'string') v.textContent = node; else v.appendChild(node); r.appendChild(v); kv.appendChild(r); };
row('메일 계정 형식', m.accountFormat ? m.accountFormat : `아이디@${m.domain || '(도메인 없음)'}`);
const acc = el('div'); acc.appendChild(el('div', null, '사이트 관리자 페이지 › 회원 에서 "메일 사용 켜기" 를 누른 회원의 로그인 ID 가 메일 아이디가 됩니다. 비밀번호는 그 회원의 사이트 관리자 비밀번호와 같습니다.')); row('계정 만들기', acc);
const links = el('div');
if (m.webmailUrl) { const a = el('a', 'btn ghost small-btn', '웹메일 열기 (이 서비스)'); a.href = m.webmailUrl; a.target = '_blank'; a.rel = 'noopener'; links.appendChild(a); }
if (m.server && m.server.webmail) { const a = el('a', 'btn ghost small-btn', `메일 서버 웹메일 (${m.server.host || ''})`); a.href = m.server.webmail; a.target = '_blank'; a.rel = 'noopener'; links.appendChild(a); }
if (!links.children.length) links.appendChild(el('span', 'muted small', '메일 서버가 연결되면 접속 주소가 여기에 보입니다'));
row('접속 주소', links);
if (m.server && m.server.client) { const cl = m.server.client; const t = el('div', 'small'); t.appendChild(el('div', null, `받는 서버(IMAP) ${cl.imap.host} — 포트 ${cl.imap.ports.join(' / ')} (${cl.imap.note})`)); t.appendChild(el('div', null, `받는 서버(POP3) ${cl.pop.host} — 포트 ${cl.pop.ports.join(' / ')}`)); t.appendChild(el('div', null, `보내는 서버(SMTP) ${cl.smtp.host} — 포트 ${cl.smtp.ports.join(' / ')} (${cl.smtp.note})`)); t.appendChild(el('div', 'muted', `사용자 이름: ${cl.username}`)); row('메일 프로그램 설정', t); }
const adm = el('div');
if (!(m.admins || []).length) adm.appendChild(el('span', 'muted small', '사이트 관리자 계정이 없습니다 — 사이트 관리자 페이지에서 먼저 만드세요'));
else for (const a of m.admins) { const d = el('div', 'mg-admin'); d.appendChild(el('b', null, a.loginId)); d.appendChild(document.createTextNode(a.name && a.name !== a.loginId ? ` (${a.name})` : '')); d.appendChild(el('span', 'pill small ' + (a.mail ? (a.mail.state === 'active' ? 'done' : 'waiting') : 'idle'), a.mail ? `${a.mail.address}${a.mail.state && a.mail.state !== 'active' ? ' · ' + a.mail.state : ''}` : '메일 미사용')); adm.appendChild(d); }
adm.appendChild(el('div', 'muted small', '처음 접속: 사이트 관리자 계정으로 사이트 관리자 페이지에 로그인 → 회원 › 자기 계정의 "메일 사용 켜기"(비밀번호는 사이트 관리자 비밀번호 그대로) → 위 웹메일 주소에서 "아이디@도메인" 과 같은 비밀번호로 로그인.'));
row('초기 관리자', adm);
// v1.7.3 (요구 2): 메일 사용량 — 메일 서버(Arimail) 사용량 API 에서 이 사이트 메일 도메인의 메일함 용량·메일 수·이달 발송/수신과 이 사이트 계정별 메일함 크기. 비동기로 채운다
const us = el('div', 'small mg-usage'); us.appendChild(el('span', 'muted', '불러오는 중…')); row('메일 사용량', us); loadMailUsage(site, us, false);
const bt = el('div', 'row'); const a2 = el('a', 'btn ghost small-btn', '사이트 관리자 페이지 열기'); a2.href = `/site-admin/${site.id}/`; a2.target = '_blank'; a2.rel = 'noopener'; bt.appendChild(a2); bt.appendChild(btn('운영 화면에서 관리', 'ghost small-btn', () => window.showTab('manage')));
row('관리', bt);
c.appendChild(kv);
return c;
}
/**
* v1.7.3 (요구 2): GET /api/sites/<ID>/mail-usage → 메일함 용량(도메인 전체) · 메일 수 · 계정 수(이 사이트 것 따로) · 이달/오늘 발송·수신 · 이 사이트 계정별 메일함.
* 운영자가 사용량 API 키를 아직 연결하지 않았거나 Arimail 이 낮은 버전이면 그 이유를 한 줄로 보여 준다(비밀값은 오지 않는다).
*/
async function loadMailUsage(site, box, refresh) {
const fb = (n) => (typeof fmtBytes === 'function' ? fmtBytes(Number(n) || 0) : `${Number(n) || 0} B`); const fn = (n) => (typeof fmt === 'function' ? fmt(Number(n) || 0) : String(Number(n) || 0));
let u; try { u = await api(`/api/sites/${site.id}/mailusage${refresh ? '?refresh=1' : ''}`); } catch (e) { box.innerHTML = ''; box.appendChild(el('span', 'muted', e.message)); return; }
box.innerHTML = '';
if (!u.ok) { const soft = ['no_usage_key', 'usage_unsupported', 'usage_api_disabled', 'no_server'].includes(u.code); box.appendChild(el('span', 'muted', soft ? `메일 서버 사용량은 운영팀이 메일 서버의 사용량 API 를 연결하면 보입니다 (${u.message})` : u.message)); return; }
const t = u.traffic || {}; const mo = t.month || {}; const dy = t.day || {};
box.appendChild(el('div', null, `메일함 용량 ${fb(u.storage.bytes)}${u.storage.limit ? ` / ${fb(u.storage.limit)}` : ''} · 메일 ${fn(u.messages.total)}통 · 계정 ${fn(u.accounts.total)}개${u.accounts.mine ? ` (이 사이트 ${u.accounts.mine}개, ${fb(u.myBytes || 0)})` : ''}`));
box.appendChild(el('div', null, `이달 발송 ${fn(mo.sent)}통 · 수신 ${fn(mo.received)}통 (${fb(mo.sentBytes)} / ${fb(mo.receivedBytes)}) · 오늘 ${fn(dy.sent)} / ${fn(dy.received)}`));
if ((u.accountList || []).length) { const ul = el('ul', 'git-list'); for (const a of u.accountList) { const li = el('li'); li.appendChild(el('span', 'bname', a.mailbox || a.login)); li.appendChild(el('span', 'st', `${fb(a.bytes)}${a.limit ? ` / ${fb(a.limit)}` : ''} · ${fn(a.messages)}통 · 발송 ${fn(a.sent)} / 수신 ${fn(a.received)}`)); ul.appendChild(li); } box.appendChild(ul); }
const meta = el('div', 'muted'); meta.appendChild(document.createTextNode(`메일 서버 ${u.server ? (u.server.nickname || u.server.host) : ''} · ${new Date(u.at).toLocaleString(typeof uiLocale === 'function' ? uiLocale() : undefined)}${u.cached ? ' (캐시)' : ''} `));
meta.appendChild(btn('새로 받기', 'ghost small-btn', () => { box.innerHTML = ''; box.appendChild(el('span', 'muted', '불러오는 중…')); loadMailUsage(site, box, true); })); box.appendChild(meta);
}
function mailItem(d) {
const sub = d.type === 'subdomain'; const ext = d.type === 'external'; const manual = ext && d.mode === 'manual';
const c = el('section', 'card');
const top = el('div', 'dom-head-row'); top.appendChild(el('h2', null, d.name)); top.appendChild(el('span', 'pill idle small', kindLabel(d))); top.appendChild(readyPill(d));
const site = data.sites.find((s0) => s0.id === d.siteId); top.appendChild(el('span', 'muted small', site ? `연결된 사이트: ${site.title}` : '사이트에 연결되지 않음'));
c.appendChild(top);
if (sub) c.appendChild(el('p', 'muted small', `메일 주소 예: admin@${d.name}`));
const waitBox = el('p', 'small dom-wait hidden'); c.appendChild(waitBox); if (d.pending) checkPropagation(d, waitBox);
const msg = el('p', 'muted small'); const detail = el('div');
const row = el('div', 'row dom-row'); const sel = el('select');
const o0 = el('option', null, '메일을 쓸 사이트 선택'); o0.value = ''; sel.appendChild(o0);
for (const s0 of data.sites) { const o = el('option', null, s0.title); o.value = s0.id; if (s0.id === d.siteId || (!d.siteId && typeof current !== 'undefined' && current && current.id === s0.id)) o.selected = true; sel.appendChild(o); }
row.appendChild(sel);
row.appendChild(mailSetupBtn(d, sel, msg, detail, waitBox, manual, 'primary small-btn'));
row.appendChild(btn('도메인 상태', 'ghost small-btn', async () => { detail.innerHTML = ''; detail.appendChild(el('p', 'muted small', '확인 중…')); try { const s0 = await api(`/api/domains/${d.id}/status${sel.value ? '?siteId=' + encodeURIComponent(sel.value) : ''}`); detail.innerHTML = ''; detail.appendChild(statusTable(s0)); } catch (e) { detail.innerHTML = ''; detail.appendChild(el('p', 'small', e.message)); } }));
if (manual) row.appendChild(btn('필요한 DNS 설정 (메일 포함)', 'ghost small-btn', async () => { detail.innerHTML = ''; await loadRequired(d, sel, detail, true); }));
c.appendChild(row); c.appendChild(msg); c.appendChild(detail);
return c;
}
window.DomainsUI = { load };
})();