diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-15 00:49:54 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-15 00:49:54 +0200 |
| commit | 76724252d08162d4df39090af19796054bf4add8 (patch) | |
| tree | 2ed96118f3a0ee7139281b308f2489d7a176ea35 /packages/meshbay-hub/src/meshbay_hub | |
| parent | 4156b5b8c3986278a887d1dce4db265746e9155f (diff) | |
| download | meshbay-76724252d08162d4df39090af19796054bf4add8.tar.gz | |
fix(members): restore the member list and invite form, and retire the pairing form
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 104 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/i18n.js | 3 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 5 |
3 files changed, 64 insertions, 48 deletions
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` <div class="members-panel"> - ${isNodeAdmin && html` + ${isAdmin && html` + <form class="invite-form" onSubmit=${doInvite}> + <h4>${t('members.invite_title')}</h4> + ${error && html`<p class="error-msg">${error}</p>`} + ${inviteCode && html` + <div class="success-msg" style="margin-bottom:8px"> + <p>${t('members.invite_code_ready', { user: inviteCode.username })}</p> + <p style="font-family:monospace;font-size:1.4em;letter-spacing:2px;margin:6px 0"> + ${inviteCode.code} + </p> + <p>${t('members.invite_code_hint')}</p> + </div> + `} + <div style="display:flex;gap:8px"> + <input type="text" placeholder="${t('members.username_placeholder')}" + value=${inviteUser} onInput=${e => setInviteUser(e.target.value)} required /> + <button class="admin-btn" type="submit" disabled=${inviting}> + ${inviting ? '...' : t('members.invite_btn')} + </button> + </div> + </form> + `} + <table class="admin-table"> + <thead> + <tr> + <th>${t('admin.col_username')}</th> + <th>${t('members.group_role')}</th> + </tr> + </thead> + <tbody> + ${members.map(m => html` + <tr key=${m.user_id}> + <td>${m.username}</td> + <td> + ${m.user_id === adminId + ? html`<span class="badge" style="background:var(--accent);color:var(--accent-text)">${t('members.owner')}</span>` + : html`<span class="badge">${t('members.member')}</span>` + } + </td> + </tr> + `)} + </tbody> + </table> + ${isNodeAdmin && !operatorPaired && html` <form class="invite-form" onSubmit=${doPair}> <h4>${t('members.pair_title')}</h4> <p class="settings-hint">${t('members.pair_hint')}</p> @@ -2571,49 +2626,6 @@ function AdminPage({ token }) { value=${userSearch} onInput=${e => { setUserSearch(e.target.value); loadUsers(e.target.value); }} /> <span class="settings-value">${usersTotal} total</span> </div> - ${isAdmin && html` - <form class="invite-form" onSubmit=${doInvite}> - <h4>${t('members.invite_title')}</h4> - ${error && html`<p class="error-msg">${error}</p>`} - ${inviteCode && html` - <div class="success-msg" style="margin-bottom:8px"> - <p>${t('members.invite_code_ready', { user: inviteCode.username })}</p> - <p style="font-family:monospace;font-size:1.4em;letter-spacing:2px;margin:6px 0"> - ${inviteCode.code} - </p> - <p>${t('members.invite_code_hint')}</p> - </div> - `} - <div style="display:flex;gap:8px"> - <input type="text" placeholder="${t('members.username_placeholder')}" - value=${inviteUser} onInput=${e => setInviteUser(e.target.value)} required /> - <button class="admin-btn" type="submit" disabled=${inviting}> - ${inviting ? '...' : t('members.invite_btn')} - </button> - </div> - </form> - `} - <table class="admin-table"> - <thead> - <tr> - <th>${t('admin.col_username')}</th> - <th>${t('members.group_role')}</th> - </tr> - </thead> - <tbody> - ${members.map(m => html` - <tr key=${m.user_id}> - <td>${m.username}</td> - <td> - ${m.user_id === adminId - ? html`<span class="badge" style="background:var(--accent);color:var(--accent-text)">${t('members.owner')}</span>` - : html`<span class="badge">${t('members.member')}</span>` - } - </td> - </tr> - `)} - </tbody> - </table> <table class="admin-table"> <thead><tr> <th>${t('admin.col_username')}</th> 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; } |