agentgate 0.3.2 → 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.
@@ -0,0 +1,852 @@
1
+ // Agents routes
2
+ import { Router } from 'express';
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';
7
+
8
+ const router = Router();
9
+
10
+ // Agents Management
11
+ router.get('/', (req, res) => {
12
+ const keys = listApiKeys();
13
+ res.send(renderKeysPage(keys));
14
+ });
15
+
16
+ router.post('/create', async (req, res) => {
17
+ const { name } = req.body;
18
+ const wantsJson = req.headers.accept?.includes('application/json');
19
+
20
+ if (!name || !name.trim()) {
21
+ return wantsJson
22
+ ? res.status(400).json({ error: 'Name is required' })
23
+ : res.send(renderKeysPage(listApiKeys(), 'Name is required'));
24
+ }
25
+
26
+ const newKey = await createApiKey(name.trim());
27
+ const keys = listApiKeys();
28
+
29
+ if (wantsJson) {
30
+ return res.json({ success: true, key: newKey.key, keyPrefix: newKey.keyPrefix, name: newKey.name, keys });
31
+ }
32
+ res.send(renderKeysPage(keys, null, newKey));
33
+ });
34
+
35
+ router.post('/:id/delete', (req, res) => {
36
+ const { id } = req.params;
37
+ const wantsJson = req.headers.accept?.includes('application/json');
38
+
39
+ deleteApiKey(id);
40
+ const keys = listApiKeys();
41
+
42
+ if (wantsJson) {
43
+ return res.json({ success: true, keys });
44
+ }
45
+ res.redirect('/ui/keys');
46
+ });
47
+
48
+ router.post('/:id/webhook', (req, res) => {
49
+ const { id } = req.params;
50
+ const { webhook_url, webhook_token } = req.body;
51
+ const wantsJson = req.headers.accept?.includes('application/json');
52
+
53
+ const agent = getApiKeyById(id);
54
+ if (!agent) {
55
+ return wantsJson
56
+ ? res.status(404).json({ error: 'Agent not found' })
57
+ : res.status(404).send('Agent not found');
58
+ }
59
+
60
+ updateAgentWebhook(id, webhook_url, webhook_token);
61
+ const keys = listApiKeys();
62
+
63
+ if (wantsJson) {
64
+ return res.json({ success: true, keys });
65
+ }
66
+ res.redirect('/ui/keys');
67
+ });
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
+
125
+ router.delete('/:id', (req, res) => {
126
+ const { id } = req.params;
127
+ const wantsJson = req.headers.accept?.includes('application/json');
128
+
129
+ deleteApiKey(id);
130
+ const keys = listApiKeys();
131
+
132
+ if (wantsJson) {
133
+ return res.json({ success: true, keys });
134
+ }
135
+ res.redirect('/ui/keys');
136
+ });
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
+
287
+ // Render function
288
+ function renderKeysPage(keys, error = null, newKey = null) {
289
+ const renderKeyRow = (k) => `
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>
302
+ <td><code class="key-value">${escapeHtml(k.key_prefix)}</code></td>
303
+ <td>
304
+ ${k.webhook_url ? `
305
+ <span class="webhook-status webhook-configured" title="${escapeHtml(k.webhook_url)}">✓ Configured</span>
306
+ ` : `
307
+ <span class="webhook-status webhook-none">Not set</span>
308
+ `}
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>
310
+ </td>
311
+ <td>${formatDate(k.created_at)}</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>
315
+ <button type="button" class="delete-btn" onclick="deleteKey('${k.id}')" title="Delete">&times;</button>
316
+ </td>
317
+ </tr>
318
+ `;
319
+
320
+ return `<!DOCTYPE html>
321
+ <html>
322
+ <head>
323
+ <title>agentgate - Agents</title>
324
+ <link rel="icon" type="image/svg+xml" href="/public/favicon.svg">
325
+ <link rel="stylesheet" href="/public/style.css">
326
+ <script src="/socket.io/socket.io.js"></script>
327
+ <style>
328
+ .keys-table { width: 100%; border-collapse: collapse; margin-top: 16px; }
329
+ .keys-table th, .keys-table td { padding: 12px; text-align: left; border-bottom: 1px solid #374151; }
330
+ .keys-table th { font-weight: 600; color: #9ca3af; font-size: 14px; }
331
+ .key-value { background: #1f2937; padding: 4px 8px; border-radius: 4px; font-size: 13px; color: #e5e7eb; }
332
+ .new-key-banner { background: #065f46; border: 1px solid #10b981; padding: 16px; border-radius: 8px; margin-bottom: 20px; color: #d1fae5; }
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; }
334
+ .delete-btn { background: none; border: none; color: #f87171; font-size: 20px; cursor: pointer; padding: 0 4px; line-height: 1; font-weight: bold; }
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); }
338
+ .back-link { color: #a78bfa; text-decoration: none; font-weight: 500; }
339
+ .back-link:hover { text-decoration: underline; }
340
+ .error-message { background: #7f1d1d; color: #fecaca; padding: 12px; border-radius: 8px; margin-bottom: 16px; }
341
+ .webhook-status { font-size: 12px; padding: 4px 8px; border-radius: 4px; margin-right: 8px; }
342
+ .webhook-configured { background: #065f46; color: #6ee7b7; }
343
+ .webhook-none { background: #374151; color: #9ca3af; }
344
+ .btn-sm { font-size: 12px; padding: 4px 8px; background: #4f46e5; color: white; border: none; border-radius: 4px; cursor: pointer; }
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; }
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; }
350
+ .modal-overlay.active { display: flex; }
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); }
352
+ .modal h3 { margin: 0 0 16px 0; color: #f3f4f6; }
353
+ .modal label { display: block; margin-bottom: 4px; color: #d1d5db; font-size: 14px; }
354
+ .modal input { width: 100%; padding: 10px; border: 1px solid #374151; border-radius: 6px; background: #111827; color: #f3f4f6; margin-bottom: 12px; box-sizing: border-box; }
355
+ .modal input:focus { border-color: #6366f1; outline: none; }
356
+ .modal-buttons { display: flex; gap: 12px; justify-content: flex-end; margin-top: 16px; }
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; }
368
+ </style>
369
+ </head>
370
+ <body>
371
+ <div>
372
+ ${simpleNavHeader()}
373
+ </div>
374
+ <p>Manage API keys for your agents. Keys are hashed and can only be viewed once at creation.</p>
375
+
376
+ ${error ? `<div class="error-message">${escapeHtml(error)}</div>` : ''}
377
+
378
+ ${newKey ? `
379
+ <div class="new-key-banner">
380
+ <strong>New API key created!</strong> Copy it now - you won't be able to see it again.
381
+ <code>${newKey.key}</code>
382
+ <button type="button" class="btn-sm btn-primary" onclick="copyKey('${newKey.key}', this)" style="margin-top: 8px;">Copy to Clipboard</button>
383
+ </div>
384
+ ` : ''}
385
+
386
+ <div class="card">
387
+ <h3>Create New Key</h3>
388
+ <form method="POST" action="/ui/keys/create" style="display: flex; gap: 12px; align-items: flex-end;">
389
+ <div style="flex: 1;">
390
+ <label>Key Name</label>
391
+ <input type="text" name="name" placeholder="e.g., clawdbot, moltbot, dev-agent" required>
392
+ </div>
393
+ <button type="submit" class="btn-primary">Create Key</button>
394
+ </form>
395
+ </div>
396
+
397
+ <div class="card">
398
+ <h3>Existing Keys (${keys.length})</h3>
399
+ ${keys.length === 0 ? `
400
+ <p style="color: var(--gray-500); text-align: center; padding: 20px;">No API keys yet. Create one above.</p>
401
+ ` : `
402
+ <table class="keys-table">
403
+ <thead>
404
+ <tr>
405
+ <th>Name</th>
406
+ <th>Key Prefix</th>
407
+ <th>Webhook</th>
408
+ <th>Created</th>
409
+ <th></th>
410
+ </tr>
411
+ </thead>
412
+ <tbody id="keys-tbody">
413
+ ${keys.map(renderKeyRow).join('')}
414
+ </tbody>
415
+ </table>
416
+ `}
417
+ </div>
418
+
419
+ <!-- Webhook Modal -->
420
+ <div id="webhook-modal" class="modal-overlay">
421
+ <div class="modal">
422
+ <h3>Configure Webhook for <span id="modal-agent-name"></span></h3>
423
+ <p style="color: #9ca3af; font-size: 14px; margin-bottom: 16px;">
424
+ When messages or queue updates are ready, agentgate will POST to this URL.
425
+ </p>
426
+ <form id="webhook-form">
427
+ <input type="hidden" id="webhook-agent-id" name="id">
428
+ <label for="webhook-url">Webhook URL</label>
429
+ <input type="url" id="webhook-url" name="webhook_url" placeholder="https://your-agent-gateway.com/webhook">
430
+ <p class="help-text">The endpoint that will receive POST notifications</p>
431
+
432
+ <label for="webhook-token">Authorization Token (optional)</label>
433
+ <input type="text" id="webhook-token" name="webhook_token" placeholder="secret-token">
434
+ <p class="help-text">Sent as Bearer token in Authorization header</p>
435
+
436
+ <div class="modal-buttons">
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>
439
+ <button type="submit" class="btn-primary">Save Webhook</button>
440
+ </div>
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>
504
+ </div>
505
+ </div>
506
+
507
+ <script>
508
+ function copyKey(key, btn) {
509
+ navigator.clipboard.writeText(key).then(() => {
510
+ const orig = btn.textContent;
511
+ btn.textContent = 'Copied!';
512
+ setTimeout(() => btn.textContent = orig, 1500);
513
+ });
514
+ }
515
+
516
+ function showWebhookModal(btn) {
517
+ document.getElementById('webhook-agent-id').value = btn.dataset.id;
518
+ document.getElementById('modal-agent-name').textContent = btn.dataset.name;
519
+ document.getElementById('webhook-url').value = btn.dataset.url;
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 = '';
527
+ document.getElementById('webhook-modal').classList.add('active');
528
+ }
529
+
530
+ function closeWebhookModal() {
531
+ document.getElementById('webhook-modal').classList.remove('active');
532
+ }
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
+
641
+ document.querySelectorAll('.webhook-btn').forEach(btn => {
642
+ btn.addEventListener('click', () => showWebhookModal(btn));
643
+ });
644
+
645
+ document.getElementById('webhook-form').addEventListener('submit', async (e) => {
646
+ e.preventDefault();
647
+ const id = document.getElementById('webhook-agent-id').value;
648
+ const webhookUrl = document.getElementById('webhook-url').value;
649
+ const webhookToken = document.getElementById('webhook-token').value;
650
+
651
+ try {
652
+ const res = await fetch('/ui/keys/' + id + '/webhook', {
653
+ method: 'POST',
654
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
655
+ body: JSON.stringify({ webhook_url: webhookUrl, webhook_token: webhookToken })
656
+ });
657
+ const data = await res.json();
658
+
659
+ if (data.success) {
660
+ closeWebhookModal();
661
+ window.location.reload();
662
+ } else {
663
+ alert(data.error || 'Failed to save webhook');
664
+ }
665
+ } catch (err) {
666
+ alert('Error: ' + err.message);
667
+ }
668
+ });
669
+
670
+ document.getElementById('webhook-modal').addEventListener('click', (e) => {
671
+ if (e.target.classList.contains('modal-overlay')) {
672
+ closeWebhookModal();
673
+ }
674
+ });
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
+
693
+ async function deleteKey(id) {
694
+ if (!confirm('Delete this API key? Any agents using it will lose access.')) return;
695
+
696
+ try {
697
+ const res = await fetch('/ui/keys/' + id, {
698
+ method: 'DELETE',
699
+ headers: { 'Accept': 'application/json' }
700
+ });
701
+ const data = await res.json();
702
+
703
+ if (data.success) {
704
+ const row = document.getElementById('key-' + id);
705
+ if (row) row.remove();
706
+
707
+ const tbody = document.getElementById('keys-tbody');
708
+ const count = tbody ? tbody.querySelectorAll('tr').length : 0;
709
+ document.querySelector('.card:last-of-type h3').textContent = 'Existing Keys (' + count + ')';
710
+
711
+ if (count === 0) {
712
+ const table = document.querySelector('.keys-table');
713
+ if (table) {
714
+ table.outerHTML = '<p style="color: #9ca3af; text-align: center; padding: 20px;">No API keys yet. Create one above.</p>';
715
+ }
716
+ }
717
+ } else {
718
+ alert(data.error || 'Failed to delete');
719
+ }
720
+ } catch (err) {
721
+ alert('Error: ' + err.message);
722
+ }
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
+ });
845
+ </script>
846
+ ${socketScript()}
847
+ ${localizeScript()}
848
+ </body>
849
+ </html>`;
850
+ }
851
+
852
+ export default router;