scdb-web-panel 1.0.3
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/package.json +5 -0
- package/public/app.js +587 -0
- package/public/index.html +190 -0
- package/public/style.css +591 -0
package/package.json
ADDED
package/public/app.js
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
const API = '';
|
|
2
|
+
function get(url, opts = {}) {
|
|
3
|
+
return fetch(API + url, { credentials: 'include', ...opts });
|
|
4
|
+
}
|
|
5
|
+
function post(url, body, opts = {}) {
|
|
6
|
+
return fetch(API + url, {
|
|
7
|
+
method: 'POST',
|
|
8
|
+
credentials: 'include',
|
|
9
|
+
headers: { 'Content-Type': 'application/json' },
|
|
10
|
+
body: JSON.stringify(body),
|
|
11
|
+
...opts,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function showPanel(id) {
|
|
16
|
+
document.querySelectorAll('.panel').forEach((p) => p.classList.remove('active'));
|
|
17
|
+
document.querySelectorAll('.navbar .nav-item').forEach((b) => b.classList.remove('active'));
|
|
18
|
+
const panel = document.getElementById('panel-' + id);
|
|
19
|
+
const tab = document.querySelector(`.navbar [data-tab="${id}"]`);
|
|
20
|
+
if (panel) panel.classList.add('active');
|
|
21
|
+
if (tab) tab.classList.add('active');
|
|
22
|
+
if (id === 'tables') loadTables();
|
|
23
|
+
if (id === 'add-table') loadAddTable();
|
|
24
|
+
if (id === 'audit') {
|
|
25
|
+
loadApiKeys();
|
|
26
|
+
if (document.getElementById('audit-log-section')?.style.display !== 'none') loadAudit();
|
|
27
|
+
}
|
|
28
|
+
if (id === 'users') loadUsers();
|
|
29
|
+
if (id === 'profile') loadProfile();
|
|
30
|
+
if (id === 'docs') loadDocs();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function showLogin() {
|
|
34
|
+
document.getElementById('app').innerHTML = `
|
|
35
|
+
<div class="login-box">
|
|
36
|
+
<h1>Secure DB</h1>
|
|
37
|
+
<form id="login-form">
|
|
38
|
+
<input type="text" name="username" placeholder="Username" required autocomplete="username" />
|
|
39
|
+
<input type="password" name="password" placeholder="Password" required autocomplete="current-password" />
|
|
40
|
+
<button type="submit" class="btn primary">Log in</button>
|
|
41
|
+
</form>
|
|
42
|
+
<div id="login-err" style="color:#f87171;font-size:0.9rem;margin-top:0.5rem;"></div>
|
|
43
|
+
</div>
|
|
44
|
+
<div id="setup-container"></div>
|
|
45
|
+
`;
|
|
46
|
+
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
const errEl = document.getElementById('login-err');
|
|
49
|
+
errEl.textContent = '';
|
|
50
|
+
const form = e.target;
|
|
51
|
+
const res = await post('/login', {
|
|
52
|
+
username: form.username.value,
|
|
53
|
+
password: form.password.value,
|
|
54
|
+
});
|
|
55
|
+
if (res.ok) {
|
|
56
|
+
window.location.reload();
|
|
57
|
+
} else {
|
|
58
|
+
const d = await res.json().catch(() => ({}));
|
|
59
|
+
errEl.textContent = d.error || 'Login failed';
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
fetch(API + '/health')
|
|
63
|
+
.then((r) => r.json())
|
|
64
|
+
.then((data) => {
|
|
65
|
+
if (data.ok) checkSetup();
|
|
66
|
+
})
|
|
67
|
+
.catch(() => { });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function checkSetup() {
|
|
71
|
+
const meRes = await get('/me').catch(() => null);
|
|
72
|
+
if (meRes && meRes.ok) return;
|
|
73
|
+
const setupRes = await get('/is-setup').catch(() => null);
|
|
74
|
+
if (!setupRes || !setupRes.ok) return;
|
|
75
|
+
const data = await setupRes.json().catch(() => ({}));
|
|
76
|
+
if (data.isSetup) return;
|
|
77
|
+
showSetupForm();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function showSetupForm() {
|
|
81
|
+
const container = document.getElementById('setup-container');
|
|
82
|
+
if (!container) return;
|
|
83
|
+
container.innerHTML = `
|
|
84
|
+
<div class="setup-box">
|
|
85
|
+
<h2>First-time setup</h2>
|
|
86
|
+
<p class="hint">Create the first admin user.</p>
|
|
87
|
+
<form id="setup-form">
|
|
88
|
+
<input type="text" name="username" placeholder="Username" required minlength="2" />
|
|
89
|
+
<input type="password" name="password" placeholder="Password" required />
|
|
90
|
+
<button type="submit" class="btn primary">Create admin</button>
|
|
91
|
+
</form>
|
|
92
|
+
<div id="setup-err" style="color:#f87171;font-size:0.9rem;margin-top:0.5rem;"></div>
|
|
93
|
+
</div>
|
|
94
|
+
`;
|
|
95
|
+
document.getElementById('setup-form').addEventListener('submit', async (e) => {
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
const errEl = document.getElementById('setup-err');
|
|
98
|
+
errEl.textContent = '';
|
|
99
|
+
const form = e.target;
|
|
100
|
+
const res = await post('/setup', {
|
|
101
|
+
username: form.username.value,
|
|
102
|
+
password: form.password.value,
|
|
103
|
+
});
|
|
104
|
+
if (res.ok) {
|
|
105
|
+
container.innerHTML = '<p class="hint">Admin created. Log in above.</p>';
|
|
106
|
+
} else {
|
|
107
|
+
const d = await res.json().catch(() => ({}));
|
|
108
|
+
errEl.textContent = d.error || 'Setup failed';
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function loadMe() {
|
|
114
|
+
const res = await get('/me');
|
|
115
|
+
if (!res.ok) {
|
|
116
|
+
showLogin();
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
const me = await res.json();
|
|
120
|
+
const el = document.getElementById('user-info');
|
|
121
|
+
if (el) el.textContent = `${me.name || me.principalType} (${me.role})`;
|
|
122
|
+
const usersNav = document.getElementById('nav-users');
|
|
123
|
+
const docsAdminBtn = document.getElementById('btn-docs-admin');
|
|
124
|
+
const auditLogSection = document.getElementById('audit-log-section');
|
|
125
|
+
const apiKeyRoleAdmin = document.getElementById('api-key-role-admin');
|
|
126
|
+
if (usersNav) usersNav.style.display = me.role === 'admin' ? '' : 'none';
|
|
127
|
+
if (docsAdminBtn) docsAdminBtn.style.display = me.role === 'admin' ? '' : 'none';
|
|
128
|
+
if (auditLogSection) auditLogSection.style.display = me.role === 'admin' ? '' : 'none';
|
|
129
|
+
if (apiKeyRoleAdmin) apiKeyRoleAdmin.style.display = me.role === 'admin' ? '' : 'none';
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function loadTables() {
|
|
134
|
+
const res = await get('/tables');
|
|
135
|
+
if (!res.ok) return;
|
|
136
|
+
const { tables } = await res.json();
|
|
137
|
+
const ul = document.getElementById('table-list');
|
|
138
|
+
ul.innerHTML = tables.length ? tables.map((t) => `<li data-table="${t}">${t}</li>`).join('') : '<li class="hint">No tables</li>';
|
|
139
|
+
ul.querySelectorAll('li[data-table]').forEach((li) => {
|
|
140
|
+
li.addEventListener('click', () => loadTableDetail(li.dataset.table));
|
|
141
|
+
});
|
|
142
|
+
document.getElementById('table-detail').innerHTML = '';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function loadAddTable() {
|
|
146
|
+
const status = document.getElementById('create-table-status');
|
|
147
|
+
const form = document.getElementById('create-table-form');
|
|
148
|
+
if (status) {
|
|
149
|
+
status.textContent = '';
|
|
150
|
+
status.className = 'status';
|
|
151
|
+
}
|
|
152
|
+
if (form) form.reset();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function createTableFromForm(e) {
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
const form = e.target;
|
|
158
|
+
const statusEl = document.getElementById('create-table-status');
|
|
159
|
+
if (statusEl) { statusEl.textContent = ''; statusEl.className = 'status'; }
|
|
160
|
+
const name = form.name.value.trim();
|
|
161
|
+
const colsRaw = form.columns.value.trim();
|
|
162
|
+
if (!name || !colsRaw) {
|
|
163
|
+
if (statusEl) { statusEl.textContent = 'Name and columns are required.'; statusEl.className = 'status error'; }
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// Split columns by comma, keep raw SQL fragments
|
|
167
|
+
const columns = colsRaw.split(',').map(s => s.trim()).filter(Boolean);
|
|
168
|
+
const res = await post('/tables', { name, columns });
|
|
169
|
+
const data = await res.json().catch(() => ({}));
|
|
170
|
+
if (res.ok) {
|
|
171
|
+
if (statusEl) { statusEl.textContent = 'Table created.'; statusEl.className = 'status success'; }
|
|
172
|
+
// go back to tables and refresh
|
|
173
|
+
setTimeout(() => showPanel('tables'), 700);
|
|
174
|
+
} else {
|
|
175
|
+
if (statusEl) { statusEl.textContent = data.error || 'Failed to create table'; statusEl.className = 'status error'; }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function loadTableDetail(name) {
|
|
180
|
+
const res = await get('/tables/' + encodeURIComponent(name));
|
|
181
|
+
if (!res.ok) return;
|
|
182
|
+
const { columns } = await res.json();
|
|
183
|
+
const qRes = await post('/query', { sql: `SELECT * FROM "${name.replace(/"/g, '""')}" LIMIT 100`, params: [] });
|
|
184
|
+
if (!qRes.ok) {
|
|
185
|
+
document.getElementById('table-detail').innerHTML = '<p class="hint">Error loading data</p>';
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const { rows } = await qRes.json();
|
|
189
|
+
const cols = columns.map((c) => c.name);
|
|
190
|
+
let html = '<table><thead><tr>' + cols.map((c) => `<th>${escapeHtml(c)}</th>`).join('') + '</tr></thead><tbody>';
|
|
191
|
+
rows.forEach((row) => {
|
|
192
|
+
html += '<tr>' + cols.map((c) => `<td>${escapeHtml(String(row[c] ?? ''))}</td>`).join('') + '</tr>';
|
|
193
|
+
});
|
|
194
|
+
html += '</tbody></table>';
|
|
195
|
+
document.getElementById('table-detail').innerHTML = html;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function escapeHtml(s) {
|
|
199
|
+
const div = document.createElement('div');
|
|
200
|
+
div.textContent = s;
|
|
201
|
+
return div.innerHTML;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function runQuery() {
|
|
205
|
+
const sql = document.getElementById('sql-input').value.trim();
|
|
206
|
+
const paramsEl = document.getElementById('sql-params').value.trim();
|
|
207
|
+
let params = [];
|
|
208
|
+
if (paramsEl) {
|
|
209
|
+
try {
|
|
210
|
+
params = JSON.parse(paramsEl);
|
|
211
|
+
if (!Array.isArray(params)) params = [params];
|
|
212
|
+
} catch {
|
|
213
|
+
document.getElementById('query-result').textContent = 'Parameters must be a JSON array';
|
|
214
|
+
document.getElementById('query-result').className = 'error';
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const isWrite = /^\s*(INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|REPLACE)\s/i.test(sql);
|
|
219
|
+
const endpoint = isWrite ? '/execute' : '/query';
|
|
220
|
+
const res = await post(endpoint, { sql, params });
|
|
221
|
+
const resultEl = document.getElementById('query-result');
|
|
222
|
+
const data = await res.json().catch(() => ({}));
|
|
223
|
+
if (res.ok) {
|
|
224
|
+
resultEl.className = '';
|
|
225
|
+
resultEl.textContent = isWrite ? JSON.stringify(data, null, 2) : JSON.stringify(data.rows, null, 2);
|
|
226
|
+
} else {
|
|
227
|
+
resultEl.className = 'error';
|
|
228
|
+
resultEl.textContent = data.error || res.statusText;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function loadApiKeys() {
|
|
233
|
+
const tableBody = document.querySelector('#api-keys-table tbody');
|
|
234
|
+
const statusEl = document.getElementById('api-keys-status');
|
|
235
|
+
if (!tableBody) return;
|
|
236
|
+
if (statusEl) {
|
|
237
|
+
statusEl.textContent = 'Loading...';
|
|
238
|
+
statusEl.className = 'status';
|
|
239
|
+
}
|
|
240
|
+
const res = await get('/api-keys');
|
|
241
|
+
if (statusEl) statusEl.textContent = '';
|
|
242
|
+
if (!res.ok) {
|
|
243
|
+
const d = await res.json().catch(() => ({}));
|
|
244
|
+
if (statusEl) {
|
|
245
|
+
statusEl.textContent = d.error || 'Failed to load API keys';
|
|
246
|
+
statusEl.className = 'status error';
|
|
247
|
+
}
|
|
248
|
+
tableBody.innerHTML = '';
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const data = await res.json();
|
|
252
|
+
const keys = data.keys || [];
|
|
253
|
+
if (!keys.length) {
|
|
254
|
+
tableBody.innerHTML = '<tr><td colspan="5" class="hint">No API keys</td></tr>';
|
|
255
|
+
} else {
|
|
256
|
+
tableBody.innerHTML = keys
|
|
257
|
+
.map(
|
|
258
|
+
(k) => `
|
|
259
|
+
<tr data-key-id="${k.id}">
|
|
260
|
+
<td>${k.id}</td>
|
|
261
|
+
<td>${escapeHtml(String(k.name ?? ''))}</td>
|
|
262
|
+
<td>${escapeHtml(String(k.role ?? ''))}</td>
|
|
263
|
+
<td>${escapeHtml(String(k.created_at ?? ''))}</td>
|
|
264
|
+
<td><button type="button" class="btn secondary btn-api-key-delete">Revoke</button></td>
|
|
265
|
+
</tr>`
|
|
266
|
+
)
|
|
267
|
+
.join('');
|
|
268
|
+
tableBody.querySelectorAll('.btn-api-key-delete').forEach((btn) => {
|
|
269
|
+
btn.addEventListener('click', async () => {
|
|
270
|
+
const row = btn.closest('tr');
|
|
271
|
+
const id = row?.dataset.keyId;
|
|
272
|
+
if (!id || !window.confirm('Revoke this API key? It will stop working immediately.')) return;
|
|
273
|
+
const delRes = await fetch(API + '/api-keys/' + encodeURIComponent(id), {
|
|
274
|
+
method: 'DELETE',
|
|
275
|
+
credentials: 'include',
|
|
276
|
+
});
|
|
277
|
+
const d = await delRes.json().catch(() => ({}));
|
|
278
|
+
if (delRes.ok) {
|
|
279
|
+
row.remove();
|
|
280
|
+
const statusEl = document.getElementById('api-keys-status');
|
|
281
|
+
if (statusEl) {
|
|
282
|
+
statusEl.textContent = 'Key revoked.';
|
|
283
|
+
statusEl.className = 'status success';
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
const statusEl = document.getElementById('api-keys-status');
|
|
287
|
+
if (statusEl) {
|
|
288
|
+
statusEl.textContent = d.error || 'Failed to revoke';
|
|
289
|
+
statusEl.className = 'status error';
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function loadAudit() {
|
|
298
|
+
const res = await get('/audit');
|
|
299
|
+
if (!res.ok) {
|
|
300
|
+
document.getElementById('audit-log').textContent = res.status === 403 ? 'Admin only' : 'Failed to load';
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const { rows } = await res.json();
|
|
304
|
+
document.getElementById('audit-log').textContent = JSON.stringify(rows, null, 2);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function loadUsers() {
|
|
308
|
+
const tableBody = document.querySelector('#users-table tbody');
|
|
309
|
+
const statusEl = document.getElementById('users-status');
|
|
310
|
+
if (!tableBody || !statusEl) return;
|
|
311
|
+
statusEl.textContent = 'Loading users...';
|
|
312
|
+
statusEl.className = 'status';
|
|
313
|
+
const res = await get('/users');
|
|
314
|
+
if (!res.ok) {
|
|
315
|
+
const d = await res.json().catch(() => ({}));
|
|
316
|
+
statusEl.textContent = d.error || 'Failed to load users';
|
|
317
|
+
statusEl.className = 'status error';
|
|
318
|
+
tableBody.innerHTML = '';
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const data = await res.json();
|
|
322
|
+
const users = data.users || [];
|
|
323
|
+
if (!users.length) {
|
|
324
|
+
tableBody.innerHTML = '<tr><td colspan="5" class="hint">No users found</td></tr>';
|
|
325
|
+
} else {
|
|
326
|
+
tableBody.innerHTML = users
|
|
327
|
+
.map(
|
|
328
|
+
(u) => `
|
|
329
|
+
<tr data-user-id="${u.id}">
|
|
330
|
+
<td>${u.id}</td>
|
|
331
|
+
<td>${escapeHtml(String(u.username ?? ''))}</td>
|
|
332
|
+
<td>
|
|
333
|
+
<select class="user-role-select">
|
|
334
|
+
<option value="admin"${u.role === 'admin' ? ' selected' : ''}>admin</option>
|
|
335
|
+
<option value="readwrite"${u.role === 'readwrite' ? ' selected' : ''}>readwrite</option>
|
|
336
|
+
<option value="readonly"${u.role === 'readonly' ? ' selected' : ''}>readonly</option>
|
|
337
|
+
</select>
|
|
338
|
+
</td>
|
|
339
|
+
<td>${escapeHtml(String(u.created_at ?? ''))}</td>
|
|
340
|
+
<td>
|
|
341
|
+
<button type="button" class="btn secondary btn-user-save">Save</button>
|
|
342
|
+
<button type="button" class="btn secondary btn-user-password">Reset password</button>
|
|
343
|
+
<button type="button" class="btn secondary btn-user-delete">Delete</button>
|
|
344
|
+
</td>
|
|
345
|
+
</tr>`
|
|
346
|
+
)
|
|
347
|
+
.join('');
|
|
348
|
+
}
|
|
349
|
+
statusEl.textContent = '';
|
|
350
|
+
statusEl.className = 'status';
|
|
351
|
+
|
|
352
|
+
tableBody.querySelectorAll('.btn-user-save').forEach((btn) => {
|
|
353
|
+
btn.addEventListener('click', async () => {
|
|
354
|
+
const row = btn.closest('tr');
|
|
355
|
+
const id = row?.dataset.userId;
|
|
356
|
+
const select = row?.querySelector('.user-role-select');
|
|
357
|
+
if (!id || !select) return;
|
|
358
|
+
const newRole = select.value;
|
|
359
|
+
if (!window.confirm(`Change role of user ${id} to "${newRole}"?`)) return;
|
|
360
|
+
const resUpdate = await fetch(API + '/users/' + encodeURIComponent(id), {
|
|
361
|
+
method: 'PATCH',
|
|
362
|
+
credentials: 'include',
|
|
363
|
+
headers: { 'Content-Type': 'application/json' },
|
|
364
|
+
body: JSON.stringify({ role: newRole }),
|
|
365
|
+
});
|
|
366
|
+
const d = await resUpdate.json().catch(() => ({}));
|
|
367
|
+
if (resUpdate.ok) {
|
|
368
|
+
statusEl.textContent = 'User updated.';
|
|
369
|
+
statusEl.className = 'status success';
|
|
370
|
+
} else {
|
|
371
|
+
statusEl.textContent = d.error || 'Failed to update user';
|
|
372
|
+
statusEl.className = 'status error';
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
tableBody.querySelectorAll('.btn-user-password').forEach((btn) => {
|
|
378
|
+
btn.addEventListener('click', async () => {
|
|
379
|
+
const row = btn.closest('tr');
|
|
380
|
+
const id = row?.dataset.userId;
|
|
381
|
+
if (!id) return;
|
|
382
|
+
const newPassword = window.prompt('Enter a new temporary password for this user:');
|
|
383
|
+
if (!newPassword) return;
|
|
384
|
+
if (newPassword.length < 6) {
|
|
385
|
+
window.alert('Password must be at least 6 characters.');
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const resUpdate = await fetch(API + '/users/' + encodeURIComponent(id), {
|
|
389
|
+
method: 'PATCH',
|
|
390
|
+
credentials: 'include',
|
|
391
|
+
headers: { 'Content-Type': 'application/json' },
|
|
392
|
+
body: JSON.stringify({ password: newPassword }),
|
|
393
|
+
});
|
|
394
|
+
const d = await resUpdate.json().catch(() => ({}));
|
|
395
|
+
if (resUpdate.ok) {
|
|
396
|
+
statusEl.textContent = 'Password reset.';
|
|
397
|
+
statusEl.className = 'status success';
|
|
398
|
+
} else {
|
|
399
|
+
statusEl.textContent = d.error || 'Failed to reset password';
|
|
400
|
+
statusEl.className = 'status error';
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
tableBody.querySelectorAll('.btn-user-delete').forEach((btn) => {
|
|
406
|
+
btn.addEventListener('click', async () => {
|
|
407
|
+
const row = btn.closest('tr');
|
|
408
|
+
const id = row?.dataset.userId;
|
|
409
|
+
if (!id) return;
|
|
410
|
+
if (!window.confirm(`Delete user ${id}? This cannot be undone.`)) return;
|
|
411
|
+
const resDel = await fetch(API + '/users/' + encodeURIComponent(id), {
|
|
412
|
+
method: 'DELETE',
|
|
413
|
+
credentials: 'include',
|
|
414
|
+
});
|
|
415
|
+
const d = await resDel.json().catch(() => ({}));
|
|
416
|
+
if (resDel.ok) {
|
|
417
|
+
statusEl.textContent = 'User deleted.';
|
|
418
|
+
statusEl.className = 'status success';
|
|
419
|
+
row.remove();
|
|
420
|
+
} else {
|
|
421
|
+
statusEl.textContent = d.error || 'Failed to delete user';
|
|
422
|
+
statusEl.className = 'status error';
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function createUserFromForm(e) {
|
|
429
|
+
e.preventDefault();
|
|
430
|
+
const form = e.target;
|
|
431
|
+
const statusEl = document.getElementById('create-user-status');
|
|
432
|
+
statusEl.textContent = '';
|
|
433
|
+
statusEl.className = 'status';
|
|
434
|
+
const username = form.username.value.trim();
|
|
435
|
+
const password = form.password.value;
|
|
436
|
+
const role = form.role.value;
|
|
437
|
+
if (!username || password.length < 6) {
|
|
438
|
+
statusEl.textContent = 'Username and password (min 6 chars) are required.';
|
|
439
|
+
statusEl.className = 'status error';
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const res = await post('/users', { username, password, role });
|
|
443
|
+
const d = await res.json().catch(() => ({}));
|
|
444
|
+
if (res.ok) {
|
|
445
|
+
statusEl.textContent = 'User created.';
|
|
446
|
+
statusEl.className = 'status success';
|
|
447
|
+
form.reset();
|
|
448
|
+
loadUsers();
|
|
449
|
+
} else {
|
|
450
|
+
statusEl.textContent = d.error || 'Failed to create user';
|
|
451
|
+
statusEl.className = 'status error';
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function loadProfile() {
|
|
456
|
+
const res = await get('/profile');
|
|
457
|
+
if (!res.ok) return;
|
|
458
|
+
const profile = await res.json();
|
|
459
|
+
const u = document.getElementById('profile-username');
|
|
460
|
+
const r = document.getElementById('profile-role');
|
|
461
|
+
const c = document.getElementById('profile-created');
|
|
462
|
+
if (u) u.textContent = profile.username || '';
|
|
463
|
+
if (r) r.textContent = profile.role || '';
|
|
464
|
+
if (c) c.textContent = profile.created_at || '';
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function changePassword(e) {
|
|
468
|
+
e.preventDefault();
|
|
469
|
+
const form = e.target;
|
|
470
|
+
const statusEl = document.getElementById('password-status');
|
|
471
|
+
statusEl.textContent = '';
|
|
472
|
+
statusEl.className = 'status';
|
|
473
|
+
const oldPassword = form.oldPassword.value;
|
|
474
|
+
const newPassword = form.newPassword.value;
|
|
475
|
+
const confirm = form.confirmNewPassword.value;
|
|
476
|
+
if (!oldPassword || !newPassword || !confirm) {
|
|
477
|
+
statusEl.textContent = 'All fields are required.';
|
|
478
|
+
statusEl.className = 'status error';
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (newPassword !== confirm) {
|
|
482
|
+
statusEl.textContent = 'New passwords do not match.';
|
|
483
|
+
statusEl.className = 'status error';
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (newPassword.length < 6) {
|
|
487
|
+
statusEl.textContent = 'New password must be at least 6 characters.';
|
|
488
|
+
statusEl.className = 'status error';
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const res = await post('/profile/password', { oldPassword, newPassword });
|
|
492
|
+
const d = await res.json().catch(() => ({}));
|
|
493
|
+
if (res.ok) {
|
|
494
|
+
statusEl.textContent = 'Password updated.';
|
|
495
|
+
statusEl.className = 'status success';
|
|
496
|
+
form.reset();
|
|
497
|
+
} else {
|
|
498
|
+
statusEl.textContent = d.error || 'Failed to update password';
|
|
499
|
+
statusEl.className = 'status error';
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function loadDocs(kind) {
|
|
504
|
+
const target = kind || 'auto';
|
|
505
|
+
let path = '/docs/user';
|
|
506
|
+
const btnAdmin = document.getElementById('btn-docs-admin');
|
|
507
|
+
if (target === 'admin') {
|
|
508
|
+
path = '/docs/admin';
|
|
509
|
+
} else if (target === 'auto' && btnAdmin && btnAdmin.style.display !== 'none') {
|
|
510
|
+
path = '/docs/admin';
|
|
511
|
+
}
|
|
512
|
+
const contentEl = document.getElementById('docs-content');
|
|
513
|
+
if (!contentEl) return;
|
|
514
|
+
contentEl.textContent = 'Loading documentation...';
|
|
515
|
+
contentEl.classList.remove('docs-rendered');
|
|
516
|
+
const res = await get(path);
|
|
517
|
+
if (!res.ok) {
|
|
518
|
+
const d = await res.json().catch(() => ({}));
|
|
519
|
+
contentEl.textContent = d.error || 'Failed to load docs';
|
|
520
|
+
contentEl.classList.remove('docs-rendered');
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const text = await res.text();
|
|
524
|
+
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
|
525
|
+
const rawHtml = typeof marked.parse === 'function' ? marked.parse(text) : marked(text);
|
|
526
|
+
const html = typeof rawHtml === 'string' ? rawHtml : (await rawHtml);
|
|
527
|
+
contentEl.innerHTML = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
|
|
528
|
+
contentEl.classList.add('docs-rendered');
|
|
529
|
+
} else {
|
|
530
|
+
contentEl.textContent = text;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
document.addEventListener('DOMContentLoaded', async () => {
|
|
535
|
+
const ok = await loadMe();
|
|
536
|
+
if (!ok) return;
|
|
537
|
+
|
|
538
|
+
document.querySelectorAll('.navbar .nav-item').forEach((btn) => {
|
|
539
|
+
btn.addEventListener('click', () => showPanel(btn.dataset.tab));
|
|
540
|
+
});
|
|
541
|
+
document.getElementById('logout-btn').addEventListener('click', async () => {
|
|
542
|
+
await post('/logout');
|
|
543
|
+
window.location.reload();
|
|
544
|
+
});
|
|
545
|
+
document.getElementById('run-query').addEventListener('click', runQuery);
|
|
546
|
+
|
|
547
|
+
document.getElementById('create-api-key').addEventListener('click', async () => {
|
|
548
|
+
const name = document.getElementById('api-key-name').value.trim() || null;
|
|
549
|
+
const role = document.getElementById('api-key-role').value;
|
|
550
|
+
const res = await post('/api-keys', { name, role });
|
|
551
|
+
const el = document.getElementById('api-key-result');
|
|
552
|
+
if (res.ok) {
|
|
553
|
+
const d = await res.json();
|
|
554
|
+
el.textContent = 'Key (copy now; not shown again): ' + d.key;
|
|
555
|
+
el.className = '';
|
|
556
|
+
loadApiKeys();
|
|
557
|
+
} else {
|
|
558
|
+
const d = await res.json().catch(() => ({}));
|
|
559
|
+
el.textContent = d.error || 'Failed';
|
|
560
|
+
el.className = 'error';
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const createUserForm = document.getElementById('create-user-form');
|
|
565
|
+
if (createUserForm) {
|
|
566
|
+
createUserForm.addEventListener('submit', createUserFromForm);
|
|
567
|
+
}
|
|
568
|
+
const createTableForm = document.getElementById('create-table-form');
|
|
569
|
+
if (createTableForm) {
|
|
570
|
+
createTableForm.addEventListener('submit', createTableFromForm);
|
|
571
|
+
}
|
|
572
|
+
const passwordForm = document.getElementById('password-form');
|
|
573
|
+
if (passwordForm) {
|
|
574
|
+
passwordForm.addEventListener('submit', changePassword);
|
|
575
|
+
}
|
|
576
|
+
const btnDocsUser = document.getElementById('btn-docs-user');
|
|
577
|
+
const btnDocsAdmin = document.getElementById('btn-docs-admin');
|
|
578
|
+
if (btnDocsUser) {
|
|
579
|
+
btnDocsUser.addEventListener('click', () => loadDocs('user'));
|
|
580
|
+
}
|
|
581
|
+
if (btnDocsAdmin) {
|
|
582
|
+
btnDocsAdmin.addEventListener('click', () => loadDocs('admin'));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// initial loads
|
|
586
|
+
loadTables();
|
|
587
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
|
+
<title>Secure DB Panel</title>
|
|
8
|
+
<link rel="stylesheet" href="/style.css" />
|
|
9
|
+
</head>
|
|
10
|
+
|
|
11
|
+
<body>
|
|
12
|
+
<div id="app">
|
|
13
|
+
<header class="topbar">
|
|
14
|
+
<div class="topbar-left">
|
|
15
|
+
<span class="app-logo">🔐</span>
|
|
16
|
+
<h1 class="app-title">Secure DB</h1>
|
|
17
|
+
</div>
|
|
18
|
+
<nav class="navbar" id="navbar">
|
|
19
|
+
<button type="button" data-tab="tables" class="nav-item active">Tables</button>
|
|
20
|
+
<button type="button" data-tab="add-table" class="nav-item">Add Table</button>
|
|
21
|
+
<button type="button" data-tab="query" class="nav-item">Query</button>
|
|
22
|
+
<button type="button" data-tab="users" class="nav-item" id="nav-users" style="display:none;">Users</button>
|
|
23
|
+
<button type="button" data-tab="docs" class="nav-item">Docs</button>
|
|
24
|
+
<button type="button" data-tab="profile" class="nav-item">Profile</button>
|
|
25
|
+
<button type="button" data-tab="audit" class="nav-item" id="nav-audit">API keys & Audit</button>
|
|
26
|
+
</nav>
|
|
27
|
+
<div class="topbar-right">
|
|
28
|
+
<span id="user-info" class="user-info"></span>
|
|
29
|
+
<button type="button" id="logout-btn" class="btn secondary">Logout</button>
|
|
30
|
+
</div>
|
|
31
|
+
</header>
|
|
32
|
+
<main class="main-layout">
|
|
33
|
+
<section id="panel-tables" class="panel active">
|
|
34
|
+
<h2>Tables</h2>
|
|
35
|
+
<ul id="table-list"></ul>
|
|
36
|
+
<div id="table-detail"></div>
|
|
37
|
+
</section>
|
|
38
|
+
<section id="panel-query" class="panel">
|
|
39
|
+
<h2>SQL Editor</h2>
|
|
40
|
+
<textarea id="sql-input" rows="6" placeholder="SELECT * FROM users LIMIT 10"></textarea>
|
|
41
|
+
<p class="hint">Use ? placeholders for parameters; pass as JSON array in Parameters (e.g. [1, "foo"]).</p>
|
|
42
|
+
<input type="text" id="sql-params" placeholder='Parameters (JSON array, e.g. [1]' />
|
|
43
|
+
<button type="button" id="run-query" class="btn primary">Run</button>
|
|
44
|
+
<pre id="query-result"></pre>
|
|
45
|
+
</section>
|
|
46
|
+
<section id="panel-users" class="panel">
|
|
47
|
+
<h2>Users</h2>
|
|
48
|
+
<p class="hint">Admin-only: manage panel users.</p>
|
|
49
|
+
<div class="users-layout">
|
|
50
|
+
<div class="users-list">
|
|
51
|
+
<h3>Existing users</h3>
|
|
52
|
+
<div id="users-status" class="status"></div>
|
|
53
|
+
<table class="table" id="users-table">
|
|
54
|
+
<thead>
|
|
55
|
+
<tr>
|
|
56
|
+
<th>ID</th>
|
|
57
|
+
<th>Username</th>
|
|
58
|
+
<th>Role</th>
|
|
59
|
+
<th>Created</th>
|
|
60
|
+
<th>Actions</th>
|
|
61
|
+
</tr>
|
|
62
|
+
</thead>
|
|
63
|
+
<tbody></tbody>
|
|
64
|
+
</table>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="users-form">
|
|
67
|
+
<h3>Create user</h3>
|
|
68
|
+
<form id="create-user-form">
|
|
69
|
+
<label>
|
|
70
|
+
<span>Username</span>
|
|
71
|
+
<input type="text" name="username" required minlength="2" />
|
|
72
|
+
</label>
|
|
73
|
+
<label>
|
|
74
|
+
<span>Password</span>
|
|
75
|
+
<input type="password" name="password" required minlength="6" />
|
|
76
|
+
</label>
|
|
77
|
+
<label>
|
|
78
|
+
<span>Role</span>
|
|
79
|
+
<select name="role">
|
|
80
|
+
<option value="readwrite">readwrite</option>
|
|
81
|
+
<option value="readonly">readonly</option>
|
|
82
|
+
<option value="admin">admin</option>
|
|
83
|
+
</select>
|
|
84
|
+
</label>
|
|
85
|
+
<button type="submit" class="btn primary">Create</button>
|
|
86
|
+
<div id="create-user-status" class="status"></div>
|
|
87
|
+
</form>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</section>
|
|
91
|
+
<section id="panel-profile" class="panel">
|
|
92
|
+
<h2>Profile</h2>
|
|
93
|
+
<div class="profile-layout">
|
|
94
|
+
<div class="profile-info">
|
|
95
|
+
<h3>Account</h3>
|
|
96
|
+
<p><strong>Username:</strong> <span id="profile-username"></span></p>
|
|
97
|
+
<p><strong>Role:</strong> <span id="profile-role"></span></p>
|
|
98
|
+
<p><strong>Created:</strong> <span id="profile-created"></span></p>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="profile-password">
|
|
101
|
+
<h3>Change password</h3>
|
|
102
|
+
<form id="password-form">
|
|
103
|
+
<label>
|
|
104
|
+
<span>Current password</span>
|
|
105
|
+
<input type="password" name="oldPassword" required />
|
|
106
|
+
</label>
|
|
107
|
+
<label>
|
|
108
|
+
<span>New password</span>
|
|
109
|
+
<input type="password" name="newPassword" required minlength="6" />
|
|
110
|
+
</label>
|
|
111
|
+
<label>
|
|
112
|
+
<span>Confirm new password</span>
|
|
113
|
+
<input type="password" name="confirmNewPassword" required minlength="6" />
|
|
114
|
+
</label>
|
|
115
|
+
<button type="submit" class="btn primary">Update password</button>
|
|
116
|
+
<div id="password-status" class="status"></div>
|
|
117
|
+
</form>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</section>
|
|
121
|
+
<section id="panel-docs" class="panel">
|
|
122
|
+
<h2>Documentation</h2>
|
|
123
|
+
<div class="docs-controls">
|
|
124
|
+
<button type="button" class="btn secondary" id="btn-docs-user">User guide</button>
|
|
125
|
+
<button type="button" class="btn secondary" id="btn-docs-admin" style="display:none;">Admin guide</button>
|
|
126
|
+
</div>
|
|
127
|
+
<div id="docs-content" class="docs-content docs-rendered"></div>
|
|
128
|
+
</section>
|
|
129
|
+
<section id="panel-audit" class="panel">
|
|
130
|
+
<h2>API keys & Audit</h2>
|
|
131
|
+
<div class="api-key-section">
|
|
132
|
+
<h3>Your API keys</h3>
|
|
133
|
+
<p class="hint">Admins see all keys; others see only keys they created.</p>
|
|
134
|
+
<div id="api-keys-status" class="status"></div>
|
|
135
|
+
<table class="table" id="api-keys-table">
|
|
136
|
+
<thead>
|
|
137
|
+
<tr>
|
|
138
|
+
<th>ID</th>
|
|
139
|
+
<th>Name</th>
|
|
140
|
+
<th>Role</th>
|
|
141
|
+
<th>Created</th>
|
|
142
|
+
<th></th>
|
|
143
|
+
</tr>
|
|
144
|
+
</thead>
|
|
145
|
+
<tbody></tbody>
|
|
146
|
+
</table>
|
|
147
|
+
<h3 class="api-key-create-title">Create API key</h3>
|
|
148
|
+
<input type="text" id="api-key-name" placeholder="Name (optional)" />
|
|
149
|
+
<select id="api-key-role">
|
|
150
|
+
<option value="readwrite">readwrite</option>
|
|
151
|
+
<option value="readonly">readonly</option>
|
|
152
|
+
<option value="admin" id="api-key-role-admin">admin</option>
|
|
153
|
+
</select>
|
|
154
|
+
<button type="button" id="create-api-key" class="btn primary">Create</button>
|
|
155
|
+
<pre id="api-key-result"></pre>
|
|
156
|
+
</div>
|
|
157
|
+
<div id="audit-log-section" style="display:none;">
|
|
158
|
+
<h3>Audit log</h3>
|
|
159
|
+
<p class="hint">Admin only. Last 100 entries.</p>
|
|
160
|
+
<pre id="audit-log"></pre>
|
|
161
|
+
</div>
|
|
162
|
+
</section>
|
|
163
|
+
<section id="panel-add-table" class="panel">
|
|
164
|
+
<h2>Create Table</h2>
|
|
165
|
+
<div class="card">
|
|
166
|
+
<form id="create-table-form">
|
|
167
|
+
<label>
|
|
168
|
+
<span>Table name</span>
|
|
169
|
+
<input type="text" name="name" placeholder="e.g. customers" required minlength="1" />
|
|
170
|
+
</label>
|
|
171
|
+
<label>
|
|
172
|
+
<span>Columns (comma-separated, e.g. id INTEGER PRIMARY KEY, name TEXT)</span>
|
|
173
|
+
<textarea name="columns" rows="4" placeholder="id INTEGER PRIMARY KEY, name TEXT, created_at TEXT"
|
|
174
|
+
required></textarea>
|
|
175
|
+
</label>
|
|
176
|
+
<div style="display:flex;gap:.5rem;align-items:center">
|
|
177
|
+
<button type="submit" class="btn primary">Create table</button>
|
|
178
|
+
<div id="create-table-status" class="status" style="margin-left:.5rem"></div>
|
|
179
|
+
</div>
|
|
180
|
+
</form>
|
|
181
|
+
</div>
|
|
182
|
+
</section>
|
|
183
|
+
</main>
|
|
184
|
+
</div>
|
|
185
|
+
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
186
|
+
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
|
|
187
|
+
<script src="/app.js"></script>
|
|
188
|
+
</body>
|
|
189
|
+
|
|
190
|
+
</html>
|
package/public/style.css
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
* {
|
|
2
|
+
box-sizing: border-box;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
body {
|
|
6
|
+
font-family: system-ui, sans-serif;
|
|
7
|
+
margin: 0;
|
|
8
|
+
background: #f5f5f7;
|
|
9
|
+
color: #111827;
|
|
10
|
+
min-height: 100vh;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
#app {
|
|
14
|
+
max-width: 1080px;
|
|
15
|
+
margin: 0 auto;
|
|
16
|
+
padding: 1.5rem 1rem 2rem;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.topbar {
|
|
20
|
+
display: flex;
|
|
21
|
+
align-items: center;
|
|
22
|
+
justify-content: space-between;
|
|
23
|
+
gap: 1rem;
|
|
24
|
+
padding: 0.75rem 1rem;
|
|
25
|
+
border-radius: 999px;
|
|
26
|
+
background: #ffffff;
|
|
27
|
+
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
|
28
|
+
margin-bottom: 1.25rem;
|
|
29
|
+
flex-wrap: wrap;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.topbar-left {
|
|
33
|
+
display: flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
gap: 0.5rem;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.app-logo {
|
|
39
|
+
font-size: 1.25rem;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.app-title {
|
|
43
|
+
margin: 0;
|
|
44
|
+
font-size: 1.1rem;
|
|
45
|
+
font-weight: 600;
|
|
46
|
+
color: #111827;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.navbar {
|
|
50
|
+
display: flex;
|
|
51
|
+
align-items: center;
|
|
52
|
+
gap: 0.5rem;
|
|
53
|
+
flex-wrap: wrap;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.nav-item {
|
|
57
|
+
padding: 0.4rem 0.9rem;
|
|
58
|
+
border-radius: 999px;
|
|
59
|
+
border: none;
|
|
60
|
+
background: transparent;
|
|
61
|
+
color: #6b7280;
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
font-size: 0.9rem;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.nav-item:hover {
|
|
67
|
+
background: #e5e7eb;
|
|
68
|
+
color: #111827;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.nav-item.active {
|
|
72
|
+
background: #2563eb;
|
|
73
|
+
color: #ffffff;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.nav-item[data-tab="add-table"] {
|
|
77
|
+
position: relative;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.nav-item[data-tab="add-table"]::after {
|
|
81
|
+
content: '\2795';
|
|
82
|
+
margin-left: 0.45rem;
|
|
83
|
+
font-weight: 700;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.topbar-right {
|
|
87
|
+
display: flex;
|
|
88
|
+
align-items: center;
|
|
89
|
+
gap: 0.75rem;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.user-info {
|
|
93
|
+
font-size: 0.9rem;
|
|
94
|
+
color: #4b5563;
|
|
95
|
+
white-space: nowrap;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.panel {
|
|
99
|
+
display: none;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.panel.active {
|
|
103
|
+
display: block;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.panel h2 {
|
|
107
|
+
font-size: 1.05rem;
|
|
108
|
+
margin-top: 0;
|
|
109
|
+
margin-bottom: 0.75rem;
|
|
110
|
+
color: #111827;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.panel .card {
|
|
114
|
+
padding: 1rem;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.card form label {
|
|
118
|
+
display: flex;
|
|
119
|
+
flex-direction: column;
|
|
120
|
+
gap: 0.35rem;
|
|
121
|
+
margin-bottom: 0.75rem;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.card form input,
|
|
125
|
+
.card form textarea {
|
|
126
|
+
width: 100%;
|
|
127
|
+
padding: 0.6rem 0.7rem;
|
|
128
|
+
border-radius: 0.6rem;
|
|
129
|
+
border: 1px solid #d1d5db;
|
|
130
|
+
background: #fff;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.card form textarea {
|
|
134
|
+
font-family: ui-monospace, monospace;
|
|
135
|
+
resize: vertical;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.panel .status {
|
|
139
|
+
margin-top: 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.main-layout {
|
|
143
|
+
display: flex;
|
|
144
|
+
flex-direction: column;
|
|
145
|
+
gap: 1rem;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.btn {
|
|
149
|
+
padding: 0.5rem 1rem;
|
|
150
|
+
border-radius: 4px;
|
|
151
|
+
cursor: pointer;
|
|
152
|
+
font-size: 0.9rem;
|
|
153
|
+
border: none;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.btn.primary {
|
|
157
|
+
background: #2563eb;
|
|
158
|
+
color: #fff;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.btn.primary:hover {
|
|
162
|
+
background: #1d4ed8;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.btn.secondary {
|
|
166
|
+
background: #e5e7eb;
|
|
167
|
+
color: #111827;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.btn.secondary:hover {
|
|
171
|
+
background: #d1d5db;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#table-list {
|
|
175
|
+
list-style: none;
|
|
176
|
+
padding: 0;
|
|
177
|
+
margin: 0 0 1rem 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#table-list li {
|
|
181
|
+
padding: 0.5rem;
|
|
182
|
+
background: #ffffff;
|
|
183
|
+
margin-bottom: 0.25rem;
|
|
184
|
+
border-radius: 6px;
|
|
185
|
+
cursor: pointer;
|
|
186
|
+
border: 1px solid #e5e7eb;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#table-list li:hover {
|
|
190
|
+
background: #f3f4f6;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#table-detail table,
|
|
194
|
+
.table {
|
|
195
|
+
width: 100%;
|
|
196
|
+
border-collapse: collapse;
|
|
197
|
+
font-size: 0.85rem;
|
|
198
|
+
background: #ffffff;
|
|
199
|
+
border-radius: 0.75rem;
|
|
200
|
+
overflow: hidden;
|
|
201
|
+
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#table-detail th,
|
|
205
|
+
#table-detail td,
|
|
206
|
+
.table th,
|
|
207
|
+
.table td {
|
|
208
|
+
padding: 0.55rem 0.75rem;
|
|
209
|
+
text-align: left;
|
|
210
|
+
border-bottom: 1px solid #e5e7eb;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#table-detail th,
|
|
214
|
+
.table th {
|
|
215
|
+
color: #6b7280;
|
|
216
|
+
font-weight: 600;
|
|
217
|
+
background: #f9fafb;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
#sql-input {
|
|
221
|
+
width: 100%;
|
|
222
|
+
font-family: ui-monospace, monospace;
|
|
223
|
+
font-size: 0.9rem;
|
|
224
|
+
padding: 0.6rem 0.7rem;
|
|
225
|
+
background: #ffffff;
|
|
226
|
+
border: 1px solid #d1d5db;
|
|
227
|
+
border-radius: 0.6rem;
|
|
228
|
+
color: #111827;
|
|
229
|
+
resize: vertical;
|
|
230
|
+
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#sql-params {
|
|
234
|
+
width: 100%;
|
|
235
|
+
max-width: 400px;
|
|
236
|
+
padding: 0.45rem 0.7rem;
|
|
237
|
+
margin-top: 0.25rem;
|
|
238
|
+
background: #ffffff;
|
|
239
|
+
border: 1px solid #d1d5db;
|
|
240
|
+
border-radius: 999px;
|
|
241
|
+
color: #111827;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.hint {
|
|
245
|
+
color: #6b7280;
|
|
246
|
+
font-size: 0.85rem;
|
|
247
|
+
margin: 0.25rem 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#run-query {
|
|
251
|
+
margin-top: 0.5rem;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.card {
|
|
255
|
+
background: #ffffff;
|
|
256
|
+
border-radius: 1rem;
|
|
257
|
+
padding: 1rem;
|
|
258
|
+
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06);
|
|
259
|
+
border: 1px solid #e5e7eb;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#query-result,
|
|
263
|
+
#audit-log {
|
|
264
|
+
background: #ffffff;
|
|
265
|
+
padding: 1rem;
|
|
266
|
+
border-radius: 0.75rem;
|
|
267
|
+
overflow: auto;
|
|
268
|
+
max-height: 400px;
|
|
269
|
+
font-size: 0.8rem;
|
|
270
|
+
white-space: pre-wrap;
|
|
271
|
+
word-break: break-word;
|
|
272
|
+
border: 1px solid #e5e7eb;
|
|
273
|
+
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
#docs-content {
|
|
277
|
+
background: #ffffff;
|
|
278
|
+
padding: 1rem 1.25rem;
|
|
279
|
+
border-radius: 0.75rem;
|
|
280
|
+
overflow: auto;
|
|
281
|
+
max-height: 500px;
|
|
282
|
+
font-size: 0.9rem;
|
|
283
|
+
line-height: 1.5;
|
|
284
|
+
border: 1px solid #e5e7eb;
|
|
285
|
+
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#docs-content:not(.docs-rendered) {
|
|
289
|
+
white-space: pre-wrap;
|
|
290
|
+
word-break: break-word;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#query-result.error {
|
|
294
|
+
color: #b91c1c;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.login-box {
|
|
298
|
+
max-width: 360px;
|
|
299
|
+
margin: 4rem auto 1rem;
|
|
300
|
+
padding: 2rem;
|
|
301
|
+
background: #ffffff;
|
|
302
|
+
border-radius: 1rem;
|
|
303
|
+
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.12);
|
|
304
|
+
border: 1px solid #e5e7eb;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.login-box h1 {
|
|
308
|
+
margin-top: 0;
|
|
309
|
+
font-size: 1.35rem;
|
|
310
|
+
text-align: center;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.login-box input {
|
|
314
|
+
width: 100%;
|
|
315
|
+
padding: 0.6rem 0.8rem;
|
|
316
|
+
margin-bottom: 0.75rem;
|
|
317
|
+
background: #ffffff;
|
|
318
|
+
border: 1px solid #d1d5db;
|
|
319
|
+
border-radius: 0.6rem;
|
|
320
|
+
color: #111827;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.login-box button {
|
|
324
|
+
width: 100%;
|
|
325
|
+
padding: 0.7rem;
|
|
326
|
+
margin-top: 0.5rem;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
.setup-box {
|
|
330
|
+
max-width: 360px;
|
|
331
|
+
margin: 2rem auto;
|
|
332
|
+
padding: 1.5rem;
|
|
333
|
+
background: #ffffff;
|
|
334
|
+
border-radius: 1rem;
|
|
335
|
+
border: 1px solid #d1d5db;
|
|
336
|
+
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.setup-box h2 {
|
|
340
|
+
margin-top: 0;
|
|
341
|
+
font-size: 1rem;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.setup-box input {
|
|
345
|
+
width: 100%;
|
|
346
|
+
padding: 0.6rem 0.8rem;
|
|
347
|
+
margin-bottom: 0.75rem;
|
|
348
|
+
background: #ffffff;
|
|
349
|
+
border: 1px solid #d1d5db;
|
|
350
|
+
border-radius: 0.6rem;
|
|
351
|
+
color: #111827;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.setup-box button {
|
|
355
|
+
width: 100%;
|
|
356
|
+
padding: 0.7rem;
|
|
357
|
+
margin-top: 0.5rem;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.api-key-section {
|
|
361
|
+
margin-bottom: 1rem;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.api-key-section h3 {
|
|
365
|
+
font-size: 0.9rem;
|
|
366
|
+
margin-bottom: 0.5rem;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.api-key-create-title {
|
|
370
|
+
margin-top: 1.25rem;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.api-key-section input,
|
|
374
|
+
.api-key-section select {
|
|
375
|
+
padding: 0.45rem 0.7rem;
|
|
376
|
+
margin-right: 0.5rem;
|
|
377
|
+
margin-bottom: 0.5rem;
|
|
378
|
+
background: #ffffff;
|
|
379
|
+
border: 1px solid #d1d5db;
|
|
380
|
+
border-radius: 999px;
|
|
381
|
+
color: #111827;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#api-key-result {
|
|
385
|
+
margin-top: 0.5rem;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.users-layout {
|
|
389
|
+
display: grid;
|
|
390
|
+
grid-template-columns: minmax(0, 2fr) minmax(0, 1.5fr);
|
|
391
|
+
gap: 1rem;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.users-list,
|
|
395
|
+
.users-form,
|
|
396
|
+
.profile-layout,
|
|
397
|
+
.docs-layout {
|
|
398
|
+
background: #ffffff;
|
|
399
|
+
border-radius: 1rem;
|
|
400
|
+
padding: 1rem;
|
|
401
|
+
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.05);
|
|
402
|
+
border: 1px solid #e5e7eb;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
.users-form form label,
|
|
406
|
+
.profile-password form label {
|
|
407
|
+
display: flex;
|
|
408
|
+
flex-direction: column;
|
|
409
|
+
gap: 0.25rem;
|
|
410
|
+
margin-bottom: 0.75rem;
|
|
411
|
+
font-size: 0.9rem;
|
|
412
|
+
color: #374151;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.users-form input,
|
|
416
|
+
.users-form select,
|
|
417
|
+
.profile-password input {
|
|
418
|
+
padding: 0.55rem 0.75rem;
|
|
419
|
+
border-radius: 0.6rem;
|
|
420
|
+
border: 1px solid #d1d5db;
|
|
421
|
+
background: #ffffff;
|
|
422
|
+
color: #111827;
|
|
423
|
+
font-size: 0.9rem;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
.status {
|
|
427
|
+
margin-top: 0.5rem;
|
|
428
|
+
font-size: 0.85rem;
|
|
429
|
+
min-height: 1.2em;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
.status.error {
|
|
433
|
+
color: #b91c1c;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
.status.success {
|
|
437
|
+
color: #15803d;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
.profile-layout {
|
|
441
|
+
display: grid;
|
|
442
|
+
grid-template-columns: minmax(0, 1.5fr) minmax(0, 2fr);
|
|
443
|
+
gap: 1rem;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
.profile-info p {
|
|
447
|
+
margin: 0.25rem 0;
|
|
448
|
+
font-size: 0.9rem;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.docs-controls {
|
|
452
|
+
display: flex;
|
|
453
|
+
gap: 0.5rem;
|
|
454
|
+
margin-bottom: 0.5rem;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
.docs-content {
|
|
458
|
+
min-height: 200px;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/* Rendered Markdown in docs panel */
|
|
462
|
+
#docs-content.docs-rendered h1 {
|
|
463
|
+
font-size: 1.35rem;
|
|
464
|
+
margin: 0 0 0.75rem;
|
|
465
|
+
padding-bottom: 0.35rem;
|
|
466
|
+
border-bottom: 1px solid #e5e7eb;
|
|
467
|
+
color: #111827;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
#docs-content.docs-rendered h2 {
|
|
471
|
+
font-size: 1.15rem;
|
|
472
|
+
margin: 1.25rem 0 0.5rem;
|
|
473
|
+
color: #111827;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
#docs-content.docs-rendered h3 {
|
|
477
|
+
font-size: 1rem;
|
|
478
|
+
margin: 1rem 0 0.4rem;
|
|
479
|
+
color: #374151;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
#docs-content.docs-rendered p {
|
|
483
|
+
margin: 0.5rem 0;
|
|
484
|
+
color: #374151;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
#docs-content.docs-rendered ul,
|
|
488
|
+
#docs-content.docs-rendered ol {
|
|
489
|
+
margin: 0.5rem 0;
|
|
490
|
+
padding-left: 1.5rem;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
#docs-content.docs-rendered li {
|
|
494
|
+
margin: 0.25rem 0;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
#docs-content.docs-rendered code {
|
|
498
|
+
background: #f3f4f6;
|
|
499
|
+
padding: 0.15rem 0.4rem;
|
|
500
|
+
border-radius: 0.35rem;
|
|
501
|
+
font-size: 0.85em;
|
|
502
|
+
font-family: ui-monospace, monospace;
|
|
503
|
+
color: #111827;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
#docs-content.docs-rendered pre {
|
|
507
|
+
background: #f3f4f6;
|
|
508
|
+
padding: 0.75rem 1rem;
|
|
509
|
+
border-radius: 0.5rem;
|
|
510
|
+
overflow: auto;
|
|
511
|
+
margin: 0.75rem 0;
|
|
512
|
+
border: 1px solid #e5e7eb;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
#docs-content.docs-rendered pre code {
|
|
516
|
+
background: none;
|
|
517
|
+
padding: 0;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
#docs-content.docs-rendered a {
|
|
521
|
+
color: #2563eb;
|
|
522
|
+
text-decoration: none;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
#docs-content.docs-rendered a:hover {
|
|
526
|
+
text-decoration: underline;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
#docs-content.docs-rendered hr {
|
|
530
|
+
border: none;
|
|
531
|
+
border-top: 1px solid #e5e7eb;
|
|
532
|
+
margin: 1.25rem 0;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
#docs-content.docs-rendered blockquote {
|
|
536
|
+
margin: 0.5rem 0;
|
|
537
|
+
padding-left: 1rem;
|
|
538
|
+
border-left: 3px solid #d1d5db;
|
|
539
|
+
color: #6b7280;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
#docs-content.docs-rendered strong {
|
|
543
|
+
font-weight: 600;
|
|
544
|
+
color: #111827;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
#docs-content.docs-rendered table {
|
|
548
|
+
border-collapse: collapse;
|
|
549
|
+
width: 100%;
|
|
550
|
+
margin: 0.75rem 0;
|
|
551
|
+
font-size: 0.9rem;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
#docs-content.docs-rendered th,
|
|
555
|
+
#docs-content.docs-rendered td {
|
|
556
|
+
border: 1px solid #e5e7eb;
|
|
557
|
+
padding: 0.4rem 0.6rem;
|
|
558
|
+
text-align: left;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
#docs-content.docs-rendered th {
|
|
562
|
+
background: #f9fafb;
|
|
563
|
+
font-weight: 600;
|
|
564
|
+
color: #374151;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
@media (max-width: 768px) {
|
|
568
|
+
.topbar {
|
|
569
|
+
border-radius: 1rem;
|
|
570
|
+
align-items: flex-start;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
.navbar {
|
|
574
|
+
order: 3;
|
|
575
|
+
width: 100%;
|
|
576
|
+
justify-content: center;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
.topbar-right {
|
|
580
|
+
margin-left: auto;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
.users-layout,
|
|
584
|
+
.profile-layout {
|
|
585
|
+
grid-template-columns: minmax(0, 1fr);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
.nav-item[data-tab="add-table"]::after {
|
|
589
|
+
display: none;
|
|
590
|
+
}
|
|
591
|
+
}
|