Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
743 lines
82 KiB
JavaScript
743 lines
82 KiB
JavaScript
'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 };
|
||
})();
|