aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js104
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js5
-rw-r--r--packages/meshbay-hub/tests/test_spa_ordering.py42
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"