fix: 이력 처리중 버그 수정 + 관리자 모델 제한 기능

This commit is contained in:
root
2026-04-23 07:38:22 +09:00
parent 4af1279a08
commit f9075ae3f6
4 changed files with 412 additions and 181 deletions

View File

@@ -582,7 +582,27 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
<div class="form-group"><label>아이디</label><input type="text" class="form-input" id="new-username" placeholder="username" autocomplete="off"></div>
<div class="form-group"><label>비밀번호</label><input type="password" class="form-input" id="new-password" placeholder="password" autocomplete="new-password"></div>
</div>
<div class="form-group"><label>사용 권한</label><div class="perm-checks"><label class="perm-check"><input type="checkbox" id="new-perm-stt"> STT 음성변환</label><label class="perm-check"><input type="checkbox" id="new-perm-ocr"> OCR 이미지인식</label></div></div>
<div class="form-group" style="margin-bottom:12px">
<label>기능 권한</label>
<div class="perm-checks">
<label class="perm-check"><input type="checkbox" id="new-perm-stt"> STT 음성변환</label>
<label class="perm-check"><input type="checkbox" id="new-perm-ocr"> OCR 이미지인식</label>
</div>
</div>
<div id="new-stt-models-wrap" style="margin-bottom:12px;display:none">
<label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">
STT Whisper 모델 제한
<span style="color:var(--muted);font-size:.6rem;text-transform:none;margin-left:4px">· 선택 없음 = 모두 허용</span>
</label>
<div id="new-stt-model-checks" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px"></div>
</div>
<div id="new-ocr-models-wrap" style="margin-bottom:12px;display:none">
<label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">
OCR Ollama 모델 제한
<span style="color:var(--muted);font-size:.6rem;text-transform:none;margin-left:4px">· 선택 없음 = 모두 허용</span>
</label>
<div id="new-ocr-model-checks" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px"></div>
</div>
<div style="margin-top:14px"><button class="btn-add" id="btn-add-user">사용자 추가</button></div>
<div class="admin-msg" id="add-msg"></div>
</div>
@@ -595,7 +615,25 @@ textarea.cprompt{width:100%;background:var(--surf);border:1px solid var(--border
<div class="modal-box">
<div class="modal-title">권한 편집 — <span id="edit-modal-username"></span></div>
<div class="form-group"><label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">새 비밀번호 (변경 시에만)</label><input type="password" class="form-input" id="edit-password" placeholder="비워두면 변경 안 함" style="width:100%;margin-top:5px" autocomplete="new-password"></div>
<div class="form-group" style="margin-top:14px"><label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">사용 권한</label><div class="perm-checks" style="margin-top:6px"><label class="perm-check"><input type="checkbox" id="edit-perm-stt"> STT 음성변환</label><label class="perm-check"><input type="checkbox" id="edit-perm-ocr"> OCR 이미지인식</label></div></div>
<div class="form-group" style="margin-top:14px">
<label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">기능 권한</label>
<div class="perm-checks" style="margin-top:6px">
<label class="perm-check"><input type="checkbox" id="edit-perm-stt"> STT 음성변환</label>
<label class="perm-check"><input type="checkbox" id="edit-perm-ocr"> OCR 이미지인식</label>
</div>
</div>
<div id="edit-stt-models-wrap" style="margin-top:12px;display:none">
<label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">
STT 모델 제한 <span style="color:var(--muted);font-size:.6rem;text-transform:none;margin-left:4px">· 선택 없음 = 모두 허용</span>
</label>
<div id="edit-stt-model-checks" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px"></div>
</div>
<div id="edit-ocr-models-wrap" style="margin-top:12px;display:none">
<label style="font-family:var(--mono);font-size:.62rem;letter-spacing:.1em;color:var(--muted);text-transform:uppercase">
OCR 모델 제한 <span style="color:var(--muted);font-size:.6rem;text-transform:none;margin-left:4px">· 선택 없음 = 모두 허용</span>
</label>
<div id="edit-ocr-model-checks" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px"></div>
</div>
<div class="modal-actions"><button class="btn-sm" id="btn-modal-cancel">취소</button><button class="btn-add" id="btn-modal-save">저장</button></div>
<div class="admin-msg" id="edit-msg"></div>
</div>
@@ -864,15 +902,156 @@ function renderPagination(){
}
// ══ ADMIN ══
async function loadUsers(){const tbody=document.getElementById('user-tbody');tbody.innerHTML='';try{const r=await api('GET','/api/admin/users');const d=await r.json();Object.entries(d.users||{}).forEach(([name,info])=>{const tr=document.createElement('tr');const p=info.permissions||{};const isAdmin=info.role==='admin';tr.innerHTML=`<td style="font-family:var(--mono);font-size:.78rem">${esc(name)}</td><td><span class="role-badge ${info.role}">${info.role}</span></td><td><span class="perm-badge ${p.stt?'on':'off'}">${p.stt?'허용':'차단'}</span></td><td><span class="perm-badge ${p.ocr?'on':'off'}">${p.ocr?'허용':'차단'}</span></td><td>${isAdmin?'<span style="font-family:var(--mono);font-size:.6rem;color:var(--muted)">기본</span>':`<button class="btn-sm" onclick="openEditModal('${esc(name)}',${p.stt},${p.ocr})">편집</button><button class="btn-sm danger" onclick="doDeleteUser('${esc(name)}')">삭제</button>`}</td>`;tbody.appendChild(tr)})}catch{}}
document.getElementById('btn-reload-users').addEventListener('click',loadUsers);
document.getElementById('btn-add-user').addEventListener('click',async()=>{const u=document.getElementById('new-username').value.trim(),p=document.getElementById('new-password').value;const msg=document.getElementById('add-msg');if(!u||!p){showAdminMsg(msg,'아이디와 비밀번호를 입력하세요','err');return}const fd=new FormData();fd.append('username',u);fd.append('password',p);fd.append('perm_stt',document.getElementById('new-perm-stt').checked?'true':'false');fd.append('perm_ocr',document.getElementById('new-perm-ocr').checked?'true':'false');try{const r=await api('POST','/api/admin/users',fd);const d=await r.json();if(r.ok){showAdminMsg(msg,d.message,'ok');document.getElementById('new-username').value='';document.getElementById('new-password').value='';loadUsers()}else showAdminMsg(msg,d.detail||'실패','err')}catch{showAdminMsg(msg,'서버 오류','err')}});
function openEditModal(n,stt,ocr){editTarget=n;document.getElementById('edit-modal-username').textContent=n;document.getElementById('edit-perm-stt').checked=stt;document.getElementById('edit-perm-ocr').checked=ocr;document.getElementById('edit-password').value='';document.getElementById('edit-msg').style.display='none';document.getElementById('edit-modal').classList.add('visible')}
document.getElementById('btn-modal-cancel').addEventListener('click',()=>document.getElementById('edit-modal').classList.remove('visible'));
document.getElementById('edit-modal').addEventListener('click',e=>{if(e.target===document.getElementById('edit-modal'))document.getElementById('edit-modal').classList.remove('visible')});
document.getElementById('btn-modal-save').addEventListener('click',async()=>{if(!editTarget)return;const fd=new FormData();fd.append('perm_stt',document.getElementById('edit-perm-stt').checked?'true':'false');fd.append('perm_ocr',document.getElementById('edit-perm-ocr').checked?'true':'false');const pw=document.getElementById('edit-password').value;if(pw)fd.append('password',pw);try{const r=await fetch(`/api/admin/users/${editTarget}`,{method:'PUT',headers:{Authorization:'Bearer '+(token||'')},body:fd});const d=await r.json();const msg=document.getElementById('edit-msg');if(r.ok){showAdminMsg(msg,d.message,'ok');setTimeout(()=>{document.getElementById('edit-modal').classList.remove('visible');loadUsers()},800)}else showAdminMsg(msg,d.detail||'실패','err')}catch{showAdminMsg(document.getElementById('edit-msg'),'서버 오류','err')}});
async function doDeleteUser(username){if(!confirm(`"${username}" 사용자를 삭제하시겠습니까?`))return;try{const r=await fetch(`/api/admin/users/${username}`,{method:'DELETE',headers:{Authorization:'Bearer '+(token||'')}});if(r.ok)loadUsers();else{const d=await r.json();alert(d.detail||'삭제 실패')}}catch{alert('서버 오류')}}
function showAdminMsg(el,msg,type){el.style.display='block';el.className='admin-msg '+type;el.textContent=msg;setTimeout(()=>el.style.display='none',3000)}
// 모델 체크박스 렌더링 헬퍼
function renderModelChecks(container, models, selected=[]) {
container.innerHTML = '';
if (!models.length) {
container.innerHTML = '<span style="font-family:var(--mono);font-size:.65rem;color:var(--muted)">연결된 Ollama 모델 없음</span>';
return;
}
models.forEach(m => {
const lbl = document.createElement('label');
lbl.className = 'perm-check';
lbl.innerHTML = `<input type="checkbox" value="${esc(m)}"${selected.includes(m)?' checked':''}> <span style="font-size:.7rem">${esc(m)}</span>`;
container.appendChild(lbl);
});
}
// 체크된 모델 목록 수집
function getCheckedModels(container) {
return Array.from(container.querySelectorAll('input[type=checkbox]:checked')).map(cb => cb.value);
}
// STT 체크박스 토글 시 모델 섹션 표시
document.getElementById('new-perm-stt').addEventListener('change', function() {
const wrap = document.getElementById('new-stt-models-wrap');
wrap.style.display = this.checked ? 'block' : 'none';
if (this.checked) renderModelChecks(document.getElementById('new-stt-model-checks'), whisperModels, []);
});
document.getElementById('new-perm-ocr').addEventListener('change', function() {
const wrap = document.getElementById('new-ocr-models-wrap');
wrap.style.display = this.checked ? 'block' : 'none';
if (this.checked) renderModelChecks(document.getElementById('new-ocr-model-checks'), ollamaModels, []);
});
// Whisper 모델 목록 (하드코딩 + 환경 설정 기반)
const whisperModels = ['tiny', 'base', 'small', 'medium', 'large-v2', 'large-v3'];
async function loadUsers() {
const tbody = document.getElementById('user-tbody'); tbody.innerHTML = '';
try {
const r = await api('GET', '/api/admin/users'); const d = await r.json();
Object.entries(d.users || {}).forEach(([name, info]) => {
const tr = document.createElement('tr');
const p = info.permissions || {}; const isAdmin = info.role === 'admin';
const sttModels = (p.allowed_stt_models||[]).length ? p.allowed_stt_models.join(', ') : '전체';
const ocrModels = (p.allowed_ocr_models||[]).length ? p.allowed_ocr_models.join(', ') : '전체';
tr.innerHTML = `
<td style="font-family:var(--mono);font-size:.78rem">${esc(name)}</td>
<td><span class="role-badge ${info.role}">${info.role}</span></td>
<td>
<span class="perm-badge ${p.stt?'on':'off'}">${p.stt?'허용':'차단'}</span>
${p.stt?`<span style="font-family:var(--mono);font-size:.58rem;color:var(--muted)">${sttModels}</span>`:''}
</td>
<td>
<span class="perm-badge ${p.ocr?'on':'off'}">${p.ocr?'허용':'차단'}</span>
${p.ocr?`<span style="font-family:var(--mono);font-size:.58rem;color:var(--muted)">${ocrModels}</span>`:''}
</td>
<td>${isAdmin
? '<span style="font-family:var(--mono);font-size:.6rem;color:var(--muted)">기본</span>'
: `<button class="btn-sm" onclick="openEditModal('${esc(name)}',${JSON.stringify(p)})">편집</button>
<button class="btn-sm danger" onclick="doDeleteUser('${esc(name)}')">삭제</button>`
}</td>`;
tbody.appendChild(tr);
});
} catch {}
}
document.getElementById('btn-reload-users').addEventListener('click', loadUsers);
document.getElementById('btn-add-user').addEventListener('click', async () => {
const u = document.getElementById('new-username').value.trim();
const p = document.getElementById('new-password').value;
const msg = document.getElementById('add-msg');
if (!u || !p) { showAdminMsg(msg, '아이디와 비밀번호를 입력하세요', 'err'); return; }
const fd = new FormData();
fd.append('username', u); fd.append('password', p);
fd.append('perm_stt', document.getElementById('new-perm-stt').checked ? 'true' : 'false');
fd.append('perm_ocr', document.getElementById('new-perm-ocr').checked ? 'true' : 'false');
fd.append('allowed_stt_models', getCheckedModels(document.getElementById('new-stt-model-checks')).join(','));
fd.append('allowed_ocr_models', getCheckedModels(document.getElementById('new-ocr-model-checks')).join(','));
try {
const r = await api('POST', '/api/admin/users', fd); const d = await r.json();
if (r.ok) {
showAdminMsg(msg, d.message, 'ok');
document.getElementById('new-username').value = '';
document.getElementById('new-password').value = '';
document.getElementById('new-perm-stt').checked = false;
document.getElementById('new-perm-ocr').checked = false;
document.getElementById('new-stt-models-wrap').style.display = 'none';
document.getElementById('new-ocr-models-wrap').style.display = 'none';
loadUsers();
} else showAdminMsg(msg, d.detail || '실패', 'err');
} catch { showAdminMsg(msg, '서버 오류', 'err'); }
});
function openEditModal(name, perms) {
editTarget = name;
document.getElementById('edit-modal-username').textContent = name;
document.getElementById('edit-perm-stt').checked = perms.stt || false;
document.getElementById('edit-perm-ocr').checked = perms.ocr || false;
document.getElementById('edit-password').value = '';
document.getElementById('edit-msg').style.display = 'none';
// STT 모델
const sttWrap = document.getElementById('edit-stt-models-wrap');
sttWrap.style.display = perms.stt ? 'block' : 'none';
renderModelChecks(document.getElementById('edit-stt-model-checks'), whisperModels, perms.allowed_stt_models || []);
// OCR 모델
const ocrWrap = document.getElementById('edit-ocr-models-wrap');
ocrWrap.style.display = perms.ocr ? 'block' : 'none';
renderModelChecks(document.getElementById('edit-ocr-model-checks'), ollamaModels, perms.allowed_ocr_models || []);
document.getElementById('edit-modal').classList.add('visible');
}
// 편집 모달 내 권한 체크박스 토글
document.getElementById('edit-perm-stt').addEventListener('change', function() {
document.getElementById('edit-stt-models-wrap').style.display = this.checked ? 'block' : 'none';
});
document.getElementById('edit-perm-ocr').addEventListener('change', function() {
document.getElementById('edit-ocr-models-wrap').style.display = this.checked ? 'block' : 'none';
});
document.getElementById('btn-modal-cancel').addEventListener('click', () => document.getElementById('edit-modal').classList.remove('visible'));
document.getElementById('edit-modal').addEventListener('click', e => { if (e.target === document.getElementById('edit-modal')) document.getElementById('edit-modal').classList.remove('visible') });
document.getElementById('btn-modal-save').addEventListener('click', async () => {
if (!editTarget) return;
const fd = new FormData();
fd.append('perm_stt', document.getElementById('edit-perm-stt').checked ? 'true' : 'false');
fd.append('perm_ocr', document.getElementById('edit-perm-ocr').checked ? 'true' : 'false');
fd.append('allowed_stt_models', getCheckedModels(document.getElementById('edit-stt-model-checks')).join(','));
fd.append('allowed_ocr_models', getCheckedModels(document.getElementById('edit-ocr-model-checks')).join(','));
const pw = document.getElementById('edit-password').value;
if (pw) fd.append('password', pw);
try {
const r = await fetch(`/api/admin/users/${editTarget}`, { method: 'PUT', headers: { Authorization: 'Bearer ' + (token || '') }, body: fd });
const d = await r.json(); const msg = document.getElementById('edit-msg');
if (r.ok) { showAdminMsg(msg, d.message, 'ok'); setTimeout(() => { document.getElementById('edit-modal').classList.remove('visible'); loadUsers(); }, 800); }
else showAdminMsg(msg, d.detail || '실패', 'err');
} catch { showAdminMsg(document.getElementById('edit-msg'), '서버 오류', 'err'); }
});
async function doDeleteUser(username) {
if (!confirm(`"${username}" 사용자를 삭제하시겠습니까?`)) return;
try {
const r = await fetch(`/api/admin/users/${username}`, { method: 'DELETE', headers: { Authorization: 'Bearer ' + (token || '') } });
if (r.ok) loadUsers(); else { const d = await r.json(); alert(d.detail || '삭제 실패'); }
} catch { alert('서버 오류'); }
}
function showAdminMsg(el, msg, type) { el.style.display = 'block'; el.className = 'admin-msg ' + type; el.textContent = msg; setTimeout(() => el.style.display = 'none', 3000); }
// ══ 공통 ══
document.addEventListener('click',e=>{if(!e.target.classList.contains('tab-btn'))return;const parent=e.target.closest('.result-tabs');parent.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));e.target.classList.add('active');const panel=parent.closest('.panel');panel.querySelectorAll('.tab-content').forEach(c=>c.classList.remove('active'));const t=document.getElementById(e.target.dataset.tab);if(t)t.classList.add('active')});