constkrw=(n)=>{constv=Math.round(n||0).toLocaleString(uiLocale());returncashUnit==='원'?TF('{1}원',v):`${v}${/^[가-힣]+$/.test(cashUnit)?'':' '}${cashUnit}`;};// v1.3.10 통화 단위 번역, v1.4.1 관리자 지정 단위
constDS={pending:'배포 대기중',deployed:'발행 완료',working:'작업 중',deploying:'배포 중',empty:'완성 전',transferred:'전송 완료','vhost-wait':'웹서버 설정 대기','dns-wait':'DNS 설정 대기','cert-wait':'인증서 준비 중(HTTP)','check-failed':'접속 확인 실패','vhost-failed':'도메인 연결 실패',failed:'배포 실패'};// v1.6.31 (요구 14) 단계 구분
* 탭별 데이터 불러오기 (v1.3.2). 탭을 열 때와 "새로고침" 때 같은 함수를 쓴다.
* sites 는 loadOverview 가 그리므로 별도 함수가 없다. detail 은 열려 있는 사이트 상세를 다시 연다.
*/
constTAB_LOADERS={templates:()=>loadTemplates(),search:()=>loadSearchRegs(),feedback:()=>loadFeedback(),i18n:()=>loadI18n(),errors:()=>loadErrors(),billing:()=>loadBilling(),settings:()=>loadSettings(),recovery:()=>loadRecovery(),audit:()=>loadAudit()/* v1.6.31 */,ai:()=>loadSettings()/* v1.5.16 AI 연결 탭: 인증·사용할 Claude 모델·제공자 — 설정과 같은 응답을 쓴다 */,deploy:()=>loadTargets(),mail:()=>loadMailSys(),theme:()=>loadTheme(),detail:()=>(curSite?openSite(curSite.id||curSite):null)};
letactiveAdminTab='sites';
/** 탭 열기: 화면 전환 + 데이터 불러오기 + 주소의 #탭 기록(브라우저 새로고침에도 같은 탭 유지) */
{constfb=overview.feedback||{};constb=$('fbBadge');b.textContent=fb.new||'';b.classList.toggle('hidden',!fb.new);constd=document.createElement('div');d.className='tot';d.innerHTML=`<span class="k">사용자 문의</span><span class="v">${fb.new||0} 새 글 / ${fb.total||0}건</span>`;kpi.appendChild(d);constl=document.createElement('div');l.className='tot';l.innerHTML=`<span class="k">활성 언어</span><span class="v">${(overview.languages||0)+1}개</span>`;kpi.appendChild(l);}
// v1.3.5: "문의 메일 발송 정상(도메인)" 칸 삭제 — 메일은 사이트 단위 메일 시스템·외부 연동 접속 정보에서 관리하며 여러 도메인을 지원한다
{constst=overview.stats||{};constl=st.local||{};$('statsCollectLine').textContent=l.at?`접속 로그 수집: ${l.ok?`${dt(l.at)} 기준 정상 (${l.lines||0}줄)`:`${dt(l.at)} 실패 — ${l.detail||''}`}`:'접속 로그 수집: 아직 실행되지 않음';}
constb=document.createElement('span');b.className='badge '+ds.state;b.textContent=DS[ds.state]||ds.state;b.title=ds.state==='pending'?(ds.neverDeployed?'아직 한 번도 배포되지 않았습니다':`마지막 배포 ${dt(ds.lastDeployAt)} (${ds.lastDeployTarget||''}) 이후 변경됨`):(ds.lastDeployAt?`마지막 배포 ${dt(ds.lastDeployAt)} → ${ds.lastDeployTarget||''}`:'');td(tr,b);
td(tr,ds.lastChangeAt?`${dt(ds.lastChangeAt)} · ${T(CHG[ds.lastChangeKind]||(ds.neverDeployed?'신규 생성':''))}`:'-');// v1.3.10 조각 번역
{constc=document.createElement('td');if(s.source)c.textContent='원본 서버';else{constsel=document.createElement('select');sel.style.margin='0';sel.style.minWidth='120px';sel.title='이 사이트의 배포 서버';sel.appendChild(newOption(`기본 서버 (${(targets.targets.find((t)=>t.isDefault)||{}).name || '없음'})`,''));for(consttoftargets.targets||[])sel.appendChild(newOption(`${t.name}${t.isDefault?' ★':''}`,t.id));sel.value=s.deployTargetId&&(targets.targets||[]).some((t)=>t.id===s.deployTargetId)?s.deployTargetId:'';sel.onclick=(e)=>e.stopPropagation();sel.onchange=async(e)=>{e.stopPropagation();try{awaitapi(`/api/admin/sites/${s.id}/target`,{method:'POST',body:JSON.stringify({targetId:sel.value||null})});s.deployTargetId=sel.value||null;}catch(err){alert(err.message);}};c.appendChild(sel);}tr.appendChild(c);}
if(s.source){if(!confirm(`가져온 기존 사이트입니다. 수정 내용을 원본 서버(${s.source})에 반영할까요?`))return;returnrunDeploy(s,{id:'source',name:'원본 서버 '+s.source,manageVhost:false,enableSsl:false},null,null);}
// v1.3.17 (B24): 목록의 배포 서버 선택(s.deployTargetId)이 최신 지정 — 예전 배포 기록(s.deploy.targetId)보다 우선 ('' = 기본 서버)
consttid=s.deployTargetId||targets.defaultId;// 서버와 같은 규칙: 사이트 지정 서버 → 없으면 기본 서버 const t = targets.targets.find((x) => x.id === tid) || targets.targets.find((x) => x.isDefault);
// v1.3.17 (B25): 첫 배포 대화상자의 SSL 체크는 이 사이트에만 적용한다(배포 요청에 ssl 로 보냄). 예전에는 서버 공용 설정을 꺼서 다른 사이트에도 영향
if((s.domains||[]).length&&(t.manageVhost!==false||t.enableSsl!==false)&&t.allowSudo!==true){if(!confirm(`도메인 연결·SSL 에는 배포 서버 "${t.name}" 에서 sudo 실행이 필요합니다. 허용할까요? (허용하지 않으면 파일만 배포)`)){/* 파일만 */}else{awaitapi(`/api/admin/deploy-targets/${t.id}`,{method:'PUT',body:JSON.stringify({...t,allowSudo:true})});}}
asyncfunctiondeleteSite(id,title){if(!confirm(`"${title}" 사이트와 파일을 모두 삭제할까요? 되돌릴 수 없습니다.`))return;try{awaitapi(`/api/admin/sites/${id}`,{method:'DELETE'});showTab('sites');loadOverview();}catch(e){alert(e.message);}}
// v1.5.13 (요구 3): 생성·조회한 공개키는 별도 창(showKeyDialog)에 한 줄로 보이고 "공개키 복사" 버튼으로 복사한다
if(t.type==='ssh')act.appendChild(mkBtn(t.hasKey?'키 재생성':'키 생성','ghost',async()=>{if(t.hasKey&&!confirm('기존 키를 새 키로 바꿉니다. 배포 서버에 새 공개키를 다시 등록해야 합니다. 계속할까요?'))return;try{constr=awaitapi(`/api/admin/deploy-targets/${t.id}/generate-key`,{method:'POST',body:'{}'});showKeyDialog(r.name,r.publicKey,true);loadTargets();}catch(e){alert(e.message);}}));
act.appendChild(mkBtn('삭제','ghost danger',async()=>{if(!confirm(`"${t.name}" 배포 서버를 삭제할까요? (이미 배포된 사이트 파일은 남습니다)`))return;awaitapi(`/api/admin/deploy-targets/${t.id}`,{method:'DELETE'});loadTargets();}));
tr.appendChild(act);tb.appendChild(tr);
}
if(!targets.targets.length){consttr=document.createElement('tr');td(tr,'등록된 배포 서버가 없습니다. "배포 서버 추가" 를 누르세요.').colSpan=11;tb.appendChild(tr);}
}
/**
* v1.5.13 (요구 3): 공개키 창. 키 한 줄을 읽기 전용 textarea 에 넣고 "공개키 복사" 로 클립보드에 복사한다.
* 클립보드 API 는 https 또는 localhost 에서만 쓸 수 있으므로(사내 http://IP 접속), 안 되면 textarea 선택 + execCommand('copy') 로, 그것도 안 되면 prompt 로 보여 준다.
* @param {string} name 배포 서버 이름 @param {string} publicKey 공개키 한 줄 @param {boolean} fresh 방금 생성했는지(안내 문구)
*/
functionshowKeyDialog(name,publicKey,fresh){
constkey=String(publicKey||'').trim();
$('tKeyTitle').textContent=`${fresh?'새로 만든 ':''}공개키 — ${name}`;
$('tKeyNote').innerHTML=(fresh?'키를 새로 만들었습니다. 이 공개키를 등록해야 배포 서버에 접속할 수 있습니다. ':'')+'배포 서버의 <code>~/.ssh/authorized_keys</code> 에 아래 한 줄을 그대로 추가하세요 (접속 사용자의 홈). 줄바꿈 없이 한 줄입니다.';
try{constr=awaitapi(id?`/api/admin/deploy-targets/${id}`:'/api/admin/deploy-targets',{method:id?'PUT':'POST',body:JSON.stringify(body)});$('tListMsg').textContent=`저장됨 (${r.name})${r.type==='ssh'&&!r.hasKey?' — 행의 "키 생성" 으로 키를 만들거나 수정 창에서 개인키를 붙여넣은 뒤 연결 테스트를 하세요':''}`;closeTargetForm();loadTargets();}
if(!confirm(`도메인 연결(nginx 설정)과 SSL 인증서 발급에는 배포 서버 "${t.name}" 에서 sudo 명령 실행이 필요합니다.\n이 서버에서 sudo 사용을 허용할까요? (허용하지 않으면 파일만 배포되고 도메인/SSL 은 설정되지 않습니다)`))returnfalse;
try{awaitapi(`/api/admin/deploy-targets/${t.id}`,{method:'PUT',body:JSON.stringify({...t,allowSudo:true})});targets=awaitapi('/api/admin/deploy-targets');}catch(e){alert('설정 저장 실패: '+e.message);returnfalse;}
returntrue;
}
$('dDeploy').onclick=async()=>{
constok=awaitensureSudo();if(!ok){showResult('fail','배포 취소',['sudo 사용을 허용하지 않아 배포를 진행하지 않았습니다. 파일만 배포하려면 배포 서버 설정에서 vhost/SSL 자동 설정을 끄세요.']);return;}
showResult('busy','배포 중…',['파일 전송 → 도메인 연결 → SSL 발급 → 이전 버전 정리 → 접속 확인 순으로 진행합니다. SSL 발급은 1~2분 걸릴 수 있습니다.']);$('dDeploy').disabled=true;
showResult(okAll?'ok':'fail',okAll?'배포 완료':'배포는 되었지만 접속되는 주소가 없습니다',[r.detail,r.defaultServer?`기본 오류 서버: ${r.defaultServer.ok?'정상':'주의'} — ${r.defaultServer.detail}`:'',r.ssl?`SSL: ${r.ssl.detail}`:'',r.access&&r.access.items.length?'접속 가능 여부:':'접속 주소: 도메인이나 공개 URL 이 없어 확인할 주소가 없습니다. 사이트 도메인을 입력하거나 배포 서버의 공개 URL 을 설정하세요.'].filter(Boolean),r.access);
// v1.6.31 (요구 15): 이전 배포본으로 되돌리기 — 서버에 직전 배포본이 있을 때만 활성
{constds=s.deployState||{};$('dRollback').disabled=!ds.hasPrev;$('dRollback').title=ds.hasPrev?'배포 서버에 보관된 직전 배포본으로 운영 주소를 되돌립니다':'배포 서버에 보관된 직전 배포본이 없습니다 (1.6.31 이후 두 번째 배포부터 가능)';
$('dRollback').onclick=async()=>{if(!confirm('운영 주소를 직전 배포본으로 되돌릴까요? (빌더의 사이트 내용은 바뀌지 않으며, 다음 배포 때 다시 현재 내용이 올라갑니다)'))return;showResult('busy','되돌리는 중…',[]);try{constr=awaitapi(`/api/admin/recovery/action`,{method:'POST',body:JSON.stringify({kind:'deploy',id:s.id,action:'rollback'})});showResult(r.ok?'ok':'fail',r.ok?'되돌리기 완료':'되돌리기 실패',[r.detail||'']);}catch(e){showResult('fail','되돌리기 실패',[e.message]);}};}
$('dAccess').onclick=async()=>{showResult('busy','접속 확인 중…',[]);try{constr=awaitapi(`/api/admin/sites/${s.id}/access-check`,{method:'POST',body:JSON.stringify({targetId:sel.value})});showResult(r.items.some((i)=>i.ok)?'ok':'fail','접속 확인 결과',[r.items.length?'':'확인할 주소가 없습니다 (도메인 또는 공개 URL 필요)'].filter(Boolean),r);}catch(e){showResult('fail','확인 실패',[e.message]);}};
// v1.5.27 (요구 6) 승인 대기 계정: 승인(로그인 가능) 또는 거절(계정 삭제)
if(u.pendingApproval){act.appendChild(mkBtn('가입 승인','primary',async()=>{try{awaitapi(`/api/admin/users/${encodeURIComponent(u.userId)}/approve`,{method:'POST',body:'{}'});loadBilling();loadOverview();}catch(e){alert(e.message);}}));act.appendChild(mkBtn('거절(삭제)','ghost danger',async()=>{if(!confirm(`${u.name||''} (${u.userId}) 의 가입 신청을 거절하고 계정을 지울까요?`))return;try{awaitapi(`/api/admin/users/${encodeURIComponent(u.userId)}/reject`,{method:'POST',body:'{}'});loadBilling();loadOverview();}catch(e){alert(e.message);}}));}
if(u.role)act.appendChild(mkBtn('비밀번호 재설정','ghost',async()=>{constpw=prompt(`${u.name||''} (${u.userId}) 의 새 비밀번호 (${window.pwRuleText||'6자 이상'})`);if(!pw)return;try{awaitapi(`/api/admin/users/${encodeURIComponent(u.userId)}/password`,{method:'POST',body:JSON.stringify({password:pw})});alert('비밀번호를 재설정했습니다');}catch(e){alert(e.message);}}));
if(u.role)act.appendChild(mkBtn(u.role==='admin'?'일반으로':'관리자로','ghost',async()=>{if(me&&u.userId===me.id)returnalert('자기 자신의 역할은 바꿀 수 없습니다');try{awaitapi(`/api/admin/users/${encodeURIComponent(u.userId)}/role`,{method:'POST',body:JSON.stringify({role:u.role==='admin'?'user':'admin'})});loadBilling();loadOverview();}catch(e){alert(e.message);}}));
// v1.4.0 계정 종류: 서브 도메인 전용 / 자체 도메인 — 고르고 "변경" 을 눌러야 적용 (관리자는 항상 자체 도메인)
constsel=document.createElement('select');for(const[v,l]of[['subdomain','서브 도메인 전용'],['domain','자체 도메인']]){consto=document.createElement('option');o.value=v;o.textContent=T(l);sel.appendChild(o);}sel.value=u.accountType||'domain';
box.appendChild(sel);box.appendChild(mkBtn('변경','ghost',async()=>{try{awaitapi(`/api/admin/users/${encodeURIComponent(u.userId)}/account-type`,{method:'POST',body:JSON.stringify({accountType:sel.value})});u.accountType=sel.value;alert(TF('계정 종류를 바꿨습니다: {1}',sel.options[sel.selectedIndex].textContent));}catch(e){alert(e.message);}}));
act.appendChild(mkBtn('수정','ghost',()=>{$('ppId').value=p.id;$('ppName').value=p.name;$('ppAmount').value=p.amount;$('ppBonusType').value=p.bonusType;$('ppBonusValue').value=p.bonusValue;$('ppSort').value=p.sort||0;$('ppActive').checked=p.active!==false;$('ppNote').value=p.note||'';$('ppFormTitle').textContent=TF('상품 수정 — {1}',p.name);$('ppName').focus();}));
act.appendChild(mkBtn('삭제','ghost danger',async()=>{if(!confirm(TF('상품 "{1}" 을 지울까요? 주문 기록이 있는 상품은 지우지 않고 숨김으로 바뀝니다.',p.name)))return;try{constr=awaitapi(`/api/admin/prepaid/packages/${p.id}`,{method:'DELETE'});$('ppMsg').textContent=r.removed?T('지웠습니다'):T('주문 기록이 있어 숨김으로 바꿨습니다');awaitloadPrepaid();}catch(e){alert(e.message);}}));
tr.appendChild(act);tb.appendChild(tr);
}
if(!d.packages.length){consttr=document.createElement('tr');td(tr,'아직 상품이 없습니다 — 아래에서 만드세요').colSpan=7;tb.appendChild(tr);}
constpost=async(type)=>{constamount=+$('chargeAmount').value;if(!$('chargeAmount').value.trim()||!Number.isFinite(amount))returnalert('금액을 입력하세요');if(type==='set'&&!confirm(`${u.name||''} (${uid}) 의 캐시 잔액을 ${krw(amount)} 으로 설정할까요?`))return;try{awaitapi(`/api/admin/credits/${encodeURIComponent(uid)}`,{method:'POST',body:JSON.stringify({type,amount,memo:$('chargeMemo').value})});$('chargeAmount').value='';$('chargeMemo').value='';awaitloadBilling();}catch(e){alert(e.message);}};
$('fbSummary').textContent=`— ${fbData.counts.total}건 (새 글 ${fbData.counts.new} · 다음 작업 반영 ${fbData.counts.planned||0}) · AI 분석 비용 합계 ${krw(fbData.counts.costKrw)} (${usd(fbData.counts.costUsd)})`;
$('fbdPlanInfo').textContent=x.planned?`반영 결정 ${dt(x.plannedAt)} (${x.plannedBy||''})${x.collectedAt?` · 작업 서버 수집 ${dt(x.collectedAt)}`:' · 아직 수집되지 않음'}`:(x.analysis?'AI 분석을 참고해 반영 여부와 반영할 내용을 정하세요.':'AI 분석이 끝나면 내용을 참고해 결정하세요.');
$('fbdReanalyze').onclick=async()=>{try{awaitapi(`/api/admin/feedback/${id}/analyze`,{method:'POST',body:'{}'});$('fbdMsg').textContent='AI 분석을 다시 요청했습니다. 잠시 후 새로고침하세요.';}catch(e){$('fbdMsg').textContent=e.message;}};
}
// 다음 작업 반영 목록 (v1.1.6): 관리자가 반영으로 결정한 글을 마크다운으로 — 작업 서버가 같은 내용을 수집한다
constact=document.createElement('td');act.className='act';act.appendChild(mkBtn(l.enabled?'끄기':'켜기','ghost',async()=>{awaitapi(`/api/admin/i18n/${l.lang}`,{method:'PUT',body:JSON.stringify({enabled:!l.enabled})});loadI18n();loadOverview();}));constex=document.createElement('a');ex.className='btn ghost';ex.href=`/api/admin/i18n/export?lang=${encodeURIComponent(l.lang)}`;ex.textContent='현재 번역 내려받기';ex.title='최신 원본 문구에 기존 번역을 채워 넣은 파일 — 바뀐 문구만 번역해 다시 올리면 됩니다';act.appendChild(ex);act.appendChild(mkBtn('삭제','ghost danger',async()=>{if(!confirm(`${l.name}(${l.lang}) 번역을 삭제할까요?`))return;awaitapi(`/api/admin/i18n/${l.lang}`,{method:'DELETE'});loadI18n();loadOverview();}));tr.appendChild(act);tb.appendChild(tr);}
if(!d.languages.length){consttr=document.createElement('tr');td(tr,'올린 언어가 없습니다. 원본 파일을 내려받아 번역한 뒤 올리세요.').colSpan=7;tb.appendChild(tr);}
// v1.3.16 서비스 화면 기본 언어 (표시 중인 언어만)
try{constst=awaitapi('/api/admin/settings');constui=st.uiLang||{default:'ko'};constsel=$('uiDefLang');sel.innerHTML='';sel.appendChild(newOption('한국어 (ko)','ko'));for(constlofd.languages.filter((x)=>x.state==='shown'))sel.appendChild(newOption(`${l.name} (${l.lang})`,l.lang));sel.value=ui.default||'ko';if(sel.value!==(ui.default||'ko'))sel.value='ko';$('uiDefForce').checked=false;$('uiDefMsg').textContent=ui.forcedAt?TF('마지막 강제 적용: {1}',dt(ui.forcedAt)):'';markSaved();}catch(_){}
loadSiteMessages();
}
$('uiDefSave').onclick=async()=>{
if($('uiDefForce').checked&&!confirm(T('모든 사용자의 화면 언어를 이 언어로 바꿉니다(이후 사용자가 다시 고를 수 있음). 계속할까요?')))return;
try{constr=awaitapi('/api/admin/settings',{method:'PUT',body:JSON.stringify({uiLang:{default:$('uiDefLang').value,force:$('uiDefForce').checked}})});$('uiDefMsg').textContent=TF('적용했습니다 — 기본 언어 {1}{2}',r.uiLang.default,$('uiDefForce').checked?' · '+T('모든 사용자에게 강제'):'');$('uiDefForce').checked=false;markSaved();}catch(e){$('uiDefMsg').textContent=e.message;}
try{constr=awaitapi('/api/admin/i18n/upload',{method:'POST',body:fd});constbox=$('i18nResults');box.innerHTML='';for(constxofr.results){constd=document.createElement('div');d.className='muted';d.style.color=x.ok?'#6ee7b7':'#fca5a5';d.textContent=x.ok?`✓ ${x.file}: ${x.name}(${x.lang}) ${x.translated}개 번역 저장${x.badPatternCount?` — 자리표시({1} 등)가 원문과 다른 ${x.badPatternCount}개 항목은 제외했습니다(예: ${x.badPatterns.slice(0,2).join(' / ')})`:''}${x.outdated?` — 이 파일은 v${x.sourceVersion} 원본 기준이라 현재 서비스(v${r.version||'?'})에서는 화면에 표시되지 않습니다(바뀐 문구가 섞이는 것 방지). 오른쪽 링크로 "최신 원본 + 지금 올린 번역" 파일을 받아 비어 있는 translation 만 채워 다시 올리세요.`:''}`:`✗ ${x.error}`;box.appendChild(d);
// v1.3.0: 구버전 기준 번역은 바로 이어서 보충할 수 있게 최신 원본에 기존 번역을 채운 파일 링크를 붙인다
if(x.ok&&x.outdated){consta=document.createElement('a');a.className='btn ghost';a.style.marginLeft='6px';a.href=`/api/admin/i18n/export?lang=${encodeURIComponent(x.lang)}`;a.textContent=`${x.lang} 최신 원본 + 기존 번역 내려받기`;a.setAttribute('download','');d.appendChild(a);}}$('i18nMsg').textContent='';loadI18n();loadOverview();}
if(!list.length){consttr=document.createElement('tr');td(tr,'모델 조회를 눌러 목록을 불러오세요').colSpan=4;tb.appendChild(tr);}
}
$('pFetch').onclick=async()=>{$('pMsg').textContent='조회 중…';try{if(provCur.id==='openai'){constam=document.querySelector('input[name=pAuth]:checked').value;if(am!==provCur.authMode){awaitapi(`/api/admin/providers/openai`,{method:'PUT',body:JSON.stringify({authMode:am})});provCur.authMode=am;}}constserverLogin=provCur.id==='openai'&&provCur.authMode==='server-login';constr=awaitapi(`/api/admin/providers/${provCur.id}/test`,{method:'POST',body:JSON.stringify({apiKey:serverLogin?'':($('pKey').value.trim()||'••••'),baseUrl:$('pBase').value.trim()})});if(!r.ok){$('pMsg').textContent='실패: '+r.error;return;}$('pMsg').textContent=`모델 ${r.models.length}개 조회됨 — 제공할 모델을 체크하세요`;renderProvModels(r.models.map((m)=>{constsel=provModels.find((x)=>x.id===m.id);returnsel?{...m,input:sel.input,output:sel.output}:m;}));}catch(e){$('pMsg').textContent='실패: '+e.message;}};
// v1.6.38 (요구 1): 저장은 됐지만 등급 목록에 못 들어가면(키 없음·모델 없음·서버 로그인 안 됨) 그 이유를 바로 말한다 — "추가했는데 설정 탭에 반영이 안 됨" 의 원인
$('pMsg').textContent=r.enabled&&!r.usable?`저장했지만 설정 탭의 등급 목록에는 나오지 않습니다 — ${r.unavailableReason||''}`:`저장됨 (${r.models.length}개 모델 제공${r.enabled?'':', 비활성'}) — 설정 탭의 "모델 등급 지정"·"내부 호출 모델" 목록에 반영했습니다`;$('pMsg').className=r.enabled&&!r.usable?'msg err':'muted';
awaitloadSettings();awaitloadProviders();/* v1.4.14 (요구 6): 등급 지정 목록을 바로 다시 그린다 · v1.6.38: 제공자 표의 "등급 목록" 열도 */}catch(e){$('pMsg').textContent='저장 실패: '+e.message;}};
* 다른 서비스에 접속하는 주소·설정·키(API Key/Token)를 암호화 보관소(data/credentials.json)에 넣고 관리한다.
* 키 원문은 서버가 돌려주지 않으므로 화면에는 hint(앞뒤 몇 글자)만 보인다. 키 칸을 비워 저장하면 기존 키를 유지한다.
* API: GET/POST /api/admin/credentials, PUT/DELETE /api/admin/credentials/<id>, POST /api/admin/credentials/import, POST /api/admin/credentials/<id>/test
act.appendChild(mkBtn('수정','ghost',()=>{$('vKind').value=x.kind;syncVaultKind();$('vId').value=x.id;$('vLabel').value=x.label;$('vUrl').value=x.url;$('vSettings').value=Object.entries(x.settings||{}).map(([k,v])=>`${k}=${v}`).join('\n');$('vSecret').value='';$('vSecret').placeholder=x.hasSecret?`저장됨: ${x.secretHint} (바꾸려면 새 값 입력)`:'키 또는 토큰';$('vMsg').textContent='';}));
act.appendChild(mkBtn('삭제','ghost danger',async()=>{if(!confirm(`"${x.label}" 접속 정보를 삭제할까요? 이 정보를 쓰는 기능이 멈출 수 있습니다.`))return;try{awaitapi(`/api/admin/credentials/${x.id}`,{method:'DELETE'});loadVault();}catch(e){alert(e.message);}}));
tr.appendChild(act);tb.appendChild(tr);
}
if(!vaultData.items.length){consttr=document.createElement('tr');td(tr,'저장된 접속 정보가 없습니다').colSpan=6;tb.appendChild(tr);}
try{constr=awaitapi('/api/admin/credentials/upload',{method:'POST',body:fd});$('vUpload').value='';$('vMsg').textContent=`${f.name} 에서 가져왔습니다 — 키 항목 ${r.found.key}${r.found.url?', 주소 '+r.found.url:''}${r.found.settings.length?', 설정 '+r.found.settings.join(', '):''} (파일의 항목: ${r.fileItems.join(', ')}). 원래 파일은 서버에 저장하지 않았습니다.`;loadVault();}catch(e){$('vMsg').textContent=e.message;}
};
$('vImport').onclick=async()=>{constfile=$('vFile').value.trim();if(!file)return($('vMsg').textContent='키 파일 경로를 입력하세요');try{constr=awaitapi('/api/admin/credentials/import',{method:'POST',body:JSON.stringify({kind:$('vKind').value,id:$('vId').value.trim(),label:$('vLabel').value.trim(),file})});$('vMsg').textContent=`가져왔습니다 — 키 항목 ${r.found.key}${r.found.url?', 주소 '+r.found.url:''}${r.found.settings.length?', 설정 '+r.found.settings.join(', '):''} (파일의 항목: ${r.fileItems.join(', ')}). 이제 평문 키 파일을 지워도 됩니다.`;loadVault();}catch(e){$('vMsg').textContent=e.message;}};
// ---------- 사이트 템플릿 (v1.3.10) ----------
/**
* GET /api/admin/templates (tree·items·tags·mySites), POST 등록, PUT /<id> 수정(사용자 사용 허용 포함),
* POST /<id>/resnapshot 다시 담기, DELETE /<id>. 별도 디렉터리: POST/PUT/DELETE /api/admin/template-dirs[/<id>].
constss=$('tpSite');constkeep=ss.value;ss.innerHTML='';ss.appendChild(newOption(d.mySites.length?T('사이트 선택'):T('관리자가 만든 사이트가 없습니다 — 빌더에서 먼저 만드세요'),''));for(constxofd.mySites)ss.appendChild(newOption(x.title,x.id));ss.value=keep;
{constc=document.createElement('td');constlab=document.createElement('label');lab.className='radio';constcb=document.createElement('input');cb.type='checkbox';cb.checked=!!t.enabled;cb.onchange=async()=>{try{awaitapi(`/api/admin/templates/${t.id}`,{method:'PUT',body:JSON.stringify({enabled:cb.checked})});loadTemplates();}catch(e){alert(e.message);cb.checked=!cb.checked;}};lab.appendChild(cb);lab.appendChild(document.createTextNode(' '+T(t.enabled?'허용':'허용 안 함')));c.appendChild(lab);tr2.appendChild(c);}
act.appendChild(mkBtn('다시 담기','ghost',async()=>{if(!confirm(T('원본 사이트의 지금 파일로 템플릿을 다시 담을까요? (이미 이 템플릿으로 만든 사용자 사이트는 바뀌지 않습니다)')))return;try{awaitapi(`/api/admin/templates/${t.id}/resnapshot`,{method:'POST',body:'{}'});loadTemplates();}catch(e){alert(e.message);}}));
act.appendChild(mkBtn('삭제','ghost danger',async()=>{if(!confirm(TF('템플릿 "{1}" 을 지울까요? (이 템플릿으로 만든 사이트는 그대로 남습니다)',t.title)))return;try{awaitapi(`/api/admin/templates/${t.id}`,{method:'DELETE'});loadTemplates();}catch(e){alert(e.message);}}));
$('tpRegister').onclick=async()=>{$('tpMsg').textContent=T('담는 중…');try{constt=awaitapi('/api/admin/templates',{method:'POST',body:JSON.stringify({siteId:$('tpSite').value,title:$('tpTitle').value,dir:$('tpDir').value,tags:$('tpTags').value,description:$('tpDesc').value})});$('tpMsg').textContent=TF('등록했습니다: {1} (파일 {2}개) — 사용자에게 보이려면 "사용자 사용 허용" 을 켜세요',t.title,t.files);$('tpTitle').value='';$('tpTags').value='';$('tpDesc').value='';loadTemplates();}catch(e){$('tpMsg').textContent=e.message;}};
// ---------- 검색 등록 (v1.3.5) ----------
/**
* GET /api/admin/search — 등록을 시작한 사이트와 엔진별 단계. 동작은 POST /api/admin/search/<사이트ID>/<동작>
* (start|code|apply|step|check|indexnow|stop, 사용자 관리 탭과 같은 서버 함수 searchAction).
constinfo=document.createElement('p');info.className='muted';info.textContent=it.availability.ok?`운영 주소 ${it.liveUrl}`:`진행 불가: ${it.availability.reason}`;card.appendChild(info);
if(st.id==='code'&&st.actor==='manual'){constinp=document.createElement('input');inp.placeholder='확인 코드 또는 meta 태그';inp.value=e.code||'';inp.style.flex='1';inp.style.margin='0';row.appendChild(inp);row.appendChild(mkBtn('저장','ghost',async()=>{try{awaitapi(`/api/admin/search/${it.id}/code`,{method:'POST',body:JSON.stringify({engine:e.engine,code:inp.value})});loadSearchRegs();}catch(er){alert(er.message);}}));}
if(st.id==='apply'&&e.code)row.appendChild(mkBtn(st.done?'다시 넣고 재배포':'태그 넣고 재배포','primary',run('apply',{})));
if(!items.length){constp=document.createElement('p');p.className='muted';p.textContent=$('srPendingOnly').checked?'수동 작업이 남은 사이트가 없습니다':'검색 등록을 시작한 사이트가 없습니다';box.appendChild(p);}
uc.appendChild(mkBtn(s.hasUsageKey?'키 다시 발급':'키 발급','ghost small',()=>mvIssueUsageKey(s)));
if(s.hasUsageKey)uc.appendChild(mkBtn('키 삭제','ghost small',async()=>{if(!confirm(`"${s.nickname}" 에 보관한 사용량 API 키를 지울까요?\nArimail 쪽 키는 그대로이며 메일 사용량 표시만 꺼집니다.`))return;try{constr=awaitapi(`/api/admin/mail-servers/${s.id}/usage-key`,{method:'POST',body:JSON.stringify({action:'clear'})});drawMailServers(r.servers);$('mvMsg').textContent=`${s.nickname}: ${r.detail}`;$('mvMsg').className='muted';$('mvUsage').classList.add('hidden');}catch(e){$('mvMsg').textContent=`${s.nickname}: ${e.message}`;$('mvMsg').className='msg err';}}));
if(!confirm(`"${s.nickname}" 의 관리자 비밀번호를 ${s.prevPasswordAt?dt(s.prevPasswordAt)+' 에 바꾸기 전':'이전'} 값으로 되돌릴까요?\n지금 값은 "이전 비밀번호" 로 보관되어 다시 되돌릴 수 있습니다.`))return;
try{constr=awaitapi(`/api/admin/mail-servers/${s.id}/revert-password`,{method:'POST',body:'{}'});drawMailServers(r.servers);$('mvMsg').textContent=`${s.nickname}: 이전 비밀번호로 되돌렸습니다${r.server.lockedUntil?' — 잠금이 풀린 뒤 "연결 확인" 을 누르세요':'. "연결 확인" 으로 로그인되는지 확인하세요'}`;$('mvMsg').className='muted ok';}catch(e){$('mvMsg').textContent=`${s.nickname}: ${e.message}`;$('mvMsg').className='msg err';}
}));
// v1.6.39 (요구 4): 연동 관리자 계정 비밀번호 초기화 (새 무작위 비밀번호 → Arimail 에 설정 → 보관)
if(s.source!=='config')act.appendChild(mkBtn('삭제','ghost danger',async()=>{if(!confirm(`메일 서버 "${s.nickname}" 등록을 지울까요?\nArimail 서버 자체와 그 안의 계정·메일은 그대로이며, 이 서버를 고른 사이트는 자동 선택(도메인이 같은 서버 → 기본 서버)으로 돌아갑니다.`))return;try{constr=awaitapi(`/api/admin/mail-servers/${s.id}`,{method:'DELETE'});drawMailServers(r.servers);$('mvMsg').textContent='지웠습니다';$('mvMsg').className='muted';loadMailSys();}catch(e){$('mvMsg').textContent=e.message;$('mvMsg').className='msg err';}}));
tr.appendChild(act);tb.appendChild(tr);
}
if(!mvServers.length){consttr=document.createElement('tr');td(tr,'등록된 메일 서버가 없습니다 — "메일 서버 추가" 로 다른 서버에 설치된 Arimail 을 등록하거나, 이 서버에서 deploy/install-arimail.sh 를 실행하세요').colSpan=8;tb.appendChild(tr);}
}
/** v1.6.39: 잠금 안내 문구 — "잠금 · 14:52 까지 (남은 12:31)" */
* v1.6.39 (요구 4): 비밀번호 초기화. 먼저 저장된 비밀번호로 시도하고, 그 로그인이 안 되면(비밀번호를 잘못 저장한 경우) Arimail 의 다른 관리자 계정(초기 admin 등)으로
* 한 번 로그인해 바꾸는 폼(#mvReset)을 연다. 새 비밀번호는 응답을 한 번 보여 주고(표의 "보기" 로 다시 볼 수 있음) 이전 값은 "이전 비밀번호로" 되돌릴 수 있다.
*/
letmvResetTarget=null;
asyncfunctionmvReset(s,via){
if(!via&&!confirm(`"${s.nickname}" 의 연동 관리자 계정 "${s.adminLogin}" 비밀번호를 새 무작위 값으로 바꿀까요?\n\nArimail 의 그 계정 비밀번호가 실제로 바뀌고 웹메이커 보관소에 저장됩니다. 이전 값은 "이전 비밀번호로" 되돌릴 수 있습니다.`))return;
$('mvMsg').innerHTML='';constb=document.createElement('b');b.textContent=`${s.nickname}: ${r.how} 비밀번호를 초기화했습니다${r.created?' (계정이 없어 새로 만들었습니다)':''}. `;$('mvMsg').appendChild(b);
$('mvMsg').appendChild(document.createTextNode(` — ${r.server.adminUrl||'Arimail /admin'} 에 ${s.adminLogin} 으로 로그인할 수 있습니다. 표의 "보기" 로 다시 볼 수 있습니다.${r.test?(r.test.ok?' 연결 확인 성공.':` 연결 확인: ${r.test.detail}`):''}`));
* v1.7.3 (요구 2): 사용량 API 키 발급 — 서버가 저장된 관리자 계정으로 Arimail 에 로그인해 POST /api/setup/usage-key 로 키를 받아 보관한다(키는 화면에 오지 않는다).
* 이미 키가 있으면 Arimail 쪽 키가 바뀌어 이전 키는 즉시 무효 — 확인 창에 적는다.
*/
asyncfunctionmvIssueUsageKey(s){
if(!confirm(`"${s.nickname}" 의 Arimail 에 저장된 관리자 계정(${s.adminLogin})으로 로그인해 사용량 API 키를 ${s.hasUsageKey?'다시 ':''}발급받아 보관합니다.${s.hasUsageKey?'\n기존 키는 Arimail 에서 즉시 무효가 됩니다.':''}\n조건: Arimail 1.0.9 이상, 등록 계정이 기본 도메인의 관리자. 계속할까요?`))return;
$('mvMsg').textContent=`${s.nickname}: 키 발급 중…`;$('mvMsg').className='muted';
$('mvPwNote').textContent=s?(s.hasPassword?'(비우면 기존 비밀번호 유지)':'(저장된 비밀번호가 없습니다 — 입력하세요)'):'(새 서버는 필수)';$('mvFormMsg').textContent='';
$('mvUsageKey').value='';$('mvUkNote').textContent=s?(s.hasUsageKey?`(보관 중 ${s.usageKeyHint||''} — 비우면 유지)`:'(없음 — 붙여 넣거나 표의 "키 발급")'):'(선택 — 저장 뒤 표의 "키 발급" 으로 받을 수도 있음)';// v1.7.3
if($('mvUsageKey').value.trim())body.usageKey=$('mvUsageKey').value.trim();// v1.7.3: 사용량 API 키(비우면 유지)
// v1.6.39 (요구 2): 저장된 비밀번호가 있는 서버의 비밀번호 칸에 값이 있으면 덮어쓰기 전에 확인 — 2026-10-21 운영 서버에서 동작하던 비밀번호를 실수로 덮어쓴 일의 재발 방지
if(mvEditing&&mvEditing.hasPassword&&body.password&&!confirm(`"${mvEditing.nickname}" 의 저장된 Arimail 관리자 비밀번호를 지금 입력한 값으로 바꿉니다.\n\n틀린 값으로 바꾸면 메일 계정 관리·연결 확인이 실패합니다(여러 번 실패하면 Arimail 이 15분간 로그인을 막습니다). 비밀번호를 그대로 두려면 "취소" 를 누르고 칸을 비우세요.\n바꾼 뒤에는 표의 "이전 비밀번호로" 로 되돌릴 수 있습니다.\n\n계속할까요?`)){$('mvFormMsg').textContent='비밀번호를 바꾸지 않았습니다 (저장 취소)';$('mvFormMsg').className='muted';return;}
$('mvFormMsg').textContent='저장 중…';
try{constr=mvEditing?awaitapi(`/api/admin/mail-servers/${mvEditing.id}`,{method:'PUT',body:JSON.stringify(body)}):awaitapi('/api/admin/mail-servers',{method:'POST',body:JSON.stringify(body)});$('mvPw').value='';$('mvUsageKey').value='';drawMailServers(r.servers);$('mvFormMsg').textContent=`저장했습니다 — ${r.server.nickname}${r.server.ip?' ('+r.server.ip+')':''}. "연결 확인" 으로 Arimail 로그인까지 확인하세요`;$('mvFormMsg').className='muted ok';mvEditing=r.server;$('mvFormTitle').textContent=`메일 서버 수정 — ${r.server.nickname}`;$('mvPwNote').textContent='(비우면 기존 비밀번호 유지)';loadMailSys();}
// v1.5.7: 받는 메일은 연동 모듈(Arimail) 상태로 — 연결되어 있으면 주소·도메인·버전, 아니면 연결 방법(시스템 설정 탭)
constam=v.backend.arimail;
$('msBackend').textContent=`보내기: ${v.backend.canSend?'가능 — 메일 발송 키(Resend) 사용, 메일 도메인이 Resend 에서 확인되면 발송':'불가 — 설정 탭 › 외부 연동 접속 정보에 메일 발송(Resend) 키를 넣으세요'} · 받는 메일·웹메일(기본 서버): ${am?(am.ok?`Arimail ${am.version||''} 연결됨 (${am.nickname||am.host}${am.ip?' '+am.ip:''} · ${am.host}, 도메인 ${am.domain}) — 메일 도메인이 ${am.domain} 인 사이트에 적용`:`Arimail 설정은 있지만 응답이 없습니다 (${am.detail})`):'메일 서버(Arimail) 미연결 — 아래 "메일 서버 추가" 로 등록하거나(다른 서버에 설치된 Arimail 도 가능) 이 서버에서 deploy/install-arimail.sh 를 실행'}`;
if(!(d.records||[]).length){consttr=document.createElement('tr');td(tr,d.recordsError||'메일 시스템을 켜면 이 도메인에 등록할 DNS 레코드가 표시됩니다').colSpan=6;rt.appendChild(tr);}
}
$('msApply').onclick=async()=>{
if(!msCur)return;$('msMsg').textContent='저장하고 DNS 를 확인하는 중…';
try{awaitapi(`/api/admin/mail/${msCur}`,{method:'POST',body:JSON.stringify({domain:$('msDomain').value.trim(),mxHost:$('msMx').value.trim(),spfInclude:$('msSpf').value.trim(),dmarc:$('msDmarc').value.trim(),mailServer:$('msServer').value/* v1.6.38 */})});$('msMsg').textContent='저장했습니다. DNS 가 전파·확인될 때까지 사용자에게 "메일시스템 설정중" 으로 표시됩니다.';awaitloadMailSys();}
$('msOff').onclick=async()=>{if(!msCur||!confirm('이 사이트의 메일 시스템을 끕니다. 사용자는 웹메일에 들어갈 수 없게 되며, 계정과 메일은 남아 다시 켜면 이어서 씁니다. 계속할까요?'))return;try{awaitapi(`/api/admin/mail/${msCur}/disable`,{method:'POST',body:'{}'});$('msMsg').textContent='껐습니다';awaitloadMailSys();}catch(e){$('msMsg').textContent=e.message;}};
$('thState').textContent=thInfo.saved?'— 사용자 지정 적용 중':'— 기본값';
renderTheme();buildPreview();
}
$('thRevert').onclick=()=>{if(!thInfo)return;thCur=JSON.parse(JSON.stringify(thInfo.theme));renderTheme();$('thMsg').textContent='저장된 값으로 되돌렸습니다 (아직 저장 안 함)';};
constsel=q('#siteSelect');if(sel){sel.innerHTML='<option>+ 새 사이트 만들기</option><optgroup label="완성된 사이트"><option selected>샘플 베이커리 · 발행됨</option></optgroup>';}
set('#siteStatePill','발행됨');constpill=q('#siteStatePill');if(pill)pill.className='pill small done';
set('#statusPill','진행 중');constsp=q('#statusPill');if(sp)sp.className='pill running';constpr=q('#progress');if(pr)pr.className='progress running';set('#progressText','수정 반영 진행 중…');set('#progressSub','경과 12초');
constta=q('#aiText');if(ta)ta.textContent='강남역 근처 필라테스 스튜디오. 1:1 수업 중심, 예약을 많이 받고 싶어요.';
set('#siteUrl','https://shop.example.com/');constes=q('#emptyState');if(es)es.innerHTML='<div class="empty-inner"><h3>샘플 베이커리</h3><p>오늘 구운 빵을 매일 아침 8시에 만나 보세요.</p><p><button class="primary">예약하기</button> <button class="ghost">메뉴 보기</button></p></div>';
$('thSave').onclick=async()=>{try{awaitapi('/api/admin/settings',{method:'PUT',body:JSON.stringify({theme:thCur})});$('thMsg').textContent='저장했습니다. 사용자 화면을 새로고침하면 적용됩니다.';loadTheme();}catch(e){$('thMsg').textContent=e.message;}};
el.innerHTML=`<b>HAL 관리</b> — 이 서버의 다음 설정은 Ariweb HAL 통합 설정(HAL ${esc(h.halCompat||'')})이 관리합니다. 여기서는 바꿀 수 없고 HAL 운영자 콘솔 › 통합 설정에서 바꿉니다.<br><span style="font-family:monospace;font-size:12px">${h.keys.map(esc).join(', ')}</span>${h.appliedAt?`<br><small>마지막 적용 ${esc(h.appliedAt)}${(h.errors||[]).length?` · 적용 오류: ${h.errors.map((x)=>esc(x.key)).join(', ')}`:''}</small>`:''}`;
cashUnit=s.cashUnit||'원';$('sCashUnit').value=cashUnit;document.querySelectorAll('.cash-unit').forEach((e)=>{e.textContent=cashUnit;});$('chargeAmount').placeholder=TF('금액({1})',cashUnit);// v1.4.1 캐시 표시 단위
$('sBacklogKey').value='';$('sBacklogKey').placeholder=s.backlogKeyHint?`저장됨: ${s.backlogKeyHint} (바꾸려면 새 키 입력)`:'수집 키 (비워 두면 비활성)';
$('sServiceKey').value='';$('sServiceKey').placeholder=s.serviceApiKeyHint?`저장됨: ${s.serviceApiKeyHint} (바꾸려면 새 키 입력)`:'서비스 키 (비워 두면 외부 연동 비활성)';
renderClaudeModels(s);// v1.5.16 AI 연결 탭: 사용할 Claude 모델 체크 목록
/** 사용 가능한 모델 전체를 제공자별 묶음으로 채운 select. cur 가 목록에 없으면(제공자를 껐거나 모델을 뺌) "현재 사용 불가" 항목으로 보인다 — v1.5.16: 내부 호출 모델도 같은 목록을 쓴다 */
constfillModelSelect=(sel,cur)=>{
sel.innerHTML='';
for(constgofgroups){constog=document.createElement('optgroup');og.label=g.key;for(constmofg.items)og.appendChild(newOption(`${m.label} (${m.ref}) — 입력 ${m.input} / 출력 ${m.output} per 1M`,m.ref));sel.appendChild(og);}
if(cur&&![...sel.options].some((o)=>o.value===cur))sel.appendChild(newOption(`${cur} (현재 사용 불가 — AI 연결 탭에서 제공자·모델 확인)`,cur));
note.textContent=TF('고를 수 있는 모델 {1}개 ({2})',models.length,groups.map((g)=>`${g.key}${g.items.length}`).join(', '));
box.appendChild(note);
// v1.6.38 (요구 1): 켜 두었지만 목록에 못 들어간 제공자는 빨간 줄로 따로 — 회색 한 줄에 묻혀 "추가한 모델이 안 보인다" 로 이어지던 문제
if(miss.length){constwarn=document.createElement('p');warn.className='msg err';warn.style.margin='0 0 6px';warn.textContent=TF('목록에 들어가지 못한 AI 제공자: {1} — AI 연결 탭에서 고치면 바로 이 목록에 나옵니다',miss.map((x)=>`${x.label}(${x.reason})`).join(' / '));box.appendChild(warn);}
sel.appendChild(newOption(T('기본 안내 (페이지 없음)'),''));
for(constcoflist){consto=newOption(`${c.title} — ${c.published?T('배포됨'):(c.hasIndex?T('미배포'):T('첫 페이지 없음'))}`,c.id);o.disabled=!c.published&&!c.hasIndex;sel.appendChild(o);}
if(!list.length)$('welcomeMsg').textContent=T('관리자 계정이 만든 사이트가 없습니다. /builder 에서 먼저 만드세요');
}
$('btnSaveWelcome').onclick=()=>saveSetting({welcomeSiteId:$('sWelcome').value},'welcomeMsg',(s)=>{constc=(s.welcomeCandidates||[]).find((x)=>x.id===s.welcomeSiteId);returnc?TF('저장했습니다 — {1}',c.title):T('저장했습니다 — 기본 안내');});
$('btnSaveCashUnit').onclick=()=>saveSetting({cashUnit:$('sCashUnit').value},'cashUnitMsg',()=>TF('저장했습니다 — 예: {1}',krw(12000)));// v1.4.1 저장 후 loadSettings 가 cashUnit 을 바꾼 뒤 예시를 보인다
$('btnSaveBacklogKey').onclick=async()=>{try{awaitapi('/api/admin/settings',{method:'PUT',body:JSON.stringify({backlogKey:$('sBacklogKey').value.trim()})});$('backlogKeyMsg').textContent=$('sBacklogKey').value.trim()?'저장되었습니다. 이 키를 작업 서버의 수집 설정에 등록하세요(다시 표시되지 않음).':'수집을 껐습니다.';awaitloadSettings();}catch(e){$('backlogKeyMsg').textContent='실패: '+e.message;}};
$('btnSaveServiceKey').onclick=async()=>{try{awaitapi('/api/admin/settings',{method:'PUT',body:JSON.stringify({serviceApiKey:$('sServiceKey').value.trim()})});$('serviceKeyMsg').textContent=$('sServiceKey').value.trim()?'저장되었습니다. 이 키는 다시 표시되지 않으니 외부 서비스에 지금 등록하세요.':'외부 연동을 비활성화했습니다.';awaitloadSettings();}catch(e){$('serviceKeyMsg').textContent='실패: '+e.message;}};
asyncfunctionloadMail(){
constm=awaitapi('/api/admin/settings/mail');$('mKeyFile').value=m.keyFile||'';$('mApiKey').value='';$('mApiKey').placeholder=m.source==='vault'?'암호화 보관소의 키 사용 중 (바꾸려면 새 키 입력)':m.source==='settings'?'저장된 키 사용 중 (바꾸려면 새 키 입력)':'또는 Resend API 키 직접 입력 (re_…)';$('mDomain').value=m.defaultDomain||'';$('mFromName').value=m.fromName||'';$('mFormBase').value=m.formBase||'';
$('mStatus').textContent=m.configured?`키 확인됨 (${m.source==='vault'?'암호화 보관소':m.source==='file'?'평문 키 파일 '+(m.keyName||''):'직접 입력'}) · Resend 검증 도메인: ${m.verifiedDomains.length?m.verifiedDomains.join(', '):'없음/조회 실패'}${m.error?' · '+m.error:''}`:`미설정: ${m.error||''}`;
$('mTest').onclick=async()=>{constto=$('mTestTo').value.trim();if(!to)returnalert('테스트 수신 주소를 입력하세요');$('mTestMsg').textContent='발송 중…';try{constr=awaitapi('/api/admin/settings/mail-test',{method:'POST',body:JSON.stringify({to,domain:$('mDomain').value})});$('mTestMsg').textContent=r.ok?`발송 성공 (발신 ${r.from})`:'실패: '+r.error;}catch(e){$('mTestMsg').textContent='실패: '+e.message;}};
if(mode==='oauth-token'&&!body.auth.oauthToken&&!settingsData.auth.oauthToken)returnalert('OAuth 토큰을 입력하세요');if(mode==='api-key'&&!body.auth.apiKey&&!settingsData.auth.apiKey)returnalert('API 키를 입력하세요');
try{awaitapi('/api/admin/settings',{method:'PUT',body:JSON.stringify(body)});$('connResult').textContent='저장됨. 연결 테스트로 확인하세요. (인증이 되면 사용할 Claude 모델이 설정 탭의 등급 목록에 나옵니다)';loadSettings();}catch(e){alert(e.message);}
};
$('btnTestConn').onclick=async()=>{$('connResult').textContent='테스트 중… (10~30초)';$('btnTestConn').disabled=true;try{constr=awaitapi('/api/admin/settings/test-connection',{method:'POST',body:'{}'});$('connResult').textContent=r.ok?`연결 성공 — 모델 ${r.model}, 응답 "${r.reply}", 비용 ${usd(r.costUsd)}${r.claude&&r.claude.bin?` (${r.claude.bin})`:''}`:`연결 실패 — ${r.error||r.subtype}`;}catch(e){$('connResult').textContent='연결 실패 — '+e.message;}$('btnTestConn').disabled=false;};
// v1.5.36: DNS 공급자·등록 업체 선택 상자 (준비 여부 표시). 목록은 /api/admin/settings 의 domainProviders
{constcat=(settingsData&&settingsData.domainProviders)||{dns:[],registrar:[]};constfill=(sel,items,cur)=>{sel.innerHTML='';for(constitofitems)sel.appendChild(newOption(`${it.label}${it.id==='manual'?'':(it.ready?' — 준비됨':' — 접속 정보 없음')}`,it.id));sel.value=cur;if(sel.value!==cur&&items.length)sel.value=items[0].id;};fill($('dmDns'),cat.dns,d.dnsProvider||d.platformDns||'manual');fill($('dmRegistrar'),cat.registrar,d.registrar||'openprovider');$('dmProvNote').textContent='접속 정보는 왼쪽 "외부 연동 접속 정보" 에서 종류 Openprovider · Porkbun · Route 53 · Google Cloud DNS 로 넣고 "연결 확인" 으로 검사합니다. 구매한 도메인의 영역은 DNS 공급자에 만들어지고, 등록 업체와 공급자가 다르면 네임서버를 자동으로 지정합니다.';constdz=$('dzProvider');if(dz){dz.innerHTML='';for(constitofcat.dns.filter((x)=>x.id!=='manual'))dz.appendChild(newOption(`${it.label}${it.ready?'':' (접속 정보 없음)'}`,it.id));dz.value=(d.dnsProvider&&d.dnsProvider!=='manual')?d.dnsProvider:(cat.dns.find((x)=>x.ready&&x.id!=='manual')||{}).id||'';}}
if($('dmAllowSub'))$('dmAllowSub').checked=d.allowSubdomain!==false;// v1.6.35 (요구 1-1) 서브 도메인 등록 허용
}
if($('btnDmAllowSub'))$('btnDmAllowSub').onclick=()=>saveDomains({allowSubdomain:$('dmAllowSub').checked},'dmAllowSubMsg',(d)=>(d.allowSubdomain!==false?T('저장했습니다 — 서브 도메인 등록 허용'):T('저장했습니다 — 서브 도메인 등록 금지 (기존 등록은 유지)')));
$('btnDmMargin').onclick=()=>saveDomains({marginPct:$('dmMargin').value},'dmMarginMsg',(d)=>TF('저장했습니다 — 사용자 가격 = 공급사 가격 × {1}%',d.marginPct));
$('btnDmPlatform').onclick=()=>saveDomains({platformDomain:$('dmPlatform').value,dnsProvider:$('dmDns').value,registrar:$('dmRegistrar').value},'dmPlatformMsg',(d)=>TF('저장했습니다 — {1} (DNS {2} · 등록 {3})',d.platformDomain||'-',d.dnsProvider||'manual',d.registrar||'openprovider'));// v1.5.36: DNS 공급자·등록 업체 함께 저장
$('btnDmIp').onclick=()=>saveDomains({targetIp:$('dmIp').value},'dmIpMsg',(d)=>(d.targetIp?TF('저장했습니다 — {1}',d.targetIp):T('비웠습니다 — 서비스 주소의 IP 를 씁니다')));
$('btnDmDefType').onclick=()=>saveDomains({defaultAccountType:$('dmDefType').value},'dmDefTypeMsg',(d)=>TF('저장했습니다 — 새 가입자: {1}',d.defaultAccountType==='domain'?T('자체 도메인'):T('서브 도메인 전용')));
$('btnSaveMargin').onclick=()=>saveSetting({marginPct:$('sMargin').value},'marginMsg',(s)=>TF('마진 {1}% 로 변경했습니다',s.marginPct)).then(()=>{billingData=null;});
// ---------- v1.4.14 (요구 9) 플랫폼 도메인 와일드카드 인증서 ----------
/**
* 상태를 받아 그린다. 발급이 진행 중이면 10초마다 다시 받아 수동 DNS 모드의 "넣을 TXT" 와 결과를 보인다.
* cert.hasCert → 만료일·남은 일수, pending → TXT 이름·값, last → 마지막 발급 결과
if(st.running)parts.push(TF('발급 진행 중 ({1} 시작)',dt(st.job.startedAt)));
if(st.last)parts.push(`${T('마지막 발급')}: ${dt(st.last.at)}${st.last.ok?T('성공'):T('실패')} — ${st.last.detail}${st.last.reconfigured?` · ${TF('사이트 {1}개 설정 갱신',st.last.reconfigured)}`:''}`);
if(st.cert&&st.cert.hasCert&&st.cert.daysLeft!=null&&st.cert.daysLeft<20)parts.push(T('⚠ 만료가 가까워 갱신이 필요합니다 — 수동 DNS 방식이면 "발급·갱신" 을 누르고 뜨는 TXT 를 넣어 주세요'));
if(pend){$('sslTxtName').value=pend.name;$('sslTxtValue').value=pend.value;$('sslPendingNote').textContent=pend.auto?T('(자동 등록됨 — 전파 확인 중)'):TF('— DNS 관리 화면에 넣으면 자동으로 이어집니다 (최대 {1}분 대기, {2} 요청)',pend.waitMinutes||st.waitMinutes,dt(pend.at));}
try{constr=awaitapi('/api/admin/ssl/wildcard',{method:'POST',body:'{}'});$('sslMsg').textContent=r.mode==='manual'?TF('시작했습니다 — 몇 초 뒤 위에 TXT 레코드가 표시됩니다 (최대 {1}분 대기)',r.waitMinutes):T('시작했습니다 — DNS 에 TXT 를 자동으로 넣고 인증합니다 (수 분)');setTimeout(loadSsl,4000);}
$('btnSaveLeEmail').onclick=()=>saveSetting({letsEncryptEmail:$('sLeEmail').value},'leMsg',(s)=>(s.letsEncryptEmail?TF('저장했습니다 — {1}',s.letsEncryptEmail):T('비웠습니다 (이메일 없이 발급)')));
$('btnSaveInternal').onclick=()=>saveSetting({internalRef:$('sInternalRef').value},'internalMsg',(s)=>TF('저장했습니다 — {1}',s.internalRef)).then(()=>loadOverview());// v1.5.16 내부 호출 모델
/**
* v1.5.16 (요구 3) AI 연결 탭 › 사용할 Claude 모델: Claude 가격표(settings.MODELS)를 체크 목록으로 보이고, 체크한 id 배열을 settings.claudeModels 로 저장한다.
* 비어 있으면(= 저장한 적 없음) 전체 사용으로 본다. 저장 뒤 loadSettings 가 등급·내부 호출 목록을 다시 그린다.
l.appendChild(cb);l.appendChild(document.createTextNode(` ${m.label} (${m.id}) — 입력 ${m.input} / 출력 ${m.output} per 1M${m.note?' · '+m.note:''}`));box.appendChild(l);
constnote=document.createElement('p');note.className='muted';note.textContent=used.size?TF('지금 등급·내부 호출에 지정된 Claude 모델: {1} — 이 모델을 끄면 설정 탭에서 "현재 사용 불가" 로 보입니다',[...used].join(', ')):'';box.appendChild(note);
if(!ids.length){$('claudeModelsMsg').textContent=T('적어도 한 모델은 사용으로 두세요');$('claudeModelsMsg').className='msg err';return;}
saveSetting({claudeModels:ids},'claudeModelsMsg',(s)=>TF('저장했습니다 — 사용 {1}개 (설정 탭의 등급·내부 호출 목록에 반영)',(s.claudeModels||[]).length||(s.models||[]).length));
};
/**
* v1.3.16 저장하지 않은 변경 표시: data-dirty-watch 입력칸은 불러온 값(dataset.saved)과 다르면 노란 테두리(.dirty)로 표시하고,
constbar=$('verBar');bar.classList.remove('hidden');bar.textContent='';bar.appendChild(document.createTextNode(`서비스가 v${loadedVersion} → v${v} 로 업데이트되어 화면 데이터를 다시 불러왔습니다. 새 기능 화면까지 받으려면 `));consta=document.createElement('button');a.type='button';a.className='ghost';a.textContent='페이지 새로 열기';a.onclick=()=>location.reload();bar.appendChild(a);
if(!prov){$('dzMsg').textContent=T('준비된 DNS 공급자가 없습니다 — 외부 연동 접속 정보에 넣으세요');return;}
try{constr=awaitapi(`/api/admin/dns/zones?provider=${encodeURIComponent(prov)}`);dzZones=r.zones||[];$('dzMsg').textContent=TF('{1}: 영역 {2}개',prov,dzZones.length);}
constact=document.createElement('td');act.className='act';act.appendChild(mkBtn('레코드','ghost',()=>dzRecords(z.name)));act.appendChild(mkBtn('지우기','ghost danger',async()=>{if(!confirm(`${z.name} 영역을 ${prov} 에서 지울까요? 레코드가 모두 사라집니다.`))return;letq='';if(owner){if(!confirm(`이 영역은 회원 ${owner.userId} 의 도메인입니다. 그래도 지울까요?`))return;q='&force=1';}try{awaitapi(`/api/admin/dns/zones/${encodeURIComponent(z.name)}?provider=${encodeURIComponent(prov)}${q}`,{method:'DELETE'});dzLoad();}catch(e){alert(e.message);}}));tr.appendChild(act);tb.appendChild(tr);