agentgate 0.1.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 +216 -0
- package/package.json +63 -0
- package/public/favicon.svg +48 -0
- package/public/icons/bluesky.svg +1 -0
- package/public/icons/fitbit.svg +16 -0
- package/public/icons/github.svg +1 -0
- package/public/icons/google-calendar.svg +1 -0
- package/public/icons/jira.svg +1 -0
- package/public/icons/linkedin.svg +1 -0
- package/public/icons/mastodon.svg +1 -0
- package/public/icons/reddit.svg +1 -0
- package/public/icons/youtube.svg +1 -0
- package/public/logo.svg +52 -0
- package/public/style.css +584 -0
- package/src/cli.js +77 -0
- package/src/index.js +344 -0
- package/src/lib/db.js +325 -0
- package/src/lib/hsyncManager.js +57 -0
- package/src/lib/queueExecutor.js +362 -0
- package/src/routes/bluesky.js +130 -0
- package/src/routes/calendar.js +120 -0
- package/src/routes/fitbit.js +127 -0
- package/src/routes/github.js +72 -0
- package/src/routes/jira.js +77 -0
- package/src/routes/linkedin.js +137 -0
- package/src/routes/mastodon.js +91 -0
- package/src/routes/queue.js +186 -0
- package/src/routes/reddit.js +138 -0
- package/src/routes/ui/bluesky.js +66 -0
- package/src/routes/ui/calendar.js +120 -0
- package/src/routes/ui/fitbit.js +122 -0
- package/src/routes/ui/github.js +60 -0
- package/src/routes/ui/index.js +35 -0
- package/src/routes/ui/jira.js +72 -0
- package/src/routes/ui/linkedin.js +120 -0
- package/src/routes/ui/mastodon.js +140 -0
- package/src/routes/ui/reddit.js +120 -0
- package/src/routes/ui/youtube.js +120 -0
- package/src/routes/ui.js +1077 -0
- package/src/routes/youtube.js +119 -0
package/src/routes/ui.js
ADDED
|
@@ -0,0 +1,1077 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import {
|
|
3
|
+
listAccounts, getSetting, setSetting, deleteSetting,
|
|
4
|
+
setAdminPassword, verifyAdminPassword, hasAdminPassword,
|
|
5
|
+
listQueueEntries, getQueueEntry, updateQueueStatus, clearQueueByStatus, deleteQueueEntry, getPendingQueueCount, getQueueCounts,
|
|
6
|
+
listApiKeys, createApiKey, deleteApiKey
|
|
7
|
+
} from '../lib/db.js';
|
|
8
|
+
import { connectHsync, disconnectHsync, getHsyncUrl, isHsyncConnected } from '../lib/hsyncManager.js';
|
|
9
|
+
import { executeQueueEntry } from '../lib/queueExecutor.js';
|
|
10
|
+
import { registerAllRoutes, renderAllCards } from './ui/index.js';
|
|
11
|
+
|
|
12
|
+
const router = Router();
|
|
13
|
+
|
|
14
|
+
const PORT = process.env.PORT || 3050;
|
|
15
|
+
const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;
|
|
16
|
+
|
|
17
|
+
// Auth cookie settings
|
|
18
|
+
const AUTH_COOKIE = 'rms_auth';
|
|
19
|
+
const COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 1 week
|
|
20
|
+
|
|
21
|
+
// Check if user is authenticated
|
|
22
|
+
function isAuthenticated(req) {
|
|
23
|
+
return req.signedCookies[AUTH_COOKIE] === 'authenticated';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Auth middleware for protected routes
|
|
27
|
+
function requireAuth(req, res, next) {
|
|
28
|
+
if (req.path === '/login' || req.path === '/setup-password') {
|
|
29
|
+
return next();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!hasAdminPassword()) {
|
|
33
|
+
return res.redirect('/ui/setup-password');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!isAuthenticated(req)) {
|
|
37
|
+
return res.redirect('/ui/login');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
next();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Apply auth middleware to all routes
|
|
44
|
+
router.use(requireAuth);
|
|
45
|
+
|
|
46
|
+
// Login page
|
|
47
|
+
router.get('/login', (req, res) => {
|
|
48
|
+
if (!hasAdminPassword()) {
|
|
49
|
+
return res.redirect('/ui/setup-password');
|
|
50
|
+
}
|
|
51
|
+
if (isAuthenticated(req)) {
|
|
52
|
+
return res.redirect('/ui');
|
|
53
|
+
}
|
|
54
|
+
res.send(renderLoginPage());
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Handle login
|
|
58
|
+
router.post('/login', async (req, res) => {
|
|
59
|
+
const { password } = req.body;
|
|
60
|
+
if (!password) {
|
|
61
|
+
return res.send(renderLoginPage('Password required'));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const valid = await verifyAdminPassword(password);
|
|
65
|
+
if (!valid) {
|
|
66
|
+
return res.send(renderLoginPage('Invalid password'));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
res.cookie(AUTH_COOKIE, 'authenticated', {
|
|
70
|
+
signed: true,
|
|
71
|
+
httpOnly: true,
|
|
72
|
+
maxAge: COOKIE_MAX_AGE,
|
|
73
|
+
sameSite: 'lax'
|
|
74
|
+
});
|
|
75
|
+
res.redirect('/ui');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Logout
|
|
79
|
+
router.post('/logout', (req, res) => {
|
|
80
|
+
res.clearCookie(AUTH_COOKIE);
|
|
81
|
+
res.redirect('/ui/login');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Password setup page (first time only)
|
|
85
|
+
router.get('/setup-password', (req, res) => {
|
|
86
|
+
if (hasAdminPassword()) {
|
|
87
|
+
return res.redirect('/ui/login');
|
|
88
|
+
}
|
|
89
|
+
res.send(renderSetupPasswordPage());
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Handle password setup
|
|
93
|
+
router.post('/setup-password', async (req, res) => {
|
|
94
|
+
if (hasAdminPassword()) {
|
|
95
|
+
return res.redirect('/ui/login');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const { password, confirmPassword } = req.body;
|
|
99
|
+
if (!password || password.length < 4) {
|
|
100
|
+
return res.send(renderSetupPasswordPage('Password must be at least 4 characters'));
|
|
101
|
+
}
|
|
102
|
+
if (password !== confirmPassword) {
|
|
103
|
+
return res.send(renderSetupPasswordPage('Passwords do not match'));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await setAdminPassword(password);
|
|
107
|
+
|
|
108
|
+
res.cookie(AUTH_COOKIE, 'authenticated', {
|
|
109
|
+
signed: true,
|
|
110
|
+
httpOnly: true,
|
|
111
|
+
maxAge: COOKIE_MAX_AGE,
|
|
112
|
+
sameSite: 'lax'
|
|
113
|
+
});
|
|
114
|
+
res.redirect('/ui');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Main UI page
|
|
118
|
+
router.get('/', (req, res) => {
|
|
119
|
+
const accounts = listAccounts();
|
|
120
|
+
const hsyncConfig = getSetting('hsync');
|
|
121
|
+
const hsyncUrl = getHsyncUrl();
|
|
122
|
+
const hsyncConnected = isHsyncConnected();
|
|
123
|
+
const pendingQueueCount = getPendingQueueCount();
|
|
124
|
+
|
|
125
|
+
res.send(renderPage(accounts, { hsyncConfig, hsyncUrl, hsyncConnected, pendingQueueCount }));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Register all service routes (github, bluesky, reddit, etc.)
|
|
129
|
+
registerAllRoutes(router, BASE_URL);
|
|
130
|
+
|
|
131
|
+
// hsync setup
|
|
132
|
+
router.post('/hsync/setup', async (req, res) => {
|
|
133
|
+
const { url, token } = req.body;
|
|
134
|
+
if (!url) {
|
|
135
|
+
return res.status(400).send('URL required');
|
|
136
|
+
}
|
|
137
|
+
setSetting('hsync', {
|
|
138
|
+
url: url.replace(/\/$/, ''),
|
|
139
|
+
token: token || '',
|
|
140
|
+
enabled: true
|
|
141
|
+
});
|
|
142
|
+
await connectHsync(PORT);
|
|
143
|
+
res.redirect('/ui');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
router.post('/hsync/delete', async (req, res) => {
|
|
147
|
+
await disconnectHsync();
|
|
148
|
+
deleteSetting('hsync');
|
|
149
|
+
res.redirect('/ui');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Write Queue Management
|
|
153
|
+
router.get('/queue', (req, res) => {
|
|
154
|
+
const filter = req.query.filter || 'all';
|
|
155
|
+
let entries;
|
|
156
|
+
if (filter === 'all') {
|
|
157
|
+
entries = listQueueEntries();
|
|
158
|
+
} else {
|
|
159
|
+
entries = listQueueEntries(filter);
|
|
160
|
+
}
|
|
161
|
+
const counts = getQueueCounts();
|
|
162
|
+
res.send(renderQueuePage(entries, filter, counts));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
router.post('/queue/:id/approve', async (req, res) => {
|
|
166
|
+
const { id } = req.params;
|
|
167
|
+
const entry = getQueueEntry(id);
|
|
168
|
+
const wantsJson = req.headers.accept?.includes('application/json');
|
|
169
|
+
|
|
170
|
+
if (!entry) {
|
|
171
|
+
return wantsJson
|
|
172
|
+
? res.status(404).json({ error: 'Queue entry not found' })
|
|
173
|
+
: res.status(404).send('Queue entry not found');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (entry.status !== 'pending') {
|
|
177
|
+
return wantsJson
|
|
178
|
+
? res.status(400).json({ error: 'Can only approve pending requests' })
|
|
179
|
+
: res.status(400).send('Can only approve pending requests');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
updateQueueStatus(id, 'approved');
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
await executeQueueEntry(entry);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
updateQueueStatus(id, 'failed', { results: [{ error: err.message }] });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const updated = getQueueEntry(id);
|
|
191
|
+
const counts = getQueueCounts();
|
|
192
|
+
|
|
193
|
+
if (wantsJson) {
|
|
194
|
+
return res.json({ success: true, entry: updated, counts });
|
|
195
|
+
}
|
|
196
|
+
res.redirect('/ui/queue');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
router.post('/queue/:id/reject', (req, res) => {
|
|
200
|
+
const { id } = req.params;
|
|
201
|
+
const { reason } = req.body;
|
|
202
|
+
const wantsJson = req.headers.accept?.includes('application/json');
|
|
203
|
+
|
|
204
|
+
const entry = getQueueEntry(id);
|
|
205
|
+
if (!entry) {
|
|
206
|
+
return wantsJson
|
|
207
|
+
? res.status(404).json({ error: 'Queue entry not found' })
|
|
208
|
+
: res.status(404).send('Queue entry not found');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (entry.status !== 'pending') {
|
|
212
|
+
return wantsJson
|
|
213
|
+
? res.status(400).json({ error: 'Can only reject pending requests' })
|
|
214
|
+
: res.status(400).send('Can only reject pending requests');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
updateQueueStatus(id, 'rejected', { rejection_reason: reason || 'No reason provided' });
|
|
218
|
+
|
|
219
|
+
const updated = getQueueEntry(id);
|
|
220
|
+
const counts = getQueueCounts();
|
|
221
|
+
|
|
222
|
+
if (wantsJson) {
|
|
223
|
+
return res.json({ success: true, entry: updated, counts });
|
|
224
|
+
}
|
|
225
|
+
res.redirect('/ui/queue');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
router.post('/queue/clear', (req, res) => {
|
|
229
|
+
const wantsJson = req.headers.accept?.includes('application/json');
|
|
230
|
+
const { status } = req.body;
|
|
231
|
+
|
|
232
|
+
// Only allow clearing non-pending statuses
|
|
233
|
+
const allowedStatuses = ['completed', 'failed', 'rejected', 'all'];
|
|
234
|
+
if (status && !allowedStatuses.includes(status)) {
|
|
235
|
+
return wantsJson
|
|
236
|
+
? res.status(400).json({ error: 'Invalid status' })
|
|
237
|
+
: res.status(400).send('Invalid status');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
clearQueueByStatus(status || 'all');
|
|
241
|
+
const counts = getQueueCounts();
|
|
242
|
+
|
|
243
|
+
if (wantsJson) {
|
|
244
|
+
return res.json({ success: true, counts });
|
|
245
|
+
}
|
|
246
|
+
res.redirect('/ui/queue');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
router.delete('/queue/:id', (req, res) => {
|
|
250
|
+
const { id } = req.params;
|
|
251
|
+
const wantsJson = req.headers.accept?.includes('application/json');
|
|
252
|
+
|
|
253
|
+
const entry = getQueueEntry(id);
|
|
254
|
+
if (!entry) {
|
|
255
|
+
return wantsJson
|
|
256
|
+
? res.status(404).json({ error: 'Queue entry not found' })
|
|
257
|
+
: res.status(404).send('Queue entry not found');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
deleteQueueEntry(id);
|
|
261
|
+
const counts = getQueueCounts();
|
|
262
|
+
|
|
263
|
+
if (wantsJson) {
|
|
264
|
+
return res.json({ success: true, counts });
|
|
265
|
+
}
|
|
266
|
+
res.redirect('/ui/queue');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// API Keys Management
|
|
270
|
+
router.get('/keys', (req, res) => {
|
|
271
|
+
const keys = listApiKeys();
|
|
272
|
+
res.send(renderKeysPage(keys));
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
router.post('/keys/create', async (req, res) => {
|
|
276
|
+
const { name } = req.body;
|
|
277
|
+
const wantsJson = req.headers.accept?.includes('application/json');
|
|
278
|
+
|
|
279
|
+
if (!name || !name.trim()) {
|
|
280
|
+
return wantsJson
|
|
281
|
+
? res.status(400).json({ error: 'Name is required' })
|
|
282
|
+
: res.send(renderKeysPage(listApiKeys(), 'Name is required'));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const newKey = await createApiKey(name.trim());
|
|
286
|
+
const keys = listApiKeys();
|
|
287
|
+
|
|
288
|
+
if (wantsJson) {
|
|
289
|
+
// Only return the full key in JSON response at creation time
|
|
290
|
+
return res.json({ success: true, key: newKey.key, keyPrefix: newKey.keyPrefix, name: newKey.name, keys });
|
|
291
|
+
}
|
|
292
|
+
res.send(renderKeysPage(keys, null, newKey));
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
router.post('/keys/:id/delete', (req, res) => {
|
|
296
|
+
const { id } = req.params;
|
|
297
|
+
const wantsJson = req.headers.accept?.includes('application/json');
|
|
298
|
+
|
|
299
|
+
deleteApiKey(id);
|
|
300
|
+
const keys = listApiKeys();
|
|
301
|
+
|
|
302
|
+
if (wantsJson) {
|
|
303
|
+
return res.json({ success: true, keys });
|
|
304
|
+
}
|
|
305
|
+
res.redirect('/ui/keys');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
router.delete('/keys/:id', (req, res) => {
|
|
309
|
+
const { id } = req.params;
|
|
310
|
+
const wantsJson = req.headers.accept?.includes('application/json');
|
|
311
|
+
|
|
312
|
+
deleteApiKey(id);
|
|
313
|
+
const keys = listApiKeys();
|
|
314
|
+
|
|
315
|
+
if (wantsJson) {
|
|
316
|
+
return res.json({ success: true, keys });
|
|
317
|
+
}
|
|
318
|
+
res.redirect('/ui/keys');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// HTML Templates
|
|
322
|
+
|
|
323
|
+
function renderPage(accounts, { hsyncConfig, hsyncUrl, hsyncConnected, pendingQueueCount }) {
|
|
324
|
+
return `<!DOCTYPE html>
|
|
325
|
+
<html>
|
|
326
|
+
<head>
|
|
327
|
+
<title>agentgate - Admin</title>
|
|
328
|
+
<link rel="icon" type="image/svg+xml" href="/public/favicon.svg">
|
|
329
|
+
<link rel="stylesheet" href="/public/style.css">
|
|
330
|
+
<script>
|
|
331
|
+
function copyText(text, btn) {
|
|
332
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
333
|
+
const orig = btn.textContent;
|
|
334
|
+
btn.textContent = 'Copied!';
|
|
335
|
+
setTimeout(() => btn.textContent = orig, 1500);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
</script>
|
|
339
|
+
</head>
|
|
340
|
+
<body>
|
|
341
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
|
342
|
+
<div style="display: flex; align-items: center; gap: 12px;">
|
|
343
|
+
<img src="/public/favicon.svg" alt="agentgate" style="height: 64px;">
|
|
344
|
+
<h1 style="margin: 0;">agentgate</h1>
|
|
345
|
+
</div>
|
|
346
|
+
<div style="display: flex; gap: 12px; align-items: center;">
|
|
347
|
+
<a href="/ui/keys" class="nav-btn nav-btn-default">API Keys</a>
|
|
348
|
+
<a href="/ui/queue" class="nav-btn nav-btn-default" style="position: relative;">
|
|
349
|
+
Write Queue
|
|
350
|
+
${pendingQueueCount > 0 ? `<span class="badge">${pendingQueueCount}</span>` : ''}
|
|
351
|
+
</a>
|
|
352
|
+
<div class="nav-divider"></div>
|
|
353
|
+
<form method="POST" action="/ui/logout" style="margin: 0;">
|
|
354
|
+
<button type="submit" class="nav-btn nav-btn-default" style="color: #f87171;">Logout</button>
|
|
355
|
+
</form>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
<p>API gateway for agents with human-in-the-loop write approval.</p>
|
|
359
|
+
<p class="help">API pattern: <code>/api/{service}/{accountName}/...</code></p>
|
|
360
|
+
|
|
361
|
+
<h2>Services</h2>
|
|
362
|
+
|
|
363
|
+
${renderAllCards(accounts, BASE_URL)}
|
|
364
|
+
|
|
365
|
+
<h2>Usage</h2>
|
|
366
|
+
<div class="card">
|
|
367
|
+
<p>Make requests with your API key in the Authorization header:</p>
|
|
368
|
+
<pre>
|
|
369
|
+
# Read requests (immediate)
|
|
370
|
+
curl -H "Authorization: Bearer rms_your_key_here" \\
|
|
371
|
+
http://localhost:${PORT}/api/github/personal/users/octocat
|
|
372
|
+
|
|
373
|
+
# Write requests (queued for approval)
|
|
374
|
+
curl -X POST http://localhost:${PORT}/api/queue/github/personal/submit \\
|
|
375
|
+
-H "Authorization: Bearer rms_your_key_here" \\
|
|
376
|
+
-H "Content-Type: application/json" \\
|
|
377
|
+
-d '{"requests":[{"method":"POST","path":"/repos/owner/repo/issues","body":{"title":"Bug"}}],"comment":"Creating issue"}'
|
|
378
|
+
</pre>
|
|
379
|
+
</div>
|
|
380
|
+
|
|
381
|
+
<h2>Advanced</h2>
|
|
382
|
+
<div class="card">
|
|
383
|
+
<details ${hsyncConfig?.enabled ? 'open' : ''}>
|
|
384
|
+
<summary>hsync (Remote Access) ${hsyncConnected ? '<span class="status configured">Connected</span>' : hsyncConfig?.enabled ? '<span class="status not-configured">Disconnected</span>' : ''}</summary>
|
|
385
|
+
<div style="margin-top: 16px;">
|
|
386
|
+
${hsyncConfig?.enabled ? `
|
|
387
|
+
<p>URL: <strong>${hsyncConfig.url}</strong></p>
|
|
388
|
+
${hsyncUrl ? `<p>Public URL: <span class="copyable">${hsyncUrl} <button type="button" class="copy-btn" onclick="copyText('${hsyncUrl}', this)">Copy</button></span></p>` : '<p class="help">Connecting... (refresh page to see URL)</p>'}
|
|
389
|
+
<form method="POST" action="/ui/hsync/delete">
|
|
390
|
+
<button type="submit" class="btn-danger">Disable</button>
|
|
391
|
+
</form>
|
|
392
|
+
` : `
|
|
393
|
+
<p class="help">Optional: Use <a href="https://hsync.tech" target="_blank">hsync</a> to expose this gateway to remote agents without opening ports.</p>
|
|
394
|
+
<form method="POST" action="/ui/hsync/setup">
|
|
395
|
+
<label>URL</label>
|
|
396
|
+
<input type="text" name="url" placeholder="https://yourname.hsync.tech" required>
|
|
397
|
+
<label>Token (optional)</label>
|
|
398
|
+
<input type="password" name="token" placeholder="Token if required">
|
|
399
|
+
<button type="submit" class="btn-primary">Enable hsync</button>
|
|
400
|
+
</form>
|
|
401
|
+
`}
|
|
402
|
+
</div>
|
|
403
|
+
</details>
|
|
404
|
+
</div>
|
|
405
|
+
</body>
|
|
406
|
+
</html>`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function renderLoginPage(error = '') {
|
|
410
|
+
return `<!DOCTYPE html>
|
|
411
|
+
<html>
|
|
412
|
+
<head>
|
|
413
|
+
<title>agentgate - Login</title>
|
|
414
|
+
<link rel="icon" type="image/svg+xml" href="/public/favicon.svg">
|
|
415
|
+
<link rel="stylesheet" href="/public/style.css">
|
|
416
|
+
</head>
|
|
417
|
+
<body>
|
|
418
|
+
<div class="card login-card">
|
|
419
|
+
<h1>agentgate</h1>
|
|
420
|
+
<h3>Welcome back</h3>
|
|
421
|
+
${error ? `<div class="error-message">${error}</div>` : ''}
|
|
422
|
+
<form method="POST" action="/ui/login">
|
|
423
|
+
<label>Admin Password</label>
|
|
424
|
+
<input type="password" name="password" placeholder="Enter your password" required autofocus>
|
|
425
|
+
<button type="submit" class="btn-primary">Login</button>
|
|
426
|
+
</form>
|
|
427
|
+
</div>
|
|
428
|
+
</body>
|
|
429
|
+
</html>`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function renderSetupPasswordPage(error = '') {
|
|
433
|
+
return `<!DOCTYPE html>
|
|
434
|
+
<html>
|
|
435
|
+
<head>
|
|
436
|
+
<title>agentgate - Setup</title>
|
|
437
|
+
<link rel="icon" type="image/svg+xml" href="/public/favicon.svg">
|
|
438
|
+
<link rel="stylesheet" href="/public/style.css">
|
|
439
|
+
</head>
|
|
440
|
+
<body>
|
|
441
|
+
<div class="card login-card">
|
|
442
|
+
<h1>agentgate</h1>
|
|
443
|
+
<h3>First time setup</h3>
|
|
444
|
+
<p class="help" style="text-align: center;">Create an admin password to protect your gateway.</p>
|
|
445
|
+
${error ? `<div class="error-message">${error}</div>` : ''}
|
|
446
|
+
<form method="POST" action="/ui/setup-password">
|
|
447
|
+
<label>Password</label>
|
|
448
|
+
<input type="password" name="password" placeholder="Choose a password" required autofocus>
|
|
449
|
+
<label>Confirm Password</label>
|
|
450
|
+
<input type="password" name="confirmPassword" placeholder="Confirm your password" required>
|
|
451
|
+
<button type="submit" class="btn-primary">Get Started</button>
|
|
452
|
+
</form>
|
|
453
|
+
</div>
|
|
454
|
+
</body>
|
|
455
|
+
</html>`;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function renderQueuePage(entries, filter, counts) {
|
|
459
|
+
const escapeHtml = (str) => {
|
|
460
|
+
if (typeof str !== 'string') str = JSON.stringify(str, null, 2);
|
|
461
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
// Render markdown links [text](url) while escaping everything else
|
|
465
|
+
const renderMarkdownLinks = (str) => {
|
|
466
|
+
if (!str) return '';
|
|
467
|
+
// First escape HTML
|
|
468
|
+
let escaped = escapeHtml(str);
|
|
469
|
+
// Then convert markdown links to anchor tags
|
|
470
|
+
// Pattern: [text](url) where url must start with http:// or https://
|
|
471
|
+
escaped = escaped.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
|
|
472
|
+
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
473
|
+
return escaped;
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const statusBadge = (status) => {
|
|
477
|
+
const colors = {
|
|
478
|
+
pending: 'background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3);',
|
|
479
|
+
approved: 'background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.3);',
|
|
480
|
+
executing: 'background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.3);',
|
|
481
|
+
completed: 'background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3);',
|
|
482
|
+
failed: 'background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3);',
|
|
483
|
+
rejected: 'background: rgba(156, 163, 175, 0.15); color: #9ca3af; border: 1px solid rgba(156, 163, 175, 0.3);'
|
|
484
|
+
};
|
|
485
|
+
return `<span class="status" style="${colors[status] || ''}">${status}</span>`;
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
const formatDate = (dateStr) => {
|
|
489
|
+
if (!dateStr) return '';
|
|
490
|
+
const d = new Date(dateStr);
|
|
491
|
+
return d.toLocaleString();
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const renderEntry = (entry) => {
|
|
495
|
+
const requestsSummary = entry.requests.map((r) =>
|
|
496
|
+
`<div class="request-item"><code>${r.method}</code> <span>${escapeHtml(r.path)}</span></div>`
|
|
497
|
+
).join('');
|
|
498
|
+
|
|
499
|
+
let actions = '';
|
|
500
|
+
if (entry.status === 'pending') {
|
|
501
|
+
actions = `
|
|
502
|
+
<div class="queue-actions" id="actions-${entry.id}">
|
|
503
|
+
<button type="button" class="btn-primary btn-sm" onclick="approveEntry('${entry.id}')">Approve</button>
|
|
504
|
+
<input type="text" id="reason-${entry.id}" placeholder="Rejection reason (optional)" class="reject-input">
|
|
505
|
+
<button type="button" class="btn-danger btn-sm" onclick="rejectEntry('${entry.id}')">Reject</button>
|
|
506
|
+
</div>
|
|
507
|
+
`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
let resultSection = '';
|
|
511
|
+
if (entry.results) {
|
|
512
|
+
resultSection = `
|
|
513
|
+
<details style="margin-top: 12px;">
|
|
514
|
+
<summary>Results (${entry.results.length})</summary>
|
|
515
|
+
<pre style="margin-top: 8px; font-size: 12px;">${escapeHtml(JSON.stringify(entry.results, null, 2))}</pre>
|
|
516
|
+
</details>
|
|
517
|
+
`;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (entry.rejection_reason) {
|
|
521
|
+
resultSection = `
|
|
522
|
+
<div class="rejection-reason">
|
|
523
|
+
<strong>Rejection reason:</strong> ${escapeHtml(entry.rejection_reason)}
|
|
524
|
+
</div>
|
|
525
|
+
`;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return `
|
|
529
|
+
<div class="card queue-entry" id="entry-${entry.id}" data-status="${entry.status}">
|
|
530
|
+
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px;">
|
|
531
|
+
<div class="entry-header">
|
|
532
|
+
<strong>${entry.service}</strong> / ${entry.account_name}
|
|
533
|
+
<span class="status-badge">${statusBadge(entry.status)}</span>
|
|
534
|
+
</div>
|
|
535
|
+
<div style="display: flex; align-items: center; gap: 12px;">
|
|
536
|
+
<span class="help" style="margin: 0;">${formatDate(entry.submitted_at)}</span>
|
|
537
|
+
<button type="button" class="delete-btn" onclick="deleteEntry('${entry.id}')" title="Delete">×</button>
|
|
538
|
+
</div>
|
|
539
|
+
</div>
|
|
540
|
+
|
|
541
|
+
${entry.comment ? `<p class="agent-comment"><strong>Agent says:</strong> ${renderMarkdownLinks(entry.comment)}</p>` : ''}
|
|
542
|
+
|
|
543
|
+
<div class="help" style="margin-bottom: 8px;">Submitted by: <code>${escapeHtml(entry.submitted_by || 'unknown')}</code></div>
|
|
544
|
+
|
|
545
|
+
<div class="requests-list">
|
|
546
|
+
${requestsSummary}
|
|
547
|
+
</div>
|
|
548
|
+
|
|
549
|
+
<details style="margin-top: 12px;">
|
|
550
|
+
<summary>Request Details</summary>
|
|
551
|
+
<pre style="margin-top: 8px; font-size: 12px;">${escapeHtml(JSON.stringify(entry.requests, null, 2))}</pre>
|
|
552
|
+
</details>
|
|
553
|
+
|
|
554
|
+
${resultSection}
|
|
555
|
+
${actions}
|
|
556
|
+
</div>
|
|
557
|
+
`;
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const filters = ['all', 'pending', 'completed', 'failed', 'rejected'];
|
|
561
|
+
const filterLinks = filters.map(f =>
|
|
562
|
+
`<a href="/ui/queue?filter=${f}" class="filter-link ${filter === f ? 'active' : ''}">${f}${counts[f] > 0 ? ` (${counts[f]})` : ''}</a>`
|
|
563
|
+
).join('');
|
|
564
|
+
|
|
565
|
+
return `<!DOCTYPE html>
|
|
566
|
+
<html>
|
|
567
|
+
<head>
|
|
568
|
+
<title>agentgate - Write Queue</title>
|
|
569
|
+
<link rel="icon" type="image/svg+xml" href="/public/favicon.svg">
|
|
570
|
+
<link rel="stylesheet" href="/public/style.css">
|
|
571
|
+
<style>
|
|
572
|
+
.filter-bar { display: flex; gap: 10px; margin-bottom: 24px; flex-wrap: wrap; align-items: center; }
|
|
573
|
+
.filter-link {
|
|
574
|
+
padding: 10px 20px;
|
|
575
|
+
border-radius: 25px;
|
|
576
|
+
text-decoration: none;
|
|
577
|
+
background: rgba(255, 255, 255, 0.05);
|
|
578
|
+
color: var(--gray-400);
|
|
579
|
+
font-weight: 600;
|
|
580
|
+
font-size: 13px;
|
|
581
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
582
|
+
transition: all 0.3s ease;
|
|
583
|
+
}
|
|
584
|
+
.filter-link:hover {
|
|
585
|
+
background: rgba(255, 255, 255, 0.1);
|
|
586
|
+
color: var(--gray-200);
|
|
587
|
+
border-color: rgba(255, 255, 255, 0.2);
|
|
588
|
+
}
|
|
589
|
+
.filter-link.active {
|
|
590
|
+
background: linear-gradient(135deg, var(--primary) 0%, #8b5cf6 100%);
|
|
591
|
+
color: white;
|
|
592
|
+
border-color: transparent;
|
|
593
|
+
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.4);
|
|
594
|
+
}
|
|
595
|
+
.queue-entry { margin-bottom: 20px; }
|
|
596
|
+
.request-item {
|
|
597
|
+
padding: 12px 16px;
|
|
598
|
+
background: rgba(0, 0, 0, 0.2);
|
|
599
|
+
border-radius: 8px;
|
|
600
|
+
margin: 6px 0;
|
|
601
|
+
font-size: 14px;
|
|
602
|
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
603
|
+
display: flex;
|
|
604
|
+
align-items: center;
|
|
605
|
+
gap: 12px;
|
|
606
|
+
}
|
|
607
|
+
.request-item code {
|
|
608
|
+
background: rgba(99, 102, 241, 0.2);
|
|
609
|
+
padding: 4px 10px;
|
|
610
|
+
border-radius: 6px;
|
|
611
|
+
font-weight: 700;
|
|
612
|
+
color: var(--primary-light);
|
|
613
|
+
border: 1px solid rgba(99, 102, 241, 0.3);
|
|
614
|
+
font-size: 12px;
|
|
615
|
+
}
|
|
616
|
+
.request-item span { color: var(--gray-300); }
|
|
617
|
+
.queue-actions {
|
|
618
|
+
margin-top: 20px;
|
|
619
|
+
padding-top: 20px;
|
|
620
|
+
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
621
|
+
display: flex;
|
|
622
|
+
align-items: center;
|
|
623
|
+
gap: 12px;
|
|
624
|
+
flex-wrap: wrap;
|
|
625
|
+
}
|
|
626
|
+
.queue-actions input[type="text"] {
|
|
627
|
+
width: 240px;
|
|
628
|
+
padding: 10px 14px;
|
|
629
|
+
margin: 0;
|
|
630
|
+
font-size: 13px;
|
|
631
|
+
}
|
|
632
|
+
.back-link {
|
|
633
|
+
color: #818cf8;
|
|
634
|
+
text-decoration: none;
|
|
635
|
+
font-weight: 600;
|
|
636
|
+
transition: color 0.2s ease;
|
|
637
|
+
}
|
|
638
|
+
.back-link:hover { color: #ffffff; }
|
|
639
|
+
.delete-btn {
|
|
640
|
+
background: rgba(239, 68, 68, 0.1);
|
|
641
|
+
border: 1px solid rgba(239, 68, 68, 0.2);
|
|
642
|
+
color: #f87171;
|
|
643
|
+
font-size: 18px;
|
|
644
|
+
cursor: pointer;
|
|
645
|
+
padding: 4px 10px;
|
|
646
|
+
line-height: 1;
|
|
647
|
+
font-weight: bold;
|
|
648
|
+
border-radius: 6px;
|
|
649
|
+
transition: all 0.2s ease;
|
|
650
|
+
}
|
|
651
|
+
.delete-btn:hover {
|
|
652
|
+
background: rgba(239, 68, 68, 0.2);
|
|
653
|
+
border-color: rgba(239, 68, 68, 0.4);
|
|
654
|
+
}
|
|
655
|
+
.clear-section { margin-left: auto; display: flex; gap: 10px; }
|
|
656
|
+
.entry-header {
|
|
657
|
+
display: flex;
|
|
658
|
+
align-items: center;
|
|
659
|
+
gap: 12px;
|
|
660
|
+
}
|
|
661
|
+
.entry-header strong {
|
|
662
|
+
color: #f3f4f6;
|
|
663
|
+
font-size: 16px;
|
|
664
|
+
}
|
|
665
|
+
.reject-input {
|
|
666
|
+
width: 240px;
|
|
667
|
+
padding: 10px 14px;
|
|
668
|
+
margin: 0;
|
|
669
|
+
font-size: 13px;
|
|
670
|
+
background: rgba(15, 15, 25, 0.6);
|
|
671
|
+
border: 2px solid rgba(239, 68, 68, 0.2);
|
|
672
|
+
border-radius: 8px;
|
|
673
|
+
color: #f3f4f6;
|
|
674
|
+
}
|
|
675
|
+
.reject-input:focus {
|
|
676
|
+
outline: none;
|
|
677
|
+
border-color: #f87171;
|
|
678
|
+
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.15);
|
|
679
|
+
}
|
|
680
|
+
.reject-input::placeholder {
|
|
681
|
+
color: #6b7280;
|
|
682
|
+
}
|
|
683
|
+
.agent-comment {
|
|
684
|
+
margin: 0 0 16px 0;
|
|
685
|
+
padding: 16px;
|
|
686
|
+
background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%);
|
|
687
|
+
border-radius: 10px;
|
|
688
|
+
border-left: 4px solid #6366f1;
|
|
689
|
+
color: #e5e7eb;
|
|
690
|
+
}
|
|
691
|
+
.agent-comment strong { color: #818cf8; }
|
|
692
|
+
.agent-comment a { color: #818cf8; }
|
|
693
|
+
.rejection-reason {
|
|
694
|
+
margin-top: 16px;
|
|
695
|
+
padding: 16px;
|
|
696
|
+
background: rgba(239, 68, 68, 0.1);
|
|
697
|
+
border-radius: 10px;
|
|
698
|
+
border-left: 4px solid #f87171;
|
|
699
|
+
color: #e5e7eb;
|
|
700
|
+
}
|
|
701
|
+
.rejection-reason strong { color: #f87171; }
|
|
702
|
+
.empty-state {
|
|
703
|
+
text-align: center;
|
|
704
|
+
padding: 60px 40px;
|
|
705
|
+
}
|
|
706
|
+
.empty-state p { color: #6b7280; margin: 0; font-size: 16px; }
|
|
707
|
+
</style>
|
|
708
|
+
</head>
|
|
709
|
+
<body>
|
|
710
|
+
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
711
|
+
<h1>Write Queue</h1>
|
|
712
|
+
<a href="/ui" class="back-link">← Back to Dashboard</a>
|
|
713
|
+
</div>
|
|
714
|
+
<p>Review and approve write requests from agents.</p>
|
|
715
|
+
|
|
716
|
+
<div class="filter-bar" id="filter-bar">
|
|
717
|
+
${filterLinks}
|
|
718
|
+
<div class="clear-section">
|
|
719
|
+
${filter === 'completed' && counts.completed > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'completed\')">Clear Completed</button>' : ''}
|
|
720
|
+
${filter === 'failed' && counts.failed > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'failed\')">Clear Failed</button>' : ''}
|
|
721
|
+
${filter === 'rejected' && counts.rejected > 0 ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'rejected\')">Clear Rejected</button>' : ''}
|
|
722
|
+
${filter === 'all' && (counts.completed > 0 || counts.failed > 0 || counts.rejected > 0) ? '<button type="button" class="btn-sm btn-danger" onclick="clearByStatus(\'all\')">Clear All Non-Pending</button>' : ''}
|
|
723
|
+
</div>
|
|
724
|
+
</div>
|
|
725
|
+
|
|
726
|
+
<div id="entries-container">
|
|
727
|
+
${entries.length === 0 ? `
|
|
728
|
+
<div class="card empty-state">
|
|
729
|
+
<p>No ${filter === 'all' ? '' : filter + ' '}requests in queue</p>
|
|
730
|
+
</div>
|
|
731
|
+
` : entries.map(renderEntry).join('')}
|
|
732
|
+
</div>
|
|
733
|
+
|
|
734
|
+
<script>
|
|
735
|
+
const statusColors = {
|
|
736
|
+
pending: 'background: #fef3c7; color: #92400e;',
|
|
737
|
+
approved: 'background: #dbeafe; color: #1e40af;',
|
|
738
|
+
executing: 'background: #dbeafe; color: #1e40af;',
|
|
739
|
+
completed: 'background: #d1fae5; color: #065f46;',
|
|
740
|
+
failed: 'background: #fee2e2; color: #991b1b;',
|
|
741
|
+
rejected: 'background: #f3f4f6; color: #374151;'
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
function updateCounts(counts) {
|
|
745
|
+
const filters = ['all', 'pending', 'completed', 'failed', 'rejected'];
|
|
746
|
+
const filterBar = document.getElementById('filter-bar');
|
|
747
|
+
const links = filterBar.querySelectorAll('.filter-link');
|
|
748
|
+
links.forEach((link, i) => {
|
|
749
|
+
const f = filters[i];
|
|
750
|
+
const count = counts[f] || 0;
|
|
751
|
+
link.textContent = f + (count > 0 ? ' (' + count + ')' : '');
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function updateEntryStatus(id, entry) {
|
|
756
|
+
const el = document.getElementById('entry-' + id);
|
|
757
|
+
if (!el) return;
|
|
758
|
+
|
|
759
|
+
el.dataset.status = entry.status;
|
|
760
|
+
|
|
761
|
+
// Update status badge
|
|
762
|
+
const badgeContainer = el.querySelector('.status-badge');
|
|
763
|
+
if (badgeContainer) {
|
|
764
|
+
badgeContainer.innerHTML = '<span class="status" style="' + (statusColors[entry.status] || '') + '">' + entry.status + '</span>';
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Remove actions for non-pending
|
|
768
|
+
const actions = document.getElementById('actions-' + id);
|
|
769
|
+
if (actions && entry.status !== 'pending') {
|
|
770
|
+
actions.remove();
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Add result section if completed/failed
|
|
774
|
+
if (entry.results && (entry.status === 'completed' || entry.status === 'failed')) {
|
|
775
|
+
const existing = el.querySelector('.result-section');
|
|
776
|
+
if (!existing) {
|
|
777
|
+
const resultHtml = '<details class="result-section" style="margin-top: 12px;" open><summary>Results (' + entry.results.length + ')</summary><pre style="margin-top: 8px; font-size: 12px;">' + escapeHtml(JSON.stringify(entry.results, null, 2)) + '</pre></details>';
|
|
778
|
+
el.insertAdjacentHTML('beforeend', resultHtml);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Add rejection reason if rejected
|
|
783
|
+
if (entry.rejection_reason && entry.status === 'rejected') {
|
|
784
|
+
const existing = el.querySelector('.rejection-reason');
|
|
785
|
+
if (!existing) {
|
|
786
|
+
const reasonHtml = '<div class="rejection-reason"><strong>Rejection reason:</strong> ' + escapeHtml(entry.rejection_reason) + '</div>';
|
|
787
|
+
el.insertAdjacentHTML('beforeend', reasonHtml);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function escapeHtml(str) {
|
|
793
|
+
if (typeof str !== 'string') str = JSON.stringify(str, null, 2);
|
|
794
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function approveEntry(id) {
|
|
798
|
+
const btn = event.target;
|
|
799
|
+
btn.disabled = true;
|
|
800
|
+
btn.textContent = 'Approving...';
|
|
801
|
+
|
|
802
|
+
try {
|
|
803
|
+
const res = await fetch('/ui/queue/' + id + '/approve', {
|
|
804
|
+
method: 'POST',
|
|
805
|
+
headers: { 'Accept': 'application/json' }
|
|
806
|
+
});
|
|
807
|
+
const data = await res.json();
|
|
808
|
+
|
|
809
|
+
if (data.success) {
|
|
810
|
+
updateEntryStatus(id, data.entry);
|
|
811
|
+
updateCounts(data.counts);
|
|
812
|
+
} else {
|
|
813
|
+
alert(data.error || 'Failed to approve');
|
|
814
|
+
btn.disabled = false;
|
|
815
|
+
btn.textContent = 'Approve';
|
|
816
|
+
}
|
|
817
|
+
} catch (err) {
|
|
818
|
+
alert('Error: ' + err.message);
|
|
819
|
+
btn.disabled = false;
|
|
820
|
+
btn.textContent = 'Approve';
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async function rejectEntry(id) {
|
|
825
|
+
const btn = event.target;
|
|
826
|
+
const reasonInput = document.getElementById('reason-' + id);
|
|
827
|
+
const reason = reasonInput ? reasonInput.value : '';
|
|
828
|
+
|
|
829
|
+
btn.disabled = true;
|
|
830
|
+
btn.textContent = 'Rejecting...';
|
|
831
|
+
|
|
832
|
+
try {
|
|
833
|
+
const res = await fetch('/ui/queue/' + id + '/reject', {
|
|
834
|
+
method: 'POST',
|
|
835
|
+
headers: {
|
|
836
|
+
'Accept': 'application/json',
|
|
837
|
+
'Content-Type': 'application/json'
|
|
838
|
+
},
|
|
839
|
+
body: JSON.stringify({ reason })
|
|
840
|
+
});
|
|
841
|
+
const data = await res.json();
|
|
842
|
+
|
|
843
|
+
if (data.success) {
|
|
844
|
+
updateEntryStatus(id, data.entry);
|
|
845
|
+
updateCounts(data.counts);
|
|
846
|
+
} else {
|
|
847
|
+
alert(data.error || 'Failed to reject');
|
|
848
|
+
btn.disabled = false;
|
|
849
|
+
btn.textContent = 'Reject';
|
|
850
|
+
}
|
|
851
|
+
} catch (err) {
|
|
852
|
+
alert('Error: ' + err.message);
|
|
853
|
+
btn.disabled = false;
|
|
854
|
+
btn.textContent = 'Reject';
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async function clearByStatus(status) {
|
|
859
|
+
const btn = event.target;
|
|
860
|
+
const originalText = btn.textContent;
|
|
861
|
+
btn.disabled = true;
|
|
862
|
+
btn.textContent = 'Clearing...';
|
|
863
|
+
|
|
864
|
+
try {
|
|
865
|
+
const res = await fetch('/ui/queue/clear', {
|
|
866
|
+
method: 'POST',
|
|
867
|
+
headers: {
|
|
868
|
+
'Accept': 'application/json',
|
|
869
|
+
'Content-Type': 'application/json'
|
|
870
|
+
},
|
|
871
|
+
body: JSON.stringify({ status })
|
|
872
|
+
});
|
|
873
|
+
const data = await res.json();
|
|
874
|
+
|
|
875
|
+
if (data.success) {
|
|
876
|
+
// Remove cleared entries from DOM
|
|
877
|
+
document.querySelectorAll('.queue-entry').forEach(el => {
|
|
878
|
+
const entryStatus = el.dataset.status;
|
|
879
|
+
if (status === 'all') {
|
|
880
|
+
if (entryStatus === 'completed' || entryStatus === 'failed' || entryStatus === 'rejected') {
|
|
881
|
+
el.remove();
|
|
882
|
+
}
|
|
883
|
+
} else if (entryStatus === status) {
|
|
884
|
+
el.remove();
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
updateCounts(data.counts);
|
|
888
|
+
|
|
889
|
+
// Show empty message if no entries left
|
|
890
|
+
const container = document.getElementById('entries-container');
|
|
891
|
+
if (container.querySelectorAll('.queue-entry').length === 0) {
|
|
892
|
+
container.innerHTML = '<div class="card" style="text-align: center; padding: 40px;"><p style="color: var(--gray-500); margin: 0;">No requests in queue</p></div>';
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Hide the clear button if nothing left to clear
|
|
896
|
+
btn.style.display = 'none';
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
btn.disabled = false;
|
|
900
|
+
btn.textContent = originalText;
|
|
901
|
+
} catch (err) {
|
|
902
|
+
alert('Error: ' + err.message);
|
|
903
|
+
btn.disabled = false;
|
|
904
|
+
btn.textContent = originalText;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
async function deleteEntry(id) {
|
|
909
|
+
try {
|
|
910
|
+
const res = await fetch('/ui/queue/' + id, {
|
|
911
|
+
method: 'DELETE',
|
|
912
|
+
headers: { 'Accept': 'application/json' }
|
|
913
|
+
});
|
|
914
|
+
const data = await res.json();
|
|
915
|
+
|
|
916
|
+
if (data.success) {
|
|
917
|
+
const el = document.getElementById('entry-' + id);
|
|
918
|
+
if (el) el.remove();
|
|
919
|
+
updateCounts(data.counts);
|
|
920
|
+
|
|
921
|
+
// Show empty message if no entries left
|
|
922
|
+
const container = document.getElementById('entries-container');
|
|
923
|
+
if (container.querySelectorAll('.queue-entry').length === 0) {
|
|
924
|
+
container.innerHTML = '<div class="card" style="text-align: center; padding: 40px;"><p style="color: var(--gray-500); margin: 0;">No requests in queue</p></div>';
|
|
925
|
+
}
|
|
926
|
+
} else {
|
|
927
|
+
alert(data.error || 'Failed to delete');
|
|
928
|
+
}
|
|
929
|
+
} catch (err) {
|
|
930
|
+
alert('Error: ' + err.message);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
</script>
|
|
934
|
+
</body>
|
|
935
|
+
</html>`;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function renderKeysPage(keys, error = null, newKey = null) {
|
|
939
|
+
const escapeHtml = (str) => {
|
|
940
|
+
if (typeof str !== 'string') return '';
|
|
941
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
const formatDate = (dateStr) => {
|
|
945
|
+
if (!dateStr) return '';
|
|
946
|
+
const d = new Date(dateStr);
|
|
947
|
+
return d.toLocaleString();
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
const renderKeyRow = (k) => `
|
|
951
|
+
<tr id="key-${k.id}">
|
|
952
|
+
<td><strong>${escapeHtml(k.name)}</strong></td>
|
|
953
|
+
<td><code class="key-value">${escapeHtml(k.key_prefix)}</code></td>
|
|
954
|
+
<td>${formatDate(k.created_at)}</td>
|
|
955
|
+
<td>
|
|
956
|
+
<button type="button" class="delete-btn" onclick="deleteKey('${k.id}')" title="Delete">×</button>
|
|
957
|
+
</td>
|
|
958
|
+
</tr>
|
|
959
|
+
`;
|
|
960
|
+
|
|
961
|
+
return `<!DOCTYPE html>
|
|
962
|
+
<html>
|
|
963
|
+
<head>
|
|
964
|
+
<title>agentgate - API Keys</title>
|
|
965
|
+
<link rel="icon" type="image/svg+xml" href="/public/favicon.svg">
|
|
966
|
+
<link rel="stylesheet" href="/public/style.css">
|
|
967
|
+
<style>
|
|
968
|
+
.keys-table { width: 100%; border-collapse: collapse; margin-top: 16px; }
|
|
969
|
+
.keys-table th, .keys-table td { padding: 12px; text-align: left; border-bottom: 1px solid var(--gray-200); }
|
|
970
|
+
.keys-table th { font-weight: 600; color: var(--gray-600); font-size: 14px; }
|
|
971
|
+
.key-value { background: var(--gray-100); padding: 4px 8px; border-radius: 4px; font-size: 13px; }
|
|
972
|
+
.new-key-banner { background: #d1fae5; border: 1px solid #10b981; padding: 16px; border-radius: 8px; margin-bottom: 20px; }
|
|
973
|
+
.new-key-banner code { background: white; padding: 8px 12px; border-radius: 4px; display: block; margin-top: 8px; font-size: 14px; word-break: break-all; }
|
|
974
|
+
.delete-btn { background: none; border: none; color: #dc2626; font-size: 20px; cursor: pointer; padding: 0 4px; line-height: 1; font-weight: bold; }
|
|
975
|
+
.delete-btn:hover { color: #991b1b; }
|
|
976
|
+
.back-link { color: var(--primary); text-decoration: none; font-weight: 500; }
|
|
977
|
+
.back-link:hover { text-decoration: underline; }
|
|
978
|
+
.error-message { background: #fee2e2; color: #991b1b; padding: 12px; border-radius: 8px; margin-bottom: 16px; }
|
|
979
|
+
</style>
|
|
980
|
+
</head>
|
|
981
|
+
<body>
|
|
982
|
+
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
983
|
+
<h1>API Keys</h1>
|
|
984
|
+
<a href="/ui" class="back-link">← Back to Dashboard</a>
|
|
985
|
+
</div>
|
|
986
|
+
<p>Manage API keys for your agents. Keys are hashed and can only be viewed once at creation.</p>
|
|
987
|
+
|
|
988
|
+
${error ? `<div class="error-message">${escapeHtml(error)}</div>` : ''}
|
|
989
|
+
|
|
990
|
+
${newKey ? `
|
|
991
|
+
<div class="new-key-banner">
|
|
992
|
+
<strong>New API key created!</strong> Copy it now - you won't be able to see it again.
|
|
993
|
+
<code>${newKey.key}</code>
|
|
994
|
+
<button type="button" class="btn-sm btn-primary" onclick="copyKey('${newKey.key}', this)" style="margin-top: 8px;">Copy to Clipboard</button>
|
|
995
|
+
</div>
|
|
996
|
+
` : ''}
|
|
997
|
+
|
|
998
|
+
<div class="card">
|
|
999
|
+
<h3>Create New Key</h3>
|
|
1000
|
+
<form method="POST" action="/ui/keys/create" style="display: flex; gap: 12px; align-items: flex-end;">
|
|
1001
|
+
<div style="flex: 1;">
|
|
1002
|
+
<label>Key Name</label>
|
|
1003
|
+
<input type="text" name="name" placeholder="e.g., clawdbot, moltbot, dev-agent" required>
|
|
1004
|
+
</div>
|
|
1005
|
+
<button type="submit" class="btn-primary">Create Key</button>
|
|
1006
|
+
</form>
|
|
1007
|
+
</div>
|
|
1008
|
+
|
|
1009
|
+
<div class="card">
|
|
1010
|
+
<h3>Existing Keys (${keys.length})</h3>
|
|
1011
|
+
${keys.length === 0 ? `
|
|
1012
|
+
<p style="color: var(--gray-500); text-align: center; padding: 20px;">No API keys yet. Create one above.</p>
|
|
1013
|
+
` : `
|
|
1014
|
+
<table class="keys-table">
|
|
1015
|
+
<thead>
|
|
1016
|
+
<tr>
|
|
1017
|
+
<th>Name</th>
|
|
1018
|
+
<th>Key Prefix</th>
|
|
1019
|
+
<th>Created</th>
|
|
1020
|
+
<th></th>
|
|
1021
|
+
</tr>
|
|
1022
|
+
</thead>
|
|
1023
|
+
<tbody id="keys-tbody">
|
|
1024
|
+
${keys.map(renderKeyRow).join('')}
|
|
1025
|
+
</tbody>
|
|
1026
|
+
</table>
|
|
1027
|
+
`}
|
|
1028
|
+
</div>
|
|
1029
|
+
|
|
1030
|
+
<script>
|
|
1031
|
+
function copyKey(key, btn) {
|
|
1032
|
+
navigator.clipboard.writeText(key).then(() => {
|
|
1033
|
+
const orig = btn.textContent;
|
|
1034
|
+
btn.textContent = 'Copied!';
|
|
1035
|
+
setTimeout(() => btn.textContent = orig, 1500);
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
async function deleteKey(id) {
|
|
1040
|
+
if (!confirm('Delete this API key? Any agents using it will lose access.')) return;
|
|
1041
|
+
|
|
1042
|
+
try {
|
|
1043
|
+
const res = await fetch('/ui/keys/' + id, {
|
|
1044
|
+
method: 'DELETE',
|
|
1045
|
+
headers: { 'Accept': 'application/json' }
|
|
1046
|
+
});
|
|
1047
|
+
const data = await res.json();
|
|
1048
|
+
|
|
1049
|
+
if (data.success) {
|
|
1050
|
+
const row = document.getElementById('key-' + id);
|
|
1051
|
+
if (row) row.remove();
|
|
1052
|
+
|
|
1053
|
+
// Update count
|
|
1054
|
+
const tbody = document.getElementById('keys-tbody');
|
|
1055
|
+
const count = tbody ? tbody.querySelectorAll('tr').length : 0;
|
|
1056
|
+
document.querySelector('.card:last-of-type h3').textContent = 'Existing Keys (' + count + ')';
|
|
1057
|
+
|
|
1058
|
+
// Show empty message if no keys left
|
|
1059
|
+
if (count === 0) {
|
|
1060
|
+
const table = document.querySelector('.keys-table');
|
|
1061
|
+
if (table) {
|
|
1062
|
+
table.outerHTML = '<p style="color: var(--gray-500); text-align: center; padding: 20px;">No API keys yet. Create one above.</p>';
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
} else {
|
|
1066
|
+
alert(data.error || 'Failed to delete');
|
|
1067
|
+
}
|
|
1068
|
+
} catch (err) {
|
|
1069
|
+
alert('Error: ' + err.message);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
</script>
|
|
1073
|
+
</body>
|
|
1074
|
+
</html>`;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
export default router;
|