@3sln/trove 0.0.4 → 0.0.7
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 +6 -0
- package/package.json +1 -1
- package/packages/core/src/apiKeys.js +326 -0
- package/packages/core/src/collections/index.js +83 -13
- package/packages/core/src/index.js +14 -2
- package/packages/core/src/issues.js +4 -0
- package/packages/core/src/storage/diagnose.js +234 -0
- package/packages/core/src/storage/drivers.js +83 -0
- package/packages/core/src/storage/filesystem.js +22 -0
- package/packages/core/src/storage/registry.js +162 -0
- package/packages/server/src/adapters/bun.js +6 -0
- package/packages/server/src/adapters/node.js +6 -0
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/access.js +47 -5
- package/packages/server/src/engine/providers/core.js +105 -11
- package/packages/server/src/index.js +123 -11
- package/packages/server/src/mcp/tools.js +40 -8
- package/packages/server/src/router.js +1 -1
- package/packages/server/src/routes.js +135 -32
- package/packages/server/src/scope.js +2 -2
- package/packages/web/dist/assets/main-f0f2tfhp.js +356 -0
- package/packages/web/dist/assets/{main-4cxs7prw.js.map → main-f0f2tfhp.js.map} +17 -16
- package/packages/web/dist/assets/{styles-kcx1x337.css → styles-d3cyysgp.css} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +58 -9
- package/packages/web/src/bl/actions.js +112 -13
- package/packages/web/src/bl/activity.js +32 -0
- package/packages/web/src/bl/commands.js +41 -2
- package/packages/web/src/bl/index.js +9 -4
- package/packages/web/src/bl/services.js +78 -1
- package/packages/web/src/platform/api.js +57 -14
- package/packages/web/src/platform/pluginRpc.js +7 -4
- package/packages/web/src/styles.css +137 -0
- package/packages/web/src/ui/components/activityPanel.js +28 -1
- package/packages/web/src/ui/components/collectionGate.js +81 -0
- package/packages/web/src/ui/components/overlays.js +64 -34
- package/packages/web/src/ui/components/phoneChrome.js +2 -2
- package/packages/web/src/ui/components/settingsView.js +197 -1
- package/packages/web/src/ui/components/statusBar.js +19 -2
- package/packages/web/src/ui/compositions/workbench.js +9 -2
- package/packages/web/dist/assets/main-4cxs7prw.js +0 -356
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The only thing on screen until a collection is chosen.
|
|
2
|
+
//
|
|
3
|
+
// Every collection-scoped request names its collection in the path, so before one is
|
|
4
|
+
// known there is no file list to draw, nothing to search, and nowhere to upload to. The
|
|
5
|
+
// old behaviour was to pick something — the remembered one, else `default`, else the
|
|
6
|
+
// first in the list — and that is exactly the guess this replaces: on a shared drive it
|
|
7
|
+
// opened a collection plenty of people could not read, and presented the permission error
|
|
8
|
+
// as their drive.
|
|
9
|
+
//
|
|
10
|
+
// So there are two questions and no guesses. A drive with nothing in it asks for a
|
|
11
|
+
// collection to be made; a drive with several asks which one. Once answered, the choice is
|
|
12
|
+
// remembered in localStorage and this never appears again.
|
|
13
|
+
|
|
14
|
+
import { dd } from '../../runtime.js';
|
|
15
|
+
import { icon } from '../icon.js';
|
|
16
|
+
|
|
17
|
+
const { div, h2, p, button, span } = dd;
|
|
18
|
+
|
|
19
|
+
export default function collectionGate(state, ui) {
|
|
20
|
+
const ex = state.ex || {};
|
|
21
|
+
return div({ className: 'editor' },
|
|
22
|
+
div({ className: 'stage' },
|
|
23
|
+
div({ className: 'gate' },
|
|
24
|
+
ex.gate === 'create' ? createPrompt(ui) : choosePrompt(ex, ui),
|
|
25
|
+
),
|
|
26
|
+
),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A drive with no collections at all.
|
|
32
|
+
*
|
|
33
|
+
* Only shown to someone who may actually create one — `OpenInitialCollectionAction`
|
|
34
|
+
* sends everyone else to the "ask an administrator" message instead, because inviting
|
|
35
|
+
* someone to do a thing they are not allowed to do is worse than saying nothing.
|
|
36
|
+
*/
|
|
37
|
+
function createPrompt(ui) {
|
|
38
|
+
return div({ className: 'gate-card' },
|
|
39
|
+
div({ className: 'gate-icon' }, icon('files', { size: 26 })),
|
|
40
|
+
h2('Make your first collection'),
|
|
41
|
+
p({ className: 'gate-sub' },
|
|
42
|
+
'A collection is a backing store you own — a bucket, a directory, a mount. Files '
|
|
43
|
+
+ 'live in one, and permissions are granted on one. Nothing can be uploaded until '
|
|
44
|
+
+ 'there is somewhere to put it.'),
|
|
45
|
+
div({ className: 'gate-actions' },
|
|
46
|
+
button({ className: 'btn primary' }, icon('plus', { size: 14 }), 'New collection')
|
|
47
|
+
.on({ click: () => ui.exec('collections.create') }),
|
|
48
|
+
),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Collections exist, but the user has not said which one they want. */
|
|
53
|
+
function choosePrompt(ex, ui) {
|
|
54
|
+
const collections = ex.collections || [];
|
|
55
|
+
return div({ className: 'gate-card' },
|
|
56
|
+
div({ className: 'gate-icon' }, icon('grid', { size: 26 })),
|
|
57
|
+
h2(collections.length === 1 ? 'Open your collection' : 'Choose a collection'),
|
|
58
|
+
p({ className: 'gate-sub' },
|
|
59
|
+
collections.length === 1
|
|
60
|
+
? 'This is the one you have access to. Opening it will be remembered for next time.'
|
|
61
|
+
: 'Files, search and uploads all belong to one collection. Your choice is remembered '
|
|
62
|
+
+ 'for next time, and you can switch from the status bar whenever you like.'),
|
|
63
|
+
div({ className: 'gate-list' },
|
|
64
|
+
...collections.map((c) => button({ className: 'gate-choice' },
|
|
65
|
+
div({ className: 'gate-choice-main' },
|
|
66
|
+
span({ className: 'n' }, c.name || c.id),
|
|
67
|
+
span({ className: 'd' }, c.driver ? `${c.driver}${c.description ? ` · ${c.description}` : ''}` : (c.description || '')),
|
|
68
|
+
),
|
|
69
|
+
// What they may do, from the server's own answer — so a read-only collection says
|
|
70
|
+
// so before they open it and find the Upload button missing.
|
|
71
|
+
span({ className: 'gate-caps' }, (c.capabilities || []).join(' · ')),
|
|
72
|
+
).on({ click: () => ui.exec('collections.switch', c.id) })),
|
|
73
|
+
),
|
|
74
|
+
ex.canCreateCollection
|
|
75
|
+
? div({ className: 'gate-actions' },
|
|
76
|
+
button({ className: 'btn' }, icon('plus', { size: 14 }), 'New collection')
|
|
77
|
+
.on({ click: () => ui.exec('collections.create') }),
|
|
78
|
+
)
|
|
79
|
+
: null,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -86,66 +86,96 @@ function openerChooserDialog(d, ui) {
|
|
|
86
86
|
);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
// A collection is a backing-store config
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
89
|
+
// A collection is a backing-store config, and which stores exist is the SERVER's answer.
|
|
90
|
+
//
|
|
91
|
+
// This form used to hardcode its own list — Filesystem / NAS, S3, Memory — and the fields
|
|
92
|
+
// for each. On Cloudflare Workers that offered a filesystem the runtime cannot provide, so
|
|
93
|
+
// the form could produce a collection the server would refuse to build. Now the drivers and
|
|
94
|
+
// their fields come from /api/capabilities, which reports what was actually registered.
|
|
95
|
+
//
|
|
96
|
+
// The form persists across re-renders (keyed to the dialog instance) so switching driver
|
|
97
|
+
// keeps what has been typed.
|
|
93
98
|
let colState = { ref: null, form: null };
|
|
94
99
|
function collectionDialog(d, ui) {
|
|
95
100
|
const wb = ui.platform.workbench;
|
|
101
|
+
const drivers = ui.platform.capabilities?.storageDrivers || [];
|
|
102
|
+
|
|
96
103
|
if (colState.ref !== d) {
|
|
97
|
-
colState = { ref: d, form: { name: '', description: '', driver:
|
|
104
|
+
colState = { ref: d, form: { name: '', description: '', driver: drivers[0]?.key || '' } };
|
|
98
105
|
}
|
|
99
106
|
const form = colState.form;
|
|
100
|
-
const
|
|
107
|
+
const driver = drivers.find((x) => x.key === form.driver) || drivers[0];
|
|
108
|
+
const set = (k) => (e) => {
|
|
109
|
+
form[k] = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
|
|
110
|
+
if (k === 'driver') ui.rerender?.();
|
|
111
|
+
};
|
|
112
|
+
|
|
101
113
|
const submit = () => {
|
|
114
|
+
// Only the fields this driver declared, so a leftover value from a driver the user
|
|
115
|
+
// switched away from is not smuggled into the store config.
|
|
102
116
|
const store = { driver: form.driver };
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (form.prefix) store.prefix = form.prefix;
|
|
117
|
+
for (const f of driver?.fields || []) {
|
|
118
|
+
const v = form[f.name];
|
|
119
|
+
if (v !== undefined && v !== '') store[f.name] = f.type === 'number' ? Number(v) : v;
|
|
107
120
|
}
|
|
108
|
-
if (form.driver === 'filesystem' && form.prefix) store.prefix = form.prefix;
|
|
109
121
|
d.onSubmit?.({ name: form.name, description: form.description, store });
|
|
110
122
|
};
|
|
123
|
+
|
|
111
124
|
const field = (lbl, k, ph = '') => div({ className: 'field', $styling: { 'margin-bottom': '10px' } },
|
|
112
125
|
label(lbl), input({ className: 'input', placeholder: ph }).on({ input: set(k) }));
|
|
126
|
+
|
|
127
|
+
const ready = !!form.name.trim() && !!form.driver
|
|
128
|
+
&& (driver?.fields || []).every((f) => !f.required || String(form[f.name] ?? '').trim());
|
|
129
|
+
|
|
113
130
|
return div({},
|
|
114
131
|
div({ className: 'scrim' }).on({ click: () => wb.closeDialog() }),
|
|
115
132
|
div({ className: 'dialog', $styling: { width: 'min(480px, 94vw)' } },
|
|
116
133
|
h3('New collection'),
|
|
117
134
|
div({ className: 'body' }, 'A collection is a backing store you own. Configure where its files live.'),
|
|
118
135
|
field('Name', 'name', 'Team Vault'),
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
drivers.length
|
|
137
|
+
? div({ className: 'field', $styling: { 'margin-bottom': '10px' } },
|
|
138
|
+
label('Backing store'),
|
|
139
|
+
select({ className: 'input' },
|
|
140
|
+
...drivers.map((x) => option({ value: x.key, selected: form.driver === x.key }, x.label)),
|
|
141
|
+
).on({ change: set('driver') }))
|
|
142
|
+
// No drivers at all means the deployment registered none, which is a server
|
|
143
|
+
// misconfiguration — saying so beats an empty dropdown.
|
|
144
|
+
: div({ className: 'body' }, 'This server has no storage drivers registered, so a collection cannot be created.'),
|
|
145
|
+
driver ? storeFields(driver, form, set) : null,
|
|
127
146
|
div({ className: 'row-actions' },
|
|
128
147
|
button({ className: 'btn' }, 'Cancel').on({ click: () => wb.closeDialog() }),
|
|
129
|
-
button({ className: 'btn primary' }
|
|
148
|
+
button({ className: 'btn primary', $attrs: ready ? {} : { disabled: 'true' } }, 'Create collection')
|
|
149
|
+
.on({ click: () => ready && submit() }),
|
|
130
150
|
),
|
|
131
151
|
),
|
|
132
152
|
);
|
|
133
153
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
f('Bucket', 'bucket', 'my-bucket'),
|
|
141
|
-
f('Prefix (optional)', 'prefix', 'team-a/'),
|
|
142
|
-
f('Region', 'region', 'auto'),
|
|
143
|
-
f('Endpoint (R2/MinIO; blank for AWS)', 'endpoint', 'https://<acct>.r2.cloudflarestorage.com'),
|
|
144
|
-
f('Access key id', 'accessKeyId'),
|
|
145
|
-
f('Secret access key', 'secretAccessKey', '', 'password'),
|
|
146
|
-
);
|
|
154
|
+
|
|
155
|
+
/** The chosen driver's declared fields, rendered from its descriptor. */
|
|
156
|
+
function storeFields(driver, form, set) {
|
|
157
|
+
if (!driver.fields.length) {
|
|
158
|
+
return div({ className: 'body', $styling: { 'font-size': '12px' } },
|
|
159
|
+
driver.description || 'This store needs no configuration.');
|
|
147
160
|
}
|
|
148
|
-
return div({
|
|
161
|
+
return div({},
|
|
162
|
+
driver.description
|
|
163
|
+
? div({ className: 'body', $styling: { 'font-size': '12px', 'margin-bottom': '10px' } }, driver.description)
|
|
164
|
+
: null,
|
|
165
|
+
...driver.fields.map((f) => (f.type === 'boolean'
|
|
166
|
+
? div({ className: 'field', $styling: { 'margin-bottom': '10px' } },
|
|
167
|
+
label({}, input({ type: 'checkbox' }).on({ change: set(f.name) }), ` ${f.label}`),
|
|
168
|
+
f.help ? div({ className: 'body', $styling: { 'font-size': '11.5px' } }, f.help) : null)
|
|
169
|
+
: div({ className: 'field', $styling: { 'margin-bottom': '10px' } },
|
|
170
|
+
label(f.label + (f.required ? '' : ' (optional)')),
|
|
171
|
+
input({
|
|
172
|
+
className: 'input', placeholder: f.placeholder, autocomplete: 'off',
|
|
173
|
+
// A field the driver marked secret is a password field, so a shoulder or a
|
|
174
|
+
// screen share does not read an access key out of the form.
|
|
175
|
+
type: f.type === 'password' || f.secret ? 'password' : (f.type === 'number' ? 'number' : 'text'),
|
|
176
|
+
}).on({ input: set(f.name) }),
|
|
177
|
+
f.help ? div({ className: 'body', $styling: { 'font-size': '11.5px' } }, f.help) : null))),
|
|
178
|
+
);
|
|
149
179
|
}
|
|
150
180
|
|
|
151
181
|
// ---- Context menu ----------------------------------------------------------
|
|
@@ -43,7 +43,7 @@ export function phoneTopBar(state, ui) {
|
|
|
43
43
|
// rows of chrome repeating "notes.txt" costs a fifth of the screen to say it twice.
|
|
44
44
|
const title = state.wb.activity === 'settings' ? 'Settings'
|
|
45
45
|
: state.wb.activity === 'plugins' ? 'Plugins'
|
|
46
|
-
: f.
|
|
46
|
+
: f.collectionLabel;
|
|
47
47
|
return div({ className: 'phonebar top' },
|
|
48
48
|
button({ className: 'pb-brand', title: 'Trove — home' }, img({ src: '/icon.svg', alt: 'Trove' }))
|
|
49
49
|
.on({ click: () => ui.exec('workbench.view.home') }),
|
|
@@ -112,7 +112,7 @@ function statusSheet(state, ui) {
|
|
|
112
112
|
const wb = ui.platform.workbench;
|
|
113
113
|
const go = (cmd) => () => { wb.closeSheet(); ui.exec(cmd); };
|
|
114
114
|
return div({ className: 'sheet-body' },
|
|
115
|
-
div({ className: 'sheet-title' }, f.
|
|
115
|
+
div({ className: 'sheet-title' }, f.collectionLabel),
|
|
116
116
|
|
|
117
117
|
// Problems and running work first: they are the reason someone opened this.
|
|
118
118
|
f.issues.length
|
|
@@ -3,7 +3,7 @@ import { prettyKey, eventToKey } from '../../platform/keybindings.js';
|
|
|
3
3
|
import { icon } from '../icon.js';
|
|
4
4
|
import { listAssociations, rememberOpener } from '../../bl/openers.js';
|
|
5
5
|
|
|
6
|
-
const { div, h2, h3, p, span, select, option, input, label, button } = dd;
|
|
6
|
+
const { div, h2, h3, p, span, select, option, input, label, button, ul, li, code } = dd;
|
|
7
7
|
|
|
8
8
|
export default function settingsView(state, ui) {
|
|
9
9
|
const groups = ui.platform.settings.grouped();
|
|
@@ -14,6 +14,7 @@ export default function settingsView(state, ui) {
|
|
|
14
14
|
p({ className: 'sub' }, 'Preferences are stored in this browser. Plugins contribute their own settings here too.'),
|
|
15
15
|
...groups.map((g) => group(g, ui)),
|
|
16
16
|
mcpSection(ui),
|
|
17
|
+
apiKeysSection(state, ui),
|
|
17
18
|
openersSection(ui),
|
|
18
19
|
keybindingsSection(ui),
|
|
19
20
|
),
|
|
@@ -170,6 +171,201 @@ function mcpSection(ui) {
|
|
|
170
171
|
// Default openers per file type — the "always use this" choices from the opener
|
|
171
172
|
// chooser. Each row shows the type → viewer; the × forgets it (so the next open of
|
|
172
173
|
// that type asks again). Empty when the user hasn't set any defaults.
|
|
174
|
+
// --- API keys -------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
const CAPS = [
|
|
177
|
+
{ id: 'read', label: 'Read', hint: 'list and download' },
|
|
178
|
+
{ id: 'write', label: 'Write', hint: 'upload and rename' },
|
|
179
|
+
{ id: 'delete', label: 'Delete', hint: 'trash and purge' },
|
|
180
|
+
{ id: 'admin', label: 'Admin', hint: 'includes all of the above' },
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
const ANY = '*';
|
|
184
|
+
|
|
185
|
+
/** One-shot guard so a render does not queue a second load while the first is in flight. */
|
|
186
|
+
let keysRequested = false;
|
|
187
|
+
|
|
188
|
+
function apiKeysSection(state, ui) {
|
|
189
|
+
const keys = state.keys || {};
|
|
190
|
+
|
|
191
|
+
// Loaded lazily, when Settings is first opened — the list is admin-only and most
|
|
192
|
+
// sessions never need it, so it is not worth a request at boot.
|
|
193
|
+
if (!keysRequested && !keys.loaded && !keys.loading) {
|
|
194
|
+
keysRequested = true;
|
|
195
|
+
ui.exec('keys.load');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// A non-admin gets a 403 here, which is the correct answer rather than an error worth
|
|
199
|
+
// showing. The section simply is not part of their settings screen.
|
|
200
|
+
if (keys.error && !keys.keys?.length) return null;
|
|
201
|
+
if (!keys.loaded) return null;
|
|
202
|
+
|
|
203
|
+
const collections = state.ex?.collections || [];
|
|
204
|
+
|
|
205
|
+
return div({ className: 'group' },
|
|
206
|
+
h3('API keys'),
|
|
207
|
+
p({ className: 'sub' },
|
|
208
|
+
'Give a script or a service access without giving it an account. A key carries '
|
|
209
|
+
+ 'capabilities and no identity, so anything it does is attributed to the key rather '
|
|
210
|
+
+ 'than to a person \u2014 and it can only reach the collections you scope it to.'),
|
|
211
|
+
|
|
212
|
+
keys.minted ? mintedBanner(keys.minted, ui) : null,
|
|
213
|
+
keys.draft ? draftForm(keys, collections, ui) : null,
|
|
214
|
+
|
|
215
|
+
!keys.draft && !keys.minted
|
|
216
|
+
? div({ className: 'keys-actions' },
|
|
217
|
+
button({ className: 'btn' }, icon('plus', { size: 14 }), 'New key')
|
|
218
|
+
.on({ click: () => ui.exec('keys.new') }))
|
|
219
|
+
: null,
|
|
220
|
+
|
|
221
|
+
keys.keys?.length
|
|
222
|
+
? div({ className: 'keys-list' }, ...keys.keys.map((k) => keyRow(k, keys, collections, ui)))
|
|
223
|
+
: div({ className: 'keys-empty' }, 'No keys yet.'),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The secret, shown once.
|
|
229
|
+
*
|
|
230
|
+
* Deliberately loud and deliberately blocking the rest of the section: the server stored
|
|
231
|
+
* only a hash, so this is the only moment the value exists anywhere. A quiet row in a
|
|
232
|
+
* table would be dismissed without being copied.
|
|
233
|
+
*/
|
|
234
|
+
function mintedBanner(minted, ui) {
|
|
235
|
+
const copy = () => {
|
|
236
|
+
navigator.clipboard?.writeText(minted.secret)
|
|
237
|
+
.then(() => ui.platform.notifications.success('Key copied'))
|
|
238
|
+
.catch(() => ui.platform.notifications.info(minted.secret, { sticky: true }));
|
|
239
|
+
};
|
|
240
|
+
return div({ className: 'key-minted' },
|
|
241
|
+
div({ className: 'key-minted-head' },
|
|
242
|
+
icon('warn', { size: 15 }),
|
|
243
|
+
span(`\u201c${minted.key.name}\u201d is ready \u2014 copy it now.`),
|
|
244
|
+
),
|
|
245
|
+
p({ className: 'key-minted-note' },
|
|
246
|
+
'This is the only time it can be shown. It is stored as a hash, so it cannot be '
|
|
247
|
+
+ 'recovered \u2014 if it is lost, revoke the key and make another.'),
|
|
248
|
+
div({ className: 'key-secret' },
|
|
249
|
+
code({ className: 'mono' }, minted.secret),
|
|
250
|
+
button({ className: 'btn small', title: 'Copy' }, icon('link', { size: 13 })).on({ click: copy }),
|
|
251
|
+
),
|
|
252
|
+
div({ className: 'keys-actions' },
|
|
253
|
+
button({ className: 'btn' }, 'Done').on({ click: () => ui.exec('keys.dismissMinted') }),
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function draftForm(keys, collections, ui) {
|
|
259
|
+
const draft = keys.draft;
|
|
260
|
+
const scopes = ui.app.apiKeys.draftScopes();
|
|
261
|
+
const ready = !!draft.name.trim() && !!scopes;
|
|
262
|
+
|
|
263
|
+
return div({ className: 'key-form' },
|
|
264
|
+
div({ className: 'setting' },
|
|
265
|
+
div({ className: 'info' },
|
|
266
|
+
div({ className: 't' }, 'Name'),
|
|
267
|
+
div({ className: 'd' }, 'What it is for. This is what the list shows, so make it recognisable.'),
|
|
268
|
+
),
|
|
269
|
+
div({ className: 'control' },
|
|
270
|
+
input({ className: 'input', value: draft.name, $attrs: { placeholder: 'CI uploader' } })
|
|
271
|
+
.on({ input: (e) => ui.app.apiKeys.patchDraft({ name: e.target.value }) }),
|
|
272
|
+
),
|
|
273
|
+
),
|
|
274
|
+
|
|
275
|
+
div({ className: 'setting' },
|
|
276
|
+
div({ className: 'info' },
|
|
277
|
+
div({ className: 't' }, 'Expires after'),
|
|
278
|
+
div({ className: 'd' }, 'Days from now. Leave blank and it never expires.'),
|
|
279
|
+
),
|
|
280
|
+
div({ className: 'control' },
|
|
281
|
+
input({
|
|
282
|
+
className: 'input', type: 'number', value: draft.expiresInDays,
|
|
283
|
+
$attrs: { min: '1', max: '3650', placeholder: 'never' },
|
|
284
|
+
}).on({ input: (e) => ui.app.apiKeys.patchDraft({ expiresInDays: e.target.value }) }),
|
|
285
|
+
),
|
|
286
|
+
),
|
|
287
|
+
|
|
288
|
+
div({ className: 'key-scopes' },
|
|
289
|
+
div({ className: 'key-scopes-head' },
|
|
290
|
+
div({ className: 't' }, 'What it may do'),
|
|
291
|
+
div({ className: 'd' },
|
|
292
|
+
'Per collection. A key with nothing ticked grants nothing and cannot be created.'),
|
|
293
|
+
),
|
|
294
|
+
scopeRow({ id: ANY, name: 'All collections', wildcard: true }, draft, ui),
|
|
295
|
+
...collections.map((c) => scopeRow(c, draft, ui)),
|
|
296
|
+
),
|
|
297
|
+
|
|
298
|
+
div({ className: 'keys-actions' },
|
|
299
|
+
button({ className: 'btn primary', $attrs: ready ? {} : { disabled: 'true' } },
|
|
300
|
+
keys.busy === 'mint' ? 'Creating\u2026' : 'Create key')
|
|
301
|
+
.on({ click: () => ready && ui.exec('keys.mint') }),
|
|
302
|
+
button({ className: 'btn' }, 'Cancel').on({ click: () => ui.exec('keys.cancel') }),
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function scopeRow(collection, draft, ui) {
|
|
308
|
+
const held = new Set(draft.caps[collection.id] || []);
|
|
309
|
+
return div({ className: `key-scope ${collection.wildcard ? 'wildcard' : ''}` },
|
|
310
|
+
div({ className: 'key-scope-name' },
|
|
311
|
+
icon(collection.wildcard ? 'grid' : 'files', { size: 14 }),
|
|
312
|
+
span(collection.name || collection.id),
|
|
313
|
+
),
|
|
314
|
+
div({ className: 'key-scope-caps' },
|
|
315
|
+
...CAPS.map((c) => label({ className: `cap ${held.has(c.id) ? 'on' : ''}`, title: c.hint },
|
|
316
|
+
input({ type: 'checkbox', checked: held.has(c.id) })
|
|
317
|
+
.on({ change: () => ui.app.apiKeys.toggleCap(collection.id, c.id) }),
|
|
318
|
+
span(c.label),
|
|
319
|
+
)),
|
|
320
|
+
),
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function keyRow(k, keys, collections, ui) {
|
|
325
|
+
const revoked = !!k.revokedAt;
|
|
326
|
+
const expired = k.expiresAt != null && k.expiresAt <= Date.now();
|
|
327
|
+
const dead = revoked || expired;
|
|
328
|
+
return div({ className: `key-row ${dead ? 'dead' : ''}` },
|
|
329
|
+
div({ className: 'key-row-main' },
|
|
330
|
+
div({ className: 'key-row-name' },
|
|
331
|
+
span({ className: 't' }, k.name),
|
|
332
|
+
revoked ? span({ className: 'key-tag' }, 'revoked')
|
|
333
|
+
: expired ? span({ className: 'key-tag' }, 'expired') : null,
|
|
334
|
+
),
|
|
335
|
+
div({ className: 'key-row-scopes' }, ...scopeSummary(k, collections)),
|
|
336
|
+
div({ className: 'key-row-meta' }, metaLine(k)),
|
|
337
|
+
),
|
|
338
|
+
!dead
|
|
339
|
+
? button({
|
|
340
|
+
className: 'btn small danger',
|
|
341
|
+
$attrs: keys.busy === k.id ? { disabled: 'true' } : {},
|
|
342
|
+
}, keys.busy === k.id ? '\u2026' : 'Revoke').on({ click: () => ui.exec('keys.revoke', k.id) })
|
|
343
|
+
: null,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function scopeSummary(k, collections) {
|
|
348
|
+
// The name if we know it, the id if we do not. A key can outlive the collection it was
|
|
349
|
+
// scoped to, and showing a bare id then is more honest than showing nothing — it is
|
|
350
|
+
// also the thing you would search for to find out what happened to it.
|
|
351
|
+
const nameOf = (id) => collections.find((c) => c.id === id)?.name || id;
|
|
352
|
+
return (k.scopes || []).map((s) => span({ className: 'key-chip' },
|
|
353
|
+
span({ className: 'where' }, s.collectionId === ANY ? 'all collections' : nameOf(s.collectionId)),
|
|
354
|
+
span({ className: 'what' }, s.capabilities.join(' \u00b7 ')),
|
|
355
|
+
));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function metaLine(k) {
|
|
359
|
+
const when = (ms) => new Date(ms).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
|
360
|
+
const bits = [`created ${when(k.createdAt)}`];
|
|
361
|
+
if (k.createdBy) bits.push(`by ${k.createdBy}`);
|
|
362
|
+
// "Never used" is the more useful of the two facts: it is how you find the key nobody
|
|
363
|
+
// needs and can safely revoke.
|
|
364
|
+
bits.push(k.lastUsedAt ? `last used ${when(k.lastUsedAt)}` : 'never used');
|
|
365
|
+
if (k.expiresAt) bits.push(`expires ${when(k.expiresAt)}`);
|
|
366
|
+
return bits.join(' \u00b7 ');
|
|
367
|
+
}
|
|
368
|
+
|
|
173
369
|
function openersSection(ui) {
|
|
174
370
|
const rows = listAssociations(ui.platform);
|
|
175
371
|
return div({ className: 'group' },
|
|
@@ -97,7 +97,15 @@ export function statusFacts(state, ui) {
|
|
|
97
97
|
const items = ex.items || [];
|
|
98
98
|
const act = state.act || { tasks: [], issues: [] };
|
|
99
99
|
return {
|
|
100
|
-
|
|
100
|
+
// `null` with nothing open — `collectionLabel` renders that as "no collection". The
|
|
101
|
+
// old fallback made the bar name a collection that may not exist, on a drive where
|
|
102
|
+
// the user had not yet chosen one.
|
|
103
|
+
collectionId: ex.collectionId ?? null,
|
|
104
|
+
// What to CALL it, so the phone shell and the desktop bar cannot end up saying
|
|
105
|
+
// different things — which is the entire reason these facts are derived once. The
|
|
106
|
+
// phone rendered `collectionId` raw, which showed an opaque `col_…` id where the
|
|
107
|
+
// desktop showed the name.
|
|
108
|
+
collectionLabel: collectionLabel(ex),
|
|
101
109
|
// The COLLECTION's totals when the server could give them, not the page's. Summing
|
|
102
110
|
// what happens to be loaded reports a 3,000-file drive as 500 files — a wrong number,
|
|
103
111
|
// not a rounded one. Falls back to the page only when the server didn't say.
|
|
@@ -117,6 +125,13 @@ export function statusFacts(state, ui) {
|
|
|
117
125
|
};
|
|
118
126
|
}
|
|
119
127
|
|
|
128
|
+
/** What to call the current collection: its name, its id, or an honest nothing. */
|
|
129
|
+
function collectionLabel(ex) {
|
|
130
|
+
if (!ex?.collectionId) return 'no collection';
|
|
131
|
+
const match = (ex.collections || []).find((c) => c.id === ex.collectionId);
|
|
132
|
+
return match?.name || ex.collectionId;
|
|
133
|
+
}
|
|
134
|
+
|
|
120
135
|
export default function statusBar(state, ui) {
|
|
121
136
|
const ex = state.ex;
|
|
122
137
|
const items = ex.items || [];
|
|
@@ -156,7 +171,9 @@ export default function statusBar(state, ui) {
|
|
|
156
171
|
} })
|
|
157
172
|
: null,
|
|
158
173
|
button({ className: 'seg', title: 'Switch collection' },
|
|
159
|
-
|
|
174
|
+
// The NAME, and nothing invented. `|| 'default'` used to sit here, which meant the
|
|
175
|
+
// status bar cheerfully named a collection on a drive that had none.
|
|
176
|
+
icon('files', { size: 13 }), span(collectionLabel(ex)),
|
|
160
177
|
(ex.collections || []).length > 1 || ex.canCreateCollection ? icon('chevron-down', { size: 11 }) : null)
|
|
161
178
|
.on({ click: (e) => {
|
|
162
179
|
const items = ui.app.collectionMenu?.() || [];
|
|
@@ -10,6 +10,7 @@ import activityBar from '../components/activityBar.js';
|
|
|
10
10
|
import statusBar from '../components/statusBar.js';
|
|
11
11
|
import launcher from '../components/launcher.js';
|
|
12
12
|
import settingsView from '../components/settingsView.js';
|
|
13
|
+
import collectionGate from '../components/collectionGate.js';
|
|
13
14
|
import pluginsView from '../components/pluginsView.js';
|
|
14
15
|
import editorArea from '../components/editorArea.js';
|
|
15
16
|
import commandPalette from '../components/commandPalette.js';
|
|
@@ -43,6 +44,7 @@ export default function workbench({ engine, app, platform, plugins }) {
|
|
|
43
44
|
platform.workbench.observeNav(),
|
|
44
45
|
app.explorer.observe(),
|
|
45
46
|
app.search.observe(),
|
|
47
|
+
app.apiKeys.observe(),
|
|
46
48
|
app.transfers.observe(),
|
|
47
49
|
platform.notifications.observe(),
|
|
48
50
|
platform.context.observe(),
|
|
@@ -60,8 +62,8 @@ export default function workbench({ engine, app, platform, plugins }) {
|
|
|
60
62
|
// shallow-equal to the last one, so a forced re-render that left no trace in the
|
|
61
63
|
// object would be discarded as "nothing changed" — which is the opposite of what
|
|
62
64
|
// asking for one means.
|
|
63
|
-
(wb, overlay, nav, ex, se, tr, notif, ctx, settings, pluginList, statusItems, so, off, act, vp, voice, _bump) =>
|
|
64
|
-
({ wb, overlay, nav, ex, se, tr, notif, ctx, settings, plugins: pluginList, statusItems, so, off, act, vp, voice, _bump }),
|
|
65
|
+
(wb, overlay, nav, ex, se, keys, tr, notif, ctx, settings, pluginList, statusItems, so, off, act, vp, voice, _bump) =>
|
|
66
|
+
({ wb, overlay, nav, ex, se, keys, tr, notif, ctx, settings, plugins: pluginList, statusItems, so, off, act, vp, voice, _bump }),
|
|
65
67
|
);
|
|
66
68
|
|
|
67
69
|
return alias(() => watch(combined, (state) => view(state, ui)));
|
|
@@ -94,6 +96,11 @@ function view(state, ui) {
|
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
function mainArea(state, ui) {
|
|
99
|
+
// Before a collection is known there is nothing to draw: every scoped request names one
|
|
100
|
+
// in its path, so a file list, a search box and an Upload button would all be lying.
|
|
101
|
+
// Settings stays reachable — an admin with no collections still needs to get at it.
|
|
102
|
+
if (state.ex?.gate && state.wb.activity !== 'settings') return collectionGate(state, ui);
|
|
103
|
+
|
|
97
104
|
switch (state.wb.activity) {
|
|
98
105
|
case 'settings': return settingsView(state, ui);
|
|
99
106
|
case 'plugins': return pluginsView(state, ui);
|