const state = { users: [], companies: [], me: null, }; const ACTION_LABELS = { 'user.create': '회원 생성', 'user.update': '회원 수정', 'user.deactivate': '회원 비활성화', 'user.delete': '회원 삭제', 'user.2fa_reset': 'OTP 재설정', 'database.reset': 'DB 초기화', 'selling_override.create': '규칙 생성', 'selling_override.bulk_markup': '일괄 마크업', 'selling_override.bulk_clear': '규칙 해제', }; function fmtDate(iso) { if (!iso) return '—'; const d = new Date(iso); return d.toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' }); } function companyNames(ids) { if (!ids?.length) return '—'; return ids .map((id) => state.companies.find((c) => c.id === id)?.name || id.slice(0, 8)) .join(', '); } function setEditorStatus(msg, isError = false) { const el = document.getElementById('userEditorStatus'); el.textContent = msg; el.className = isError ? 'text-xs text-red-400 mt-2' : 'text-xs text-slate-400 mt-2'; } function summarizeChange(before, after) { if (!before && !after) return '—'; if (!before) return JSON.stringify(after, null, 0); if (!after) return JSON.stringify(before, null, 0); const keys = new Set([...Object.keys(before), ...Object.keys(after)]); const parts = []; keys.forEach((k) => { const b = JSON.stringify(before[k]); const a = JSON.stringify(after[k]); if (b !== a) parts.push(`${k}: ${b} → ${a}`); }); return parts.length ? parts.join('; ') : '—'; } function renderUsers() { const tbody = document.getElementById('usersTableBody'); tbody.innerHTML = state.users.map((u) => ` ${u.username} ${u.email} ${u.role} ${companyNames(u.company_ids)} ${u.is_2fa_enabled ? '✓' : '—'} ${u.is_active ? '활성' : '비활성'} ${fmtDate(u.created_at)} `).join(''); tbody.querySelectorAll('[data-edit]').forEach((btn) => { btn.addEventListener('click', () => openUserEditor(btn.dataset.edit)); }); } function renderAudit(rows) { const tbody = document.getElementById('auditTableBody'); tbody.innerHTML = rows.map((r) => ` ${fmtDate(r.created_at)} ${r.actor_username || r.actor_id?.slice(0, 8) || '—'}
${r.actor_role || ''} ${ACTION_LABELS[r.action] || r.action} ${r.resource_type}${r.resource_id ? `
${r.resource_id.slice(0, 8)}…` : ''} ${summarizeChange(r.before_state, r.after_state)} ${r.ip_address || '—'} `).join(''); } async function loadUsers() { state.users = await API.get('/api/v1/admin/users'); renderUsers(); } async function loadAudit() { const action = document.getElementById('auditActionFilter').value; const resource_type = document.getElementById('auditResourceFilter').value; const params = new URLSearchParams(); if (action) params.set('action', action); if (resource_type) params.set('resource_type', resource_type); params.set('limit', '200'); const rows = await API.get(`/api/v1/admin/audit-logs?${params}`); renderAudit(rows); } function fillCompanySelect(selected = []) { const sel = document.getElementById('editCompanyIds'); sel.innerHTML = state.companies.map((c) => ` `).join(''); } function openUserEditor(userId = null) { const isNew = !userId; const user = isNew ? null : state.users.find((u) => u.id === userId); document.getElementById('userEditorTitle').textContent = isNew ? '회원 추가' : `회원 편집 — ${user.username}`; document.getElementById('editUserId').value = user?.id || ''; document.getElementById('editUsername').value = user?.username || ''; document.getElementById('editUsername').disabled = !isNew; document.getElementById('editEmail').value = user?.email || ''; document.getElementById('editPassword').value = ''; document.getElementById('editPassword').required = isNew; document.getElementById('passwordHint').textContent = isNew ? '(신규 필수)' : '(변경 시에만 입력)'; document.getElementById('editRole').value = user?.role || 'operator'; document.getElementById('editIsActive').checked = user?.is_active ?? true; document.getElementById('edit2faEnabled').checked = user?.is_2fa_enabled ?? false; document.getElementById('reset2faBtn').classList.toggle('hidden', isNew); const canDelete = !isNew && user?.id !== state.me?.id; document.getElementById('deleteUserBtn').classList.toggle('hidden', !canDelete); fillCompanySelect(user?.company_ids || []); setEditorStatus(''); document.getElementById('userEditorBackdrop').classList.remove('hidden'); } function closeUserEditor() { document.getElementById('userEditorBackdrop').classList.add('hidden'); } function selectedCompanyIds() { return Array.from(document.getElementById('editCompanyIds').selectedOptions).map((o) => o.value); } async function resetUser2fa() { const id = document.getElementById('editUserId').value; if (!id) return; const username = document.getElementById('editUsername').value; if (!window.confirm(`${username} 계정의 OTP를 재설정할까요?\n다음 로그인 시 QR을 다시 등록해야 합니다.`)) { return; } try { let updated = await API.patch(`/api/v1/admin/users/${id}`, { reset_2fa: true }); if (updated?.is_2fa_enabled) { updated = await API.post(`/api/v1/admin/users/${id}/reset-2fa`, {}); } if (updated?.is_2fa_enabled) { throw new Error('서버에 OTP 재설정이 반영되지 않았습니다. API 서버를 재시작한 뒤 다시 시도하세요.'); } setEditorStatus('OTP가 재설정되었습니다. 해당 사용자는 다음 로그인 시 QR 등록이 필요합니다.'); document.getElementById('edit2faEnabled').checked = false; await loadUsers(); } catch (e) { const msg = e.message || 'OTP 재설정 실패'; setEditorStatus( msg === 'Not Found' ? 'OTP 재설정 API를 찾을 수 없습니다. 서버를 재시작한 뒤 다시 시도하세요.' : msg, true, ); } } async function deleteUser() { const id = document.getElementById('editUserId').value; if (!id) return; const username = document.getElementById('editUsername').value; if (id === state.me?.id) { setEditorStatus('본인 계정은 삭제할 수 없습니다.', true); return; } const typed = window.prompt( `계정을 영구 삭제합니다. 되돌릴 수 없습니다.\n삭제하려면 사용자명 "${username}" 을 입력하세요.`, ); if (typed !== username) { if (typed !== null) setEditorStatus('사용자명이 일치하지 않아 삭제가 취소되었습니다.', true); return; } try { await API.delete(`/api/v1/admin/users/${id}`); closeUserEditor(); await loadUsers(); } catch (e) { setEditorStatus(e.message || '계정 삭제 실패', true); } } async function saveUser() { const id = document.getElementById('editUserId').value; const isNew = !id; const body = { email: document.getElementById('editEmail').value.trim(), role: document.getElementById('editRole').value, company_ids: selectedCompanyIds(), is_active: document.getElementById('editIsActive').checked, }; const password = document.getElementById('editPassword').value; if (password) body.password = password; try { if (isNew) { body.username = document.getElementById('editUsername').value.trim(); if (!password) { setEditorStatus('비밀번호를 입력하세요.', true); return; } await API.post('/api/v1/admin/users', body); setEditorStatus('회원이 생성되었습니다.'); } else { await API.patch(`/api/v1/admin/users/${id}`, body); setEditorStatus('저장되었습니다.'); } await loadUsers(); setTimeout(closeUserEditor, 500); } catch (e) { setEditorStatus(e.message || '저장 실패', true); } } function showTab(tab) { const isUsers = tab === 'users'; document.getElementById('usersPanel').classList.toggle('hidden', !isUsers); document.getElementById('auditPanel').classList.toggle('hidden', isUsers); document.getElementById('tabUsers').classList.toggle('btn-primary', isUsers); document.getElementById('tabUsers').classList.toggle('btn-ghost', !isUsers); document.getElementById('tabAudit').classList.toggle('btn-primary', !isUsers); document.getElementById('tabAudit').classList.toggle('btn-ghost', isUsers); if (!isUsers) loadAudit(); } document.getElementById('tabUsers').addEventListener('click', () => showTab('users')); document.getElementById('tabAudit').addEventListener('click', () => showTab('audit')); document.getElementById('addUserBtn').addEventListener('click', () => openUserEditor()); document.getElementById('closeUserEditor').addEventListener('click', closeUserEditor); document.getElementById('userEditorBackdrop').addEventListener('click', (e) => { if (e.target.id === 'userEditorBackdrop') closeUserEditor(); }); document.getElementById('saveUserBtn').addEventListener('click', saveUser); document.getElementById('reset2faBtn').addEventListener('click', resetUser2fa); document.getElementById('deleteUserBtn').addEventListener('click', deleteUser); document.getElementById('refreshAuditBtn').addEventListener('click', loadAudit); document.getElementById('auditActionFilter').addEventListener('change', loadAudit); document.getElementById('auditResourceFilter').addEventListener('change', loadAudit); (async function init() { if (!requireAuth()) return; state.me = await applyAdminNav({ requireAdmin: true }); if (!state.me) return; try { await initCatalogSelectors({ skipPropertyLoad: true }); } catch (err) { console.warn('헤더 업체 선택 초기화 실패:', err); } state.companies = await API.get('/api/v1/companies'); await loadUsers(); })();