xavis 소스·DB 스키마·활용사례/F-Scan/프롬프트 라이브러리 등 기능 반영. @xavis.co.kr → @ncue.net, 관리자 토큰 ncue-admin, 런타임 data/ Git 추적 제외. Co-authored-by: Cursor <cursoragent@cursor.com>
133 lines
5.5 KiB
Plaintext
133 lines
5.5 KiB
Plaintext
<!doctype html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<%- include('partials/favicon') %>
|
|
<title>사용자 현황관리 - XAVIS</title>
|
|
<link rel="stylesheet" href="/public/styles.css" />
|
|
</head>
|
|
<body>
|
|
<div class="app-shell">
|
|
<%- include('partials/nav', { activeMenu: 'admin-users', adminMode: true }) %>
|
|
<div class="content-area">
|
|
<header class="topbar">
|
|
<h1>사용자 현황관리</h1>
|
|
<a class="top-action-link" href="/learning">학습센터</a>
|
|
</header>
|
|
<main class="container">
|
|
<section class="panel">
|
|
<p class="subtitle" style="margin-bottom: 16px">
|
|
OPS 이메일(<strong>@ncue.net</strong>) 매직 링크로 <strong>로그인에 성공한</strong> 사용자 목록입니다. 이메일과 최근 접속일(마지막 로그인 시각)을 표시합니다.
|
|
</p>
|
|
<p class="admin-hint" style="margin-bottom: 16px">
|
|
<strong>전체 로그아웃</strong>을 실행하면 해당 사용자의 모든 디바이스에서 OPS 세션이 즉시 무효화됩니다. 이후 다시 매직 링크로 로그인해야 합니다.
|
|
</p>
|
|
<p id="adminUsersFlash" class="form-message" hidden role="status"></p>
|
|
<% if (typeof dbError !== 'undefined' && dbError) { %>
|
|
<p class="admin-error">목록을 불러오지 못했습니다: <%= dbError %></p>
|
|
<% } else if (!pgConnected) { %>
|
|
<p class="admin-warn">PostgreSQL이 비활성화되어 있어 사용자 목록을 조회할 수 없습니다.</p>
|
|
<% } else if (!users || users.length === 0) { %>
|
|
<p class="admin-hint">아직 로그인 기록이 없습니다.</p>
|
|
<% } else { %>
|
|
<div class="table-wrap">
|
|
<table class="data-table" aria-label="인증 사용자 목록">
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">이메일</th>
|
|
<th scope="col">최근 접속일</th>
|
|
<th scope="col">세션 무효화</th>
|
|
<th scope="col">작업</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<% users.forEach(function (u) { %>
|
|
<tr data-email="<%= u.email %>">
|
|
<td><%= u.email %></td>
|
|
<td>
|
|
<% if (u.lastLoginAt) { %>
|
|
<%= new Date(u.lastLoginAt).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }) %>
|
|
<% } else { %>
|
|
—
|
|
<% } %>
|
|
</td>
|
|
<td class="js-sessions-revoked-at">
|
|
<% if (u.sessionsRevokedAt) { %>
|
|
<%= new Date(u.sessionsRevokedAt).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }) %>
|
|
<% } else { %>
|
|
—
|
|
<% } %>
|
|
</td>
|
|
<td>
|
|
<button
|
|
type="button"
|
|
class="danger btn-sm js-revoke-sessions-btn"
|
|
data-email="<%= u.email %>"
|
|
>
|
|
전체 로그아웃
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
<% }); %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p class="admin-hint" style="margin-top: 12px">총 <%= users.length %>명</p>
|
|
<% } %>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
(function () {
|
|
const flash = document.getElementById("adminUsersFlash");
|
|
function showFlash(text, isError) {
|
|
if (!flash) return;
|
|
flash.textContent = text;
|
|
flash.hidden = false;
|
|
flash.style.color = isError ? "#b91c1c" : "#059669";
|
|
}
|
|
|
|
document.querySelectorAll(".js-revoke-sessions-btn").forEach(function (btn) {
|
|
btn.addEventListener("click", async function () {
|
|
const email = btn.getAttribute("data-email") || "";
|
|
if (!email) return;
|
|
const ok = window.confirm(
|
|
email + " 사용자의 모든 디바이스에서 OPS 세션을 만료(전체 로그아웃)시키겠습니까?"
|
|
);
|
|
if (!ok) return;
|
|
|
|
btn.disabled = true;
|
|
try {
|
|
const res = await fetch("/api/admin/users/revoke-sessions", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email: email }),
|
|
});
|
|
const data = await res.json().catch(function () {
|
|
return {};
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(data.error || "요청 실패");
|
|
}
|
|
const row = btn.closest("tr");
|
|
const cell = row && row.querySelector(".js-sessions-revoked-at");
|
|
if (cell && data.revokedAt) {
|
|
cell.textContent = new Date(data.revokedAt).toLocaleString("ko-KR", {
|
|
timeZone: "Asia/Seoul",
|
|
});
|
|
}
|
|
showFlash(email + " 사용자의 세션이 무효화되었습니다.");
|
|
} catch (err) {
|
|
showFlash(err.message || "세션 만료 처리 실패", true);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|