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 | 128 |
1 files changed, 55 insertions, 73 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 bde218b..29aa7eb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -22,21 +22,23 @@ export const INVITE_EMAIL_PREF = 'invite_email'; * 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="live" — a hosted group. What widens the node's sharing — adding + * a directory, its `writable` and `removable` switches — goes + * through the loopback API only, so only on the node's own + * machine. Removing, ejecting and plugging are signed ops + * over MNP, or the loopback API when there is no 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). + * **The list is shown wherever the operator is.** 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. `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). From a browser the table is read + * only where it would widen anything (MNP 6.0). * * Props: * roots — the node's current roots: { name, path, writable, @@ -75,8 +77,6 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, <p class=${msg.error ? 'error-msg' : 'settings-hint'} role=${msg.error ? 'alert' : 'status'} style="margin-top:10px">${msg.text}</p>`; - 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. @@ -115,12 +115,20 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, // left out otherwise, rather than printing a row of blanks. const hasPaths = displayRoots.some((r) => r.path); - // 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). + // Which door a change goes through. + // + // Widening what the node shares — a new directory, `writable`, `removable` — + // only through the loopback API, which exists only on the node's own + // machine. Over MNP these were signed ops, and a + // signature proves that the operator's key signed, not that the operator + // meant it: in a browser that key is driven by code the hub serves. + // + // Narrowing — remove, eject, plug — MNP first, then loopback. const overMnp = !isLocal && transport && transport.connected; const overLoopback = !isLocal && !overMnp && nodeAvail; + const onNodeMachine = !isLocal && nodeAvail; const canEdit = isLocal || overMnp || overLoopback; + const canWiden = isLocal || onNodeMachine; // Deliberately no index refresh after a root change. // @@ -158,9 +166,8 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, ...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.op('updateRoot', { groupId, rootName, updates }); - else throw new Error(t('node.root_no_route')); + if (onNodeMachine) await platform.node.op('updateRoot', { groupId, rootName, updates }); + else throw new Error(t('settings_node.roots_local_only_hint')); }); // 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. @@ -169,8 +176,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, const next = { ...prev }; delete next[rootName]; return next; }); } - }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback, - transport, groupId, signFn, run]); + }, [isLocal, localRoots, onLocalRootsChange, onNodeMachine, groupId, run]); const doEjectRoot = useCallback((rootName) => run(async () => { if (overMnp) await transport.ejectRoot(groupId, rootName, signFn); @@ -203,10 +209,9 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback, transport, groupId, signFn, run]); - // 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. + // Adding a root: a native folder picker on the node's own machine, and + // nothing anywhere else — a path typed into a page is a path a script in the + // page could have typed. const addRootAtPath = useCallback(async (path, name) => { if (isLocal) { if ((localRoots || []).some(r => r.path === path)) return true; @@ -222,16 +227,12 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, } setIndexProgress(null); return run(async () => { - if (overMnp) { - await transport.addRoot(groupId, path, { name }, signFn); - } else if (overLoopback) { - await platform.node.op('addRoot', { groupId, path, name }); - await platform.node.op('reload'); - await platform.watchIndexProgress(groupId, setIndexProgress); - } else throw new Error(t('node.root_no_route')); + if (!onNodeMachine) throw new Error(t('settings_node.roots_local_only_hint')); + await platform.node.op('addRoot', { groupId, path, name }); + await platform.node.op('reload'); + await platform.watchIndexProgress(groupId, setIndexProgress); }); - }, [isLocal, localRoots, onLocalRootsChange, overMnp, overLoopback, - transport, groupId, signFn, run]); + }, [isLocal, localRoots, onLocalRootsChange, onNodeMachine, groupId, run]); const doPickRoot = useCallback(async () => { const chosen = await platform.rootPicker.choose(); @@ -240,48 +241,23 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, if (ok && !isLocal) say(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) say(t('node.root_added')); } - }, [pathDraft, addRootAtPath, isLocal]); - - 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> - `} + const addControls = !canWiden || !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> `; + // Said once, under the table, rather than as a tooltip on each switch: the + // switches are not broken, they are somewhere else. + const localOnlyHint = canEdit && !canWiden && html` + <p class="settings-hint" style="margin-top:8px"> + ${t('settings_node.roots_local_only_hint')}</p>`; if (!displayRoots.length) { return html` <div class="shared-directories-table"> <p class="settings-hint">${t('settings_node.shared_directories_hint')}</p> + ${localOnlyHint} ${addControls} ${message} </div> @@ -326,14 +302,15 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, doing the job. */''} ${canEdit && html` <td class="sdt-col-toggle"> - <${ToggleSwitch} checked=${!!r.writable} disabled=${busy || !!r.ejected} + <${ToggleSwitch} checked=${!!r.writable} + disabled=${busy || !!r.ejected || !canWiden} label=${t('node.root_rw')} onChange=${(v) => doUpdateRoot(r.name, { writable: v })} /> </td> `} ${canEdit && !isLocal && html` <td class="sdt-col-toggle"> - <${ToggleSwitch} checked=${!!r.removable} disabled=${busy} + <${ToggleSwitch} checked=${!!r.removable} disabled=${busy || !canWiden} label=${t('node.removable')} onChange=${(v) => doUpdateRoot(r.name, { removable: v })} /> </td> @@ -360,6 +337,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, `; })} </tbody> </table> + ${localOnlyHint} ${addControls} ${message} ${indexProgress && indexProgress.scanning && html` @@ -426,6 +404,9 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, // Node loopback state (Electron-only) const [nodeDetected, setNodeDetected] = useState(false); + // A node on this machine is not necessarily the one hosting this group, and + // the table's loopback door is only a door to *that* node. + const [nodeHostsGroup, setNodeHostsGroup] = useState(false); const [nodeRoots, setNodeRoots] = useState([]); const [nodeGroupName, setNodeGroupName] = useState(''); const [nodeBusy, setNodeBusy] = useState(false); @@ -464,6 +445,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, const data = await platform.node.op('groups'); const groups = data.groups || []; const ng = groups.find(g => g.id === groupId); + setNodeHostsGroup(Boolean(ng)); if (ng) { setNodeRoots(ng.roots || []); setNodeGroupName(ng.name || ''); @@ -1182,7 +1164,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, groupId=${groupId} transport=${transportRef.current} signFn=${adminSignFn} - nodeDetected=${nodeDetected} + nodeDetected=${nodeDetected && nodeHostsGroup} onRootsChange=${loadNodeInfo} onRefreshIndex=${onRefreshIndex} /> </${CollapsibleSection}> |