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.
@@ -1,5 +1,8 @@
1
1
  import { Router } from 'express';
2
- import { createQueueEntry, getQueueEntry, getAccountCredentials, listQueueEntriesBySubmitter } from '../lib/db.js';
2
+ import { createQueueEntry, getQueueEntry, getAccountCredentials, listQueueEntriesBySubmitter, updateQueueStatus, getSharedQueueVisibility, listAllQueueEntries, getAgentWithdrawEnabled, checkServiceAccess, checkBypassAuth, addQueueWarning, getQueueWarnings } from '../lib/db.js';
3
+ import { notifyAgentQueueWarning } from '../lib/agentNotifier.js';
4
+ import { emitCountUpdate, emitEvent } from '../lib/socketManager.js';
5
+ import { executeQueueEntry } from '../lib/queueExecutor.js';
3
6
 
4
7
  const router = Router();
5
8
 
@@ -11,7 +14,7 @@ const VALID_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
11
14
 
12
15
  // Submit a batch of write requests for approval
13
16
  // POST /api/queue/:service/:accountName/submit
14
- router.post('/:service/:accountName/submit', (req, res) => {
17
+ router.post('/:service/:accountName/submit', async (req, res) => {
15
18
  try {
16
19
  const { service, accountName } = req.params;
17
20
  const { requests, comment } = req.body;
@@ -33,6 +36,19 @@ router.post('/:service/:accountName/submit', (req, res) => {
33
36
  });
34
37
  }
35
38
 
39
+ // Check service access
40
+ const agentName = req.apiKeyInfo?.name;
41
+ if (agentName) {
42
+ const access = checkServiceAccess(service, accountName, agentName);
43
+ if (!access.allowed) {
44
+ return res.status(403).json({
45
+ error: `Agent '${agentName}' does not have access to service '${service}/${accountName}'`,
46
+ reason: access.reason
47
+ });
48
+ }
49
+ }
50
+
51
+
36
52
  // Validate requests array
37
53
  if (!Array.isArray(requests) || requests.length === 0) {
38
54
  return res.status(400).json({
@@ -66,9 +82,45 @@ router.post('/:service/:accountName/submit', (req, res) => {
66
82
  // Get the API key name for audit trail
67
83
  const submittedBy = req.apiKeyInfo?.name || 'unknown';
68
84
 
85
+ // Check if agent has bypass_auth enabled for this service
86
+ const hasBypass = agentName && checkBypassAuth(service, accountName, agentName);
87
+
69
88
  // Create the queue entry
70
89
  const entry = createQueueEntry(service, accountName, requests, comment, submittedBy);
71
90
 
91
+ // If bypass enabled, execute immediately
92
+ if (hasBypass) {
93
+ try {
94
+ updateQueueStatus(entry.id, 'approved');
95
+ await executeQueueEntry({ ...entry, status: 'approved' });
96
+ const updatedEntry = getQueueEntry(entry.id);
97
+
98
+ emitCountUpdate();
99
+
100
+ return res.status(200).json({
101
+ id: entry.id,
102
+ status: updatedEntry.status,
103
+ message: 'Request executed immediately (bypass_auth enabled)',
104
+ bypassed: true,
105
+ results: updatedEntry.results
106
+ });
107
+ } catch (err) {
108
+ updateQueueStatus(entry.id, 'failed', { results: [{ error: err.message }] });
109
+ emitCountUpdate();
110
+
111
+ return res.status(500).json({
112
+ id: entry.id,
113
+ status: 'failed',
114
+ message: 'Bypass execution failed',
115
+ bypassed: true,
116
+ error: err.message
117
+ });
118
+ }
119
+ }
120
+
121
+ // Emit real-time update
122
+ emitCountUpdate();
123
+
72
124
  res.status(201).json({
73
125
  id: entry.id,
74
126
  status: entry.status,
@@ -88,10 +140,14 @@ router.post('/:service/:accountName/submit', (req, res) => {
88
140
  router.get('/list', (req, res) => {
89
141
  try {
90
142
  const submittedBy = req.apiKeyInfo?.name || 'unknown';
91
- const entries = listQueueEntriesBySubmitter(submittedBy);
143
+ const sharedVisibility = getSharedQueueVisibility();
144
+ const entries = sharedVisibility
145
+ ? listAllQueueEntries()
146
+ : listQueueEntriesBySubmitter(submittedBy);
92
147
 
93
148
  res.json({
94
149
  count: entries.length,
150
+ shared_visibility: sharedVisibility,
95
151
  entries: entries
96
152
  });
97
153
  } catch (error) {
@@ -115,10 +171,14 @@ router.get('/:service/:accountName/list', (req, res) => {
115
171
  });
116
172
  }
117
173
 
118
- const entries = listQueueEntriesBySubmitter(submittedBy, service, accountName);
174
+ const sharedVisibility = getSharedQueueVisibility();
175
+ const entries = sharedVisibility
176
+ ? listAllQueueEntries(service, accountName)
177
+ : listQueueEntriesBySubmitter(submittedBy, service, accountName);
119
178
 
120
179
  res.json({
121
180
  count: entries.length,
181
+ shared_visibility: sharedVisibility,
122
182
  entries: entries
123
183
  });
124
184
  } catch (error) {
@@ -183,4 +243,178 @@ router.get('/:service/:accountName/status/:id', (req, res) => {
183
243
  }
184
244
  });
185
245
 
246
+
247
+ // Withdraw a pending queue item (agent can only withdraw their own submissions)
248
+ // DELETE /api/queue/:service/:accountName/status/:id
249
+ router.delete('/:service/:accountName/status/:id', (req, res) => {
250
+ try {
251
+ // Check if withdraw is enabled
252
+ if (!getAgentWithdrawEnabled()) {
253
+ return res.status(403).json({
254
+ error: 'Disabled',
255
+ message: 'Agent withdraw is not enabled. Ask admin to enable agent_withdraw_enabled setting.'
256
+ });
257
+ }
258
+
259
+ const { id } = req.params;
260
+ const agentName = req.apiKeyInfo?.name || 'unknown'; // Set by auth middleware
261
+
262
+ const entry = getQueueEntry(id);
263
+
264
+ if (!entry) {
265
+ return res.status(404).json({
266
+ error: 'Not found',
267
+ message: 'Queue entry not found'
268
+ });
269
+ }
270
+
271
+ // Verify the requesting agent is the submitter
272
+ if (entry.submitted_by !== agentName) {
273
+ return res.status(403).json({
274
+ error: 'Forbidden',
275
+ message: 'You can only withdraw your own submissions'
276
+ });
277
+ }
278
+
279
+ // Only allow withdrawal of pending items
280
+ if (entry.status !== 'pending') {
281
+ return res.status(400).json({
282
+ error: 'Cannot withdraw',
283
+ message: `Cannot withdraw entry with status "${entry.status}". Only pending items can be withdrawn.`
284
+ });
285
+ }
286
+
287
+ // Get optional reason from request body
288
+ const { reason } = req.body || {};
289
+
290
+ // Update status to withdrawn (with optional reason)
291
+ updateQueueStatus(id, 'withdrawn', {
292
+ reviewed_at: new Date().toISOString().replace('T', ' ').replace('Z', ''),
293
+ rejection_reason: reason || null
294
+ });
295
+
296
+ // Emit real-time update
297
+ emitCountUpdate();
298
+
299
+ res.json({
300
+ success: true,
301
+ message: 'Queue entry withdrawn',
302
+ id: id,
303
+ reason: reason || null
304
+ });
305
+
306
+ } catch (error) {
307
+ res.status(500).json({
308
+ error: 'Failed to withdraw',
309
+ message: error.message
310
+ });
311
+ }
312
+ });
313
+
314
+ // Add a warning to a queue item (peer review)
315
+ // POST /api/queue/:service/:accountName/:id/warn
316
+ router.post('/:service/:accountName/:id/warn', async (req, res) => {
317
+ try {
318
+ const { id } = req.params;
319
+ const { message } = req.body;
320
+ const agentName = req.apiKeyInfo?.name;
321
+
322
+ if (!agentName) {
323
+ return res.status(401).json({
324
+ error: 'Unauthorized',
325
+ message: 'Agent authentication required'
326
+ });
327
+ }
328
+
329
+ if (!message || typeof message !== 'string' || message.trim().length === 0) {
330
+ return res.status(400).json({
331
+ error: 'Invalid request',
332
+ message: 'Warning message is required'
333
+ });
334
+ }
335
+
336
+ const entry = getQueueEntry(id);
337
+
338
+ if (!entry) {
339
+ return res.status(404).json({
340
+ error: 'Not found',
341
+ message: 'Queue entry not found'
342
+ });
343
+ }
344
+
345
+ // Only allow warnings on pending items
346
+ if (entry.status !== 'pending') {
347
+ return res.status(400).json({
348
+ error: 'Cannot warn',
349
+ message: `Cannot add warning to entry with status "${entry.status}". Only pending items can be warned.`
350
+ });
351
+ }
352
+
353
+ // Cannot warn your own items (should withdraw instead)
354
+ if (entry.submitted_by === agentName) {
355
+ return res.status(403).json({
356
+ error: 'Forbidden',
357
+ message: 'Cannot warn your own submission. Use withdraw instead.'
358
+ });
359
+ }
360
+
361
+ // Add the warning
362
+ const warningId = addQueueWarning(id, agentName, message.trim());
363
+
364
+ // Notify the submitting agent
365
+ if (entry.submitted_by) {
366
+ notifyAgentQueueWarning(entry, agentName, message.trim()).catch(err => {
367
+ console.error('Failed to notify agent of warning:', err.message);
368
+ });
369
+ }
370
+
371
+ // Emit socket event for real-time UI update
372
+ const warnings = getQueueWarnings(id);
373
+ emitEvent('queueItemUpdate', {
374
+ id,
375
+ type: 'warning_added',
376
+ warningCount: warnings.length,
377
+ warnings
378
+ });
379
+
380
+ res.json({
381
+ success: true,
382
+ message: 'Warning added',
383
+ warning_id: warningId,
384
+ queue_id: id
385
+ });
386
+
387
+ } catch (error) {
388
+ res.status(500).json({
389
+ error: 'Failed to add warning',
390
+ message: error.message
391
+ });
392
+ }
393
+ });
394
+
395
+ // Get warnings for a queue item
396
+ // GET /api/queue/:service/:accountName/:id/warnings
397
+ router.get('/:service/:accountName/:id/warnings', (req, res) => {
398
+ try {
399
+ const { id } = req.params;
400
+
401
+ const entry = getQueueEntry(id);
402
+ if (!entry) {
403
+ return res.status(404).json({
404
+ error: 'Not found',
405
+ message: 'Queue entry not found'
406
+ });
407
+ }
408
+
409
+ const warnings = getQueueWarnings(id);
410
+ res.json({ warnings });
411
+
412
+ } catch (error) {
413
+ res.status(500).json({
414
+ error: 'Failed to get warnings',
415
+ message: error.message
416
+ });
417
+ }
418
+ });
419
+
186
420
  export default router;
@@ -0,0 +1,87 @@
1
+ import { Router } from 'express';
2
+ import {
3
+ listServicesWithAccess,
4
+ checkServiceAccess,
5
+ checkBypassAuth
6
+ } from '../lib/db.js';
7
+
8
+ const router = Router();
9
+
10
+ // GET /api/services - List services with access info (filtered by agent access)
11
+ // Includes bypass_auth status for the calling agent
12
+ router.get('/', (req, res) => {
13
+ const agentName = req.apiKeyInfo?.name;
14
+ const allServices = listServicesWithAccess();
15
+
16
+ // Filter to only show services the agent has access to
17
+ // And include bypass_auth status for this agent
18
+ const accessibleServices = allServices
19
+ .filter(svc => {
20
+ const access = checkServiceAccess(svc.service, svc.account_name, agentName);
21
+ return access.allowed;
22
+ })
23
+ .map(svc => ({
24
+ ...svc,
25
+ bypass_auth: agentName ? checkBypassAuth(svc.service, svc.account_name, agentName) : false
26
+ }));
27
+
28
+ res.json({ services: accessibleServices });
29
+ });
30
+
31
+ // GET /api/services/:service/:account/access - Get YOUR access info for a service/account
32
+ // SECURITY: Only returns the calling agent's own access info, NOT other agents
33
+ router.get('/:service/:account/access', (req, res) => {
34
+ const { service, account } = req.params;
35
+ const agentName = req.apiKeyInfo?.name;
36
+
37
+ // Check if agent has access to this service
38
+ const accessCheck = checkServiceAccess(service, account, agentName);
39
+ if (!accessCheck.allowed) {
40
+ return res.status(403).json({
41
+ error: `You do not have access to ${service}/${account}`,
42
+ reason: accessCheck.reason
43
+ });
44
+ }
45
+
46
+ // SECURITY FIX: Only return the calling agent's own access info
47
+ // Do NOT return the full agent list (that would leak other agents' info)
48
+ const agentBypass = agentName ? checkBypassAuth(service, account, agentName) : false;
49
+
50
+ res.json({
51
+ service,
52
+ account_name: account,
53
+ your_access: {
54
+ allowed: true,
55
+ bypass_auth: agentBypass
56
+ }
57
+ });
58
+ });
59
+
60
+ // NOTE: Configuration endpoints (PUT access mode, POST agents, PUT bypass)
61
+ // have been REMOVED from the API for security.
62
+ // All access configuration must be done through the Admin UI at /ui/access
63
+ // which requires admin authentication.
64
+
65
+ // GET /api/services/:service/:account/access/agents/:agentName/bypass - Check bypass_auth (read-only)
66
+ // Agents can check their own bypass status
67
+ router.get('/:service/:account/access/agents/:agentName/bypass', (req, res) => {
68
+ const { service, account, agentName } = req.params;
69
+ const callingAgent = req.apiKeyInfo?.name;
70
+
71
+ // Agents can only check their own bypass status
72
+ if (callingAgent && callingAgent.toLowerCase() !== agentName.toLowerCase()) {
73
+ return res.status(403).json({
74
+ error: 'You can only check your own bypass status'
75
+ });
76
+ }
77
+
78
+ const hasBypass = checkBypassAuth(service, account, agentName);
79
+ res.json({
80
+ service,
81
+ account,
82
+ agent: agentName,
83
+ bypass_auth: hasBypass
84
+ });
85
+ });
86
+
87
+ export default router;
@@ -0,0 +1,290 @@
1
+ // Service Access Control routes
2
+ import { Router } from 'express';
3
+ import {
4
+ listServicesWithAccess,
5
+ listApiKeys,
6
+ getServiceAccess,
7
+ setServiceAccessMode,
8
+ setServiceAgentAccess,
9
+ setBypassAuth,
10
+ checkBypassAuth
11
+ } from '../../lib/db.js';
12
+ import { escapeHtml, simpleNavHeader, socketScript, localizeScript, renderAvatar } from './shared.js';
13
+
14
+ const router = Router();
15
+
16
+ // Access Control page
17
+ router.get('/', (req, res) => {
18
+ const services = listServicesWithAccess();
19
+ const agents = listApiKeys();
20
+ res.send(renderAccessPage(services, agents));
21
+ });
22
+
23
+ // Update access mode for a service
24
+ router.post('/:service/:account/mode', (req, res) => {
25
+ const { service, account } = req.params;
26
+ const { mode } = req.body;
27
+ const wantsJson = req.headers.accept?.includes('application/json');
28
+
29
+ try {
30
+ setServiceAccessMode(service, account, mode);
31
+ if (wantsJson) {
32
+ return res.json({ success: true, mode });
33
+ }
34
+ res.redirect('/ui/access');
35
+ } catch (err) {
36
+ if (wantsJson) {
37
+ return res.status(400).json({ error: err.message });
38
+ }
39
+ res.status(400).send(err.message);
40
+ }
41
+ });
42
+
43
+ // Toggle agent access for a service
44
+ router.post('/:service/:account/agent/:agentName', (req, res) => {
45
+ const { service, account, agentName } = req.params;
46
+ const { allowed, bypass_auth } = req.body;
47
+ const wantsJson = req.headers.accept?.includes('application/json');
48
+
49
+ // Update access
50
+ setServiceAgentAccess(service, account, agentName, allowed !== 'false', bypass_auth === 'true');
51
+
52
+ if (wantsJson) {
53
+ return res.json({ success: true });
54
+ }
55
+ res.redirect('/ui/access');
56
+ });
57
+
58
+ // Toggle bypass_auth for an agent
59
+ router.post('/:service/:account/agent/:agentName/bypass', (req, res) => {
60
+ const { service, account, agentName } = req.params;
61
+ const { enabled } = req.body;
62
+ const wantsJson = req.headers.accept?.includes('application/json');
63
+
64
+ setBypassAuth(service, account, agentName, enabled === 'true' || enabled === true);
65
+
66
+ if (wantsJson) {
67
+ const hasBypass = checkBypassAuth(service, account, agentName);
68
+ return res.json({ success: true, bypass_auth: hasBypass });
69
+ }
70
+ res.redirect('/ui/access');
71
+ });
72
+
73
+ function renderAccessPage(services, agents) {
74
+ const renderServiceCard = (svc) => {
75
+ const access = getServiceAccess(svc.service, svc.account_name);
76
+ const agentRows = agents.map(agent => {
77
+ const agentAccess = access.agents.find(a => a.name.toLowerCase() === agent.name.toLowerCase());
78
+ const isAllowed = agentAccess ? agentAccess.allowed : (access.access_mode === 'all');
79
+ const hasBypass = agentAccess?.bypass_auth || false;
80
+
81
+ return `
82
+ <tr class="agent-row" data-service="${escapeHtml(svc.service)}" data-account="${escapeHtml(svc.account_name)}" data-agent="${escapeHtml(agent.name)}">
83
+ <td>
84
+ <div class="agent-with-avatar">
85
+ ${renderAvatar(agent.name, { size: 28 })}
86
+ <span>${escapeHtml(agent.name)}</span>
87
+ </div>
88
+ </td>
89
+ <td>
90
+ <label class="toggle">
91
+ <input type="checkbox" class="access-toggle" ${isAllowed ? 'checked' : ''}>
92
+ <span class="toggle-slider"></span>
93
+ </label>
94
+ </td>
95
+ <td>
96
+ <label class="toggle ${!isAllowed ? 'disabled' : ''}">
97
+ <input type="checkbox" class="bypass-toggle" ${hasBypass ? 'checked' : ''} ${!isAllowed ? 'disabled' : ''}>
98
+ <span class="toggle-slider bypass"></span>
99
+ </label>
100
+ ${hasBypass ? '<span class="bypass-badge">⚡ Bypass</span>' : ''}
101
+ </td>
102
+ </tr>
103
+ `;
104
+ }).join('');
105
+
106
+ return `
107
+ <div class="card service-card" data-service="${escapeHtml(svc.service)}" data-account="${escapeHtml(svc.account_name)}">
108
+ <div class="service-header">
109
+ <h3>${escapeHtml(svc.service)} / ${escapeHtml(svc.account_name)}</h3>
110
+ <select class="mode-select" data-service="${escapeHtml(svc.service)}" data-account="${escapeHtml(svc.account_name)}">
111
+ <option value="all" ${access.access_mode === 'all' ? 'selected' : ''}>All agents</option>
112
+ <option value="allowlist" ${access.access_mode === 'allowlist' ? 'selected' : ''}>Allowlist only</option>
113
+ <option value="none" ${access.access_mode === 'none' ? 'selected' : ''}>No agents</option>
114
+ </select>
115
+ </div>
116
+
117
+ <table class="access-table">
118
+ <thead>
119
+ <tr>
120
+ <th>Agent</th>
121
+ <th>Access</th>
122
+ <th>Bypass Queue</th>
123
+ </tr>
124
+ </thead>
125
+ <tbody>
126
+ ${agentRows}
127
+ </tbody>
128
+ </table>
129
+ </div>
130
+ `;
131
+ };
132
+
133
+ return `<!DOCTYPE html>
134
+ <html>
135
+ <head>
136
+ <title>agentgate - Access Control</title>
137
+ <link rel="icon" type="image/svg+xml" href="/public/favicon.svg">
138
+ <link rel="stylesheet" href="/public/style.css">
139
+ <script src="/socket.io/socket.io.js"></script>
140
+ <style>
141
+ .service-card { margin-bottom: 24px; }
142
+ .service-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
143
+ .service-header h3 { margin: 0; }
144
+ .mode-select { padding: 8px 12px; border-radius: 6px; background: #1f2937; border: 1px solid #374151; color: #f3f4f6; cursor: pointer; }
145
+ .mode-select:focus { border-color: #6366f1; outline: none; }
146
+
147
+ .access-table { width: 100%; border-collapse: collapse; }
148
+ .access-table th, .access-table td { padding: 12px; text-align: left; border-bottom: 1px solid #374151; }
149
+ .access-table th { font-weight: 600; color: #9ca3af; font-size: 14px; }
150
+
151
+ .agent-with-avatar { display: flex; align-items: center; gap: 10px; }
152
+
153
+ /* Toggle switch */
154
+ .toggle { position: relative; display: inline-block; width: 44px; height: 24px; }
155
+ .toggle.disabled { opacity: 0.5; pointer-events: none; }
156
+ .toggle input { opacity: 0; width: 0; height: 0; }
157
+ .toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #374151; transition: 0.3s; border-radius: 24px; }
158
+ .toggle-slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: 0.3s; border-radius: 50%; }
159
+ .toggle input:checked + .toggle-slider { background-color: #10b981; }
160
+ .toggle input:checked + .toggle-slider.bypass { background-color: #f59e0b; }
161
+ .toggle input:checked + .toggle-slider:before { transform: translateX(20px); }
162
+
163
+ .bypass-badge { font-size: 11px; background: rgba(245, 158, 11, 0.2); color: #fbbf24; padding: 2px 8px; border-radius: 10px; margin-left: 8px; }
164
+
165
+ .no-services { text-align: center; padding: 40px; color: #9ca3af; }
166
+
167
+ .info-box { background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.3); border-radius: 8px; padding: 16px; margin-bottom: 24px; }
168
+ .info-box h4 { margin: 0 0 8px 0; color: #60a5fa; }
169
+ .info-box p { margin: 0; color: #9ca3af; font-size: 14px; }
170
+ .info-box ul { margin: 8px 0 0 0; padding-left: 20px; color: #9ca3af; font-size: 14px; }
171
+ </style>
172
+ </head>
173
+ <body>
174
+ <div>
175
+ ${simpleNavHeader()}
176
+ </div>
177
+
178
+ <div class="info-box">
179
+ <h4>🔐 Service Access Control</h4>
180
+ <p>Manage which agents can access which services, and enable queue bypass for trusted agents.</p>
181
+ <ul>
182
+ <li><strong>Access</strong> - Whether the agent can use this service</li>
183
+ <li><strong>Bypass Queue</strong> - Skip approval queue and execute immediately (⚡ use with caution!)</li>
184
+ </ul>
185
+ </div>
186
+
187
+ ${services.length === 0 ? `
188
+ <div class="card no-services">
189
+ <p>No services configured yet.</p>
190
+ <p>Connect a service (GitHub, Bluesky, etc.) from the <a href="/ui">home page</a> to manage access.</p>
191
+ </div>
192
+ ` : services.map(renderServiceCard).join('')}
193
+
194
+ <script>
195
+ // Mode select change
196
+ document.querySelectorAll('.mode-select').forEach(select => {
197
+ select.addEventListener('change', async function() {
198
+ const service = this.dataset.service;
199
+ const account = this.dataset.account;
200
+ const mode = this.value;
201
+ const originalValue = this.dataset.originalMode || this.value;
202
+
203
+ try {
204
+ const res = await fetch('/ui/access/' + encodeURIComponent(service) + '/' + encodeURIComponent(account) + '/mode', {
205
+ method: 'POST',
206
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
207
+ body: JSON.stringify({ mode: mode })
208
+ });
209
+ if (!res.ok) throw new Error('Failed to update mode');
210
+ this.dataset.originalMode = mode;
211
+ } catch (err) {
212
+ console.error('Failed to update mode:', err);
213
+ this.value = originalValue; // Revert on error
214
+ }
215
+ });
216
+ });
217
+
218
+ // Access toggle
219
+ document.querySelectorAll('.access-toggle').forEach(toggle => {
220
+ toggle.addEventListener('change', async function() {
221
+ const row = this.closest('.agent-row');
222
+ const service = row.dataset.service;
223
+ const account = row.dataset.account;
224
+ const agent = row.dataset.agent;
225
+ const allowed = this.checked;
226
+
227
+ // Also get bypass status
228
+ const bypassToggle = row.querySelector('.bypass-toggle');
229
+ const bypass = bypassToggle ? bypassToggle.checked : false;
230
+
231
+ // Enable/disable bypass toggle based on access
232
+ if (bypassToggle) {
233
+ bypassToggle.disabled = !allowed;
234
+ bypassToggle.closest('.toggle').classList.toggle('disabled', !allowed);
235
+ }
236
+
237
+ try {
238
+ await fetch('/ui/access/' + encodeURIComponent(service) + '/' + encodeURIComponent(account) + '/agent/' + encodeURIComponent(agent), {
239
+ method: 'POST',
240
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
241
+ body: JSON.stringify({ allowed: allowed.toString(), bypass_auth: bypass.toString() })
242
+ });
243
+ } catch (err) {
244
+ console.error('Failed to update access:', err);
245
+ this.checked = !allowed; // Revert on error
246
+ }
247
+ });
248
+ });
249
+
250
+ // Bypass toggle
251
+ document.querySelectorAll('.bypass-toggle').forEach(toggle => {
252
+ toggle.addEventListener('change', async function() {
253
+ const row = this.closest('.agent-row');
254
+ const service = row.dataset.service;
255
+ const account = row.dataset.account;
256
+ const agent = row.dataset.agent;
257
+ const enabled = this.checked;
258
+
259
+ // Update badge
260
+ const badge = row.querySelector('.bypass-badge');
261
+ if (enabled && !badge) {
262
+ const td = this.closest('td');
263
+ const span = document.createElement('span');
264
+ span.className = 'bypass-badge';
265
+ span.textContent = '⚡ Bypass';
266
+ td.appendChild(span);
267
+ } else if (!enabled && badge) {
268
+ badge.remove();
269
+ }
270
+
271
+ try {
272
+ await fetch('/ui/access/' + encodeURIComponent(service) + '/' + encodeURIComponent(account) + '/agent/' + encodeURIComponent(agent) + '/bypass', {
273
+ method: 'POST',
274
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
275
+ body: JSON.stringify({ enabled: enabled })
276
+ });
277
+ } catch (err) {
278
+ console.error('Failed to update bypass:', err);
279
+ this.checked = !enabled; // Revert on error
280
+ }
281
+ });
282
+ });
283
+ </script>
284
+ ${socketScript()}
285
+ ${localizeScript()}
286
+ </body>
287
+ </html>`;
288
+ }
289
+
290
+ export default router;