Preserve latest comparison functionality

This commit is contained in:
b17301
2026-06-08 14:25:47 +09:00
parent b93289bfa8
commit 886192ae58
12 changed files with 7966 additions and 485 deletions
+212 -1
View File
@@ -121,6 +121,104 @@
color: var(--ink);
}
.last-login-button {
border: 0;
background: transparent;
box-shadow: none;
padding: 0;
min-height: 0;
color: var(--ink);
font: inherit;
font-weight: 700;
line-height: 1.25;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.last-login-button:hover,
.last-login-button:focus {
background: transparent;
box-shadow: none;
text-decoration: none;
}
.login-history-modal {
position: fixed;
inset: 0;
z-index: 80;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(17, 24, 39, 0.28);
}
.login-history-modal.open {
display: flex;
}
.login-history-dialog {
width: min(1080px, calc(100vw - 48px));
max-height: min(760px, calc(100vh - 48px));
overflow: auto;
border: 1px solid var(--line);
border-radius: 0;
background: #ffffff;
box-shadow: none;
padding: 18px;
}
.login-history-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.login-history-head strong {
font-size: 18px;
line-height: 1.3;
}
.login-history-close {
border: 0;
background: transparent;
box-shadow: none;
color: var(--ink);
padding: 0;
min-height: 0;
font-weight: 800;
cursor: pointer;
}
.login-history-close:hover,
.login-history-close:focus {
background: transparent;
box-shadow: none;
}
.login-history-table-wrap {
overflow: auto;
}
.login-history-table {
width: 100%;
table-layout: fixed;
}
.login-history-table th,
.login-history-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.login-history-table .ua-cell {
max-width: 360px;
}
@media (max-width: 1100px) {
.admin-user-main-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -201,12 +299,14 @@
<th>페이지 권한</th>
<th>상태</th>
<th>관리자</th>
<th>최종접속기록(KST)</th>
<th>수정일</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr
data-user-id="{{ user.id }}"
data-username="{{ user.username }}"
data-display-name="{{ user.display_name }}"
data-role="{{ 'admin' if user.is_admin else 'viewer' }}"
@@ -232,14 +332,35 @@
</td>
<td>{{ '활성' if user.is_active else '비활성' }}</td>
<td>{{ '예' if user.is_admin else '-' }}</td>
<td>
{% if user.last_login_at %}
<button type="button" class="last-login-button" data-login-history-user="{{ user.id }}">
{{ user.last_login_at_kst }}
</button>
{% else %}
<span class="muted">-</span>
{% endif %}
</td>
<td>{{ user.updated_at }}</td>
</tr>
{% else %}
<tr><td colspan="7" class="empty">사용자가 없습니다.</td></tr>
<tr><td colspan="8" class="empty">사용자가 없습니다.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<div class="login-history-modal" id="loginHistoryModal" aria-hidden="true">
<div class="login-history-dialog" role="dialog" aria-modal="true" aria-labelledby="loginHistoryTitle">
<div class="login-history-head">
<strong id="loginHistoryTitle">접속이력</strong>
<button type="button" class="login-history-close" id="loginHistoryClose">닫기</button>
</div>
<div id="loginHistoryBody" class="login-history-table-wrap">
<div class="empty">접속이력을 불러오지 않았습니다.</div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
@@ -253,6 +374,79 @@
const role = form.querySelector("[name='role']");
const active = form.querySelector("[name='is_active']");
const permissionInputs = [...form.querySelectorAll("[name='permissions']")];
const loginHistoryModal = document.getElementById("loginHistoryModal");
const loginHistoryTitle = document.getElementById("loginHistoryTitle");
const loginHistoryBody = document.getElementById("loginHistoryBody");
const loginHistoryClose = document.getElementById("loginHistoryClose");
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function openLoginHistoryModal() {
loginHistoryModal?.classList.add("open");
loginHistoryModal?.setAttribute("aria-hidden", "false");
}
function closeLoginHistoryModal() {
loginHistoryModal?.classList.remove("open");
loginHistoryModal?.setAttribute("aria-hidden", "true");
}
function renderLoginHistory(payload) {
const events = Array.isArray(payload?.events) ? payload.events : [];
if (!events.length) {
loginHistoryBody.innerHTML = '<div class="empty">접속이력이 없습니다.</div>';
return;
}
loginHistoryBody.innerHTML = `
<table class="login-history-table">
<thead>
<tr>
<th>일시(KST)</th>
<th>결과</th>
<th>아이디</th>
<th>IP</th>
<th>실패사유</th>
<th>사용환경</th>
</tr>
</thead>
<tbody>
${events.map((event) => `
<tr>
<td title="${escapeHtml(event.created_at_kst || event.created_at)}">${escapeHtml(event.created_at_kst || event.created_at)}</td>
<td>${event.success ? "성공" : "실패"}</td>
<td title="${escapeHtml(event.username)}">${escapeHtml(event.username || "-")}</td>
<td title="${escapeHtml(event.ip_address)}">${escapeHtml(event.ip_address || "-")}</td>
<td title="${escapeHtml(event.failure_reason)}">${escapeHtml(event.failure_reason || "-")}</td>
<td class="ua-cell" title="${escapeHtml(event.user_agent)}">${escapeHtml(event.user_agent || "-")}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
}
async function loadLoginHistory(userId, label) {
loginHistoryTitle.textContent = `${label || "사용자"} 접속이력`;
loginHistoryBody.innerHTML = '<div class="empty">접속이력을 불러오는 중입니다.</div>';
openLoginHistoryModal();
try {
const response = await fetch(`/admin/users/${encodeURIComponent(userId)}/login-events?limit=100`, {
headers: { Accept: "application/json" },
});
const payload = await response.json();
if (!response.ok) throw new Error(payload?.error || "접속이력을 불러오지 못했습니다.");
renderLoginHistory(payload);
} catch (error) {
loginHistoryBody.innerHTML = `<div class="empty">${escapeHtml(error.message || "접속이력을 불러오지 못했습니다.")}</div>`;
}
}
document.querySelectorAll(".user-edit-button").forEach((button) => {
button.addEventListener("click", () => {
@@ -270,6 +464,23 @@
});
});
document.querySelectorAll(".last-login-button").forEach((button) => {
button.addEventListener("click", () => {
const row = button.closest("tr");
loadLoginHistory(button.dataset.loginHistoryUser, row?.dataset.username || button.textContent.trim());
});
});
loginHistoryClose?.addEventListener("click", closeLoginHistoryModal);
loginHistoryModal?.addEventListener("click", (event) => {
if (event.target === loginHistoryModal) closeLoginHistoryModal();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && loginHistoryModal?.classList.contains("open")) {
closeLoginHistoryModal();
}
});
role.addEventListener("change", () => {
const isAdmin = role.value === "admin";
permissionInputs.forEach((input) => {