Preserve latest comparison functionality
This commit is contained in:
+212
-1
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user