sqlite-hub 0.17.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +101 -19
  2. package/bin/sqlite-hub.js +83 -401
  3. package/examples/api/queries.js +31 -0
  4. package/examples/api/rows.js +27 -0
  5. package/frontend/assets/mockups/charts_1_bars_1200.webp +0 -0
  6. package/frontend/assets/mockups/charts_2_pie_1200.webp +0 -0
  7. package/frontend/assets/mockups/charts_3_scatter_plot_1200.webp +0 -0
  8. package/frontend/assets/mockups/connections_1200.webp +0 -0
  9. package/frontend/assets/mockups/data_1_1200.webp +0 -0
  10. package/frontend/assets/mockups/data_2_row_editor_1200.webp +0 -0
  11. package/frontend/assets/mockups/documents_1200.webp +0 -0
  12. package/frontend/assets/mockups/media_tagging_1_setup_1200.webp +0 -0
  13. package/frontend/assets/mockups/media_tagging_2_tagging_queue_1200.webp +0 -0
  14. package/frontend/assets/mockups/media_tagging_3_media_viewer_1200.webp +0 -0
  15. package/frontend/assets/mockups/overview_1200.webp +0 -0
  16. package/frontend/assets/mockups/settings_1200.webp +0 -0
  17. package/frontend/assets/mockups/sql_editor_1_1200.webp +0 -0
  18. package/frontend/assets/mockups/sql_editor_2_query_details_1200.webp +0 -0
  19. package/frontend/assets/mockups/sql_editor_3_export_column_1200.webp +0 -0
  20. package/frontend/assets/mockups/sql_editor_4_export_column_as_markdown_1200.webp +0 -0
  21. package/frontend/assets/mockups/sql_editor_5_export_query_result_1200.webp +0 -0
  22. package/frontend/assets/mockups/structure_1_1200.webp +0 -0
  23. package/frontend/assets/mockups/structure_2_inspector_1200.webp +0 -0
  24. package/frontend/assets/mockups/table_designer_1_1200.webp +0 -0
  25. package/frontend/assets/mockups/table_designer_2_checks_1200.webp +0 -0
  26. package/frontend/js/api.js +19 -0
  27. package/frontend/js/app.js +64 -0
  28. package/frontend/js/components/formControls.js +38 -0
  29. package/frontend/js/components/modal.js +60 -20
  30. package/frontend/js/store.js +115 -0
  31. package/frontend/js/views/settings.js +141 -5
  32. package/frontend/styles/tailwind.generated.css +2 -2
  33. package/package.json +6 -6
  34. package/server/middleware/apiTokenAuth.js +30 -0
  35. package/server/routes/connections.js +17 -0
  36. package/server/routes/externalApi.js +222 -0
  37. package/server/routes/settings.js +64 -4
  38. package/server/server.js +22 -1
  39. package/server/services/apiTokenService.js +101 -0
  40. package/server/services/databaseCommandService.js +399 -0
  41. package/server/services/nativeFileDialogService.js +93 -1
  42. package/server/services/storage/appStateStore.js +113 -0
  43. package/server/utils/errors.js +7 -0
  44. package/tests/api-token-auth.test.js +127 -0
  45. package/tests/cli-service-delegation.test.js +43 -0
  46. package/tests/connections-file-dialog-route.test.js +43 -0
  47. package/tests/copy-column-modal.test.js +34 -0
  48. package/tests/database-command-service.test.js +102 -0
  49. package/tests/form-controls.test.js +34 -0
  50. package/tests/native-file-dialog.test.js +27 -0
  51. package/tests/settings-api-tokens-route.test.js +76 -0
  52. package/tests/settings-view.test.js +47 -0
  53. package/frontend/assets/mockups/connections.png +0 -0
  54. package/frontend/assets/mockups/data.png +0 -0
  55. package/frontend/assets/mockups/data_row_editor.png +0 -0
  56. package/frontend/assets/mockups/home.png +0 -0
  57. package/frontend/assets/mockups/sql_editor.png +0 -0
  58. package/frontend/assets/mockups/sql_editor_croped.png +0 -0
  59. package/frontend/assets/mockups/sql_editor_querydetail.png +0 -0
  60. package/frontend/assets/mockups/structure.png +0 -0
  61. package/frontend/assets/mockups/structure_inspector.png +0 -0
