summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js137
1 files changed, 114 insertions, 23 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index a006dfe..1ef878a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -68,6 +68,9 @@ async function getAllCachedIndexes() {
let _sessionKeys = null;
let _bundleKey = null;
let _pendingBundlePush = null;
+// A one-time pairing code the user just typed, consumed by the next connection
+// attempt. Deliberately not persisted: it is single-use and short-lived.
+let _pendingJoinCode = null;
function _openKeyDB() {
return new Promise((resolve, reject) => {
@@ -778,9 +781,23 @@ function GroupPage({ groupId, group, token, username, userId }) {
const [uploading, setUploading] = useState(false);
const [menuOpen, setMenuOpen] = useState(null);
const [isNodeAdmin, setIsNodeAdmin] = useState(false);
+ const [needsCode, setNeedsCode] = useState(false);
+ const [codeInput, setCodeInput] = useState('');
+ const [retryKey, setRetryKey] = useState(0);
const transportRef = useRef(null);
const gekRef = useRef(null);
+ const submitJoinCode = useCallback((e) => {
+ e.preventDefault();
+ const code = codeInput.trim();
+ if (!code) return;
+ _pendingJoinCode = code;
+ setCodeInput('');
+ setNeedsCode(false);
+ setError('');
+ setRetryKey(k => k + 1);
+ }, [codeInput]);
+
useEffect(() => {
if (menuOpen === null) return;
const close = () => setMenuOpen(null);
@@ -812,9 +829,12 @@ function GroupPage({ groupId, group, token, username, userId }) {
return;
}
- // Session keys for P2P GEK bundle fetch (node delivers wrapped GEK)
+ // Session keys for the P2P key exchange. skEdB64 belongs here too: the
+ // node identifies us by the Ed25519 identity, and join_request signs both
+ // public keys with it — without it we can neither join nor pair.
const sessionKeys = _sessionKeys ? {
skXB64: _sessionKeys.skXB64,
+ skEdB64: _sessionKeys.skEdB64,
pkXB64: _sessionKeys.pkXB64,
} : null;
@@ -824,7 +844,9 @@ function GroupPage({ groupId, group, token, username, userId }) {
transportRef.current = transport;
const ack = await transport.connect(
- nodeId, token, groupId, null, sessionKeys, _bundleKey, username);
+ nodeId, token, groupId, null, sessionKeys, _bundleKey, username,
+ userId, _pendingJoinCode);
+ _pendingJoinCode = null;
if (cancelled) return;
setIsNodeAdmin(!!ack.is_node_admin);
@@ -881,6 +903,10 @@ function GroupPage({ groupId, group, token, username, userId }) {
cacheGroupIndex(groupId, group ? group.name : groupId, freshEntries);
} catch (err) {
if (!cancelled) {
+ // The node has never seen this browser for this account: it needs a
+ // one-time code from the operator before it will hand over the group
+ // key. Not an error to shout about — a step in joining.
+ if (err.reason === 'code_required') setNeedsCode(true);
setError(err.message);
setStatus('error');
}
@@ -902,7 +928,7 @@ function GroupPage({ groupId, group, token, username, userId }) {
transportRef.current = null;
}
};
- }, [groupId, token]);
+ }, [groupId, token, retryKey]);
const downloadFile = useCallback(async (entry) => {
const transport = transportRef.current;
@@ -1079,6 +1105,17 @@ function GroupPage({ groupId, group, token, username, userId }) {
`}
</div>
${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
+ ${needsCode && html`
+ <form class="invite-form" style="margin-bottom:12px" onSubmit=${submitJoinCode}>
+ <h4>${t('group.join_code_title')}</h4>
+ <p class="settings-hint">${t('group.join_code_hint')}</p>
+ <div style="display:flex;gap:8px">
+ <input type="text" placeholder="XXXX-XXXX" style="font-family:monospace"
+ value=${codeInput} onInput=${e => setCodeInput(e.target.value)} required />
+ <button class="admin-btn" type="submit">${t('group.join_code_btn')}</button>
+ </div>
+ </form>
+ `}
${dlState && html`
<div class="dl-bar">
<span class="dl-name">${dlState.name}</span>
@@ -1224,7 +1261,8 @@ function GroupPage({ groupId, group, token, username, userId }) {
${tab === 'members' && html`
<${MembersPanel} groupId=${groupId} group=${group} token=${token}
- transportRef=${transportRef} gekRef=${gekRef} />
+ transportRef=${transportRef} gekRef=${gekRef}
+ isNodeAdmin=${isNodeAdmin} userId=${userId} />
`}
`}
${status === 'offline' && html`
@@ -1365,13 +1403,41 @@ function _b64ToU8(b64) {
// ── Members Panel ────────────────────────────────────────────────────────
-function MembersPanel({ groupId, group, token, transportRef, gekRef }) {
+function MembersPanel({ groupId, group, token, transportRef, gekRef,
+ isNodeAdmin, userId }) {
const [members, setMembers] = useState([]);
const [adminId, setAdminId] = useState('');
const [loading, setLoading] = useState(true);
const [inviteUser, setInviteUser] = useState('');
const [inviting, setInviting] = useState(false);
const [error, setError] = useState('');
+ const [inviteCode, setInviteCode] = useState(null);
+ const [pairCode, setPairCode] = useState('');
+ const [pairStatus, setPairStatus] = useState('');
+ const [pairing, setPairing] = useState(false);
+
+ // Pairing lives here rather than in Settings because this is where a live
+ // connection to the node exists — and it is offered only when the node itself
+ // says this account is its operator (is_node_admin comes from the authenticated
+ // handshake_ack, not from the hub).
+ const doPair = useCallback(async (e) => {
+ e.preventDefault();
+ const code = pairCode.trim();
+ if (!code) return;
+ setPairing(true);
+ setPairStatus('');
+ try {
+ const transport = transportRef && transportRef.current;
+ if (!transport || !transport.connected) throw new Error('Not connected to the node');
+ await transport.pairOperator(userId, code);
+ setPairCode('');
+ setPairStatus('paired');
+ } catch (err) {
+ setPairStatus(err.message);
+ } finally {
+ setPairing(false);
+ }
+ }, [pairCode, transportRef, userId]);
const loadMembers = useCallback(() => {
setLoading(true);
@@ -1393,36 +1459,34 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) {
if (!inviteUser.trim()) return;
setInviting(true);
setError('');
+ setInviteCode(null);
try {
const transport = transportRef && transportRef.current;
const username = inviteUser.trim();
-
- // Fetch invitee's public keys (hub = public key directory)
- const pubkeys = await hubFetch(`/v1/users/${username}/pubkeys`, { token });
- const pkXBytes = Uint8Array.from(atob(pubkeys.pk_x25519), c => c.charCodeAt(0));
-
- // Get raw GEK from the active transport connection
- if (!transport || !transport.connected || !transport.gekRaw) {
- throw new Error('Not connected to node or no GEK available');
+ if (!transport || !transport.connected) {
+ throw new Error('Not connected to the node — it must be online to invite');
}
- const gekBytes = transport.gekRaw;
- // Wrap GEK for invitee and store on node via P2P.
- // The node requires the operator's Ed25519 signature to accept the bundle
- // (C5b), so inviting from a browser that is not the node operator's will be
- // refused by the node — deliberately: only the operator decides what is
- // stored on their machine.
+ // The hub is asked for the account id, and nothing else. It is no longer
+ // asked for the invitee's public key: the node wraps the group key itself,
+ // for a key the invitee proves possession of when they connect (H3). A hub
+ // that answered with the wrong account here would produce an invite whose
+ // code it never learns — the code goes to a human, out of band.
+ const account = await hubFetch(`/v1/users/${username}/pubkeys`, { token });
+
const signFn = (_sessionKeys && window.MeshBayKeys)
? (transcript) => window.MeshBayKeys.signBytes(_sessionKeys.skEdB64, transcript)
: null;
- const bundle = await window.MeshBayCrypto.wrapGEK(gekBytes, pkXBytes);
- await transport.storeGekBundle(pubkeys.user_id, groupId, bundle, signFn);
+ const result = await transport.createInvite(
+ account.user_id, groupId, username, signFn);
- // Add member on hub (membership management only)
+ // Membership on the hub is what lets them reach the node at all; the code
+ // is what gets them the key.
await hubFetch(`/v1/groups/${groupId}/members/${username}`, {
method: 'POST', token, body: {},
});
+ setInviteCode({ username, code: result.code, expires: result.expires_at });
setInviteUser('');
loadMembers();
} catch (err) {
@@ -1430,7 +1494,7 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) {
} finally {
setInviting(false);
}
- }, [groupId, token, inviteUser, loadMembers]);
+ }, [groupId, token, inviteUser, loadMembers, transportRef]);
if (loading) return html`<p class="page-message">${t('explore.loading')}</p>`;
@@ -1461,6 +1525,15 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) {
<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 />
@@ -1470,6 +1543,24 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) {
</div>
</form>
`}
+ ${isNodeAdmin && html`
+ <form class="invite-form" onSubmit=${doPair}>
+ <h4>${t('members.pair_title')}</h4>
+ <p class="settings-hint">${t('members.pair_hint')}</p>
+ ${pairStatus && html`
+ <p class=${pairStatus === 'paired' ? 'success-msg' : 'error-msg'}>
+ ${pairStatus === 'paired' ? t('members.pair_success') : pairStatus}
+ </p>
+ `}
+ <div style="display:flex;gap:8px">
+ <input type="text" placeholder="XXXX-XXXX" style="font-family:monospace"
+ value=${pairCode} onInput=${e => setPairCode(e.target.value)} required />
+ <button class="admin-btn" type="submit" disabled=${pairing}>
+ ${pairing ? '...' : t('members.pair_btn')}
+ </button>
+ </div>
+ </form>
+ `}
</div>
`;
}