diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/group-settings.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/group-settings.js | 393 |
1 files changed, 251 insertions, 142 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index de6f8c0..eeed786 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -177,42 +177,104 @@ function PhotoRootsRow({ folders, value, busy, msg, onSave }) { // ── Shared Directories Table ──────────────────────────────────────────── /** - * Reusable table of a group's root directories with per-root controls. + * A group's root directories, and the operator's controls over them. * - * Used in both the Settings page (with full edit controls) and the Create - * Group wizard (with add-only). Each root shows its name, a writable - * toggle, a removable badge, and eject/plug buttons for removable roots. + * One component, two modes, because the Create Group wizard and the Settings + * page were drifting apart while showing the same thing: + * + * mode="live" — a hosted group. Every change is a signed operator op sent + * over MNP, or the loopback API when the node is on this + * machine and there is no live connection. + * mode="local" — the wizard, before the group exists. Changes are held in + * an array the caller owns; nothing is persisted until the + * group is attached. + * + * **Both paths matter and neither is optional.** The operator of a node is not + * necessarily sitting at it: they may be signing in from any browser, and the + * only thing that reaches their node from there is MNP. An earlier version of + * this read its roots exclusively from the loopback API, which resolves to + * "not available" in a browser — so the section rendered for nobody on the + * web, while the controls it replaced had worked there. `mnpRoots` is the + * source whenever a connection exists; the loopback list is the fallback for + * a local node that is not currently connected (a group still scanning, say). * * Props: - * roots — array of { name, writable, removable, ejected, available, kind } - * groupId — the group id - * transport — MeshBayTransport instance (null when not connected) - * signFn — signing function for admin ops - * platform — platform bridge (for Electron root picker) - * nodeDetected — whether local node API is available - * readOnly — suppress edit controls (default false) - * onRootsChange — callback(roots) after a change - * onRefreshIndex — trigger a full index refresh after add/remove - */ -/** - * Two modes: - * mode="live" — connected to a node, persists changes via MNP/loopback API - * mode="local" — during group creation, manages a local array, reports changes - * via onLocalRootsChange(roots) + * roots — the node's current roots: { name, path, writable, + * removable, ejected, available, kind } + * groupId — the group id + * transport — MeshBayTransport instance, or null when not connected + * signFn — signing function for admin ops + * nodeDetected — whether the loopback node API answers + * readOnly — suppress every edit control + * onRootsChange — called after a change, to re-read the loopback list + * onRefreshIndex — full index refresh, needed after an add or a remove + * mode — "live" (default) or "local" + * localRoots / onLocalRootsChange — the array, in "local" mode */ function SharedDirectoriesTable({ roots, groupId, transport, signFn, - nodeDetected: nodeAvail, readOnly, - onRootsChange, onRefreshIndex, - mode = 'live', - localRoots, onLocalRootsChange }) { + nodeDetected: nodeAvail, readOnly, + onRootsChange, onRefreshIndex, + mode = 'live', + localRoots, onLocalRootsChange }) { const isLocal = mode === 'local'; - const [optimistic, setOptimistic] = useState({}); - const serverRoots = isLocal ? (localRoots || []) : roots; - const displayRoots = serverRoots.map(r => - optimistic[r.name] ? { ...r, ...optimistic[r.name] } : r); + const serverRoots = isLocal ? (localRoots || []) : (roots || []); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(''); const [indexProgress, setIndexProgress] = useState(null); + const [pathDraft, setPathDraft] = useState(''); + const [addingByPath, setAddingByPath] = useState(false); + + // A toggle has to move under the finger, and the answer only comes back + // when the node has signed, written node.toml and pushed the new table. + // The patch is therefore held until the incoming `roots` actually agrees + // with it — clearing it when the request resolves (which is what this did) + // drops it in the frame *before* the new table arrives, so the switch + // visibly snaps back and then forward again. + const [optimistic, setOptimistic] = useState({}); + useEffect(() => { + setOptimistic((prev) => { + const keys = Object.keys(prev); + if (!keys.length) return prev; + const next = {}; + let changed = false; + for (const name of keys) { + const server = serverRoots.find(r => r.name === name); + const patch = prev[name]; + // Gone from the table, or the server now says what we asked for: + // either way this patch has nothing left to hide. + const settled = !server + || Object.keys(patch).every(k => server[k] === patch[k]); + if (settled) changed = true; else next[name] = patch; + } + return changed ? next : prev; + }); + }, [serverRoots]); + + const displayRoots = serverRoots.map(r => + optimistic[r.name] ? { ...r, ...optimistic[r.name] } : r); + + // Which door a change goes through. MNP first: it is the only one that + // exists for an operator on the web, and it is signed, which the loopback + // API is not (it is authorized by being on localhost with the run token). + const overMnp = !isLocal && transport && transport.connected; + const overLoopback = !isLocal && !overMnp && nodeAvail; + const canEdit = !readOnly && (isLocal || overMnp || overLoopback); + + const rootUrl = (name, suffix = '') => + '/api/groups/' + groupId + '/roots/' + encodeURIComponent(name) + suffix; + + const run = useCallback(async (work, { refreshIndex = false } = {}) => { + setBusy(true); setMsg(''); + try { + await work(); + if (onRootsChange) await onRootsChange(); + if (refreshIndex && onRefreshIndex) await onRefreshIndex(); + return true; + } catch (err) { + setMsg(platform.bridgeMessage(err)); + return false; + } finally { setBusy(false); } + }, [onRootsChange, onRefreshIndex]); const doUpdateRoot = useCallback(async (rootName, updates) => { if (isLocal) { @@ -222,53 +284,35 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, } return; } - setOptimistic(prev => ({ ...prev, [rootName]: { ...(prev[rootName] || {}), ...updates } })); - setBusy(true); setMsg(''); - try { - if (transport && transport.connected) { - await transport.updateRoot(groupId, rootName, updates, signFn); - } else if (nodeAvail) { - await platform.node.call('PATCH', - '/api/groups/' + groupId + '/roots/' + encodeURIComponent(rootName), - updates); - } - if (onRootsChange) await onRootsChange(); - } catch (err) { setMsg(err.message); } - finally { - setOptimistic(prev => { const next = { ...prev }; delete next[rootName]; return next; }); - setBusy(false); + setOptimistic(prev => ({ + ...prev, [rootName]: { ...(prev[rootName] || {}), ...updates }, + })); + const ok = await run(async () => { + if (overMnp) await transport.updateRoot(groupId, rootName, updates, signFn); + else if (overLoopback) await platform.node.call('PATCH', rootUrl(rootName), updates); + else throw new Error(t('node.root_no_route')); + }); + // Only a failure clears the patch here; a success waits for the node's + // own table, so the switch never travels backwards on its way forwards. + if (!ok) { + setOptimistic(prev => { + const next = { ...prev }; delete next[rootName]; return next; + }); } - }, [isLocal, localRoots, onLocalRootsChange, transport, groupId, signFn, nodeAvail, onRootsChange]); + }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback, + transport, groupId, signFn, run]); - const doEjectRoot = useCallback(async (rootName) => { - if (isLocal) return; - setBusy(true); setMsg(''); - try { - if (transport && transport.connected) { - await transport.ejectRoot(groupId, rootName, signFn); - } else if (nodeAvail) { - await platform.node.call('PUT', - '/api/groups/' + groupId + '/roots/' + encodeURIComponent(rootName) + '/eject'); - } - if (onRootsChange) onRootsChange(); - } catch (err) { setMsg(err.message); } - finally { setBusy(false); } - }, [isLocal, transport, groupId, signFn, nodeAvail, onRootsChange]); + const doEjectRoot = useCallback((rootName) => run(async () => { + if (overMnp) await transport.ejectRoot(groupId, rootName, signFn); + else if (overLoopback) await platform.node.call('PUT', rootUrl(rootName, '/eject')); + else throw new Error(t('node.root_no_route')); + }), [overMnp, overLoopback, transport, groupId, signFn, run]); - const doPlugRoot = useCallback(async (rootName) => { - if (isLocal) return; - setBusy(true); setMsg(''); - try { - if (transport && transport.connected) { - await transport.plugRoot(groupId, rootName, signFn); - } else if (nodeAvail) { - await platform.node.call('PUT', - '/api/groups/' + groupId + '/roots/' + encodeURIComponent(rootName) + '/plug'); - } - if (onRootsChange) onRootsChange(); - } catch (err) { setMsg(err.message); } - finally { setBusy(false); } - }, [isLocal, transport, groupId, signFn, nodeAvail, onRootsChange]); + const doPlugRoot = useCallback((rootName) => run(async () => { + if (overMnp) await transport.plugRoot(groupId, rootName, signFn); + else if (overLoopback) await platform.node.call('PUT', rootUrl(rootName, '/plug')); + else throw new Error(t('node.root_no_route')); + }), [overMnp, overLoopback, transport, groupId, signFn, run]); const doRemoveRoot = useCallback(async (rootName) => { if (isLocal) { @@ -278,59 +322,99 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, return; } if (!confirm(t('node.root_remove_confirm', { name: rootName }))) return; - setBusy(true); setMsg(''); - try { - if (transport && transport.connected) { - await transport.removeRoot(groupId, rootName, signFn); - } else if (nodeAvail) { - await platform.node.call('DELETE', - '/api/groups/' + groupId + '/roots/' + encodeURIComponent(rootName)); + const ok = await run(async () => { + if (overMnp) await transport.removeRoot(groupId, rootName, signFn); + else if (overLoopback) { + await platform.node.call('DELETE', rootUrl(rootName)); await platform.node.call('POST', '/api/reload'); - } - setMsg(t('node.root_removed')); - if (onRootsChange) onRootsChange(); - if (onRefreshIndex) await onRefreshIndex(); - } catch (err) { setMsg(err.message); } - finally { setBusy(false); } - }, [isLocal, localRoots, onLocalRootsChange, transport, groupId, signFn, nodeAvail, onRootsChange, onRefreshIndex]); + } else throw new Error(t('node.root_no_route')); + }, { refreshIndex: true }); + if (ok) setMsg(t('node.root_removed')); + }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback, + transport, groupId, signFn, run]); - const doAddRoot = useCallback(async () => { - const chosen = await platform.rootPicker.choose(); - if (!chosen) return; + // Adding a root needs a directory that exists on the *node's* filesystem. + // With the node on this machine that is a native folder picker; from any + // other browser the operator has to type the path, because nothing in a web + // page can browse a remote disk. Both end at the same signed op. + const addRootAtPath = useCallback(async (path, name) => { if (isLocal) { - if ((localRoots || []).some(r => r.path === chosen.path)) return; + if ((localRoots || []).some(r => r.path === path)) return true; const isFirst = (localRoots || []).length === 0; - const newRoot = { - name: chosen.name, path: chosen.path, - writable: isFirst, removable: false, - }; - if (onLocalRootsChange) onLocalRootsChange([...(localRoots || []), newRoot]); - return; + // The first directory is writable so a new group can receive an upload + // without the operator having to find this switch first. Every later + // one is read-only until they say otherwise. + if (onLocalRootsChange) { + onLocalRootsChange([...(localRoots || []), + { name, path, writable: isFirst, removable: false }]); + } + return true; } - setBusy(true); setMsg(''); setIndexProgress(null); - try { - if (nodeAvail) { - await platform.node.call('POST', - '/api/groups/' + groupId + '/roots', - { path: chosen.path, name: chosen.name }); + setIndexProgress(null); + return run(async () => { + if (overMnp) { + await transport.addRoot(groupId, path, { name }, signFn); + } else if (overLoopback) { + await platform.node.call('POST', '/api/groups/' + groupId + '/roots', + { path, name }); await platform.node.call('POST', '/api/reload'); await platform.watchIndexProgress(groupId, setIndexProgress); - } - setMsg(t('node.root_added')); - if (onRootsChange) onRootsChange(); - if (onRefreshIndex) await onRefreshIndex(); - } catch (err) { setMsg(platform.bridgeMessage(err)); } - finally { setBusy(false); } - }, [isLocal, localRoots, onLocalRootsChange, groupId, nodeAvail, onRootsChange, onRefreshIndex]); + } else throw new Error(t('node.root_no_route')); + }, { refreshIndex: true }); + }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback, + transport, groupId, signFn, run]); + + const doPickRoot = useCallback(async () => { + const chosen = await platform.rootPicker.choose(); + if (!chosen) return; + const ok = await addRootAtPath(chosen.path, chosen.name); + if (ok && !isLocal) setMsg(t('node.root_added')); + }, [addRootAtPath, isLocal]); + + const doAddByPath = useCallback(async () => { + const path = pathDraft.trim(); + if (!path) return; + // The name is the node's business — it derives the basename and refuses a + // duplicate. Sending one guessed from a string typed here would be a + // second opinion about something already decided in one place. + const ok = await addRootAtPath(path, ''); + if (ok) { setPathDraft(''); setAddingByPath(false); if (!isLocal) setMsg(t('node.root_added')); } + }, [pathDraft, addRootAtPath, isLocal]); - if (!displayRoots || displayRoots.length === 0) { + const addControls = !canEdit ? '' : html` + ${platform.rootPicker.available ? html` + <button class="btn btn-small btn-secondary" style="margin-top:8px" + disabled=${busy} onClick=${doPickRoot}> + <${Icon} name="folder-plus" /> ${t('node.add_root')} + </button> + ` : addingByPath ? html` + <div class="sdt-add-row"> + <input class="sdt-add-input" type="text" value=${pathDraft} + placeholder=${t('node.root_path_placeholder')} + disabled=${busy} + onInput=${(e) => setPathDraft(e.target.value)} + onKeyDown=${(e) => { if (e.key === 'Enter') doAddByPath(); }} /> + <button class="btn btn-small btn-secondary" disabled=${busy || !pathDraft.trim()} + onClick=${doAddByPath}>${t('node.add_root')}</button> + <button class="btn btn-small" disabled=${busy} + onClick=${() => { setAddingByPath(false); setPathDraft(''); }}> + ${t('settings.cancel')}</button> + </div> + <p class="settings-hint">${t('node.root_path_hint')}</p> + ` : html` + <button class="btn btn-small btn-secondary" style="margin-top:8px" + disabled=${busy} onClick=${() => setAddingByPath(true)}> + <${Icon} name="folder-plus" /> ${t('node.add_root')} + </button> + `} + `; + + if (!displayRoots.length) { return html` <div class="shared-directories-table"> + ${msg && html`<p class="settings-hint">${msg}</p>`} <p class="settings-hint">${t('settings_node.shared_directories_hint')}</p> - <button class="btn btn-small btn-secondary" style="margin-top:8px" - onClick=${doAddRoot}> - <${Icon} name="folder-plus" /> ${t('node.add_root')} - </button> + ${addControls} </div> `; } @@ -342,15 +426,16 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, <thead> <tr> <th class="sdt-col-dir">${t('node.directory')}</th> - ${!readOnly && html`<th class="sdt-col-toggle">${t('node.root_rw')}</th>`} - ${!readOnly && !isLocal && html`<th class="sdt-col-toggle">${t('node.removable')}</th>`} + <th class="sdt-col-path">${t('node.root_path')}</th> + ${canEdit && html`<th class="sdt-col-toggle">${t('node.root_rw')}</th>`} + ${canEdit && !isLocal && html`<th class="sdt-col-toggle">${t('node.removable')}</th>`} <th class="sdt-col-actions"></th> </tr> </thead> <tbody> ${displayRoots.map(r => { const rowClass = r.ejected ? 'sdt-row-ejected' - : (!isLocal && !r.available) ? 'sdt-row-unavail' : ''; + : (!isLocal && r.available === false) ? 'sdt-row-unavail' : ''; return html` <tr class=${rowClass} key=${r.name}> <td class="sdt-col-dir"> @@ -360,30 +445,38 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, </span> ${r.ejected && html` <span class="node-root-badge node-root-badge-warn">${t('group.root_ejected')}</span>`} - ${!isLocal && !r.available && !r.ejected && html` + ${!isLocal && r.available === false && !r.ejected && html` <span class="node-root-badge node-root-badge-warn">${t('node.unavailable')}</span>`} </td> - ${!readOnly && html` + ${/* Two roots can never share a name, so the name is the identity — + but it is the *basename*, and two libraries under different + parents look identical without this. */''} + <td class="sdt-col-path" title=${r.path || ''}>${r.path || ''}</td> + ${canEdit && html` <td class="sdt-col-toggle"> <${ToggleSwitch} checked=${!!r.writable} disabled=${busy || !!r.ejected} onChange=${(v) => doUpdateRoot(r.name, { writable: v })} /> </td> `} - ${!readOnly && !isLocal && html` + ${canEdit && !isLocal && html` <td class="sdt-col-toggle"> <${ToggleSwitch} checked=${!!r.removable} disabled=${busy} onChange=${(v) => doUpdateRoot(r.name, { removable: v })} /> </td> `} <td class="sdt-col-actions"> - ${!readOnly && !isLocal && html` + ${canEdit && !isLocal && html` <button class="sdt-action-btn" disabled=${busy || !r.removable} title=${r.ejected ? t('group.root_plug') : t('group.root_eject')} onClick=${() => r.ejected ? doPlugRoot(r.name) : doEjectRoot(r.name)}> ${r.ejected ? '\u{1F50C}' : '\u{23CF}'} </button> - <button class="sdt-action-btn sdt-action-danger" disabled=${busy} - title=${t('node.remove_root')} + `} + ${canEdit && html` + <button class="sdt-action-btn sdt-action-danger" + disabled=${busy || displayRoots.length < 2} + title=${displayRoots.length < 2 + ? t('node.root_remove_last') : t('node.remove_root')} onClick=${() => doRemoveRoot(r.name)}> \u{2715} </button> @@ -393,13 +486,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, `; })} </tbody> </table> - ${!readOnly && (isLocal || nodeAvail) && html` - <button class="btn btn-small btn-secondary" style="margin-top:8px" - disabled=${busy} - onClick=${doAddRoot}> - <${Icon} name="folder-plus" /> ${t('node.add_root')} - </button> - `} + ${addControls} ${indexProgress && indexProgress.scanning && html` <div class="index-progress" style="margin-top:8px"> <div class="index-progress-bar"> @@ -435,6 +522,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, */ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, isNodeAdmin, userId, operatorPaired, connected, + mnpRoots, enabledApps, onEnabledApps, scanSettings, onScanSettings, tmdbConfig, onTmdbConfig, onTmdbEnabled, @@ -461,6 +549,29 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, // platform.watchIndexProgress. const [nodeIndexProgress, setNodeIndexProgress] = useState(null); + // The roots to show, from whichever source can actually answer. + // + // `mnpRoots` comes from the index payload the node pushes over the live + // connection, and is the only source an operator signing in from an + // ordinary browser has. `nodeRoots` comes from the loopback API and exists + // only on the machine running the node. Preferring MNP when connected also + // keeps this table on the same data Files and the apps read, so an eject + // shows in one place at the same instant it shows in the other. + const effectiveRoots = (connected && mnpRoots && mnpRoots.length) + ? mnpRoots : nodeRoots; + + // Declared here rather than inline at the call site: a function rebuilt on + // every render is a new prop identity every render, and the callbacks that + // close over it in the table below are memoised on it. + const adminSignFn = useCallback((transcript) => { + const sk = transportRef.current && transportRef.current.sessionKeys + && transportRef.current.sessionKeys.skEdB64; + if (!sk || !window.MeshBayKeys) { + throw new Error(t('node.root_no_signing_key')); + } + return window.MeshBayKeys.signBytes(sk, transcript); + }, [transportRef]); + const loadNodeInfo = useCallback(async () => { if (!platform.node.available) return; try { @@ -1102,24 +1213,22 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, </div> `} - ${/* Shared directories — the group's root folders. Shown to the - operator when the node is detected locally (Electron) or a live - MNP connection is available, so root properties can be toggled. - Appears early because it is the fundamental structural control. */ - isNodeAdmin && (connected || nodeDetected) && nodeRoots.length > 0 && html` + ${/* Shared directories — the group's root folders, and the structural + control everything else in this page sits on top of, so it comes + first. Rendered whenever the operator has a route to their node: + a live MNP connection (any browser, anywhere) or the loopback API + (the node on this machine). It used to require the second, which + meant it rendered for nobody on the web. */ + isNodeAdmin && (connected || nodeDetected) && html` <${CollapsibleSection} titleKey="settings_node.shared_directories_title"> <p class="settings-hint">${t('settings_node.shared_directories_hint')}</p> + ${!connected && nodeDetected && html` + <p class="settings-hint">${t('settings_node.roots_offline_hint')}</p>`} <${SharedDirectoriesTable} - roots=${nodeRoots} + roots=${effectiveRoots} groupId=${groupId} transport=${transportRef.current} - signFn=${(() => { - const sk = transportRef.current && transportRef.current.sessionKeys - && transportRef.current.sessionKeys.skEdB64; - return (sk && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) - : null; - })()} + signFn=${adminSignFn} nodeDetected=${nodeDetected} onRootsChange=${loadNodeInfo} onRefreshIndex=${onRefreshIndex} /> |