From 76724252d08162d4df39090af19796054bf4add8 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 15 Aug 2026 00:49:54 +0200 Subject: fix(members): restore the member list and invite form, and retire the pairing form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the invite form above the member list cut both out of MembersPanel and pasted them into AdminPage, where `doInvite`, `members`, `adminId` and `inviteCode` do not exist. A standard member saw an empty Members tab, the group owner saw only a pairing form, and the hub's own Users tab referenced four undefined names. The pairing form outstaying its welcome is a second bug and an older one. `is_node_admin` compares the connecting account with the account that owns the node — it says nothing about whether *this browser's key* was ever paired, which is the thing pairing changes and the thing that lets you sign an invite. So the form showed for an operator who paired months ago, accepted a fresh code, reported success, and stayed exactly where it was. The node already reports the roster role in `join_result`; the transport keeps it, and the form appears only when this identity is not an operator key yet. Also dropped a clause from the pairing hint: the code never passing through the hub is worth saying, the theory behind it is not. test_spa_ordering.py gets three checks for this class of bug — a cut-and-paste between components is invisible to every other test we have. Co-Authored-By: Claude Opus 5 --- packages/meshbay-hub/src/meshbay_hub/static/app.js | 104 ++++++++++++--------- 1 file changed, 58 insertions(+), 46 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index a5b74f0..0f101d2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -890,6 +890,11 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, const [nodeDirs, setNodeDirs] = useState([]); const [menuOpen, setMenuOpen] = useState(null); const [isNodeAdmin, setIsNodeAdmin] = useState(false); + // Paired ≠ operator account. `is_node_admin` says the hub account owning this + // node is the one connecting; this says the node pinned *this browser's* key + // as an operator key. Only the second one lets you sign an invite, and only + // the second one should make the pairing form go away. + const [operatorPaired, setOperatorPaired] = useState(false); const [needsCode, setNeedsCode] = useState(false); const [codeInput, setCodeInput] = useState(''); const [retryKey, setRetryKey] = useState(0); @@ -955,6 +960,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, _pendingJoinCode = null; if (cancelled) return; setIsNodeAdmin(!!ack.is_node_admin); + setOperatorPaired(transport.memberRole === 'operator'); // A first join to this node generated an identity for it; leave it with // the node so any other browser can become the same person here with the @@ -1421,7 +1427,9 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, ${tab === 'members' && html` <${MembersPanel} groupId=${groupId} group=${group} token=${token} transportRef=${transportRef} gekRef=${gekRef} - isNodeAdmin=${isNodeAdmin} userId=${userId} /> + isNodeAdmin=${isNodeAdmin} userId=${userId} + operatorPaired=${operatorPaired} + onPaired=${() => setOperatorPaired(true)} /> `} `} ${status === 'offline' && html` @@ -1576,7 +1584,7 @@ function _b64ToU8(b64) { // ── Members Panel ──────────────────────────────────────────────────────── function MembersPanel({ groupId, group, token, transportRef, gekRef, - isNodeAdmin, userId }) { + isNodeAdmin, userId, operatorPaired, onPaired }) { const [members, setMembers] = useState([]); const [adminId, setAdminId] = useState(''); const [loading, setLoading] = useState(true); @@ -1604,6 +1612,10 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef, 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 { @@ -1675,7 +1687,50 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef, return html`
- ${isNodeAdmin && html` + ${isAdmin && html` +
+

${t('members.invite_title')}

+ ${error && html`

${error}

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

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

+

+ ${inviteCode.code} +

+

${t('members.invite_code_hint')}

+
+ `} +
+ setInviteUser(e.target.value)} required /> + +
+
+ `} + + + + + + + + + ${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')}` + } +
+ ${isNodeAdmin && !operatorPaired && html`

${t('members.pair_title')}

${t('members.pair_hint')}

@@ -2571,49 +2626,6 @@ function AdminPage({ token }) { value=${userSearch} onInput=${e => { setUserSearch(e.target.value); loadUsers(e.target.value); }} /> ${usersTotal} total
- ${isAdmin && html` - -

${t('members.invite_title')}

- ${error && html`

${error}

`} - ${inviteCode && html` -
-

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

-

- ${inviteCode.code} -

-

${t('members.invite_code_hint')}

-
- `} -
- setInviteUser(e.target.value)} required /> - -
- - `} - - - - - - - - - ${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')}` - } -
-- cgit v1.2.3
${t('admin.col_username')}