feat: 账号认证、用户管理与 API Token 管理
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 同步
This commit is contained in:
jif2.zhang 2026-09-11 17:12:37 +08:00
parent 963046edd9
commit 1765caba50
16 changed files with 2132 additions and 141 deletions

2
.gitignore vendored
View File

@ -3,5 +3,7 @@ data/projects.json
data/system.json
data/configs/*.json
data/mcp.json
data/users.json
data/api-tokens.json
*.log
.reasonix/*

126
docs/原型设计.md Normal file
View File

@ -0,0 +1,126 @@
# 页面实现文档:登录 / 项目列表 / 系统管理
## 1. 页面流程
```mermaid
flowchart TD
A[登录页 login.html] -->|登录成功| B[项目列表页 projects.html]
B -->|点击项目卡片| D[配置管理页 index.html]
B -->|管理员点击「系统管理」| C[系统管理 admin.html]
C -->|返回项目列表| B
A -.->|未登录访问任意页面| B
B -.->|token 过期 API 返回 401| A
```
## 2. 认证机制
- 登录成功返回 `{ token, user }`,前端存入 `localStorage`
- 所有 API 请求自动携带 `Authorization: Bearer <token>` 头。
- 服务端使用内存 session`src/routes/auth.js`TTL 24 小时。
- API 返回 401 时前端自动清理凭证并跳转登录页。
- **首次启动自动创建默认管理员**`admin / admin123`(密码经 scrypt 加盐哈希,存于 `data/users.json`)。
| 角色 | 项目列表 | 进入配置 | 系统管理 |
|------|---------|---------|---------|
| 管理员 (admin) | ✓ | ✓ | ✓ |
| 普通用户 (user) | ✓ | ✓ | ✗(入口隐藏 + 后端 403 |
## 3. 页面清单public/ 目录)
| 文件 | 说明 |
|------|------|
| `public/login.html` | 登录页,居中卡片,深蓝渐变背景 |
| `public/projects.html` | 项目列表页,卡片网格 + 搜索 + 新建/删除项目 |
| `public/admin.html` | 系统管理页,用户管理 + Token 管理两个 Tab |
| `public/index.html` | 配置管理页(原有),标题栏增加用户信息/系统管理/退出入口 |
## 4. 新增 API
### 4.1 认证
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/auth/login` | 登录Body: `{ username, password }`,返回 `{ token, user }` |
| POST | `/api/auth/logout` | 注销,销毁 session |
| GET | `/api/auth/me` | 获取当前登录用户 |
### 4.2 用户管理(需管理员)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/users` | 用户列表(不含密码字段) |
| POST | `/api/users` | 新增用户 `{ username, displayName, password, role, enabled }` |
| PUT | `/api/users/:id` | 编辑 `{ displayName, role, enabled }` |
| PUT | `/api/users/:id/password` | 重置密码 `{ password }` |
| DELETE | `/api/users/:id` | 删除用户 |
### 4.3 Token 管理(需管理员)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/tokens` | Token 列表 |
| POST | `/api/tokens` | 生成 `{ name, projectId, permission, expiresInDays }`,返回含明文 value |
| PUT | `/api/tokens/:id` | 编辑 `{ name, projectId, permission, enabled }` |
| PUT | `/api/tokens/:id/revoke` | 吊销 |
| DELETE | `/api/tokens/:id` | 删除 |
## 5. 数据模型
`data/` 目录扩展:
```
data/
├── users.json # 用户表password 为 salt:hash
└── api-tokens.json # API Token 表reg_ 前缀MCP token 仍由 mcp.json 管理)
```
### 用户 (User)
```json
{
"id": "a1b2c3d4",
"username": "admin",
"displayName": "系统管理员",
"password": "salt:scrypt-hash",
"role": "admin",
"enabled": true,
"createdAt": "2026-09-11T08:00:00.000Z",
"updatedAt": "2026-09-11T08:00:00.000Z",
"lastLoginAt": "2026-09-11T09:00:00.000Z"
}
```
### API Token
```json
{
"id": "x1y2z3a4",
"name": "CI 自动发布",
"projectId": "proj-001",
"permission": "readwrite",
"value": "reg_<hex>",
"enabled": true,
"createdAt": "2026-09-11T08:00:00.000Z",
"expiresAt": "2026-12-10T08:00:00.000Z"
}
```
## 6. 目录结构变更
```
src/
├── server.js # 挂载 auth/users/tokens 路由 + 全局认证中间件
├── routes/
│ ├── auth.js # 登录/登出/me + session 管理
│ ├── users.js # 用户管理(管理员)
│ └── tokens.js # Token 管理(管理员)
└── services/
├── userService.js # 用户业务逻辑 + scrypt 密码哈希
└── tokenService.js # Token 业务逻辑
public/
├── login.html # 登录页
├── projects.html # 项目列表页
├── admin.html # 系统管理页
└── js/api.js # 新增 auth/user/token 接口封装
```

View File

@ -60,7 +60,7 @@
## 3. 非功能需求
- **NFR-1**技术栈Node.js + Express 后端,原生 HTML/CSS/JS 前端
- **NFR-2**:后续对接 SSO 单点登录,当前版本暂不实现认证
- **NFR-2**提供登录认证(用户名/密码 + Bearer session token用户分为管理员含系统管理权限与普通用户后续对接 SSO 单点登录
- **NFR-3**:界面响应式,支持主流浏览器
- **NFR-4**:配置数据实时写入持久化存储,保证数据一致性

667
public/admin.html Normal file
View File

@ -0,0 +1,667 @@
<!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, "&#39;") + ")'>编辑</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>

View File

@ -632,11 +632,56 @@ body.mode-readonly .row-btn:not(.view) {
color: #fff;
}
.titlebar .current-project {
font-size: 13px;
color: #cfe3f7;
padding-left: 14px;
border-left: 1px solid rgba(255, 255, 255, 0.3);
.titlebar .current-project {
font-size: 13px;
color: #cfe3f7;
padding-left: 14px;
border-left: 1px solid rgba(255, 255, 255, 0.3);
}
/* ===== 标题栏右侧:用户信息与入口 ===== */
.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;
}
/* ===== 工具栏(分组处理) ===== */

