501 lines
16 KiB
HTML
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, "&")
|
|
.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", () => {
|
|
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 %}
|