@elinpf/dsh-ops-access-hub 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/web.js CHANGED
@@ -4,6 +4,15 @@
4
4
  * the page itself prompts for a Bearer token (kept in localStorage) and all
5
5
  * data fetches carry it, so no secret material is embedded in the HTML.
6
6
  *
7
+ * Admin views: entry list/editor, the named-token roster and the audit log with
8
+ * its actor column. The token input reports what it resolves to via `/whoami`.
9
+ *
10
+ * The roster is the fine-grained control surface (ADR-0010): a status badge
11
+ * per record (active / expiring / expired / revoked), a search box plus status
12
+ * and role filters over the roster, and an edit dialog that patches a live
13
+ * token's label, role or expiry in place — the secret never changes, so a
14
+ * correction never forces the holder to reconfigure.
15
+ *
7
16
  * UI copy is Chinese per repo convention for operator-facing surfaces.
8
17
  * NOTE: this string must not contain backticks or `${` sequences — the
9
18
  * inline JS uses string concatenation instead of template literals.
@@ -34,6 +43,8 @@ export const WEB_UI_HTML = `<!DOCTYPE html>
34
43
  .badge.verified { border-color: #2f7d4f; color: #7fd6a4; }
35
44
  .badge.mismatch { border-color: #a33; color: #f0a0a0; }
36
45
  .badge.unverifiable { border-color: #8a6d1d; color: #e6c96a; }
46
+ .badge.expired { border-color: #a33; color: #f0a0a0; }
47
+ .badge.revoked { border-color: #4a5666; color: #8b98a9; }
37
48
  .muted { color: #8b98a9; }
38
49
  #msg { margin: 8px 0; min-height: 20px; color: #f0a0a0; white-space: pre-wrap; }
39
50
  #msg.ok { color: #7fd6a4; }
@@ -54,6 +65,7 @@ export const WEB_UI_HTML = `<!DOCTYPE html>
54
65
  <button id="saveToken">保存 token</button>
55
66
  <button id="refresh">刷新</button>
56
67
  <button id="newEntry">新建条目</button>
68
+ <button id="showTokens">token 管理</button>
57
69
  <button id="showAudit">审计记录</button>
58
70
  <span id="whoami" class="muted"></span>
59
71
  </header>
