Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
298 lines
36 KiB
JavaScript
298 lines
36 KiB
JavaScript
'use strict';
|
|
/**
|
|
* AI Webmaker 초기 설정 화면 (v1.5.7, 요구 2·7)
|
|
*
|
|
* 설정 파일(lib/config.js)의 항목표를 받아 그룹별 카드로 그리고, 바뀐 값만 저장한다. 두 곳에서 같은 코드를 쓴다.
|
|
* - 전용 설정 페이지 (setup-server.js, 포트 3081): 설치 직후 토큰 또는 관리자 로그인으로 들어온다. API /api/setup/*
|
|
* - 관리자 콘솔 › 시스템 설정 탭 (admin.html): 로그인한 관리자. API /api/admin/config/*
|
|
* 어느 쪽이든 WMSetup.mount(컨테이너, { apiBase }) 로 붙인다. 화면 문구는 한국어 원문이며 i18n 런타임이 번역한다.
|
|
*
|
|
* 화면 구성
|
|
* 0) (v1.5.11, 요구 1·2) 설치 점검표: 관리자 비밀번호 · AI 엔진 인증 · 공개 주소 — 하나라도 안 되면 서비스 첫 화면이 이 페이지로 넘어온다.
|
|
* 바로 아래에 "관리자 계정"(최초 관리자 만들기 · 비밀번호 재설정 · 새 관리자 — v1.6.31 고정 비밀번호 없음) 과 "AI 엔진 인증"(Claude 서버 로그인/OAuth 토큰/API 키, OpenAI) 카드
|
|
* 1) 상태 카드: 웹메이커 서비스 · 설정 페이지 서비스 · Gitea(소스 보관) · Arimail(메일) · Claude · Codex · Resend — 연결 확인 버튼
|
|
* 2) 설정 그룹 카드: 항목마다 입력칸 + 출처(파일/환경 변수/기본값) 배지 + 설명. 비밀값은 저장 여부·힌트만 보이고 새 값을 넣으면 바뀐다
|
|
* 3) 설치 안내: 연동 서비스 설치 스크립트 명령 (현재 값으로 채운 예시)
|
|
* 저장 → PUT { values }. 재시작이 필요한 항목이 바뀌면 안내와 함께 "서비스 재시작" 버튼을 강조한다.
|
|
* 열리는 곳 (v1.5.11): 별도 포트(standalone, 기본 8795) · 본 서비스 /setup(inline — 토큰 또는 관리자 로그인) · 관리자 콘솔 탭. 앞 둘은 API /api/setup, 탭은 /api/admin/config
|
|
*/
|
|
(function () {
|
|
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
const SOURCE = { file: '설정 파일', env: '환경 변수', default: '기본값', vault: '보관소' };
|
|
const CSS = `
|
|
.wms { font-size: 14px; }
|
|
.wms .wms-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; margin: 10px 0 16px; }
|
|
.wms .wms-stat { border: 1px solid var(--line, #e3e6ea); border-radius: 10px; padding: 10px 12px; background: var(--panel, #fff); }
|
|
.wms .wms-stat h3 { margin: 0 0 4px; font-size: 14px; display: flex; align-items: center; gap: 8px; }
|
|
.wms .wms-stat .d { font-size: 12px; color: var(--muted, #6b7684); word-break: break-all; }
|
|
.wms .wms-stat .act { margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
|
|
.wms .pill { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; background: #eef1f5; color: #475569; }
|
|
.wms .pill.ok { background: #d9f5e6; color: #0f7a46; } .wms .pill.bad { background: #fde2e2; color: #b42318; } .wms .pill.warn { background: #fff1d6; color: #9a5b00; }
|
|
.wms .wms-group { border: 1px solid var(--line, #e3e6ea); border-radius: 10px; padding: 12px 14px; margin-bottom: 12px; background: var(--panel, #fff); }
|
|
.wms .wms-group h2 { margin: 0 0 4px; font-size: 16px; display: flex; align-items: center; gap: 8px; }
|
|
.wms .wms-group .gd { color: var(--muted, #6b7684); font-size: 12px; margin-bottom: 8px; }
|
|
.wms .wms-field { display: grid; grid-template-columns: minmax(160px, 220px) 1fr; gap: 6px 12px; padding: 8px 0; border-top: 1px dashed var(--line, #e3e6ea); align-items: start; }
|
|
.wms .wms-field label { font-weight: 600; font-size: 13px; padding-top: 6px; }
|
|
.wms .wms-field label .env { display: block; font-weight: 400; font-size: 11px; color: var(--muted, #6b7684); font-family: ui-monospace, monospace; }
|
|
.wms .wms-field input[type=text], .wms .wms-field input[type=password], .wms .wms-field input[type=number], .wms .wms-field select { width: 100%; max-width: 640px; margin: 0; box-sizing: border-box; }
|
|
.wms .wms-field input.dirty, .wms .wms-field select.dirty { border-color: #f59e0b; box-shadow: 0 0 0 2px rgba(245,158,11,.15); }
|
|
.wms .wms-field .meta { font-size: 11px; color: var(--muted, #6b7684); margin-top: 3px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
.wms .wms-field .desc { font-size: 12px; color: var(--muted, #6b7684); margin-top: 3px; line-height: 1.45; }
|
|
.wms .wms-field.ro input { background: #f6f7f9; color: #475569; }
|
|
.wms .wms-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
|
|
.wms .wms-msg { font-size: 12px; min-height: 16px; }
|
|
.wms .wms-msg.ok { color: #0f7a46; } .wms .wms-msg.bad { color: #b42318; }
|
|
.wms details.wms-adv summary { cursor: pointer; color: var(--muted, #6b7684); font-size: 13px; }
|
|
.wms code.cmd { display: block; background: #0f172a; color: #e2e8f0; padding: 8px 10px; border-radius: 8px; font-size: 12px; overflow: auto; white-space: pre; margin: 4px 0 8px; }
|
|
.wms .wms-secret { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
.wms .wms-secret input { flex: 1; min-width: 200px; }
|
|
.wms .req { color: #b42318; }
|
|
.wms .wms-head { display: flex; gap: 14px; flex-wrap: wrap; align-items: baseline; margin-bottom: 6px; }
|
|
.wms .wms-head .muted { font-size: 12px; }
|
|
/* v1.5.16 (요구 1): 점검표 카드는 밝은 배경(호박색·연두색)을 쓰는데 글자 색을 정하지 않아 관리자 콘솔(어두운 테마: 글자 #e6ebf5, 제목 #ffc457)에서는
|
|
글자가 배경과 거의 같은 밝기라 읽을 수 없었다. 배경의 보색 계열로 글자 색을 명시한다 — 호박색 배경 ↔ 짙은 파랑, 연두 배경 ↔ 짙은 자주 (어느 테마에서든 같은 색) */
|
|
.wms .wms-check { border: 2px solid #f59e0b; border-radius: 10px; padding: 12px 14px; margin-bottom: 12px; background: #fffbeb; color: #1e3a8a; }
|
|
.wms .wms-check h2, .wms .wms-check b { color: #1e3a8a; }
|
|
.wms .wms-check .gd { color: #1e40af; }
|
|
.wms .wms-check.done { border-color: #10b981; background: #ecfdf5; color: #701a75; }
|
|
.wms .wms-check.done h2, .wms .wms-check.done b { color: #86198f; }
|
|
.wms .wms-check.done .gd, .wms .wms-check.done li.warn { color: #86198f; }
|
|
.wms .wms-check h2 { margin: 0 0 6px; font-size: 16px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
.wms .wms-check ul { margin: 6px 0 0; padding-left: 0; list-style: none; }
|
|
.wms .wms-check li { display: grid; grid-template-columns: 84px 1fr; gap: 8px; padding: 5px 0; border-top: 1px dashed rgba(30,58,138,.18); font-size: 13px; align-items: start; }
|
|
.wms .wms-check li .h { color: #1d4ed8; font-size: 12px; margin-top: 2px; }
|
|
.wms .wms-check li.warn { color: #1e40af; font-size: 12px; }
|
|
.wms .wms-check li.warn b { color: #1e40af; }
|
|
.wms .wms-check .pill { color: #1e293b; } .wms .wms-check .pill.ok { color: #0f7a46; } .wms .wms-check .pill.bad { color: #b42318; } .wms .wms-check .pill.warn { color: #9a5b00; }
|
|
.wms .wms-inline { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 6px 0; }
|
|
.wms .wms-inline input, .wms .wms-inline select { margin: 0; min-width: 160px; }
|
|
.wms table.wms-admins { border-collapse: collapse; font-size: 13px; margin: 6px 0; }
|
|
.wms table.wms-admins td, .wms table.wms-admins th { border-bottom: 1px solid var(--line, #e3e6ea); padding: 4px 10px 4px 0; text-align: left; }
|
|
.wms .wms-radio { display: flex; gap: 14px; flex-wrap: wrap; margin: 6px 0; font-size: 13px; }
|
|
.wms .wms-sub { border-top: 1px dashed var(--line, #e3e6ea); padding-top: 8px; margin-top: 8px; }
|
|
`;
|
|
function ensureStyle() { if (document.getElementById('wms-style')) return; const s = document.createElement('style'); s.id = 'wms-style'; s.textContent = CSS; document.head.appendChild(s); }
|
|
function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
|
|
function btn(label, cls, fn) { const b = el('button', cls || 'ghost', label); b.type = 'button'; b.onclick = fn; return b; }
|
|
|
|
/**
|
|
* 설정 화면을 컨테이너에 붙인다.
|
|
* @param {HTMLElement} root
|
|
* @param {{apiBase:string, standalone?:boolean, onAuthFail?:Function}} opts
|
|
*/
|
|
function mount(root, opts) {
|
|
ensureStyle(); root.classList.add('wms');
|
|
let csrfTok = ''; const getCsrf = async () => { if (csrfTok) return csrfTok; try { const r = await fetch(opts.apiBase.replace(/\/api\/.*$/, '') + (opts.apiBase.includes('/api/setup') ? '/api/setup/session' : '/api/auth/csrf')); const b = await r.json(); csrfTok = b.csrf || ''; } catch (_) {} return csrfTok; }; // v1.6.31 CSRF
|
|
const api = async (path, init) => {
|
|
const tok = await getCsrf(); const r = await fetch(opts.apiBase + path, { headers: { 'Content-Type': 'application/json', ...(tok ? { 'X-WM-CSRF': tok } : {}) }, ...(init || {}) });
|
|
const b = await r.json().catch(() => ({}));
|
|
if (r.status === 401 && opts.onAuthFail) { opts.onAuthFail(); throw new Error('로그인이 필요합니다'); }
|
|
if (!r.ok) throw new Error(b.error || `HTTP ${r.status}`);
|
|
return b;
|
|
};
|
|
let view = null; const inputs = new Map(); // key → { def, get(): 값 또는 undefined(변경 없음), el }
|
|
const msg = el('div', 'wms-msg'); const setMsg = (t, ok) => { msg.textContent = t || ''; msg.className = 'wms-msg ' + (t ? (ok ? 'ok' : 'bad') : ''); };
|
|
|
|
async function load() { root.innerHTML = '<p class="muted">설정을 불러오는 중…</p>'; try { view = await api(''); render(); } catch (e) { root.innerHTML = ''; root.appendChild(el('p', 'muted', '불러오기 실패: ' + e.message)); } }
|
|
|
|
function pill(ok, labelOk, labelBad) { return el('span', 'pill ' + (ok === true ? 'ok' : ok === false ? 'bad' : 'warn'), ok === true ? (labelOk || '정상') : ok === false ? (labelBad || '확인 필요') : '알 수 없음'); }
|
|
/** 상태 카드 하나 */
|
|
function statCard(title, st, extra) {
|
|
const c = el('div', 'wms-stat'); const h = el('h3'); h.appendChild(document.createTextNode(title)); h.appendChild(pill(st ? !!st.ok : null)); c.appendChild(h);
|
|
c.appendChild(el('div', 'd', st ? (st.detail || '') : '상태 없음'));
|
|
const act = el('div', 'act'); (extra || []).forEach((b) => b && act.appendChild(b)); if (act.childNodes.length) c.appendChild(act);
|
|
return c;
|
|
}
|
|
const testBtn = (kind, card) => btn('연결 확인', 'ghost', async () => { const d = card.querySelector('.d'); d.textContent = '확인 중…'; try { const r = await api(`/test/${kind}`, { method: 'POST', body: '{}' }); d.textContent = r.detail || (r.ok ? '정상' : '실패'); card.querySelector('.pill').replaceWith(pill(!!r.ok)); } catch (e) { d.textContent = '확인 실패: ' + e.message; } });
|
|
const provBtn = (label, kind, action, card) => btn(label, 'ghost', async () => { const d = card.querySelector('.d'); d.textContent = '실행 중…'; try { const r = await api(`/provision/${kind}/${action}`, { method: 'POST', body: '{}' }); d.textContent = r.detail; } catch (e) { d.textContent = '실패: ' + e.message; } });
|
|
const linkBtn = (label, href) => { if (!href) return null; const a = el('a', 'btn ghost', label); a.href = href; a.target = '_blank'; a.rel = 'noopener'; return a; };
|
|
|
|
function render() {
|
|
root.innerHTML = ''; inputs.clear();
|
|
const v = view; const s = v.status || {}; const cfg = v.config;
|
|
// ---- 머리말: 서버·파일 정보 ----
|
|
const head = el('div', 'wms-head');
|
|
head.appendChild(el('b', '', `AI Webmaker v${v.version}`));
|
|
head.appendChild(el('span', 'muted', `서버 ${v.server.hostname} (${(v.server.ips || []).join(', ') || '-'}) · 계정 ${v.server.user} · Node ${v.server.node}`));
|
|
head.appendChild(el('span', 'muted', `설정 파일 ${cfg.file}${cfg.exists ? (cfg.updatedAt ? ` · 저장 ${new Date(cfg.updatedAt).toLocaleString()} (${cfg.updatedBy || ''})` : '') : ' · 아직 없음 (저장하면 만들어짐)'}`));
|
|
if (cfg.broken) head.appendChild(el('span', 'wms-msg bad', `설정 파일을 읽을 수 없습니다: ${cfg.broken}`));
|
|
root.appendChild(head);
|
|
// ---- v1.5.11: 설치 점검표 · 관리자 계정 · AI 엔진 인증 ----
|
|
if (v.ready) root.appendChild(checklistCard(v));
|
|
if (v.admins) root.appendChild(adminCard(v));
|
|
if (v.aiAuth) root.appendChild(aiCard(v));
|
|
// ---- 상태 카드 ----
|
|
const grid = el('div', 'wms-grid');
|
|
{ const c = statCard('웹메이커 서비스', s.service ? { ok: s.service.active, detail: s.service.detail } : null); c.querySelector('.act') || c.appendChild(el('div', 'act')); c.querySelector('.act').appendChild(btn('서비스 재시작', 'ghost', () => restart())); grid.appendChild(c); }
|
|
if (s.setup) grid.appendChild(statCard('초기 설정 페이지 서비스 (webmaker-setup)', { ok: s.setup.active, detail: s.setup.detail }));
|
|
{ const c = statCard('소스 보관 — ' + (s.source && s.source.provider !== 'none' ? 'Gitea' : '연동 안 함'), s.source); const acts = [testBtn('source', c)]; if (s.source && s.source.apiUrl && s.source.api && !s.source.api.skipped) { acts.push(provBtn('저장소 만들기', 'source', 'ensure-repo', c)); acts.push(provBtn('공개키 등록', 'source', 'register-key', c)); } acts.push(linkBtn('Gitea 열기', s.source && s.source.web)); c.querySelector('.act') || c.appendChild(el('div', 'act')); acts.filter(Boolean).forEach((b) => c.querySelector('.act').appendChild(b)); if (s.source && s.source.publicKey) { const ta = el('textarea'); ta.readOnly = true; ta.rows = 2; ta.style.cssText = 'width:100%;font-size:11px;font-family:ui-monospace,monospace;margin-top:6px'; ta.value = `이 서버의 공개키 (${s.source.keyFile || ''}):\n${s.source.publicKey}`; c.appendChild(ta); } grid.appendChild(c); }
|
|
{ const c = statCard('메일 서버 — ' + (s.mail && s.mail.provider !== 'none' ? 'Arimail' : '연동 안 함'), s.mail); c.querySelector('.act') || c.appendChild(el('div', 'act')); [testBtn('mail', c), linkBtn('웹메일', s.mail && s.mail.webmail), linkBtn('Arimail 관리자 페이지', s.mail && s.mail.admin)].filter(Boolean).forEach((b) => c.querySelector('.act').appendChild(b)); grid.appendChild(c); }
|
|
{ const c = statCard('Claude Code', s.claude); c.querySelector('.act') || c.appendChild(el('div', 'act')); c.querySelector('.act').appendChild(testBtn('claude', c)); grid.appendChild(c); }
|
|
{ const c = statCard('OpenAI Codex (선택)', s.codex); c.querySelector('.act') || c.appendChild(el('div', 'act')); c.querySelector('.act').appendChild(testBtn('codex', c)); grid.appendChild(c); }
|
|
{ const c = statCard('메일 발송 (Resend)', s.resend); c.querySelector('.act') || c.appendChild(el('div', 'act')); c.querySelector('.act').appendChild(testBtn('resend', c)); grid.appendChild(c); }
|
|
root.appendChild(grid);
|
|
// ---- 저장 막대 ----
|
|
const bar = el('div', 'wms-bar');
|
|
bar.appendChild(btn('바뀐 값 모두 저장', 'primary', () => saveAll()));
|
|
bar.appendChild(btn('서비스 재시작', 'ghost', () => restart()));
|
|
bar.appendChild(btn('환경 변수에서 옮겨 적기', 'ghost', async () => { try { const r = await api('/migrate-env', { method: 'POST', body: '{}' }); setMsg(r.detail, true); await load(); } catch (e) { setMsg(e.message, false); } }));
|
|
bar.appendChild(btn('새로고침', 'ghost', () => load()));
|
|
bar.appendChild(msg); root.appendChild(bar);
|
|
// ---- 설정 그룹 ----
|
|
const advanced = [];
|
|
for (const g of cfg.groups) { const card = groupCard(g); if (g.advanced) advanced.push(card); else root.appendChild(card); }
|
|
if (advanced.length) { const d = el('details', 'wms-adv'); d.appendChild(el('summary', '', '고급 설정 보기 (외부 API 주소)')); advanced.forEach((c) => d.appendChild(c)); root.appendChild(d); }
|
|
// ---- 설치 안내 ----
|
|
const inst = el('div', 'wms-group'); inst.appendChild(el('h2', '', '연동 서비스 설치·등록 명령'));
|
|
inst.appendChild(el('div', 'gd', '서비스 서버에서 서비스 실행 계정으로 실행합니다. 이미 설치되어 있으면 등록(연동 정보 저장)만 합니다. 실행 뒤 이 화면을 새로고침하면 상태가 바뀝니다.'));
|
|
const cmd = (label, text) => { inst.appendChild(el('div', '', label)); const c = el('code', 'cmd', text); inst.appendChild(c); };
|
|
cmd('Gitea (사이트 소스 보관) 설치·등록', v.install.gitea);
|
|
cmd('Arimail (메일 서버) 새로 설치·등록', v.install.arimail);
|
|
cmd('이미 설치된 Arimail 등록만', v.install.arimailRegister);
|
|
cmd('설치된 연동 서비스 점검·자동 등록', v.install.services);
|
|
cmd('명령줄로 설정 보기·바꾸기', v.install.cli + '\nnode scripts/config.js set service.publicBaseUrl https://webmaker.example.com');
|
|
root.appendChild(inst);
|
|
}
|
|
|
|
/** v1.5.11 설치 점검표 카드: 필수 항목(관리자·AI 인증·공개 주소)과 참고 항목. 끝나면 서비스로 가는 링크 */
|
|
function checklistCard(v) {
|
|
const r = v.ready; const c = el('div', 'wms-check' + (r.required ? '' : ' done'));
|
|
const h = el('h2'); h.appendChild(document.createTextNode(r.required ? '설치 점검표 — 아직 운영할 수 없습니다' : '설치 점검표 — 운영할 수 있습니다'));
|
|
h.appendChild(el('span', 'pill ' + (r.required ? 'warn' : 'ok'), r.required ? `${r.items.filter((i) => !i.ok).length}개 남음` : '완료')); c.appendChild(h);
|
|
c.appendChild(el('div', 'gd', r.required ? '아래 항목이 모두 끝날 때까지 서비스 첫 화면(/, /login)은 이 페이지로 넘어옵니다. 끝나면 자동으로 정상 로그인 화면이 나옵니다. (점검 전에 로그인 화면을 열려면 /login?direct=1)' : '서비스 첫 화면은 더 이상 이 페이지로 넘어오지 않습니다. 설정은 관리자 콘솔 › 시스템 설정 탭이나 이 페이지에서 계속 바꿀 수 있습니다.'));
|
|
const ul = el('ul');
|
|
for (const it of r.items) { const li = el('li'); li.appendChild(pill(it.ok, '완료', '필요')); const d = el('div'); d.appendChild(el('b', '', it.label + ' — ')); d.appendChild(document.createTextNode(it.detail || '')); if (!it.ok && it.hint) d.appendChild(el('div', 'h', '→ ' + it.hint)); li.appendChild(d); ul.appendChild(li); }
|
|
for (const w of r.warnings || []) { const li = el('li', 'warn'); li.appendChild(el('span', 'pill' + (w.ok ? ' ok' : ''), w.ok ? '있음' : '참고')); const d = el('div'); d.appendChild(el('b', '', w.label + ' (참고) — ')); d.appendChild(document.createTextNode(w.detail || '')); li.appendChild(d); ul.appendChild(li); }
|
|
c.appendChild(ul);
|
|
const bar = el('div', 'wms-bar'); bar.appendChild(btn('다시 점검', 'ghost', () => load()));
|
|
const home = opts.standalone ? (v.server.publicBaseUrl || '') + '/' : '/';
|
|
if (!r.required) { const a = el('a', 'btn primary', '서비스 열기 (로그인 화면)'); a.href = home + 'login?direct=1'; bar.appendChild(a); }
|
|
else { const a = el('a', 'btn ghost', '로그인 화면 열기 (점검 전)'); a.href = home + 'login?direct=1'; bar.appendChild(a); }
|
|
c.appendChild(bar); return c;
|
|
}
|
|
/** v1.5.11 관리자 계정 카드: 목록 · 기본 관리자 만들기 · 비밀번호 재설정 · 새 관리자 */
|
|
function adminCard(v) {
|
|
const st = v.admins; const card = el('div', 'wms-group'); card.appendChild(el('h2', '', '관리자 계정'));
|
|
card.appendChild(el('div', 'gd', '처음 설치한 서버에는 관리자 계정이 없습니다(v1.6.31: 고정 비밀번호 admin/admin 을 만들지 않습니다). 이 화면(접속 토큰)에서 아이디·비밀번호를 정해 최초 관리자를 만드세요. 설치 점검이 끝나면 접속 토큰은 더 쓸 수 없고 관리자 로그인으로만 들어옵니다.'));
|
|
const m = el('span', 'wms-msg');
|
|
if (st.admins.length) { const t = el('table', 'wms-admins'); const tr = el('tr'); ['아이디', '이름', '마지막 로그인', '상태'].forEach((x) => tr.appendChild(el('th', '', x))); t.appendChild(tr); for (const u of st.admins) { const r = el('tr'); r.appendChild(el('td', '', u.id)); r.appendChild(el('td', '', u.name || '')); r.appendChild(el('td', '', u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : '없음')); const td = el('td'); td.appendChild(u.mustChangePassword ? el('span', 'pill warn', '기본 비밀번호 — 변경 필요') : el('span', 'pill ok', '사용 가능')); r.appendChild(td); t.appendChild(r); } card.appendChild(t); }
|
|
else card.appendChild(el('p', 'wms-msg bad', '관리자 계정이 없습니다.'));
|
|
const act = async (path, body, okNote) => { try { const r = await api(path, { method: 'POST', body: JSON.stringify(body || {}) }); m.textContent = r.detail || okNote; m.className = 'wms-msg ok'; await load(); } catch (e) { m.textContent = e.message; m.className = 'wms-msg bad'; } };
|
|
const row1 = el('div', 'wms-inline');
|
|
if (!st.admins.length) row1.appendChild(btn('관리자 admin 을 1회용 비밀번호로 만들기', 'ghost', async () => { try { const r = await api('/admin/bootstrap', { method: 'POST', body: '{}' }); m.textContent = r.password ? `관리자 admin 을 만들었습니다. 1회용 비밀번호(지금만 표시): ${r.password} — 로그인 뒤 바로 바꾸세요` : (r.detail || '이미 있습니다'); m.className = 'wms-msg ok'; await load(); } catch (e) { m.textContent = e.message; m.className = 'wms-msg bad'; } }));
|
|
row1.appendChild(m); card.appendChild(row1);
|
|
// 비밀번호 재설정
|
|
const sub1 = el('div', 'wms-sub'); sub1.appendChild(el('b', '', '관리자 비밀번호 재설정')); const r1 = el('div', 'wms-inline');
|
|
const sel = el('select'); for (const u of st.admins) { const o = el('option', '', u.id); o.value = u.id; sel.appendChild(o); } const pw1 = el('input'); pw1.type = 'password'; pw1.placeholder = '새 비밀번호 (6자 이상)'; pw1.autocomplete = 'new-password';
|
|
r1.appendChild(sel); r1.appendChild(pw1); r1.appendChild(btn('재설정', 'ghost', () => act('/admin/password', { id: sel.value, password: pw1.value }))); sub1.appendChild(r1); if (st.admins.length) card.appendChild(sub1);
|
|
// 새 관리자
|
|
const sub2 = el('div', 'wms-sub'); sub2.appendChild(el('b', '', '새 관리자 만들기')); const r2 = el('div', 'wms-inline');
|
|
const id2 = el('input'); id2.placeholder = '아이디 (영문 소문자·숫자 3~32자)'; id2.autocomplete = 'off'; const nm2 = el('input'); nm2.placeholder = '이름 (회사명)'; const pw2 = el('input'); pw2.type = 'password'; pw2.placeholder = '비밀번호 (6자 이상)'; pw2.autocomplete = 'new-password';
|
|
r2.appendChild(id2); r2.appendChild(nm2); r2.appendChild(pw2); r2.appendChild(btn('만들기', 'ghost', () => act('/admin/create', { id: id2.value, name: nm2.value, password: pw2.value }))); sub2.appendChild(r2); card.appendChild(sub2);
|
|
return card;
|
|
}
|
|
/** v1.5.11 AI 엔진 인증 카드: Claude(서버 로그인 · OAuth 토큰 · API 키) 와 OpenAI(API 키 · codex 서버 로그인) */
|
|
function aiCard(v) {
|
|
const a = v.aiAuth; const s = v.status || {}; const card = el('div', 'wms-group'); card.appendChild(el('h2', '', 'AI 엔진 인증'));
|
|
card.appendChild(el('div', 'gd', 'AI 작업(사이트 생성·수정)에는 Claude Code 인증이 필요합니다. 서버 로그인은 서비스 실행 계정으로 서버에서 claude 를 한 번 실행해 브라우저로 로그인하는 방식이고, 서버에 들어가기 어려우면 OAuth 토큰(다른 PC 에서 claude setup-token)이나 API 키를 여기에 넣습니다. 관리자 콘솔 › AI 연결 탭의 "Claude 모델 연결" 과 같은 값입니다(v1.5.16).'));
|
|
const m = el('span', 'wms-msg');
|
|
// Claude
|
|
const c1 = el('div'); c1.appendChild(el('b', '', 'Claude Code')); c1.appendChild(el('span', 'muted', ' — ' + (s.claude ? s.claude.detail : '')));
|
|
const modes = [['server-login', '서버 로그인 (claude 실행 후 브라우저 로그인)'], ['oauth-token', 'OAuth 토큰 (claude setup-token 으로 발급)'], ['api-key', 'Anthropic API 키 (sk-ant-…)']];
|
|
const rad = el('div', 'wms-radio'); const cur = (a.claude || {}).mode || 'server-login'; let mode = cur;
|
|
const tok = el('input'); tok.type = 'password'; tok.autocomplete = 'new-password'; tok.placeholder = a.claude && a.claude.hasOauthToken ? '저장된 OAuth 토큰 있음 — 바꾸려면 새 값' : 'OAuth 토큰 (sk-ant-oat…)'; const key = el('input'); key.type = 'password'; key.autocomplete = 'new-password'; key.placeholder = a.claude && a.claude.hasApiKey ? '저장된 API 키 있음 — 바꾸려면 새 값' : 'API 키 (sk-ant-api…)';
|
|
const show = () => { tok.classList.toggle('hidden', mode !== 'oauth-token'); key.classList.toggle('hidden', mode !== 'api-key'); };
|
|
for (const [val, label] of modes) { const l = el('label', 'radio'); const r = el('input'); r.type = 'radio'; r.name = 'wmsClaudeMode'; r.value = val; r.checked = val === cur; r.onchange = () => { mode = val; show(); }; l.appendChild(r); l.appendChild(document.createTextNode(' ' + label)); rad.appendChild(l); }
|
|
c1.appendChild(rad); const r1 = el('div', 'wms-inline'); r1.appendChild(tok); r1.appendChild(key); show();
|
|
r1.appendChild(btn('Claude 인증 저장·확인', 'primary', async () => { m.textContent = '확인 중…'; m.className = 'wms-msg'; try { const r = await api('/ai/claude', { method: 'PUT', body: JSON.stringify({ mode, oauthToken: tok.value, apiKey: key.value }) }); m.textContent = r.detail; m.className = 'wms-msg ' + (r.ok ? 'ok' : 'bad'); await load(); } catch (e) { m.textContent = e.message; m.className = 'wms-msg bad'; } }));
|
|
c1.appendChild(r1); card.appendChild(c1);
|
|
// OpenAI (선택)
|
|
const o = a.openai; const c2 = el('div', 'wms-sub'); c2.appendChild(el('b', '', 'OpenAI (선택)')); c2.appendChild(el('span', 'muted', ' — ' + (s.codex ? s.codex.detail : '')));
|
|
const en = el('label', 'radio'); const cb = el('input'); cb.type = 'checkbox'; cb.checked = !!(o && o.enabled); en.appendChild(cb); en.appendChild(document.createTextNode(' OpenAI 제공자 켬'));
|
|
const rad2 = el('div', 'wms-radio'); let am = (o && o.authMode) || 'api-key'; const okey = el('input'); okey.type = 'password'; okey.autocomplete = 'new-password'; okey.placeholder = o && o.hasKey ? `저장된 키 ${o.apiKeyMasked || ''} — 바꾸려면 새 값` : 'OpenAI API 키 (sk-…)';
|
|
for (const [val, label] of [['api-key', 'API 키'], ['server-login', '서버 로그인 (codex login)']]) { const l = el('label', 'radio'); const r = el('input'); r.type = 'radio'; r.name = 'wmsOpenaiMode'; r.value = val; r.checked = val === am; r.onchange = () => { am = val; okey.classList.toggle('hidden', am !== 'api-key'); }; l.appendChild(r); l.appendChild(document.createTextNode(' ' + label)); rad2.appendChild(l); }
|
|
okey.classList.toggle('hidden', am !== 'api-key');
|
|
const r2 = el('div', 'wms-inline'); r2.appendChild(en); r2.appendChild(rad2); r2.appendChild(okey);
|
|
r2.appendChild(btn('OpenAI 저장', 'ghost', async () => { try { const r = await api('/ai/openai', { method: 'PUT', body: JSON.stringify({ enabled: cb.checked, authMode: am, apiKey: okey.value }) }); m.textContent = r.detail; m.className = 'wms-msg ' + (r.ok ? 'ok' : 'bad'); await load(); } catch (e) { m.textContent = e.message; m.className = 'wms-msg bad'; } }));
|
|
c2.appendChild(r2); card.appendChild(c2);
|
|
const bar = el('div', 'wms-bar'); bar.appendChild(m); card.appendChild(bar);
|
|
return card;
|
|
}
|
|
/** 그룹 카드: 항목 입력칸 */
|
|
function groupCard(g) {
|
|
const card = el('div', 'wms-group'); const h = el('h2', '', g.label); card.appendChild(h); card.appendChild(el('div', 'gd', g.desc));
|
|
const ro = g.keys.every((k) => k.deployTime);
|
|
for (const k of g.keys) card.appendChild(fieldRow(k));
|
|
if (!ro) { const bar = el('div', 'wms-bar'); const m = el('span', 'wms-msg'); bar.appendChild(btn('이 묶음 저장', 'primary', () => saveKeys(g.keys.map((k) => k.key), m))); bar.appendChild(m); card.appendChild(bar); }
|
|
return card;
|
|
}
|
|
/** 항목 한 줄 */
|
|
function fieldRow(k) {
|
|
const row = el('div', 'wms-field' + (k.deployTime ? ' ro' : '')); const lab = el('label'); lab.appendChild(document.createTextNode(k.label)); if (k.required) lab.appendChild(el('span', 'req', ' *')); if (k.env) lab.appendChild(el('span', 'env', k.env)); row.appendChild(lab);
|
|
const box = el('div'); let get;
|
|
const markDirty = (inp, dirty) => inp.classList.toggle('dirty', dirty);
|
|
if (k.type === 'secret') {
|
|
const wrap = el('div', 'wms-secret'); const inp = el('input'); inp.type = 'password'; inp.autocomplete = 'new-password'; inp.placeholder = k.hasSecret ? `저장됨 ${k.secretHint} — 바꾸려면 새 값 입력` : '아직 없음 — 값을 입력해 저장'; wrap.appendChild(inp);
|
|
const clr = el('label', 'radio'); const cb = el('input'); cb.type = 'checkbox'; clr.appendChild(cb); clr.appendChild(document.createTextNode(' 지우기')); if (k.hasSecret) wrap.appendChild(clr);
|
|
inp.oninput = () => markDirty(inp, !!inp.value); cb.onchange = () => markDirty(inp, cb.checked || !!inp.value);
|
|
get = () => (cb.checked ? null : (inp.value ? inp.value : undefined)); box.appendChild(wrap);
|
|
inputs.set(k.key, { def: k, get, el: inp });
|
|
} else if (k.type === 'bool') {
|
|
const lab2 = el('label', 'radio'); const cb = el('input'); cb.type = 'checkbox'; cb.checked = k.value === true || k.value === 'true' || k.value === 1 || k.value === '1'; const init = cb.checked; lab2.appendChild(cb); lab2.appendChild(document.createTextNode(' 켬')); cb.disabled = !!k.deployTime; cb.onchange = () => markDirty(cb, cb.checked !== init);
|
|
get = () => (cb.checked !== init ? cb.checked : undefined); box.appendChild(lab2); inputs.set(k.key, { def: k, get, el: cb });
|
|
} else if (k.type === 'enum') {
|
|
const sel = el('select'); for (const [val, label] of k.options || []) { const o = el('option', '', label); o.value = val; sel.appendChild(o); } sel.value = k.value || ''; const init = sel.value; sel.disabled = !!k.deployTime; sel.onchange = () => markDirty(sel, sel.value !== init);
|
|
get = () => (sel.value !== init ? sel.value : undefined); box.appendChild(sel); inputs.set(k.key, { def: k, get, el: sel });
|
|
} else {
|
|
const inp = el('input'); inp.type = k.type === 'int' ? 'number' : 'text'; if (k.type === 'int') { inp.setAttribute('data-plain', '1'); if (k.min !== undefined) inp.min = k.min; if (k.max !== undefined) inp.max = k.max; }
|
|
inp.value = k.value === undefined || k.value === null ? '' : String(k.value); const init = inp.value; inp.readOnly = !!k.deployTime; inp.placeholder = k.default !== '' && k.default !== undefined ? `기본값: ${k.default}` : '';
|
|
inp.oninput = () => markDirty(inp, inp.value !== init);
|
|
get = () => (inp.value !== init ? inp.value : undefined); box.appendChild(inp); inputs.set(k.key, { def: k, get, el: inp });
|
|
}
|
|
const meta = el('div', 'meta'); meta.appendChild(el('span', 'pill ' + (k.source === 'file' || k.source === 'vault' ? 'ok' : k.source === 'env' ? 'warn' : ''), SOURCE[k.source] || k.source)); if (k.restart) meta.appendChild(el('span', 'pill', '재시작 후 적용')); if (k.deployTime) meta.appendChild(el('span', 'pill', '배포 때 정해짐 — 읽기 전용')); if (k.source === 'env' && !k.deployTime) meta.appendChild(el('span', '', '→ 저장하면 설정 파일 값이 환경 변수보다 우선합니다'));
|
|
box.appendChild(meta); box.appendChild(el('div', 'desc', k.desc)); row.appendChild(box); return row;
|
|
}
|
|
/** 바뀐 값 모으기 */
|
|
function collect(keys) { const values = {}; let n = 0; for (const key of keys) { const it = inputs.get(key); if (!it || it.def.deployTime) continue; const v = it.get(); if (v === undefined) continue; values[key] = v; n++; } return { values, n }; }
|
|
async function saveKeys(keys, m) {
|
|
const { values, n } = collect(keys); const target = m || msg; const set = (t, ok) => { target.textContent = t; target.className = 'wms-msg ' + (ok ? 'ok' : 'bad'); };
|
|
if (!n) return set('바뀐 값이 없습니다', true);
|
|
try { const r = await api('', { method: 'PUT', body: JSON.stringify({ values }) }); set(r.note, true); await load(); if (r.restart) setMsg('재시작해야 적용되는 항목이 바뀌었습니다 — 위의 "서비스 재시작" 을 누르세요.', true); } catch (e) { set('저장 실패: ' + e.message, false); }
|
|
}
|
|
function saveAll() { return saveKeys([...inputs.keys()], null); }
|
|
async function restart() {
|
|
if (!confirm('웹메이커 서비스를 다시 시작합니다. 실행 중인 AI 작업이 있으면 끝날 때까지(최대 10분) 기다린 뒤 재시작합니다. 계속할까요?')) return;
|
|
setMsg('재시작 중… (AI 작업이 있으면 기다립니다)', true);
|
|
try { const r = await api('/restart', { method: 'POST', body: '{}' }); setMsg(r.detail, !!r.ok); setTimeout(load, 1500); } catch (e) { setMsg(e.message, false); }
|
|
}
|
|
load();
|
|
return { reload: load };
|
|
}
|
|
|
|
/**
|
|
* 전용 설정 페이지(setup.html)의 부팅: 로그인 상태를 확인하고, 없으면 토큰/관리자 로그인 상자를 보인다.
|
|
* 관리자 콘솔 안에서는 쓰지 않는다(admin.js 가 mount 만 부른다).
|
|
*/
|
|
function boot() {
|
|
const root = document.getElementById('setupRoot'); const login = document.getElementById('setupLogin');
|
|
// standalone: 별도 포트(setup-server.js) · inline: 본 서비스의 /setup (v1.5.11, 토큰 또는 관리자 로그인) — 둘 다 /api/setup. 그 밖(관리자 콘솔 탭)은 /api/admin/config
|
|
const standalone = !!document.querySelector('meta[name="wm-setup-standalone"]'); const inline = !!document.querySelector('meta[name="wm-setup-inline"]');
|
|
const apiBase = standalone || inline ? '/api/setup' : '/api/admin/config';
|
|
const ver = document.getElementById('setupVer');
|
|
const showLogin = (note) => { root.innerHTML = ''; login.classList.remove('hidden'); if (note) document.getElementById('setupLoginMsg').textContent = note; };
|
|
const start = () => { login.classList.add('hidden'); mount(root, { apiBase, standalone, onAuthFail: () => showLogin('세션이 끝났습니다. 다시 로그인하세요.') }); };
|
|
if (!standalone && !inline) return start();
|
|
// 주소의 ?token= 은 설치 스크립트가 알려 준 접속 토큰 — 한 번 쓰고 주소에서 지운다
|
|
const u = new URL(location.href); const tok = u.searchParams.get('token');
|
|
const doLogin = async (body) => { const r = await fetch('/api/setup/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const b = await r.json().catch(() => ({})); if (!r.ok) throw new Error(b.error || `HTTP ${r.status}`); return b; };
|
|
(async () => {
|
|
try { const r = await fetch('/api/setup/session'); const b = await r.json().catch(() => ({})); if (ver && b.version) ver.textContent = 'v' + b.version; const ta = document.getElementById('toAdmin'); if (ta && standalone && b.publicBaseUrl) ta.href = b.publicBaseUrl + '/admin'; if (b.loggedIn) return start(); } catch (_) {}
|
|
if (tok) { try { await doLogin({ token: tok }); u.searchParams.delete('token'); history.replaceState(null, '', u.toString()); return start(); } catch (e) { showLogin('토큰이 맞지 않습니다: ' + e.message); } } else showLogin('');
|
|
})();
|
|
document.getElementById('setupTokenBtn').onclick = async () => { try { await doLogin({ token: document.getElementById('setupToken').value.trim() }); start(); } catch (e) { document.getElementById('setupLoginMsg').textContent = e.message; } };
|
|
document.getElementById('setupAdminBtn').onclick = async () => { try { await doLogin({ id: document.getElementById('setupId').value.trim(), password: document.getElementById('setupPw').value }); start(); } catch (e) { document.getElementById('setupLoginMsg').textContent = e.message; } };
|
|
const lo = document.getElementById('setupLogout'); if (lo) lo.onclick = async () => { await fetch('/api/setup/logout', { method: 'POST' }); location.reload(); };
|
|
}
|
|
window.WMSetup = { mount, boot };
|
|
})();
|