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();
})();