Files

501 lines
16 KiB
HTML

{% extends "base.html" %}
{% block title %}사용자 관리{% endblock %}
{% block head_extra %}
<style>
.admin-user-form {
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: #f8fafc;
margin-bottom: 18px;
}
.admin-user-main-row {
display: grid;
grid-template-columns: minmax(110px, 0.9fr) minmax(120px, 1fr) minmax(140px, 1fr) minmax(130px, 0.9fr) auto;
gap: 10px;
align-items: end;
}
.admin-user-form .field {
min-width: 0;
}
.admin-user-form input:not([type="checkbox"]),
.admin-user-form select {
width: 100%;
min-height: 36px;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
color: var(--ink);
box-sizing: border-box;
padding: 8px 10px;
}
.admin-user-actions {
display: flex;
align-items: end;
justify-content: flex-end;
min-width: 76px;
}
.admin-user-actions button {
width: 76px;
min-height: 36px;
padding: 0 14px;
border-radius: 8px;
}
.admin-user-permission-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: center;
}
.permission-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 7px 0 0;
}
.permission-grid label {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-height: 28px;
padding: 4px 9px;
border: 1px solid var(--line);
border-radius: 999px;
background: #ffffff;
color: var(--ink);
font-size: 12px;
font-weight: 800;
line-height: 1;
white-space: nowrap;
cursor: pointer;
}
.permission-grid input[type="checkbox"],
.admin-active-toggle input[type="checkbox"] {
width: 14px;
height: 14px;
min-height: 0;
margin: 0;
accent-color: #111827;
}
.permission-chip-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.permission-chip {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 2px 7px;
border: 1px solid var(--line);
background: #f7f9fb;
font-size: 12px;
font-weight: 800;
}
.admin-active-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 30px;
padding-top: 19px;
white-space: nowrap;
font-size: 12px;
font-weight: 800;
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));
}
.admin-user-actions {
justify-content: flex-start;
}
}
@media (max-width: 720px) {
.admin-user-main-row,
.admin-user-permission-row {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<section class="panel">
<div class="section-title">
<div>
<h2>사용자 관리</h2>
</div>
</div>
<form class="admin-user-form" method="post" action="/admin/users">
<div class="admin-user-main-row">
<div class="field">
<label for="username">아이디</label>
<input id="username" name="username" required autocomplete="off">
</div>
<div class="field">
<label for="displayName">이름</label>
<input id="displayName" name="display_name">
</div>
<div class="field">
<label for="password">비밀번호</label>
<input id="password" name="password" type="password" autocomplete="new-password">
</div>
<div class="field">
<label for="role">역할</label>
<select id="role" name="role">
<option value="viewer">사용자별 권한</option>
<option value="admin">관리자</option>
</select>
</div>
<div class="admin-user-actions">
<button type="submit">저장</button>
</div>
</div>
<div class="admin-user-permission-row">
<div>
<label>페이지 권한</label>
<div class="permission-grid">
{% for permission in permission_options %}
<label>
<input type="checkbox" name="permissions" value="{{ permission.key }}" {% if permission.key != 'admin' %}checked{% endif %}>
{{ permission.label }}
</label>
{% endfor %}
</div>
</div>
<label class="admin-active-toggle">
<input type="checkbox" name="is_active" value="1" checked>
활성
</label>
</div>
</form>
<table>
<thead>
<tr>
<th>아이디</th>
<th>이름</th>
<th>역할</th>
<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' }}"
data-active="{{ '1' if user.is_active else '0' }}"
data-permissions="{{ ','.join(user.direct_permissions) }}"
>
<td><button type="button" class="button-secondary user-edit-button">{{ user.username }}</button></td>
<td>{{ user.display_name }}</td>
<td>{{ ', '.join(user.roles) }}</td>
<td>
<div class="permission-chip-list">
{% for permission in permission_options %}
{% if permission.key in user.direct_permissions %}
<span class="permission-chip">{{ permission.label }}</span>
{% endif %}
{% endfor %}
{% if user.is_admin %}
<span class="permission-chip">전체</span>
{% elif not user.direct_permissions %}
<span class="muted">역할 기본값</span>
{% endif %}
</div>
</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="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 %}
<script>
(() => {
const form = document.querySelector("form[action='/admin/users']");
if (!form) return;
const username = form.querySelector("[name='username']");
const displayName = form.querySelector("[name='display_name']");
const password = form.querySelector("[name='password']");
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", () => {
const row = button.closest("tr");
const permissions = new Set(String(row?.dataset.permissions || "").split(",").filter(Boolean));
username.value = row?.dataset.username || "";
displayName.value = row?.dataset.displayName || "";
password.value = "";
role.value = row?.dataset.role || "viewer";
active.checked = row?.dataset.active === "1";
permissionInputs.forEach((input) => {
input.checked = permissions.has(input.value) || (role.value !== "admin" && !permissions.size && input.value !== "admin");
});
username.focus();
});
});
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) => {
input.disabled = isAdmin;
if (isAdmin) input.checked = false;
});
});
form.addEventListener("submit", () => {
const isAdmin = role.value === "admin";
permissionInputs.forEach((input) => {
input.disabled = isAdmin;
});
});
})();
</script>
{% endblock %}