v4.0.10: 관리자 계정 변경 안전장치와 접속시간 지표 추가

역할 변경은 명시적으로 버튼을 눌렀을 때만 가능하게 제한하고, 마지막 접속시간과 비밀번호 분실 안내를 추가해 운영 관리성을 높인다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 18:24:45 +09:00
parent bc30356c8b
commit 5a56ddcede
11 changed files with 112 additions and 22 deletions

View File

@@ -92,12 +92,20 @@
<input type="text" id="edit-username" class="w-full box-border rounded-xl border border-slate-200 px-3 py-2.5 text-sm disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400" autocomplete="off" />
</div>
<div class="mb-4">
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400" for="edit-role">역할</label>
<select id="edit-role" class="w-full box-border rounded-xl border border-slate-200 px-3 py-2.5 text-sm disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400">
<option value="user">일반 회원</option>
<option value="admin">관리자</option>
</select>
<p id="edit-role-hint" class="mt-1.5 hidden text-[0.6875rem] leading-snug text-amber-600">본인 계정의 관리자 권한은 변경할 수 없습니다.</p>
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400">역할</label>
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5">
<div class="flex items-center justify-between gap-2">
<span id="edit-role-summary" class="text-sm font-semibold text-slate-700"></span>
<button type="button" id="toggle-role-editor" class="cursor-pointer rounded-lg border-0 bg-slate-200 px-2.5 py-1 text-[0.6875rem] font-bold text-slate-600 hover:bg-slate-300">역할 변경하기</button>
</div>
<div id="edit-role-editor" class="mt-2 hidden">
<select id="edit-role" class="w-full box-border rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400">
<option value="user">일반 회원</option>
<option value="admin">관리자</option>
</select>
<p id="edit-role-hint" class="mt-1.5 hidden text-[0.6875rem] leading-snug text-amber-600">본인 계정의 관리자 권한은 변경할 수 없습니다.</p>
</div>
</div>
</div>
<div class="mb-4">
<label class="mb-1.5 block text-[0.6875rem] font-bold uppercase text-slate-400" for="edit-isActive">상태</label>
@@ -171,6 +179,7 @@
let pendingAvatarImage = null;
let pendingRemoveAvatar = false;
let adminDecks = [];
let isRoleEditorOpen = false;
function escapeHtml(str) {
return String(str ?? '')
@@ -191,6 +200,41 @@
return url;
}
/**
* 날짜를 상대 시간 텍스트로 변환
* @param {string|null|undefined} value
* @returns {string}
*/
function formatRelativeTime(value) {
if (!value) return '기록 없음';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '기록 없음';
const diffMs = Date.now() - date.getTime();
if (diffMs < 0) return '방금 전';
const minuteMs = 60 * 1000;
const hourMs = 60 * minuteMs;
const dayMs = 24 * hourMs;
const monthMs = 30 * dayMs;
if (diffMs < minuteMs) return '방금 전';
if (diffMs < hourMs) return `${Math.floor(diffMs / minuteMs)}분 전`;
if (diffMs < dayMs) return `${Math.floor(diffMs / hourMs)}시간 전`;
if (diffMs < monthMs) return `${Math.floor(diffMs / dayMs)}일 전`;
return `${Math.floor(diffMs / monthMs)}달 전`;
}
/**
* 역할 변경 입력창 표시 여부
* @param {boolean} isOpen
*/
function setRoleEditorOpen(isOpen) {
isRoleEditorOpen = isOpen;
document.getElementById('edit-role-editor').classList.toggle('hidden', !isOpen);
document.getElementById('toggle-role-editor').textContent = isOpen ? '역할 변경 닫기' : '역할 변경하기';
}
async function guardAdmin() {
if (!UaDeckApi.getToken()) {
location.href = '/login.html?redirect=/admin.html';
@@ -228,10 +272,13 @@
<div class="min-w-0 flex-1">
<h3 class="mb-0.5 text-sm font-bold leading-snug text-slate-900">${escapeHtml(u.username)}${isSelf ? ' <span class="rounded-md bg-blue-50 px-1.5 py-0.5 text-[0.625rem] font-bold text-blue-600">나</span>' : ''}</h3>
<div class="truncate text-xs text-slate-400">${escapeHtml(u.email)}</div>
<div class="mt-1.5 text-xs font-semibold text-slate-600" title="공개 덱 / 전체 저장 덱">덱 ${deckPublic}/${deckTotal} <span class="font-normal text-slate-400">(공개/전체)</span></div>
<div class="mt-1.5 flex flex-wrap gap-1">
<span class="rounded-md bg-blue-50 px-1.5 py-0.5 text-[0.625rem] font-bold text-blue-600">${escapeHtml(u.role)}</span>
<span class="rounded-md px-1.5 py-0.5 text-[0.625rem] font-bold ${u.isActive ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-500'}">${u.isActive ? '활성' : '비활성'}</span>
<div class="mt-1 text-[0.6875rem] text-slate-500">마지막 접속: ${escapeHtml(formatRelativeTime(u.lastLoginAt))}</div>
<div class="mt-1.5 flex flex-wrap justify-between gap-1">
<div class="flex gap-1">
<span class="rounded-md bg-blue-50 px-1.5 py-0.5 text-[0.625rem] font-bold text-blue-600">${escapeHtml(u.role)}</span>
<span class="rounded-md px-1.5 py-0.5 text-[0.625rem] font-bold ${u.isActive ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-500'}">${u.isActive ? '활성' : '비활성'}</span>
</div>
<div class="mt-1.5 text-xs font-semibold text-slate-600" title="공개 덱 / 전체 저장 덱">덱 ${deckPublic}/${deckTotal} <span class="font-normal text-slate-400">(공개/전체)</span></div>
</div>
</div>
<button type="button" data-edit='${JSON.stringify(u).replace(/'/g, '&#39;')}' class="shrink-0 cursor-pointer rounded-lg border-0 bg-slate-100 px-2.5 py-1.5 text-[0.6875rem] font-bold text-slate-600 hover:bg-slate-200">수정</button>
@@ -256,13 +303,17 @@
document.getElementById('edit-username').value = u.username;
document.getElementById('edit-role').value = u.role;
document.getElementById('edit-isActive').value = u.isActive ? '1' : '0';
document.getElementById('edit-role-summary').textContent = u.role === 'admin' ? '관리자' : '일반 회원';
document.getElementById('edit-modal-title').textContent = u.username;
document.getElementById('edit-modal-sub').textContent = u.email;
document.getElementById('edit-avatar-preview').src = resolveAvatarSrc(u);
document.getElementById('edit-avatar-remove').classList.toggle('hidden', !u.avatarUrl);
setRoleEditorOpen(false);
document.getElementById('edit-role').disabled = isSelf;
document.getElementById('edit-isActive').disabled = isSelf;
document.getElementById('toggle-role-editor').disabled = isSelf;
document.getElementById('toggle-role-editor').classList.toggle('hidden', isSelf);
document.getElementById('edit-role-hint').classList.toggle('hidden', !isSelf);
document.getElementById('edit-status-hint').classList.toggle('hidden', !isSelf);
document.getElementById('modal-error').classList.add('hidden');
@@ -449,6 +500,10 @@
document.getElementById('password-error').classList.add('hidden');
setModalOpen(passwordModal, true);
});
document.getElementById('toggle-role-editor').addEventListener('click', () => {
if (!editingUser || editingUser.id === currentAdminId) return;
setRoleEditorOpen(!isRoleEditorOpen);
});
document.getElementById('password-cancel').addEventListener('click', () => {
setModalOpen(passwordModal, false);
@@ -549,7 +604,9 @@
username: document.getElementById('edit-username').value,
};
if (!isSelf) {
body.role = document.getElementById('edit-role').value;
if (isRoleEditorOpen) {
body.role = document.getElementById('edit-role').value;
}
body.isActive = document.getElementById('edit-isActive').value === '1';
}
if (pendingAvatarImage) body.avatarImage = pendingAvatarImage;