'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 = '
설정을 불러오는 중…
'; 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 }; })();