diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-20 08:56:23 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-20 08:56:23 +0200 |
| commit | c8af746c846b5dbc792f7e4f0d806647d513cc5c (patch) | |
| tree | 56c54ec5c703ec042589697b22b53e42451a61e4 /packages/meshbay-hub/src | |
| parent | 90c3c6a01f46c9b29c5d92702d7383f32e8951d7 (diff) | |
| download | meshbay-c8af746c846b5dbc792f7e4f0d806647d513cc5c.tar.gz | |
feat(node): full Node admin panel — CLI parity, hot-reload, group lifecycle
Node admin panel (NodePage) now covers every CLI operation over MNP:
group attach/detach, roster, member unpin, GEK rotate, denylist, reload.
Daemon hot-loads new groups and tears down removed ones on config reload
instead of requiring a full restart. Group attach/detach via MNP or
local API triggers an automatic reload so the group is live immediately.
Fixed GroupPage hang on first visit to a newly created group: the JWT
issued at login didn't include the new group, the node rejected with
not_a_member, and the token-refresh path returned without re-triggering
the connect effect (Boolean(token) didn't change). Now bumps retryKey
after a successful refresh so the effect re-runs with the fresh token.
NodePage marks groups hosted by the node but absent from the hub with a
"not on hub" badge so stale groups are visible and easy to remove.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src')
5 files changed, 922 insertions, 12 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 816c0ab..3fdba07 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -403,6 +403,9 @@ const ICON_PATHS = { 'bell-off': ['M18 9a6 6 0 0 0-12 0c0 6-2.5 7.5-2.5 7.5h17S18 15 18 9', 'M10.3 20a2 2 0 0 0 3.4 0', 'M4 4l16 16'], + server: ['M4 6.5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2z', + 'M4 15.5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2z', + 'M8 7.5h.01', 'M8 16.5h.01'], }; // The M of the wordmark is a picture; the rest is text. Resolved from this @@ -618,7 +621,7 @@ function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount, // ── Sidebar ────────────────────────────────────────────────────────────────── -function Sidebar({ groups, presence, route, menuOpen, role }) { +function Sidebar({ groups, presence, route, menuOpen, role, hasNodeKey }) { const isStaff = role === 'moderator' || role === 'admin'; return html` <aside class="sidebar ${menuOpen ? 'open' : ''}"> @@ -638,6 +641,13 @@ function Sidebar({ groups, presence, route, menuOpen, role }) { <a class="sidebar-item ${route === '/create-group' ? 'active' : ''}" href="#/create-group"><${Icon} name="plus" /> ${t('sidebar.create_group')}</a> </div> + ${platform.capabilities.nodeAdmin && hasNodeKey && html` + <div class="sidebar-section"> + <div class="sidebar-heading">${t('sidebar.node')}</div> + <a class="sidebar-item ${route === '/node' ? 'active' : ''}" + href="#/node"><${Icon} name="server" /> ${t('node.title')}</a> + </div> + `} <div class="sidebar-section"> <div class="sidebar-heading">${t('sidebar.my_groups')}</div> ${groups.length === 0 @@ -1479,7 +1489,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, if (err.reason === 'not_a_member' && !refreshedRef.current && onRefreshAuth) { refreshedRef.current = true; try { - if (await onRefreshAuth()) return; // new token → effect re-runs + if (await onRefreshAuth()) { + setRetryKey(k => k + 1); + return; + } } catch { /* fall through to the message below */ } } @@ -1494,6 +1507,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, if (err.reason === 'unknown_device') setNeedsDevice(true); setError(err.message); setStatus('error'); + if (transportRef.current) { + try { transportRef.current.close(); } catch { /* already gone */ } + transportRef.current = null; + } // A refusal means the node answered, so it is up; only a failure to // reach it at all is evidence of absence. if (onPresence) { @@ -1966,7 +1983,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, </button> `} </div> - ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`} + ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}${' '} + <button class="admin-btn" style="margin-left:8px;font-size:0.9em" + onClick=${() => setRetryKey(k => k + 1)}>${t('group.retry')}</button> + </div>`} ${needsDevice && html` <div class="invite-form" style="margin-bottom:12px"> <h4>${t('device.add_title')}</h4> @@ -2016,9 +2036,12 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, <${Icon} name="gear" cls="tab-icon" /></button> </div> - ${tab === 'files' && status !== 'connected' && html` + ${tab === 'files' && (status === 'discovering' || status === 'connecting' || status === 'fetching') && html` <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p> `} + ${tab === 'files' && status === 'offline' && html` + <p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p> + `} ${tab === 'files' && status === 'connected' && html` <div class="file-toolbar"> @@ -2153,9 +2176,12 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, else setPreviewEntry(entry); }} /> `} - ${tab === 'chat' && status !== 'connected' && html` + ${tab === 'chat' && (status === 'discovering' || status === 'connecting' || status === 'fetching') && html` <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p> `} + ${tab === 'chat' && status === 'offline' && html` + <p class="page-message">${t('group.offline_title')} ${t('group.offline_hint')}</p> + `} ${tab === 'settings' && html` <${GroupSettingsPanel} groupId=${groupId} group=${group} token=${token} @@ -4935,6 +4961,529 @@ function BlocklistForm({ onAdd }) { `; } +// ── Node management (D5) ──────────────────────────────────────────────────── + +function NodePage({ token, username, userId, groups }) { + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(''); + const [nodeGroups, setNodeGroups] = useState([]); + const [busy, setBusy] = useState(false); + const [actionMsg, setActionMsg] = useState(''); + const [roster, setRoster] = useState(null); + const [rosterGroup, setRosterGroup] = useState(''); + const [denylist, setDenylist] = useState(null); + const [showDenylist, setShowDenylist] = useState(false); + const [attachGroup_, setAttachGroup_] = useState(''); + const [attachDir, setAttachDir] = useState(''); + const [attachUpload, setAttachUpload] = useState(''); + const transportRef = useRef(null); + + const connectAndFetch = useCallback(async () => { + if (!groups.length) { + setStatus('no_groups'); + return; + } + setStatus('connecting'); + setError(''); + try { + if (!_bundleKey) _bundleKey = await _loadBundleKey(); + const live = (await ensureFreshToken()) || token; + + const nodeCandidates = (await Promise.all( + groups.map(g => + hubFetch(`/v1/groups/${g.id}/nodes`, { token: live }) + .then(d => (d.nodes || []).map(n => ({ groupId: g.id, nodeId: n.node_id }))) + .catch(() => [])) + )).flat(); + + if (!nodeCandidates.length) { + setError(t('node.offline')); + setStatus('error'); + return; + } + + // Group candidates by node, preserving all groupIds. The same node + // is reachable through any group it serves; a group that was just + // attached but not yet loaded will fail, so we try every groupId + // before giving up on a node. + const byNode = new Map(); + for (const c of nodeCandidates) { + if (!byNode.has(c.nodeId)) byNode.set(c.nodeId, []); + const ids = byNode.get(c.nodeId); + if (!ids.includes(c.groupId)) ids.push(c.groupId); + } + + console.log('[NodePage] candidates:', byNode.size, 'node(s)', + [...byNode.entries()].map(([n, gs]) => n.slice(0, 8) + '×' + gs.length)); + let lastErr = null; + for (const [nodeId, groupIds] of byNode) { + for (const groupId of groupIds) { + const transport = new window.MeshBayTransport(HUB, live); + try { + console.log('[NodePage] connecting via group', groupId.slice(0, 8), + 'node', nodeId.slice(0, 8)); + await transport.connect(nodeId, live, groupId, + null, null, _bundleKey, username, userId); + console.log('[NodePage] connected, fetching status'); + const result = await transport.fetchNodeStatus(); + console.log('[NodePage] got status:', result.groups?.length, 'groups'); + transportRef.current = transport; + setNodeGroups(result.groups || []); + setStatus('connected'); + return; + } catch (err) { + console.warn('[NodePage] candidate failed:', nodeId.slice(0, 8), + 'group', groupId.slice(0, 8), err.message); + try { transport.close(); } catch {} + lastErr = err; + } + } + } + setError(lastErr ? lastErr.message : t('node.not_operator')); + setStatus('error'); + } catch (err) { + setError(err.message); + setStatus('error'); + } + }, [token, username, userId, groups]); + + useEffect(() => { + connectAndFetch(); + return () => { + const tr = transportRef.current; + if (tr && tr.connected) try { tr.close(); } catch {} + }; + }, [connectAndFetch]); + + const refresh = useCallback(async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + try { + const result = await transport.fetchNodeStatus(); + setNodeGroups(result.groups || []); + } catch {} + }, []); + + const signFn = useCallback(async (transcript) => { + const transport = transportRef.current; + if (!transport || !transport.sessionKeys || !transport.sessionKeys.skEdB64) + throw new Error('No signing key'); + return window.MeshBayKeys.signBytes(transport.sessionKeys.skEdB64, transcript); + }, []); + + const addRoot = useCallback(async (groupId) => { + const chosen = await platform.rootPicker.choose(); + if (!chosen) return; + const transport = transportRef.current; + if (!transport || !transport.connected) return; + setBusy(true); + setActionMsg(''); + try { + await transport.addRoot(groupId, chosen.path, {}, signFn); + await refresh(); + setActionMsg(t('node.root_added')); + } catch (err) { + setActionMsg(err.message); + } finally { + setBusy(false); + } + }, [signFn, refresh]); + + const removeRoot = useCallback(async (groupId, rootName) => { + if (!confirm(t('node.root_remove_confirm', { name: rootName }))) return; + const transport = transportRef.current; + if (!transport || !transport.connected) return; + setBusy(true); + setActionMsg(''); + try { + await transport.removeRoot(groupId, rootName, signFn); + await refresh(); + setActionMsg(t('node.root_removed')); + } catch (err) { + setActionMsg(err.message); + } finally { + setBusy(false); + } + }, [signFn, refresh]); + + const loadRoster = useCallback(async (groupId) => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + setBusy(true); + try { + const result = await transport.fetchRoster(groupId); + setRoster(result); + setRosterGroup(groupId); + } catch (err) { + setActionMsg(err.message); + } finally { + setBusy(false); + } + }, []); + + const unpinMember = useCallback(async (userId) => { + if (!confirm(t('node.unpin_confirm', { name: userId }))) return; + const transport = transportRef.current; + if (!transport || !transport.connected) return; + setBusy(true); + setActionMsg(''); + try { + await transport.unpinMember(userId, signFn); + setActionMsg(t('node.unpin_done')); + if (rosterGroup) await loadRoster(rosterGroup); + } catch (err) { + setActionMsg(err.message); + } finally { + setBusy(false); + } + }, [signFn, rosterGroup, loadRoster]); + + const rotateGek = useCallback(async (groupId) => { + if (!confirm(t('node.gek_rotate_confirm'))) return; + const transport = transportRef.current; + if (!transport || !transport.connected) return; + setBusy(true); + setActionMsg(''); + try { + await transport.rotateGek(groupId, signFn); + setActionMsg(t('node.gek_rotated')); + } catch (err) { + setActionMsg(err.message); + } finally { + setBusy(false); + } + }, [signFn]); + + const loadDenylist = useCallback(async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + setBusy(true); + try { + const result = await transport.fetchDenylist(); + setDenylist(result); + setShowDenylist(true); + } catch (err) { + setActionMsg(err.message); + } finally { + setBusy(false); + } + }, []); + + const clearDenylist = useCallback(async (subject) => { + const label = subject || t('node.denylist_clear_all'); + if (!confirm(t('node.denylist_clear_confirm', { subject: label }))) return; + const transport = transportRef.current; + if (!transport || !transport.connected) return; + setBusy(true); + setActionMsg(''); + try { + await transport.clearDenylist(subject); + setActionMsg(t('node.denylist_cleared')); + await loadDenylist(); + } catch (err) { + setActionMsg(err.message); + } finally { + setBusy(false); + } + }, [loadDenylist]); + + const pickAttachDir = useCallback(async (setter) => { + const chosen = await platform.rootPicker.choose(); + if (chosen) setter(chosen.path); + }, []); + + const detachGroup = useCallback(async (name) => { + if (!confirm(t('node.detach_confirm', { name }))) return; + const transport = transportRef.current; + if (!transport || !transport.connected) return; + setBusy(true); + setActionMsg(''); + try { + await transport.detachGroup(name, signFn); + setActionMsg(t('node.detached')); + await refresh(); + } catch (err) { + setActionMsg(err.message); + } finally { + setBusy(false); + } + }, [signFn, refresh]); + + const attachGroup = useCallback(async () => { + if (!attachGroup_ || !attachDir.trim()) return; + const transport = transportRef.current; + if (!transport || !transport.connected) return; + setBusy(true); + setActionMsg(''); + try { + const ack = await transport.attachGroup(attachGroup_, attachDir.trim(), + attachUpload.trim(), signFn); + setActionMsg(t('node.attached', { name: ack.name || attachGroup_ })); + setAttachGroup_(''); + setAttachDir(''); + setAttachUpload(''); + await refresh(); + } catch (err) { + setActionMsg(err.message); + } finally { + setBusy(false); + } + }, [attachGroup_, attachDir, attachUpload, signFn]); + + const reloadConfig = useCallback(async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + setBusy(true); + setActionMsg(''); + try { + await transport.reloadConfig(); + setActionMsg(t('node.reloaded')); + await refresh(); + } catch (err) { + setActionMsg(err.message); + } finally { + setBusy(false); + } + }, [refresh]); + + if (status === 'idle' || status === 'connecting') { + return html`<div class="page-content"> + <p class="page-message">${t('status.connecting')}</p> + </div>`; + } + if (status === 'no_groups') { + return html`<div class="page-content"> + <p class="page-message">${t('node.no_groups')}</p> + </div>`; + } + if (status === 'error') { + return html`<div class="page-content"> + <h2>${t('node.title')}</h2> + <p class="error-msg">${error}</p> + <button class="btn btn-primary" onClick=${connectAndFetch}> + ${t('node.retry')}</button> + </div>`; + } + + return html` + <div class="page-content node-page"> + <div class="node-header"> + <h2>${t('node.title')}</h2> + <button class="btn btn-small btn-secondary" disabled=${busy} + onClick=${reloadConfig}> + ${t('node.reload')}</button> + </div> + ${actionMsg && html`<div class="node-message">${actionMsg}</div>`} + ${(() => { + const hubIds = new Set((groups || []).map(g => g.id)); + return nodeGroups.map(g => { + const stale = !hubIds.has(g.id); + return html` + <div class="node-group ${stale ? 'node-group-stale' : ''}" key=${g.id}> + <div class="node-group-header"> + <h3>${g.name}${stale ? html` <span class="node-warn">${t('node.stale')}</span>` : ''}</h3> + <div class="node-group-stats"> + <span>${t('node.files', { n: g.file_count })}</span> + <span>${t('node.peers', { n: g.peers })}</span> + ${!g.has_gek && html` + <span class="node-warn">${t('node.no_gek')}</span>`} + </div> + </div> + <div class="node-roots"> + <div class="node-roots-header"> + <span class="settings-heading">${t('node.roots')}</span> + </div> + ${(g.roots || []).map(r => html` + <div class="node-root ${!r.available ? 'node-root-unavailable' : ''}" + key=${r.name}> + <div class="node-root-info"> + <span class="node-root-name"> + <${Icon} name="folder" /> + ${r.name} + </span> + ${r.upload && html` + <span class="node-root-badge">${t('node.upload_root')}</span>`} + ${!r.available && html` + <span class="node-root-badge node-root-badge-warn"> + ${t('node.unavailable')}</span>`} + <span class="node-root-kind">${r.kind}</span> + </div> + ${(g.roots || []).length > 1 && html` + <button class="btn btn-small btn-danger" + disabled=${busy} onClick=${() => removeRoot(g.id, r.name)}> + ${t('node.remove_root')}</button>`} + </div> + `)} + <button class="btn btn-small btn-secondary" + disabled=${busy} onClick=${() => addRoot(g.id)}> + <${Icon} name="folder-plus" /> ${t('node.add_root')} + </button> + </div> + + <div class="node-section"> + <span class="settings-heading">${t('node.gek')}</span> + ${g.has_gek ? html` + <button class="btn btn-small btn-secondary" disabled=${busy} + onClick=${() => rotateGek(g.id)}> + ${t('node.gek_rotate')}</button> + ` : html` + <p class="node-hint">${t('node.gek_init_hint')}</p> + `} + </div> + + <div class="node-section"> + <div class="node-section-header"> + <span class="settings-heading">${t('node.roster')}</span> + <button class="btn btn-small btn-secondary" disabled=${busy} + onClick=${() => loadRoster(g.id)}> + ${t('node.roster_load')}</button> + </div> + ${roster && rosterGroup === g.id && html` + <div class="node-roster"> + ${(roster.members || []).length === 0 ? html` + <p class="node-hint">${t('node.roster_empty')}</p> + ` : html` + <table class="node-table"> + <thead><tr> + <th>${t('node.roster_user')}</th> + <th>${t('node.roster_role')}</th> + <th>${t('node.roster_status')}</th> + <th>${t('node.roster_via')}</th> + <th></th> + </tr></thead> + <tbody> + ${(roster.members || []).map(m => html` + <tr key=${m.user_id}> + <td>${m.username || m.user_id.slice(0, 12)}</td> + <td>${m.role}</td> + <td>${m.status}</td> + <td>${m.pinned_via || ''}</td> + <td>${m.pk_ed25519 && m.user_id !== userId && html` + <button class="btn btn-small btn-danger" + disabled=${busy} + onClick=${() => unpinMember(m.user_id)}> + ${t('node.unpin')}</button> + `}</td> + </tr> + `)} + </tbody> + </table> + `} + ${(roster.invites || []).length > 0 && html` + <p class="node-hint">${t('node.roster_invites', + { n: roster.invites.length })}</p> + `} + </div> + `} + </div> + + <div class="node-section"> + <button class="btn btn-small btn-danger" disabled=${busy} + onClick=${() => detachGroup(g.name)}> + ${t('node.detach_group')}</button> + </div> + </div> + `; + }); + })()} + + <div class="node-group"> + <div class="node-section-header"> + <span class="settings-heading">${t('node.denylist')}</span> + <button class="btn btn-small btn-secondary" disabled=${busy} + onClick=${loadDenylist}> + ${t('node.denylist_load')}</button> + </div> + ${showDenylist && denylist && html` + <div class="node-denylist"> + ${denylist.count === 0 ? html` + <p class="node-hint">${t('node.denylist_empty')}</p> + ` : html` + <p class="node-hint">${t('node.denylist_count', + { n: denylist.count })}</p> + ${(denylist.users || []).map(u => html` + <div class="node-deny-entry" key=${'u:' + u}> + <span>user: ${u}</span> + <button class="btn btn-small btn-danger" disabled=${busy} + onClick=${() => clearDenylist(u)}> + ${t('node.denylist_remove')}</button> + </div> + `)} + ${(denylist.groups || []).map(g => html` + <div class="node-deny-entry" key=${'g:' + g}> + <span>group: ${g}</span> + <button class="btn btn-small btn-danger" disabled=${busy} + onClick=${() => clearDenylist(g)}> + ${t('node.denylist_remove')}</button> + </div> + `)} + ${(denylist.jtis || []).map(j => html` + <div class="node-deny-entry" key=${'j:' + j}> + <span>token: ${j.slice(0, 16)}</span> + <button class="btn btn-small btn-danger" disabled=${busy} + onClick=${() => clearDenylist(j)}> + ${t('node.denylist_remove')}</button> + </div> + `)} + <button class="btn btn-small btn-danger" disabled=${busy} + onClick=${() => clearDenylist('')}> + ${t('node.denylist_clear_all')}</button> + `} + </div> + `} + </div> + + ${(() => { + const hostedIds = new Set(nodeGroups.map(g => g.id)); + const unhosted = (groups || []).filter(g => !hostedIds.has(g.id)); + if (!unhosted.length) return null; + return html` + <div class="node-group"> + <span class="settings-heading">${t('node.attach_group')}</span> + <div class="node-attach-form"> + <label>${t('node.attach_pick')}</label> + <select value=${attachGroup_} + onChange=${e => setAttachGroup_(e.target.value)}> + <option value="">---</option> + ${unhosted.map(g => html` + <option key=${g.id} value=${g.name}>${g.name}</option> + `)} + </select> + <label>${t('node.attach_dir')}</label> + <div class="node-attach-dir-row"> + <input type="text" placeholder=${t('node.attach_dir')} + value=${attachDir} readOnly + onInput=${e => setAttachDir(e.target.value)} /> + ${platform.rootPicker && html` + <button class="btn btn-small btn-secondary" + onClick=${() => pickAttachDir(setAttachDir)}> + <${Icon} name="folder" /></button> + `} + </div> + <label>${t('node.attach_upload_label')}</label> + <div class="node-attach-dir-row"> + <input type="text" placeholder=${t('node.attach_upload_dir')} + value=${attachUpload} readOnly + onInput=${e => setAttachUpload(e.target.value)} /> + ${platform.rootPicker && html` + <button class="btn btn-small btn-secondary" + onClick=${() => pickAttachDir(setAttachUpload)}> + <${Icon} name="folder" /></button> + `} + </div> + <button class="btn btn-primary" + disabled=${busy || !attachGroup_ || !attachDir.trim()} + onClick=${attachGroup}> + ${t('node.attach_group')}</button> + </div> + <p class="node-hint">${t('node.restart_needed')}</p> + </div> + `; + })()} + </div> + `; +} + // ── App ────────────────────────────────────────────────────────────────────── function App() { @@ -4954,6 +5503,7 @@ function App() { const [unreadCount, setUnreadCount] = useState(0); const [notifDisabled, setNotifDisabled] = useState(false); const [userPrefs, setUserPrefs] = useState({}); + const [hasNodeKey, setHasNodeKey] = useState(false); const resolved = resolveTheme(theme); @@ -5002,7 +5552,7 @@ function App() { }, [user, notifDisabled]); useEffect(() => { - if (!user) { setGroups([]); setNotifications([]); setUnreadCount(0); return; } + if (!user) { setGroups([]); setNotifications([]); setUnreadCount(0); setHasNodeKey(false); return; } hubFetch('/v1/groups/mine', { token: user.token }) .then(data => setGroups(data.groups || [])) .catch(() => setGroups([])); @@ -5012,6 +5562,11 @@ function App() { if (prefs.notifications_disabled === 'true') setNotifDisabled(true); }) .catch(() => {}); + if (platform.capabilities.nodeAdmin) { + hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token }) + .then(data => setHasNodeKey(Boolean(data.pk_node_ed25519))) + .catch(() => {}); + } fetchNotifications(); }, [user]); @@ -5226,6 +5781,9 @@ function App() { .then(data => setGroups(data.groups || [])) .catch(() => {}); }} />`; + } else if (route === '/node' && platform.capabilities.nodeAdmin && hasNodeKey) { + page = html`<${NodePage} token=${user.token} username=${user.username} + userId=${user.userId} groups=${groups} />`; } else if (route.startsWith('/group/')) { const groupId = route.slice(7); const group = groups.find(g => g.id === groupId); @@ -5274,7 +5832,8 @@ function App() { presence=${presence} route=${route} menuOpen=${menuOpen} - role=${user.role} />`} + role=${user.role} + hasNodeKey=${hasNodeKey} />`} ${menuOpen && html`<div class="overlay visible" onClick=${() => setMenuOpen(false)} />`} <main class="main"> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index cb4b349..4193f00 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -442,6 +442,63 @@ export default { 'group.leave_confirm': 'Leave “{name}”? You will lose access to its files and chat. Files you uploaded stay on the node, and the node keeps the identity it pinned for you until its operator removes it.', 'group.mute': 'Mute notifications', 'group.unmute': 'Unmute notifications', + 'group.retry': 'Retry', 'profile.title': 'Profile', 'usermenu.profile': 'Profile', + + // Node management (D5) + 'sidebar.node': 'Node', + 'node.title': 'Node', + 'node.offline': 'Node is offline', + 'node.no_groups': 'No groups configured on this node.', + 'node.retry': 'Retry', + 'node.files': { one: '1 file', other: '{n} files' }, + 'node.peers': { one: '1 peer', other: '{n} peers' }, + 'node.no_gek': 'No group key', + 'node.roots': 'Directories', + 'node.upload_root': 'uploads', + 'node.unavailable': 'unavailable', + 'node.add_root': 'Add directory', + 'node.remove_root': 'Remove', + 'node.root_added': 'Directory added.', + 'node.root_removed': 'Directory removed. Restart recommended to update the index.', + 'node.root_remove_confirm': 'Remove "{name}" from this group?', + 'node.not_operator': 'Could not reach your node. Make sure it is running.', + 'node.gek': 'Group key', + 'node.gek_rotate': 'Rotate group key', + 'node.gek_rotate_confirm': 'Rotate the group key? Connected members will receive the new key automatically. Content already downloaded is unaffected.', + 'node.gek_rotated': 'Group key rotated.', + 'node.gek_init_hint': 'No group key yet. Run GEK init from the CLI to set one up.', + 'node.roster': 'Roster', + 'node.roster_load': 'Load roster', + 'node.roster_empty': 'No members pinned yet.', + 'node.roster_user': 'User', + 'node.roster_role': 'Role', + 'node.roster_status': 'Status', + 'node.roster_via': 'Pinned via', + 'node.roster_invites': { one: '1 pending invite', other: '{n} pending invites' }, + 'node.unpin': 'Unpin', + 'node.unpin_confirm': 'Unpin "{name}"? They will need to pair again.', + 'node.unpin_done': 'Identity unpinned.', + 'node.denylist': 'Denylist', + 'node.denylist_load': 'Load denylist', + 'node.denylist_empty': 'Denylist is empty.', + 'node.denylist_count': { one: '1 entry', other: '{n} entries' }, + 'node.denylist_remove': 'Remove', + 'node.denylist_clear_all': 'Clear all', + 'node.denylist_clear_confirm': 'Remove "{subject}" from the denylist?', + 'node.denylist_cleared': 'Denylist entry removed.', + 'node.reload': 'Reload config', + 'node.reloaded': 'Config reloaded. Root changes on existing groups are now active.', + 'node.attach_group': 'Add group', + 'node.attach_pick': 'Group to host', + 'node.attach_dir': 'Shared directory', + 'node.attach_upload_label': 'Upload directory (for file uploads and chat attachments)', + 'node.attach_upload_dir': 'Upload directory', + 'node.attached': 'Group added and loaded. Run meshbay-node gek-init --group {name} if not done yet.', + 'node.restart_needed': 'After adding: initialise the group key (meshbay-node gek-init --group <name>).', + 'node.detach_group': 'Remove group', + 'node.detach_confirm': 'Remove "{name}" from this node?', + 'node.detached': 'Group removed.', + 'node.stale': 'not on hub', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index 6f8f654..d379e9d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -201,8 +201,14 @@ export const folder = { async forget() { return bridge && bridge.folder ? bridge.folder.forget() : false; }, }; +/** Pick a directory to add as a group root. Returns { path, name } or null. */ +export const rootPicker = { + available: Boolean(bridge && bridge.rootPicker), + async choose() { return bridge && bridge.rootPicker ? bridge.rootPicker.choose() : null; }, +}; + export default { isNative, hubBase, capabilities, secrets, nativeSave, - apiFetch, device, bridgeMessage, folder }; + apiFetch, device, bridgeMessage, folder, rootPicker }; // Also a global, because `transport.js` is loaded as a classic script — it // predates the module graph and exposes `MeshBayTransport` the same way. The @@ -211,5 +217,5 @@ export default { isNative, hubBase, capabilities, secrets, nativeSave, if (typeof window !== 'undefined') { window.MeshBayPlatform = { isNative, hubBase, capabilities, secrets, nativeSave, apiFetch, device, - bridgeMessage, folder }; + bridgeMessage, folder, rootPicker }; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 9ff5923..ac3d08f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2048,3 +2048,177 @@ a.transfer-name { margin-left: 6px; } .dir-row .root-offline { white-space: nowrap; } + +/* ── Node management (D5) ────────────────────────────────────────────────── */ + +.node-page { max-width: 700px; } + +.node-group { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px 20px; + margin-bottom: 16px; +} +.node-group-stale { + opacity: 0.65; + border-color: var(--warn, #d97706); +} +.node-group-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} +.node-group-header h3 { font-size: 1.05em; margin: 0; } +.node-group-stats { + display: flex; + gap: 12px; + font-size: 0.85em; + color: var(--text-secondary); +} +.node-warn { color: var(--warn, #d97706); font-weight: 500; } + +.node-roots-header { margin-bottom: 8px; } + +.node-root { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 0; +} +.node-root + .node-root { border-top: 1px solid var(--border); } +.node-root-info { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-width: 0; +} +.node-root-name { + display: flex; + align-items: center; + gap: 4px; + font-weight: 500; +} +.node-root-name .icon { width: 16px; height: 16px; flex-shrink: 0; } +.node-root-badge { + font-size: 0.75em; + padding: 1px 6px; + border-radius: 4px; + background: var(--accent); + color: var(--accent-text); +} +.node-root-badge-warn { + background: var(--warn, #d97706); + color: #fff; +} +.node-root-kind { + font-size: 0.8em; + color: var(--text-dim); +} +.node-root-unavailable { opacity: 0.6; } + +.node-message { + font-size: 0.9em; + color: var(--text-secondary); + margin-bottom: 12px; + padding: 8px 12px; + background: var(--bg-raised); + border-radius: 6px; +} + +.btn-small { + padding: 4px 10px; + font-size: 0.82em; + border-radius: 5px; +} + +.node-roots > .btn-small { margin-top: 8px; } + +.node-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 4px; +} +.node-header h2 { margin-bottom: 0; } + +.node-section { margin-top: 16px; } +.node-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} +.node-hint { + font-size: 0.85em; + color: var(--text-secondary); + margin: 4px 0; +} + +.node-table { + width: 100%; + font-size: 0.85em; + border-collapse: collapse; + margin-top: 4px; +} +.node-table th { + text-align: left; + padding: 4px 8px; + border-bottom: 1px solid var(--border); + color: var(--text-secondary); + font-weight: 500; +} +.node-table td { + padding: 4px 8px; + border-bottom: 1px solid var(--border); +} + +.node-deny-entry { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 4px 0; + font-size: 0.85em; +} +.node-deny-entry + .node-deny-entry { border-top: 1px solid var(--border); } + +.node-attach-form { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; + max-width: 400px; +} +.node-attach-form label { + font-size: 0.82em; + color: var(--text-secondary); + margin-top: 4px; +} +.node-attach-form input, +.node-attach-form select { + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--bg-base); + color: var(--text); + font-size: 0.9em; + cursor: pointer; +} +.node-attach-form select option { + background: var(--bg-base); + color: var(--text); +} +.node-attach-dir-row { + display: flex; + gap: 6px; + align-items: center; +} +.node-attach-dir-row input { flex: 1; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index a0509a0..17886f4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -217,6 +217,9 @@ class MeshBayTransport { await this._pc.setRemoteDescription({ type: 'answer', sdp: answer.sdp }); await channelReady; + console.log('[MeshBay] DataChannel open, sending handshake for group', groupId, + 'channel=', this._channel?.readyState, + 'crypto=', !!window.MeshBayCrypto); // The client nonce is what makes the NODE's proof fresh (C3) — without it a // recorded handshake_ack could be replayed by an impersonating peer. @@ -229,6 +232,7 @@ class MeshBayTransport { group_id: groupId || '', nonce: window.MeshBayCrypto.b64encode(this._nonceClient), }); + console.log('[MeshBay] Handshake reply:', reply.type); if (reply.type === 'handshake_challenge') { if (!window.MeshBayCrypto) { @@ -380,11 +384,9 @@ class MeshBayTransport { // A node that answers a handshake with anything other than a challenge is not // running the mutual protocol. Accepting a bare handshake_ack here would let a // peer skip proving GEK possession entirely (C3/C6). + console.warn('[MeshBay] Handshake rejected:', reply.detail, 'code:', reply.code); const rejected = new Error( 'MNP handshake rejected: ' + (reply.detail || `unexpected ${reply.type}`)); - // `not_a_member` usually means our token predates being added to the group; - // the caller refreshes it and tries again rather than showing that to someone - // who was invited thirty seconds ago. rejected.reason = reply.code || ''; throw rejected; } @@ -622,6 +624,112 @@ class MeshBayTransport { return msg; } + // ── Node management (D5) ─────────────────────────────────────────────── + + async fetchNodeStatus() { + const msg = await this._sendAndWait({ type: 'node_status', v: '0.1' }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + async addRoot(groupId, path, { name, kind, upload } = {}, signFn) { + const msg = await this._sendAndWait({ + type: 'root_add', v: '0.1', + group_id: groupId, path, + name: name || '', kind: kind || 'generic', upload: !!upload, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'root_add', path, signFn); + } + return msg; + } + + async removeRoot(groupId, rootName, signFn) { + const msg = await this._sendAndWait({ + type: 'root_remove', v: '0.1', + group_id: groupId, root_name: rootName, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'root_remove', rootName, signFn); + } + return msg; + } + + async unpinMember(userId, signFn) { + const msg = await this._sendAndWait({ + type: 'member_unpin', v: '0.1', user_id: userId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'member_unpin', userId, signFn); + } + return msg; + } + + async rotateGek(groupId, signFn) { + const msg = await this._sendAndWait({ + type: 'gek_rotate', v: '0.1', group_id: groupId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'gek_rotate', groupId, signFn); + } + return msg; + } + + async fetchRoster(groupId) { + const msg = await this._sendAndWait({ + type: 'roster_read', v: '0.1', group_id: groupId || '', + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + async fetchDenylist() { + const msg = await this._sendAndWait({ type: 'denylist_read', v: '0.1' }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + async clearDenylist(subject) { + const msg = await this._sendAndWait({ + type: 'denylist_clear', v: '0.1', subject: subject || '', + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + async attachGroup(name, sharedDir, uploadDir, signFn) { + const msg = await this._sendAndWait({ + type: 'group_attach', v: '0.1', + name, shared_dir: sharedDir, upload_dir: uploadDir || '', + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'group_attach', name, signFn); + } + return msg; + } + + async detachGroup(name, signFn) { + const msg = await this._sendAndWait({ + type: 'group_detach', v: '0.1', name, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'group_detach', name, signFn); + } + return msg; + } + + async reloadConfig() { + const msg = await this._sendAndWait({ type: 'node_reload', v: '0.1' }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + /** * Ask for a video stream, and say how much we can take. * @@ -1003,6 +1111,8 @@ class MeshBayTransport { const id = this._seqId++; const timeout = setTimeout(() => { this._pending.delete(id); + console.error('[MeshBay] Response timeout for', obj.type, + 'after', timeoutMs, 'ms, channel=', this._channel?.readyState); reject(new Error('Response timeout')); }, timeoutMs); this._pending.set(id, { @@ -1040,6 +1150,10 @@ class MeshBayTransport { _onMessage(data) { const incoming = new Uint8Array(data); + this._msgCount = (this._msgCount || 0) + 1; + if (this._msgCount <= 3) { + console.log('[MeshBay] recv', incoming.length, 'bytes, msg #' + this._msgCount); + } const combined = new Uint8Array(this._recvBuf.length + incoming.length); combined.set(this._recvBuf); combined.set(incoming, this._recvBuf.length); |