View File

@ -7,10 +7,19 @@
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<!-- 标题栏:仅展示应用名与当前项目 -->
<header class="titlebar">
<span class="app-title">📋 Registry 配置管理中心</span>
<span class="current-project" id="current-project-name">未选择项目</span>
<!-- 标题栏:仅展示应用名与当前项目 -->
<header class="titlebar">
<span class="app-title">📋 Registry 配置管理中心</span>
<span class="current-project" id="current-project-name">未选择项目</span>
<div class="titlebar-right">
<span class="titlebar-user">
👤 <span id="user-name"></span>
<span class="role-badge" id="user-role"></span>
</span>
<a class="titlebar-link" id="admin-entry" href="/admin.html" style="display:none">⚙ 系统管理</a>
<a class="titlebar-link" href="/projects.html">项目列表</a>
<a class="titlebar-link" id="logout-link" href="#">退出登录</a>
</div>
</header>
<!-- 工具栏:分组处理(项目 / 模式 / 配置 / 数据) -->
@ -312,8 +321,33 @@
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/tree.js"></script>
<script src="/js/api.js"></script>
<script src="/js/tree.js"></script>
<script>
// 认证初始化:检查登录状态,填充标题栏用户信息
(async function initAuth() {
try {
var user = await API.getMe();
document.getElementById("user-name").textContent = user.displayName || user.username;
var roleEl = document.getElementById("user-role");
if (user.role === "admin") {
roleEl.textContent = "管理员";
roleEl.className = "role-badge admin";
document.getElementById("admin-entry").style.display = "";
}
} catch (e) {
window.location.href = "/login.html";
return;
}
})();
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";
});
</script>
<script src="/js/app.js"></script>
</body>
</html>

View File

@ -1,129 +1,145 @@
/**
* API 调用封装
* 统一处理与后端 REST 接口的交互返回解析后的数据或抛出错误
*/
const API = {
/**
* 通用请求方法
* @param {string} url - 请求路径
* @param {Object} options - fetch 选项
* @returns {Promise<any>} 响应数据
*/
async request(url, options = {}) {
const opts = {
headers: { "Content-Type": "application/json" },
...options,
};
if (opts.body && typeof opts.body !== "string") {
opts.body = JSON.stringify(opts.body);
}
const res = await fetch(url, opts);
const json = await res.json();
if (!json.success) {
throw new Error(json.message || "请求失败");
}
return json.data;
},
/**
* 获取所有项目
* @returns {Promise<Array>} 项目列表
*/
getProjects() {
return this.request("/api/projects");
},
/**
* 获取单个项目
* @param {string} id - 项目 ID
* @returns {Promise<Object>} 项目对象
*/
getProject(id) {
return this.request(`/api/projects/${id}`);
},
/**
* 创建项目
* @param {Object} data - 项目数据
* @returns {Promise<Object>} 创建的项目
*/
createProject(data) {
return this.request("/api/projects", { method: "POST", body: data });
},
/**
* 删除项目
* @param {string} id - 项目 ID
* @returns {Promise<void>}
*/
deleteProject(id) {
return this.request(`/api/projects/${id}`, { method: "DELETE" });
},
/**
* 更新项目维度映射方式
* @param {string} id - 项目 ID
* @param {Object} mappings - { idcMapping, environmentMapping, groupMapping }
* @returns {Promise<Object>} 更新后的项目
*/
updateMappings(id, mappings) {
return this.request(`/api/projects/${id}/mappings`, { method: "PUT", body: mappings });
},
/**
* 获取项目下所有配置
* @param {string} projectId - 项目 ID
* @returns {Promise<Array>} 配置项列表
*/
getConfigs(projectId) {
return this.request(`/api/projects/${projectId}/configs`);
},
/**
* 新增配置项
* @param {string} projectId - 项目 ID
* @param {Object} data - 配置项数据
* @returns {Promise<Object>} 创建的配置项
*/
createConfig(projectId, data) {
return this.request(`/api/projects/${projectId}/configs`, { method: "POST", body: data });
},
/**
* 更新配置项
* @param {string} projectId - 项目 ID
* @param {string} configId - 配置项 ID
* @param {Object} data - 要更新的字段
* @returns {Promise<Object>} 更新后的配置项
*/
updateConfig(projectId, configId, data) {
return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "PUT", body: data });
},
/**
* 删除配置项
* @param {string} projectId - 项目 ID
* @param {string} configId - 配置项 ID
* @returns {Promise<void>}
*/
deleteConfig(projectId, configId) {
return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "DELETE" });
},
/**
* 获取当前模式
* @returns {Promise<Object>} { mode }
*/
getMode() {
return this.request("/api/mode");
},
/**
* 切换模式
* @param {string} mode - 目标模式plan/edit/readonly
* @returns {Promise<Object>} { mode }
*/
setMode(mode) {
return this.request("/api/mode", { method: "PUT", body: { mode } });
},
};
/**
* API 调用封装
* 统一处理与后端 REST 接口的交互返回解析后的数据或抛出错误
*/
const API = {
/**
* 通用请求方法
* @param {string} url - 请求路径
* @param {Object} options - fetch 选项
* @returns {Promise<any>} 响应数据
*/
async request(url, options = {}) {
const headers = { "Content-Type": "application/json" };
// 自动携带认证 token
const token = localStorage.getItem("registry_token");
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const opts = { headers, ...options };
if (opts.body && typeof opts.body !== "string") {
opts.body = JSON.stringify(opts.body);
}
const res = await fetch(url, opts);
// 401 未登录,跳转登录页
if (res.status === 401 && !url.includes("/api/auth/")) {
localStorage.removeItem("registry_token");
localStorage.removeItem("registry_user");
window.location.href = "/login.html";
throw new Error("登录已过期,请重新登录");
}
const json = await res.json();
if (!json.success) {
throw new Error(json.message || "请求失败");
}
return json.data;
},
// ========== 认证 ==========
login(username, password) {
return this.request("/api/auth/login", { method: "POST", body: { username, password } });
},
logout() {
return this.request("/api/auth/logout", { method: "POST" });
},
getMe() {
return this.request("/api/auth/me");
},
// ========== 项目 ==========
getProjects() {
return this.request("/api/projects");
},
getProject(id) {
return this.request(`/api/projects/${id}`);
},
createProject(data) {
return this.request("/api/projects", { method: "POST", body: data });
},
deleteProject(id) {
return this.request(`/api/projects/${id}`, { method: "DELETE" });
},
updateMappings(id, mappings) {
return this.request(`/api/projects/${id}/mappings`, { method: "PUT", body: mappings });
},
// ========== 配置 ==========
getConfigs(projectId) {
return this.request(`/api/projects/${projectId}/configs`);
},
createConfig(projectId, data) {
return this.request(`/api/projects/${projectId}/configs`, { method: "POST", body: data });
},
updateConfig(projectId, configId, data) {
return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "PUT", body: data });
},
deleteConfig(projectId, configId) {
return this.request(`/api/projects/${projectId}/configs/${configId}`, { method: "DELETE" });
},
// ========== 模式 ==========
getMode() {
return this.request("/api/mode");
},
setMode(mode) {
return this.request("/api/mode", { method: "PUT", body: { mode } });
},
// ========== 用户管理 ==========
getUsers() {
return this.request("/api/users");
},
createUser(data) {
return this.request("/api/users", { method: "POST", body: data });
},
updateUser(id, data) {
return this.request(`/api/users/${id}`, { method: "PUT", body: data });
},
resetPassword(id, password) {
return this.request(`/api/users/${id}/password`, { method: "PUT", body: { password } });
},
deleteUser(id) {
return this.request(`/api/users/${id}`, { method: "DELETE" });
},
// ========== Token 管理 ==========
getTokens() {
return this.request("/api/tokens");
},
createToken(data) {
return this.request("/api/tokens", { method: "POST", body: data });
},
updateToken(id, data) {
return this.request(`/api/tokens/${id}`, { method: "PUT", body: data });
},
revokeToken(id) {
return this.request(`/api/tokens/${id}/revoke`, { method: "PUT" });
},
deleteToken(id) {
return this.request(`/api/tokens/${id}`, { method: "DELETE" });
},
};

