From 9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 23 Aug 2026 15:15:35 +0200 Subject: feat(hub): split the group UI into a pluggable "applications" architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with no way to add another group-level app without touching the shell itself. It is now app.js (routing, non-group pages) plus nine focused files — apps.js (the registry), chat-app.js, files-app.js, video-player.js, group-page.js (the shell), group-settings.js, hub-client.js, icon.js and file-utils.js — with docs/apps.md as the checklist for adding one (Videos/Music/Photos are sketched there, not built). Node side gained the matching enablement mechanism, mirroring member_upload exactly: a roster setting, a signed apps_enabled op enforced by _has_admin_authority, exposed in the handshake ack. Operators toggle applications per group from Settings, which also gained a small reorder: Invite, Pairing, Applications, Shared directories, Uploads, danger zone, Your devices, Members. Two bugs surfaced during the split, both missing an import across the new file boundary and invisible to node --check or a module-load probe since they only throw when the code path actually runs: - group-page.js called onRefreshAuth on a stale-token handshake rejection, but app.js never imported refreshAccessToken from hub-client.js — so a brand new member (including a group's own creator) hit "Not a member of this group" and the retry silently failed, throwing before it could refresh the token. - chat-app.js called getLocale() for message timestamps without importing it from i18n.js. Opening Chat on a group with real messages threw mid- render; uncaught, that appears to wedge Preact's render scheduler, so every button on the page stopped responding until reload. Caught the second class of bug with a proper no-undef audit across all split files (a temporarily installed ESLint 9, since the system one is too old to parse this codebase's syntax) rather than trusting grep. 827 tests pass; 6 new ones cover the apps_enabled policy. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA --- .../src/meshbay_hub/static/group-settings.js | 635 +++++++++++++++++++++ 1 file changed, 635 insertions(+) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/group-settings.js (limited to 'packages/meshbay-hub/src/meshbay_hub/static/group-settings.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js new file mode 100644 index 0000000..a14fd21 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -0,0 +1,635 @@ +import { + html, useState, useEffect, useCallback, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import { hubFetch, navigate } from './hub-client.js'; +import { APPS } from './apps.js'; +import * as platform from './platform.js'; + +// ── Members Panel ──────────────────────────────────────────────────────── + +/** + * Everything about the group that is not its files or its chat. + * + * Was "Members", which was a list with three unrelated forms stacked on top of + * it and the group's own controls somewhere else entirely — leaving or deleting + * a group lived in the header, beside its title. One tab now, in sections, with + * the roster last: it is the part that grows without limit, and burying the + * controls under two hundred names is how a tab stops being usable. + */ +function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, + isNodeAdmin, userId, operatorPaired, connected, + memberUpload, onMemberUpload, + enabledApps, onEnabledApps, + onPaired, onLeft }) { + const [members, setMembers] = useState([]); + const [adminId, setAdminId] = useState(''); + const [loading, setLoading] = useState(true); + const [inviteUser, setInviteUser] = useState(''); + const [inviting, setInviting] = useState(false); + const [error, setError] = useState(''); + + // Node loopback state (Electron-only) + const [nodeDetected, setNodeDetected] = useState(false); + const [nodeRoots, setNodeRoots] = useState([]); + const [nodeGroupName, setNodeGroupName] = useState(''); + const [nodeBusy, setNodeBusy] = useState(false); + const [nodeMsg, setNodeMsg] = useState(''); + + const loadNodeInfo = useCallback(async () => { + if (!platform.node.available) return; + try { + const detect = await platform.node.detect(); + if (!detect.detected) { setNodeDetected(false); return; } + setNodeDetected(true); + const data = await platform.node.call('GET', '/api/groups'); + const groups = data.groups || []; + const ng = groups.find(g => g.id === groupId); + if (ng) { + setNodeRoots(ng.roots || []); + setNodeGroupName(ng.name || ''); + } + } catch { setNodeDetected(false); } + }, [groupId]); + + useEffect(() => { loadNodeInfo(); }, [loadNodeInfo]); + const [inviteCode, setInviteCode] = useState(null); + const [pairCode, setPairCode] = useState(''); + const [pairStatus, setPairStatus] = useState(''); + const [pairing, setPairing] = useState(false); + // Your own devices on this node. Not a members feature — it is beside them + // because this is where a live connection to the node exists. + const [devices, setDevices] = useState([]); + const [approveCode, setApproveCode] = useState(''); + const [deviceMsg, setDeviceMsg] = useState(''); + + // Pairing lives here rather than in Settings because this is where a live + // connection to the node exists — and it is offered only when the node itself + // says this account is its operator (is_node_admin comes from the authenticated + // handshake_ack, not from the hub). + const loadDevices = useCallback(async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + try { + const out = await transport.listDevices(); + setDevices(out.devices); + } catch { /* a node that has none says so by listing none */ } + }, [transportRef]); + + useEffect(() => { loadDevices(); }, [loadDevices]); + + const approveDevice = useCallback(async (e) => { + e.preventDefault(); + const code = approveCode.trim(); + if (!code) return; + setDeviceMsg(''); + try { + await transportRef.current.approveDevice(userId, code); + setApproveCode(''); + setDeviceMsg(t('device.approved')); + await loadDevices(); + } catch (err) { setDeviceMsg(err.message); } + }, [approveCode, userId, transportRef, loadDevices]); + + const revokeDevice = useCallback(async (device) => { + if (!confirm(t('device.revoke_confirm'))) return; + setDeviceMsg(''); + try { + await transportRef.current.revokeDevice( + userId, device.pk_ed25519, device.pk_x25519 || ''); + await loadDevices(); + } catch (err) { setDeviceMsg(err.message); } + }, [userId, transportRef, loadDevices]); + + const doPair = useCallback(async (e) => { + e.preventDefault(); + const code = pairCode.trim(); + if (!code) return; + setPairing(true); + setPairStatus(''); + try { + const transport = transportRef && transportRef.current; + if (!transport || !transport.connected) throw new Error('Not connected to the node'); + await transport.pairOperator(userId, code); + setPairCode(''); + setPairStatus('paired'); + // The node has pinned this key as an operator key; the form has nothing + // left to do. It used to stay put through a refresh, because what governed + // it was the account, which pairing does not change. + if (onPaired) onPaired(); + } catch (err) { + setPairStatus(err.message); + } finally { + setPairing(false); + } + }, [pairCode, transportRef, userId]); + + const [uploadBusy, setUploadBusy] = useState(false); + const [uploadMsg, setUploadMsg] = useState(''); + + /** + * Close or open uploading for everyone who is not the operator. + * + * Signed, like removing a member: the node refuses an unsigned instruction, + * so this is a request to the node rather than a decision taken here. The + * button does not move until the node has said it did it. + */ + const setUploads = useCallback(async (allowed) => { + const transport = transportRef && transportRef.current; + setUploadMsg(''); + setUploadBusy(true); + try { + if (!transport || !transport.connected) { + throw new Error('Not connected to the node'); + } + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.setMemberUpload(allowed, signFn); + if (onMemberUpload) onMemberUpload(allowed); + } catch (err) { + setUploadMsg(err.message); + } finally { + setUploadBusy(false); + } + }, [transportRef, onMemberUpload]); + + const [appsBusy, setAppsBusy] = useState(false); + const [appsMsg, setAppsMsg] = useState(''); + const activeApps = enabledApps && enabledApps.length ? enabledApps : APPS.map(a => a.key); + + /** + * Toggle one app in or out of the group's enabled set. Same shape as + * `setUploads`: signed, and the checkbox does not move until the node has + * said it did it. Refuses to submit an empty set client-side — the node + * refuses it too, but there is no reason to make a round trip to learn that. + */ + const toggleApp = useCallback(async (key) => { + const next = activeApps.includes(key) + ? activeApps.filter(k => k !== key) + : [...activeApps, key]; + if (next.length === 0) { + setAppsMsg(t('members.apps_need_one')); + return; + } + const transport = transportRef && transportRef.current; + setAppsMsg(''); + setAppsBusy(true); + try { + if (!transport || !transport.connected) { + throw new Error('Not connected to the node'); + } + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.setAppsEnabled(next, signFn); + if (onEnabledApps) onEnabledApps(next); + } catch (err) { + setAppsMsg(err.message); + } finally { + setAppsBusy(false); + } + }, [transportRef, onEnabledApps, activeApps]); + + const [removing, setRemoving] = useState(''); + + /** + * Take someone out of this group: both halves, in the order that fails safe. + * + * The node first, because that is the half that stops the group key being + * wrapped for them; if the hub removal then fails, they are a member on paper + * with no key. The other order would leave them able to reach a node that + * still serves them. + */ + const removeMember = useCallback(async (member) => { + const transport = transportRef && transportRef.current; + setError(''); + setRemoving(member.user_id); + try { + if (platform.node.available) { + try { + await platform.node.call('POST', + `/api/members/${member.user_id}/revoke?group_id=${groupId}`); + } catch { /* best effort — node may not host this group */ } + try { + await platform.node.call('POST', + `/api/members/${member.user_id}/unpin`); + } catch { /* best effort */ } + } else if (transport && transport.connected && operatorPaired) { + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.revokeMember(member.user_id, signFn); + } + await hubFetch(`/v1/groups/${groupId}/members/${member.username}`, { + method: 'DELETE', token, + }); + loadMembers(); + } catch (err) { + setError(err.message); + } finally { + setRemoving(''); + } + }, [groupId, token, transportRef, operatorPaired]); + + const loadMembers = useCallback(() => { + setLoading(true); + hubFetch(`/v1/groups/${groupId}/members`, { token }) + .then(data => { + setMembers(data.members || []); + setAdminId(data.admin_id || ''); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [groupId, token]); + + useEffect(() => { loadMembers(); }, [loadMembers]); + + const isAdmin = group && group.is_admin; + + const doInvite = useCallback(async (e) => { + e.preventDefault(); + if (!inviteUser.trim()) return; + setInviting(true); + setError(''); + setInviteCode(null); + try { + const transport = transportRef && transportRef.current; + const username = inviteUser.trim(); + if (!transport || !transport.connected) { + throw new Error('Not connected to the node — it must be online to invite'); + } + + // The hub is asked for the account id, and nothing else. It is no longer + // asked for the invitee's public key: the node wraps the group key itself, + // for a key the invitee proves possession of when they connect (H3). A hub + // that answered with the wrong account here would produce an invite whose + // code it never learns — the code goes to a human, out of band. + const account = await hubFetch(`/v1/users/${username}/pubkeys`, { token }); + + // Signed with the identity this node pinned for us — the only one it + // will accept, and the only one we hold here. + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + const result = await transport.createInvite( + account.user_id, groupId, username, signFn); + + // Membership on the hub is what lets them reach the node at all; the code + // is what gets them the key. + await hubFetch(`/v1/groups/${groupId}/members/${username}`, { + method: 'POST', token, body: {}, + }); + + setInviteCode({ username, code: result.code, expires: result.expires_at }); + setInviteUser(''); + loadMembers(); + } catch (err) { + setError(err.message); + } finally { + setInviting(false); + } + }, [groupId, token, inviteUser, loadMembers, transportRef]); + + if (loading) return html`

${t('explore.loading')}

`; + + const isOwner = Boolean(isAdmin); + + return html` +
+ ${error && html`
${error}
`} + + ${/* Inviting needs the node: it is the node that wraps the group key and + issues the code, not the hub. Public groups admit anyone — no invite. + The form stays in the DOM so a brief reconnect does not destroy the + input the user is typing into — controls are disabled instead. */ + isAdmin && group?.join_policy !== 'open' && html` +
+

${t('members.invite_title')}

+ ${!connected ? html` +

${t('group.offline_title')}

+ ` : !operatorPaired ? html` +

+ ${isNodeAdmin ? t('members.invite_needs_pairing') + : t('members.invite_ask_operator')} +

+ ` : ''} +
+ ${inviteCode && html` +
+

${t('members.invite_code_ready', { user: inviteCode.username })}

+

${inviteCode.code}

+

${t('members.invite_code_hint')}

+
+ `} +
+ setInviteUser(e.target.value)} + disabled=${!connected || !operatorPaired} required /> + +
+
+
+ `} + + ${isNodeAdmin && !operatorPaired && connected && html` +
+

${t('members.pair_title')}

+

${t('members.pair_hint')}

+ ${pairStatus && html` +

+ ${pairStatus === 'paired' ? t('members.pair_success') : pairStatus} +

+ `} +
+ setPairCode(e.target.value)} required /> + +
+
+ `} + + ${/* Which group "applications" members see. New ones (Videos, Music, + Photos) show up here automatically as they register in apps.js — + nothing about this section changes to add one. */ + isNodeAdmin && connected && html` +
+

${t('members.apps_title')}

+

${t('members.apps_hint')}

+
    + ${APPS.map(a => html` +
  • + +
  • + `)} +
+ ${appsMsg && html`

${appsMsg}

`} +
+ `} + + ${/* Roots management (Electron-only, when node is local) */ + nodeDetected && nodeRoots.length > 0 && html` +
+

${t('settings_node.roots')}

+ ${nodeMsg && html`

${nodeMsg}

`} +
+ ${nodeRoots.map(r => html` +
+
+ + <${Icon} name="folder" /> + ${r.name} + + ${r.upload && html` + ${t('node.upload_root')}`} + ${!r.available && html` + + ${t('node.unavailable')}`} +
+ ${nodeRoots.length > 1 && !r.upload && html` + `} +
+ `)} + +
+
+ `} + + ${/* Operator only, and only with a live connection: the node is what + holds and enforces this, so there is nothing to show or change + without one. */ isNodeAdmin && connected && html` +
+

${t('members.uploads_title')}

+
+ + ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} + + +
+

${t('members.uploads_hint')}

+ ${uploadMsg && html`

${uploadMsg}

`} +
+ `} + + ${/* Upload toggle via loopback when MNP not connected */ + nodeDetected && !connected && html` +
+

${t('members.uploads_title')}

+
+ + ${memberUpload ? t('members.uploads_on') : t('members.uploads_off')} + + +
+

${t('members.uploads_hint')}

+
+ `} + + ${/* Delete/leave — node detach first (reversible), then hub delete + (irreversible). */ html` +
+

+ ${isOwner ? t('group.delete_group') : t('group.leave')} +

+
+ + ${isOwner ? t('members.danger_delete_hint') + : t('members.danger_leave_hint')} + + ${isOwner + ? html` + + ` + : html` + + `} +
+
+ `} + + ${connected && html` +
+

${t('device.mine_title')}

+

${t('device.mine_hint')}

+ ${deviceMsg && html`

${deviceMsg}

`} + ${devices.length === 0 + ? html`

${t('device.mine_empty')}

` + : html` +
    + ${devices.map(d => html` +
  • + ${d.pk_ed25519.slice(0, 16)}… + + ${d.is_this_one && html` + ${t('device.this_one')}${' '} + `} + ${d.pinned_via}${d.label ? ' · ' + d.label : ''} + + ${!d.is_this_one && devices.length > 1 && html` + + `} +
  • + `)} +
+ `} +
+

${t('device.approve_hint')}

+
+ setApproveCode(e.target.value)} /> + +
+
+
+ `} + +
+

+ ${t('group.tab_members')} (${members.length}) +

+ + + + + + + + + + ${members.map(m => html` + + + + + + `)} + +
${t('admin.col_username')}${t('members.group_role')}
${m.username} + ${m.user_id === adminId + ? html`${t('members.owner')}` + : html`${t('members.member')}` + } + + ${isAdmin && m.user_id !== adminId && html` + + `} +
+ ${isAdmin && members.length > 1 && html` +

${t('members.remove_hint')}

+ `} +
+
+ `; +} + +// ── Chat Panel ────────────────────────────────────────────────────────── + +/** + * Message text with its links made clickable. + * + * Only http and https, and built as elements rather than markup: a message is + * something another member wrote, so it must never become HTML. `javascript:` + * and `data:` are not matched at all, and the anchors carry noopener so the new + * tab cannot reach back into this one. + */ +const URL_RE = /\bhttps?:\/\/[^\s<>"']+/gi; + + +export { GroupSettingsPanel }; -- cgit v1.2.3