All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 21s
- 后端: Auth(登录/登出/me + session 中间件)、Users、Tokens 路由与 Service, scrypt 哈希+timingSafeEqual 校验、requireAdmin 保护、admin 用户自动初始化 - 前端: login.html(登录页, 无账号密码提示)、admin.html(用户+Token 管理, 令牌明文显隐/复制/吊销/重置密码)、标题栏用户信息+系统管理入口 - 持久化: jsonAdapter 支持 users/api-tokens,运行时数据忽略入库 - 文档: 需求.md/原型设计.md 同步
668 lines
30 KiB
HTML
668 lines
30 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>系统管理 - Registry 配置管理中心</title>
|
||
<link rel="stylesheet" href="/css/style.css">
|
||
<style>
|
||
.titlebar-right { margin-left: auto; display: flex; align-items: center; gap: 14px; font-size: 12px; }
|
||
.titlebar-user { color: #e8f1fb; display: flex; align-items: center; gap: 6px; }
|
||
.role-badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; background: rgba(255,255,255,0.18); border: 1px solid rgba(255,255,255,0.35); }
|
||
.role-badge.admin { background: #ffb900; border-color: #ffd666; color: #4a3200; font-weight: 600; }
|
||
.titlebar-link { color: #cfe3f7; text-decoration: none; cursor: pointer; padding-left: 14px; border-left: 1px solid rgba(255,255,255,0.3); }
|
||
.titlebar-link:hover { color: #fff; text-decoration: underline; }
|
||
|
||
.admin-container { flex: 1; display: flex; overflow: hidden; }
|
||
.admin-nav { width: 200px; background: #fff; border-right: 1px solid #c0c0c0; padding: 10px 0; flex-shrink: 0; }
|
||
.admin-nav-title { padding: 4px 18px 10px; font-size: 11px; color: #999; letter-spacing: 1px; }
|
||
.admin-nav-item { display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 18px; cursor: pointer; color: #333; font-size: 13px; border-left: 3px solid transparent; }
|
||
.admin-nav-item:hover { background: #e5f1fb; }
|
||
.admin-nav-item.active { background: #cce8ff; color: #003a70; font-weight: 600; border-left-color: #0078d4; }
|
||
.admin-content { flex: 1; overflow: auto; padding: 18px; }
|
||
.admin-panel { background: #fff; border: 1px solid #e0e0e0; border-radius: 6px; overflow: hidden; }
|
||
.admin-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: #f5f5f5; border-bottom: 1px solid #e0e0e0; flex-wrap: wrap; gap: 8px; }
|
||
.admin-panel-header h2 { font-size: 14px; color: #003a70; }
|
||
.admin-panel-header .panel-sub { font-weight: normal; font-size: 11px; color: #999; margin-left: 8px; }
|
||
.admin-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||
.search-input { height: 30px; width: 200px; padding: 0 10px; border: 1px solid #adadad; border-radius: 3px; font-size: 13px; font-family: inherit; }
|
||
.filter-select { height: 30px; padding: 0 8px; border: 1px solid #adadad; border-radius: 3px; background: #fff; font-size: 12px; }
|
||
.tab-panel { display: none; }
|
||
.tab-panel.active { display: block; }
|
||
|
||
.data-table { width: 100%; border-collapse: collapse; }
|
||
.data-table th { background: #f0f0f0; border-bottom: 1px solid #c0c0c0; border-right: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; font-weight: 600; color: #555; font-size: 12px; white-space: nowrap; }
|
||
.data-table td { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; font-size: 12px; vertical-align: middle; }
|
||
.data-table tr:hover td { background: #f8fbff; }
|
||
.data-table .mono { font-family: Consolas, monospace; color: #0451a5; }
|
||
|
||
.role-tag { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; border: 1px solid transparent; }
|
||
.role-tag.admin { background: #fff3e0; color: #b45309; border-color: #f0c98f; }
|
||
.role-tag.user { background: #e5f1fb; color: #0078d4; border-color: #bcdcf7; }
|
||
.status-badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; border: 1px solid transparent; }
|
||
.badge-active { background: #e8f5e9; color: #2e7d32; border-color: #b5dfb8; }
|
||
.badge-disabled { background: #fdecea; color: #c62828; border-color: #f2b8b5; }
|
||
.badge-expired { background: #f0f0f0; color: #777; border-color: #d5d5d5; }
|
||
|
||
.dim-chip { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; background: #eef4fb; color: #0a4a86; border: 1px solid #d0dced; }
|
||
.token-reveal { display: flex; align-items: center; gap: 6px; }
|
||
.token-value { font-family: Consolas, monospace; color: #1e1e1e; letter-spacing: 1px; }
|
||
.token-mask { color: #999; letter-spacing: 2px; }
|
||
.token-btn { border: 1px solid #adadad; background: #fff; border-radius: 3px; padding: 1px 6px; font-size: 11px; cursor: pointer; white-space: nowrap; }
|
||
.token-btn:hover { background: #e5f1fb; border-color: #5b9bd5; }
|
||
|
||
.form-row { display: flex; gap: 12px; }
|
||
.form-half { flex: 1; }
|
||
.form-group label.hint { font-weight: normal; color: #999; font-size: 11px; }
|
||
.hint { font-weight: normal; color: #999; font-size: 11px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header class="titlebar">
|
||
<span class="app-title">📋 Registry 配置管理中心</span>
|
||
<span class="current-project">系统管理</span>
|
||
<div class="titlebar-right">
|
||
<span class="titlebar-user">
|
||
👤 <span id="user-name"></span>
|
||
<span class="role-badge admin">管理员</span>
|
||
</span>
|
||
<a class="titlebar-link" href="/projects.html">← 返回项目列表</a>
|
||
<a class="titlebar-link" id="logout-link" href="#">退出登录</a>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="admin-container">
|
||
<aside class="admin-nav">
|
||
<div class="admin-nav-title">系统设置</div>
|
||
<div class="admin-nav-item active" data-tab="users" onclick="switchTab('users')">👥 用户管理</div>
|
||
<div class="admin-nav-item" data-tab="tokens" onclick="switchTab('tokens')">🔑 Token 管理</div>
|
||
</aside>
|
||
|
||
<div class="admin-content">
|
||
<!-- 用户管理 -->
|
||
<div class="tab-panel active" id="panel-users">
|
||
<div class="admin-panel">
|
||
<div class="admin-panel-header">
|
||
<h2>用户管理 <span class="panel-sub">管理系统账号与角色权限</span></h2>
|
||
<div class="admin-toolbar">
|
||
<input type="text" class="search-input" id="user-search" placeholder="🔍 搜索用户名">
|
||
<button class="btn btn-primary" style="height:30px" id="btn-add-user">➕ 新增用户</button>
|
||
</div>
|
||
</div>
|
||
<div style="overflow:auto">
|
||
<table class="data-table" id="user-table">
|
||
<thead>
|
||
<tr>
|
||
<th>用户名</th><th>显示名称</th><th>角色</th><th>状态</th>
|
||
<th>创建时间</th><th>最后登录</th><th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="user-tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Token 管理 -->
|
||
<div class="tab-panel" id="panel-tokens">
|
||
<div class="admin-panel">
|
||
<div class="admin-panel-header">
|
||
<h2>Token 管理 <span class="panel-sub">MCP / API 访问令牌</span></h2>
|
||
<div class="admin-toolbar">
|
||
<input type="text" class="search-input" id="token-search" placeholder="🔍 搜索 Token 名称">
|
||
<button class="btn btn-primary" style="height:30px" id="btn-add-token">🔑 生成 Token</button>
|
||
</div>
|
||
</div>
|
||
<div style="overflow:auto">
|
||
<table class="data-table" id="token-table">
|
||
<thead>
|
||
<tr>
|
||
<th>名称</th><th>关联项目</th><th>权限</th><th>Token</th>
|
||
<th>状态</th><th>过期时间</th><th>创建时间</th><th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="token-tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<footer class="statusbar">
|
||
<span id="status-text">系统管理 · 用户管理</span>
|
||
<span id="status-user"></span>
|
||
</footer>
|
||
|
||
<!-- 新增/编辑用户模态框 -->
|
||
<div class="modal-overlay" id="modal-user" style="display:none">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h3 id="modal-user-title">新增用户</h3>
|
||
<button class="modal-close" onclick="closeModal('modal-user')">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<input type="hidden" id="edit-user-id">
|
||
<div class="form-group">
|
||
<label>用户名 <span class="required">*</span></label>
|
||
<input type="text" id="input-username" placeholder="登录账号,例如 zhangsan">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>显示名称</label>
|
||
<input type="text" id="input-display-name" placeholder="用于界面展示的名称">
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group form-half">
|
||
<label>角色</label>
|
||
<select id="input-role"><option value="user">普通用户</option><option value="admin">管理员</option></select>
|
||
</div>
|
||
<div class="form-group form-half">
|
||
<label>状态</label>
|
||
<select id="input-enabled"><option value="true">启用</option><option value="false">停用</option></select>
|
||
</div>
|
||
</div>
|
||
<div class="form-group" id="password-group">
|
||
<label>密码 <span class="required">*</span></label>
|
||
<input type="password" id="input-password" placeholder="首次登录后建议修改">
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeModal('modal-user')">取消</button>
|
||
<button class="btn btn-primary" id="btn-save-user">保存</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 重置密码模态框 -->
|
||
<div class="modal-overlay" id="modal-reset-pw" style="display:none">
|
||
<div class="modal" style="width:360px">
|
||
<div class="modal-header">
|
||
<h3>重置密码</h3>
|
||
<button class="modal-close" onclick="closeModal('modal-reset-pw')">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<input type="hidden" id="reset-user-id">
|
||
<div class="form-group">
|
||
<label>新密码 <span class="required">*</span></label>
|
||
<input type="password" id="input-new-password" placeholder="请输入新密码">
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeModal('modal-reset-pw')">取消</button>
|
||
<button class="btn btn-primary" id="btn-save-reset-pw">确认重置</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 生成/编辑 Token 模态框 -->
|
||
<div class="modal-overlay" id="modal-token" style="display:none">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h3 id="modal-token-title">生成 Token</h3>
|
||
<button class="modal-close" onclick="closeModal('modal-token')">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<input type="hidden" id="edit-token-id">
|
||
<div class="form-group">
|
||
<label>名称 <span class="required">*</span></label>
|
||
<input type="text" id="input-token-name" placeholder="例如:CI 自动发布">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>关联项目</label>
|
||
<select id="input-token-project"><option value="all">全部项目</option></select>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group form-half">
|
||
<label>权限范围</label>
|
||
<select id="input-token-permission"><option value="readonly">只读</option><option value="readwrite">读写</option></select>
|
||
</div>
|
||
<div class="form-group form-half">
|
||
<label>过期时间</label>
|
||
<select id="input-token-expiry">
|
||
<option value="30">30 天</option>
|
||
<option value="90" selected>90 天</option>
|
||
<option value="365">1 年</option>
|
||
<option value="0">永不过期</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<p class="hint">生成后 Token 明文仅展示一次,请及时复制并妥善保管。</p>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeModal('modal-token')">取消</button>
|
||
<button class="btn btn-primary" id="btn-save-token">生成</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Token 明文展示模态框 -->
|
||
<div class="modal-overlay" id="modal-token-reveal" style="display:none">
|
||
<div class="modal" style="width:480px">
|
||
<div class="modal-header">
|
||
<h3>Token 已生成</h3>
|
||
<button class="modal-close" onclick="closeModal('modal-token-reveal')">✕</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p style="margin-bottom:12px;color:#666;font-size:12px">请立即复制保存,关闭后将无法再查看完整 Token。</p>
|
||
<div style="display:flex;gap:8px;align-items:center;background:#f5f5f5;padding:10px 12px;border-radius:4px;border:1px solid #e0e0e0">
|
||
<code id="reveal-token-value" style="flex:1;word-break:break-all;font-family:Consolas,monospace;font-size:13px;color:#0451a5"></code>
|
||
<button class="token-btn" id="btn-copy-reveal">复制</button>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-primary" onclick="closeModal('modal-token-reveal')">我已保存</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 确认删除模态框 -->
|
||
<div class="modal-overlay" id="modal-confirm" style="display:none">
|
||
<div class="modal" style="width:360px">
|
||
<div class="modal-header">
|
||
<h3>确认操作</h3>
|
||
<button class="modal-close" onclick="closeModal('modal-confirm')">✕</button>
|
||
</div>
|
||
<div class="modal-body"><p id="confirm-message"></p></div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeModal('modal-confirm')">取消</button>
|
||
<button class="btn btn-danger" id="btn-confirm-ok">确认</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="/js/api.js"></script>
|
||
<script>
|
||
var currentUser = null;
|
||
var confirmCallback = null;
|
||
|
||
function toast(message, type) {
|
||
var el = document.createElement("div");
|
||
el.className = "toast toast-" + (type || "info");
|
||
el.textContent = message;
|
||
document.body.appendChild(el);
|
||
setTimeout(function () { el.remove(); }, 2500);
|
||
}
|
||
|
||
function openModal(id) { document.getElementById(id).style.display = "flex"; }
|
||
window.closeModal = function (id) { document.getElementById(id).style.display = "none"; };
|
||
|
||
function openConfirm(message, callback) {
|
||
document.getElementById("confirm-message").textContent = message;
|
||
confirmCallback = callback;
|
||
openModal("modal-confirm");
|
||
}
|
||
document.getElementById("btn-confirm-ok").addEventListener("click", function () {
|
||
closeModal("modal-confirm");
|
||
if (confirmCallback) confirmCallback();
|
||
confirmCallback = null;
|
||
});
|
||
|
||
function esc(s) { var d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
|
||
|
||
function fmtDate(iso) {
|
||
if (!iso) return "—";
|
||
var d = new Date(iso);
|
||
var pad = function (n) { return n < 10 ? "0" + n : n; };
|
||
return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes());
|
||
}
|
||
|
||
window.switchTab = function (tab) {
|
||
document.querySelectorAll(".admin-nav-item").forEach(function (el) { el.classList.toggle("active", el.dataset.tab === tab); });
|
||
document.querySelectorAll(".tab-panel").forEach(function (el) { el.classList.remove("active"); });
|
||
document.getElementById("panel-" + tab).classList.add("active");
|
||
document.getElementById("status-text").textContent = "系统管理 · " + (tab === "users" ? "用户管理" : "Token 管理");
|
||
}
|
||
|
||
// ========== 用户管理 ==========
|
||
|
||
var usersCache = [];
|
||
|
||
async function loadUsers() {
|
||
try {
|
||
usersCache = await API.getUsers();
|
||
renderUsers(usersCache);
|
||
} catch (err) {
|
||
toast("加载用户失败: " + err.message, "error");
|
||
}
|
||
}
|
||
|
||
function renderUsers(users) {
|
||
var tbody = document.getElementById("user-tbody");
|
||
if (!users.length) {
|
||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#999;padding:30px">暂无用户</td></tr>';
|
||
return;
|
||
}
|
||
tbody.innerHTML = users.map(function (u) {
|
||
return "<tr>" +
|
||
'<td class="mono">' + esc(u.username) + "</td>" +
|
||
"<td>" + esc(u.displayName || "") + "</td>" +
|
||
'<td><span class="role-tag ' + u.role + '">' + (u.role === "admin" ? "管理员" : "普通用户") + "</span></td>" +
|
||
'<td><span class="status-badge ' + (u.enabled ? "badge-active" : "badge-disabled") + '">' + (u.enabled ? "启用" : "停用") + "</span></td>" +
|
||
"<td>" + fmtDate(u.createdAt) + "</td>" +
|
||
"<td>" + fmtDate(u.lastLoginAt) + "</td>" +
|
||
'<td style="white-space:nowrap">' +
|
||
'<button class="row-btn" onclick="editUserById(\'' + u.id + '\')">编辑</button> ' +
|
||
'<button class="row-btn" onclick="resetPassword(\'' + u.id + '\')">重置密码</button> ' +
|
||
(u.enabled
|
||
? '<button class="row-btn danger" onclick="toggleUser(\'' + u.id + "', false)\">停用</button>"
|
||
: '<button class="row-btn" onclick="toggleUser(\'' + u.id + "', true)\">启用</button>") +
|
||
(u.username !== "admin"
|
||
? ' <button class="row-btn danger" onclick="confirmDeleteUser(\'' + u.id + '\')">删除</button>'
|
||
: "") +
|
||
"</td>" +
|
||
"</tr>";
|
||
}).join("");
|
||
}
|
||
|
||
document.getElementById("user-search").addEventListener("input", function () {
|
||
var kw = this.value.trim().toLowerCase();
|
||
document.querySelectorAll("#user-tbody tr").forEach(function (tr) {
|
||
tr.style.display = kw && tr.textContent.toLowerCase().indexOf(kw) === -1 ? "none" : "";
|
||
});
|
||
});
|
||
|
||
document.getElementById("btn-add-user").addEventListener("click", function () {
|
||
document.getElementById("modal-user-title").textContent = "新增用户";
|
||
document.getElementById("edit-user-id").value = "";
|
||
document.getElementById("input-username").value = "";
|
||
document.getElementById("input-username").disabled = false;
|
||
document.getElementById("input-display-name").value = "";
|
||
document.getElementById("input-role").value = "user";
|
||
document.getElementById("input-enabled").value = "true";
|
||
document.getElementById("input-password").value = "";
|
||
document.getElementById("password-group").style.display = "";
|
||
openModal("modal-user");
|
||
});
|
||
|
||
window.editUserById = function (id) {
|
||
var u = usersCache.find(function (x) { return x.id === id; });
|
||
if (!u) return;
|
||
document.getElementById("modal-user-title").textContent = "编辑用户";
|
||
document.getElementById("edit-user-id").value = u.id;
|
||
document.getElementById("input-username").value = u.username;
|
||
document.getElementById("input-username").disabled = true;
|
||
document.getElementById("input-display-name").value = u.displayName || "";
|
||
document.getElementById("input-role").value = u.role;
|
||
document.getElementById("input-enabled").value = String(u.enabled);
|
||
document.getElementById("password-group").style.display = "none";
|
||
openModal("modal-user");
|
||
};
|
||
|
||
document.getElementById("btn-save-user").addEventListener("click", async function () {
|
||
var id = document.getElementById("edit-user-id").value;
|
||
var isNew = !id;
|
||
try {
|
||
if (isNew) {
|
||
var data = {
|
||
username: document.getElementById("input-username").value,
|
||
displayName: document.getElementById("input-display-name").value,
|
||
password: document.getElementById("input-password").value,
|
||
role: document.getElementById("input-role").value,
|
||
enabled: document.getElementById("input-enabled").value === "true",
|
||
};
|
||
await API.createUser(data);
|
||
toast("用户创建成功", "success");
|
||
} else {
|
||
await API.updateUser(id, {
|
||
displayName: document.getElementById("input-display-name").value,
|
||
role: document.getElementById("input-role").value,
|
||
enabled: document.getElementById("input-enabled").value === "true",
|
||
});
|
||
toast("用户已更新", "success");
|
||
}
|
||
closeModal("modal-user");
|
||
loadUsers();
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
});
|
||
|
||
window.resetPassword = function (id) {
|
||
document.getElementById("reset-user-id").value = id;
|
||
document.getElementById("input-new-password").value = "";
|
||
openModal("modal-reset-pw");
|
||
};
|
||
|
||
document.getElementById("btn-save-reset-pw").addEventListener("click", async function () {
|
||
var id = document.getElementById("reset-user-id").value;
|
||
var pw = document.getElementById("input-new-password").value;
|
||
try {
|
||
await API.resetPassword(id, pw);
|
||
closeModal("modal-reset-pw");
|
||
toast("密码已重置", "success");
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
});
|
||
|
||
window.toggleUser = async function (id, enabled) {
|
||
try {
|
||
await API.updateUser(id, { enabled: enabled });
|
||
toast(enabled ? "用户已启用" : "用户已停用", "success");
|
||
loadUsers();
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
};
|
||
|
||
window.confirmDeleteUser = function (id) {
|
||
var u = usersCache.find(function (x) { return x.id === id; });
|
||
var name = u ? u.username : id;
|
||
openConfirm("确认删除用户「" + name + "」?", async function () {
|
||
try {
|
||
await API.deleteUser(id);
|
||
toast("用户已删除", "success");
|
||
loadUsers();
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
});
|
||
}
|
||
|
||
// ========== Token 管理 ==========
|
||
|
||
async function loadTokens() {
|
||
try {
|
||
var tokens = await API.getTokens();
|
||
renderTokens(tokens);
|
||
} catch (err) {
|
||
toast("加载 Token 失败: " + err.message, "error");
|
||
}
|
||
}
|
||
|
||
async function loadProjectsForSelect() {
|
||
try {
|
||
var projects = await API.getProjects();
|
||
var sel = document.getElementById("input-token-project");
|
||
sel.innerHTML = '<option value="all">全部项目</option>' +
|
||
projects.map(function (p) { return '<option value="' + p.id + '">' + esc(p.name) + "</option>"; }).join("");
|
||
} catch (e) {}
|
||
}
|
||
|
||
function renderTokens(tokens) {
|
||
var tbody = document.getElementById("token-tbody");
|
||
if (!tokens.length) {
|
||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#999;padding:30px">暂无 Token</td></tr>';
|
||
return;
|
||
}
|
||
tbody.innerHTML = tokens.map(function (t) {
|
||
var status = "badge-active";
|
||
var statusText = "有效";
|
||
if (!t.enabled) { status = "badge-disabled"; statusText = "已吊销"; }
|
||
else if (t.expiresAt && new Date(t.expiresAt) < new Date()) { status = "badge-expired"; statusText = "已过期"; }
|
||
var masked = t.value.substring(0, 6) + "••••••••••••";
|
||
return "<tr>" +
|
||
"<td>" + esc(t.name) + "</td>" +
|
||
"<td>" + esc(t.projectId === "all" ? "全部项目" : t.projectId) + "</td>" +
|
||
'<td><span class="dim-chip">' + (t.permission === "readwrite" ? "读写" : "只读") + "</span></td>" +
|
||
'<td><div class="token-reveal">' +
|
||
'<span class="token-value token-mask" data-id="' + t.id + '">' + masked + "</span>" +
|
||
'<button class="token-btn" onclick="toggleTokenVis(this)">显示</button>' +
|
||
'<button class="token-btn" onclick="copyTokenVal(this, \'' + t.value + "')\">复制</button>" +
|
||
"</div></td>" +
|
||
'<td><span class="status-badge ' + status + '">' + statusText + "</span></td>" +
|
||
"<td>" + fmtDate(t.expiresAt) + "</td>" +
|
||
"<td>" + fmtDate(t.createdAt) + "</td>" +
|
||
'<td style="white-space:nowrap">' +
|
||
'<button class="row-btn" onclick=\'editToken(' + JSON.stringify({ id: t.id, name: t.name, projectId: t.projectId, permission: t.permission }).replace(/'/g, "'") + ")'>编辑</button> " +
|
||
(t.enabled
|
||
? '<button class="row-btn danger" onclick="revokeToken(\'' + t.id + "')\">吊销</button>"
|
||
: "") +
|
||
' <button class="row-btn danger" onclick="confirmDeleteToken(\'' + t.id + "', '" + esc(t.name) + "')\">删除</button>" +
|
||
"</td>" +
|
||
"</tr>";
|
||
}).join("");
|
||
}
|
||
|
||
window.toggleTokenVis = function (btn) {
|
||
var span = btn.parentElement.querySelector(".token-value");
|
||
if (btn.textContent === "显示") {
|
||
btn.dataset.masked = span.textContent;
|
||
// 显示完整值需要从 tbody 原始数据中获取,这里通过 API 重新加载
|
||
span.classList.remove("token-mask");
|
||
span.textContent = span.dataset.full || btn.dataset.masked;
|
||
btn.textContent = "隐藏";
|
||
// 尝试从隐藏的 data 属性获取完整值
|
||
var id = span.dataset.id;
|
||
API.getTokens().then(function (tokens) {
|
||
var t = tokens.find(function (tk) { return tk.id === id; });
|
||
if (t) { span.dataset.full = t.value; span.textContent = t.value; }
|
||
});
|
||
} else {
|
||
span.classList.add("token-mask");
|
||
span.textContent = btn.dataset.masked;
|
||
btn.textContent = "显示";
|
||
}
|
||
};
|
||
|
||
window.copyTokenVal = function (btn, val) {
|
||
if (navigator.clipboard) navigator.clipboard.writeText(val);
|
||
btn.textContent = "已复制";
|
||
setTimeout(function () { btn.textContent = "复制"; }, 1200);
|
||
};
|
||
|
||
document.getElementById("token-search").addEventListener("input", function () {
|
||
var kw = this.value.trim().toLowerCase();
|
||
document.querySelectorAll("#token-tbody tr").forEach(function (tr) {
|
||
tr.style.display = kw && tr.textContent.toLowerCase().indexOf(kw) === -1 ? "none" : "";
|
||
});
|
||
});
|
||
|
||
document.getElementById("btn-add-token").addEventListener("click", function () {
|
||
document.getElementById("modal-token-title").textContent = "生成 Token";
|
||
document.getElementById("edit-token-id").value = "";
|
||
document.getElementById("input-token-name").value = "";
|
||
document.getElementById("input-token-project").value = "all";
|
||
document.getElementById("input-token-permission").value = "readonly";
|
||
document.getElementById("input-token-expiry").value = "90";
|
||
document.getElementById("btn-save-token").textContent = "生成";
|
||
loadProjectsForSelect();
|
||
openModal("modal-token");
|
||
});
|
||
|
||
window.editToken = function (t) {
|
||
document.getElementById("modal-token-title").textContent = "编辑 Token";
|
||
document.getElementById("edit-token-id").value = t.id;
|
||
document.getElementById("input-token-name").value = t.name;
|
||
document.getElementById("input-token-project").value = t.projectId;
|
||
document.getElementById("input-token-permission").value = t.permission;
|
||
document.getElementById("btn-save-token").textContent = "保存";
|
||
loadProjectsForSelect();
|
||
openModal("modal-token");
|
||
};
|
||
|
||
document.getElementById("btn-save-token").addEventListener("click", async function () {
|
||
var id = document.getElementById("edit-token-id").value;
|
||
var name = document.getElementById("input-token-name").value.trim();
|
||
if (!name) { toast("请输入 Token 名称", "error"); return; }
|
||
try {
|
||
if (id) {
|
||
await API.updateToken(id, {
|
||
name: name,
|
||
projectId: document.getElementById("input-token-project").value,
|
||
permission: document.getElementById("input-token-permission").value,
|
||
});
|
||
closeModal("modal-token");
|
||
toast("Token 已更新", "success");
|
||
loadTokens();
|
||
} else {
|
||
var data = await API.createToken({
|
||
name: name,
|
||
projectId: document.getElementById("input-token-project").value,
|
||
permission: document.getElementById("input-token-permission").value,
|
||
expiresInDays: parseInt(document.getElementById("input-token-expiry").value) || 0,
|
||
});
|
||
closeModal("modal-token");
|
||
document.getElementById("reveal-token-value").textContent = data.value;
|
||
openModal("modal-token-reveal");
|
||
toast("Token 已生成", "success");
|
||
loadTokens();
|
||
}
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
});
|
||
|
||
document.getElementById("btn-copy-reveal").addEventListener("click", function () {
|
||
var val = document.getElementById("reveal-token-value").textContent;
|
||
if (navigator.clipboard) navigator.clipboard.writeText(val);
|
||
this.textContent = "已复制";
|
||
setTimeout(function () { document.getElementById("btn-copy-reveal").textContent = "复制"; }, 1200);
|
||
});
|
||
|
||
window.revokeToken = function (id) {
|
||
openConfirm("确认吊销此 Token?吊销后使用该 Token 的服务将无法继续访问。", async function () {
|
||
try {
|
||
await API.revokeToken(id);
|
||
toast("Token 已吊销", "success");
|
||
loadTokens();
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
});
|
||
};
|
||
|
||
window.confirmDeleteToken = function (id, name) {
|
||
openConfirm("确认删除 Token「" + name + "」?", async function () {
|
||
try {
|
||
await API.deleteToken(id);
|
||
toast("Token 已删除", "success");
|
||
loadTokens();
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
});
|
||
}
|
||
|
||
// ========== 初始化 ==========
|
||
|
||
async function init() {
|
||
try {
|
||
var data = await API.getMe();
|
||
currentUser = data;
|
||
} catch (e) {
|
||
window.location.href = "/login.html";
|
||
return;
|
||
}
|
||
if (currentUser.role !== "admin") {
|
||
window.location.href = "/projects.html";
|
||
return;
|
||
}
|
||
document.getElementById("user-name").textContent = currentUser.displayName || currentUser.username;
|
||
document.getElementById("status-user").textContent = currentUser.username;
|
||
await Promise.all([loadUsers(), loadTokens()]);
|
||
}
|
||
|
||
document.getElementById("logout-link").addEventListener("click", async function (e) {
|
||
e.preventDefault();
|
||
try { await API.logout(); } catch (e) {}
|
||
localStorage.removeItem("registry_token");
|
||
localStorage.removeItem("registry_user");
|
||
window.location.href = "/login.html";
|
||
});
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|