diff options
Diffstat (limited to 'packages/meshbay-hub')
4 files changed, 106 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; } 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" |