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 ++++++++++++--------- .../meshbay-hub/src/meshbay_hub/static/i18n.js | 3 +- .../src/meshbay_hub/static/transport.js | 5 + packages/meshbay-hub/tests/test_spa_ordering.py | 42 +++++++++ 4 files changed, 106 insertions(+), 48 deletions(-) (limited to 'packages/meshbay-hub') 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')}` - } -
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index 6f27c32..1fe8085 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -267,8 +267,7 @@ const en = { 'members.pair_hint': 'Your node only accepts operator actions — invites, file ' + 'deletion — from a browser it has been paired with. Run ' + '`meshbay-node operator pair` on the node and type the code here. The code ' - + 'never passes through the hub, which is what stops the hub from claiming to ' - + 'be you.', + + 'never passes through the hub.', 'members.pair_btn': 'Pair', 'members.pair_success': 'This browser is now paired with the node.', 'members.invite_code_ready': 'Invitation code for {user} — send it to them the way ' diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 0306a5c..d904286 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -383,6 +383,7 @@ class MeshBayTransport { err.reason = reason; throw err; } + this.memberRole = 'operator'; return resp; } @@ -588,6 +589,10 @@ class MeshBayTransport { const myPkX = Uint8Array.from(atob(pkXB64), c => c.charCodeAt(0)); const gekRaw = await C.unwrapGEK(resp, skXRaw, myPkX); this._gekRaw = gekRaw; + // What the node's roster says this identity is, which is not what the hub + // says: `operator` here means this browser's key was paired with the node, + // not merely that the account owns it. + this.memberRole = resp.role || ''; return gekRaw; } diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py index 0ef34fc..9301fcc 100644 --- a/packages/meshbay-hub/tests/test_spa_ordering.py +++ b/packages/meshbay-hub/tests/test_spa_ordering.py @@ -101,3 +101,45 @@ def test_the_ack_still_verifies_the_announced_node_key(): "the challenge's node_pk must be checked against the ack's") assert "verifyNodeSignature" in source, ( "the ack's signature over the handshake transcript must still be verified") + + +# ── Component boundaries ──────────────────────────────────────────────────── +# +# A second class of bug this file exists for. Moving a block between components +# is a plain cut and paste, and nothing checks that the paste landed somewhere +# the names it uses exist: the invite form and the member list were cut out of +# MembersPanel and pasted into AdminPage, which left a standard member seeing an +# empty Members tab, the group owner seeing only a pairing form, and the hub's +# Users tab referencing `doInvite`, `members` and `adminId` — none of which are +# defined there. + +APP = STATIC / "app.js" + + +def _component(name: str) -> str: + """The source of one top-level `function Name(...)`, up to the next one.""" + source = APP.read_text() + start = source.find(f"\nfunction {name}(") + assert start != -1, f"{name} is gone from app.js — update this test" + end = source.find("\nfunction ", start + 1) + return source[start:end if end != -1 else len(source)] + + +def test_members_panel_renders_what_it_owns(): + panel = _component("MembersPanel") + assert "members.map(" in panel, "the member list is not rendered" + assert "onSubmit=${doInvite}" in panel, "the invite form is not rendered" + assert "onSubmit=${doPair}" in panel, "the pairing form is not rendered" + + +def test_the_invite_form_comes_before_the_list(): + panel = _component("MembersPanel") + assert panel.index("onSubmit=${doInvite}") < panel.index("members.map("), \ + "the invite form belongs above the member list" + + +def test_admin_page_does_not_borrow_the_members_panel_state(): + admin = _component("AdminPage") + for name in ("doInvite", "adminId", "inviteCode", "setInviteUser"): + assert name not in admin, \ + f"AdminPage references {name}, which only exists in MembersPanel" -- cgit v1.2.3
${t('admin.col_username')}