aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
diff options
context:
space:
mode:
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.js393
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} />