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.
- package/README.md +118 -4
- package/package.json +3 -2
- package/public/mobile.css +178 -0
- package/public/style.css +129 -0
- package/src/index.js +304 -51
- package/src/lib/agentNotifier.js +82 -1
- package/src/lib/db.js +868 -9
- package/src/lib/socketManager.js +73 -0
- package/src/routes/agents.js +117 -3
- package/src/routes/linkedin.js +30 -10
- package/src/routes/memento.js +106 -0
- package/src/routes/queue.js +238 -4
- package/src/routes/services.js +87 -0
- package/src/routes/ui/access.js +290 -0
- package/src/routes/ui/auth.js +149 -0
- package/src/routes/ui/calendar.js +65 -14
- package/src/routes/ui/fitbit.js +63 -14
- package/src/routes/ui/home.js +313 -0
- package/src/routes/ui/index.js +52 -35
- package/src/routes/ui/keys.js +852 -0
- package/src/routes/ui/linkedin.js +75 -19
- package/src/routes/ui/mastodon.js +70 -18
- package/src/routes/ui/mementos.js +363 -0
- package/src/routes/ui/messages.js +588 -0
- package/src/routes/ui/queue.js +599 -0
- package/src/routes/ui/reddit.js +63 -14
- package/src/routes/ui/services.js +46 -0
- package/src/routes/ui/settings.js +59 -0
- package/src/routes/ui/shared.js +269 -0
- package/src/routes/ui/youtube.js +63 -14
- package/src/routes/ui-new.js +196 -0
- package/src/routes/webhooks.js +247 -0
- package/src/routes/ui.js +0 -1901
|
@@ -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">×</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;
|