neoagent 2.4.1-beta.45 → 2.4.1-beta.47
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/.env.example +12 -0
- package/package.json +1 -1
- package/server/admin/access.js +198 -0
- package/server/admin/admin.js +2 -2
- package/server/admin/analytics.js +128 -0
- package/server/admin/index.html +484 -1
- package/server/admin/login.html +171 -45
- package/server/admin/sql.js +134 -0
- package/server/admin/users.js +147 -0
- package/server/db/database.js +19 -0
- package/server/http/static.js +20 -6
- package/server/middleware/adminAuth.js +37 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +4 -4
- package/server/routes/admin.js +316 -5
- package/server/services/account/admin_two_factor.js +132 -0
package/server/admin/login.html
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>NeoAgent Admin</title>
|
|
7
|
+
<link rel="icon" href="/admin/logo.svg" type="image/svg+xml">
|
|
7
8
|
<link rel="stylesheet" href="/admin/admin.css">
|
|
8
9
|
<style>
|
|
9
10
|
body {
|
|
@@ -70,62 +71,148 @@
|
|
|
70
71
|
font-size: 14px;
|
|
71
72
|
margin-top: 4px;
|
|
72
73
|
}
|
|
74
|
+
|
|
75
|
+
/* ── Admin hero mark ────────────────────────────── */
|
|
76
|
+
.admin-hero {
|
|
77
|
+
display: flex;
|
|
78
|
+
flex-direction: column;
|
|
79
|
+
align-items: center;
|
|
80
|
+
margin-bottom: 28px;
|
|
81
|
+
gap: 14px;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.admin-a-mark {
|
|
85
|
+
width: 80px;
|
|
86
|
+
height: 80px;
|
|
87
|
+
border-radius: 22px;
|
|
88
|
+
background: var(--bg-secondary);
|
|
89
|
+
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
|
90
|
+
box-shadow:
|
|
91
|
+
0 0 0 4px color-mix(in srgb, var(--accent) 8%, transparent),
|
|
92
|
+
0 12px 32px rgba(0,0,0,0.45);
|
|
93
|
+
display: flex;
|
|
94
|
+
align-items: center;
|
|
95
|
+
justify-content: center;
|
|
96
|
+
font-size: 42px;
|
|
97
|
+
font-weight: 800;
|
|
98
|
+
color: var(--accent);
|
|
99
|
+
letter-spacing: -2px;
|
|
100
|
+
font-family: var(--font-sans);
|
|
101
|
+
line-height: 1;
|
|
102
|
+
user-select: none;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.admin-hero .brand {
|
|
106
|
+
margin-bottom: 0;
|
|
107
|
+
justify-content: center;
|
|
108
|
+
}
|
|
73
109
|
</style>
|
|
74
110
|
</head>
|
|
75
111
|
<body>
|
|
76
112
|
<div class="login-wrap">
|
|
77
113
|
<div class="login-card">
|
|
78
|
-
<div class="
|
|
79
|
-
<div class="
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
<div
|
|
114
|
+
<div class="admin-hero">
|
|
115
|
+
<div class="admin-a-mark" aria-hidden="true">A</div>
|
|
116
|
+
<div class="brand">
|
|
117
|
+
<div class="brand-mark">
|
|
118
|
+
<img src="/admin/logo.svg" width="32" height="32" alt="">
|
|
119
|
+
</div>
|
|
120
|
+
<div>
|
|
121
|
+
<div class="brand-name">NeoAgent</div>
|
|
122
|
+
<div class="brand-sub">Admin</div>
|
|
123
|
+
</div>
|
|
85
124
|
</div>
|
|
86
125
|
</div>
|
|
87
126
|
|
|
88
|
-
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
127
|
+
<!-- Step 1: credentials -->
|
|
128
|
+
<div id="step-credentials">
|
|
129
|
+
<h1 class="login-heading">Sign in</h1>
|
|
130
|
+
<p class="login-sub">
|
|
131
|
+
Credentials are in your <code>.env</code> or via <code>neoagent admin</code>
|
|
132
|
+
</p>
|
|
133
|
+
|
|
134
|
+
<div class="alert alert-error" id="error" role="alert"></div>
|
|
135
|
+
|
|
136
|
+
<form id="form" novalidate>
|
|
137
|
+
<div class="field">
|
|
138
|
+
<label for="username">Username</label>
|
|
139
|
+
<input
|
|
140
|
+
id="username"
|
|
141
|
+
type="text"
|
|
142
|
+
autocomplete="username"
|
|
143
|
+
autofocus
|
|
144
|
+
required
|
|
145
|
+
spellcheck="false"
|
|
146
|
+
autocorrect="off"
|
|
147
|
+
autocapitalize="none"
|
|
148
|
+
>
|
|
149
|
+
</div>
|
|
150
|
+
<div class="field">
|
|
151
|
+
<label for="password">Password</label>
|
|
152
|
+
<input
|
|
153
|
+
id="password"
|
|
154
|
+
type="password"
|
|
155
|
+
autocomplete="current-password"
|
|
156
|
+
required
|
|
157
|
+
>
|
|
158
|
+
</div>
|
|
159
|
+
<button type="submit" class="btn btn-primary submit-btn" id="submit-btn">
|
|
160
|
+
Sign in
|
|
161
|
+
</button>
|
|
162
|
+
</form>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<!-- Step 2: 2FA code (shown after password is accepted) -->
|
|
166
|
+
<div id="step-totp" style="display:none;">
|
|
167
|
+
<h1 class="login-heading">Two-factor authentication</h1>
|
|
168
|
+
<p class="login-sub">Enter the 6-digit code from your authenticator app, or a recovery code.</p>
|
|
169
|
+
|
|
170
|
+
<div class="alert alert-error" id="totp-error" role="alert"></div>
|
|
171
|
+
|
|
172
|
+
<form id="totp-form" novalidate>
|
|
173
|
+
<div class="field">
|
|
174
|
+
<label for="totp-code">Authenticator code</label>
|
|
175
|
+
<input
|
|
176
|
+
id="totp-code"
|
|
177
|
+
type="text"
|
|
178
|
+
inputmode="numeric"
|
|
179
|
+
autocomplete="one-time-code"
|
|
180
|
+
maxlength="11"
|
|
181
|
+
placeholder="123 456"
|
|
182
|
+
spellcheck="false"
|
|
183
|
+
autocorrect="off"
|
|
184
|
+
autocapitalize="none"
|
|
185
|
+
required
|
|
186
|
+
>
|
|
187
|
+
</div>
|
|
188
|
+
<button type="submit" class="btn btn-primary submit-btn" id="totp-btn">
|
|
189
|
+
Verify
|
|
190
|
+
</button>
|
|
191
|
+
</form>
|
|
192
|
+
<div style="margin-top:14px;text-align:center;">
|
|
193
|
+
<button type="button" class="btn btn-ghost" style="font-size:12px;padding:4px 10px;"
|
|
194
|
+
onclick="backToCredentials()">← Back</button>
|
|
117
195
|
</div>
|
|
118
|
-
|
|
119
|
-
Sign in
|
|
120
|
-
</button>
|
|
121
|
-
</form>
|
|
196
|
+
</div>
|
|
122
197
|
</div>
|
|
123
198
|
</div>
|
|
124
199
|
|
|
125
200
|
<script>
|
|
126
|
-
const form
|
|
201
|
+
const form = document.getElementById('form');
|
|
127
202
|
const submitBtn = document.getElementById('submit-btn');
|
|
128
|
-
const errorEl
|
|
203
|
+
const errorEl = document.getElementById('error');
|
|
204
|
+
const totpForm = document.getElementById('totp-form');
|
|
205
|
+
const totpBtn = document.getElementById('totp-btn');
|
|
206
|
+
const totpError = document.getElementById('totp-error');
|
|
207
|
+
|
|
208
|
+
function showStep(step) {
|
|
209
|
+
document.getElementById('step-credentials').style.display = step === 1 ? '' : 'none';
|
|
210
|
+
document.getElementById('step-totp').style.display = step === 2 ? '' : 'none';
|
|
211
|
+
if (step === 2) setTimeout(() => document.getElementById('totp-code')?.focus(), 50);
|
|
212
|
+
if (step === 1) setTimeout(() => document.getElementById('username')?.focus(), 50);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function backToCredentials() { showStep(1); }
|
|
129
216
|
|
|
130
217
|
form.addEventListener('submit', async (e) => {
|
|
131
218
|
e.preventDefault();
|
|
@@ -143,12 +230,18 @@
|
|
|
143
230
|
}),
|
|
144
231
|
});
|
|
145
232
|
|
|
146
|
-
|
|
147
|
-
|
|
233
|
+
const body = await res.json().catch(() => ({}));
|
|
234
|
+
|
|
235
|
+
if (res.ok || body.requiresTwoFactor) {
|
|
236
|
+
if (body.requiresTwoFactor) {
|
|
237
|
+
// Password accepted — go to 2FA step
|
|
238
|
+
showStep(2);
|
|
239
|
+
} else {
|
|
240
|
+
window.location.replace('/admin');
|
|
241
|
+
}
|
|
148
242
|
return;
|
|
149
243
|
}
|
|
150
244
|
|
|
151
|
-
const body = await res.json().catch(() => ({}));
|
|
152
245
|
errorEl.textContent = body.error || 'Invalid credentials';
|
|
153
246
|
errorEl.classList.add('visible');
|
|
154
247
|
} catch {
|
|
@@ -159,6 +252,39 @@
|
|
|
159
252
|
submitBtn.textContent = 'Sign in';
|
|
160
253
|
}
|
|
161
254
|
});
|
|
255
|
+
|
|
256
|
+
totpForm.addEventListener('submit', async (e) => {
|
|
257
|
+
e.preventDefault();
|
|
258
|
+
totpError.classList.remove('visible');
|
|
259
|
+
totpBtn.disabled = true;
|
|
260
|
+
totpBtn.textContent = 'Verifying…';
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
const code = document.getElementById('totp-code').value.trim();
|
|
264
|
+
const res = await fetch('/admin/api/2fa/verify', {
|
|
265
|
+
method: 'POST',
|
|
266
|
+
headers: { 'Content-Type': 'application/json' },
|
|
267
|
+
body: JSON.stringify({ code }),
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
if (res.ok) {
|
|
271
|
+
window.location.replace('/admin');
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const body = await res.json().catch(() => ({}));
|
|
276
|
+
totpError.textContent = body.error || 'Invalid code';
|
|
277
|
+
totpError.classList.add('visible');
|
|
278
|
+
document.getElementById('totp-code').value = '';
|
|
279
|
+
document.getElementById('totp-code').focus();
|
|
280
|
+
} catch {
|
|
281
|
+
totpError.textContent = 'Network error';
|
|
282
|
+
totpError.classList.add('visible');
|
|
283
|
+
} finally {
|
|
284
|
+
totpBtn.disabled = false;
|
|
285
|
+
totpBtn.textContent = 'Verify';
|
|
286
|
+
}
|
|
287
|
+
});
|
|
162
288
|
</script>
|
|
163
289
|
</body>
|
|
164
290
|
</html>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ── SQL Editor ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const SQL_TEMPLATES = [
|
|
6
|
+
{ label: '— Select a template —', query: '' },
|
|
7
|
+
{ label: 'User summary', query: `SELECT u.id, u.username, u.email,\n u.created_at, u.last_login,\n COUNT(DISTINCT r.id) AS runs,\n COALESCE(SUM(r.total_tokens),0) AS tokens\nFROM users u\nLEFT JOIN agent_runs r ON r.user_id = u.id\nGROUP BY u.id\nORDER BY runs DESC\nLIMIT 50` },
|
|
8
|
+
{ label: 'Recent failed runs', query: `SELECT r.id, u.username, r.title, r.status,\n r.error, r.created_at\nFROM agent_runs r\nJOIN users u ON u.id = r.user_id\nWHERE r.status = 'failed'\nORDER BY r.created_at DESC\nLIMIT 50` },
|
|
9
|
+
{ label: 'Artifact storage by user', query: `SELECT u.username,\n COUNT(a.id) AS files,\n SUM(a.byte_size) AS bytes,\n ROUND(SUM(a.byte_size) / 1048576.0, 2) AS mb\nFROM users u\nLEFT JOIN artifacts a ON a.user_id = u.id\nGROUP BY u.id\nORDER BY bytes DESC` },
|
|
10
|
+
{ label: 'Active sessions', query: `SELECT u.username, s.ip_address,\n s.user_agent, s.created_at, s.last_seen_at\nFROM user_sessions s\nJOIN users u ON u.id = s.user_id\nWHERE s.revoked_at IS NULL\n AND s.expires_at > datetime('now')\nORDER BY s.last_seen_at DESC\nLIMIT 50` },
|
|
11
|
+
{ label: 'Runs per day (30 days)', query: `SELECT DATE(created_at) AS day,\n COUNT(*) AS runs,\n COALESCE(SUM(total_tokens),0) AS tokens\nFROM agent_runs\nWHERE created_at >= datetime('now', '-30 days')\nGROUP BY day\nORDER BY day DESC` },
|
|
12
|
+
{ label: 'Most used agents', query: `SELECT a.slug, a.display_name, u.username AS owner,\n COUNT(r.id) AS runs\nFROM agents a\nJOIN users u ON u.id = a.user_id\nLEFT JOIN agent_runs r ON r.agent_id = a.id\nGROUP BY a.id\nORDER BY runs DESC\nLIMIT 20` },
|
|
13
|
+
{ label: 'Integration connections', query: `SELECT u.username, ic.provider_key,\n ic.status, ic.account_email, ic.last_connected_at\nFROM integration_connections ic\nJOIN users u ON u.id = ic.user_id\nORDER BY ic.last_connected_at DESC\nLIMIT 50` },
|
|
14
|
+
{ label: 'All tables', query: `SELECT name FROM sqlite_master WHERE type='table' ORDER BY name` },
|
|
15
|
+
{ label: 'Table info (users)', query: `SELECT * FROM pragma_table_info('users')` },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function loadSql() {
|
|
19
|
+
// Only initialize the editor once
|
|
20
|
+
if (document.getElementById('sql-editor')?.dataset.ready === '1') return;
|
|
21
|
+
initSqlEditor();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function initSqlEditor() {
|
|
25
|
+
const el = document.getElementById('sql-content');
|
|
26
|
+
if (!el) return;
|
|
27
|
+
|
|
28
|
+
const templateOptions = SQL_TEMPLATES.map((t, i) =>
|
|
29
|
+
`<option value="${i}">${esc(t.label)}</option>`).join('');
|
|
30
|
+
|
|
31
|
+
el.innerHTML = `
|
|
32
|
+
<div class="sql-toolbar">
|
|
33
|
+
<select id="sql-template" onchange="onSqlTemplate(this)" style="width:auto;min-width:220px;" aria-label="Query template">
|
|
34
|
+
${templateOptions}
|
|
35
|
+
</select>
|
|
36
|
+
<span class="spacer"></span>
|
|
37
|
+
<button class="btn btn-primary" id="sql-run-btn" onclick="runSql()" style="padding:7px 16px;">
|
|
38
|
+
<svg viewBox="0 0 20 20" fill="currentColor" style="width:13px;height:13px;" aria-hidden="true">
|
|
39
|
+
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/>
|
|
40
|
+
</svg>
|
|
41
|
+
Run
|
|
42
|
+
</button>
|
|
43
|
+
</div>
|
|
44
|
+
<textarea id="sql-editor" data-ready="1" class="sql-textarea" spellcheck="false" autocorrect="off" autocapitalize="off"
|
|
45
|
+
placeholder="SELECT * FROM users LIMIT 10"
|
|
46
|
+
onkeydown="onSqlKeydown(event)"
|
|
47
|
+
aria-label="SQL query editor"
|
|
48
|
+
></textarea>
|
|
49
|
+
<div id="sql-results"></div>`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function onSqlTemplate(sel) {
|
|
53
|
+
const idx = parseInt(sel.value, 10);
|
|
54
|
+
const q = SQL_TEMPLATES[idx]?.query || '';
|
|
55
|
+
const editor = document.getElementById('sql-editor');
|
|
56
|
+
if (editor && q) { editor.value = q; editor.focus(); }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function onSqlKeydown(e) {
|
|
60
|
+
// Ctrl+Enter / Cmd+Enter to run
|
|
61
|
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
runSql();
|
|
64
|
+
}
|
|
65
|
+
// Tab → insert 2 spaces
|
|
66
|
+
if (e.key === 'Tab') {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
const ta = e.target;
|
|
69
|
+
const s = ta.selectionStart, end = ta.selectionEnd;
|
|
70
|
+
ta.value = ta.value.slice(0, s) + ' ' + ta.value.slice(end);
|
|
71
|
+
ta.selectionStart = ta.selectionEnd = s + 2;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function runSql() {
|
|
76
|
+
const query = document.getElementById('sql-editor')?.value?.trim();
|
|
77
|
+
if (!query) return;
|
|
78
|
+
const resultsEl = document.getElementById('sql-results');
|
|
79
|
+
const runBtn = document.getElementById('sql-run-btn');
|
|
80
|
+
if (!resultsEl || !runBtn) return;
|
|
81
|
+
|
|
82
|
+
runBtn.disabled = true;
|
|
83
|
+
resultsEl.innerHTML = '<div class="empty"><span class="spinner"></span></div>';
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const res = await api('/admin/api/sql', {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'Content-Type': 'application/json' },
|
|
89
|
+
body: JSON.stringify({ query }),
|
|
90
|
+
});
|
|
91
|
+
const data = await res.json();
|
|
92
|
+
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
resultsEl.innerHTML = `<div class="sql-error">${esc(data.error || 'Query failed')}</div>`;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!data.rows?.length) {
|
|
99
|
+
resultsEl.innerHTML = '<div class="empty">Query returned 0 rows</div>';
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
resultsEl.innerHTML = `
|
|
104
|
+
<div class="sql-meta">
|
|
105
|
+
${fmtNum(data.rows.length)}${data.truncated ? ` of ${fmtNum(data.total)}` : ''} row${data.rows.length === 1 ? '' : 's'}
|
|
106
|
+
${data.truncated ? '<span class="badge badge-warn" style="margin-left:6px;">truncated at 500</span>' : ''}
|
|
107
|
+
<button class="btn btn-ghost" style="margin-left:auto;padding:4px 10px;font-size:11px;" onclick="copySqlResults()">Copy CSV</button>
|
|
108
|
+
</div>
|
|
109
|
+
<div class="sql-result-wrap">
|
|
110
|
+
<table class="sql-result-table">
|
|
111
|
+
<thead><tr>${data.columns.map((c) => `<th>${esc(c)}</th>`).join('')}</tr></thead>
|
|
112
|
+
<tbody>${data.rows.map((row) =>
|
|
113
|
+
`<tr>${data.columns.map((c) => `<td>${esc(row[c] == null ? 'NULL' : String(row[c]))}</td>`).join('')}</tr>`
|
|
114
|
+
).join('')}</tbody>
|
|
115
|
+
</table>
|
|
116
|
+
</div>`;
|
|
117
|
+
// stash for copy
|
|
118
|
+
window._lastSqlData = data;
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (err.message !== 'unauthorized') {
|
|
121
|
+
resultsEl.innerHTML = `<div class="sql-error">Network error</div>`;
|
|
122
|
+
}
|
|
123
|
+
} finally {
|
|
124
|
+
runBtn.disabled = false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function copySqlResults() {
|
|
129
|
+
const d = window._lastSqlData;
|
|
130
|
+
if (!d) return;
|
|
131
|
+
const header = d.columns.join(',');
|
|
132
|
+
const rows = d.rows.map((r) => d.columns.map((c) => JSON.stringify(r[c] ?? '')).join(','));
|
|
133
|
+
navigator.clipboard.writeText([header, ...rows].join('\n')).catch(() => {});
|
|
134
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ── User Management ────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
let _userSearchTimer = null;
|
|
6
|
+
|
|
7
|
+
async function loadUsers() {
|
|
8
|
+
const q = document.getElementById('user-search')?.value?.trim() || '';
|
|
9
|
+
await fetchUsers(q);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function fetchUsers(q = '') {
|
|
13
|
+
const el = document.getElementById('users-table-wrap');
|
|
14
|
+
if (!el) return;
|
|
15
|
+
el.innerHTML = '<div class="empty"><span class="spinner"></span></div>';
|
|
16
|
+
try {
|
|
17
|
+
const url = q ? `/admin/api/users?q=${encodeURIComponent(q)}` : '/admin/api/users';
|
|
18
|
+
const data = await api(url).then((r) => r.json());
|
|
19
|
+
renderUsersTable(el, data.users || []);
|
|
20
|
+
const cnt = document.getElementById('users-count');
|
|
21
|
+
if (cnt) cnt.textContent = `${(data.users || []).length} user${(data.users || []).length === 1 ? '' : 's'}`;
|
|
22
|
+
} catch (err) {
|
|
23
|
+
if (err.message !== 'unauthorized') el.innerHTML = '<div class="empty">Failed to load users</div>';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function onUserSearch() {
|
|
28
|
+
clearTimeout(_userSearchTimer);
|
|
29
|
+
_userSearchTimer = setTimeout(() => {
|
|
30
|
+
const q = document.getElementById('user-search')?.value?.trim() || '';
|
|
31
|
+
fetchUsers(q);
|
|
32
|
+
}, 300);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderUsersTable(el, users) {
|
|
36
|
+
if (!users.length) {
|
|
37
|
+
el.innerHTML = '<div class="empty">No users found</div>';
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
el.innerHTML = `
|
|
41
|
+
<table class="users-table">
|
|
42
|
+
<thead><tr>
|
|
43
|
+
<th>User</th>
|
|
44
|
+
<th>Email</th>
|
|
45
|
+
<th>Joined</th>
|
|
46
|
+
<th>Last Login</th>
|
|
47
|
+
<th>Runs</th>
|
|
48
|
+
<th>Storage</th>
|
|
49
|
+
<th style="text-align:right;">Actions</th>
|
|
50
|
+
</tr></thead>
|
|
51
|
+
<tbody>${users.map((u) => `
|
|
52
|
+
<tr data-uid="${esc(u.id)}">
|
|
53
|
+
<td>
|
|
54
|
+
<div style="display:flex;align-items:center;gap:9px;">
|
|
55
|
+
<span class="user-avatar">${esc((u.display_name || u.username || '?')[0]).toUpperCase()}</span>
|
|
56
|
+
<div>
|
|
57
|
+
<div style="font-weight:600;color:var(--text);">${esc(u.display_name || u.username)}</div>
|
|
58
|
+
${u.display_name && u.display_name !== u.username ? `<div style="font-size:11px;color:var(--text-muted);">@${esc(u.username)}</div>` : ''}
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</td>
|
|
62
|
+
<td>
|
|
63
|
+
<span style="font-size:12px;">${esc(u.email || '—')}</span>
|
|
64
|
+
${u.email_verified_at
|
|
65
|
+
? '<span class="badge badge-ok" style="font-size:10px;margin-left:5px;">verified</span>'
|
|
66
|
+
: u.email ? '<span class="badge badge-warn" style="font-size:10px;margin-left:5px;">unverified</span>' : ''}
|
|
67
|
+
</td>
|
|
68
|
+
<td style="font-size:12px;color:var(--text-muted);">${fmtDate(u.created_at)}</td>
|
|
69
|
+
<td style="font-size:12px;color:var(--text-muted);">${u.last_login ? fmtDate(u.last_login) : '—'}</td>
|
|
70
|
+
<td style="font-family:var(--font-mono);font-size:12px;">${fmtNum(u.run_count)}</td>
|
|
71
|
+
<td style="font-family:var(--font-mono);font-size:12px;">${fmtBytes(u.storage_bytes)}</td>
|
|
72
|
+
<td>
|
|
73
|
+
<div style="display:flex;gap:6px;justify-content:flex-end;">
|
|
74
|
+
<button class="btn btn-ghost" style="padding:5px 10px;font-size:11px;"
|
|
75
|
+
onclick="forceLogout('${esc(u.id)}','${esc(u.username)}',this)" title="Revoke all sessions">
|
|
76
|
+
Logout
|
|
77
|
+
</button>
|
|
78
|
+
<button class="btn btn-danger" style="padding:5px 10px;font-size:11px;"
|
|
79
|
+
onclick="deleteUser('${esc(u.id)}','${esc(u.display_name || u.username)}',this)" title="GDPR: delete all user data">
|
|
80
|
+
Delete
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
</td>
|
|
84
|
+
</tr>`).join('')}
|
|
85
|
+
</tbody>
|
|
86
|
+
</table>
|
|
87
|
+
<p class="gdpr-note">
|
|
88
|
+
⚠ <strong>Delete</strong> permanently erases all user data (runs, messages, memories, artifacts, sessions) — irreversible. Required by GDPR Art. 17 right to erasure.
|
|
89
|
+
</p>`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function forceLogout(id, username, btn) {
|
|
93
|
+
if (!confirm(`Force logout ${username}?\n\nThis will revoke all active sessions. They can log back in.`)) return;
|
|
94
|
+
btn.disabled = true;
|
|
95
|
+
try {
|
|
96
|
+
const res = await api(`/admin/api/users/${id}/sessions`, { method: 'DELETE' });
|
|
97
|
+
if (res.ok) {
|
|
98
|
+
btn.textContent = 'Done';
|
|
99
|
+
setTimeout(loadUsers, 800);
|
|
100
|
+
} else {
|
|
101
|
+
const b = await res.json().catch(() => ({}));
|
|
102
|
+
alert(b.error || 'Failed');
|
|
103
|
+
btn.disabled = false;
|
|
104
|
+
}
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (err.message !== 'unauthorized') alert('Network error');
|
|
107
|
+
btn.disabled = false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function deleteUser(id, displayName, btn) {
|
|
112
|
+
if (!confirm(
|
|
113
|
+
`⚠ GDPR Erasure — permanently delete "${displayName}"?\n\n` +
|
|
114
|
+
'This will delete the account and ALL associated data:\n' +
|
|
115
|
+
'• Agent runs, steps, and messages\n• Memories and conversations\n• Integrations and platform connections\n• Artifacts and files on disk\n• All sessions\n\n' +
|
|
116
|
+
'This action CANNOT be undone.'
|
|
117
|
+
)) return;
|
|
118
|
+
btn.disabled = true;
|
|
119
|
+
btn.textContent = 'Deleting…';
|
|
120
|
+
try {
|
|
121
|
+
const res = await api(`/admin/api/users/${id}`, { method: 'DELETE' });
|
|
122
|
+
if (res.ok) {
|
|
123
|
+
btn.closest('tr')?.remove();
|
|
124
|
+
const cnt = document.getElementById('users-count');
|
|
125
|
+
if (cnt) {
|
|
126
|
+
const n = parseInt(cnt.textContent) - 1;
|
|
127
|
+
cnt.textContent = `${n} user${n === 1 ? '' : 's'}`;
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
const b = await res.json().catch(() => ({}));
|
|
131
|
+
alert(b.error || 'Failed to delete user');
|
|
132
|
+
btn.disabled = false;
|
|
133
|
+
btn.textContent = 'Delete';
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (err.message !== 'unauthorized') alert('Network error');
|
|
137
|
+
btn.disabled = false;
|
|
138
|
+
btn.textContent = 'Delete';
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function fmtDate(iso) {
|
|
143
|
+
if (!iso) return '—';
|
|
144
|
+
try {
|
|
145
|
+
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
|
146
|
+
} catch { return iso; }
|
|
147
|
+
}
|
package/server/db/database.js
CHANGED
|
@@ -1074,6 +1074,25 @@ try {
|
|
|
1074
1074
|
// Ignore cleanup failures for obsolete wearable tables.
|
|
1075
1075
|
}
|
|
1076
1076
|
|
|
1077
|
+
// Admin 2FA tables (singleton — no foreign key, no user_id)
|
|
1078
|
+
db.exec(`
|
|
1079
|
+
CREATE TABLE IF NOT EXISTS admin_two_factor (
|
|
1080
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
1081
|
+
enabled INTEGER NOT NULL DEFAULT 0,
|
|
1082
|
+
secret TEXT,
|
|
1083
|
+
pending_secret TEXT,
|
|
1084
|
+
enabled_at TEXT,
|
|
1085
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1086
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1087
|
+
);
|
|
1088
|
+
CREATE TABLE IF NOT EXISTS admin_recovery_codes (
|
|
1089
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1090
|
+
code_hash TEXT NOT NULL,
|
|
1091
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1092
|
+
used_at TEXT
|
|
1093
|
+
);
|
|
1094
|
+
`);
|
|
1095
|
+
|
|
1077
1096
|
// Migrations for existing databases
|
|
1078
1097
|
for (const col of [
|
|
1079
1098
|
"ALTER TABLE agent_runs ADD COLUMN agent_id TEXT",
|
package/server/http/static.js
CHANGED
|
@@ -7,6 +7,7 @@ const { DATA_DIR } = require('../../runtime/paths');
|
|
|
7
7
|
const { requireAuth } = require('../middleware/auth');
|
|
8
8
|
|
|
9
9
|
const FLUTTER_WEB_DIR = path.join(__dirname, '..', 'public');
|
|
10
|
+
const LANDING_DIR = path.join(__dirname, '..', '..', 'landing');
|
|
10
11
|
const BUILD_ID_PATH = path.join(FLUTTER_WEB_DIR, '.last_build_id');
|
|
11
12
|
|
|
12
13
|
function setNoStoreHeaders(res) {
|
|
@@ -66,11 +67,20 @@ function registerStaticRoutes(app) {
|
|
|
66
67
|
const adminRouter = require('../routes/admin');
|
|
67
68
|
app.use('/admin', adminRouter);
|
|
68
69
|
|
|
69
|
-
app
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
// Flutter app at /app
|
|
71
|
+
app.use(
|
|
72
|
+
'/app',
|
|
73
|
+
express.static(FLUTTER_WEB_DIR, {
|
|
74
|
+
index: false,
|
|
75
|
+
setHeaders: setFlutterStaticHeaders,
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
app.get(/^\/app(\/.*)?$/, serveFlutterApp);
|
|
79
|
+
|
|
80
|
+
// Landing page at /
|
|
81
|
+
app.use(express.static(LANDING_DIR));
|
|
82
|
+
app.get('/', (req, res) => res.sendFile(path.join(LANDING_DIR, 'index.html')));
|
|
83
|
+
app.get('/legal.html', (req, res) => res.sendFile(path.join(LANDING_DIR, 'legal.html')));
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
function serveFlutterApp(req, res) {
|
|
@@ -83,11 +93,15 @@ function serveFlutterApp(req, res) {
|
|
|
83
93
|
);
|
|
84
94
|
}
|
|
85
95
|
setNoStoreHeaders(res);
|
|
86
|
-
|
|
96
|
+
// Rewrite <base href="/"> to <base href="/app/"> so Flutter asset paths resolve correctly
|
|
97
|
+
const html = fs.readFileSync(entry, 'utf8').replace('<base href="/">', '<base href="/app/">');
|
|
98
|
+
res.set('Content-Type', 'text/html');
|
|
99
|
+
return res.send(html);
|
|
87
100
|
}
|
|
88
101
|
|
|
89
102
|
module.exports = {
|
|
90
103
|
FLUTTER_WEB_DIR,
|
|
104
|
+
LANDING_DIR,
|
|
91
105
|
registerStaticRoutes,
|
|
92
106
|
serveFlutterApp
|
|
93
107
|
};
|
|
@@ -1,7 +1,44 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Constant-time string comparison — prevents timing attacks on API key checks.
|
|
7
|
+
*/
|
|
8
|
+
function safeEqual(a, b) {
|
|
9
|
+
if (!a || !b) return false;
|
|
10
|
+
const bufA = Buffer.from(String(a));
|
|
11
|
+
const bufB = Buffer.from(String(b));
|
|
12
|
+
if (bufA.length !== bufB.length) {
|
|
13
|
+
// Lengths differ — do a dummy compare anyway to keep timing consistent
|
|
14
|
+
crypto.timingSafeEqual(bufA, Buffer.alloc(bufA.length));
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Allows admin access via either:
|
|
22
|
+
* 1. Browser session (`req.session.isAdmin === true`)
|
|
23
|
+
* 2. API key (`Authorization: Bearer <ADMIN_API_KEY>`)
|
|
24
|
+
*
|
|
25
|
+
* When ADMIN_API_KEY is absent or empty, Bearer auth is disabled entirely.
|
|
26
|
+
*/
|
|
3
27
|
function requireAdminAuth(req, res, next) {
|
|
28
|
+
// 1. Session-based auth (admin web dashboard)
|
|
4
29
|
if (req.session?.isAdmin === true) return next();
|
|
30
|
+
|
|
31
|
+
// 2. API key auth (programmatic / scripts)
|
|
32
|
+
const configuredKey = process.env.ADMIN_API_KEY;
|
|
33
|
+
if (configuredKey) {
|
|
34
|
+
const authHeader = String(req.headers.authorization || '');
|
|
35
|
+
if (authHeader.startsWith('Bearer ')) {
|
|
36
|
+
const provided = authHeader.slice(7).trim();
|
|
37
|
+
if (safeEqual(provided, configuredKey)) return next();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 3. Reject
|
|
5
42
|
if (req.path.startsWith('/api/')) {
|
|
6
43
|
return res.status(401).json({ error: 'Admin authentication required' });
|
|
7
44
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
1a8195f0cf8ff4ea724469829b5dfbbb
|