@@ -86,6 +86,12 @@ export function createConnection(payload) {
86
86
  });
87
87
  }
88
88
 
89
+ export function chooseOpenDatabasePath() {
90
+ return request("/api/connections/choose-open-path", {
91
+ method: "POST",
92
+ });
93
+ }
94
+
89
95
  export function chooseCreateDatabasePath() {
90
96
  return request("/api/connections/choose-create-path", {
91
97
  method: "POST",
@@ -425,6 +431,19 @@ export function patchSettings(settings) {
425
431
  });
426
432
  }
427
433
 
434
+ export function createApiToken(name) {
435
+ return request("/api/settings/api-tokens", {
436
+ method: "POST",
437
+ body: { name },
438
+ });
439
+ }
440
+
441
+ export function deleteApiToken(tokenId) {
442
+ return request(`/api/settings/api-tokens/${encodeURIComponent(tokenId)}`, {
443
+ method: "DELETE",
444
+ });
445
+ }
446
+
428
447
  const TEXT_EXPORT_EXTENSIONS = {
429
448
  csv: "csv",
430
449
  tsv: "tsv",
@@ -24,6 +24,8 @@ import { renderTopNav } from './components/topNav.js';
24
24
  import { createRouter } from './router.js';
25
25
  import {
26
26
  createActiveConnectionBackup,
27
+ createSettingsApiToken,
28
+ chooseOpenDatabasePath,
27
29
  chooseCreateDatabasePath,
28
30
  createDocument,
29
31
  createDocumentFromMarkdownExport,
@@ -49,6 +51,7 @@ import {
49
51
  openDeleteDataRowModal,
50
52
  openDeleteEditorRowModal,
51
53
  openDeleteQueryHistoryModal,
54
+ openDeleteSettingsApiTokenModal,
52
55
  openDeleteQueryChartModal,
53
56
  openDataExportModal,
54
57
  openDeleteDocumentModal,
@@ -103,6 +106,7 @@ import {
103
106
  submitCreateMediaTaggingTagTable,
104
107
  submitCreateMediaTaggingMappingTable,
105
108
  submitDeleteQueryHistoryConfirmation,
109
+ submitDeleteSettingsApiTokenConfirmation,
106
110
  submitRowUpdatePreviewConfirmation,
107
111
  setQueryHistoryPanelVisibility,
108
112
  sortDataTableByColumn,
@@ -113,6 +117,7 @@ import {
113
117
  setCopyColumnModalEditedText,
114
118
  setCopyColumnModalSubmitting,
115
119
  setRoute,
120
+ setSettingsSection,
116
121
  saveQueryHistoryNotes,
117
122
  saveQueryHistoryTitle,
118
123
  saveCurrentTableDesignerDraft,
@@ -2091,6 +2096,32 @@ async function handleAction(actionNode) {
2091
2096
  case 'refresh-view':
2092
2097
  await refreshCurrentRoute();
2093
2098
  return;
2099
+ case 'set-settings-section':
2100
+ setSettingsSection(actionNode.dataset.section);
2101
+ return;
2102
+ case 'open-delete-api-token-modal':
2103
+ openDeleteSettingsApiTokenModal(actionNode.dataset.tokenId);
2104
+ return;
2105
+ case 'copy-created-api-token': {
2106
+ const tokenInput = document.querySelector('[data-created-api-token]');
2107
+ const token = tokenInput instanceof HTMLInputElement ? tokenInput.value : '';
2108
+
2109
+ if (token && navigator.clipboard?.writeText) {
2110
+ await navigator.clipboard.writeText(token);
2111
+ showToast('API token copied.', 'success');
2112
+ }
2113
+ return;
2114
+ }
2115
+ case 'copy-database-id': {
2116
+ const databaseIdNode = document.querySelector('[data-database-id]');
2117
+ const databaseId = databaseIdNode?.textContent?.trim() ?? '';
2118
+
2119
+ if (databaseId && navigator.clipboard?.writeText) {
2120
+ await navigator.clipboard.writeText(databaseId);
2121
+ showToast('Database ID copied.', 'success');
2122
+ }
2123
+ return;
2124
+ }
2094
2125
  case 'open-row-editor-url':
2095
2126
  openRowEditorUrl(actionNode);
2096
2127
  return;
@@ -2181,6 +2212,33 @@ async function handleAction(actionNode) {
2181
2212
  case 'edit-connection':
2182
2213
  openEditConnectionModal(actionNode.dataset.connectionId);
2183
2214
  return;
2215
+ case 'choose-open-database-path': {
2216
+ const labelNode = actionNode.querySelector('[data-open-database-path-button-label]');
2217
+
2218
+ actionNode.setAttribute('disabled', '');
2219
+ if (labelNode) {
2220
+ labelNode.textContent = 'Choosing...';
2221
+ }
2222
+
2223
+ const selectedPath = await chooseOpenDatabasePath();
2224
+ const pathInput = document.querySelector(
2225
+ '[data-form="open-connection"] [data-open-database-path]',
2226
+ );
2227
+
2228
+ if (selectedPath && pathInput instanceof HTMLInputElement) {
2229
+ pathInput.value = selectedPath;
2230
+ pathInput.focus({ preventScroll: true });
2231
+ pathInput.setSelectionRange(pathInput.value.length, pathInput.value.length);
2232
+ }
2233
+
2234
+ if (actionNode.isConnected) {
2235
+ actionNode.removeAttribute('disabled');
2236
+ if (labelNode) {
2237
+ labelNode.textContent = 'Browse...';
2238
+ }
2239
+ }
2240
+ return;
2241
+ }
2184
2242
  case 'choose-create-database-path': {
2185
2243
  const labelNode = actionNode.querySelector('[data-create-database-path-button-label]');
2186
2244
 
@@ -3116,6 +3174,9 @@ document.addEventListener('submit', async event => {
3116
3174
  const formData = submitter ? new FormData(form, submitter) : new FormData(form);
3117
3175
 
3118
3176
  switch (form.dataset.form) {
3177
+ case 'create-api-token':
3178
+ await createSettingsApiToken(String(formData.get('name') ?? ''));
3179
+ return;
3119
3180
  case 'open-connection': {
3120
3181
  resetStructureGraphForDatabaseChange();
3121
3182
  const connection = await submitOpenConnection({
@@ -3206,6 +3267,9 @@ document.addEventListener('submit', async event => {
3206
3267
  }
3207
3268
  return;
3208
3269
  }
3270
+ case 'delete-api-token-confirm':
3271
+ await submitDeleteSettingsApiTokenConfirmation();
3272
+ return;
3209
3273
  case 'document-insert-table': {
3210
3274
  const inserted = await submitDocumentInsertTable();
3211
3275
 
@@ -0,0 +1,38 @@
1
+ import { escapeHtml } from '../utils/format.js';
2
+
3
+ export const STANDARD_TEXT_INPUT_CLASS =
4
+ 'control-input w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors placeholder:text-on-surface-variant/35 focus:border-primary-container';
5
+
6
+ export function renderTextInput({
7
+ className = '',
8
+ dataAttributes = {},
9
+ disabled = false,
10
+ maxlength = null,
11
+ name = '',
12
+ placeholder = '',
13
+ readonly = false,
14
+ spellcheck = null,
15
+ type = 'text',
16
+ value = '',
17
+ } = {}) {
18
+ const attributes = [
19
+ `class="${escapeHtml([STANDARD_TEXT_INPUT_CLASS, className].filter(Boolean).join(' '))}"`,
20
+ name ? `name="${escapeHtml(name)}"` : '',
21
+ placeholder ? `placeholder="${escapeHtml(placeholder)}"` : '',
22
+ `type="${escapeHtml(type)}"`,
23
+ `value="${escapeHtml(value)}"`,
24
+ Number.isInteger(maxlength) ? `maxlength="${maxlength}"` : '',
25
+ disabled ? 'disabled' : '',
26
+ readonly ? 'readonly' : '',
27
+ typeof spellcheck === 'boolean' ? `spellcheck="${spellcheck}"` : '',
28
+ ...Object.entries(dataAttributes).map(([name, attributeValue]) => {
29
+ const attributeName = `data-${String(name).replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)}`;
30
+
31
+ return attributeValue === true
32
+ ? attributeName
33
+ : `${attributeName}="${escapeHtml(attributeValue)}"`;
34
+ }),
35
+ ].filter(Boolean);
36
+
37
+ return `<input ${attributes.join(' ')} />`;
38
+ }
@@ -7,6 +7,7 @@ import {
7
7
  isMarkdownTodoCopyColumnMode,
8
8
  } from "../utils/copyColumnExport.js";
9
9
  import { renderConnectionLogo } from "./connectionLogo.js";
10
+ import { renderTextInput } from "./formControls.js";
10
11
  import {
11
12
  analyzeQueryChartResult,
12
13
  getQueryChartTypeLabel,
@@ -27,13 +28,7 @@ function renderField({ label, name, type = "text", placeholder = "", value = ""
27
28
  <span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
28
29
  ${escapeHtml(label)}
29
30
  </span>
30
- <input
31
- class="control-input w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
32
- name="${escapeHtml(name)}"
33
- placeholder="${escapeHtml(placeholder)}"
34
- type="${escapeHtml(type)}"
35
- value="${escapeHtml(value)}"
36
- />
31
+ ${renderTextInput({ name, placeholder, type, value })}
37
32
  </label>
38
33
  `;
39
34
  }
@@ -152,14 +147,33 @@ function renderError(error) {
152
147
  `;
153
148
  }
154
149
 
155
- function renderOpenConnectionForm(modal) {
150
+ export function renderOpenConnectionForm(modal) {
156
151
  return `
157
152
  <form class="space-y-5" data-form="open-connection">
158
- ${renderField({
159
- label: "SQLite File Path",
160
- name: "path",
161
- placeholder: "/absolute/path/to/database.sqlite",
162
- })}
153
+ <label class="block space-y-2">
154
+ <span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
155
+ SQLite File Path
156
+ </span>
157
+ <span class="flex items-stretch gap-2">
158
+ ${renderTextInput({
159
+ className: "min-w-0 flex-1",
160
+ dataAttributes: { openDatabasePath: true },
161
+ name: "path",
162
+ placeholder: "/absolute/path/to/database.sqlite",
163
+ })}
164
+ <button
165
+ class="standard-button flex-none"
166
+ data-action="choose-open-database-path"
167
+ type="button"
168
+ >
169
+ <span class="material-symbols-outlined text-sm">folder_open</span>
170
+ <span data-open-database-path-button-label>Browse...</span>
171
+ </button>
172
+ </span>
173
+ <span class="block text-[11px] leading-5 text-on-surface-variant/60">
174
+ Choose an existing SQLite file, or enter an absolute path manually.
175
+ </span>
176
+ </label>
163
177
  ${renderField({
164
178
  label: "Label",
165
179
  name: "label",
@@ -272,13 +286,12 @@ export function renderCreateDatabaseForm(modal) {
272
286
  New SQLite File Path
273
287
  </span>
274
288
  <span class="flex items-stretch gap-2">
275
- <input
276
- class="control-input min-w-0 flex-1 border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
277
- data-create-database-path
278
- name="path"
279
- placeholder="/absolute/path/to/new-database.sqlite"
280
- type="text"
281
- />
289
+ ${renderTextInput({
290
+ className: "min-w-0 flex-1",
291
+ dataAttributes: { createDatabasePath: true },
292
+ name: "path",
293
+ placeholder: "/absolute/path/to/new-database.sqlite",
294
+ })}
282
295
  <button
283
296
  class="standard-button flex-none"
284
297
  data-action="choose-create-database-path"
@@ -847,6 +860,28 @@ function renderDeleteDocumentForm(modal) {
847
860
  ].join("");
848
861
  }
849
862
 
863
+ export function renderDeleteApiTokenForm(modal) {
864
+ return [
865
+ '<form class="space-y-5" data-form="delete-api-token-confirm"><div class="space-y-3">',
866
+ '<p class="text-sm leading-7 text-on-surface">Delete API token <span class="font-bold text-primary-container">',
867
+ escapeHtml(modal.tokenName ?? "API token"),
868
+ "</span>?</p>",
869
+ '<p class="text-sm leading-7 text-on-surface-variant/65">Requests using this token will immediately lose access to <span class="font-semibold text-on-surface">',
870
+ escapeHtml(modal.databaseLabel ?? "the active database"),
871
+ ".</span></p>",
872
+ '<div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-3 font-mono text-[10px] text-on-surface-variant/55">',
873
+ escapeHtml(modal.tokenPrefix ?? ""),
874
+ "...</div>",
875
+ "</div>",
876
+ renderError(modal.error),
877
+ '<div class="flex items-center justify-between gap-3 pt-2">',
878
+ '<button class="standard-button" data-action="close-modal" type="button">Cancel</button>',
879
+ '<button class="delete-button" type="submit">',
880
+ modal.submitting ? "Deleting..." : "Delete Token",
881
+ "</button></div></form>",
882
+ ].join("");
883
+ }
884
+
850
885
  function getDocumentInsertQueryTitle(query) {
851
886
  return query?.displayTitle || query?.title || query?.previewSql || query?.rawSql || "Saved query";
852
887
  }
@@ -1687,6 +1722,11 @@ export function renderModal(state) {
1687
1722
  title: "Delete Document",
1688
1723
  body: renderDeleteDocumentForm(modal),
1689
1724
  },
1725
+ "delete-api-token": {
1726
+ eyebrow: "Settings // Confirm token deletion",
1727
+ title: "Delete API Token",
1728
+ body: renderDeleteApiTokenForm(modal),
1729
+ },
1690
1730
  "document-insert-table": {
1691
1731
  eyebrow: "Documents // Saved query output",
1692
1732
  title: "Insert Table",
@@ -253,6 +253,11 @@ const state = {
253
253
  },
254
254
  settings: {
255
255
  data: { ...DEFAULT_SETTINGS },
256
+ section: 'information',
257
+ apiTokens: [],
258
+ tokenDatabase: null,
259
+ createdApiToken: null,
260
+ tokenSaving: false,
256
261
  loading: false,
257
262
  error: null,
258
263
  appVersion: null,
@@ -1310,6 +1315,9 @@ async function refreshSettingsState() {
1310
1315
  };
1311
1316
  state.settings.appVersion = response.metadata?.appVersion ?? null;
1312
1317
  state.settings.sqliteVersion = response.metadata?.sqliteVersion ?? null;
1318
+ state.settings.apiTokens = response.metadata?.apiTokens ?? [];
1319
+ state.settings.tokenDatabase = response.metadata?.activeDatabase ?? null;
1320
+ state.settings.createdApiToken = null;
1313
1321
  } catch (error) {
1314
1322
  state.settings.error = normalizeError(error);
1315
1323
  } finally {
@@ -1318,6 +1326,103 @@ async function refreshSettingsState() {
1318
1326
  }
1319
1327
  }
1320
1328
 
1329
+ export async function createSettingsApiToken(name) {
1330
+ state.settings.tokenSaving = true;
1331
+ state.settings.error = null;
1332
+ emitChange();
1333
+
1334
+ try {
1335
+ const response = await api.createApiToken(name);
1336
+ state.settings.apiTokens = response.metadata?.apiTokens ?? state.settings.apiTokens;
1337
+ state.settings.tokenDatabase = response.metadata?.activeDatabase ?? state.settings.tokenDatabase;
1338
+ state.settings.createdApiToken = response.data ?? null;
1339
+ pushToast('API token created. Store it now; it will not be shown again.', 'success');
1340
+ return response.data ?? null;
1341
+ } catch (error) {
1342
+ state.settings.error = normalizeError(error);
1343
+ return null;
1344
+ } finally {
1345
+ state.settings.tokenSaving = false;
1346
+ emitChange();
1347
+ }
1348
+ }
1349
+
1350
+ export function setSettingsSection(section) {
1351
+ const normalizedSection = section === 'api-tokens' ? 'api-tokens' : 'information';
1352
+
1353
+ if (state.settings.section === normalizedSection) {
1354
+ return;
1355
+ }
1356
+
1357
+ state.settings.section = normalizedSection;
1358
+ emitChange();
1359
+ }
1360
+
1361
+ export async function deleteSettingsApiToken(tokenId) {
1362
+ state.settings.tokenSaving = true;
1363
+ state.settings.error = null;
1364
+ emitChange();
1365
+
1366
+ try {
1367
+ const response = await api.deleteApiToken(tokenId);
1368
+ state.settings.apiTokens = response.metadata?.apiTokens ?? [];
1369
+ state.settings.tokenDatabase = response.metadata?.activeDatabase ?? state.settings.tokenDatabase;
1370
+
1371
+ if (state.settings.createdApiToken?.id === tokenId) {
1372
+ state.settings.createdApiToken = null;
1373
+ }
1374
+
1375
+ pushToast('API token deleted.', 'success');
1376
+ return true;
1377
+ } catch (error) {
1378
+ if (state.modal?.kind === 'delete-api-token') {
1379
+ withModalError(error);
1380
+ } else {
1381
+ state.settings.error = normalizeError(error);
1382
+ }
1383
+ return false;
1384
+ } finally {
1385
+ state.settings.tokenSaving = false;
1386
+ emitChange();
1387
+ }
1388
+ }
1389
+
1390
+ export function openDeleteSettingsApiTokenModal(tokenId) {
1391
+ const token = state.settings.apiTokens.find(candidate => candidate.id === tokenId);
1392
+
1393
+ if (!token) {
1394
+ pushToast('The selected API token could not be loaded.', 'alert');
1395
+ return;
1396
+ }
1397
+
1398
+ state.modal = {
1399
+ kind: 'delete-api-token',
1400
+ tokenId: token.id,
1401
+ tokenName: token.name,
1402
+ tokenPrefix: token.tokenPrefix,
1403
+ databaseLabel: state.settings.tokenDatabase?.label ?? 'active database',
1404
+ error: null,
1405
+ submitting: false,
1406
+ };
1407
+ emitChange();
1408
+ }
1409
+
1410
+ export async function submitDeleteSettingsApiTokenConfirmation() {
1411
+ if (state.modal?.kind !== 'delete-api-token') {
1412
+ return false;
1413
+ }
1414
+
1415
+ const tokenId = state.modal.tokenId;
1416
+ startModalSubmission();
1417
+ const deleted = await deleteSettingsApiToken(tokenId);
1418
+
1419
+ if (deleted) {
1420
+ closeModalInternal();
1421
+ }
1422
+
1423
+ return deleted;
1424
+ }
1425
+
1321
1426
  async function loadQueryHistoryDetail(historyId, options = {}) {
1322
1427
  const normalizedId = String(historyId ?? '').trim();
1323
1428
  const numericId = Number(normalizedId);
@@ -3490,6 +3595,16 @@ export async function submitCreateConnection(payload) {
3490
3595
  }
3491
3596
  }
3492
3597
 
3598
+ export async function chooseOpenDatabasePath() {
3599
+ try {
3600
+ const response = await api.chooseOpenDatabasePath();
3601
+ return response.data?.path ?? null;
3602
+ } catch (error) {
3603
+ pushToast(normalizeError(error).message || 'Native file dialog could not be opened.', 'alert');
3604
+ return null;
3605
+ }
3606
+ }
3607
+
3493
3608
  export async function chooseCreateDatabasePath() {
3494
3609
  try {
3495
3610
  const response = await api.chooseCreateDatabasePath();
@@ -1,6 +1,35 @@
1
1
  import { renderPageHeader } from '../components/pageHeader.js';
2
+ import { renderTextInput } from '../components/formControls.js';
2
3
  import { escapeHtml } from '../utils/format.js';
3
4
 
5
+ function renderSettingsNavigation(activeSection) {
6
+ const items = [
7
+ { id: 'information', icon: 'info', label: 'Information' },
8
+ { id: 'api-tokens', icon: 'key', label: 'API Tokens' },
9
+ ];
10
+
11
+ return `
12
+ <nav class="flex flex-wrap gap-2 border-b border-outline-variant/10 pb-4" aria-label="Settings sections">
13
+ ${items
14
+ .map(
15
+ item => `
16
+ <button
17
+ class="query-history-tab ${activeSection === item.id ? 'is-active' : ''}"
18
+ aria-current="${activeSection === item.id ? 'page' : 'false'}"
19
+ data-action="set-settings-section"
20
+ data-section="${item.id}"
21
+ type="button"
22
+ >
23
+ <span class="material-symbols-outlined text-sm mr-[5px]">${item.icon}</span>
24
+ ${item.label}
25
+ </button>
26
+ `,
27
+ )
28
+ .join('')}
29
+ </nav>
30
+ `;
31
+ }
32
+
4
33
  function renderSettingsContent(state) {
5
34
  if (state.settings.loading && !state.settings.appVersion) {
6
35
  return `
@@ -24,11 +53,97 @@ function renderSettingsContent(state) {
24
53
  `;
25
54
  }
26
55
 
56
+ const activeSection = state.settings.section === 'api-tokens' ? 'api-tokens' : 'information';
27
57
  const appVersion = escapeHtml(state.settings.appVersion ?? '0.0.0');
28
58
  const sqliteVersion = escapeHtml(state.settings.sqliteVersion ?? 'unknown');
59
+ const tokenDatabase = state.settings.tokenDatabase;
60
+ const apiTokens = state.settings.apiTokens ?? [];
61
+ const createdApiToken = state.settings.createdApiToken;
62
+ const tokenSaving = Boolean(state.settings.tokenSaving);
63
+ const tokenItems = apiTokens.length
64
+ ? apiTokens
65
+ .map(
66
+ token => `
67
+ <div class="flex flex-col gap-3 border border-outline-variant/10 bg-surface-container-high px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
68
+ <div class="min-w-0">
69
+ <div class="truncate text-sm font-semibold text-on-surface">${escapeHtml(token.name)}</div>
70
+ <div class="mt-1 font-mono text-[10px] text-on-surface-variant/60">
71
+ ${escapeHtml(token.tokenPrefix)}... · Created ${escapeHtml(token.createdAt ?? 'unknown')}
72
+ </div>
73
+ </div>
74
+ <button
75
+ class="delete-button"
76
+ data-action="open-delete-api-token-modal"
77
+ data-token-id="${escapeHtml(token.id)}"
78
+ type="button"
79
+ ${tokenSaving ? 'disabled' : ''}
80
+ >
81
+ Delete
82
+ </button>
83
+ </div>
84
+ `,
85
+ )
86
+ .join('')
87
+ : '<div class="text-sm text-on-surface-variant">No API tokens exist for this database.</div>';
88
+ const tokenSection = tokenDatabase
89
+ ? `
90
+ <div class="space-y-5">
91
+ <div class="space-y-3">
92
+ <div class="text-sm font-semibold text-on-surface">${escapeHtml(tokenDatabase.label)}</div>
93
+ <div>
94
+ <div class="text-[10px] font-mono uppercase tracking-[0.2em] text-on-surface-variant/60">
95
+ Database ID
96
+ </div>
97
+ <div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_9rem]">
98
+ <div
99
+ class="min-w-0 flex-1 break-all border border-outline-variant/20 bg-surface-container-lowest px-4 py-3 font-mono text-sm text-primary-container"
100
+ data-database-id
101
+ >${escapeHtml(tokenDatabase.id)}</div>
102
+ <button class="standard-button w-full self-center justify-center" data-action="copy-database-id" type="button">
103
+ <span class="material-symbols-outlined text-sm">content_copy</span>
104
+ Copy ID
105
+ </button>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ <form class="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_9rem]" data-form="create-api-token">
110
+ ${renderTextInput({
111
+ className: 'min-w-0 flex-1',
112
+ dataAttributes: { apiTokenName: true },
113
+ maxlength: 80,
114
+ name: 'name',
115
+ placeholder: 'Token name',
116
+ })}
117
+ <button class="standard-button w-full self-center justify-center" type="submit" ${tokenSaving ? 'disabled' : ''}>
118
+ Create Token
119
+ </button>
120
+ </form>
121
+ ${
122
+ createdApiToken?.token
123
+ ? `
124
+ <div class="border border-primary-container/20 bg-primary-container/5 p-4">
125
+ <div class="text-[10px] font-bold uppercase tracking-[0.2em] text-primary-container">Shown once</div>
126
+ <div class="mt-2 text-sm text-on-surface-variant">Store this token now. SQLite Hub only keeps its hash.</div>
127
+ <div class="mt-3 flex flex-col gap-2 sm:flex-row">
128
+ ${renderTextInput({
129
+ className: 'min-w-0 flex-1 font-mono',
130
+ dataAttributes: { createdApiToken: true },
131
+ readonly: true,
132
+ value: createdApiToken.token,
133
+ })}
134
+ <button class="standard-button" data-action="copy-created-api-token" type="button">Copy</button>
135
+ </div>
136
+ </div>
137
+ `
138
+ : ''
139
+ }
140
+ <div class="space-y-2">${tokenItems}</div>
141
+ </div>
142
+ `
143
+ : '<div class="text-sm leading-6 text-on-surface-variant">Select a database before creating database-specific API tokens.</div>';
29
144
 
30
- return `
31
- <div class="grid grid-cols-1 gap-6 xl:grid-cols-2">
145
+ const informationSection = `
146
+ <div class="grid grid-cols-1 gap-6 xl:grid-cols-2">
32
147
  <section class="shell-section overflow-hidden">
33
148
  <div class="flex items-center justify-between border-b border-outline-variant/10 bg-surface-container-highest px-4 py-2">
34
149
  <span class="text-[10px] font-bold uppercase tracking-[0.25em]">Application</span>
@@ -120,8 +235,26 @@ function renderSettingsContent(state) {
120
235
  </a>
121
236
  </div>
122
237
  </section>
123
- </div>
124
- `;
238
+ </div>
239
+ `;
240
+ const apiTokensSection = `
241
+ <section class="shell-section overflow-hidden">
242
+ <div class="flex items-center justify-between border-b border-outline-variant/10 bg-surface-container-highest px-4 py-2">
243
+ <span class="text-[10px] font-bold uppercase tracking-[0.25em] mr-px">API Tokens</span>
244
+ <span class="material-symbols-outlined text-xs text-primary-container">key</span>
245
+ </div>
246
+ <div class="p-6">
247
+ ${tokenSection}
248
+ </div>
249
+ </section>
250
+ `;
251
+
252
+ return `
253
+ <div class="space-y-6">
254
+ ${renderSettingsNavigation(activeSection)}
255
+ ${activeSection === 'api-tokens' ? apiTokensSection : informationSection}
256
+ </div>
257
+ `;
125
258
  }
126
259
 
127
260
  export function renderSettingsView(state) {
@@ -131,7 +264,10 @@ export function renderSettingsView(state) {
131
264
  <div class="view-frame mx-auto max-w-6xl space-y-8">
132
265
  ${renderPageHeader({
133
266
  title: 'Settings',
134
- subtitle: 'Application // Build + Credits',
267
+ subtitle:
268
+ state.settings.section === 'api-tokens'
269
+ ? 'Security // Database API access'
270
+ : 'Application // Build + Credits',
135
271
  })}
136
272
  ${renderSettingsContent(state)}
137
273
  </div>