neoagent 2.4.1-beta.44 → 2.4.1-beta.46
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/docs/configuration.md +6 -0
- package/flutter_app/lib/main_app_shell.dart +0 -2
- package/flutter_app/lib/main_chat.dart +1 -0
- package/flutter_app/lib/main_controller.dart +14 -2
- package/flutter_app/lib/main_models.dart +36 -0
- package/flutter_app/lib/main_navigation.dart +1 -9
- package/flutter_app/lib/main_settings.dart +4 -265
- package/flutter_app/lib/main_shared.dart +138 -3
- package/flutter_app/lib/main_unified.dart +5 -100
- package/lib/manager.js +32 -0
- package/package.json +1 -1
- package/runtime/paths.js +3 -1
- package/server/admin/access.js +198 -0
- package/server/admin/admin.css +268 -0
- package/server/admin/admin.js +348 -0
- package/server/admin/analytics.js +128 -0
- package/server/admin/index.html +993 -0
- package/server/admin/login.html +251 -0
- package/server/admin/logo.svg +43 -0
- 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 +4 -1
- package/server/index.js +1 -1
- package/server/middleware/adminAuth.js +48 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +70538 -71095
- package/server/routes/admin.js +618 -0
- package/server/routes/settings.js +10 -2
- package/server/services/account/admin_two_factor.js +132 -0
- package/server/services/widgets/service.js +17 -1
- package/server/utils/logger.js +44 -6
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>NeoAgent Admin</title>
|
|
7
|
+
<link rel="stylesheet" href="/admin/admin.css">
|
|
8
|
+
<style>
|
|
9
|
+
body {
|
|
10
|
+
display: flex;
|
|
11
|
+
align-items: center;
|
|
12
|
+
justify-content: center;
|
|
13
|
+
min-height: 100vh;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.login-wrap {
|
|
17
|
+
width: 100%;
|
|
18
|
+
max-width: 380px;
|
|
19
|
+
padding: 20px;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.login-card {
|
|
23
|
+
background: var(--bg-card);
|
|
24
|
+
border: 1px solid var(--border);
|
|
25
|
+
border-radius: var(--radius);
|
|
26
|
+
padding: 36px 32px 32px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.brand {
|
|
30
|
+
margin-bottom: 32px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.brand-mark {
|
|
34
|
+
width: 32px;
|
|
35
|
+
height: 32px;
|
|
36
|
+
border-radius: 9px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.brand-mark img {
|
|
40
|
+
width: 32px;
|
|
41
|
+
height: 32px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.login-heading {
|
|
45
|
+
font-size: 20px;
|
|
46
|
+
font-weight: 700;
|
|
47
|
+
color: var(--text);
|
|
48
|
+
letter-spacing: -0.3px;
|
|
49
|
+
margin-bottom: 4px;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.login-sub {
|
|
53
|
+
font-size: 13px;
|
|
54
|
+
color: var(--text-muted);
|
|
55
|
+
margin-bottom: 28px;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.login-sub code {
|
|
59
|
+
font-family: var(--font-mono);
|
|
60
|
+
font-size: 12px;
|
|
61
|
+
background: var(--bg-secondary);
|
|
62
|
+
padding: 1px 5px;
|
|
63
|
+
border-radius: 4px;
|
|
64
|
+
color: var(--accent);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.submit-btn {
|
|
68
|
+
width: 100%;
|
|
69
|
+
padding: 11px;
|
|
70
|
+
font-size: 14px;
|
|
71
|
+
margin-top: 4px;
|
|
72
|
+
}
|
|
73
|
+
</style>
|
|
74
|
+
</head>
|
|
75
|
+
<body>
|
|
76
|
+
<div class="login-wrap">
|
|
77
|
+
<div class="login-card">
|
|
78
|
+
<div class="brand">
|
|
79
|
+
<div class="brand-mark">
|
|
80
|
+
<img src="/admin/logo.svg" width="32" height="32" alt="">
|
|
81
|
+
</div>
|
|
82
|
+
<div>
|
|
83
|
+
<div class="brand-name">NeoAgent</div>
|
|
84
|
+
<div class="brand-sub">Admin</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<!-- Step 1: credentials -->
|
|
89
|
+
<div id="step-credentials">
|
|
90
|
+
<h1 class="login-heading">Sign in</h1>
|
|
91
|
+
<p class="login-sub">
|
|
92
|
+
Credentials are in your <code>.env</code> or via <code>neoagent admin</code>
|
|
93
|
+
</p>
|
|
94
|
+
|
|
95
|
+
<div class="alert alert-error" id="error" role="alert"></div>
|
|
96
|
+
|
|
97
|
+
<form id="form" novalidate>
|
|
98
|
+
<div class="field">
|
|
99
|
+
<label for="username">Username</label>
|
|
100
|
+
<input
|
|
101
|
+
id="username"
|
|
102
|
+
type="text"
|
|
103
|
+
autocomplete="username"
|
|
104
|
+
autofocus
|
|
105
|
+
required
|
|
106
|
+
spellcheck="false"
|
|
107
|
+
autocorrect="off"
|
|
108
|
+
autocapitalize="none"
|
|
109
|
+
>
|
|
110
|
+
</div>
|
|
111
|
+
<div class="field">
|
|
112
|
+
<label for="password">Password</label>
|
|
113
|
+
<input
|
|
114
|
+
id="password"
|
|
115
|
+
type="password"
|
|
116
|
+
autocomplete="current-password"
|
|
117
|
+
required
|
|
118
|
+
>
|
|
119
|
+
</div>
|
|
120
|
+
<button type="submit" class="btn btn-primary submit-btn" id="submit-btn">
|
|
121
|
+
Sign in
|
|
122
|
+
</button>
|
|
123
|
+
</form>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<!-- Step 2: 2FA code (shown after password is accepted) -->
|
|
127
|
+
<div id="step-totp" style="display:none;">
|
|
128
|
+
<h1 class="login-heading">Two-factor authentication</h1>
|
|
129
|
+
<p class="login-sub">Enter the 6-digit code from your authenticator app, or a recovery code.</p>
|
|
130
|
+
|
|
131
|
+
<div class="alert alert-error" id="totp-error" role="alert"></div>
|
|
132
|
+
|
|
133
|
+
<form id="totp-form" novalidate>
|
|
134
|
+
<div class="field">
|
|
135
|
+
<label for="totp-code">Authenticator code</label>
|
|
136
|
+
<input
|
|
137
|
+
id="totp-code"
|
|
138
|
+
type="text"
|
|
139
|
+
inputmode="numeric"
|
|
140
|
+
autocomplete="one-time-code"
|
|
141
|
+
maxlength="11"
|
|
142
|
+
placeholder="123 456"
|
|
143
|
+
spellcheck="false"
|
|
144
|
+
autocorrect="off"
|
|
145
|
+
autocapitalize="none"
|
|
146
|
+
required
|
|
147
|
+
>
|
|
148
|
+
</div>
|
|
149
|
+
<button type="submit" class="btn btn-primary submit-btn" id="totp-btn">
|
|
150
|
+
Verify
|
|
151
|
+
</button>
|
|
152
|
+
</form>
|
|
153
|
+
<div style="margin-top:14px;text-align:center;">
|
|
154
|
+
<button type="button" class="btn btn-ghost" style="font-size:12px;padding:4px 10px;"
|
|
155
|
+
onclick="backToCredentials()">← Back</button>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<script>
|
|
162
|
+
const form = document.getElementById('form');
|
|
163
|
+
const submitBtn = document.getElementById('submit-btn');
|
|
164
|
+
const errorEl = document.getElementById('error');
|
|
165
|
+
const totpForm = document.getElementById('totp-form');
|
|
166
|
+
const totpBtn = document.getElementById('totp-btn');
|
|
167
|
+
const totpError = document.getElementById('totp-error');
|
|
168
|
+
|
|
169
|
+
function showStep(step) {
|
|
170
|
+
document.getElementById('step-credentials').style.display = step === 1 ? '' : 'none';
|
|
171
|
+
document.getElementById('step-totp').style.display = step === 2 ? '' : 'none';
|
|
172
|
+
if (step === 2) setTimeout(() => document.getElementById('totp-code')?.focus(), 50);
|
|
173
|
+
if (step === 1) setTimeout(() => document.getElementById('username')?.focus(), 50);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function backToCredentials() { showStep(1); }
|
|
177
|
+
|
|
178
|
+
form.addEventListener('submit', async (e) => {
|
|
179
|
+
e.preventDefault();
|
|
180
|
+
errorEl.classList.remove('visible');
|
|
181
|
+
submitBtn.disabled = true;
|
|
182
|
+
submitBtn.textContent = 'Signing in…';
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const res = await fetch('/admin/api/login', {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
headers: { 'Content-Type': 'application/json' },
|
|
188
|
+
body: JSON.stringify({
|
|
189
|
+
username: document.getElementById('username').value.trim(),
|
|
190
|
+
password: document.getElementById('password').value,
|
|
191
|
+
}),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const body = await res.json().catch(() => ({}));
|
|
195
|
+
|
|
196
|
+
if (res.ok || body.requiresTwoFactor) {
|
|
197
|
+
if (body.requiresTwoFactor) {
|
|
198
|
+
// Password accepted — go to 2FA step
|
|
199
|
+
showStep(2);
|
|
200
|
+
} else {
|
|
201
|
+
window.location.replace('/admin');
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
errorEl.textContent = body.error || 'Invalid credentials';
|
|
207
|
+
errorEl.classList.add('visible');
|
|
208
|
+
} catch {
|
|
209
|
+
errorEl.textContent = 'Network error — is the server running?';
|
|
210
|
+
errorEl.classList.add('visible');
|
|
211
|
+
} finally {
|
|
212
|
+
submitBtn.disabled = false;
|
|
213
|
+
submitBtn.textContent = 'Sign in';
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
totpForm.addEventListener('submit', async (e) => {
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
totpError.classList.remove('visible');
|
|
220
|
+
totpBtn.disabled = true;
|
|
221
|
+
totpBtn.textContent = 'Verifying…';
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const code = document.getElementById('totp-code').value.trim();
|
|
225
|
+
const res = await fetch('/admin/api/2fa/verify', {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: { 'Content-Type': 'application/json' },
|
|
228
|
+
body: JSON.stringify({ code }),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
if (res.ok) {
|
|
232
|
+
window.location.replace('/admin');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const body = await res.json().catch(() => ({}));
|
|
237
|
+
totpError.textContent = body.error || 'Invalid code';
|
|
238
|
+
totpError.classList.add('visible');
|
|
239
|
+
document.getElementById('totp-code').value = '';
|
|
240
|
+
document.getElementById('totp-code').focus();
|
|
241
|
+
} catch {
|
|
242
|
+
totpError.textContent = 'Network error';
|
|
243
|
+
totpError.classList.add('visible');
|
|
244
|
+
} finally {
|
|
245
|
+
totpBtn.disabled = false;
|
|
246
|
+
totpBtn.textContent = 'Verify';
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
</script>
|
|
250
|
+
</body>
|
|
251
|
+
</html>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 100 100">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="tile" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
4
|
+
<stop offset="0%" stop-color="#705331"></stop>
|
|
5
|
+
<stop offset="100%" stop-color="#22564c"></stop>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<radialGradient id="shine" cx="26%" cy="14%" r="72%">
|
|
8
|
+
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.22"></stop>
|
|
9
|
+
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"></stop>
|
|
10
|
+
</radialGradient>
|
|
11
|
+
<radialGradient id="core" cx="38%" cy="32%" r="75%">
|
|
12
|
+
<stop offset="0%" stop-color="#fffdf6"></stop>
|
|
13
|
+
<stop offset="48%" stop-color="#eee3cc"></stop>
|
|
14
|
+
<stop offset="100%" stop-color="#b5a888"></stop>
|
|
15
|
+
</radialGradient>
|
|
16
|
+
<radialGradient id="node" cx="36%" cy="30%" r="80%">
|
|
17
|
+
<stop offset="0%" stop-color="#f8dca8"></stop>
|
|
18
|
+
<stop offset="52%" stop-color="#d3a85f"></stop>
|
|
19
|
+
<stop offset="100%" stop-color="#9b6f2f"></stop>
|
|
20
|
+
</radialGradient>
|
|
21
|
+
<linearGradient id="tube" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
22
|
+
<stop offset="0%" stop-color="#fffaf0"></stop>
|
|
23
|
+
<stop offset="52%" stop-color="#efe7d6"></stop>
|
|
24
|
+
<stop offset="100%" stop-color="#cabf9f"></stop>
|
|
25
|
+
</linearGradient>
|
|
26
|
+
<filter id="sh" x="-30%" y="-30%" width="160%" height="160%">
|
|
27
|
+
<feDropShadow dx="0" dy="1.1" stdDeviation="1.1" flood-color="#000000" flood-opacity="0.45"></feDropShadow>
|
|
28
|
+
</filter>
|
|
29
|
+
<clipPath id="tc"><rect x="0" y="0" width="100" height="100" rx="22.5"></rect></clipPath>
|
|
30
|
+
</defs>
|
|
31
|
+
<g clip-path="url(#tc)">
|
|
32
|
+
<rect x="0" y="0" width="100" height="100" fill="url(#tile)"></rect>
|
|
33
|
+
<rect x="0" y="0" width="100" height="100" fill="url(#shine)"></rect>
|
|
34
|
+
<g fill="none" stroke="url(#tube)" stroke-linecap="round" filter="url(#sh)">
|
|
35
|
+
<circle cx="50" cy="50" r="17" stroke-width="5" stroke-dasharray="78 29" transform="rotate(-35 50 50)" stroke-opacity="0.92"></circle>
|
|
36
|
+
<circle cx="50" cy="50" r="30" stroke-width="5" stroke-dasharray="150 39" transform="rotate(70 50 50)" stroke-opacity="0.8"></circle>
|
|
37
|
+
</g>
|
|
38
|
+
<circle cx="50" cy="50" r="7" fill="url(#core)"></circle>
|
|
39
|
+
<circle cx="50" cy="20" r="5.6" fill="url(#node)"></circle>
|
|
40
|
+
<circle cx="48" cy="18.4" r="1.7" fill="#fff7e6" fill-opacity="0.8"></circle>
|
|
41
|
+
</g>
|
|
42
|
+
<rect x="0.6" y="0.6" width="98.8" height="98.8" rx="22" fill="none" stroke="rgba(255,255,255,0.16)" stroke-width="1.2"></rect>
|
|
43
|
+
</svg>
|
|
@@ -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
|
@@ -63,11 +63,14 @@ function registerStaticRoutes(app) {
|
|
|
63
63
|
});
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
const adminRouter = require('../routes/admin');
|
|
67
|
+
app.use('/admin', adminRouter);
|
|
68
|
+
|
|
66
69
|
app.use(express.static(FLUTTER_WEB_DIR, {
|
|
67
70
|
index: false,
|
|
68
71
|
setHeaders: setFlutterStaticHeaders,
|
|
69
72
|
}));
|
|
70
|
-
app.get(/^\/(?!api|screenshots|telnyx-audio).*/, serveFlutterApp);
|
|
73
|
+
app.get(/^\/(?!api|admin|screenshots|telnyx-audio).*/, serveFlutterApp);
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
function serveFlutterApp(req, res) {
|
package/server/index.js
CHANGED
|
@@ -103,7 +103,7 @@ const sessionMiddleware = createSessionMiddleware({
|
|
|
103
103
|
});
|
|
104
104
|
const activeSockets = new Set();
|
|
105
105
|
|
|
106
|
-
setupConsoleInterceptor(io);
|
|
106
|
+
app.locals.logHistory = setupConsoleInterceptor(io);
|
|
107
107
|
applyHttpMiddleware(app, {
|
|
108
108
|
secureCookies: SECURE_COOKIES,
|
|
109
109
|
trustProxy: TRUST_PROXY,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
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
|
+
*/
|
|
27
|
+
function requireAdminAuth(req, res, next) {
|
|
28
|
+
// 1. Session-based auth (admin web dashboard)
|
|
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
|
|
42
|
+
if (req.path.startsWith('/api/')) {
|
|
43
|
+
return res.status(401).json({ error: 'Admin authentication required' });
|
|
44
|
+
}
|
|
45
|
+
return res.redirect('/admin/login');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { requireAdminAuth };
|