@elinpf/dsh-ops-access-hub 0.3.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/README.md +27 -4
- package/README.zh.md +31 -4
- package/lib/cli.d.ts +3 -0
- package/lib/cli.js +203 -12
- package/lib/index.d.ts +4 -1
- package/lib/index.js +3 -1
- package/lib/server.d.ts +25 -3
- package/lib/server.js +197 -31
- package/lib/store.d.ts +77 -5
- package/lib/store.js +136 -6
- package/lib/tokens.d.ts +110 -0
- package/lib/tokens.js +131 -0
- package/lib/web.d.ts +10 -1
- package/lib/web.js +246 -3
- package/package.json +1 -1
package/lib/tokens.d.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named-token model for the hub (ADR-0009).
|
|
3
|
+
*
|
|
4
|
+
* A hub used to speak exactly two shared, environment-injected Bearer tokens
|
|
5
|
+
* (one admin, one read). That is enough for "one consumer, one operator", but
|
|
6
|
+
* not for a team: everybody shares the same secret, nobody can be revoked
|
|
7
|
+
* alone, and an audit line cannot say *who* resolved a credential.
|
|
8
|
+
*
|
|
9
|
+
* A named token is an independently issued credential with a human label, a
|
|
10
|
+
* role (`admin`/`read`) and an optional expiry. Only the SHA-256 digest of the
|
|
11
|
+
* plaintext is persisted — the plaintext is returned exactly once, by the
|
|
12
|
+
* create call, and has no recovery path. The label becomes the audit `actor`,
|
|
13
|
+
* so every recorded action names the holder.
|
|
14
|
+
*
|
|
15
|
+
* The static env/flag tokens stay valid as bootstrap/break-glass credentials
|
|
16
|
+
* (see `./server.js`), so revoking every named token can never lock the
|
|
17
|
+
* operator out of the hub.
|
|
18
|
+
*
|
|
19
|
+
* This module is pure: it holds the model, the minting/hashing helpers and
|
|
20
|
+
* the input parsers; persistence lives in `./store.js`.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
export type TokenRole = 'admin' | 'read';
|
|
25
|
+
/** Roles a named token may carry; anything else is a 400. */
|
|
26
|
+
export declare const TOKEN_ROLES: readonly TokenRole[];
|
|
27
|
+
/**
|
|
28
|
+
* Token label charset — unicode letters/digits first, then letters/digits and
|
|
29
|
+
* `. _ @ + -` plus spaces, 1..64 characters. Deliberately wider than
|
|
30
|
+
* `NAME_PATTERN`: a label names a *person* (Chinese names are expected) and
|
|
31
|
+
* only ever travels in a JSON body and the audit log, never in a path.
|
|
32
|
+
*/
|
|
33
|
+
export declare const TOKEN_NAME_PATTERN: RegExp;
|
|
34
|
+
/** Hard cap on issued-but-not-purged token records (the store owns the doc). */
|
|
35
|
+
export declare const MAX_TOKENS = 200;
|
|
36
|
+
/** How many plaintext characters of a token are kept for identification. */
|
|
37
|
+
export declare const TOKEN_PREFIX_CHARS = 8;
|
|
38
|
+
/**
|
|
39
|
+
* One issued credential. `hash` is the only material stored for the secret
|
|
40
|
+
* itself; `prefix` exists so an operator can tell two live tokens apart in a
|
|
41
|
+
* listing without the hub being able to re-display them.
|
|
42
|
+
*/
|
|
43
|
+
export interface HubToken {
|
|
44
|
+
id: string;
|
|
45
|
+
/** Human label of the holder; unique among non-revoked tokens; recorded as the audit `actor`. */
|
|
46
|
+
name: string;
|
|
47
|
+
role: TokenRole;
|
|
48
|
+
/** SHA-256 hex of the plaintext token — the plaintext itself is never stored. */
|
|
49
|
+
hash: string;
|
|
50
|
+
/** First {@link TOKEN_PREFIX_CHARS} characters of the plaintext, for identification only. */
|
|
51
|
+
prefix: string;
|
|
52
|
+
createdAt: string;
|
|
53
|
+
/** Label of the issuer (a named-token name, or `cli` for offline issuance). */
|
|
54
|
+
createdBy: string;
|
|
55
|
+
/** Optional ISO expiry; once past, the token authenticates as invalid. */
|
|
56
|
+
expiresAt?: string;
|
|
57
|
+
/** Set when revoked; a revoked token can never authenticate again. */
|
|
58
|
+
revokedAt?: string;
|
|
59
|
+
}
|
|
60
|
+
/** Metadata-safe projection of a token — never carries `hash`. */
|
|
61
|
+
export type TokenView = Omit<HubToken, 'hash'>;
|
|
62
|
+
/** Mint a fresh plaintext token (the only copy that will ever exist). */
|
|
63
|
+
export declare function generateToken(): string;
|
|
64
|
+
/** SHA-256 hex digest of a plaintext token; what the store keeps and compares. */
|
|
65
|
+
export declare function hashToken(token: string): string;
|
|
66
|
+
/** The identifying head of a plaintext token. */
|
|
67
|
+
export declare function tokenPrefix(token: string): string;
|
|
68
|
+
/** Constant-time comparison of two hex digests (same length always). */
|
|
69
|
+
export declare function hashEqual(a: string, b: string): boolean;
|
|
70
|
+
/** A token still accepts requests when it is neither revoked nor expired. */
|
|
71
|
+
export declare function isTokenActive(token: Pick<HubToken, 'revokedAt' | 'expiresAt'>, now?: string): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* How the roster surfaces one token. `active` and `expiring` both still
|
|
74
|
+
* authenticate; the split exists so an operator can renew *before* a holder is
|
|
75
|
+
* locked out (ADR-0010). `expired`/`revoked` are the two ways a record stops
|
|
76
|
+
* working.
|
|
77
|
+
*/
|
|
78
|
+
export type TokenStatus = 'active' | 'expiring' | 'expired' | 'revoked';
|
|
79
|
+
/** A token whose expiry is at most this far out is reported as `expiring`. */
|
|
80
|
+
export declare const EXPIRING_SOON_MS: number;
|
|
81
|
+
/**
|
|
82
|
+
* Classify a token for the roster and the CLI. Survives a hand-edited data
|
|
83
|
+
* file: an unparseable `expiresAt` reads as expired instead of crashing.
|
|
84
|
+
*/
|
|
85
|
+
export declare function tokenStatus(token: Pick<HubToken, 'revokedAt' | 'expiresAt'>, now?: Date): TokenStatus;
|
|
86
|
+
/**
|
|
87
|
+
* An edit applied through `PATCH /tokens/:id` / `token update`. An absent field
|
|
88
|
+
* means "leave it alone"; `expiresAt: null` is the explicit "clear the expiry" —
|
|
89
|
+
* with `undefined` already spoken for, clearing needs its own value.
|
|
90
|
+
*/
|
|
91
|
+
export interface TokenPatch {
|
|
92
|
+
name?: string;
|
|
93
|
+
role?: TokenRole;
|
|
94
|
+
expiresAt?: string | null;
|
|
95
|
+
}
|
|
96
|
+
/** The patch fields that actually differ from the record; audit `changes` verbatim. */
|
|
97
|
+
export type TokenChange = 'name' | 'role' | 'expiresAt';
|
|
98
|
+
/**
|
|
99
|
+
* Normalize the `expiresAt` half of a patch body: absent → keep, `null`/`''` →
|
|
100
|
+
* clear, anything else → a validated future ISO timestamp (throws when past).
|
|
101
|
+
*/
|
|
102
|
+
export declare function parseExpiresAtPatch(raw: unknown, now?: Date): string | null | undefined;
|
|
103
|
+
/** Drop the digest before a token record leaves the process. */
|
|
104
|
+
export declare function toTokenView(token: HubToken): TokenView;
|
|
105
|
+
/** Parse a role from a request body; throws on anything but `admin`/`read`. */
|
|
106
|
+
export declare function parseTokenRole(raw: unknown): TokenRole;
|
|
107
|
+
/** Parse and trim a token label; throws when it cannot name a person safely. */
|
|
108
|
+
export declare function parseTokenName(raw: unknown): string;
|
|
109
|
+
/** Parse an optional ISO expiry; throws when it is unparseable or already past. */
|
|
110
|
+
export declare function parseExpiresAt(raw: unknown, now?: Date): string | undefined;
|
package/lib/tokens.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named-token model for the hub (ADR-0009).
|
|
3
|
+
*
|
|
4
|
+
* A hub used to speak exactly two shared, environment-injected Bearer tokens
|
|
5
|
+
* (one admin, one read). That is enough for "one consumer, one operator", but
|
|
6
|
+
* not for a team: everybody shares the same secret, nobody can be revoked
|
|
7
|
+
* alone, and an audit line cannot say *who* resolved a credential.
|
|
8
|
+
*
|
|
9
|
+
* A named token is an independently issued credential with a human label, a
|
|
10
|
+
* role (`admin`/`read`) and an optional expiry. Only the SHA-256 digest of the
|
|
11
|
+
* plaintext is persisted — the plaintext is returned exactly once, by the
|
|
12
|
+
* create call, and has no recovery path. The label becomes the audit `actor`,
|
|
13
|
+
* so every recorded action names the holder.
|
|
14
|
+
*
|
|
15
|
+
* The static env/flag tokens stay valid as bootstrap/break-glass credentials
|
|
16
|
+
* (see `./server.js`), so revoking every named token can never lock the
|
|
17
|
+
* operator out of the hub.
|
|
18
|
+
*
|
|
19
|
+
* This module is pure: it holds the model, the minting/hashing helpers and
|
|
20
|
+
* the input parsers; persistence lives in `./store.js`.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
25
|
+
/** Roles a named token may carry; anything else is a 400. */
|
|
26
|
+
export const TOKEN_ROLES = ['admin', 'read'];
|
|
27
|
+
/**
|
|
28
|
+
* Token label charset — unicode letters/digits first, then letters/digits and
|
|
29
|
+
* `. _ @ + -` plus spaces, 1..64 characters. Deliberately wider than
|
|
30
|
+
* `NAME_PATTERN`: a label names a *person* (Chinese names are expected) and
|
|
31
|
+
* only ever travels in a JSON body and the audit log, never in a path.
|
|
32
|
+
*/
|
|
33
|
+
export const TOKEN_NAME_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}._@+ -]{0,63}$/u;
|
|
34
|
+
/** Hard cap on issued-but-not-purged token records (the store owns the doc). */
|
|
35
|
+
export const MAX_TOKENS = 200;
|
|
36
|
+
/** Entropy behind every issued token: 24 random bytes → 32 base64url chars. */
|
|
37
|
+
const TOKEN_BYTES = 24;
|
|
38
|
+
/** How many plaintext characters of a token are kept for identification. */
|
|
39
|
+
export const TOKEN_PREFIX_CHARS = 8;
|
|
40
|
+
/** Mint a fresh plaintext token (the only copy that will ever exist). */
|
|
41
|
+
export function generateToken() {
|
|
42
|
+
return randomBytes(TOKEN_BYTES).toString('base64url');
|
|
43
|
+
}
|
|
44
|
+
/** SHA-256 hex digest of a plaintext token; what the store keeps and compares. */
|
|
45
|
+
export function hashToken(token) {
|
|
46
|
+
return createHash('sha256').update(token, 'utf8').digest('hex');
|
|
47
|
+
}
|
|
48
|
+
/** The identifying head of a plaintext token. */
|
|
49
|
+
export function tokenPrefix(token) {
|
|
50
|
+
return token.slice(0, TOKEN_PREFIX_CHARS);
|
|
51
|
+
}
|
|
52
|
+
/** Constant-time comparison of two hex digests (same length always). */
|
|
53
|
+
export function hashEqual(a, b) {
|
|
54
|
+
const ba = Buffer.from(a, 'utf8');
|
|
55
|
+
const bb = Buffer.from(b, 'utf8');
|
|
56
|
+
if (ba.length !== bb.length)
|
|
57
|
+
return false;
|
|
58
|
+
return timingSafeEqual(ba, bb);
|
|
59
|
+
}
|
|
60
|
+
/** A token still accepts requests when it is neither revoked nor expired. */
|
|
61
|
+
export function isTokenActive(token, now = new Date().toISOString()) {
|
|
62
|
+
if (token.revokedAt !== undefined)
|
|
63
|
+
return false;
|
|
64
|
+
if (token.expiresAt !== undefined && token.expiresAt <= now)
|
|
65
|
+
return false;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
/** A token whose expiry is at most this far out is reported as `expiring`. */
|
|
69
|
+
export const EXPIRING_SOON_MS = 7 * 24 * 60 * 60 * 1000;
|
|
70
|
+
/**
|
|
71
|
+
* Classify a token for the roster and the CLI. Survives a hand-edited data
|
|
72
|
+
* file: an unparseable `expiresAt` reads as expired instead of crashing.
|
|
73
|
+
*/
|
|
74
|
+
export function tokenStatus(token, now = new Date()) {
|
|
75
|
+
if (token.revokedAt !== undefined)
|
|
76
|
+
return 'revoked';
|
|
77
|
+
if (token.expiresAt !== undefined) {
|
|
78
|
+
const expiry = Date.parse(token.expiresAt);
|
|
79
|
+
if (!Number.isFinite(expiry) || expiry <= now.getTime())
|
|
80
|
+
return 'expired';
|
|
81
|
+
if (expiry - now.getTime() <= EXPIRING_SOON_MS)
|
|
82
|
+
return 'expiring';
|
|
83
|
+
}
|
|
84
|
+
return 'active';
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Normalize the `expiresAt` half of a patch body: absent → keep, `null`/`''` →
|
|
88
|
+
* clear, anything else → a validated future ISO timestamp (throws when past).
|
|
89
|
+
*/
|
|
90
|
+
export function parseExpiresAtPatch(raw, now = new Date()) {
|
|
91
|
+
if (raw === undefined)
|
|
92
|
+
return undefined;
|
|
93
|
+
if (raw === null || raw === '')
|
|
94
|
+
return null;
|
|
95
|
+
// parseExpiresAt rejects the past/unparseable and never returns undefined here.
|
|
96
|
+
return parseExpiresAt(raw, now) ?? null;
|
|
97
|
+
}
|
|
98
|
+
/** Drop the digest before a token record leaves the process. */
|
|
99
|
+
export function toTokenView(token) {
|
|
100
|
+
const { hash: _hash, ...view } = token;
|
|
101
|
+
return view;
|
|
102
|
+
}
|
|
103
|
+
/** Parse a role from a request body; throws on anything but `admin`/`read`. */
|
|
104
|
+
export function parseTokenRole(raw) {
|
|
105
|
+
if (raw === 'admin' || raw === 'read')
|
|
106
|
+
return raw;
|
|
107
|
+
throw new Error("role must be 'admin' or 'read'");
|
|
108
|
+
}
|
|
109
|
+
/** Parse and trim a token label; throws when it cannot name a person safely. */
|
|
110
|
+
export function parseTokenName(raw) {
|
|
111
|
+
if (typeof raw !== 'string')
|
|
112
|
+
throw new Error('name must be a string');
|
|
113
|
+
const name = raw.trim();
|
|
114
|
+
if (!TOKEN_NAME_PATTERN.test(name)) {
|
|
115
|
+
throw new Error(`invalid name: must match ${TOKEN_NAME_PATTERN.source} (1..64 chars)`);
|
|
116
|
+
}
|
|
117
|
+
return name;
|
|
118
|
+
}
|
|
119
|
+
/** Parse an optional ISO expiry; throws when it is unparseable or already past. */
|
|
120
|
+
export function parseExpiresAt(raw, now = new Date()) {
|
|
121
|
+
if (raw === undefined || raw === null || raw === '')
|
|
122
|
+
return undefined;
|
|
123
|
+
if (typeof raw !== 'string')
|
|
124
|
+
throw new Error('expiresAt must be an ISO date string');
|
|
125
|
+
const ms = Date.parse(raw);
|
|
126
|
+
if (!Number.isFinite(ms))
|
|
127
|
+
throw new Error('expiresAt must be an ISO date string');
|
|
128
|
+
if (ms <= now.getTime())
|
|
129
|
+
throw new Error('expiresAt must be in the future');
|
|
130
|
+
return new Date(ms).toISOString();
|
|
131
|
+
}
|
package/lib/web.d.ts
CHANGED
|
@@ -4,10 +4,19 @@
|
|
|
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.
|
|
10
19
|
*
|
|
11
20
|
* @module
|
|
12
21
|
*/
|
|
13
|
-
export declare const WEB_UI_HTML = "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>dsh-ops-access-hub</title>\n<style>\n :root { color-scheme: light dark; }\n * { box-sizing: border-box; }\n body { font-family: system-ui, -apple-system, \"Segoe UI\", sans-serif; margin: 0; background: #0f1419; color: #d8dee6; }\n header { display: flex; gap: 8px; align-items: center; padding: 12px 16px; background: #161d26; border-bottom: 1px solid #2a3441; flex-wrap: wrap; }\n header h1 { font-size: 16px; margin: 0 12px 0 0; }\n input, select, textarea, button { font: inherit; border-radius: 4px; border: 1px solid #3a4655; background: #0f1419; color: inherit; padding: 6px 8px; }\n button { cursor: pointer; background: #1f2937; }\n button:hover { background: #2a3646; }\n button.danger { border-color: #7f2d2d; color: #f0a0a0; }\n main { padding: 16px; max-width: 1100px; margin: 0 auto; }\n table { width: 100%; border-collapse: collapse; margin-top: 12px; }\n th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #2a3441; vertical-align: top; }\n th { color: #8b98a9; font-weight: 600; font-size: 13px; }\n .badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; margin-right: 4px; border: 1px solid #3a4655; }\n .badge.verified { border-color: #2f7d4f; color: #7fd6a4; }\n .badge.mismatch { border-color: #a33; color: #f0a0a0; }\n .badge.unverifiable { border-color: #8a6d1d; color: #e6c96a; }\n .muted { color: #8b98a9; }\n #msg { margin: 8px 0; min-height: 20px; color: #f0a0a0; white-space: pre-wrap; }\n #msg.ok { color: #7fd6a4; }\n dialog { background: #161d26; color: inherit; border: 1px solid #3a4655; border-radius: 8px; padding: 16px; width: min(640px, 92vw); }\n dialog label { display: block; margin: 8px 0 2px; font-size: 13px; color: #8b98a9; }\n dialog input, dialog select, dialog textarea { width: 100%; }\n dialog textarea { min-height: 160px; font-family: ui-monospace, monospace; font-size: 13px; }\n .row { display: flex; gap: 8px; }\n .row > div { flex: 1; }\n section { margin-top: 24px; }\n h2 { font-size: 15px; }\n</style>\n</head>\n<body>\n<header>\n <h1>dsh-ops-access-hub</h1>\n <input id=\"token\" type=\"password\" placeholder=\"Bearer token\" size=\"36\">\n <button id=\"saveToken\">\u4FDD\u5B58 token</button>\n <button id=\"refresh\">\u5237\u65B0</button>\n <button id=\"newEntry\">\u65B0\u5EFA\u6761\u76EE</button>\n <button id=\"showAudit\">\u5BA1\u8BA1\u8BB0\u5F55</button>\n <span id=\"whoami\" class=\"muted\"></span>\n</header>\n<main>\n <div id=\"msg\"></div>\n <section>\n <h2>\u6761\u76EE</h2>\n <table>\n <thead><tr><th>kind / name</th><th>\u663E\u793A\u540D</th><th>\u63CF\u8FF0</th><th>\u73AF\u5883</th><th>tier</th><th>\u66F4\u65B0\u65F6\u95F4</th><th>\u64CD\u4F5C</th></tr></thead>\n <tbody id=\"entries\"></tbody>\n </table>\n </section>\n <section id=\"auditSection\" style=\"display:none\">\n <h2>\u5BA1\u8BA1\u8BB0\u5F55(\u6700\u8FD1 100 \u6761)</h2>\n <table>\n <thead><tr><th>\u65F6\u95F4</th><th>\u89D2\u8272</th><th>\u52A8\u4F5C</th><th>\u6761\u76EE</th><th>tier</th></tr></thead>\n <tbody id=\"audit\"></tbody>\n </table>\n </section>\n</main>\n<dialog id=\"editor\">\n <h2 id=\"editorTitle\">\u7F16\u8F91\u6761\u76EE</h2>\n <div class=\"row\">\n <div><label>kind</label><input id=\"fKind\" placeholder=\"k8s\"></div>\n <div><label>profile \u540D</label><input id=\"fName\" placeholder=\"prod\"></div>\n <div><label>tier</label><select id=\"fTier\"><option value=\"ro\">ro</option><option value=\"rw\">rw</option></select></div>\n </div>\n <div class=\"row\">\n <div><label>\u663E\u793A\u540D(envelope.name)</label><input id=\"fDisp\"></div>\n <div><label>\u73AF\u5883(envelope.environment)</label><input id=\"fEnv\"></div>\n </div>\n <label>\u63CF\u8FF0(envelope.description)</label><input id=\"fDesc\">\n <label>\u5B57\u6BB5(JSON object,\u503C\u4E3A\u5B57\u6BB5\u5185\u5BB9)</label>\n <textarea id=\"fFields\" spellcheck=\"false\"></textarea>\n <div id=\"formErr\" style=\"color:#f0a0a0;min-height:18px;margin-top:4px\"></div>\n <div style=\"margin-top:12px;text-align:right\">\n <button id=\"cancelEdit\">\u53D6\u6D88</button>\n <button id=\"saveEntry\">\u4FDD\u5B58</button>\n </div>\n</dialog>\n<script>\n(function () {\n var token = localStorage.getItem('hubToken') || '';\n var tokenInput = document.getElementById('token');\n tokenInput.value = token;\n var msg = document.getElementById('msg');\n var entriesBody = document.getElementById('entries');\n var auditSection = document.getElementById('auditSection');\n var auditBody = document.getElementById('audit');\n var editor = document.getElementById('editor');\n var formErr = document.getElementById('formErr');\n var entriesCache = [];\n\n function esc(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, function (c) {\n return { '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[c];\n });\n }\n function say(text, ok) {\n msg.textContent = text || '';\n msg.className = ok ? 'ok' : '';\n }\n function api(path, opts) {\n opts = opts || {};\n opts.headers = { 'Authorization': 'Bearer ' + token };\n if (opts.body) opts.headers['Content-Type'] = 'application/json';\n return fetch(path, opts).then(function (res) {\n return res.json().then(function (body) { return { status: res.status, body: body }; });\n });\n }\n function probeBadge(tierInfo) {\n if (!tierInfo) return '';\n var p = tierInfo.probe;\n if (!p) return '<span class=\"badge\">\u65E0 probe</span>';\n return '<span class=\"badge ' + esc(p.status) + '\" title=\"' + esc(p.detail || '') + ' ' + esc(p.probedAt) + '\">' + esc(p.status) + '</span>';\n }\n function tierCell(e, tier) {\n var info = e.tiers && e.tiers[tier];\n if (!info) return '<span class=\"muted\">' + tier + ': \u2014</span><br>';\n return tier + ': ' + probeBadge(info) +\n ' <button data-edit=\"' + esc(e.kind) + '|' + esc(e.name) + '|' + tier + '\">\u7F16\u8F91</button>' +\n ' <button class=\"danger\" data-del=\"' + esc(e.kind) + '|' + esc(e.name) + '|' + tier + '\">\u5220\u9664</button><br>';\n }\n function render() {\n entriesBody.innerHTML = entriesCache.map(function (e) {\n var env = e.envelope || {};\n return '<tr><td><b>' + esc(e.kind) + '</b> / ' + esc(e.name) + '</td><td>' + esc(env.name || '') +\n '</td><td>' + esc(env.description || '') + '</td><td>' + esc(env.environment || '') +\n '</td><td>' + tierCell(e, 'ro') + tierCell(e, 'rw') +\n '</td><td class=\"muted\">' + esc(e.updatedAt || '') + '</td><td></td></tr>';\n }).join('') || '<tr><td colspan=\"7\" class=\"muted\">(\u7A7A)</td></tr>';\n }\n function load() {\n api('/entries').then(function (r) {\n if (r.status !== 200) { say('\u52A0\u8F7D\u5931\u8D25:' + (r.body && r.body.error || r.status)); return; }\n entriesCache = r.body;\n render();\n say('');\n }).catch(function (err) { say('\u8BF7\u6C42\u5931\u8D25:' + err.message); });\n }\n document.getElementById('saveToken').onclick = function () {\n token = tokenInput.value.trim();\n localStorage.setItem('hubToken', token);\n say('token \u5DF2\u4FDD\u5B58', true);\n load();\n };\n document.getElementById('refresh').onclick = load;\n entriesBody.addEventListener('click', function (ev) {\n var t = ev.target;\n if (!(t instanceof HTMLElement)) return;\n var edit = t.getAttribute('data-edit');\n var del = t.getAttribute('data-del');\n if (edit) { var p = edit.split('|'); openEditor(p[0], p[1], p[2]); }\n if (del) {\n var q = del.split('|');\n if (!confirm('\u786E\u8BA4\u5220\u9664 ' + q[0] + '/' + q[1] + ' \u7684 ' + q[2] + ' tier?')) return;\n api('/entries/' + encodeURIComponent(q[0]) + '/' + encodeURIComponent(q[1]) + '/' + q[2], { method: 'DELETE' })\n .then(function (r) {\n if (r.status !== 200) { say('\u5220\u9664\u5931\u8D25:' + (r.body && r.body.error || r.status)); return; }\n say('\u5DF2\u5220\u9664', true); load();\n });\n }\n });\n function openEditor(kind, name, tier) {\n document.getElementById('fKind').value = kind || '';\n document.getElementById('fName').value = name || '';\n document.getElementById('fTier').value = tier || 'ro';\n document.getElementById('fKind').disabled = !!kind;\n document.getElementById('fName').disabled = !!name;\n document.getElementById('fTier').disabled = !!tier;\n formErr.textContent = '';\n var env = {};\n for (var i = 0; i < entriesCache.length; i++) {\n if (entriesCache[i].kind === kind && entriesCache[i].name === name) env = entriesCache[i].envelope || {};\n }\n document.getElementById('fDisp').value = env.name || '';\n document.getElementById('fDesc').value = env.description || '';\n document.getElementById('fEnv').value = env.environment || '';\n var fieldsBox = document.getElementById('fFields');\n fieldsBox.value = '{}';\n if (kind && name && tier) {\n api('/entries/' + encodeURIComponent(kind) + '/' + encodeURIComponent(name) + '/' + tier).then(function (r) {\n if (r.status === 200) fieldsBox.value = JSON.stringify(r.body.fields || {}, null, 2);\n });\n }\n editor.showModal();\n }\n document.getElementById('newEntry').onclick = function () { openEditor('', '', ''); };\n document.getElementById('cancelEdit').onclick = function () { editor.close(); };\n document.getElementById('saveEntry').onclick = function () {\n var kind = document.getElementById('fKind').value.trim();\n var name = document.getElementById('fName').value.trim();\n var tier = document.getElementById('fTier').value;\n var fields;\n try { fields = JSON.parse(document.getElementById('fFields').value); }\n catch (err) { formErr.textContent = '\u5B57\u6BB5 JSON \u89E3\u6790\u5931\u8D25:' + err.message; return; }\n if (!fields || typeof fields !== 'object' || Array.isArray(fields)) { formErr.textContent = '\u5B57\u6BB5\u5FC5\u987B\u662F JSON object'; return; }\n var envelope = {};\n var disp = document.getElementById('fDisp').value.trim();\n var desc = document.getElementById('fDesc').value.trim();\n var envv = document.getElementById('fEnv').value.trim();\n if (disp) envelope.name = disp;\n if (desc) envelope.description = desc;\n if (envv) envelope.environment = envv;\n api('/entries/' + encodeURIComponent(kind) + '/' + encodeURIComponent(name) + '/' + tier, {\n method: 'PUT',\n body: JSON.stringify({ fields: fields, envelope: envelope }),\n }).then(function (r) {\n if (r.status !== 200) { formErr.textContent = '\u4FDD\u5B58\u5931\u8D25:' + (r.body && r.body.error || r.status); return; }\n editor.close(); say('\u5DF2\u4FDD\u5B58', true); load();\n });\n };\n document.getElementById('showAudit').onclick = function () {\n auditSection.style.display = auditSection.style.display === 'none' ? '' : 'none';\n if (auditSection.style.display === 'none') return;\n api('/audit?limit=100').then(function (r) {\n if (r.status !== 200) { say('\u5BA1\u8BA1\u52A0\u8F7D\u5931\u8D25:' + (r.body && r.body.error || r.status)); return; }\n auditBody.innerHTML = r.body.map(function (a) {\n return '<tr><td class=\"muted\">' + esc(a.ts) + '</td><td>' + esc(a.role) + '</td><td>' + esc(a.action) +\n '</td><td>' + esc(a.kind) + ' / ' + esc(a.name) + '</td><td>' + esc(a.tier) + '</td></tr>';\n }).join('') || '<tr><td colspan=\"5\" class=\"muted\">(\u7A7A)</td></tr>';\n });\n };\n load();\n})();\n</script>\n</body>\n</html>\n";
|
|
22
|
+
export declare const WEB_UI_HTML = "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>dsh-ops-access-hub</title>\n<style>\n :root { color-scheme: light dark; }\n * { box-sizing: border-box; }\n body { font-family: system-ui, -apple-system, \"Segoe UI\", sans-serif; margin: 0; background: #0f1419; color: #d8dee6; }\n header { display: flex; gap: 8px; align-items: center; padding: 12px 16px; background: #161d26; border-bottom: 1px solid #2a3441; flex-wrap: wrap; }\n header h1 { font-size: 16px; margin: 0 12px 0 0; }\n input, select, textarea, button { font: inherit; border-radius: 4px; border: 1px solid #3a4655; background: #0f1419; color: inherit; padding: 6px 8px; }\n button { cursor: pointer; background: #1f2937; }\n button:hover { background: #2a3646; }\n button.danger { border-color: #7f2d2d; color: #f0a0a0; }\n main { padding: 16px; max-width: 1100px; margin: 0 auto; }\n table { width: 100%; border-collapse: collapse; margin-top: 12px; }\n th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #2a3441; vertical-align: top; }\n th { color: #8b98a9; font-weight: 600; font-size: 13px; }\n .badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; margin-right: 4px; border: 1px solid #3a4655; }\n .badge.verified { border-color: #2f7d4f; color: #7fd6a4; }\n .badge.mismatch { border-color: #a33; color: #f0a0a0; }\n .badge.unverifiable { border-color: #8a6d1d; color: #e6c96a; }\n .badge.expired { border-color: #a33; color: #f0a0a0; }\n .badge.revoked { border-color: #4a5666; color: #8b98a9; }\n .muted { color: #8b98a9; }\n #msg { margin: 8px 0; min-height: 20px; color: #f0a0a0; white-space: pre-wrap; }\n #msg.ok { color: #7fd6a4; }\n dialog { background: #161d26; color: inherit; border: 1px solid #3a4655; border-radius: 8px; padding: 16px; width: min(640px, 92vw); }\n dialog label { display: block; margin: 8px 0 2px; font-size: 13px; color: #8b98a9; }\n dialog input, dialog select, dialog textarea { width: 100%; }\n dialog textarea { min-height: 160px; font-family: ui-monospace, monospace; font-size: 13px; }\n .row { display: flex; gap: 8px; }\n .row > div { flex: 1; }\n section { margin-top: 24px; }\n h2 { font-size: 15px; }\n</style>\n</head>\n<body>\n<header>\n <h1>dsh-ops-access-hub</h1>\n <input id=\"token\" type=\"password\" placeholder=\"Bearer token\" size=\"36\">\n <button id=\"saveToken\">\u4FDD\u5B58 token</button>\n <button id=\"refresh\">\u5237\u65B0</button>\n <button id=\"newEntry\">\u65B0\u5EFA\u6761\u76EE</button>\n <button id=\"showTokens\">token \u7BA1\u7406</button>\n <button id=\"showAudit\">\u5BA1\u8BA1\u8BB0\u5F55</button>\n <span id=\"whoami\" class=\"muted\"></span>\n</header>\n<main>\n <div id=\"msg\"></div>\n <section>\n <h2>\u6761\u76EE</h2>\n <table>\n <thead><tr><th>kind / name</th><th>\u663E\u793A\u540D</th><th>\u63CF\u8FF0</th><th>\u73AF\u5883</th><th>tier</th><th>\u66F4\u65B0\u65F6\u95F4</th><th>\u64CD\u4F5C</th></tr></thead>\n <tbody id=\"entries\"></tbody>\n </table>\n </section>\n <section id=\"tokenSection\" style=\"display:none\">\n <h2>\u5177\u540D token(\u6BCF\u4EBA\u4E00\u4E2A,\u53EF\u5355\u72EC\u540A\u9500)</h2>\n <div class=\"row\" style=\"align-items:center;margin-bottom:8px\">\n <div><button id=\"newToken\">\u65B0\u5EFA token</button></div>\n <div class=\"muted\">\u660E\u6587\u53EA\u5728\u521B\u5EFA\u65F6\u663E\u793A\u4E00\u6B21,\u8BF7\u7ACB\u5373\u4EA4\u4ED8\u6301\u6709\u4EBA;\u670D\u52A1\u7AEF\u53EA\u5B58\u6458\u8981</div>\n </div>\n <div class=\"row\" style=\"align-items:center;margin-bottom:8px\">\n <div><input id=\"tokenFilter\" placeholder=\"\u641C\u7D22 \u540D\u79F0 / \u524D\u7F00 / \u521B\u5EFA\u8005\" style=\"width:100%\"></div>\n <div><select id=\"tokenStatusFilter\">\n <option value=\"\">\u5168\u90E8\u72B6\u6001</option>\n <option value=\"active\">\u6709\u6548</option>\n <option value=\"expiring\">\u5373\u5C06\u8FC7\u671F</option>\n <option value=\"expired\">\u5DF2\u8FC7\u671F</option>\n <option value=\"revoked\">\u5DF2\u540A\u9500</option>\n </select></div>\n <div><select id=\"tokenRoleFilter\">\n <option value=\"\">\u5168\u90E8\u89D2\u8272</option>\n <option value=\"admin\">admin</option>\n <option value=\"read\">read</option>\n </select></div>\n </div>\n <div id=\"tokenSummary\" class=\"muted\"></div>\n <div id=\"issuedBox\" style=\"display:none;border:1px solid #8a6d1d;border-radius:6px;padding:8px;margin:8px 0\">\n <div class=\"muted\">\u65B0 token \u660E\u6587(\u53EA\u663E\u793A\u8FD9\u4E00\u6B21,\u5173\u95ED\u6216\u5237\u65B0\u540E\u65E0\u6CD5\u518D\u6B21\u67E5\u770B):</div>\n <code id=\"issuedToken\" style=\"word-break:break-all\"></code>\n <button id=\"copyIssued\">\u590D\u5236</button>\n </div>\n <table>\n <thead><tr><th>\u540D\u79F0(\u6301\u6709\u4EBA)</th><th>\u89D2\u8272</th><th>\u524D\u7F00</th><th>\u521B\u5EFA\u8005</th><th>\u521B\u5EFA\u65F6\u95F4</th><th>\u5230\u671F\u65F6\u95F4</th><th>\u72B6\u6001</th><th>\u64CD\u4F5C</th></tr></thead>\n <tbody id=\"tokens\"></tbody>\n </table>\n </section>\n <section id=\"auditSection\" style=\"display:none\">\n <h2>\u5BA1\u8BA1\u8BB0\u5F55(\u6700\u8FD1 100 \u6761)</h2>\n <table>\n <thead><tr><th>\u65F6\u95F4</th><th>\u89D2\u8272</th><th>\u64CD\u4F5C\u8005(token)</th><th>\u52A8\u4F5C</th><th>\u6761\u76EE</th><th>tier</th></tr></thead>\n <tbody id=\"audit\"></tbody>\n </table>\n </section>\n</main>\n<dialog id=\"tokenEditor\">\n <h2>\u65B0\u5EFA token</h2>\n <label>\u6301\u6709\u4EBA\u540D\u79F0(\u5BA1\u8BA1\u4E2D\u7684\u64CD\u4F5C\u8005)</label><input id=\"tName\" placeholder=\"alice\">\n <div class=\"row\">\n <div><label>\u89D2\u8272</label><select id=\"tRole\"><option value=\"read\">read</option><option value=\"admin\">admin</option></select></div>\n <div><label>\u6709\u6548\u671F(\u53EF\u9009,ISO \u65F6\u95F4,\u7559\u7A7A\u4E3A\u957F\u671F)</label><input id=\"tExpires\" placeholder=\"2026-12-31T00:00:00Z\"></div>\n </div>\n <div id=\"tokenErr\" style=\"color:#f0a0a0;min-height:18px;margin-top:4px\"></div>\n <div style=\"margin-top:12px;text-align:right\">\n <button id=\"cancelToken\">\u53D6\u6D88</button>\n <button id=\"createToken\">\u7B7E\u53D1</button>\n </div>\n</dialog>\n<dialog id=\"tokenEdit\">\n <h2>\u7F16\u8F91 token</h2>\n <div class=\"muted\">\u53EA\u6539\u6807\u7B7E / \u89D2\u8272 / \u6709\u6548\u671F:token \u660E\u6587\u4E0D\u53D8,\u6301\u6709\u4EBA\u65E0\u9700\u91CD\u65B0\u914D\u7F6E</div>\n <label>\u6301\u6709\u4EBA\u540D\u79F0(\u5BA1\u8BA1\u4E2D\u7684\u64CD\u4F5C\u8005)</label><input id=\"eName\">\n <div class=\"row\">\n <div><label>\u89D2\u8272</label><select id=\"eRole\"><option value=\"read\">read</option><option value=\"admin\">admin</option></select></div>\n <div><label>\u6709\u6548\u671F(ISO \u65F6\u95F4)</label><input id=\"eExpires\" placeholder=\"2026-12-31T00:00:00Z\"></div>\n </div>\n <label style=\"margin-top:8px\"><input type=\"checkbox\" id=\"eClearExpires\" style=\"width:auto\"> \u6E05\u9664\u6709\u6548\u671F(\u6539\u4E3A\u957F\u671F\u6709\u6548)</label>\n <div id=\"tokenEditErr\" style=\"color:#f0a0a0;min-height:18px;margin-top:4px\"></div>\n <div style=\"margin-top:12px;text-align:right\">\n <button id=\"cancelTokenEdit\">\u53D6\u6D88</button>\n <button id=\"saveTokenEdit\">\u4FDD\u5B58\u4FEE\u6539</button>\n </div>\n</dialog>\n<dialog id=\"editor\">\n <h2 id=\"editorTitle\">\u7F16\u8F91\u6761\u76EE</h2>\n <div class=\"row\">\n <div><label>kind</label><input id=\"fKind\" placeholder=\"k8s\"></div>\n <div><label>profile \u540D</label><input id=\"fName\" placeholder=\"prod\"></div>\n <div><label>tier</label><select id=\"fTier\"><option value=\"ro\">ro</option><option value=\"rw\">rw</option></select></div>\n </div>\n <div class=\"row\">\n <div><label>\u663E\u793A\u540D(envelope.name)</label><input id=\"fDisp\"></div>\n <div><label>\u73AF\u5883(envelope.environment)</label><input id=\"fEnv\"></div>\n </div>\n <label>\u63CF\u8FF0(envelope.description)</label><input id=\"fDesc\">\n <label>\u5B57\u6BB5(JSON object,\u503C\u4E3A\u5B57\u6BB5\u5185\u5BB9)</label>\n <textarea id=\"fFields\" spellcheck=\"false\"></textarea>\n <div id=\"formErr\" style=\"color:#f0a0a0;min-height:18px;margin-top:4px\"></div>\n <div style=\"margin-top:12px;text-align:right\">\n <button id=\"cancelEdit\">\u53D6\u6D88</button>\n <button id=\"saveEntry\">\u4FDD\u5B58</button>\n </div>\n</dialog>\n<script>\n(function () {\n var token = localStorage.getItem('hubToken') || '';\n var tokenInput = document.getElementById('token');\n tokenInput.value = token;\n var msg = document.getElementById('msg');\n var entriesBody = document.getElementById('entries');\n var auditSection = document.getElementById('auditSection');\n var auditBody = document.getElementById('audit');\n var tokenSection = document.getElementById('tokenSection');\n var tokenBody = document.getElementById('tokens');\n var issuedBox = document.getElementById('issuedBox');\n var issuedToken = document.getElementById('issuedToken');\n var tokenEditor = document.getElementById('tokenEditor');\n var tokenErr = document.getElementById('tokenErr');\n var tokenEdit = document.getElementById('tokenEdit');\n var tokenEditErr = document.getElementById('tokenEditErr');\n var tokenSummary = document.getElementById('tokenSummary');\n var tokensCache = [];\n var editingTokenId = '';\n var whoami = document.getElementById('whoami');\n var editor = document.getElementById('editor');\n var formErr = document.getElementById('formErr');\n var entriesCache = [];\n\n function esc(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, function (c) {\n return { '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[c];\n });\n }\n function say(text, ok) {\n msg.textContent = text || '';\n msg.className = ok ? 'ok' : '';\n }\n function api(path, opts) {\n opts = opts || {};\n opts.headers = { 'Authorization': 'Bearer ' + token };\n if (opts.body) opts.headers['Content-Type'] = 'application/json';\n return fetch(path, opts).then(function (res) {\n return res.json().then(function (body) { return { status: res.status, body: body }; });\n });\n }\n function probeBadge(tierInfo) {\n if (!tierInfo) return '';\n var p = tierInfo.probe;\n if (!p) return '<span class=\"badge\">\u65E0 probe</span>';\n return '<span class=\"badge ' + esc(p.status) + '\" title=\"' + esc(p.detail || '') + ' ' + esc(p.probedAt) + '\">' + esc(p.status) + '</span>';\n }\n function tierCell(e, tier) {\n var info = e.tiers && e.tiers[tier];\n if (!info) return '<span class=\"muted\">' + tier + ': \u2014</span><br>';\n return tier + ': ' + probeBadge(info) +\n ' <button data-edit=\"' + esc(e.kind) + '|' + esc(e.name) + '|' + tier + '\">\u7F16\u8F91</button>' +\n ' <button class=\"danger\" data-del=\"' + esc(e.kind) + '|' + esc(e.name) + '|' + tier + '\">\u5220\u9664</button><br>';\n }\n function render() {\n entriesBody.innerHTML = entriesCache.map(function (e) {\n var env = e.envelope || {};\n return '<tr><td><b>' + esc(e.kind) + '</b> / ' + esc(e.name) + '</td><td>' + esc(env.name || '') +\n '</td><td>' + esc(env.description || '') + '</td><td>' + esc(env.environment || '') +\n '</td><td>' + tierCell(e, 'ro') + tierCell(e, 'rw') +\n '</td><td class=\"muted\">' + esc(e.updatedAt || '') + '</td><td></td></tr>';\n }).join('') || '<tr><td colspan=\"7\" class=\"muted\">(\u7A7A)</td></tr>';\n }\n function load() {\n api('/entries').then(function (r) {\n if (r.status !== 200) { say('\u52A0\u8F7D\u5931\u8D25:' + (r.body && r.body.error || r.status)); return; }\n entriesCache = r.body;\n render();\n say('');\n }).catch(function (err) { say('\u8BF7\u6C42\u5931\u8D25:' + err.message); });\n api('/whoami').then(function (r) {\n if (r.status !== 200) { whoami.textContent = ''; return; }\n whoami.textContent = r.body.actor + ' / ' + r.body.role + (r.body.source === 'static' ? ' (\u9759\u6001)' : ' (\u5177\u540D)');\n });\n }\n document.getElementById('saveToken').onclick = function () {\n token = tokenInput.value.trim();\n localStorage.setItem('hubToken', token);\n say('token \u5DF2\u4FDD\u5B58', true);\n load();\n };\n document.getElementById('refresh').onclick = load;\n entriesBody.addEventListener('click', function (ev) {\n var t = ev.target;\n if (!(t instanceof HTMLElement)) return;\n var edit = t.getAttribute('data-edit');\n var del = t.getAttribute('data-del');\n if (edit) { var p = edit.split('|'); openEditor(p[0], p[1], p[2]); }\n if (del) {\n var q = del.split('|');\n if (!confirm('\u786E\u8BA4\u5220\u9664 ' + q[0] + '/' + q[1] + ' \u7684 ' + q[2] + ' tier?')) return;\n api('/entries/' + encodeURIComponent(q[0]) + '/' + encodeURIComponent(q[1]) + '/' + q[2], { method: 'DELETE' })\n .then(function (r) {\n if (r.status !== 200) { say('\u5220\u9664\u5931\u8D25:' + (r.body && r.body.error || r.status)); return; }\n say('\u5DF2\u5220\u9664', true); load();\n });\n }\n });\n function openEditor(kind, name, tier) {\n document.getElementById('fKind').value = kind || '';\n document.getElementById('fName').value = name || '';\n document.getElementById('fTier').value = tier || 'ro';\n document.getElementById('fKind').disabled = !!kind;\n document.getElementById('fName').disabled = !!name;\n document.getElementById('fTier').disabled = !!tier;\n formErr.textContent = '';\n var env = {};\n for (var i = 0; i < entriesCache.length; i++) {\n if (entriesCache[i].kind === kind && entriesCache[i].name === name) env = entriesCache[i].envelope || {};\n }\n document.getElementById('fDisp').value = env.name || '';\n document.getElementById('fDesc').value = env.description || '';\n document.getElementById('fEnv').value = env.environment || '';\n var fieldsBox = document.getElementById('fFields');\n fieldsBox.value = '{}';\n if (kind && name && tier) {\n api('/entries/' + encodeURIComponent(kind) + '/' + encodeURIComponent(name) + '/' + tier).then(function (r) {\n if (r.status === 200) fieldsBox.value = JSON.stringify(r.body.fields || {}, null, 2);\n });\n }\n editor.showModal();\n }\n document.getElementById('newEntry').onclick = function () { openEditor('', '', ''); };\n document.getElementById('cancelEdit').onclick = function () { editor.close(); };\n document.getElementById('saveEntry').onclick = function () {\n var kind = document.getElementById('fKind').value.trim();\n var name = document.getElementById('fName').value.trim();\n var tier = document.getElementById('fTier').value;\n var fields;\n try { fields = JSON.parse(document.getElementById('fFields').value); }\n catch (err) { formErr.textContent = '\u5B57\u6BB5 JSON \u89E3\u6790\u5931\u8D25:' + err.message; return; }\n if (!fields || typeof fields !== 'object' || Array.isArray(fields)) { formErr.textContent = '\u5B57\u6BB5\u5FC5\u987B\u662F JSON object'; return; }\n var envelope = {};\n var disp = document.getElementById('fDisp').value.trim();\n var desc = document.getElementById('fDesc').value.trim();\n var envv = document.getElementById('fEnv').value.trim();\n if (disp) envelope.name = disp;\n if (desc) envelope.description = desc;\n if (envv) envelope.environment = envv;\n api('/entries/' + encodeURIComponent(kind) + '/' + encodeURIComponent(name) + '/' + tier, {\n method: 'PUT',\n body: JSON.stringify({ fields: fields, envelope: envelope }),\n }).then(function (r) {\n if (r.status !== 200) { formErr.textContent = '\u4FDD\u5B58\u5931\u8D25:' + (r.body && r.body.error || r.status); return; }\n editor.close(); say('\u5DF2\u4FDD\u5B58', true); load();\n });\n };\n document.getElementById('showAudit').onclick = function () {\n auditSection.style.display = auditSection.style.display === 'none' ? '' : 'none';\n if (auditSection.style.display === 'none') return;\n api('/audit?limit=100').then(function (r) {\n if (r.status !== 200) { say('\u5BA1\u8BA1\u52A0\u8F7D\u5931\u8D25:' + (r.body && r.body.error || r.status)); return; }\n auditBody.innerHTML = r.body.map(function (a) {\n return '<tr><td class=\"muted\">' + esc(a.ts) + '</td><td>' + esc(a.role) + '</td><td>' + esc(a.actor || '\u2014') +\n '</td><td>' + esc(a.action + (a.changes && a.changes.length ? ' (' + a.changes.join(', ') + ')' : '')) +\n '</td><td>' + esc(a.kind) + ' / ' + esc(a.name) + '</td><td>' + esc(a.tier) + '</td></tr>';\n }).join('') || '<tr><td colspan=\"6\" class=\"muted\">(\u7A7A)</td></tr>';\n });\n };\n // Mirrors tokenStatus() in tokens.ts: active and expiring both still\n // authenticate; the split is what lets an operator renew in time.\n var EXPIRING_SOON_MS = 7 * 24 * 60 * 60 * 1000;\n function tokenStatus(t) {\n if (t.revokedAt) return 'revoked';\n if (t.expiresAt) {\n var ms = Date.parse(t.expiresAt);\n if (!isFinite(ms) || ms <= Date.now()) return 'expired';\n if (ms - Date.now() <= EXPIRING_SOON_MS) return 'expiring';\n }\n return 'active';\n }\n function daysLeft(iso) {\n return Math.ceil((Date.parse(iso) - Date.now()) / 86400000);\n }\n function localTime(iso) {\n if (!iso) return '';\n var d = new Date(iso);\n return isNaN(d.getTime()) ? String(iso) : d.toLocaleString();\n }\n function statusCell(t) {\n var s = tokenStatus(t);\n if (s === 'revoked') return '<span class=\"badge revoked\">\u5DF2\u540A\u9500</span>';\n if (s === 'expired') return '<span class=\"badge expired\">\u5DF2\u8FC7\u671F</span>';\n if (s === 'expiring') return '<span class=\"badge unverifiable\">' + daysLeft(t.expiresAt) + ' \u5929\u540E\u8FC7\u671F</span>';\n return '<span class=\"badge verified\">\u6709\u6548</span>';\n }\n function findToken(id) {\n for (var i = 0; i < tokensCache.length; i++) if (tokensCache[i].id === id) return tokensCache[i];\n return null;\n }\n function renderTokens() {\n var q = document.getElementById('tokenFilter').value.trim().toLowerCase();\n var statusFilter = document.getElementById('tokenStatusFilter').value;\n var roleFilter = document.getElementById('tokenRoleFilter').value;\n var counts = { active: 0, expiring: 0, expired: 0, revoked: 0 };\n tokensCache.forEach(function (t) { counts[tokenStatus(t)] += 1; });\n var rows = tokensCache.filter(function (t) {\n if (roleFilter && t.role !== roleFilter) return false;\n if (statusFilter && tokenStatus(t) !== statusFilter) return false;\n if (!q) return true;\n return (t.name + ' ' + t.prefix + ' ' + t.createdBy).toLowerCase().indexOf(q) !== -1;\n });\n tokenSummary.textContent = '\u5171 ' + tokensCache.length + ' \u6761:\u6709\u6548 ' + counts.active + ' \u00B7 \u5373\u5C06\u8FC7\u671F ' +\n counts.expiring + ' \u00B7 \u5DF2\u8FC7\u671F ' + counts.expired + ' \u00B7 \u5DF2\u540A\u9500 ' + counts.revoked +\n (rows.length === tokensCache.length ? '' : ' \u2014 \u5F53\u524D\u7B5B\u9009\u547D\u4E2D ' + rows.length + ' \u6761');\n tokenBody.innerHTML = rows.map(function (t) {\n var actions = t.revokedAt ? '<span class=\"muted\">\u2014</span>' :\n '<button data-edit-token=\"' + esc(t.id) + '\">\u7F16\u8F91</button> ' +\n '<button class=\"danger\" data-revoke=\"' + esc(t.id) + '\">\u540A\u9500</button>';\n return '<tr><td><b>' + esc(t.name) + '</b></td><td>' + esc(t.role) + '</td><td class=\"muted\">' + esc(t.prefix) +\n '...</td><td class=\"muted\">' + esc(t.createdBy) + '</td><td class=\"muted\">' + esc(localTime(t.createdAt)) +\n '</td><td class=\"muted\">' + esc(t.expiresAt ? localTime(t.expiresAt) : '\u957F\u671F') + '</td><td>' + statusCell(t) +\n '</td><td>' + actions + '</td></tr>';\n }).join('') || '<tr><td colspan=\"8\" class=\"muted\">(\u6CA1\u6709\u5339\u914D\u7684 token)</td></tr>';\n }\n function loadTokens() {\n api('/tokens').then(function (r) {\n if (r.status !== 200) {\n tokensCache = [];\n tokenSummary.textContent = '';\n tokenBody.innerHTML = '<tr><td colspan=\"8\" class=\"muted\">\u52A0\u8F7D\u5931\u8D25(\u9700\u8981 admin token):' + esc(r.body && r.body.error || r.status) + '</td></tr>';\n return;\n }\n tokensCache = r.body;\n renderTokens();\n });\n }\n document.getElementById('tokenFilter').oninput = renderTokens;\n document.getElementById('tokenStatusFilter').onchange = renderTokens;\n document.getElementById('tokenRoleFilter').onchange = renderTokens;\n document.getElementById('showTokens').onclick = function () {\n tokenSection.style.display = tokenSection.style.display === 'none' ? '' : 'none';\n if (tokenSection.style.display === 'none') { issuedBox.style.display = 'none'; return; }\n loadTokens();\n };\n document.getElementById('newToken').onclick = function () {\n document.getElementById('tName').value = '';\n document.getElementById('tRole').value = 'read';\n document.getElementById('tExpires').value = '';\n tokenErr.textContent = '';\n tokenEditor.showModal();\n };\n document.getElementById('cancelToken').onclick = function () { tokenEditor.close(); };\n document.getElementById('createToken').onclick = function () {\n var body = {\n name: document.getElementById('tName').value,\n role: document.getElementById('tRole').value,\n expiresAt: document.getElementById('tExpires').value,\n };\n api('/tokens', { method: 'POST', body: JSON.stringify(body) }).then(function (r) {\n if (r.status !== 200) { tokenErr.textContent = '\u7B7E\u53D1\u5931\u8D25:' + (r.body && r.body.error || r.status); return; }\n tokenEditor.close();\n issuedBox.style.display = '';\n issuedToken.textContent = r.body.token;\n say('\u5DF2\u7B7E\u53D1 token ' + r.body.name + '(' + r.body.role + '),\u660E\u6587\u53EA\u663E\u793A\u8FD9\u4E00\u6B21', true);\n loadTokens();\n });\n };\n document.getElementById('copyIssued').onclick = function () {\n if (navigator.clipboard) navigator.clipboard.writeText(issuedToken.textContent || '');\n };\n document.getElementById('eClearExpires').onchange = function () {\n var box = document.getElementById('eExpires');\n box.disabled = this.checked;\n if (this.checked) box.value = '';\n };\n function openTokenEditor(id) {\n var t = findToken(id);\n if (!t) return;\n editingTokenId = id;\n document.getElementById('eName').value = t.name;\n document.getElementById('eRole').value = t.role;\n document.getElementById('eExpires').value = t.expiresAt || '';\n document.getElementById('eExpires').disabled = false;\n document.getElementById('eClearExpires').checked = false;\n tokenEditErr.textContent = '';\n tokenEdit.showModal();\n }\n document.getElementById('cancelTokenEdit').onclick = function () { tokenEdit.close(); };\n document.getElementById('saveTokenEdit').onclick = function () {\n var patch = {\n name: document.getElementById('eName').value,\n role: document.getElementById('eRole').value,\n };\n if (document.getElementById('eClearExpires').checked) patch.expiresAt = null;\n else {\n var exp = document.getElementById('eExpires').value.trim();\n if (exp) patch.expiresAt = exp;\n }\n api('/tokens/' + encodeURIComponent(editingTokenId), { method: 'PATCH', body: JSON.stringify(patch) }).then(function (r) {\n if (r.status !== 200) { tokenEditErr.textContent = '\u4FDD\u5B58\u5931\u8D25:' + (r.body && r.body.error || r.status); return; }\n tokenEdit.close();\n var changed = r.body.changes || [];\n say(changed.length ? '\u5DF2\u66F4\u65B0 ' + r.body.name + ':' + changed.join(', ') : '\u4E0E\u5F53\u524D\u503C\u4E00\u81F4,\u65E0\u9700\u4FEE\u6539', true);\n loadTokens();\n });\n };\n tokenBody.addEventListener('click', function (ev) {\n var t = ev.target;\n if (!(t instanceof HTMLElement)) return;\n var editId = t.getAttribute('data-edit-token');\n if (editId) { openTokenEditor(editId); return; }\n var revokeId = t.getAttribute('data-revoke');\n if (!revokeId) return;\n var token = findToken(revokeId);\n var label = token ? token.name : revokeId;\n if (!confirm('\u786E\u8BA4\u540A\u9500 ' + label + ' \u7684 token?\u6301\u6709\u4EBA\u5C06\u7ACB\u5373\u5931\u53BB\u8BBF\u95EE\u6743\u9650,\u4E14\u4E0D\u53EF\u6062\u590D')) return;\n api('/tokens/' + encodeURIComponent(revokeId), { method: 'DELETE' }).then(function (r) {\n if (r.status !== 200) { say('\u540A\u9500\u5931\u8D25:' + (r.body && r.body.error || r.status)); return; }\n say('\u5DF2\u540A\u9500 ' + label, true);\n loadTokens();\n });\n });\n load();\n})();\n</script>\n</body>\n</html>\n";
|