summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js573
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">