95
public/login.html Normal file
View File

@ -0,0 +1,95 @@
<!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>
body.login-page {
background: linear-gradient(135deg, #003a70 0%, #0a4a86 48%, #0b63a8 100%);
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
.login-wrap { width: 400px; max-width: calc(100vw - 32px); }
.login-card { background: #fff; border-radius: 8px; box-shadow: 0 18px 50px rgba(0,0,0,0.35); overflow: hidden; }
.login-card-header { background: linear-gradient(to bottom, #0a4a86, #003a70); color: #fff; padding: 28px 24px 22px; text-align: center; }
.login-logo { font-size: 36px; line-height: 1; }
.login-card-header h1 { font-size: 18px; font-weight: 600; margin-top: 10px; }
.login-card-header p { font-size: 12px; color: #cfe3f7; margin-top: 4px; }
.login-card-body { padding: 24px; }
.login-card-body .form-group { margin-bottom: 16px; }
.login-card-body .form-group input { height: 36px; }
.login-error { display: none; margin-bottom: 14px; padding: 8px 12px; border: 1px solid #f2b8b5; background: #fdecea; color: #c62828; border-radius: 3px; font-size: 12px; }
.login-error.show { display: block; }
.login-btn { width: 100%; height: 38px; font-size: 14px; font-weight: 600; }
.login-footer { margin-top: 16px; text-align: center; font-size: 11px; color: rgba(255,255,255,0.65); }
</style>
</head>
<body class="login-page">
<div class="login-wrap">
<div class="login-card">
<div class="login-card-header">
<div class="login-logo">📋</div>
<h1>Registry 配置管理中心</h1>
<p>微服务配置集中化 · 可视化 · 规范化管理</p>
</div>
<div class="login-card-body">
<div class="login-error" id="login-error"></div>
<form id="login-form" autocomplete="off">
<div class="form-group">
<label>用户名 <span class="required">*</span></label>
<input type="text" id="username" placeholder="请输入用户名" autofocus>
</div>
<div class="form-group">
<label>密码 <span class="required">*</span></label>
<input type="password" id="password" placeholder="请输入密码">
</div>
<button type="submit" class="btn btn-primary login-btn" id="btn-login">登 录</button>
</form>
</div>
</div>
<div class="login-footer">Registry Config Center</div>
</div>
<script>
// 已登录则直接跳转
if (localStorage.getItem("registry_token")) {
window.location.href = "/projects.html";
}
document.getElementById("login-form").addEventListener("submit", async function (e) {
e.preventDefault();
var error = document.getElementById("login-error");
var btn = document.getElementById("btn-login");
var username = document.getElementById("username").value.trim();
var password = document.getElementById("password").value;
error.classList.remove("show");
if (!username || !password) {
error.textContent = "请输入用户名和密码";
error.classList.add("show");
return;
}
btn.disabled = true;
btn.textContent = "登录中...";
try {
var data = await API.login(username, password);
localStorage.setItem("registry_token", data.token);
localStorage.setItem("registry_user", JSON.stringify(data.user));
window.location.href = "/projects.html";
} catch (err) {
error.textContent = err.message || "登录失败";
error.classList.add("show");
btn.disabled = false;
btn.textContent = "登 录";
}
});
</script>
<script src="/js/api.js"></script>
</body>
</html>

315
public/projects.html Normal file
View File

@ -0,0 +1,315 @@
<!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; }
/* 内容区 */
.page-content { flex: 1; overflow: auto; padding: 18px; }
.page-heading { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px; }
.page-heading h2 { font-size: 16px; color: #003a70; }
.page-heading .sub { font-size: 12px; color: #888; }
.content-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
.content-toolbar .spacer { flex: 1; }
.search-input { height: 30px; width: 240px; 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; }
/* 项目卡片 */
.projects-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
.project-card { background: #fff; border: 1px solid #dcdcdc; border-radius: 6px; padding: 14px 16px 12px; cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s; display: flex; flex-direction: column; }
.project-card:hover { border-color: #5b9bd5; box-shadow: 0 4px 14px rgba(0,120,212,0.15); transform: translateY(-1px); }
.project-card-top { display: flex; align-items: flex-start; gap: 10px; }
.project-icon { width: 34px; height: 34px; border-radius: 6px; background: #e5f1fb; color: #0078d4; display: flex; align-items: center; justify-content: center; font-size: 17px; flex-shrink: 0; }
.project-card-title { font-size: 15px; font-weight: 600; color: #003a70; line-height: 1.3; }
.project-card-id { font-family: Consolas, monospace; font-size: 11px; color: #999; margin-top: 2px; }
.project-card-desc { margin-top: 10px; font-size: 12px; color: #666; line-height: 1.5; min-height: 36px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.project-card-badges { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
.dim-chip { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; background: #eef4fb; color: #0a4a86; border: 1px solid #d0dced; }
.project-card-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; padding-top: 10px; border-top: 1px solid #f0f0f0; font-size: 11px; color: #999; }
.project-card-actions { display: flex; gap: 6px; opacity: 0; transition: opacity 0.15s; }
.project-card:hover .project-card-actions { opacity: 1; }
.empty-state { text-align: center; padding: 60px 20px; color: #999; }
.empty-state .empty-icon { font-size: 36px; opacity: 0.4; }
.empty-state p { margin-top: 8px; font-size: 13px; }
</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" id="user-role"></span>
</span>
<a class="titlebar-link" id="admin-entry" href="/admin.html" style="display:none">⚙ 系统管理</a>
<a class="titlebar-link" id="logout-link" href="#">退出登录</a>
</div>
</header>
<div class="toolbar">
<div class="tool-group">
<span class="group-label">项目</span>
<button class="toolbar-btn" id="btn-refresh" title="刷新列表">🔄 刷新</button>
<button class="toolbar-btn" id="btn-new-project" title="新建项目">📁 新建</button>
</div>
<div class="tool-group">
<span class="group-label" id="project-count">共 0 个项目</span>
</div>
</div>
<div class="page-content">
<div class="page-heading">
<h2>我的项目</h2>
<span class="sub">选择一个项目进入配置管理</span>
</div>
<div class="content-toolbar">
<input type="text" class="search-input" id="project-search" placeholder="🔍 搜索项目名称或描述">
<span class="spacer"></span>
<button class="btn btn-primary" style="height:30px" id="btn-new-project-2"> 新建项目</button>
</div>
<div class="projects-grid" id="projects-grid"></div>
</div>
<footer class="statusbar">
<span id="status-text">加载中...</span>
<span id="status-user"></span>
</footer>
<!-- 新建项目模态框(复用 index.html 中的结构) -->
<div class="modal-overlay" id="modal-project" style="display:none">
<div class="modal">
<div class="modal-header">
<h3>新建项目</h3>
<button class="modal-close" onclick="closeModal('modal-project')"></button>
</div>
<div class="modal-body">
<div class="form-group">
<label>项目名称 <span class="required">*</span></label>
<input type="text" id="project-name" placeholder="例如:用户服务">
</div>
<div class="form-group">
<label>项目描述</label>
<textarea id="project-description" rows="2" placeholder="项目配置说明"></textarea>
</div>
<div class="form-group">
<label>维度配置 <span class="hint">(创建后不可修改)</span></label>
<div class="dim-config-row">
<label class="checkbox-label"><input type="checkbox" id="project-enable-idc" checked> 机房</label>
<select id="project-idc-mapping" class="mapping-select"><option value="path">映射为路径</option><option value="label">仅作标签</option></select>
</div>
<div class="dim-config-row">
<label class="checkbox-label"><input type="checkbox" id="project-enable-env" checked> 环境</label>
<select id="project-env-mapping" class="mapping-select"><option value="path">映射为路径</option><option value="label">仅作标签</option></select>
</div>
<div class="dim-config-row">
<label class="checkbox-label"><input type="checkbox" id="project-enable-group" checked> 分组</label>
<select id="project-group-mapping" class="mapping-select"><option value="path">映射为路径</option><option value="label">仅作标签</option></select>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('modal-project')">取消</button>
<button class="btn btn-primary" id="btn-save-project">保存</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"; }
function closeModal(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;
});
// 初始化
async function init() {
try {
var data = await API.getMe();
currentUser = data;
} catch (e) {
window.location.href = "/login.html";
return;
}
document.getElementById("user-name").textContent = currentUser.displayName || currentUser.username;
var roleEl = document.getElementById("user-role");
if (currentUser.role === "admin") {
roleEl.textContent = "管理员";
roleEl.className = "role-badge admin";
document.getElementById("admin-entry").style.display = "";
}
document.getElementById("status-user").textContent = currentUser.username;
await loadProjects();
}
async function loadProjects() {
try {
var projects = await API.getProjects();
renderProjects(projects);
} catch (err) {
toast("加载项目失败: " + err.message, "error");
}
}
function renderProjects(projects) {
var grid = document.getElementById("projects-grid");
if (!projects.length) {
grid.innerHTML = '<div class="empty-state"><div class="empty-icon">📁</div><p>暂无项目,点击「新建项目」创建第一个</p></div>';
document.getElementById("project-count").textContent = "共 0 个项目";
document.getElementById("status-text").textContent = "已加载 0 个项目";
return;
}
var dims = [];
grid.innerHTML = projects.map(function (p) {
dims = [];
if (p.enableIdc) dims.push("🏢 机房");
if (p.enableEnvironment) dims.push("🌐 环境");
if (p.enableGroup) dims.push("🗂 分组");
var badges = dims.map(function (d) { return '<span class="dim-chip">' + d + "</span>"; }).join("");
var time = p.updatedAt ? new Date(p.updatedAt).toLocaleDateString("zh-CN") : "";
return '<div class="project-card" data-id="' + p.id + '" onclick="openProject(\'' + p.id + "')\">" +
'<div class="project-card-top">' +
'<div class="project-icon">📦</div>' +
'<div><div class="project-card-title">' + esc(p.name) + '</div>' +
'<div class="project-card-id">' + esc(p.id) + "</div></div>" +
"</div>" +
'<div class="project-card-desc">' + esc(p.description || "") + "</div>" +
'<div class="project-card-badges">' + badges + "</div>" +
'<div class="project-card-footer">' +
"<span>" + time + "</span>" +
'<div class="project-card-actions">' +
'<button class="row-btn" onclick="event.stopPropagation();openProject(\'' + p.id + "')\">进入</button>" +
'<button class="row-btn danger" onclick="event.stopPropagation();confirmDelete(\'' + p.id + "', '" + esc(p.name) + "')\">删除</button>" +
"</div>" +
"</div>" +
"</div>";
}).join("");
document.getElementById("project-count").textContent = "共 " + projects.length + " 个项目";
document.getElementById("status-text").textContent = "已加载 " + projects.length + " 个项目";
}
function esc(s) {
var d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function openProject(id) {
window.location.href = "/index.html?project=" + encodeURIComponent(id);
}
function confirmDelete(id, name) {
openConfirm("确认删除项目「" + name + "」?此操作将同时删除该项目下的所有配置,且不可恢复。", async function () {
try {
await API.deleteProject(id);
toast("项目已删除", "success");
loadProjects();
} catch (err) {
toast("删除失败: " + err.message, "error");
}
});
}
// 新建项目
document.getElementById("btn-new-project").addEventListener("click", function () { openModal("modal-project"); });
document.getElementById("btn-new-project-2").addEventListener("click", function () { openModal("modal-project"); });
document.getElementById("btn-save-project").addEventListener("click", async function () {
var name = document.getElementById("project-name").value.trim();
var desc = document.getElementById("project-description").value.trim();
if (!name) { toast("请输入项目名称", "error"); return; }
try {
await API.createProject({
name: name,
description: desc,
enableIdc: document.getElementById("project-enable-idc").checked,
enableEnvironment: document.getElementById("project-enable-env").checked,
enableGroup: document.getElementById("project-enable-group").checked,
idcMapping: document.getElementById("project-idc-mapping").value,
environmentMapping: document.getElementById("project-env-mapping").value,
groupMapping: document.getElementById("project-group-mapping").value,
});
closeModal("modal-project");
document.getElementById("project-name").value = "";
document.getElementById("project-description").value = "";
toast("项目创建成功", "success");
loadProjects();
} catch (err) {
toast("创建失败: " + err.message, "error");
}
});
// 搜索
document.getElementById("project-search").addEventListener("input", function () {
var kw = this.value.trim().toLowerCase();
var cards = document.querySelectorAll(".project-card");
var visible = 0;
cards.forEach(function (card) {
var text = card.textContent.toLowerCase();
var show = !kw || text.indexOf(kw) !== -1;
card.style.display = show ? "" : "none";
if (show) visible++;
});
document.getElementById("project-count").textContent = "共 " + visible + " 个项目";
});
// 退出登录
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";
});
document.getElementById("btn-refresh").addEventListener("click", loadProjects);
init();
</script>
</body>
</html>

View File

@ -11,6 +11,8 @@ const DATA_DIR = path.join(__dirname, "..", "..", "data");
const PROJECTS_FILE = path.join(DATA_DIR, "projects.json");
const SYSTEM_FILE = path.join(DATA_DIR, "system.json");
const MCP_FILE = path.join(DATA_DIR, "mcp.json");
const USERS_FILE = path.join(DATA_DIR, "users.json");
const API_TOKENS_FILE = path.join(DATA_DIR, "api-tokens.json");
const CONFIGS_DIR = path.join(DATA_DIR, "configs");
/**
@ -29,6 +31,12 @@ function ensureDataDir() {
if (!fs.existsSync(SYSTEM_FILE)) {
fs.writeFileSync(SYSTEM_FILE, JSON.stringify({ mode: "edit" }, null, 2), "utf-8");
}
if (!fs.existsSync(USERS_FILE)) {
fs.writeFileSync(USERS_FILE, "[]", "utf-8");
}
if (!fs.existsSync(API_TOKENS_FILE)) {
fs.writeFileSync(API_TOKENS_FILE, "[]", "utf-8");
}
}
/**
@ -140,6 +148,42 @@ function saveMcpConfig(mcp) {
writeJsonFile(MCP_FILE, mcp);
}
/**
* 加载用户列表
* @returns {Array}
*/
function loadUsers() {
ensureDataDir();
return readJsonFile(USERS_FILE) || [];
}
/**
* 保存用户列表
* @param {Array} users
*/
function saveUsers(users) {
ensureDataDir();
writeJsonFile(USERS_FILE, users);
}
/**
* 加载 API Token 列表
* @returns {Array}
*/
function loadApiTokens() {
ensureDataDir();
return readJsonFile(API_TOKENS_FILE) || [];
}
/**
* 保存 API Token 列表
* @param {Array} tokens
*/
function saveApiTokens(tokens) {
ensureDataDir();
writeJsonFile(API_TOKENS_FILE, tokens);
}
module.exports = {
loadProjects,
saveProjects,
@ -150,4 +194,8 @@ module.exports = {
saveSystem,
loadMcpConfig,
saveMcpConfig,
loadUsers,
saveUsers,
loadApiTokens,
saveApiTokens,
};

114
src/routes/auth.js Normal file
View File

@ -0,0 +1,114 @@
/**
* 认证路由
* POST /api/auth/login - 登录
* POST /api/auth/logout - 登出
* GET /api/auth/me - 获取当前登录用户
*/
const express = require("express");
const router = express.Router();
const userService = require("../services/userService");
// 内存 session 存储token -> { user, expiresAt }
const sessions = new Map();
const SESSION_TTL = 24 * 60 * 60 * 1000; // 24 小时
/**
* 清理过期 session
*/
function cleanExpiredSessions() {
const now = Date.now();
for (const [token, session] of sessions) {
if (session.expiresAt < now) sessions.delete(token);
}
}
/**
* 创建 session
*/
function createSession(user) {
cleanExpiredSessions();
const token = userService.generateToken(32);
sessions.set(token, {
user,
expiresAt: Date.now() + SESSION_TTL,
});
return token;
}
/**
* 获取 session 中的用户
*/
function getSession(token) {
if (!token) return null;
const session = sessions.get(token);
if (!session) return null;
if (session.expiresAt < Date.now()) {
sessions.delete(token);
return null;
}
// 续期
session.expiresAt = Date.now() + SESSION_TTL;
return session.user;
}
/**
* 销毁 session
*/
function destroySession(token) {
if (token) sessions.delete(token);
}
/**
* POST /api/auth/login
* Body: { username, password }
*/
router.post("/login", (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ success: false, message: "用户名和密码不能为空" });
}
const user = userService.authenticate(username, password);
if (!user) {
return res.status(401).json({ success: false, message: "用户名或密码错误" });
}
const token = createSession(user);
res.json({ success: true, data: { token, user } });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
/**
* POST /api/auth/logout
* Header: Authorization: Bearer <token>
*/
router.post("/logout", (req, res) => {
const token = extractToken(req);
destroySession(token);
res.json({ success: true, message: "已退出登录" });
});
/**
* GET /api/auth/me
* Header: Authorization: Bearer <token>
*/
router.get("/me", (req, res) => {
const token = extractToken(req);
const user = getSession(token);
if (!user) {
return res.status(401).json({ success: false, message: "未登录或登录已过期" });
}
res.json({ success: true, data: user });
});
/**
* 从请求头提取 Bearer token
*/
function extractToken(req) {
const auth = req.headers.authorization || "";
return auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
}
module.exports = { router, getSession, extractToken };

88
src/routes/tokens.js Normal file
View File

@ -0,0 +1,88 @@
/**
* API Token 管理路由需管理员权限
* GET /api/tokens - Token 列表
* POST /api/tokens - 生成 Token
* PUT /api/tokens/:id - 编辑 Token
* PUT /api/tokens/:id/revoke - 吊销 Token
* DELETE /api/tokens/:id - 删除 Token
*/
const express = require("express");
const router = express.Router();
const tokenService = require("../services/tokenService");
/**
* 管理员权限校验中间件
*/
function requireAdmin(req, res, next) {
if (!req.user || req.user.role !== "admin") {
return res.status(403).json({ success: false, message: "需要管理员权限" });
}
next();
}
router.use(requireAdmin);
/**
* GET /api/tokens
*/
router.get("/", (req, res) => {
try {
res.json({ success: true, data: tokenService.getAllTokens() });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
/**
* POST /api/tokens
* Body: { name, projectId, permission, expiresInDays }
*/
router.post("/", (req, res) => {
try {
const token = tokenService.createToken(req.body);
res.status(201).json({ success: true, data: token });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
}
});
/**
* PUT /api/tokens/:id
* Body: { name, projectId, permission, enabled }
*/
router.put("/:id", (req, res) => {
try {
const token = tokenService.updateToken(req.params.id, req.body);
res.json({ success: true, data: token });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
}
});
/**
* PUT /api/tokens/:id/revoke
*/
router.put("/:id/revoke", (req, res) => {
try {
const ok = tokenService.revokeToken(req.params.id);
if (!ok) return res.status(404).json({ success: false, message: "Token 不存在" });
res.json({ success: true, message: "Token 已吊销" });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
/**
* DELETE /api/tokens/:id
*/
router.delete("/:id", (req, res) => {
try {
const ok = tokenService.deleteToken(req.params.id);
if (!ok) return res.status(404).json({ success: false, message: "Token 不存在" });
res.json({ success: true, message: "Token 已删除" });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;

89
src/routes/users.js Normal file
View File

@ -0,0 +1,89 @@
/**
* 用户管理路由需管理员权限
* GET /api/users - 用户列表
* POST /api/users - 新增用户
* PUT /api/users/:id - 编辑用户
* PUT /api/users/:id/password - 重置密码
* DELETE /api/users/:id - 删除用户
*/
const express = require("express");
const router = express.Router();
const userService = require("../services/userService");
/**
* 管理员权限校验中间件
*/
function requireAdmin(req, res, next) {
// 此中间件依赖 auth 中间件已挂载到 req.user
if (!req.user || req.user.role !== "admin") {
return res.status(403).json({ success: false, message: "需要管理员权限" });
}
next();
}
router.use(requireAdmin);
/**
* GET /api/users
*/
router.get("/", (req, res) => {
try {
res.json({ success: true, data: userService.getAllUsers() });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
/**
* POST /api/users
* Body: { username, displayName, password, role, enabled }
*/
router.post("/", (req, res) => {
try {
const user = userService.createUser(req.body);
res.status(201).json({ success: true, data: user });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
}
});
/**
* PUT /api/users/:id
* Body: { displayName, role, enabled }
*/
router.put("/:id", (req, res) => {
try {
const user = userService.updateUser(req.params.id, req.body);
res.json({ success: true, data: user });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
}
});
/**
* PUT /api/users/:id/password
* Body: { password }
*/
router.put("/:id/password", (req, res) => {
try {
userService.resetPassword(req.params.id, req.body.password);
res.json({ success: true, message: "密码已重置" });
} catch (err) {
res.status(400).json({ success: false, message: err.message });
}
});
/**
* DELETE /api/users/:id
*/
router.delete("/:id", (req, res) => {
try {
const ok = userService.deleteUser(req.params.id);
if (!ok) return res.status(404).json({ success: false, message: "用户不存在" });
res.json({ success: true, message: "用户已删除" });
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
module.exports = router;

View File

@ -6,7 +6,11 @@ const express = require("express");
const path = require("path");
const projectRoutes = require("./routes/projects");
const configRoutes = require("./routes/configs");
const { router: authRoutes, getSession, extractToken } = require("./routes/auth");
const userRoutes = require("./routes/users");
const tokenRoutes = require("./routes/tokens");
const { loadSystem, saveSystem } = require("./persistence/jsonAdapter");
const userService = require("./services/userService");
// MCP 服务Model Context Protocol供 AI 自动操作)
const mcpRoutes = require("./mcp");
@ -17,16 +21,34 @@ const PORT = process.env.PORT || 3000;
// 解析 JSON 请求体
app.use(express.json());
// 初始化默认管理员账号(首次启动时自动创建)
userService.ensureDefaultAdmin();
// 全局认证中间件:解析 Bearer token将用户信息挂载到 req.user非强制
app.use((req, res, next) => {
const token = extractToken(req);
req.user = getSession(token) || null;
next();
});
// 静态资源服务(禁用缓存,避免浏览器加载到旧版本页面/脚本)
app.use(express.static(path.join(__dirname, "..", "public"), { etag: false, lastModified: false, cacheControl: false, setHeaders: (res) => res.setHeader("Cache-Control", "no-store") }));
// API 路由
app.use("/api/auth", authRoutes);
app.use("/api/projects", projectRoutes);
app.use("/api/projects/:projectId/configs", configRoutes);
app.use("/api/users", userRoutes);
app.use("/api/tokens", tokenRoutes);
// MCP 服务端点Streamable HTTP 传输)
app.use("/mcp", mcpRoutes);
// 根路径重定向到登录页
app.get("/", (req, res) => {
res.redirect("/login.html");
});
/**
* GET /api/mode
* 获取当前系统模式

View File

@ -0,0 +1,124 @@
/**
* API Token 服务
* 负责 API 访问令牌的增删改查与吊销持久化到 data/api-tokens.json
* MCP token 独立MCP token data/mcp.json 管理
*/
const { loadApiTokens, saveApiTokens } = require("../persistence/jsonAdapter");
const { generateToken } = require("./userService");
/**
* 获取所有 Token完整值供列表展示
* @returns {Array}
*/
function getAllTokens() {
return loadApiTokens();
}
/**
* ID 查找 Token
* @param {string} id
* @returns {Object|null}
*/
function findById(id) {
return loadApiTokens().find((t) => t.id === id) || null;
}
/**
* 创建 Token
* @param {Object} data - { name, projectId, permission, expiresInDays }
* @returns {Object} 创建的 Token含明文 value
*/
function createToken(data) {
const tokens = loadApiTokens();
if (!data.name || !data.name.trim()) {
throw new Error("Token 名称不能为空");
}
const tokenValue = `reg_${generateToken(24)}`;
const now = new Date();
let expiresAt = null;
if (data.expiresInDays && data.expiresInDays > 0) {
expiresAt = new Date(now.getTime() + data.expiresInDays * 86400000).toISOString();
}
const token = {
id: generateToken(8),
name: data.name.trim(),
projectId: data.projectId || "all",
permission: data.permission === "readwrite" ? "readwrite" : "readonly",
value: tokenValue,
enabled: true,
createdAt: now.toISOString(),
expiresAt,
};
tokens.push(token);
saveApiTokens(tokens);
return token;
}
/**
* 更新 Token 元信息
* @param {string} id
* @param {Object} data - { name, projectId, permission, enabled }
* @returns {Object}
*/
function updateToken(id, data) {
const tokens = loadApiTokens();
const idx = tokens.findIndex((t) => t.id === id);
if (idx === -1) throw new Error("Token 不存在");
if (data.name !== undefined) tokens[idx].name = data.name.trim();
if (data.projectId !== undefined) tokens[idx].projectId = data.projectId;
if (data.permission !== undefined) tokens[idx].permission = data.permission;
if (data.enabled !== undefined) tokens[idx].enabled = !!data.enabled;
saveApiTokens(tokens);
return tokens[idx];
}
/**
* 吊销 Token设置 enabled = false
* @param {string} id
* @returns {boolean}
*/
function revokeToken(id) {
const tokens = loadApiTokens();
const idx = tokens.findIndex((t) => t.id === id);
if (idx === -1) return false;
tokens[idx].enabled = false;
saveApiTokens(tokens);
return true;
}
/**
* 删除 Token
* @param {string} id
* @returns {boolean}
*/
function deleteToken(id) {
const tokens = loadApiTokens();
const idx = tokens.findIndex((t) => t.id === id);
if (idx === -1) return false;
tokens.splice(idx, 1);
saveApiTokens(tokens);
return true;
}
/**
* 根据 token value 校验是否有效用于 API 鉴权
* @param {string} value
* @returns {Object|null} token 对象或 null
*/
function validateToken(value) {
if (!value) return null;
const token = loadApiTokens().find((t) => t.value === value);
if (!token || !token.enabled) return null;
if (token.expiresAt && new Date(token.expiresAt) < new Date()) return null;
return token;
}
module.exports = {
getAllTokens,
findById,
createToken,
updateToken,
revokeToken,
deleteToken,
validateToken,
};

206
src/services/userService.js Normal file
View File

@ -0,0 +1,206 @@
/**
* 用户服务
* 负责用户的增删改查密码校验与哈希数据持久化到 data/users.json
*/
const crypto = require("crypto");
const { loadUsers, saveUsers } = require("../persistence/jsonAdapter");
/**
* 密码哈希使用 scrypt 算法 + 随机盐值
* @param {string} password - 明文密码
* @returns {string} salt:hash 格式的哈希值
*/
function hashPassword(password) {
const salt = crypto.randomBytes(16).toString("hex");
const hash = crypto.scryptSync(password, salt, 64).toString("hex");
return `${salt}:${hash}`;
}
/**
* 校验密码是否匹配
* @param {string} password - 明文密码
* @param {string} stored - 存储的 salt:hash
* @returns {boolean}
*/
function verifyPassword(password, stored) {
const [salt, hash] = stored.split(":");
const hashToCheck = crypto.scryptSync(password, salt, 64).toString("hex");
return crypto.timingSafeEqual(Buffer.from(hash, "hex"), Buffer.from(hashToCheck, "hex"));
}
/**
* 生成随机 token
* @param {number} [length=32] - 字节长度
* @returns {string} hex token
*/
function generateToken(length = 32) {
return crypto.randomBytes(length).toString("hex");
}
/**
* 确保默认管理员账号存在首次启动时初始化
*/
function ensureDefaultAdmin() {
const users = loadUsers();
if (users.length === 0) {
users.push({
id: generateToken(8),
username: "admin",
displayName: "系统管理员",
password: hashPassword("admin123"),
role: "admin",
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastLoginAt: null,
});
saveUsers(users);
console.log("已创建默认管理员账号: admin");
}
}
/**
* 按用户名查找用户
* @param {string} username
* @returns {Object|null}
*/
function findByUsername(username) {
return loadUsers().find((u) => u.username === username) || null;
}
/**
* ID 查找用户
* @param {string} id
* @returns {Object|null}
*/
function findById(id) {
return loadUsers().find((u) => u.id === id) || null;
}
/**
* 获取所有用户不含密码字段
* @returns {Array}
*/
function getAllUsers() {
return loadUsers().map(({ password, ...rest }) => rest);
}
/**
* 创建用户
* @param {Object} data - { username, displayName, password, role, enabled }
* @returns {Object} 创建的用户不含密码
* @throws {Error}
*/
function createUser(data) {
const users = loadUsers();
if (!data.username || !data.username.trim()) {
throw new Error("用户名不能为空");
}
if (!data.password) {
throw new Error("密码不能为空");
}
if (users.find((u) => u.username === data.username.trim())) {
throw new Error("用户名已存在");
}
const user = {
id: generateToken(8),
username: data.username.trim(),
displayName: (data.displayName || data.username).trim(),
password: hashPassword(data.password),
role: data.role === "admin" ? "admin" : "user",
enabled: data.enabled !== false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastLoginAt: null,
};
users.push(user);
saveUsers(users);
const { password, ...rest } = user;
return rest;
}
/**
* 更新用户
* @param {string} id
* @param {Object} data - { displayName, role, enabled }
* @returns {Object}
* @throws {Error}
*/
function updateUser(id, data) {
const users = loadUsers();
const idx = users.findIndex((u) => u.id === id);
if (idx === -1) throw new Error("用户不存在");
if (data.displayName !== undefined) users[idx].displayName = data.displayName.trim();
if (data.role !== undefined) users[idx].role = data.role === "admin" ? "admin" : "user";
if (data.enabled !== undefined) users[idx].enabled = !!data.enabled;
users[idx].updatedAt = new Date().toISOString();
saveUsers(users);
const { password, ...rest } = users[idx];
return rest;
}
/**
* 重置密码
* @param {string} id
* @param {string} newPassword
* @throws {Error}
*/
function resetPassword(id, newPassword) {
const users = loadUsers();
const idx = users.findIndex((u) => u.id === id);
if (idx === -1) throw new Error("用户不存在");
if (!newPassword) throw new Error("新密码不能为空");
users[idx].password = hashPassword(newPassword);
users[idx].updatedAt = new Date().toISOString();
saveUsers(users);
}
/**
* 删除用户
* @param {string} id
* @returns {boolean}
*/
function deleteUser(id) {
const users = loadUsers();
const idx = users.findIndex((u) => u.id === id);
if (idx === -1) return false;
users.splice(idx, 1);
saveUsers(users);
return true;
}
/**
* 登录校验成功后更新 lastLoginAt
* @param {string} username
* @param {string} password
* @returns {Object|null} 用户对象不含密码 null
*/
function authenticate(username, password) {
const user = findByUsername(username);
if (!user || !user.enabled) return null;
if (!verifyPassword(password, user.password)) return null;
// 更新最后登录时间
const users = loadUsers();
const idx = users.findIndex((u) => u.id === user.id);
if (idx !== -1) {
users[idx].lastLoginAt = new Date().toISOString();
saveUsers(users);
}
const { password: _, ...rest } = user;
return { ...rest, lastLoginAt: users[idx].lastLoginAt };
}
module.exports = {
ensureDefaultAdmin,
hashPassword,
verifyPassword,
generateToken,
findByUsername,
findById,
getAllUsers,
createUser,
updateUser,
resetPassword,
deleteUser,
authenticate,
};