@@ -66,14 +78,74 @@ export const WEB_UI_HTML = `<!DOCTYPE html>
66
78
  <tbody id="entries"></tbody>
67
79
  </table>
68
80
  </section>
81
+ <section id="tokenSection" style="display:none">
82
+ <h2>具名 token(每人一个,可单独吊销)</h2>
83
+ <div class="row" style="align-items:center;margin-bottom:8px">
84
+ <div><button id="newToken">新建 token</button></div>
85
+ <div class="muted">明文只在创建时显示一次,请立即交付持有人;服务端只存摘要</div>
86
+ </div>
87
+ <div class="row" style="align-items:center;margin-bottom:8px">
88
+ <div><input id="tokenFilter" placeholder="搜索 名称 / 前缀 / 创建者" style="width:100%"></div>
89
+ <div><select id="tokenStatusFilter">
90
+ <option value="">全部状态</option>
91
+ <option value="active">有效</option>
92
+ <option value="expiring">即将过期</option>
93
+ <option value="expired">已过期</option>
94
+ <option value="revoked">已吊销</option>
95
+ </select></div>
96
+ <div><select id="tokenRoleFilter">
97
+ <option value="">全部角色</option>
98
+ <option value="admin">admin</option>
99
+ <option value="read">read</option>
100
+ </select></div>
101
+ </div>
102
+ <div id="tokenSummary" class="muted"></div>
103
+ <div id="issuedBox" style="display:none;border:1px solid #8a6d1d;border-radius:6px;padding:8px;margin:8px 0">
104
+ <div class="muted">新 token 明文(只显示这一次,关闭或刷新后无法再次查看):</div>
105
+ <code id="issuedToken" style="word-break:break-all"></code>
106
+ <button id="copyIssued">复制</button>
107
+ </div>
108
+ <table>
109
+ <thead><tr><th>名称(持有人)</th><th>角色</th><th>前缀</th><th>创建者</th><th>创建时间</th><th>到期时间</th><th>状态</th><th>操作</th></tr></thead>
110
+ <tbody id="tokens"></tbody>
111
+ </table>
112
+ </section>
69
113
  <section id="auditSection" style="display:none">
70
114
  <h2>审计记录(最近 100 条)</h2>
71
115
  <table>
72
- <thead><tr><th>时间</th><th>角色</th><th>动作</th><th>条目</th><th>tier</th></tr></thead>
116
+ <thead><tr><th>时间</th><th>角色</th><th>操作者(token)</th><th>动作</th><th>条目</th><th>tier</th></tr></thead>
73
117
  <tbody id="audit"></tbody>
74
118
  </table>
75
119
  </section>
76
120
  </main>
121
+ <dialog id="tokenEditor">
122
+ <h2>新建 token</h2>
123
+ <label>持有人名称(审计中的操作者)</label><input id="tName" placeholder="alice">
124
+ <div class="row">
125
+ <div><label>角色</label><select id="tRole"><option value="read">read</option><option value="admin">admin</option></select></div>
126
+ <div><label>有效期(可选,ISO 时间,留空为长期)</label><input id="tExpires" placeholder="2026-12-31T00:00:00Z"></div>
127
+ </div>
128
+ <div id="tokenErr" style="color:#f0a0a0;min-height:18px;margin-top:4px"></div>
129
+ <div style="margin-top:12px;text-align:right">
130
+ <button id="cancelToken">取消</button>
131
+ <button id="createToken">签发</button>
132
+ </div>
133
+ </dialog>
134
+ <dialog id="tokenEdit">
135
+ <h2>编辑 token</h2>
136
+ <div class="muted">只改标签 / 角色 / 有效期:token 明文不变,持有人无需重新配置</div>
137
+ <label>持有人名称(审计中的操作者)</label><input id="eName">
138
+ <div class="row">
139
+ <div><label>角色</label><select id="eRole"><option value="read">read</option><option value="admin">admin</option></select></div>
140
+ <div><label>有效期(ISO 时间)</label><input id="eExpires" placeholder="2026-12-31T00:00:00Z"></div>
141
+ </div>
142
+ <label style="margin-top:8px"><input type="checkbox" id="eClearExpires" style="width:auto"> 清除有效期(改为长期有效)</label>
143
+ <div id="tokenEditErr" style="color:#f0a0a0;min-height:18px;margin-top:4px"></div>
144
+ <div style="margin-top:12px;text-align:right">
145
+ <button id="cancelTokenEdit">取消</button>
146
+ <button id="saveTokenEdit">保存修改</button>
147
+ </div>
148
+ </dialog>
77
149
  <dialog id="editor">
78
150
  <h2 id="editorTitle">编辑条目</h2>
79
151
  <div class="row">
@@ -103,6 +175,18 @@ export const WEB_UI_HTML = `<!DOCTYPE html>
103
175
  var entriesBody = document.getElementById('entries');
104
176
  var auditSection = document.getElementById('auditSection');
105
177
  var auditBody = document.getElementById('audit');
178
+ var tokenSection = document.getElementById('tokenSection');
179
+ var tokenBody = document.getElementById('tokens');
180
+ var issuedBox = document.getElementById('issuedBox');
181
+ var issuedToken = document.getElementById('issuedToken');
182
+ var tokenEditor = document.getElementById('tokenEditor');
183
+ var tokenErr = document.getElementById('tokenErr');
184
+ var tokenEdit = document.getElementById('tokenEdit');
185
+ var tokenEditErr = document.getElementById('tokenEditErr');
186
+ var tokenSummary = document.getElementById('tokenSummary');
187
+ var tokensCache = [];
188
+ var editingTokenId = '';
189
+ var whoami = document.getElementById('whoami');
106
190
  var editor = document.getElementById('editor');
