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.
- package/README.md +101 -19
- package/bin/sqlite-hub.js +83 -401
- package/examples/api/queries.js +31 -0
- package/examples/api/rows.js +27 -0
- package/frontend/assets/mockups/charts_1_bars_1200.webp +0 -0
- package/frontend/assets/mockups/charts_2_pie_1200.webp +0 -0
- package/frontend/assets/mockups/charts_3_scatter_plot_1200.webp +0 -0
- package/frontend/assets/mockups/connections_1200.webp +0 -0
- package/frontend/assets/mockups/data_1_1200.webp +0 -0
- package/frontend/assets/mockups/data_2_row_editor_1200.webp +0 -0
- package/frontend/assets/mockups/documents_1200.webp +0 -0
- package/frontend/assets/mockups/media_tagging_1_setup_1200.webp +0 -0
- package/frontend/assets/mockups/media_tagging_2_tagging_queue_1200.webp +0 -0
- package/frontend/assets/mockups/media_tagging_3_media_viewer_1200.webp +0 -0
- package/frontend/assets/mockups/overview_1200.webp +0 -0
- package/frontend/assets/mockups/settings_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_1_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_2_query_details_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_3_export_column_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_4_export_column_as_markdown_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_5_export_query_result_1200.webp +0 -0
- package/frontend/assets/mockups/structure_1_1200.webp +0 -0
- package/frontend/assets/mockups/structure_2_inspector_1200.webp +0 -0
- package/frontend/assets/mockups/table_designer_1_1200.webp +0 -0
- package/frontend/assets/mockups/table_designer_2_checks_1200.webp +0 -0
- package/frontend/js/api.js +19 -0
- package/frontend/js/app.js +64 -0
- package/frontend/js/components/formControls.js +38 -0
- package/frontend/js/components/modal.js +60 -20
- package/frontend/js/store.js +115 -0
- package/frontend/js/views/settings.js +141 -5
- package/frontend/styles/tailwind.generated.css +2 -2
- package/package.json +6 -6
- package/server/middleware/apiTokenAuth.js +30 -0
- package/server/routes/connections.js +17 -0
- package/server/routes/externalApi.js +222 -0
- package/server/routes/settings.js +64 -4
- package/server/server.js +22 -1
- package/server/services/apiTokenService.js +101 -0
- package/server/services/databaseCommandService.js +399 -0
- package/server/services/nativeFileDialogService.js +93 -1
- package/server/services/storage/appStateStore.js +113 -0
- package/server/utils/errors.js +7 -0
- package/tests/api-token-auth.test.js +127 -0
- package/tests/cli-service-delegation.test.js +43 -0
- package/tests/connections-file-dialog-route.test.js +43 -0
- package/tests/copy-column-modal.test.js +34 -0
- package/tests/database-command-service.test.js +102 -0
- package/tests/form-controls.test.js +34 -0
- package/tests/native-file-dialog.test.js +27 -0
- package/tests/settings-api-tokens-route.test.js +76 -0
- package/tests/settings-view.test.js +47 -0
- package/frontend/assets/mockups/connections.png +0 -0
- package/frontend/assets/mockups/data.png +0 -0
- package/frontend/assets/mockups/data_row_editor.png +0 -0
- package/frontend/assets/mockups/home.png +0 -0
- package/frontend/assets/mockups/sql_editor.png +0 -0
- package/frontend/assets/mockups/sql_editor_croped.png +0 -0
- package/frontend/assets/mockups/sql_editor_querydetail.png +0 -0
- package/frontend/assets/mockups/structure.png +0 -0
- package/frontend/assets/mockups/structure_inspector.png +0 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/frontend/js/api.js
CHANGED
|
@@ -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",
|
package/frontend/js/app.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
name
|
|
279
|
-
placeholder
|
|
280
|
-
|
|
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",
|
package/frontend/js/store.js
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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:
|
|
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>
|