diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 573 |
1 files changed, 566 insertions, 7 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"> |