agentgate 0.4.0 → 0.5.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.
@@ -1,11 +1,13 @@
1
- // API Keys routes
1
+ // Agents routes
2
2
  import { Router } from 'express';
3
- import { listApiKeys, createApiKey, deleteApiKey, updateAgentWebhook, getApiKeyById } from '../../lib/db.js';
4
- import { escapeHtml, formatDate } from './shared.js';
3
+ import { join } from 'path';
4
+ import { writeFileSync } from 'fs';
5
+ import { listApiKeys, createApiKey, deleteApiKey, regenerateApiKey, updateAgentWebhook, getApiKeyById, getAvatarsDir, getAvatarFilename, deleteAgentAvatar, setAgentEnabled } from '../../lib/db.js';
6
+ import { escapeHtml, formatDate, simpleNavHeader, socketScript, localizeScript, renderAvatar } from './shared.js';
5
7
 
6
8
  const router = Router();
7
9
 
8
- // API Keys Management
10
+ // Agents Management
9
11
  router.get('/', (req, res) => {
10
12
  const keys = listApiKeys();
11
13
  res.send(renderKeysPage(keys));
@@ -64,6 +66,62 @@ router.post('/:id/webhook', (req, res) => {
64
66
  res.redirect('/ui/keys');
65
67
  });
66
68
 
69
+ // Test webhook
70
+ router.post('/:id/test-webhook', async (req, res) => {
71
+ const { id } = req.params;
72
+ const agent = getApiKeyById(id);
73
+
74
+ if (!agent) {
75
+ return res.status(404).json({ error: 'Agent not found' });
76
+ }
77
+
78
+ if (!agent.webhook_url) {
79
+ return res.status(400).json({ error: 'No webhook URL configured for this agent' });
80
+ }
81
+
82
+ const payload = {
83
+ text: `🧪 [agentgate] Webhook test for ${agent.name} - if you see this, your webhook is working!`,
84
+ mode: 'now',
85
+ test: true
86
+ };
87
+
88
+ try {
89
+ const headers = { 'Content-Type': 'application/json' };
90
+ if (agent.webhook_token) {
91
+ headers['Authorization'] = `Bearer ${agent.webhook_token}`;
92
+ }
93
+
94
+ const response = await fetch(agent.webhook_url, {
95
+ method: 'POST',
96
+ headers,
97
+ body: JSON.stringify(payload)
98
+ });
99
+
100
+ const responseText = await response.text().catch(() => '');
101
+
102
+ if (response.ok) {
103
+ return res.json({
104
+ success: true,
105
+ status: response.status,
106
+ message: `Webhook test successful (HTTP ${response.status})`
107
+ });
108
+ } else {
109
+ return res.json({
110
+ success: false,
111
+ status: response.status,
112
+ message: `Webhook returned HTTP ${response.status}`,
113
+ response: responseText.substring(0, 500)
114
+ });
115
+ }
116
+ } catch (err) {
117
+ return res.json({
118
+ success: false,
119
+ status: 0,
120
+ message: `Connection failed: ${err.message}`
121
+ });
122
+ }
123
+ });
124
+
67
125
  router.delete('/:id', (req, res) => {
68
126
  const { id } = req.params;
69
127
  const wantsJson = req.headers.accept?.includes('application/json');
@@ -77,11 +135,170 @@ router.delete('/:id', (req, res) => {
77
135
  res.redirect('/ui/keys');
78
136
  });
79
137
 
138
+ // Regenerate API key
139
+ router.post('/:id/regenerate', async (req, res) => {
140
+ const { id } = req.params;
141
+ const wantsJson = req.headers.accept?.includes('application/json');
142
+
143
+ const agent = getApiKeyById(id);
144
+ if (!agent) {
145
+ return wantsJson
146
+ ? res.status(404).json({ error: 'Agent not found' })
147
+ : res.status(404).send('Agent not found');
148
+ }
149
+
150
+ try {
151
+ const newKey = await regenerateApiKey(id);
152
+ const keys = listApiKeys();
153
+
154
+ if (wantsJson) {
155
+ return res.json({ success: true, key: newKey.key, keyPrefix: newKey.keyPrefix, name: newKey.name, keys });
156
+ }
157
+ res.send(renderKeysPage(keys, null, newKey));
158
+ } catch (err) {
159
+ console.error('Key regeneration error:', err);
160
+ return wantsJson
161
+ ? res.status(500).json({ error: err.message || 'Failed to regenerate key' })
162
+ : res.send(renderKeysPage(listApiKeys(), err.message || 'Failed to regenerate key'));
163
+ }
164
+ });
165
+
166
+
167
+ // Toggle agent enabled status
168
+ router.post('/:id/toggle-enabled', (req, res) => {
169
+ const { id } = req.params;
170
+ const wantsJson = req.headers.accept?.includes('application/json');
171
+
172
+ const agent = getApiKeyById(id);
173
+ if (!agent) {
174
+ return wantsJson
175
+ ? res.status(404).json({ error: 'Agent not found' })
176
+ : res.status(404).send('Agent not found');
177
+ }
178
+
179
+ const newEnabled = agent.enabled === 0 ? 1 : 0;
180
+ setAgentEnabled(id, newEnabled);
181
+
182
+ const keys = listApiKeys();
183
+ if (wantsJson) {
184
+ return res.json({ success: true, enabled: newEnabled === 1, keys });
185
+ }
186
+ res.redirect('/ui/agents');
187
+ });
188
+
189
+ // Avatar routes
190
+
191
+ // Get avatar for an agent by name
192
+ router.get('/avatar/:name', (req, res) => {
193
+ const { name } = req.params;
194
+ const filename = getAvatarFilename(name);
195
+
196
+ if (filename) {
197
+ const filepath = join(getAvatarsDir(), filename);
198
+ return res.sendFile(filepath);
199
+ }
200
+
201
+ // Return 404 - client should handle with fallback/initials
202
+ res.status(404).send('Avatar not found');
203
+ });
204
+
205
+ // Upload avatar for an agent (by id)
206
+ router.post('/:id/avatar', (req, res) => {
207
+ const { id } = req.params;
208
+ const wantsJson = req.headers.accept?.includes('application/json');
209
+
210
+ const agent = getApiKeyById(id);
211
+ if (!agent) {
212
+ return wantsJson
213
+ ? res.status(404).json({ error: 'Agent not found' })
214
+ : res.status(404).send('Agent not found');
215
+ }
216
+
217
+ // Check if file was uploaded
218
+ if (!req.body || !req.body.avatar) {
219
+ return wantsJson
220
+ ? res.status(400).json({ error: 'No avatar data provided' })
221
+ : res.status(400).send('No avatar data provided');
222
+ }
223
+
224
+ try {
225
+ // Expect base64 encoded image with data URI prefix
226
+ const avatarData = req.body.avatar;
227
+ const matches = avatarData.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,(.+)$/);
228
+
229
+ if (!matches) {
230
+ return wantsJson
231
+ ? res.status(400).json({ error: 'Invalid image format. Use base64 data URI.' })
232
+ : res.status(400).send('Invalid image format');
233
+ }
234
+
235
+ const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1];
236
+ const base64Data = matches[2];
237
+ const buffer = Buffer.from(base64Data, 'base64');
238
+
239
+ // Size limit: 500KB
240
+ if (buffer.length > 500 * 1024) {
241
+ return wantsJson
242
+ ? res.status(400).json({ error: 'Avatar too large. Maximum size is 500KB.' })
243
+ : res.status(400).send('Avatar too large');
244
+ }
245
+
246
+ // Delete any existing avatar for this agent
247
+ deleteAgentAvatar(agent.name);
248
+
249
+ // Save new avatar
250
+ const safeName = agent.name.toLowerCase().replace(/[^a-z0-9_-]/g, '_');
251
+ const filename = `${safeName}.${ext}`;
252
+ const filepath = join(getAvatarsDir(), filename);
253
+ writeFileSync(filepath, buffer);
254
+
255
+ if (wantsJson) {
256
+ return res.json({ success: true, filename, url: `/ui/keys/avatar/${encodeURIComponent(agent.name)}` });
257
+ }
258
+ res.redirect('/ui/keys');
259
+ } catch (err) {
260
+ console.error('Avatar upload error:', err);
261
+ return wantsJson
262
+ ? res.status(500).json({ error: 'Failed to save avatar' })
263
+ : res.status(500).send('Failed to save avatar');
264
+ }
265
+ });
266
+
267
+ // Delete avatar for an agent
268
+ router.delete('/:id/avatar', (req, res) => {
269
+ const { id } = req.params;
270
+ const wantsJson = req.headers.accept?.includes('application/json');
271
+
272
+ const agent = getApiKeyById(id);
273
+ if (!agent) {
274
+ return wantsJson
275
+ ? res.status(404).json({ error: 'Agent not found' })
276
+ : res.status(404).send('Agent not found');
277
+ }
278
+
279
+ deleteAgentAvatar(agent.name);
280
+
281
+ if (wantsJson) {
282
+ return res.json({ success: true });
283
+ }
284
+ res.redirect('/ui/keys');
285
+ });
286
+
80
287
  // Render function
81
288
  function renderKeysPage(keys, error = null, newKey = null) {
82
289
  const renderKeyRow = (k) => `
83
- <tr id="key-${k.id}">
84
- <td><strong>${escapeHtml(k.name)}</strong></td>
290
+ <tr id="key-${k.id}" class="${k.enabled === 0 ? 'agent-disabled' : ''}">
291
+ <td>
292
+ <div class="agent-with-avatar">
293
+ <span class="avatar-clickable" data-id="${k.id}" data-name="${escapeHtml(k.name)}" title="Click to change avatar">
294
+ ${renderAvatar(k.name, { size: 32 })}
295
+ </span>
296
+ <div>
297
+ <strong>${escapeHtml(k.name)}</strong>
298
+ ${k.enabled === 0 ? '<span class="status-disabled">Disabled</span>' : ''}
299
+ </div>
300
+ </div>
301
+ </td>
85
302
  <td><code class="key-value">${escapeHtml(k.key_prefix)}</code></td>
86
303
  <td>
87
304
  ${k.webhook_url ? `
@@ -92,7 +309,9 @@ function renderKeysPage(keys, error = null, newKey = null) {
92
309
  <button type="button" class="btn-sm webhook-btn" data-id="${k.id}" data-name="${escapeHtml(k.name)}" data-url="${escapeHtml(k.webhook_url || '')}" data-token="${escapeHtml(k.webhook_token || '')}">Configure</button>
93
310
  </td>
94
311
  <td>${formatDate(k.created_at)}</td>
95
- <td>
312
+ <td style="white-space: nowrap;">
313
+ <button type="button" class="btn-sm btn-regen" data-id="${k.id}" data-name="${escapeHtml(k.name)}" data-prefix="${escapeHtml(k.key_prefix)}" title="Regenerate API Key">🔄</button>
314
+ <button type="button" class="btn-sm btn-toggle ${k.enabled === 0 ? 'btn-enable' : 'btn-disable'}" onclick="toggleEnabled('${k.id}')" title="${k.enabled === 0 ? 'Enable' : 'Disable'}">${k.enabled === 0 ? '✓' : '⏸'}</button>
96
315
  <button type="button" class="delete-btn" onclick="deleteKey('${k.id}')" title="Delete">&times;</button>
97
316
  </td>
98
317
  </tr>
@@ -101,9 +320,10 @@ function renderKeysPage(keys, error = null, newKey = null) {
101
320
  return `<!DOCTYPE html>
102
321
  <html>
103
322
  <head>
104
- <title>agentgate - API Keys</title>
323
+ <title>agentgate - Agents</title>
105
324
  <link rel="icon" type="image/svg+xml" href="/public/favicon.svg">
106
325
  <link rel="stylesheet" href="/public/style.css">
326
+ <script src="/socket.io/socket.io.js"></script>
107
327
  <style>
108
328
  .keys-table { width: 100%; border-collapse: collapse; margin-top: 16px; }
109
329
  .keys-table th, .keys-table td { padding: 12px; text-align: left; border-bottom: 1px solid #374151; }
@@ -113,6 +333,8 @@ function renderKeysPage(keys, error = null, newKey = null) {
113
333
  .new-key-banner code { background: #1f2937; color: #10b981; padding: 8px 12px; border-radius: 4px; display: block; margin-top: 8px; font-size: 14px; word-break: break-all; }
114
334
  .delete-btn { background: none; border: none; color: #f87171; font-size: 20px; cursor: pointer; padding: 0 4px; line-height: 1; font-weight: bold; }
115
335
  .delete-btn:hover { color: #dc2626; }
336
+ .btn-regen { background: rgba(245, 158, 11, 0.15); border: 1px solid rgba(245, 158, 11, 0.3); color: #fbbf24; font-size: 14px; padding: 4px 8px; cursor: pointer; border-radius: 4px; margin-right: 8px; }
337
+ .btn-regen:hover { background: rgba(245, 158, 11, 0.25); border-color: rgba(245, 158, 11, 0.5); }
116
338
  .back-link { color: #a78bfa; text-decoration: none; font-weight: 500; }
117
339
  .back-link:hover { text-decoration: underline; }
118
340
  .error-message { background: #7f1d1d; color: #fecaca; padding: 12px; border-radius: 8px; margin-bottom: 16px; }
@@ -121,6 +343,9 @@ function renderKeysPage(keys, error = null, newKey = null) {
121
343
  .webhook-none { background: #374151; color: #9ca3af; }
122
344
  .btn-sm { font-size: 12px; padding: 4px 8px; background: #4f46e5; color: white; border: none; border-radius: 4px; cursor: pointer; }
123
345
  .btn-sm:hover { background: #4338ca; }
346
+ .btn-test { padding: 10px 20px; background: #059669; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; }
347
+ .btn-test:hover { background: #047857; }
348
+ .btn-test:disabled { background: #6b7280; cursor: not-allowed; }
124
349
  .modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 1000; align-items: center; justify-content: center; }
125
350
  .modal-overlay.active { display: flex; }
126
351
  .modal { background: #1f2937; border-radius: 12px; padding: 24px; max-width: 500px; width: 90%; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5); }
@@ -130,12 +355,21 @@ function renderKeysPage(keys, error = null, newKey = null) {
130
355
  .modal input:focus { border-color: #6366f1; outline: none; }
131
356
  .modal-buttons { display: flex; gap: 12px; justify-content: flex-end; margin-top: 16px; }
132
357
  .modal .help-text { font-size: 12px; color: #9ca3af; margin-top: -8px; margin-bottom: 12px; }
358
+ .avatar-clickable { cursor: pointer; display: inline-block; border-radius: 50%; transition: transform 0.15s ease, box-shadow 0.15s ease; }
359
+ .avatar-clickable:hover { transform: scale(1.1); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.4); }
360
+
361
+ .agent-disabled { opacity: 0.5; }
362
+ .status-disabled { background: #7f1d1d; color: #fca5a5; font-size: 10px; padding: 2px 6px; border-radius: 4px; margin-left: 8px; }
363
+ .btn-toggle { margin-right: 8px; }
364
+ .btn-enable { background: #065f46; border-color: #10b981; color: #6ee7b7; }
365
+ .btn-enable:hover { background: #047857; }
366
+ .btn-disable { background: #7f1d1d; border-color: #ef4444; color: #fca5a5; }
367
+ .btn-disable:hover { background: #991b1b; }
133
368
  </style>
134
369
  </head>
135
370
  <body>
136
- <div style="display: flex; justify-content: space-between; align-items: center;">
137
- <h1>API Keys</h1>
138
- <a href="/ui" class="back-link">&larr; Back to Dashboard</a>
371
+ <div>
372
+ ${simpleNavHeader()}
139
373
  </div>
140
374
  <p>Manage API keys for your agents. Keys are hashed and can only be viewed once at creation.</p>
141
375
 
@@ -201,9 +435,72 @@ function renderKeysPage(keys, error = null, newKey = null) {
201
435
 
202
436
  <div class="modal-buttons">
203
437
  <button type="button" class="btn-secondary" onclick="closeWebhookModal()">Cancel</button>
438
+ <button type="button" id="webhook-test-btn" class="btn-test" onclick="testWebhook()" style="display: none;">Test</button>
204
439
  <button type="submit" class="btn-primary">Save Webhook</button>
205
440
  </div>
206
441
  </form>
442
+ <div id="webhook-test-result" style="margin-top: 12px; display: none;"></div>
443
+ </div>
444
+ </div>
445
+
446
+ <!-- Regenerate Key Modal -->
447
+ <div id="regen-modal" class="modal-overlay">
448
+ <div class="modal">
449
+ <h3>🔄 Regenerate API Key</h3>
450
+ <!-- Confirmation view -->
451
+ <div id="regen-confirm-view">
452
+ <p style="color: #fbbf24; font-size: 14px; margin-bottom: 16px; background: rgba(245, 158, 11, 0.1); padding: 12px; border-radius: 8px; border: 1px solid rgba(245, 158, 11, 0.3);">
453
+ ⚠️ <strong>Warning:</strong> This will immediately invalidate the current API key. Any agents using it will lose access until updated with the new key.
454
+ </p>
455
+ <p style="color: #9ca3af; margin-bottom: 8px;">Agent: <strong id="regen-agent-name" style="color: #f3f4f6;"></strong></p>
456
+ <p style="color: #9ca3af; margin-bottom: 16px;">Current key: <code id="regen-key-prefix" style="background: #374151; padding: 2px 6px; border-radius: 4px;"></code></p>
457
+ <input type="hidden" id="regen-agent-id">
458
+ <div class="modal-buttons">
459
+ <button type="button" class="btn-secondary" onclick="closeRegenModal()">Cancel</button>
460
+ <button type="button" id="regen-confirm-btn" class="btn-danger" onclick="confirmRegenerate()">Regenerate Key</button>
461
+ </div>
462
+ </div>
463
+ <!-- Success view with new key -->
464
+ <div id="regen-success-view" style="display: none;">
465
+ <p style="color: #34d399; font-size: 14px; margin-bottom: 16px; background: rgba(16, 185, 129, 0.1); padding: 12px; border-radius: 8px; border: 1px solid rgba(16, 185, 129, 0.3);">
466
+ ✅ <strong>Key regenerated!</strong> Copy it now - you won't be able to see it again.
467
+ </p>
468
+ <p style="color: #9ca3af; margin-bottom: 8px;">Agent: <strong id="regen-success-name" style="color: #f3f4f6;"></strong></p>
469
+ <div style="background: #1f2937; padding: 12px; border-radius: 8px; margin-bottom: 16px; word-break: break-all;">
470
+ <code id="regen-new-key" style="color: #34d399; font-size: 14px;"></code>
471
+ </div>
472
+ <div class="modal-buttons">
473
+ <button type="button" id="regen-copy-btn" class="btn-primary" onclick="copyRegenKey()">Copy to Clipboard</button>
474
+ <button type="button" class="btn-secondary" onclick="closeRegenModalAndRefresh()">Done</button>
475
+ </div>
476
+ </div>
477
+ </div>
478
+ </div>
479
+
480
+ <!-- Avatar Modal -->
481
+ <div id="avatar-modal" class="modal-overlay">
482
+ <div class="modal">
483
+ <h3>Avatar for <span id="avatar-agent-name"></span></h3>
484
+ <p style="color: #9ca3af; font-size: 14px; margin-bottom: 16px;">
485
+ Upload an image (PNG, JPG, GIF, WebP). Max size: 500KB.
486
+ </p>
487
+ <input type="hidden" id="avatar-agent-id">
488
+
489
+ <div id="avatar-preview-container" style="text-align: center; margin-bottom: 16px;">
490
+ <div id="avatar-preview" style="width: 80px; height: 80px; border-radius: 50%; margin: 0 auto; background: #374151; display: flex; align-items: center; justify-content: center; overflow: hidden;">
491
+ <span id="avatar-preview-text" style="color: #9ca3af;">No image</span>
492
+ <img id="avatar-preview-img" style="width: 100%; height: 100%; object-fit: cover; display: none;">
493
+ </div>
494
+ </div>
495
+
496
+ <input type="file" id="avatar-file" accept="image/png,image/jpeg,image/gif,image/webp" style="margin-bottom: 16px;">
497
+ <p class="help-text">Select an image file to upload</p>
498
+
499
+ <div class="modal-buttons">
500
+ <button type="button" class="btn-secondary" onclick="closeAvatarModal()">Cancel</button>
501
+ <button type="button" id="avatar-delete-btn" class="btn-danger" onclick="deleteAvatar()" style="display: none;">Delete</button>
502
+ <button type="button" id="avatar-upload-btn" class="btn-primary" onclick="uploadAvatar()" disabled>Upload</button>
503
+ </div>
207
504
  </div>
208
505
  </div>
209
506
 
@@ -221,6 +518,12 @@ function renderKeysPage(keys, error = null, newKey = null) {
221
518
  document.getElementById('modal-agent-name').textContent = btn.dataset.name;
222
519
  document.getElementById('webhook-url').value = btn.dataset.url;
223
520
  document.getElementById('webhook-token').value = btn.dataset.token;
521
+ // Show test button if webhook URL is already configured
522
+ const testBtn = document.getElementById('webhook-test-btn');
523
+ const testResult = document.getElementById('webhook-test-result');
524
+ testBtn.style.display = btn.dataset.url ? 'inline-block' : 'none';
525
+ testResult.style.display = 'none';
526
+ testResult.innerHTML = '';
224
527
  document.getElementById('webhook-modal').classList.add('active');
225
528
  }
226
529
 
@@ -228,6 +531,113 @@ function renderKeysPage(keys, error = null, newKey = null) {
228
531
  document.getElementById('webhook-modal').classList.remove('active');
229
532
  }
230
533
 
534
+ // Regenerate key modal functions
535
+ let regenNewKey = null;
536
+
537
+ function showRegenModal(btn) {
538
+ document.getElementById('regen-agent-id').value = btn.dataset.id;
539
+ document.getElementById('regen-agent-name').textContent = btn.dataset.name;
540
+ document.getElementById('regen-key-prefix').textContent = btn.dataset.prefix;
541
+ // Reset to confirmation view
542
+ document.getElementById('regen-confirm-view').style.display = '';
543
+ document.getElementById('regen-success-view').style.display = 'none';
544
+ document.getElementById('regen-confirm-btn').disabled = false;
545
+ document.getElementById('regen-confirm-btn').textContent = 'Regenerate Key';
546
+ regenNewKey = null;
547
+ document.getElementById('regen-modal').classList.add('active');
548
+ }
549
+
550
+ function closeRegenModal() {
551
+ document.getElementById('regen-modal').classList.remove('active');
552
+ }
553
+
554
+ function closeRegenModalAndRefresh() {
555
+ closeRegenModal();
556
+ window.location.reload();
557
+ }
558
+
559
+ function copyRegenKey() {
560
+ if (!regenNewKey) return;
561
+ navigator.clipboard.writeText(regenNewKey).then(() => {
562
+ const btn = document.getElementById('regen-copy-btn');
563
+ const orig = btn.textContent;
564
+ btn.textContent = 'Copied!';
565
+ setTimeout(() => btn.textContent = orig, 1500);
566
+ });
567
+ }
568
+
569
+ async function confirmRegenerate() {
570
+ const id = document.getElementById('regen-agent-id').value;
571
+ const btn = document.getElementById('regen-confirm-btn');
572
+ btn.disabled = true;
573
+ btn.textContent = 'Regenerating...';
574
+
575
+ try {
576
+ const res = await fetch('/ui/keys/' + id + '/regenerate', {
577
+ method: 'POST',
578
+ headers: { 'Accept': 'application/json' }
579
+ });
580
+ const data = await res.json();
581
+
582
+ if (data.success) {
583
+ // Show success view with new key
584
+ regenNewKey = data.key;
585
+ document.getElementById('regen-success-name').textContent = data.name;
586
+ document.getElementById('regen-new-key').textContent = data.key;
587
+ document.getElementById('regen-confirm-view').style.display = 'none';
588
+ document.getElementById('regen-success-view').style.display = '';
589
+ } else {
590
+ alert(data.error || 'Failed to regenerate key');
591
+ btn.disabled = false;
592
+ btn.textContent = 'Regenerate Key';
593
+ }
594
+ } catch (err) {
595
+ alert('Error: ' + err.message);
596
+ btn.disabled = false;
597
+ btn.textContent = 'Regenerate Key';
598
+ }
599
+ }
600
+
601
+ document.querySelectorAll('.btn-regen').forEach(btn => {
602
+ btn.addEventListener('click', () => showRegenModal(btn));
603
+ });
604
+
605
+ document.getElementById('regen-modal').addEventListener('click', (e) => {
606
+ if (e.target.classList.contains('modal-overlay')) {
607
+ closeRegenModal();
608
+ }
609
+ });
610
+
611
+ async function testWebhook() {
612
+ const id = document.getElementById('webhook-agent-id').value;
613
+ const testBtn = document.getElementById('webhook-test-btn');
614
+ const testResult = document.getElementById('webhook-test-result');
615
+
616
+ testBtn.disabled = true;
617
+ testBtn.textContent = 'Testing...';
618
+ testResult.style.display = 'block';
619
+ testResult.innerHTML = '<span style="color: #9ca3af;">Sending test webhook...</span>';
620
+
621
+ try {
622
+ const res = await fetch('/ui/keys/' + id + '/test-webhook', {
623
+ method: 'POST',
624
+ headers: { 'Accept': 'application/json' }
625
+ });
626
+ const data = await res.json();
627
+
628
+ if (data.success) {
629
+ testResult.innerHTML = '<span style="color: #34d399;">✓ ' + data.message + '</span>';
630
+ } else {
631
+ testResult.innerHTML = '<span style="color: #f87171;">✗ ' + data.message + '</span>';
632
+ }
633
+ } catch (err) {
634
+ testResult.innerHTML = '<span style="color: #f87171;">✗ Error: ' + err.message + '</span>';
635
+ } finally {
636
+ testBtn.disabled = false;
637
+ testBtn.textContent = 'Test';
638
+ }
639
+ }
640
+
231
641
  document.querySelectorAll('.webhook-btn').forEach(btn => {
232
642
  btn.addEventListener('click', () => showWebhookModal(btn));
233
643
  });
@@ -263,6 +673,23 @@ function renderKeysPage(keys, error = null, newKey = null) {
263
673
  }
264
674
  });
265
675
 
676
+ async function toggleEnabled(id) {
677
+ try {
678
+ const res = await fetch('/ui/keys/' + id + '/toggle-enabled', {
679
+ method: 'POST',
680
+ headers: { 'Accept': 'application/json' }
681
+ });
682
+ const data = await res.json();
683
+ if (data.success) {
684
+ window.location.reload();
685
+ } else {
686
+ alert(data.error || 'Failed to toggle agent status');
687
+ }
688
+ } catch (err) {
689
+ alert('Error: ' + err.message);
690
+ }
691
+ }
692
+
266
693
  async function deleteKey(id) {
267
694
  if (!confirm('Delete this API key? Any agents using it will lose access.')) return;
268
695
 
@@ -294,7 +721,130 @@ function renderKeysPage(keys, error = null, newKey = null) {
294
721
  alert('Error: ' + err.message);
295
722
  }
296
723
  }
724
+
725
+ // Avatar functionality
726
+ let currentAvatarData = null;
727
+
728
+ function showAvatarModal(btn) {
729
+ const id = btn.dataset.id;
730
+ const name = btn.dataset.name;
731
+ document.getElementById('avatar-agent-id').value = id;
732
+ document.getElementById('avatar-agent-name').textContent = name;
733
+ document.getElementById('avatar-file').value = '';
734
+ document.getElementById('avatar-upload-btn').disabled = true;
735
+ currentAvatarData = null;
736
+
737
+ // Try to load existing avatar
738
+ const img = document.getElementById('avatar-preview-img');
739
+ const text = document.getElementById('avatar-preview-text');
740
+ img.src = '/ui/keys/avatar/' + encodeURIComponent(name) + '?t=' + Date.now();
741
+ img.onload = function() {
742
+ img.style.display = 'block';
743
+ text.style.display = 'none';
744
+ document.getElementById('avatar-delete-btn').style.display = '';
745
+ };
746
+ img.onerror = function() {
747
+ img.style.display = 'none';
748
+ text.style.display = '';
749
+ document.getElementById('avatar-delete-btn').style.display = 'none';
750
+ };
751
+
752
+ document.getElementById('avatar-modal').classList.add('active');
753
+ }
754
+
755
+ function closeAvatarModal() {
756
+ document.getElementById('avatar-modal').classList.remove('active');
757
+ }
758
+
759
+ document.querySelectorAll('.avatar-clickable').forEach(el => {
760
+ el.addEventListener('click', () => showAvatarModal(el));
761
+ });
762
+
763
+ document.getElementById('avatar-file').addEventListener('change', function(e) {
764
+ const file = e.target.files[0];
765
+ if (!file) return;
766
+
767
+ if (file.size > 500 * 1024) {
768
+ alert('File too large. Maximum size is 500KB.');
769
+ e.target.value = '';
770
+ return;
771
+ }
772
+
773
+ const reader = new FileReader();
774
+ reader.onload = function(event) {
775
+ currentAvatarData = event.target.result;
776
+ const img = document.getElementById('avatar-preview-img');
777
+ const text = document.getElementById('avatar-preview-text');
778
+ img.src = currentAvatarData;
779
+ img.style.display = 'block';
780
+ text.style.display = 'none';
781
+ document.getElementById('avatar-upload-btn').disabled = false;
782
+ };
783
+ reader.readAsDataURL(file);
784
+ });
785
+
786
+ async function uploadAvatar() {
787
+ if (!currentAvatarData) return;
788
+
789
+ const id = document.getElementById('avatar-agent-id').value;
790
+ const btn = document.getElementById('avatar-upload-btn');
791
+ btn.disabled = true;
792
+ btn.textContent = 'Uploading...';
793
+
794
+ try {
795
+ const res = await fetch('/ui/keys/' + id + '/avatar', {
796
+ method: 'POST',
797
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
798
+ body: JSON.stringify({ avatar: currentAvatarData })
799
+ });
800
+ const data = await res.json();
801
+
802
+ if (data.success) {
803
+ closeAvatarModal();
804
+ window.location.reload();
805
+ } else {
806
+ alert(data.error || 'Failed to upload avatar');
807
+ btn.disabled = false;
808
+ btn.textContent = 'Upload';
809
+ }
810
+ } catch (err) {
811
+ alert('Error: ' + err.message);
812
+ btn.disabled = false;
813
+ btn.textContent = 'Upload';
814
+ }
815
+ }
816
+
817
+ async function deleteAvatar() {
818
+ if (!confirm('Delete this avatar?')) return;
819
+
820
+ const id = document.getElementById('avatar-agent-id').value;
821
+
822
+ try {
823
+ const res = await fetch('/ui/keys/' + id + '/avatar', {
824
+ method: 'DELETE',
825
+ headers: { 'Accept': 'application/json' }
826
+ });
827
+ const data = await res.json();
828
+
829
+ if (data.success) {
830
+ closeAvatarModal();
831
+ window.location.reload();
832
+ } else {
833
+ alert(data.error || 'Failed to delete avatar');
834
+ }
835
+ } catch (err) {
836
+ alert('Error: ' + err.message);
837
+ }
838
+ }
839
+
840
+ document.getElementById('avatar-modal').addEventListener('click', (e) => {
841
+ if (e.target.classList.contains('modal-overlay')) {
842
+ closeAvatarModal();
843
+ }
844
+ });
297
845
  </script>
846
+ ${socketScript()}
847
+ ${localizeScript()}
298
848
  </body>
299
849
  </html>`;
300
850
  }