cdp-tunnel 2.5.21 → 2.6.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.
@@ -0,0 +1,803 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>CDP Tunnel - SaaS Management</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+ :root {
10
+ --bg: #0d1117;
11
+ --bg-card: #161b22;
12
+ --bg-card-hover: #1c2333;
13
+ --bg-input: #0d1117;
14
+ --border: #30363d;
15
+ --border-focus: #58a6ff;
16
+ --text: #e6edf3;
17
+ --text-secondary: #8b949e;
18
+ --text-muted: #6e7681;
19
+ --accent: #58a6ff;
20
+ --accent-hover: #79c0ff;
21
+ --green: #3fb950;
22
+ --green-bg: rgba(63,185,80,0.12);
23
+ --red: #f85149;
24
+ --orange: #d29922;
25
+ --radius: 8px;
26
+ --radius-sm: 6px;
27
+ --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
28
+ }
29
+ html, body { height: 100%; }
30
+ body {
31
+ font-family: var(--font);
32
+ background: var(--bg);
33
+ color: var(--text);
34
+ line-height: 1.6;
35
+ -webkit-font-smoothing: antialiased;
36
+ }
37
+ a { color: var(--accent); text-decoration: none; }
38
+ a:hover { color: var(--accent-hover); }
39
+ button {
40
+ cursor: pointer;
41
+ font-family: var(--font);
42
+ font-size: 14px;
43
+ border: none;
44
+ border-radius: var(--radius-sm);
45
+ padding: 8px 16px;
46
+ transition: all .15s ease;
47
+ }
48
+ input {
49
+ font-family: var(--font);
50
+ font-size: 14px;
51
+ background: var(--bg-input);
52
+ border: 1px solid var(--border);
53
+ border-radius: var(--radius-sm);
54
+ padding: 10px 12px;
55
+ color: var(--text);
56
+ width: 100%;
57
+ outline: none;
58
+ transition: border-color .15s ease;
59
+ }
60
+ input:focus { border-color: var(--border-focus); }
61
+ ::selection { background: var(--accent); color: #fff; }
62
+ ::-webkit-scrollbar { width: 8px; }
63
+ ::-webkit-scrollbar-track { background: var(--bg); }
64
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
65
+
66
+ .btn-primary {
67
+ background: var(--accent);
68
+ color: #fff;
69
+ font-weight: 600;
70
+ padding: 10px 20px;
71
+ }
72
+ .btn-primary:hover { background: var(--accent-hover); }
73
+ .btn-primary:disabled { opacity: .5; cursor: not-allowed; }
74
+ .btn-secondary {
75
+ background: transparent;
76
+ color: var(--text);
77
+ border: 1px solid var(--border);
78
+ }
79
+ .btn-secondary:hover { background: var(--bg-card-hover); border-color: var(--text-muted); }
80
+ .btn-danger {
81
+ background: transparent;
82
+ color: var(--red);
83
+ border: 1px solid var(--red);
84
+ font-size: 12px;
85
+ padding: 4px 10px;
86
+ }
87
+ .btn-danger:hover { background: rgba(248,81,73,0.1); }
88
+ .btn-sm { font-size: 12px; padding: 4px 10px; }
89
+
90
+ #app {
91
+ min-height: 100vh;
92
+ display: flex;
93
+ flex-direction: column;
94
+ }
95
+
96
+ /* Login */
97
+ .login-container {
98
+ flex: 1;
99
+ display: flex;
100
+ align-items: center;
101
+ justify-content: center;
102
+ padding: 24px;
103
+ }
104
+ .login-box {
105
+ width: 100%;
106
+ max-width: 400px;
107
+ background: var(--bg-card);
108
+ border: 1px solid var(--border);
109
+ border-radius: var(--radius);
110
+ padding: 32px;
111
+ }
112
+ .login-box h1 {
113
+ font-size: 22px;
114
+ font-weight: 700;
115
+ margin-bottom: 4px;
116
+ }
117
+ .login-box p {
118
+ color: var(--text-secondary);
119
+ font-size: 14px;
120
+ margin-bottom: 24px;
121
+ }
122
+ .form-group { margin-bottom: 16px; }
123
+ .form-group label {
124
+ display: block;
125
+ font-size: 13px;
126
+ font-weight: 600;
127
+ margin-bottom: 6px;
128
+ color: var(--text-secondary);
129
+ }
130
+ .error-msg {
131
+ background: rgba(248,81,73,0.1);
132
+ border: 1px solid rgba(248,81,73,0.3);
133
+ color: var(--red);
134
+ padding: 10px 12px;
135
+ border-radius: var(--radius-sm);
136
+ font-size: 13px;
137
+ margin-bottom: 16px;
138
+ display: none;
139
+ }
140
+ .error-msg.visible { display: block; }
141
+
142
+ /* Dashboard */
143
+ .dashboard {
144
+ flex: 1;
145
+ display: flex;
146
+ flex-direction: column;
147
+ }
148
+
149
+ /* Header */
150
+ .header {
151
+ display: flex;
152
+ align-items: center;
153
+ justify-content: space-between;
154
+ padding: 16px 24px;
155
+ background: var(--bg-card);
156
+ border-bottom: 1px solid var(--border);
157
+ position: sticky;
158
+ top: 0;
159
+ z-index: 10;
160
+ }
161
+ .header-left { display: flex; align-items: center; gap: 12px; }
162
+ .header-left h1 {
163
+ font-size: 18px;
164
+ font-weight: 700;
165
+ }
166
+ .header-badge {
167
+ font-size: 11px;
168
+ background: var(--accent);
169
+ color: #fff;
170
+ padding: 2px 8px;
171
+ border-radius: 10px;
172
+ font-weight: 600;
173
+ }
174
+ .header-right { display: flex; align-items: center; gap: 12px; }
175
+ .user-info {
176
+ font-size: 13px;
177
+ color: var(--text-secondary);
178
+ }
179
+ .header-right .btn-secondary { font-size: 13px; padding: 6px 14px; }
180
+
181
+ /* Main Content */
182
+ .main {
183
+ flex: 1;
184
+ padding: 24px;
185
+ max-width: 1200px;
186
+ width: 100%;
187
+ margin: 0 auto;
188
+ }
189
+
190
+ /* Section */
191
+ .section { margin-bottom: 32px; }
192
+ .section-header {
193
+ display: flex;
194
+ align-items: center;
195
+ justify-content: space-between;
196
+ margin-bottom: 16px;
197
+ }
198
+ .section-header h2 {
199
+ font-size: 16px;
200
+ font-weight: 600;
201
+ }
202
+ .section-header .subtitle {
203
+ font-size: 13px;
204
+ color: var(--text-muted);
205
+ }
206
+
207
+ /* Status Badge */
208
+ .status-indicator {
209
+ display: inline-flex;
210
+ align-items: center;
211
+ gap: 6px;
212
+ font-size: 12px;
213
+ font-weight: 500;
214
+ }
215
+ .status-dot {
216
+ width: 8px;
217
+ height: 8px;
218
+ border-radius: 50%;
219
+ flex-shrink: 0;
220
+ }
221
+ .status-dot.online { background: var(--green); box-shadow: 0 0 6px var(--green); }
222
+ .status-dot.offline { background: var(--text-muted); }
223
+
224
+ /* Browser Grid */
225
+ .browser-grid {
226
+ display: grid;
227
+ grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
228
+ gap: 16px;
229
+ }
230
+ .browser-card {
231
+ background: var(--bg-card);
232
+ border: 1px solid var(--border);
233
+ border-radius: var(--radius);
234
+ padding: 20px;
235
+ transition: border-color .15s ease, background .15s ease;
236
+ }
237
+ .browser-card:hover { border-color: var(--text-muted); }
238
+ .browser-card-header {
239
+ display: flex;
240
+ align-items: flex-start;
241
+ justify-content: space-between;
242
+ margin-bottom: 16px;
243
+ }
244
+ .browser-card-header h3 {
245
+ font-size: 15px;
246
+ font-weight: 600;
247
+ word-break: break-all;
248
+ }
249
+ .browser-meta {
250
+ display: grid;
251
+ grid-template-columns: 1fr 1fr;
252
+ gap: 12px;
253
+ margin-bottom: 16px;
254
+ }
255
+ .meta-item {}
256
+ .meta-label {
257
+ font-size: 11px;
258
+ color: var(--text-muted);
259
+ text-transform: uppercase;
260
+ letter-spacing: .04em;
261
+ font-weight: 600;
262
+ }
263
+ .meta-value {
264
+ font-size: 13px;
265
+ color: var(--text);
266
+ margin-top: 2px;
267
+ }
268
+ .browser-urls {}
269
+ .url-item {
270
+ background: var(--bg);
271
+ border: 1px solid var(--border);
272
+ border-radius: var(--radius-sm);
273
+ padding: 8px 10px;
274
+ margin-bottom: 6px;
275
+ display: flex;
276
+ align-items: center;
277
+ gap: 8px;
278
+ }
279
+ .url-item:last-child { margin-bottom: 0; }
280
+ .url-label {
281
+ font-size: 11px;
282
+ color: var(--text-muted);
283
+ font-weight: 600;
284
+ flex-shrink: 0;
285
+ }
286
+ .url-value {
287
+ font-size: 12px;
288
+ color: var(--accent);
289
+ font-family: 'SF Mono', 'SFMono-Regular', 'Fira Code', monospace;
290
+ word-break: break-all;
291
+ flex: 1;
292
+ min-width: 0;
293
+ }
294
+ .btn-copy {
295
+ background: transparent;
296
+ border: 1px solid var(--border);
297
+ color: var(--text-secondary);
298
+ font-size: 11px;
299
+ padding: 3px 8px;
300
+ border-radius: 4px;
301
+ flex-shrink: 0;
302
+ cursor: pointer;
303
+ transition: all .15s;
304
+ }
305
+ .btn-copy:hover { background: var(--bg-card-hover); color: var(--text); border-color: var(--text-muted); }
306
+ .btn-copy.copied { background: var(--green-bg); border-color: var(--green); color: var(--green); }
307
+
308
+ /* Empty State */
309
+ .empty-state {
310
+ text-align: center;
311
+ padding: 48px 24px;
312
+ color: var(--text-muted);
313
+ }
314
+ .empty-state svg { margin-bottom: 12px; opacity: .4; }
315
+ .empty-state h3 { font-size: 16px; color: var(--text-secondary); margin-bottom: 4px; }
316
+ .empty-state p { font-size: 13px; }
317
+
318
+ /* API Keys */
319
+ .api-keys-section {}
320
+ .api-key-card {
321
+ display: flex;
322
+ align-items: center;
323
+ justify-content: space-between;
324
+ background: var(--bg-card);
325
+ border: 1px solid var(--border);
326
+ border-radius: var(--radius-sm);
327
+ padding: 12px 16px;
328
+ margin-bottom: 8px;
329
+ }
330
+ .api-key-info { flex: 1; min-width: 0; }
331
+ .api-key-name {
332
+ font-size: 14px;
333
+ font-weight: 600;
334
+ }
335
+ .api-key-meta {
336
+ font-size: 12px;
337
+ color: var(--text-muted);
338
+ margin-top: 2px;
339
+ }
340
+ .api-key-key {
341
+ font-size: 12px;
342
+ font-family: 'SF Mono', monospace;
343
+ color: var(--text-secondary);
344
+ margin-top: 4px;
345
+ word-break: break-all;
346
+ }
347
+ .api-key-actions { flex-shrink: 0; margin-left: 12px; }
348
+ .create-key-form {
349
+ display: flex;
350
+ gap: 8px;
351
+ align-items: center;
352
+ }
353
+ .create-key-form input { flex: 1; }
354
+ .create-key-form button { flex-shrink: 0; }
355
+
356
+ /* Toast */
357
+ .toast-container {
358
+ position: fixed;
359
+ bottom: 24px;
360
+ right: 24px;
361
+ z-index: 100;
362
+ display: flex;
363
+ flex-direction: column;
364
+ gap: 8px;
365
+ }
366
+ .toast {
367
+ background: var(--bg-card);
368
+ border: 1px solid var(--border);
369
+ border-radius: var(--radius-sm);
370
+ padding: 12px 16px;
371
+ font-size: 13px;
372
+ color: var(--text);
373
+ box-shadow: 0 8px 24px rgba(0,0,0,.4);
374
+ animation: toast-in .25s ease;
375
+ max-width: 360px;
376
+ }
377
+ .toast.success { border-left: 3px solid var(--green); }
378
+ .toast.error { border-left: 3px solid var(--red); }
379
+ @keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
380
+
381
+ /* Loading */
382
+ .spinner {
383
+ display: inline-block;
384
+ width: 16px;
385
+ height: 16px;
386
+ border: 2px solid var(--border);
387
+ border-top-color: var(--accent);
388
+ border-radius: 50%;
389
+ animation: spin .6s linear infinite;
390
+ }
391
+ @keyframes spin { to { transform: rotate(360deg); } }
392
+ .loading-state {
393
+ display: flex;
394
+ align-items: center;
395
+ justify-content: center;
396
+ gap: 8px;
397
+ padding: 48px;
398
+ color: var(--text-muted);
399
+ font-size: 14px;
400
+ }
401
+
402
+ /* Responsive */
403
+ @media (max-width: 640px) {
404
+ .header { padding: 12px 16px; flex-wrap: wrap; gap: 8px; }
405
+ .header-left h1 { font-size: 16px; }
406
+ .main { padding: 16px; }
407
+ .browser-grid { grid-template-columns: 1fr; }
408
+ .browser-meta { grid-template-columns: 1fr; }
409
+ .create-key-form { flex-direction: column; }
410
+ .create-key-form button { width: 100%; }
411
+ .api-key-card { flex-direction: column; align-items: stretch; gap: 8px; }
412
+ .api-key-actions { margin-left: 0; }
413
+ .user-info { display: none; }
414
+ }
415
+ </style>
416
+ </head>
417
+ <body>
418
+ <div id="app"></div>
419
+ <div class="toast-container" id="toastContainer"></div>
420
+
421
+ <script>
422
+ const API_BASE = window.location.origin;
423
+
424
+ // ====== State ======
425
+ let state = {
426
+ token: localStorage.getItem('cdp_token'),
427
+ user: null,
428
+ browsers: [],
429
+ apiKeys: [],
430
+ loading: false,
431
+ browserRefreshTimer: null,
432
+ };
433
+
434
+ // ====== DOM ======
435
+ const $ = (s, p) => (p || document).querySelector(s);
436
+ const $$ = (s, p) => [...(p || document).querySelectorAll(s)];
437
+
438
+ // ====== Router ======
439
+ function render() {
440
+ const app = $('#app');
441
+ if (!state.token || !state.user) {
442
+ app.innerHTML = renderLogin();
443
+ bindLogin();
444
+ } else {
445
+ app.innerHTML = renderDashboard();
446
+ bindDashboard();
447
+ }
448
+ }
449
+
450
+ // ====== Toast ======
451
+ function toast(msg, type = 'success') {
452
+ const c = $('#toastContainer');
453
+ const el = document.createElement('div');
454
+ el.className = `toast ${type}`;
455
+ el.textContent = msg;
456
+ c.appendChild(el);
457
+ setTimeout(() => { el.remove(); }, 3000);
458
+ }
459
+
460
+ // ====== API ======
461
+ async function api(url, opts = {}) {
462
+ const headers = { 'Content-Type': 'application/json' };
463
+ if (state.token) headers['Authorization'] = `Bearer ${state.token}`;
464
+ const res = await fetch(API_BASE + url, { ...opts, headers });
465
+ const data = await res.json();
466
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
467
+ return data;
468
+ }
469
+
470
+ // ====== Login ======
471
+ function renderLogin() {
472
+ return `
473
+ <div class="login-container">
474
+ <div class="login-box">
475
+ <h1>CDP Tunnel</h1>
476
+ <p>Sign in to manage your remote browsers</p>
477
+ <div class="error-msg" id="loginError"></div>
478
+ <form id="loginForm">
479
+ <div class="form-group">
480
+ <label for="email">Email</label>
481
+ <input type="email" id="email" placeholder="you@example.com" autocomplete="email" required autofocus>
482
+ </div>
483
+ <div class="form-group">
484
+ <label for="password">Password</label>
485
+ <input type="password" id="password" placeholder="Enter your password" autocomplete="current-password" required>
486
+ </div>
487
+ <button type="submit" class="btn-primary" id="loginBtn" style="width:100%">Sign In</button>
488
+ </form>
489
+ </div>
490
+ </div>`;
491
+ }
492
+
493
+ function bindLogin() {
494
+ const form = $('#loginForm');
495
+ const btn = $('#loginBtn');
496
+ const err = $('#loginError');
497
+ form.addEventListener('submit', async (e) => {
498
+ e.preventDefault();
499
+ err.classList.remove('visible');
500
+ btn.disabled = true;
501
+ btn.textContent = 'Signing in…';
502
+ try {
503
+ const data = await api('/api/auth/login', {
504
+ method: 'POST',
505
+ body: JSON.stringify({ email: $('#email').value, password: $('#password').value })
506
+ });
507
+ state.token = data.token;
508
+ state.user = data.user;
509
+ state.apiKeys = data.apiKeys || [];
510
+ localStorage.setItem('cdp_token', data.token);
511
+ render();
512
+ toast('Signed in successfully');
513
+ } catch (ex) {
514
+ err.textContent = ex.message;
515
+ err.classList.add('visible');
516
+ } finally {
517
+ btn.disabled = false;
518
+ btn.textContent = 'Sign In';
519
+ }
520
+ });
521
+ }
522
+
523
+ // ====== Logout ======
524
+ function logout() {
525
+ state.token = null;
526
+ state.user = null;
527
+ state.browsers = [];
528
+ state.apiKeys = [];
529
+ localStorage.removeItem('cdp_token');
530
+ if (state.browserRefreshTimer) { clearInterval(state.browserRefreshTimer); state.browserRefreshTimer = null; }
531
+ render();
532
+ }
533
+
534
+ // ====== Dashboard ======
535
+ function renderDashboard() {
536
+ return `
537
+ <div class="dashboard">
538
+ <header class="header">
539
+ <div class="header-left">
540
+ <h1>CDP Tunnel</h1>
541
+ <span class="header-badge">SaaS</span>
542
+ </div>
543
+ <div class="header-right">
544
+ <span class="user-info">${escapeHtml(state.user.displayName || state.user.email)}</span>
545
+ <button class="btn-secondary" onclick="renderApiKeys()">API Keys</button>
546
+ <button class="btn-secondary" onclick="logout()">Sign Out</button>
547
+ </div>
548
+ </header>
549
+ <main class="main" id="dashboardMain">
550
+ <div id="browserSection" class="section">${renderBrowserSection()}</div>
551
+ <div id="apiKeySection" class="section">${renderApiKeySection()}</div>
552
+ </main>
553
+ </div>`;
554
+ }
555
+
556
+ function bindDashboard() {
557
+ startBrowserPolling();
558
+ }
559
+
560
+ // ====== Browser Polling ======
561
+ function startBrowserPolling() {
562
+ if (state.browserRefreshTimer) clearInterval(state.browserRefreshTimer);
563
+ fetchBrowsers();
564
+ state.browserRefreshTimer = setInterval(fetchBrowsers, 10000);
565
+ }
566
+
567
+ async function fetchBrowsers() {
568
+ try {
569
+ const data = await api('/api/browsers');
570
+ state.browsers = data.browsers;
571
+ } catch (ex) {
572
+ if (ex.message.includes('Unauthorized') || ex.message.includes('401')) {
573
+ logout();
574
+ return;
575
+ }
576
+ }
577
+ const section = $('#browserSection');
578
+ if (section) section.innerHTML = renderBrowserSection();
579
+ }
580
+
581
+ function renderBrowserSection() {
582
+ const count = state.browsers.length;
583
+ return `
584
+ <div class="section-header">
585
+ <div>
586
+ <h2>Remote Browsers</h2>
587
+ <div class="subtitle">${count} browser${count !== 1 ? 's' : ''} connected</div>
588
+ </div>
589
+ </div>
590
+ ${count === 0 ? renderEmptyBrowsers() : renderBrowserGrid()}`;
591
+ }
592
+
593
+ function renderEmptyBrowsers() {
594
+ return `
595
+ <div class="empty-state">
596
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
597
+ <rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
598
+ <line x1="8" y1="21" x2="16" y2="21"/>
599
+ <line x1="12" y1="17" x2="12" y2="21"/>
600
+ <line x1="2" y1="10" x2="22" y2="10"/>
601
+ </svg>
602
+ <h3>No browsers connected</h3>
603
+ <p>Install the CDP Tunnel plugin on your remote machine to get started.</p>
604
+ </div>`;
605
+ }
606
+
607
+ function renderBrowserGrid() {
608
+ return `<div class="browser-grid">${state.browsers.map(b => renderBrowserCard(b)).join('')}</div>`;
609
+ }
610
+
611
+ function renderBrowserCard(b) {
612
+ const connected = b.connected;
613
+ const time = b.connectedAt ? formatTime(b.connectedAt) : '—';
614
+ return `
615
+ <div class="browser-card">
616
+ <div class="browser-card-header">
617
+ <h3>${escapeHtml(b.name)}</h3>
618
+ <span class="status-indicator">
619
+ <span class="status-dot ${connected ? 'online' : 'offline'}"></span>
620
+ ${connected ? 'Online' : 'Offline'}
621
+ </span>
622
+ </div>
623
+ <div class="browser-meta">
624
+ <div class="meta-item">
625
+ <div class="meta-label">Plugin ID</div>
626
+ <div class="meta-value" style="font-family:monospace;font-size:12px">${escapeHtml(b.pluginId)}</div>
627
+ </div>
628
+ <div class="meta-item">
629
+ <div class="meta-label">Targets</div>
630
+ <div class="meta-value">${b.targets}</div>
631
+ </div>
632
+ <div class="meta-item">
633
+ <div class="meta-label">Connected Since</div>
634
+ <div class="meta-value">${time}</div>
635
+ </div>
636
+ </div>
637
+ <div class="browser-urls">
638
+ <div class="url-item">
639
+ <span class="url-label">CDP</span>
640
+ <span class="url-value" id="cdp-${b.pluginId}">${escapeHtml(b.cdpHttpUrl)}</span>
641
+ <button class="btn-copy" onclick="copyText('cdp-${b.pluginId}', this)">Copy</button>
642
+ </div>
643
+ <div class="url-item">
644
+ <span class="url-label">WS</span>
645
+ <span class="url-value" id="ws-${b.pluginId}">${escapeHtml(b.webSocketDebuggerUrl)}</span>
646
+ <button class="btn-copy" onclick="copyText('ws-${b.pluginId}', this)">Copy</button>
647
+ </div>
648
+ </div>
649
+ </div>`;
650
+ }
651
+
652
+ // ====== API Keys ======
653
+ async function renderApiKeys() {
654
+ try {
655
+ const data = await api('/api/api-keys');
656
+ state.apiKeys = data.apiKeys;
657
+ } catch (ex) {
658
+ toast(ex.message, 'error');
659
+ return;
660
+ }
661
+
662
+ const main = $('#dashboardMain');
663
+ if (!main) return;
664
+
665
+ const apiSection = $('#apiKeySection');
666
+ apiSection.innerHTML = renderApiKeySection();
667
+ window.scrollTo({ top: apiSection.offsetTop - 80, behavior: 'smooth' });
668
+ }
669
+
670
+ function renderApiKeySection() {
671
+ return `
672
+ <div class="section-header">
673
+ <div>
674
+ <h2>API Keys</h2>
675
+ <div class="subtitle">Manage API keys for programmatic access</div>
676
+ </div>
677
+ </div>
678
+ <div class="create-key-form" style="margin-bottom:16px">
679
+ <input type="text" id="newKeyName" placeholder="Key name (e.g. ci-cd)" maxlength="64">
680
+ <button class="btn-primary btn-sm" onclick="createApiKey()">Generate Key</button>
681
+ </div>
682
+ <div class="api-keys-section">
683
+ ${state.apiKeys.length === 0 ? '<p style="color:var(--text-muted);font-size:13px">No API keys yet. Generate one above.</p>' : state.apiKeys.map(k => renderApiKeyRow(k)).join('')}
684
+ </div>`;
685
+ }
686
+
687
+ function renderApiKeyRow(k) {
688
+ const active = k.active === 1 || k.active === true;
689
+ const created = k.created_at ? formatTime(k.created_at) : '—';
690
+ const lastUsed = k.last_used_at ? formatTime(k.last_used_at) : 'Never';
691
+ return `
692
+ <div class="api-key-card">
693
+ <div class="api-key-info">
694
+ <div class="api-key-name">${escapeHtml(k.name || 'Unnamed')}</div>
695
+ <div class="api-key-meta">Created ${created} · Last used ${lastUsed}</div>
696
+ </div>
697
+ <div class="api-key-actions">
698
+ ${active ? `<button class="btn-danger" onclick="revokeApiKey('${k.id}', this)">Revoke</button>` : '<span style="font-size:12px;color:var(--text-muted)">Revoked</span>'}
699
+ </div>
700
+ </div>`;
701
+ }
702
+
703
+ async function createApiKey() {
704
+ const input = $('#newKeyName');
705
+ const name = input.value.trim() || 'unnamed';
706
+ try {
707
+ const data = await api('/api/api-keys', { method: 'POST', body: JSON.stringify({ name }) });
708
+ input.value = '';
709
+ state.apiKeys = await (await fetch(API_BASE + '/api/api-keys', { headers: { 'Authorization': `Bearer ${state.token}` } })).json().then(d => d.apiKeys);
710
+ const apiSection = $('#apiKeySection');
711
+ if (apiSection) apiSection.innerHTML = renderApiKeySection();
712
+ toast(`Key generated: ${data.apiKey.key}`);
713
+ } catch (ex) {
714
+ toast(ex.message, 'error');
715
+ }
716
+ }
717
+
718
+ async function revokeApiKey(id, btn) {
719
+ if (!confirm('Revoke this API key? This action cannot be undone.')) return;
720
+ btn.disabled = true;
721
+ btn.textContent = 'Revoking…';
722
+ try {
723
+ await api(`/api/api-keys/${id}/revoke`, { method: 'POST' });
724
+ state.apiKeys = await (await fetch(API_BASE + '/api/api-keys', { headers: { 'Authorization': `Bearer ${state.token}` } })).json().then(d => d.apiKeys);
725
+ const apiSection = $('#apiKeySection');
726
+ if (apiSection) apiSection.innerHTML = renderApiKeySection();
727
+ toast('API key revoked');
728
+ } catch (ex) {
729
+ toast(ex.message, 'error');
730
+ btn.disabled = false;
731
+ btn.textContent = 'Revoke';
732
+ }
733
+ }
734
+
735
+ // ====== Copy ======
736
+ function copyText(elemId, btn) {
737
+ const el = document.getElementById(elemId);
738
+ if (!el) return;
739
+ const text = el.textContent;
740
+ if (navigator.clipboard && navigator.clipboard.writeText) {
741
+ navigator.clipboard.writeText(text).then(() => {
742
+ btn.textContent = 'Copied!';
743
+ btn.classList.add('copied');
744
+ setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000);
745
+ }).catch(() => fallbackCopy(text, btn));
746
+ } else {
747
+ fallbackCopy(text, btn);
748
+ }
749
+ }
750
+
751
+ function fallbackCopy(text, btn) {
752
+ const ta = document.createElement('textarea');
753
+ ta.value = text;
754
+ ta.style.position = 'fixed';
755
+ ta.style.opacity = '0';
756
+ document.body.appendChild(ta);
757
+ ta.select();
758
+ document.execCommand('copy');
759
+ document.body.removeChild(ta);
760
+ btn.textContent = 'Copied!';
761
+ btn.classList.add('copied');
762
+ setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000);
763
+ }
764
+
765
+ // ====== Helpers ======
766
+ function escapeHtml(s) {
767
+ if (!s) return '';
768
+ const d = document.createElement('div');
769
+ d.appendChild(document.createTextNode(s));
770
+ return d.innerHTML;
771
+ }
772
+
773
+ function formatTime(isoStr) {
774
+ try {
775
+ const d = new Date(isoStr);
776
+ if (isNaN(d.getTime())) return isoStr;
777
+ const now = new Date();
778
+ const diff = now - d;
779
+ const mins = Math.floor(diff / 60000);
780
+ if (mins < 1) return 'Just now';
781
+ if (mins < 60) return `${mins}m ago`;
782
+ const hours = Math.floor(mins / 60);
783
+ if (hours < 24) return `${hours}h ago`;
784
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
785
+ } catch { return isoStr; }
786
+ }
787
+
788
+ // ====== Init ======
789
+ (async function init() {
790
+ if (state.token) {
791
+ try {
792
+ const data = await api('/api/auth/me');
793
+ state.user = data.user;
794
+ } catch {
795
+ state.token = null;
796
+ localStorage.removeItem('cdp_token');
797
+ }
798
+ }
799
+ render();
800
+ })();
801
+ </script>
802
+ </body>
803
+ </html>