@agenticmail/enterprise 0.5.269 → 0.5.270

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.
@@ -26,6 +26,7 @@ import { SkillConnectionsPage } from './pages/skill-connections.js';
26
26
  import { VaultPage } from './pages/vault.js';
27
27
  import { OrgChartPage } from './pages/org-chart.js';
28
28
  import { TaskPipelinePage } from './pages/task-pipeline.js';
29
+ import { DatabaseAccessPage } from './pages/database-access.js';
29
30
 
30
31
  // ─── Toast System ────────────────────────────────────────
31
32
  let toastId = 0;
@@ -156,6 +157,7 @@ function App() {
156
157
  { id: 'skills', icon: I.skills, label: 'Skills' },
157
158
  { id: 'community-skills', icon: I.marketplace, label: 'Community Skills' },
158
159
  { id: 'skill-connections', icon: I.link, label: 'Integrations & MCP' },
160
+ { id: 'database-access', icon: I.database, label: 'Database Access' },
159
161
  { id: 'knowledge', icon: I.knowledge, label: 'Knowledge Bases' },
160
162
  { id: 'knowledge-contributions', icon: I.knowledge, label: 'Knowledge Hub' },
161
163
  { id: 'approvals', icon: I.approvals, label: 'Approvals', badge: pendingCount || null },
@@ -202,6 +204,7 @@ function App() {
202
204
  vault: VaultPage,
203
205
  'org-chart': OrgChartPage,
204
206
  'task-pipeline': TaskPipelinePage,
207
+ 'database-access': DatabaseAccessPage,
205
208
  };
206
209
 
207
210
  const navigateToAgent = (agentId) => { _setSelectedAgentId(agentId); history.pushState(null, '', '/dashboard/agents/' + agentId); };
@@ -0,0 +1,614 @@
1
+ import { h, useState, useEffect, useCallback, Fragment, useApp, engineCall, getOrgId } from '../components/utils.js';
2
+ import { I } from '../components/icons.js';
3
+ import { Modal } from '../components/modal.js';
4
+ import { HelpButton } from '../components/help-button.js';
5
+
6
+ var DATABASE_TYPES = [
7
+ { section: 'Relational (SQL)', items: [
8
+ { value: 'postgresql', label: 'PostgreSQL', icon: '🐘' },
9
+ { value: 'mysql', label: 'MySQL', icon: '🐬' },
10
+ { value: 'mariadb', label: 'MariaDB', icon: '🦭' },
11
+ { value: 'mssql', label: 'Microsoft SQL Server', icon: '🪟' },
12
+ { value: 'oracle', label: 'Oracle', icon: '🔴' },
13
+ { value: 'sqlite', label: 'SQLite', icon: '📦' },
14
+ ]},
15
+ { section: 'Cloud-Native SQL', items: [
16
+ { value: 'supabase', label: 'Supabase', icon: '⚡' },
17
+ { value: 'neon', label: 'Neon', icon: '🌀' },
18
+ { value: 'planetscale', label: 'PlanetScale', icon: '🪐' },
19
+ { value: 'cockroachdb', label: 'CockroachDB', icon: '🪳' },
20
+ { value: 'turso', label: 'Turso / LibSQL', icon: '🐢' },
21
+ ]},
22
+ { section: 'NoSQL / Key-Value', items: [
23
+ { value: 'mongodb', label: 'MongoDB', icon: '🍃' },
24
+ { value: 'redis', label: 'Redis', icon: '🔴' },
25
+ { value: 'dynamodb', label: 'AWS DynamoDB', icon: '☁️' },
26
+ ]},
27
+ ];
28
+
29
+ var ALL_DB_TYPES = DATABASE_TYPES.flatMap(function(s) { return s.items; });
30
+
31
+ var PERMISSIONS = [
32
+ { value: 'read', label: 'Read', desc: 'SELECT queries', color: 'var(--success)' },
33
+ { value: 'write', label: 'Write', desc: 'INSERT / UPDATE', color: 'var(--warning)' },
34
+ { value: 'delete', label: 'Delete', desc: 'DELETE rows', color: 'var(--danger)' },
35
+ { value: 'schema', label: 'Schema', desc: 'DDL operations', color: 'var(--accent)' },
36
+ { value: 'execute', label: 'Execute', desc: 'Stored procedures', color: 'var(--text-muted)' },
37
+ ];
38
+
39
+ // ─── Styles ──────────────────────────────────────────────────────────────────
40
+
41
+ var s = {
42
+ page: 'padding: 24px; max-width: 1200px; margin: 0 auto;',
43
+ header: 'display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; flex-wrap: wrap; gap: 12px;',
44
+ title: 'font-size: 24px; font-weight: 700; display: flex; align-items: center; gap: 10px;',
45
+ tabs: 'display: flex; gap: 4px; margin-bottom: 24px; border-bottom: 1px solid var(--border);',
46
+ tab: 'padding: 8px 16px; cursor: pointer; font-size: 13px; font-weight: 500; color: var(--text-secondary); border-bottom: 2px solid transparent; transition: all 0.15s;',
47
+ tabActive: 'padding: 8px 16px; cursor: pointer; font-size: 13px; font-weight: 600; color: var(--accent); border-bottom: 2px solid var(--accent);',
48
+ grid: 'display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px;',
49
+ card: 'background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; padding: 20px; transition: border-color 0.15s;',
50
+ cardHeader: 'display: flex; align-items: center; gap: 10px; margin-bottom: 12px;',
51
+ cardIcon: 'font-size: 24px;',
52
+ cardTitle: 'font-weight: 600; font-size: 15px;',
53
+ cardType: 'font-size: 12px; color: var(--text-muted);',
54
+ badge: 'display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600;',
55
+ badgeActive: 'background: rgba(21,128,61,0.15); color: var(--success);',
56
+ badgeInactive: 'background: rgba(107,115,148,0.15); color: var(--text-muted);',
57
+ badgeError: 'background: rgba(239,68,68,0.15); color: var(--danger);',
58
+ meta: 'font-size: 12px; color: var(--text-muted); margin-top: 8px;',
59
+ actions: 'display: flex; gap: 8px; margin-top: 12px;',
60
+ btn: 'padding: 6px 14px; border-radius: 6px; font-size: 12px; font-weight: 500; cursor: pointer; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); transition: all 0.15s;',
61
+ btnPrimary: 'padding: 6px 14px; border-radius: 6px; font-size: 12px; font-weight: 500; cursor: pointer; border: none; background: var(--accent); color: #fff;',
62
+ btnDanger: 'padding: 6px 14px; border-radius: 6px; font-size: 12px; font-weight: 500; cursor: pointer; border: none; background: var(--danger); color: #fff;',
63
+ emptyState: 'text-align: center; padding: 60px 20px; color: var(--text-muted);',
64
+ emptyIcon: 'font-size: 48px; margin-bottom: 12px; opacity: 0.5;',
65
+ form: 'display: flex; flex-direction: column; gap: 16px;',
66
+ label: 'font-size: 13px; font-weight: 500; margin-bottom: 4px; color: var(--text-secondary);',
67
+ input: 'padding: 8px 12px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-primary); color: var(--text-primary); font-size: 13px; width: 100%; box-sizing: border-box;',
68
+ select: 'padding: 8px 12px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-primary); color: var(--text-primary); font-size: 13px; width: 100%;',
69
+ row: 'display: flex; gap: 12px;',
70
+ col: 'flex: 1;',
71
+ permGrid: 'display: flex; flex-wrap: wrap; gap: 8px;',
72
+ permChip: 'padding: 4px 10px; border-radius: 16px; font-size: 12px; cursor: pointer; border: 1px solid var(--border); transition: all 0.15s;',
73
+ permChipActive: 'padding: 4px 10px; border-radius: 16px; font-size: 12px; cursor: pointer; border: 1px solid var(--accent); background: var(--accent-soft); color: var(--accent); font-weight: 600;',
74
+ section: 'margin-bottom: 16px;',
75
+ sectionTitle: 'font-size: 14px; font-weight: 600; margin-bottom: 8px;',
76
+ agentRow: 'display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; background: var(--bg-secondary); border-radius: 8px; margin-bottom: 8px; gap: 12px;',
77
+ agentName: 'font-weight: 500; font-size: 13px;',
78
+ agentPerms: 'display: flex; gap: 4px; flex-wrap: wrap;',
79
+ miniChip: 'padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 600;',
80
+ auditTable: 'width: 100%; border-collapse: collapse; font-size: 12px;',
81
+ auditTh: 'text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); color: var(--text-muted); font-weight: 600;',
82
+ auditTd: 'padding: 8px 10px; border-bottom: 1px solid var(--border); color: var(--text-secondary);',
83
+ dbPicker: 'display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px;',
84
+ dbPickerItem: 'padding: 12px; border-radius: 8px; border: 1px solid var(--border); cursor: pointer; text-align: center; transition: all 0.15s; background: var(--bg-secondary);',
85
+ dbPickerItemActive: 'padding: 12px; border-radius: 8px; border: 2px solid var(--accent); cursor: pointer; text-align: center; background: var(--accent-soft);',
86
+ };
87
+
88
+ // ─── Main Page ───────────────────────────────────────────────────────────────
89
+
90
+ export function DatabaseAccessPage() {
91
+ var app = useApp();
92
+ var [tab, setTab] = useState('connections');
93
+ var [connections, setConnections] = useState([]);
94
+ var [agents, setAgents] = useState([]);
95
+ var [auditLog, setAuditLog] = useState([]);
96
+ var [showAdd, setShowAdd] = useState(false);
97
+ var [showGrant, setShowGrant] = useState(null); // connectionId
98
+ var [editConn, setEditConn] = useState(null);
99
+ var [loading, setLoading] = useState(true);
100
+
101
+ var loadData = useCallback(async function() {
102
+ setLoading(true);
103
+ try {
104
+ var [conns, agts] = await Promise.all([
105
+ engineCall('/database/connections'),
106
+ engineCall('/agents').catch(function() { return []; }),
107
+ ]);
108
+ setConnections(conns || []);
109
+ setAgents(agts || []);
110
+ } catch (e) { console.error('Load failed:', e); }
111
+ setLoading(false);
112
+ }, []);
113
+
114
+ var loadAudit = useCallback(async function() {
115
+ try {
116
+ var logs = await engineCall('/database/audit?limit=50');
117
+ setAuditLog(logs || []);
118
+ } catch { setAuditLog([]); }
119
+ }, []);
120
+
121
+ useEffect(function() { loadData(); }, []);
122
+ useEffect(function() { if (tab === 'audit') loadAudit(); }, [tab]);
123
+
124
+ var deleteConn = useCallback(async function(id) {
125
+ if (!confirm('Delete this database connection? All agent access grants will be removed.')) return;
126
+ await engineCall('/database/connections/' + id, 'DELETE');
127
+ loadData();
128
+ }, []);
129
+
130
+ var testConn = useCallback(async function(id) {
131
+ try {
132
+ var result = await engineCall('/database/connections/' + id + '/test', 'POST');
133
+ alert(result.success ? 'Connection successful! (' + result.latencyMs + 'ms)' : 'Connection failed: ' + (result.error || 'Unknown error'));
134
+ loadData();
135
+ } catch (e) { alert('Test failed: ' + e.message); }
136
+ }, []);
137
+
138
+ return h('div', { style: s.page },
139
+ h('div', { style: s.header },
140
+ h('div', { style: s.title },
141
+ I.database(20),
142
+ 'Database Access',
143
+ HelpButton({
144
+ title: 'Database Access',
145
+ content: h(Fragment, null,
146
+ h('p', null, 'Connect your agents to external databases. Each agent can be granted granular permissions (read, write, delete) on specific database connections.'),
147
+ h('p', null, 'Credentials are encrypted in the vault. All queries are sanitized, rate-limited, and logged for audit.'),
148
+ ),
149
+ }),
150
+ ),
151
+ h('button', { style: s.btnPrimary, onClick: function() { setShowAdd(true); } }, '+ Add Connection'),
152
+ ),
153
+
154
+ // Tabs
155
+ h('div', { style: s.tabs },
156
+ h('div', { style: tab === 'connections' ? s.tabActive : s.tab, onClick: function() { setTab('connections'); } }, 'Connections'),
157
+ h('div', { style: tab === 'agents' ? s.tabActive : s.tab, onClick: function() { setTab('agents'); } }, 'Agent Access'),
158
+ h('div', { style: tab === 'audit' ? s.tabActive : s.tab, onClick: function() { setTab('audit'); } }, 'Audit Log'),
159
+ ),
160
+
161
+ // Content
162
+ tab === 'connections' && ConnectionsTab({ connections: connections, agents: agents, onDelete: deleteConn, onTest: testConn, onEdit: setEditConn, onGrant: setShowGrant, onRefresh: loadData }),
163
+ tab === 'agents' && AgentAccessTab({ connections: connections, agents: agents, onRefresh: loadData }),
164
+ tab === 'audit' && AuditTab({ auditLog: auditLog, onRefresh: loadAudit }),
165
+
166
+ // Modals
167
+ showAdd && AddConnectionModal({ onClose: function() { setShowAdd(false); }, onSave: loadData }),
168
+ showGrant && GrantAccessModal({ connectionId: showGrant, agents: agents, connections: connections, onClose: function() { setShowGrant(null); }, onSave: loadData }),
169
+ editConn && EditConnectionModal({ connection: editConn, onClose: function() { setEditConn(null); }, onSave: loadData }),
170
+ );
171
+ }
172
+
173
+ // ─── Connections Tab ─────────────────────────────────────────────────────────
174
+
175
+ function ConnectionsTab(props) {
176
+ var connections = props.connections;
177
+ if (connections.length === 0) {
178
+ return h('div', { style: s.emptyState },
179
+ h('div', { style: s.emptyIcon }, I.database(48)),
180
+ h('div', { style: 'font-size: 16px; font-weight: 600; margin-bottom: 8px;' }, 'No Database Connections'),
181
+ h('div', null, 'Add a connection to let your agents query external databases.'),
182
+ );
183
+ }
184
+
185
+ return h('div', { style: s.grid },
186
+ connections.map(function(conn) {
187
+ var dbType = ALL_DB_TYPES.find(function(t) { return t.value === conn.type; });
188
+ var agentCount = 0;
189
+ // Count agents with access
190
+ var statusStyle = conn.status === 'active' ? s.badgeActive : conn.status === 'error' ? s.badgeError : s.badgeInactive;
191
+ return h('div', { key: conn.id, style: s.card },
192
+ h('div', { style: s.cardHeader },
193
+ h('span', { style: s.cardIcon }, dbType ? dbType.icon : '🗄️'),
194
+ h('div', null,
195
+ h('div', { style: s.cardTitle }, conn.name),
196
+ h('div', { style: s.cardType }, dbType ? dbType.label : conn.type),
197
+ ),
198
+ h('span', { style: s.badge + ';' + statusStyle }, conn.status),
199
+ ),
200
+ conn.host && h('div', { style: s.meta }, conn.host + (conn.port ? ':' + conn.port : '') + (conn.database ? ' / ' + conn.database : '')),
201
+ conn.description && h('div', { style: s.meta }, conn.description),
202
+ conn.lastError && h('div', { style: 'font-size: 11px; color: var(--danger); margin-top: 4px;' }, conn.lastError),
203
+ h('div', { style: s.actions },
204
+ h('button', { style: s.btn, onClick: function() { props.onTest(conn.id); } }, 'Test'),
205
+ h('button', { style: s.btnPrimary, onClick: function() { props.onGrant(conn.id); } }, 'Grant Access'),
206
+ h('button', { style: s.btn, onClick: function() { props.onEdit(conn); } }, 'Edit'),
207
+ h('button', { style: s.btnDanger, onClick: function() { props.onDelete(conn.id); } }, 'Delete'),
208
+ ),
209
+ );
210
+ })
211
+ );
212
+ }
213
+
214
+ // ─── Agent Access Tab ────────────────────────────────────────────────────────
215
+
216
+ function AgentAccessTab(props) {
217
+ var [accessMap, setAccessMap] = useState({});
218
+ var [loading, setLoading] = useState(true);
219
+
220
+ useEffect(function() {
221
+ async function load() {
222
+ var map = {};
223
+ for (var agent of props.agents) {
224
+ try {
225
+ var list = await engineCall('/database/agents/' + agent.id + '/connections');
226
+ if (list && list.length > 0) map[agent.id] = list;
227
+ } catch { /* skip */ }
228
+ }
229
+ setAccessMap(map);
230
+ setLoading(false);
231
+ }
232
+ load();
233
+ }, [props.agents]);
234
+
235
+ if (loading) return h('div', { style: 'padding: 40px; text-align: center; color: var(--text-muted);' }, 'Loading agent access...');
236
+
237
+ var agentsWithAccess = props.agents.filter(function(a) { return accessMap[a.id] && accessMap[a.id].length > 0; });
238
+
239
+ if (agentsWithAccess.length === 0) {
240
+ return h('div', { style: s.emptyState },
241
+ h('div', { style: s.emptyIcon }, '🔒'),
242
+ h('div', { style: 'font-size: 16px; font-weight: 600; margin-bottom: 8px;' }, 'No Agents Have Database Access'),
243
+ h('div', null, 'Grant access from the Connections tab to allow agents to query databases.'),
244
+ );
245
+ }
246
+
247
+ return h('div', null,
248
+ agentsWithAccess.map(function(agent) {
249
+ var grants = accessMap[agent.id] || [];
250
+ return h('div', { key: agent.id, style: s.card + '; margin-bottom: 16px;' },
251
+ h('div', { style: s.cardHeader },
252
+ h('div', { style: 'width: 32px; height: 32px; border-radius: 50%; background: var(--accent-soft); display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 700; color: var(--accent);' }, (agent.displayName || agent.name || '?')[0].toUpperCase()),
253
+ h('div', null,
254
+ h('div', { style: s.cardTitle }, agent.displayName || agent.name),
255
+ h('div', { style: s.cardType }, grants.length + ' database' + (grants.length !== 1 ? 's' : '')),
256
+ ),
257
+ ),
258
+ grants.map(function(grant) {
259
+ var conn = grant.connection || {};
260
+ var dbType = ALL_DB_TYPES.find(function(t) { return t.value === conn.type; });
261
+ return h('div', { key: grant.connectionId, style: s.agentRow },
262
+ h('div', { style: 'display: flex; align-items: center; gap: 8px;' },
263
+ h('span', null, dbType ? dbType.icon : '🗄️'),
264
+ h('span', { style: s.agentName }, conn.name || grant.connectionId),
265
+ ),
266
+ h('div', { style: s.agentPerms },
267
+ (grant.permissions || []).map(function(p) {
268
+ var permDef = PERMISSIONS.find(function(x) { return x.value === p; });
269
+ return h('span', { key: p, style: s.miniChip + '; background: ' + (permDef ? permDef.color : 'var(--text-muted)') + '22; color: ' + (permDef ? permDef.color : 'var(--text-muted)') }, p);
270
+ })
271
+ ),
272
+ h('button', { style: s.btnDanger + '; padding: 3px 8px; font-size: 11px;', onClick: async function() {
273
+ if (!confirm('Revoke ' + (agent.displayName || agent.name) + ' access to ' + (conn.name || 'this database') + '?')) return;
274
+ await engineCall('/database/connections/' + grant.connectionId + '/agents/' + agent.id, 'DELETE');
275
+ props.onRefresh();
276
+ }}, 'Revoke'),
277
+ );
278
+ }),
279
+ );
280
+ })
281
+ );
282
+ }
283
+
284
+ // ─── Audit Tab ───────────────────────────────────────────────────────────────
285
+
286
+ function AuditTab(props) {
287
+ if (props.auditLog.length === 0) {
288
+ return h('div', { style: s.emptyState },
289
+ h('div', { style: s.emptyIcon }, '📋'),
290
+ h('div', { style: 'font-size: 16px; font-weight: 600; margin-bottom: 8px;' }, 'No Query Activity Yet'),
291
+ h('div', null, 'Queries executed by agents will appear here with full audit details.'),
292
+ );
293
+ }
294
+
295
+ return h('div', { style: 'overflow-x: auto;' },
296
+ h('table', { style: s.auditTable },
297
+ h('thead', null, h('tr', null,
298
+ h('th', { style: s.auditTh }, 'Time'),
299
+ h('th', { style: s.auditTh }, 'Agent'),
300
+ h('th', { style: s.auditTh }, 'Database'),
301
+ h('th', { style: s.auditTh }, 'Op'),
302
+ h('th', { style: s.auditTh }, 'Query'),
303
+ h('th', { style: s.auditTh }, 'Rows'),
304
+ h('th', { style: s.auditTh }, 'Time'),
305
+ h('th', { style: s.auditTh }, 'Status'),
306
+ )),
307
+ h('tbody', null,
308
+ props.auditLog.map(function(entry) {
309
+ var opColor = entry.operation === 'read' ? 'var(--success)' : entry.operation === 'write' ? 'var(--warning)' : entry.operation === 'delete' ? 'var(--danger)' : 'var(--text-muted)';
310
+ return h('tr', { key: entry.id },
311
+ h('td', { style: s.auditTd }, new Date(entry.timestamp).toLocaleString()),
312
+ h('td', { style: s.auditTd }, entry.agent_name || entry.agent_id?.slice(0, 8)),
313
+ h('td', { style: s.auditTd }, entry.connection_name || entry.connection_id?.slice(0, 8)),
314
+ h('td', { style: s.auditTd + '; font-weight: 600; color: ' + opColor }, entry.operation),
315
+ h('td', { style: s.auditTd + '; font-family: monospace; font-size: 11px; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;' }, entry.query),
316
+ h('td', { style: s.auditTd }, entry.rows_affected),
317
+ h('td', { style: s.auditTd }, entry.execution_time_ms + 'ms'),
318
+ h('td', { style: s.auditTd }, entry.success
319
+ ? h('span', { style: s.badge + ';' + s.badgeActive }, 'OK')
320
+ : h('span', { style: s.badge + ';' + s.badgeError, title: entry.error }, 'FAIL')
321
+ ),
322
+ );
323
+ })
324
+ ),
325
+ ),
326
+ );
327
+ }
328
+
329
+ // ─── Add Connection Modal ────────────────────────────────────────────────────
330
+
331
+ function AddConnectionModal(props) {
332
+ var [step, setStep] = useState(1);
333
+ var [dbType, setDbType] = useState('');
334
+ var [form, setForm] = useState({ name: '', host: '', port: '', database: '', username: '', password: '', connectionString: '', ssl: false, description: '' });
335
+ var [saving, setSaving] = useState(false);
336
+
337
+ var set = function(key, val) { setForm(function(f) { var n = Object.assign({}, f); n[key] = val; return n; }); };
338
+
339
+ var isConnString = form.connectionString.length > 0;
340
+
341
+ var save = async function() {
342
+ setSaving(true);
343
+ try {
344
+ var body = { type: dbType, name: form.name || (ALL_DB_TYPES.find(function(t) { return t.value === dbType; })?.label + ' Connection'), description: form.description, status: 'inactive' };
345
+ if (isConnString) {
346
+ body.connectionString = form.connectionString;
347
+ } else {
348
+ body.host = form.host;
349
+ body.port = form.port ? parseInt(form.port) : undefined;
350
+ body.database = form.database;
351
+ body.username = form.username;
352
+ body.password = form.password;
353
+ body.ssl = form.ssl;
354
+ }
355
+ await engineCall('/database/connections', 'POST', body);
356
+ props.onSave();
357
+ props.onClose();
358
+ } catch (e) { alert('Failed: ' + e.message); }
359
+ setSaving(false);
360
+ };
361
+
362
+ return Modal({
363
+ title: step === 1 ? 'Choose Database Type' : 'Connection Details',
364
+ onClose: props.onClose,
365
+ width: step === 1 ? 600 : 480,
366
+ children: h('div', { style: s.form },
367
+ step === 1 && h(Fragment, null,
368
+ DATABASE_TYPES.map(function(section) {
369
+ return h('div', { key: section.section, style: s.section },
370
+ h('div', { style: s.sectionTitle }, section.section),
371
+ h('div', { style: s.dbPicker },
372
+ section.items.map(function(item) {
373
+ var isActive = dbType === item.value;
374
+ return h('div', {
375
+ key: item.value,
376
+ style: isActive ? s.dbPickerItemActive : s.dbPickerItem,
377
+ onClick: function() { setDbType(item.value); },
378
+ },
379
+ h('div', { style: 'font-size: 24px; margin-bottom: 4px;' }, item.icon),
380
+ h('div', { style: 'font-size: 12px; font-weight: 500;' }, item.label),
381
+ );
382
+ })
383
+ ),
384
+ );
385
+ }),
386
+ h('div', { style: 'display: flex; justify-content: flex-end; margin-top: 8px;' },
387
+ h('button', { style: s.btnPrimary, disabled: !dbType, onClick: function() { setStep(2); } }, 'Next →'),
388
+ ),
389
+ ),
390
+
391
+ step === 2 && h(Fragment, null,
392
+ h('div', null,
393
+ h('div', { style: s.label }, 'Connection Name'),
394
+ h('input', { style: s.input, placeholder: 'e.g. Production DB', value: form.name, onInput: function(e) { set('name', e.target.value); } }),
395
+ ),
396
+ h('div', null,
397
+ h('div', { style: s.label }, 'Connection String (paste full URL)'),
398
+ h('input', { style: s.input, type: 'password', placeholder: 'postgresql://user:pass@host:5432/db', value: form.connectionString, onInput: function(e) { set('connectionString', e.target.value); } }),
399
+ ),
400
+ !isConnString && h(Fragment, null,
401
+ h('div', { style: 'text-align: center; font-size: 12px; color: var(--text-muted); margin: -8px 0;' }, '— or enter fields —'),
402
+ h('div', { style: s.row },
403
+ h('div', { style: s.col },
404
+ h('div', { style: s.label }, 'Host'),
405
+ h('input', { style: s.input, placeholder: 'localhost', value: form.host, onInput: function(e) { set('host', e.target.value); } }),
406
+ ),
407
+ h('div', { style: 'width: 100px;' },
408
+ h('div', { style: s.label }, 'Port'),
409
+ h('input', { style: s.input, placeholder: '5432', value: form.port, onInput: function(e) { set('port', e.target.value); } }),
410
+ ),
411
+ ),
412
+ h('div', null,
413
+ h('div', { style: s.label }, 'Database'),
414
+ h('input', { style: s.input, placeholder: 'mydb', value: form.database, onInput: function(e) { set('database', e.target.value); } }),
415
+ ),
416
+ h('div', { style: s.row },
417
+ h('div', { style: s.col },
418
+ h('div', { style: s.label }, 'Username'),
419
+ h('input', { style: s.input, value: form.username, onInput: function(e) { set('username', e.target.value); } }),
420
+ ),
421
+ h('div', { style: s.col },
422
+ h('div', { style: s.label }, 'Password'),
423
+ h('input', { style: s.input, type: 'password', value: form.password, onInput: function(e) { set('password', e.target.value); } }),
424
+ ),
425
+ ),
426
+ h('label', { style: 'display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer;' },
427
+ h('input', { type: 'checkbox', checked: form.ssl, onChange: function(e) { set('ssl', e.target.checked); } }),
428
+ 'Use SSL/TLS',
429
+ ),
430
+ ),
431
+ h('div', null,
432
+ h('div', { style: s.label }, 'Description (optional)'),
433
+ h('input', { style: s.input, placeholder: 'What is this database used for?', value: form.description, onInput: function(e) { set('description', e.target.value); } }),
434
+ ),
435
+ h('div', { style: 'display: flex; justify-content: space-between; margin-top: 8px;' },
436
+ h('button', { style: s.btn, onClick: function() { setStep(1); } }, '← Back'),
437
+ h('button', { style: s.btnPrimary, disabled: saving || (!isConnString && !form.host), onClick: save }, saving ? 'Saving...' : 'Add Connection'),
438
+ ),
439
+ ),
440
+ ),
441
+ });
442
+ }
443
+
444
+ // ─── Grant Access Modal ──────────────────────────────────────────────────────
445
+
446
+ function GrantAccessModal(props) {
447
+ var [agentId, setAgentId] = useState('');
448
+ var [perms, setPerms] = useState(['read']);
449
+ var [maxRowsRead, setMaxRowsRead] = useState('10000');
450
+ var [maxRowsWrite, setMaxRowsWrite] = useState('1000');
451
+ var [maxRowsDelete, setMaxRowsDelete] = useState('100');
452
+ var [logAll, setLogAll] = useState(false);
453
+ var [requireApproval, setRequireApproval] = useState(false);
454
+ var [blockedTables, setBlockedTables] = useState('');
455
+ var [saving, setSaving] = useState(false);
456
+
457
+ var conn = props.connections.find(function(c) { return c.id === props.connectionId; });
458
+
459
+ var togglePerm = function(p) {
460
+ setPerms(function(prev) {
461
+ return prev.includes(p) ? prev.filter(function(x) { return x !== p; }) : prev.concat([p]);
462
+ });
463
+ };
464
+
465
+ var save = async function() {
466
+ if (!agentId) return alert('Select an agent');
467
+ setSaving(true);
468
+ try {
469
+ var body = {
470
+ agentId: agentId,
471
+ permissions: perms,
472
+ queryLimits: {
473
+ maxRowsRead: parseInt(maxRowsRead) || 10000,
474
+ maxRowsWrite: parseInt(maxRowsWrite) || 1000,
475
+ maxRowsDelete: parseInt(maxRowsDelete) || 100,
476
+ },
477
+ logAllQueries: logAll,
478
+ requireApproval: requireApproval,
479
+ };
480
+ if (blockedTables.trim()) {
481
+ body.schemaAccess = { blockedTables: blockedTables.split(',').map(function(t) { return t.trim(); }).filter(Boolean) };
482
+ }
483
+ await engineCall('/database/connections/' + props.connectionId + '/agents', 'POST', body);
484
+ props.onSave();
485
+ props.onClose();
486
+ } catch (e) { alert('Failed: ' + e.message); }
487
+ setSaving(false);
488
+ };
489
+
490
+ return Modal({
491
+ title: 'Grant Database Access' + (conn ? ' — ' + conn.name : ''),
492
+ onClose: props.onClose,
493
+ width: 480,
494
+ children: h('div', { style: s.form },
495
+ h('div', null,
496
+ h('div', { style: s.label }, 'Agent'),
497
+ h('select', { style: s.select, value: agentId, onChange: function(e) { setAgentId(e.target.value); } },
498
+ h('option', { value: '' }, '— Select Agent —'),
499
+ props.agents.map(function(a) {
500
+ return h('option', { key: a.id, value: a.id }, a.displayName || a.name);
501
+ })
502
+ ),
503
+ ),
504
+ h('div', null,
505
+ h('div', { style: s.label }, 'Permissions'),
506
+ h('div', { style: s.permGrid },
507
+ PERMISSIONS.map(function(p) {
508
+ var active = perms.includes(p.value);
509
+ return h('div', {
510
+ key: p.value,
511
+ style: active ? s.permChipActive : s.permChip,
512
+ onClick: function() { togglePerm(p.value); },
513
+ }, p.label, h('span', { style: 'font-size: 10px; color: var(--text-muted); margin-left: 4px;' }, p.desc));
514
+ })
515
+ ),
516
+ ),
517
+ h('div', { style: s.row },
518
+ h('div', { style: s.col },
519
+ h('div', { style: s.label }, 'Max Read Rows'),
520
+ h('input', { style: s.input, type: 'number', value: maxRowsRead, onInput: function(e) { setMaxRowsRead(e.target.value); } }),
521
+ ),
522
+ h('div', { style: s.col },
523
+ h('div', { style: s.label }, 'Max Write Rows'),
524
+ h('input', { style: s.input, type: 'number', value: maxRowsWrite, onInput: function(e) { setMaxRowsWrite(e.target.value); } }),
525
+ ),
526
+ h('div', { style: s.col },
527
+ h('div', { style: s.label }, 'Max Delete Rows'),
528
+ h('input', { style: s.input, type: 'number', value: maxRowsDelete, onInput: function(e) { setMaxRowsDelete(e.target.value); } }),
529
+ ),
530
+ ),
531
+ h('div', null,
532
+ h('div', { style: s.label }, 'Blocked Tables (comma-separated)'),
533
+ h('input', { style: s.input, placeholder: 'users_secrets, payment_tokens', value: blockedTables, onInput: function(e) { setBlockedTables(e.target.value); } }),
534
+ ),
535
+ h('label', { style: 'display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer;' },
536
+ h('input', { type: 'checkbox', checked: logAll, onChange: function(e) { setLogAll(e.target.checked); } }),
537
+ 'Log ALL queries (including reads)',
538
+ ),
539
+ h('label', { style: 'display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer;' },
540
+ h('input', { type: 'checkbox', checked: requireApproval, onChange: function(e) { setRequireApproval(e.target.checked); } }),
541
+ 'Require human approval for write/delete',
542
+ ),
543
+ h('div', { style: 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px;' },
544
+ h('button', { style: s.btn, onClick: props.onClose }, 'Cancel'),
545
+ h('button', { style: s.btnPrimary, disabled: saving || !agentId, onClick: save }, saving ? 'Granting...' : 'Grant Access'),
546
+ ),
547
+ ),
548
+ });
549
+ }
550
+
551
+ // ─── Edit Connection Modal ───────────────────────────────────────────────────
552
+
553
+ function EditConnectionModal(props) {
554
+ var conn = props.connection;
555
+ var [form, setForm] = useState({ name: conn.name || '', host: conn.host || '', port: String(conn.port || ''), database: conn.database || '', description: conn.description || '', ssl: conn.ssl || false });
556
+ var [saving, setSaving] = useState(false);
557
+
558
+ var set = function(key, val) { setForm(function(f) { var n = Object.assign({}, f); n[key] = val; return n; }); };
559
+
560
+ var save = async function() {
561
+ setSaving(true);
562
+ try {
563
+ await engineCall('/database/connections/' + conn.id, 'PUT', {
564
+ name: form.name,
565
+ host: form.host,
566
+ port: form.port ? parseInt(form.port) : undefined,
567
+ database: form.database,
568
+ description: form.description,
569
+ ssl: form.ssl,
570
+ });
571
+ props.onSave();
572
+ props.onClose();
573
+ } catch (e) { alert('Failed: ' + e.message); }
574
+ setSaving(false);
575
+ };
576
+
577
+ return Modal({
578
+ title: 'Edit Connection — ' + conn.name,
579
+ onClose: props.onClose,
580
+ width: 480,
581
+ children: h('div', { style: s.form },
582
+ h('div', null,
583
+ h('div', { style: s.label }, 'Name'),
584
+ h('input', { style: s.input, value: form.name, onInput: function(e) { set('name', e.target.value); } }),
585
+ ),
586
+ h('div', { style: s.row },
587
+ h('div', { style: s.col },
588
+ h('div', { style: s.label }, 'Host'),
589
+ h('input', { style: s.input, value: form.host, onInput: function(e) { set('host', e.target.value); } }),
590
+ ),
591
+ h('div', { style: 'width: 100px;' },
592
+ h('div', { style: s.label }, 'Port'),
593
+ h('input', { style: s.input, value: form.port, onInput: function(e) { set('port', e.target.value); } }),
594
+ ),
595
+ ),
596
+ h('div', null,
597
+ h('div', { style: s.label }, 'Database'),
598
+ h('input', { style: s.input, value: form.database, onInput: function(e) { set('database', e.target.value); } }),
599
+ ),
600
+ h('div', null,
601
+ h('div', { style: s.label }, 'Description'),
602
+ h('input', { style: s.input, value: form.description, onInput: function(e) { set('description', e.target.value); } }),
603
+ ),
604
+ h('label', { style: 'display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer;' },
605
+ h('input', { type: 'checkbox', checked: form.ssl, onChange: function(e) { set('ssl', e.target.checked); } }),
606
+ 'Use SSL/TLS',
607
+ ),
608
+ h('div', { style: 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px;' },
609
+ h('button', { style: s.btn, onClick: props.onClose }, 'Cancel'),
610
+ h('button', { style: s.btnPrimary, disabled: saving, onClick: save }, saving ? 'Saving...' : 'Save Changes'),
611
+ ),
612
+ ),
613
+ });
614
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/enterprise",
3
- "version": "0.5.269",
3
+ "version": "0.5.270",
4
4
  "description": "AgenticMail Enterprise — cloud-hosted AI agent identity, email, auth & compliance for organizations",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,7 @@ import { SkillConnectionsPage } from './pages/skill-connections.js';
26
26
  import { VaultPage } from './pages/vault.js';
27
27
  import { OrgChartPage } from './pages/org-chart.js';
28
28
  import { TaskPipelinePage } from './pages/task-pipeline.js';
29
+ import { DatabaseAccessPage } from './pages/database-access.js';
29
30
 
30
31
  // ─── Toast System ────────────────────────────────────────
31
32
  let toastId = 0;
@@ -156,6 +157,7 @@ function App() {
156
157
  { id: 'skills', icon: I.skills, label: 'Skills' },
157
158
  { id: 'community-skills', icon: I.marketplace, label: 'Community Skills' },
158
159
  { id: 'skill-connections', icon: I.link, label: 'Integrations & MCP' },
160
+ { id: 'database-access', icon: I.database, label: 'Database Access' },
159
161
  { id: 'knowledge', icon: I.knowledge, label: 'Knowledge Bases' },
160
162
  { id: 'knowledge-contributions', icon: I.knowledge, label: 'Knowledge Hub' },
161
163
  { id: 'approvals', icon: I.approvals, label: 'Approvals', badge: pendingCount || null },
@@ -202,6 +204,7 @@ function App() {
202
204
  vault: VaultPage,
203
205
  'org-chart': OrgChartPage,
204
206
  'task-pipeline': TaskPipelinePage,
207
+ 'database-access': DatabaseAccessPage,
205
208
  };
206
209
 
207
210
  const navigateToAgent = (agentId) => { _setSelectedAgentId(agentId); history.pushState(null, '', '/dashboard/agents/' + agentId); };
@@ -0,0 +1,614 @@
1
+ import { h, useState, useEffect, useCallback, Fragment, useApp, engineCall, getOrgId } from '../components/utils.js';
2
+ import { I } from '../components/icons.js';
3
+ import { Modal } from '../components/modal.js';
4
+ import { HelpButton } from '../components/help-button.js';
5
+
6
+ var DATABASE_TYPES = [
7
+ { section: 'Relational (SQL)', items: [
8
+ { value: 'postgresql', label: 'PostgreSQL', icon: '🐘' },
9
+ { value: 'mysql', label: 'MySQL', icon: '🐬' },
10
+ { value: 'mariadb', label: 'MariaDB', icon: '🦭' },
11
+ { value: 'mssql', label: 'Microsoft SQL Server', icon: '🪟' },
12
+ { value: 'oracle', label: 'Oracle', icon: '🔴' },
13
+ { value: 'sqlite', label: 'SQLite', icon: '📦' },
14
+ ]},
15
+ { section: 'Cloud-Native SQL', items: [
16
+ { value: 'supabase', label: 'Supabase', icon: '⚡' },
17
+ { value: 'neon', label: 'Neon', icon: '🌀' },
18
+ { value: 'planetscale', label: 'PlanetScale', icon: '🪐' },
19
+ { value: 'cockroachdb', label: 'CockroachDB', icon: '🪳' },
20
+ { value: 'turso', label: 'Turso / LibSQL', icon: '🐢' },
21
+ ]},
22
+ { section: 'NoSQL / Key-Value', items: [
23
+ { value: 'mongodb', label: 'MongoDB', icon: '🍃' },
24
+ { value: 'redis', label: 'Redis', icon: '🔴' },
25
+ { value: 'dynamodb', label: 'AWS DynamoDB', icon: '☁️' },
26
+ ]},
27
+ ];
28
+
29
+ var ALL_DB_TYPES = DATABASE_TYPES.flatMap(function(s) { return s.items; });
30
+
31
+ var PERMISSIONS = [
32
+ { value: 'read', label: 'Read', desc: 'SELECT queries', color: 'var(--success)' },
33
+ { value: 'write', label: 'Write', desc: 'INSERT / UPDATE', color: 'var(--warning)' },
34
+ { value: 'delete', label: 'Delete', desc: 'DELETE rows', color: 'var(--danger)' },
35
+ { value: 'schema', label: 'Schema', desc: 'DDL operations', color: 'var(--accent)' },
36
+ { value: 'execute', label: 'Execute', desc: 'Stored procedures', color: 'var(--text-muted)' },
37
+ ];
38
+
39
+ // ─── Styles ──────────────────────────────────────────────────────────────────
40
+
41
+ var s = {
42
+ page: 'padding: 24px; max-width: 1200px; margin: 0 auto;',
43
+ header: 'display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; flex-wrap: wrap; gap: 12px;',
44
+ title: 'font-size: 24px; font-weight: 700; display: flex; align-items: center; gap: 10px;',
45
+ tabs: 'display: flex; gap: 4px; margin-bottom: 24px; border-bottom: 1px solid var(--border);',
46
+ tab: 'padding: 8px 16px; cursor: pointer; font-size: 13px; font-weight: 500; color: var(--text-secondary); border-bottom: 2px solid transparent; transition: all 0.15s;',
47
+ tabActive: 'padding: 8px 16px; cursor: pointer; font-size: 13px; font-weight: 600; color: var(--accent); border-bottom: 2px solid var(--accent);',
48
+ grid: 'display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px;',
49
+ card: 'background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; padding: 20px; transition: border-color 0.15s;',
50
+ cardHeader: 'display: flex; align-items: center; gap: 10px; margin-bottom: 12px;',
51
+ cardIcon: 'font-size: 24px;',
52
+ cardTitle: 'font-weight: 600; font-size: 15px;',
53
+ cardType: 'font-size: 12px; color: var(--text-muted);',
54
+ badge: 'display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600;',
55
+ badgeActive: 'background: rgba(21,128,61,0.15); color: var(--success);',
56
+ badgeInactive: 'background: rgba(107,115,148,0.15); color: var(--text-muted);',
57
+ badgeError: 'background: rgba(239,68,68,0.15); color: var(--danger);',
58
+ meta: 'font-size: 12px; color: var(--text-muted); margin-top: 8px;',
59
+ actions: 'display: flex; gap: 8px; margin-top: 12px;',
60
+ btn: 'padding: 6px 14px; border-radius: 6px; font-size: 12px; font-weight: 500; cursor: pointer; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); transition: all 0.15s;',
61
+ btnPrimary: 'padding: 6px 14px; border-radius: 6px; font-size: 12px; font-weight: 500; cursor: pointer; border: none; background: var(--accent); color: #fff;',
62
+ btnDanger: 'padding: 6px 14px; border-radius: 6px; font-size: 12px; font-weight: 500; cursor: pointer; border: none; background: var(--danger); color: #fff;',
63
+ emptyState: 'text-align: center; padding: 60px 20px; color: var(--text-muted);',
64
+ emptyIcon: 'font-size: 48px; margin-bottom: 12px; opacity: 0.5;',
65
+ form: 'display: flex; flex-direction: column; gap: 16px;',
66
+ label: 'font-size: 13px; font-weight: 500; margin-bottom: 4px; color: var(--text-secondary);',
67
+ input: 'padding: 8px 12px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-primary); color: var(--text-primary); font-size: 13px; width: 100%; box-sizing: border-box;',
68
+ select: 'padding: 8px 12px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-primary); color: var(--text-primary); font-size: 13px; width: 100%;',
69
+ row: 'display: flex; gap: 12px;',
70
+ col: 'flex: 1;',
71
+ permGrid: 'display: flex; flex-wrap: wrap; gap: 8px;',
72
+ permChip: 'padding: 4px 10px; border-radius: 16px; font-size: 12px; cursor: pointer; border: 1px solid var(--border); transition: all 0.15s;',
73
+ permChipActive: 'padding: 4px 10px; border-radius: 16px; font-size: 12px; cursor: pointer; border: 1px solid var(--accent); background: var(--accent-soft); color: var(--accent); font-weight: 600;',
74
+ section: 'margin-bottom: 16px;',
75
+ sectionTitle: 'font-size: 14px; font-weight: 600; margin-bottom: 8px;',
76
+ agentRow: 'display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; background: var(--bg-secondary); border-radius: 8px; margin-bottom: 8px; gap: 12px;',
77
+ agentName: 'font-weight: 500; font-size: 13px;',
78
+ agentPerms: 'display: flex; gap: 4px; flex-wrap: wrap;',
79
+ miniChip: 'padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 600;',
80
+ auditTable: 'width: 100%; border-collapse: collapse; font-size: 12px;',
81
+ auditTh: 'text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); color: var(--text-muted); font-weight: 600;',
82
+ auditTd: 'padding: 8px 10px; border-bottom: 1px solid var(--border); color: var(--text-secondary);',
83
+ dbPicker: 'display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px;',
84
+ dbPickerItem: 'padding: 12px; border-radius: 8px; border: 1px solid var(--border); cursor: pointer; text-align: center; transition: all 0.15s; background: var(--bg-secondary);',
85
+ dbPickerItemActive: 'padding: 12px; border-radius: 8px; border: 2px solid var(--accent); cursor: pointer; text-align: center; background: var(--accent-soft);',
86
+ };
87
+
88
+ // ─── Main Page ───────────────────────────────────────────────────────────────
89
+
90
+ export function DatabaseAccessPage() {
91
+ var app = useApp();
92
+ var [tab, setTab] = useState('connections');
93
+ var [connections, setConnections] = useState([]);
94
+ var [agents, setAgents] = useState([]);
95
+ var [auditLog, setAuditLog] = useState([]);
96
+ var [showAdd, setShowAdd] = useState(false);
97
+ var [showGrant, setShowGrant] = useState(null); // connectionId
98
+ var [editConn, setEditConn] = useState(null);
99
+ var [loading, setLoading] = useState(true);
100
+
101
+ var loadData = useCallback(async function() {
102
+ setLoading(true);
103
+ try {
104
+ var [conns, agts] = await Promise.all([
105
+ engineCall('/database/connections'),
106
+ engineCall('/agents').catch(function() { return []; }),
107
+ ]);
108
+ setConnections(conns || []);
109
+ setAgents(agts || []);
110
+ } catch (e) { console.error('Load failed:', e); }
111
+ setLoading(false);
112
+ }, []);
113
+
114
+ var loadAudit = useCallback(async function() {
115
+ try {
116
+ var logs = await engineCall('/database/audit?limit=50');
117
+ setAuditLog(logs || []);
118
+ } catch { setAuditLog([]); }
119
+ }, []);
120
+
121
+ useEffect(function() { loadData(); }, []);
122
+ useEffect(function() { if (tab === 'audit') loadAudit(); }, [tab]);
123
+
124
+ var deleteConn = useCallback(async function(id) {
125
+ if (!confirm('Delete this database connection? All agent access grants will be removed.')) return;
126
+ await engineCall('/database/connections/' + id, 'DELETE');
127
+ loadData();
128
+ }, []);
129
+
130
+ var testConn = useCallback(async function(id) {
131
+ try {
132
+ var result = await engineCall('/database/connections/' + id + '/test', 'POST');
133
+ alert(result.success ? 'Connection successful! (' + result.latencyMs + 'ms)' : 'Connection failed: ' + (result.error || 'Unknown error'));
134
+ loadData();
135
+ } catch (e) { alert('Test failed: ' + e.message); }
136
+ }, []);
137
+
138
+ return h('div', { style: s.page },
139
+ h('div', { style: s.header },
140
+ h('div', { style: s.title },
141
+ I.database(20),
142
+ 'Database Access',
143
+ HelpButton({
144
+ title: 'Database Access',
145
+ content: h(Fragment, null,
146
+ h('p', null, 'Connect your agents to external databases. Each agent can be granted granular permissions (read, write, delete) on specific database connections.'),
147
+ h('p', null, 'Credentials are encrypted in the vault. All queries are sanitized, rate-limited, and logged for audit.'),
148
+ ),
149
+ }),
150
+ ),
151
+ h('button', { style: s.btnPrimary, onClick: function() { setShowAdd(true); } }, '+ Add Connection'),
152
+ ),
153
+
154
+ // Tabs
155
+ h('div', { style: s.tabs },
156
+ h('div', { style: tab === 'connections' ? s.tabActive : s.tab, onClick: function() { setTab('connections'); } }, 'Connections'),
157
+ h('div', { style: tab === 'agents' ? s.tabActive : s.tab, onClick: function() { setTab('agents'); } }, 'Agent Access'),
158
+ h('div', { style: tab === 'audit' ? s.tabActive : s.tab, onClick: function() { setTab('audit'); } }, 'Audit Log'),
159
+ ),
160
+
161
+ // Content
162
+ tab === 'connections' && ConnectionsTab({ connections: connections, agents: agents, onDelete: deleteConn, onTest: testConn, onEdit: setEditConn, onGrant: setShowGrant, onRefresh: loadData }),
163
+ tab === 'agents' && AgentAccessTab({ connections: connections, agents: agents, onRefresh: loadData }),
164
+ tab === 'audit' && AuditTab({ auditLog: auditLog, onRefresh: loadAudit }),
165
+
166
+ // Modals
167
+ showAdd && AddConnectionModal({ onClose: function() { setShowAdd(false); }, onSave: loadData }),
168
+ showGrant && GrantAccessModal({ connectionId: showGrant, agents: agents, connections: connections, onClose: function() { setShowGrant(null); }, onSave: loadData }),
169
+ editConn && EditConnectionModal({ connection: editConn, onClose: function() { setEditConn(null); }, onSave: loadData }),
170
+ );
171
+ }
172
+
173
+ // ─── Connections Tab ─────────────────────────────────────────────────────────
174
+
175
+ function ConnectionsTab(props) {
176
+ var connections = props.connections;
177
+ if (connections.length === 0) {
178
+ return h('div', { style: s.emptyState },
179
+ h('div', { style: s.emptyIcon }, I.database(48)),
180
+ h('div', { style: 'font-size: 16px; font-weight: 600; margin-bottom: 8px;' }, 'No Database Connections'),
181
+ h('div', null, 'Add a connection to let your agents query external databases.'),
182
+ );
183
+ }
184
+
185
+ return h('div', { style: s.grid },
186
+ connections.map(function(conn) {
187
+ var dbType = ALL_DB_TYPES.find(function(t) { return t.value === conn.type; });
188
+ var agentCount = 0;
189
+ // Count agents with access
190
+ var statusStyle = conn.status === 'active' ? s.badgeActive : conn.status === 'error' ? s.badgeError : s.badgeInactive;
191
+ return h('div', { key: conn.id, style: s.card },
192
+ h('div', { style: s.cardHeader },
193
+ h('span', { style: s.cardIcon }, dbType ? dbType.icon : '🗄️'),
194
+ h('div', null,
195
+ h('div', { style: s.cardTitle }, conn.name),
196
+ h('div', { style: s.cardType }, dbType ? dbType.label : conn.type),
197
+ ),
198
+ h('span', { style: s.badge + ';' + statusStyle }, conn.status),
199
+ ),
200
+ conn.host && h('div', { style: s.meta }, conn.host + (conn.port ? ':' + conn.port : '') + (conn.database ? ' / ' + conn.database : '')),
201
+ conn.description && h('div', { style: s.meta }, conn.description),
202
+ conn.lastError && h('div', { style: 'font-size: 11px; color: var(--danger); margin-top: 4px;' }, conn.lastError),
203
+ h('div', { style: s.actions },
204
+ h('button', { style: s.btn, onClick: function() { props.onTest(conn.id); } }, 'Test'),
205
+ h('button', { style: s.btnPrimary, onClick: function() { props.onGrant(conn.id); } }, 'Grant Access'),
206
+ h('button', { style: s.btn, onClick: function() { props.onEdit(conn); } }, 'Edit'),
207
+ h('button', { style: s.btnDanger, onClick: function() { props.onDelete(conn.id); } }, 'Delete'),
208
+ ),
209
+ );
210
+ })
211
+ );
212
+ }
213
+
214
+ // ─── Agent Access Tab ────────────────────────────────────────────────────────
215
+
216
+ function AgentAccessTab(props) {
217
+ var [accessMap, setAccessMap] = useState({});
218
+ var [loading, setLoading] = useState(true);
219
+
220
+ useEffect(function() {
221
+ async function load() {
222
+ var map = {};
223
+ for (var agent of props.agents) {
224
+ try {
225
+ var list = await engineCall('/database/agents/' + agent.id + '/connections');
226
+ if (list && list.length > 0) map[agent.id] = list;
227
+ } catch { /* skip */ }
228
+ }
229
+ setAccessMap(map);
230
+ setLoading(false);
231
+ }
232
+ load();
233
+ }, [props.agents]);
234
+
235
+ if (loading) return h('div', { style: 'padding: 40px; text-align: center; color: var(--text-muted);' }, 'Loading agent access...');
236
+
237
+ var agentsWithAccess = props.agents.filter(function(a) { return accessMap[a.id] && accessMap[a.id].length > 0; });
238
+
239
+ if (agentsWithAccess.length === 0) {
240
+ return h('div', { style: s.emptyState },
241
+ h('div', { style: s.emptyIcon }, '🔒'),
242
+ h('div', { style: 'font-size: 16px; font-weight: 600; margin-bottom: 8px;' }, 'No Agents Have Database Access'),
243
+ h('div', null, 'Grant access from the Connections tab to allow agents to query databases.'),
244
+ );
245
+ }
246
+
247
+ return h('div', null,
248
+ agentsWithAccess.map(function(agent) {
249
+ var grants = accessMap[agent.id] || [];
250
+ return h('div', { key: agent.id, style: s.card + '; margin-bottom: 16px;' },
251
+ h('div', { style: s.cardHeader },
252
+ h('div', { style: 'width: 32px; height: 32px; border-radius: 50%; background: var(--accent-soft); display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 700; color: var(--accent);' }, (agent.displayName || agent.name || '?')[0].toUpperCase()),
253
+ h('div', null,
254
+ h('div', { style: s.cardTitle }, agent.displayName || agent.name),
255
+ h('div', { style: s.cardType }, grants.length + ' database' + (grants.length !== 1 ? 's' : '')),
256
+ ),
257
+ ),
258
+ grants.map(function(grant) {
259
+ var conn = grant.connection || {};
260
+ var dbType = ALL_DB_TYPES.find(function(t) { return t.value === conn.type; });
261
+ return h('div', { key: grant.connectionId, style: s.agentRow },
262
+ h('div', { style: 'display: flex; align-items: center; gap: 8px;' },
263
+ h('span', null, dbType ? dbType.icon : '🗄️'),
264
+ h('span', { style: s.agentName }, conn.name || grant.connectionId),
265
+ ),
266
+ h('div', { style: s.agentPerms },
267
+ (grant.permissions || []).map(function(p) {
268
+ var permDef = PERMISSIONS.find(function(x) { return x.value === p; });
269
+ return h('span', { key: p, style: s.miniChip + '; background: ' + (permDef ? permDef.color : 'var(--text-muted)') + '22; color: ' + (permDef ? permDef.color : 'var(--text-muted)') }, p);
270
+ })
271
+ ),
272
+ h('button', { style: s.btnDanger + '; padding: 3px 8px; font-size: 11px;', onClick: async function() {
273
+ if (!confirm('Revoke ' + (agent.displayName || agent.name) + ' access to ' + (conn.name || 'this database') + '?')) return;
274
+ await engineCall('/database/connections/' + grant.connectionId + '/agents/' + agent.id, 'DELETE');
275
+ props.onRefresh();
276
+ }}, 'Revoke'),
277
+ );
278
+ }),
279
+ );
280
+ })
281
+ );
282
+ }
283
+
284
+ // ─── Audit Tab ───────────────────────────────────────────────────────────────
285
+
286
+ function AuditTab(props) {
287
+ if (props.auditLog.length === 0) {
288
+ return h('div', { style: s.emptyState },
289
+ h('div', { style: s.emptyIcon }, '📋'),
290
+ h('div', { style: 'font-size: 16px; font-weight: 600; margin-bottom: 8px;' }, 'No Query Activity Yet'),
291
+ h('div', null, 'Queries executed by agents will appear here with full audit details.'),
292
+ );
293
+ }
294
+
295
+ return h('div', { style: 'overflow-x: auto;' },
296
+ h('table', { style: s.auditTable },
297
+ h('thead', null, h('tr', null,
298
+ h('th', { style: s.auditTh }, 'Time'),
299
+ h('th', { style: s.auditTh }, 'Agent'),
300
+ h('th', { style: s.auditTh }, 'Database'),
301
+ h('th', { style: s.auditTh }, 'Op'),
302
+ h('th', { style: s.auditTh }, 'Query'),
303
+ h('th', { style: s.auditTh }, 'Rows'),
304
+ h('th', { style: s.auditTh }, 'Time'),
305
+ h('th', { style: s.auditTh }, 'Status'),
306
+ )),
307
+ h('tbody', null,
308
+ props.auditLog.map(function(entry) {
309
+ var opColor = entry.operation === 'read' ? 'var(--success)' : entry.operation === 'write' ? 'var(--warning)' : entry.operation === 'delete' ? 'var(--danger)' : 'var(--text-muted)';
310
+ return h('tr', { key: entry.id },
311
+ h('td', { style: s.auditTd }, new Date(entry.timestamp).toLocaleString()),
312
+ h('td', { style: s.auditTd }, entry.agent_name || entry.agent_id?.slice(0, 8)),
313
+ h('td', { style: s.auditTd }, entry.connection_name || entry.connection_id?.slice(0, 8)),
314
+ h('td', { style: s.auditTd + '; font-weight: 600; color: ' + opColor }, entry.operation),
315
+ h('td', { style: s.auditTd + '; font-family: monospace; font-size: 11px; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;' }, entry.query),
316
+ h('td', { style: s.auditTd }, entry.rows_affected),
317
+ h('td', { style: s.auditTd }, entry.execution_time_ms + 'ms'),
318
+ h('td', { style: s.auditTd }, entry.success
319
+ ? h('span', { style: s.badge + ';' + s.badgeActive }, 'OK')
320
+ : h('span', { style: s.badge + ';' + s.badgeError, title: entry.error }, 'FAIL')
321
+ ),
322
+ );
323
+ })
324
+ ),
325
+ ),
326
+ );
327
+ }
328
+
329
+ // ─── Add Connection Modal ────────────────────────────────────────────────────
330
+
331
+ function AddConnectionModal(props) {
332
+ var [step, setStep] = useState(1);
333
+ var [dbType, setDbType] = useState('');
334
+ var [form, setForm] = useState({ name: '', host: '', port: '', database: '', username: '', password: '', connectionString: '', ssl: false, description: '' });
335
+ var [saving, setSaving] = useState(false);
336
+
337
+ var set = function(key, val) { setForm(function(f) { var n = Object.assign({}, f); n[key] = val; return n; }); };
338
+
339
+ var isConnString = form.connectionString.length > 0;
340
+
341
+ var save = async function() {
342
+ setSaving(true);
343
+ try {
344
+ var body = { type: dbType, name: form.name || (ALL_DB_TYPES.find(function(t) { return t.value === dbType; })?.label + ' Connection'), description: form.description, status: 'inactive' };
345
+ if (isConnString) {
346
+ body.connectionString = form.connectionString;
347
+ } else {
348
+ body.host = form.host;
349
+ body.port = form.port ? parseInt(form.port) : undefined;
350
+ body.database = form.database;
351
+ body.username = form.username;
352
+ body.password = form.password;
353
+ body.ssl = form.ssl;
354
+ }
355
+ await engineCall('/database/connections', 'POST', body);
356
+ props.onSave();
357
+ props.onClose();
358
+ } catch (e) { alert('Failed: ' + e.message); }
359
+ setSaving(false);
360
+ };
361
+
362
+ return Modal({
363
+ title: step === 1 ? 'Choose Database Type' : 'Connection Details',
364
+ onClose: props.onClose,
365
+ width: step === 1 ? 600 : 480,
366
+ children: h('div', { style: s.form },
367
+ step === 1 && h(Fragment, null,
368
+ DATABASE_TYPES.map(function(section) {
369
+ return h('div', { key: section.section, style: s.section },
370
+ h('div', { style: s.sectionTitle }, section.section),
371
+ h('div', { style: s.dbPicker },
372
+ section.items.map(function(item) {
373
+ var isActive = dbType === item.value;
374
+ return h('div', {
375
+ key: item.value,
376
+ style: isActive ? s.dbPickerItemActive : s.dbPickerItem,
377
+ onClick: function() { setDbType(item.value); },
378
+ },
379
+ h('div', { style: 'font-size: 24px; margin-bottom: 4px;' }, item.icon),
380
+ h('div', { style: 'font-size: 12px; font-weight: 500;' }, item.label),
381
+ );
382
+ })
383
+ ),
384
+ );
385
+ }),
386
+ h('div', { style: 'display: flex; justify-content: flex-end; margin-top: 8px;' },
387
+ h('button', { style: s.btnPrimary, disabled: !dbType, onClick: function() { setStep(2); } }, 'Next →'),
388
+ ),
389
+ ),
390
+
391
+ step === 2 && h(Fragment, null,
392
+ h('div', null,
393
+ h('div', { style: s.label }, 'Connection Name'),
394
+ h('input', { style: s.input, placeholder: 'e.g. Production DB', value: form.name, onInput: function(e) { set('name', e.target.value); } }),
395
+ ),
396
+ h('div', null,
397
+ h('div', { style: s.label }, 'Connection String (paste full URL)'),
398
+ h('input', { style: s.input, type: 'password', placeholder: 'postgresql://user:pass@host:5432/db', value: form.connectionString, onInput: function(e) { set('connectionString', e.target.value); } }),
399
+ ),
400
+ !isConnString && h(Fragment, null,
401
+ h('div', { style: 'text-align: center; font-size: 12px; color: var(--text-muted); margin: -8px 0;' }, '— or enter fields —'),
402
+ h('div', { style: s.row },
403
+ h('div', { style: s.col },
404
+ h('div', { style: s.label }, 'Host'),
405
+ h('input', { style: s.input, placeholder: 'localhost', value: form.host, onInput: function(e) { set('host', e.target.value); } }),
406
+ ),
407
+ h('div', { style: 'width: 100px;' },
408
+ h('div', { style: s.label }, 'Port'),
409
+ h('input', { style: s.input, placeholder: '5432', value: form.port, onInput: function(e) { set('port', e.target.value); } }),
410
+ ),
411
+ ),
412
+ h('div', null,
413
+ h('div', { style: s.label }, 'Database'),
414
+ h('input', { style: s.input, placeholder: 'mydb', value: form.database, onInput: function(e) { set('database', e.target.value); } }),
415
+ ),
416
+ h('div', { style: s.row },
417
+ h('div', { style: s.col },
418
+ h('div', { style: s.label }, 'Username'),
419
+ h('input', { style: s.input, value: form.username, onInput: function(e) { set('username', e.target.value); } }),
420
+ ),
421
+ h('div', { style: s.col },
422
+ h('div', { style: s.label }, 'Password'),
423
+ h('input', { style: s.input, type: 'password', value: form.password, onInput: function(e) { set('password', e.target.value); } }),
424
+ ),
425
+ ),
426
+ h('label', { style: 'display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer;' },
427
+ h('input', { type: 'checkbox', checked: form.ssl, onChange: function(e) { set('ssl', e.target.checked); } }),
428
+ 'Use SSL/TLS',
429
+ ),
430
+ ),
431
+ h('div', null,
432
+ h('div', { style: s.label }, 'Description (optional)'),
433
+ h('input', { style: s.input, placeholder: 'What is this database used for?', value: form.description, onInput: function(e) { set('description', e.target.value); } }),
434
+ ),
435
+ h('div', { style: 'display: flex; justify-content: space-between; margin-top: 8px;' },
436
+ h('button', { style: s.btn, onClick: function() { setStep(1); } }, '← Back'),
437
+ h('button', { style: s.btnPrimary, disabled: saving || (!isConnString && !form.host), onClick: save }, saving ? 'Saving...' : 'Add Connection'),
438
+ ),
439
+ ),
440
+ ),
441
+ });
442
+ }
443
+
444
+ // ─── Grant Access Modal ──────────────────────────────────────────────────────
445
+
446
+ function GrantAccessModal(props) {
447
+ var [agentId, setAgentId] = useState('');
448
+ var [perms, setPerms] = useState(['read']);
449
+ var [maxRowsRead, setMaxRowsRead] = useState('10000');
450
+ var [maxRowsWrite, setMaxRowsWrite] = useState('1000');
451
+ var [maxRowsDelete, setMaxRowsDelete] = useState('100');
452
+ var [logAll, setLogAll] = useState(false);
453
+ var [requireApproval, setRequireApproval] = useState(false);
454
+ var [blockedTables, setBlockedTables] = useState('');
455
+ var [saving, setSaving] = useState(false);
456
+
457
+ var conn = props.connections.find(function(c) { return c.id === props.connectionId; });
458
+
459
+ var togglePerm = function(p) {
460
+ setPerms(function(prev) {
461
+ return prev.includes(p) ? prev.filter(function(x) { return x !== p; }) : prev.concat([p]);
462
+ });
463
+ };
464
+
465
+ var save = async function() {
466
+ if (!agentId) return alert('Select an agent');
467
+ setSaving(true);
468
+ try {
469
+ var body = {
470
+ agentId: agentId,
471
+ permissions: perms,
472
+ queryLimits: {
473
+ maxRowsRead: parseInt(maxRowsRead) || 10000,
474
+ maxRowsWrite: parseInt(maxRowsWrite) || 1000,
475
+ maxRowsDelete: parseInt(maxRowsDelete) || 100,
476
+ },
477
+ logAllQueries: logAll,
478
+ requireApproval: requireApproval,
479
+ };
480
+ if (blockedTables.trim()) {
481
+ body.schemaAccess = { blockedTables: blockedTables.split(',').map(function(t) { return t.trim(); }).filter(Boolean) };
482
+ }
483
+ await engineCall('/database/connections/' + props.connectionId + '/agents', 'POST', body);
484
+ props.onSave();
485
+ props.onClose();
486
+ } catch (e) { alert('Failed: ' + e.message); }
487
+ setSaving(false);
488
+ };
489
+
490
+ return Modal({
491
+ title: 'Grant Database Access' + (conn ? ' — ' + conn.name : ''),
492
+ onClose: props.onClose,
493
+ width: 480,
494
+ children: h('div', { style: s.form },
495
+ h('div', null,
496
+ h('div', { style: s.label }, 'Agent'),
497
+ h('select', { style: s.select, value: agentId, onChange: function(e) { setAgentId(e.target.value); } },
498
+ h('option', { value: '' }, '— Select Agent —'),
499
+ props.agents.map(function(a) {
500
+ return h('option', { key: a.id, value: a.id }, a.displayName || a.name);
501
+ })
502
+ ),
503
+ ),
504
+ h('div', null,
505
+ h('div', { style: s.label }, 'Permissions'),
506
+ h('div', { style: s.permGrid },
507
+ PERMISSIONS.map(function(p) {
508
+ var active = perms.includes(p.value);
509
+ return h('div', {
510
+ key: p.value,
511
+ style: active ? s.permChipActive : s.permChip,
512
+ onClick: function() { togglePerm(p.value); },
513
+ }, p.label, h('span', { style: 'font-size: 10px; color: var(--text-muted); margin-left: 4px;' }, p.desc));
514
+ })
515
+ ),
516
+ ),
517
+ h('div', { style: s.row },
518
+ h('div', { style: s.col },
519
+ h('div', { style: s.label }, 'Max Read Rows'),
520
+ h('input', { style: s.input, type: 'number', value: maxRowsRead, onInput: function(e) { setMaxRowsRead(e.target.value); } }),
521
+ ),
522
+ h('div', { style: s.col },
523
+ h('div', { style: s.label }, 'Max Write Rows'),
524
+ h('input', { style: s.input, type: 'number', value: maxRowsWrite, onInput: function(e) { setMaxRowsWrite(e.target.value); } }),
525
+ ),
526
+ h('div', { style: s.col },
527
+ h('div', { style: s.label }, 'Max Delete Rows'),
528
+ h('input', { style: s.input, type: 'number', value: maxRowsDelete, onInput: function(e) { setMaxRowsDelete(e.target.value); } }),
529
+ ),
530
+ ),
531
+ h('div', null,
532
+ h('div', { style: s.label }, 'Blocked Tables (comma-separated)'),
533
+ h('input', { style: s.input, placeholder: 'users_secrets, payment_tokens', value: blockedTables, onInput: function(e) { setBlockedTables(e.target.value); } }),
534
+ ),
535
+ h('label', { style: 'display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer;' },
536
+ h('input', { type: 'checkbox', checked: logAll, onChange: function(e) { setLogAll(e.target.checked); } }),
537
+ 'Log ALL queries (including reads)',
538
+ ),
539
+ h('label', { style: 'display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer;' },
540
+ h('input', { type: 'checkbox', checked: requireApproval, onChange: function(e) { setRequireApproval(e.target.checked); } }),
541
+ 'Require human approval for write/delete',
542
+ ),
543
+ h('div', { style: 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px;' },
544
+ h('button', { style: s.btn, onClick: props.onClose }, 'Cancel'),
545
+ h('button', { style: s.btnPrimary, disabled: saving || !agentId, onClick: save }, saving ? 'Granting...' : 'Grant Access'),
546
+ ),
547
+ ),
548
+ });
549
+ }
550
+
551
+ // ─── Edit Connection Modal ───────────────────────────────────────────────────
552
+
553
+ function EditConnectionModal(props) {
554
+ var conn = props.connection;
555
+ var [form, setForm] = useState({ name: conn.name || '', host: conn.host || '', port: String(conn.port || ''), database: conn.database || '', description: conn.description || '', ssl: conn.ssl || false });
556
+ var [saving, setSaving] = useState(false);
557
+
558
+ var set = function(key, val) { setForm(function(f) { var n = Object.assign({}, f); n[key] = val; return n; }); };
559
+
560
+ var save = async function() {
561
+ setSaving(true);
562
+ try {
563
+ await engineCall('/database/connections/' + conn.id, 'PUT', {
564
+ name: form.name,
565
+ host: form.host,
566
+ port: form.port ? parseInt(form.port) : undefined,
567
+ database: form.database,
568
+ description: form.description,
569
+ ssl: form.ssl,
570
+ });
571
+ props.onSave();
572
+ props.onClose();
573
+ } catch (e) { alert('Failed: ' + e.message); }
574
+ setSaving(false);
575
+ };
576
+
577
+ return Modal({
578
+ title: 'Edit Connection — ' + conn.name,
579
+ onClose: props.onClose,
580
+ width: 480,
581
+ children: h('div', { style: s.form },
582
+ h('div', null,
583
+ h('div', { style: s.label }, 'Name'),
584
+ h('input', { style: s.input, value: form.name, onInput: function(e) { set('name', e.target.value); } }),
585
+ ),
586
+ h('div', { style: s.row },
587
+ h('div', { style: s.col },
588
+ h('div', { style: s.label }, 'Host'),
589
+ h('input', { style: s.input, value: form.host, onInput: function(e) { set('host', e.target.value); } }),
590
+ ),
591
+ h('div', { style: 'width: 100px;' },
592
+ h('div', { style: s.label }, 'Port'),
593
+ h('input', { style: s.input, value: form.port, onInput: function(e) { set('port', e.target.value); } }),
594
+ ),
595
+ ),
596
+ h('div', null,
597
+ h('div', { style: s.label }, 'Database'),
598
+ h('input', { style: s.input, value: form.database, onInput: function(e) { set('database', e.target.value); } }),
599
+ ),
600
+ h('div', null,
601
+ h('div', { style: s.label }, 'Description'),
602
+ h('input', { style: s.input, value: form.description, onInput: function(e) { set('description', e.target.value); } }),
603
+ ),
604
+ h('label', { style: 'display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer;' },
605
+ h('input', { type: 'checkbox', checked: form.ssl, onChange: function(e) { set('ssl', e.target.checked); } }),
606
+ 'Use SSL/TLS',
607
+ ),
608
+ h('div', { style: 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px;' },
609
+ h('button', { style: s.btn, onClick: props.onClose }, 'Cancel'),
610
+ h('button', { style: s.btnPrimary, disabled: saving, onClick: save }, saving ? 'Saving...' : 'Save Changes'),
611
+ ),
612
+ ),
613
+ });
614
+ }