107
191
  var formErr = document.getElementById('formErr');
108
192
  var entriesCache = [];
@@ -153,6 +237,10 @@ export const WEB_UI_HTML = `<!DOCTYPE html>
153
237
  render();
154
238
  say('');
155
239
  }).catch(function (err) { say('请求失败:' + err.message); });
240
+ api('/whoami').then(function (r) {
241
+ if (r.status !== 200) { whoami.textContent = ''; return; }
242
+ whoami.textContent = r.body.actor + ' / ' + r.body.role + (r.body.source === 'static' ? ' (静态)' : ' (具名)');
243
+ });
156
244
  }
157
245
  document.getElementById('saveToken').onclick = function () {
158
246
  token = tokenInput.value.trim();
@@ -232,11 +320,166 @@ export const WEB_UI_HTML = `<!DOCTYPE html>
232
320
  api('/audit?limit=100').then(function (r) {
233
321
  if (r.status !== 200) { say('审计加载失败:' + (r.body && r.body.error || r.status)); return; }
234
322
  auditBody.innerHTML = r.body.map(function (a) {
235
- return '<tr><td class="muted">' + esc(a.ts) + '</td><td>' + esc(a.role) + '</td><td>' + esc(a.action) +
323
+ return '<tr><td class="muted">' + esc(a.ts) + '</td><td>' + esc(a.role) + '</td><td>' + esc(a.actor || '—') +
324
+ '</td><td>' + esc(a.action + (a.changes && a.changes.length ? ' (' + a.changes.join(', ') + ')' : '')) +
236
325
  '</td><td>' + esc(a.kind) + ' / ' + esc(a.name) + '</td><td>' + esc(a.tier) + '</td></tr>';
237
- }).join('') || '<tr><td colspan="5" class="muted">(空)</td></tr>';
326
+ }).join('') || '<tr><td colspan="6" class="muted">(空)</td></tr>';
327
+ });
328
+ };
329
+ // Mirrors tokenStatus() in tokens.ts: active and expiring both still
330
+ // authenticate; the split is what lets an operator renew in time.
331
+ var EXPIRING_SOON_MS = 7 * 24 * 60 * 60 * 1000;
332
+ function tokenStatus(t) {
333
+ if (t.revokedAt) return 'revoked';
334
+ if (t.expiresAt) {
335
+ var ms = Date.parse(t.expiresAt);
336
+ if (!isFinite(ms) || ms <= Date.now()) return 'expired';
337
+ if (ms - Date.now() <= EXPIRING_SOON_MS) return 'expiring';
338
+ }
339
+ return 'active';
340
+ }
341
+ function daysLeft(iso) {
342
+ return Math.ceil((Date.parse(iso) - Date.now()) / 86400000);
343
+ }
344
+ function localTime(iso) {
345
+ if (!iso) return '';
346
+ var d = new Date(iso);
347
+ return isNaN(d.getTime()) ? String(iso) : d.toLocaleString();
348
+ }
349
+ function statusCell(t) {
350
+ var s = tokenStatus(t);
351
+ if (s === 'revoked') return '<span class="badge revoked">已吊销</span>';
352
+ if (s === 'expired') return '<span class="badge expired">已过期</span>';
353
+ if (s === 'expiring') return '<span class="badge unverifiable">' + daysLeft(t.expiresAt) + ' 天后过期</span>';
354
+ return '<span class="badge verified">有效</span>';
355
+ }
356
+ function findToken(id) {
357
+ for (var i = 0; i < tokensCache.length; i++) if (tokensCache[i].id === id) return tokensCache[i];
358
+ return null;
359
+ }
360
+ function renderTokens() {
361
+ var q = document.getElementById('tokenFilter').value.trim().toLowerCase();
362
+ var statusFilter = document.getElementById('tokenStatusFilter').value;
363
+ var roleFilter = document.getElementById('tokenRoleFilter').value;
364
+ var counts = { active: 0, expiring: 0, expired: 0, revoked: 0 };
365
+ tokensCache.forEach(function (t) { counts[tokenStatus(t)] += 1; });
366
+ var rows = tokensCache.filter(function (t) {
367
+ if (roleFilter && t.role !== roleFilter) return false;
368
+ if (statusFilter && tokenStatus(t) !== statusFilter) return false;
369
+ if (!q) return true;
370
+ return (t.name + ' ' + t.prefix + ' ' + t.createdBy).toLowerCase().indexOf(q) !== -1;
371
+ });
372
+ tokenSummary.textContent = '共 ' + tokensCache.length + ' 条:有效 ' + counts.active + ' · 即将过期 ' +
373
+ counts.expiring + ' · 已过期 ' + counts.expired + ' · 已吊销 ' + counts.revoked +
374
+ (rows.length === tokensCache.length ? '' : ' — 当前筛选命中 ' + rows.length + ' 条');
375
+ tokenBody.innerHTML = rows.map(function (t) {
376
+ var actions = t.revokedAt ? '<span class="muted">—</span>' :
377
+ '<button data-edit-token="' + esc(t.id) + '">编辑</button> ' +
378
+ '<button class="danger" data-revoke="' + esc(t.id) + '">吊销</button>';
379
+ return '<tr><td><b>' + esc(t.name) + '</b></td><td>' + esc(t.role) + '</td><td class="muted">' + esc(t.prefix) +
380
+ '...</td><td class="muted">' + esc(t.createdBy) + '</td><td class="muted">' + esc(localTime(t.createdAt)) +
381
+ '</td><td class="muted">' + esc(t.expiresAt ? localTime(t.expiresAt) : '长期') + '</td><td>' + statusCell(t) +
382
+ '</td><td>' + actions + '</td></tr>';
383
+ }).join('') || '<tr><td colspan="8" class="muted">(没有匹配的 token)</td></tr>';
384
+ }
385
+ function loadTokens() {
386
+ api('/tokens').then(function (r) {
387
+ if (r.status !== 200) {
388
+ tokensCache = [];
389
+ tokenSummary.textContent = '';
390
+ tokenBody.innerHTML = '<tr><td colspan="8" class="muted">加载失败(需要 admin token):' + esc(r.body && r.body.error || r.status) + '</td></tr>';
391
+ return;
392
+ }
393
+ tokensCache = r.body;
394
+ renderTokens();
395
+ });
396
+ }
397
+ document.getElementById('tokenFilter').oninput = renderTokens;
398
+ document.getElementById('tokenStatusFilter').onchange = renderTokens;
399
+ document.getElementById('tokenRoleFilter').onchange = renderTokens;
400
+ document.getElementById('showTokens').onclick = function () {
401
+ tokenSection.style.display = tokenSection.style.display === 'none' ? '' : 'none';
402
+ if (tokenSection.style.display === 'none') { issuedBox.style.display = 'none'; return; }
403
+ loadTokens();
404
+ };
405
+ document.getElementById('newToken').onclick = function () {
406
+ document.getElementById('tName').value = '';
407
+ document.getElementById('tRole').value = 'read';
408
+ document.getElementById('tExpires').value = '';
409
+ tokenErr.textContent = '';
410
+ tokenEditor.showModal();
411
+ };
412
+ document.getElementById('cancelToken').onclick = function () { tokenEditor.close(); };
413
+ document.getElementById('createToken').onclick = function () {
414
+ var body = {
415
+ name: document.getElementById('tName').value,
416
+ role: document.getElementById('tRole').value,
417
+ expiresAt: document.getElementById('tExpires').value,
418
+ };
419
+ api('/tokens', { method: 'POST', body: JSON.stringify(body) }).then(function (r) {
420
+ if (r.status !== 200) { tokenErr.textContent = '签发失败:' + (r.body && r.body.error || r.status); return; }
421
+ tokenEditor.close();
422
+ issuedBox.style.display = '';
423
+ issuedToken.textContent = r.body.token;
424
+ say('已签发 token ' + r.body.name + '(' + r.body.role + '),明文只显示这一次', true);
425
+ loadTokens();
238
426
  });
239
427
  };
428
+ document.getElementById('copyIssued').onclick = function () {
429
+ if (navigator.clipboard) navigator.clipboard.writeText(issuedToken.textContent || '');
430
+ };
431
+ document.getElementById('eClearExpires').onchange = function () {
432
+ var box = document.getElementById('eExpires');
433
+ box.disabled = this.checked;
434
+ if (this.checked) box.value = '';
435
+ };
436
+ function openTokenEditor(id) {
437
+ var t = findToken(id);
438
+ if (!t) return;
439
+ editingTokenId = id;
440
+ document.getElementById('eName').value = t.name;
441
+ document.getElementById('eRole').value = t.role;
442
+ document.getElementById('eExpires').value = t.expiresAt || '';
443
+ document.getElementById('eExpires').disabled = false;
444
+ document.getElementById('eClearExpires').checked = false;
445
+ tokenEditErr.textContent = '';
446
+ tokenEdit.showModal();
447
+ }
448
+ document.getElementById('cancelTokenEdit').onclick = function () { tokenEdit.close(); };
449
+ document.getElementById('saveTokenEdit').onclick = function () {
450
+ var patch = {
451
+ name: document.getElementById('eName').value,
452
+ role: document.getElementById('eRole').value,
453
+ };
454
+ if (document.getElementById('eClearExpires').checked) patch.expiresAt = null;
455
+ else {
456
+ var exp = document.getElementById('eExpires').value.trim();
457
+ if (exp) patch.expiresAt = exp;
458
+ }
459
+ api('/tokens/' + encodeURIComponent(editingTokenId), { method: 'PATCH', body: JSON.stringify(patch) }).then(function (r) {
460
+ if (r.status !== 200) { tokenEditErr.textContent = '保存失败:' + (r.body && r.body.error || r.status); return; }
461
+ tokenEdit.close();
462
+ var changed = r.body.changes || [];
463
+ say(changed.length ? '已更新 ' + r.body.name + ':' + changed.join(', ') : '与当前值一致,无需修改', true);
464
+ loadTokens();
465
+ });
466
+ };
467
+ tokenBody.addEventListener('click', function (ev) {
468
+ var t = ev.target;
469
+ if (!(t instanceof HTMLElement)) return;
470
+ var editId = t.getAttribute('data-edit-token');
471
+ if (editId) { openTokenEditor(editId); return; }
472
+ var revokeId = t.getAttribute('data-revoke');
473
+ if (!revokeId) return;
474
+ var token = findToken(revokeId);
475
+ var label = token ? token.name : revokeId;
476
+ if (!confirm('确认吊销 ' + label + ' 的 token?持有人将立即失去访问权限,且不可恢复')) return;
477
+ api('/tokens/' + encodeURIComponent(revokeId), { method: 'DELETE' }).then(function (r) {
478
+ if (r.status !== 200) { say('吊销失败:' + (r.body && r.body.error || r.status)); return; }
479
+ say('已吊销 ' + label, true);
480
+ loadTokens();
481
+ });
482
+ });
240
483
  load();
241
484
  })();
242
485
  </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elinpf/dsh-ops-access-hub",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Standalone credential hub for the dsh ops suite: encrypted-at-rest storage, token-authenticated REST API, minimal web UI, and a YAML registry importer. Not a dsh plugin.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -17,7 +17,8 @@
17
17
  "devDependencies": {
18
18
  "@types/node": "^22.0.0",
19
19
  "typescript": "^5.4.0",
20
- "vitest": "^4.1.11"
20
+ "vitest": "^4.1.11",
21
+ "@elinpf/dsh-ops-test-support": "0.0.0"
21
22
  },
22
23
  "license": "MIT",
23
24
  "exports": {