diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-14 23:02:49 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-14 23:02:49 +0200 |
| commit | 3131630d900aa4a72bf880bfa9c5232bff64d6d6 (patch) | |
| tree | 5ef229f9a3f589fad6e7543f14b58ec7f9526758 /packages/meshbay-hub/src/meshbay_hub/static/group-page.js | |
| parent | 422647e7898b8df67cd11f8fb060aec23dec84aa (diff) | |
| download | meshbay-3131630d900aa4a72bf880bfa9c5232bff64d6d6.tar.gz | |
fix(client): link a device from the application; approve one match only
"Get a linking code" called the connection the page had just closed on the
node's unknown_device refusal, so it never filed anything. It now retries
with the request filed on that connection, before the proof (MNP 6.3), and
the page tries the group again after each refusal until a linked device
approves the code. Offered only in the desktop application: a browser's
minted identity is gone by its next connection.
Approving refuses a code that matches more than one pending request:
filing needs only the account's token, so somebody holding the passphrase
who saw a code could file their own keys under it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/group-page.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/group-page.js | 68 |
1 files changed, 59 insertions, 9 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 3e3b483..422d90b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -26,6 +26,17 @@ const GroupSettingsPanel = lazy(() => import('./group-settings.js'), 'GroupSetti import { reportIndexPush } from './index-dock.js'; import { clearPending, nodePkFromLink, pendingFor } from './invite-link.js'; +// While a linking code is shown, how long after a refused try the group is +// tried again: a person typing the code on another device takes longer, and +// each try is a connection. +const DEVICE_RETRY_MS = 5000; + +/** The node's `expires_at` (ISO) in epoch seconds; 0 when it gave none. */ +function _untilSeconds(iso) { + const ms = Date.parse(iso || ''); + return Number.isFinite(ms) ? ms / 1000 : 0; +} + /** * The group shell: everything a group's "applications" (Chat, Files, and * whatever registers in apps.js next) share — the WebRTC connection, the file @@ -218,6 +229,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, }, []); const browserAccessOff = !nativeKeys && userPrefs && userPrefs.browser_access === 'off'; const [deviceCode, setDeviceCode] = useState(''); + // While a code is shown: until when the node keeps its request (epoch + // seconds). The group is tried again after each refusal until then. + const deviceWaitRef = useRef(0); const [codeInput, setCodeInput] = useState(''); // This browser has never derived the passphrase-bundle key (fresh browser, // cleared storage, or a device-key sign-in). Ask for the passphrase here @@ -236,9 +250,15 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // set to `true` while looking at one group would otherwise silently disable // the retry for every group opened afterward in the same session, forever. const refreshedRef = useRef(false); + // Set by "Get a linking code" for the one connection that files the request. + const fileDeviceRef = useRef(false); useEffect(() => { refreshedRef.current = false; + fileDeviceRef.current = false; setNeedsPass(false); + deviceWaitRef.current = 0; + setNeedsDevice(false); + setDeviceCode(''); }, [groupId]); const submitJoinCode = useCallback((e) => { @@ -367,6 +387,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // Dropped by the teardown below, so a transport handed on to a running // download (`releaseWhenIdle`) stops driving a page that is gone. let offReconnect = null; + let deviceRetry = null; // The cache is written here and read only by the search page. It used to // seed this list too, which put a stale index on screen and then raced the @@ -453,6 +474,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, transport.onDeviceIdentity = (ok) => { if (!cancelled) setDeviceReady(ok); }; + // "Get a linking code" retries with this set: the request is filed + // on the connection the node refused, which is closed right after. + transport.fileDeviceRequest = fileDeviceRef.current; try { ack = await transport.connect( n.node_id, live, groupId, null, identity, session.bundleKey, @@ -615,8 +639,12 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, })(); }); - // We are in: an invitation to this group has served its purpose. + // We are in: an invitation to this group has served its purpose, and a + // linking code shown before has been approved. if (onJoined) onJoined(groupId); + deviceWaitRef.current = 0; + setNeedsDevice(false); + setDeviceCode(''); const indexMsg = await transport.fetchIndex(); if (cancelled) return; @@ -680,7 +708,30 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // whole point of device linking: a second browser or a native client // must not cost anyone a support request. if (err.reason === 'unknown_device') setNeedsDevice(true); - setError(err.message); + fileDeviceRef.current = false; + if (err.deviceCode) { + setDeviceCode(err.deviceCode); + deviceWaitRef.current = _untilSeconds(err.deviceExpiresAt) + || Date.now() / 1000 + 3600; + } + // A code is shown: the group opens by itself once a linked device + // approves it, as the command-line client goes on by itself. Tried + // again only once this try is over, so a slow connection is never cut + // short by the next; past the request's life the code is dropped and + // the button comes back for a new one. + if (deviceWaitRef.current && err.reason === 'unknown_device') { + if (Date.now() / 1000 < deviceWaitRef.current) { + deviceRetry = setTimeout(() => setRetryKey(k => k + 1), DEVICE_RETRY_MS); + } else { + deviceWaitRef.current = 0; + setDeviceCode(''); + } + } + // In the application the linking form says all there is to say, and + // while a code is shown this is the refusal of each retry, not news. + const quiet = err.reason === 'unknown_device' && await platform.nativeKeys(); + if (cancelled) return; + if (!quiet) setError(err.message); setStatus('error'); if (transportRef.current) { try { transportRef.current.close(); } catch { /* already gone */ } @@ -704,6 +755,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, return () => { cancelled = true; if (offReconnect) { offReconnect(); offReconnect = null; } + clearTimeout(deviceRetry); // Nothing will update this group's dock row once the page lets go of it. reportIndexPush(groupId, null); if (transportRef.current) { @@ -967,17 +1019,15 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, <button class="admin-btn" style="margin-left:8px;font-size:0.9em" onClick=${() => setRetryKey(k => k + 1)}>${t('group.retry')}</button> </div>`} - ${needsDevice && html` + ${needsDevice && nativeKeys && html` <div class="invite-form" style="margin-bottom:12px"> <h4>${t('device.add_title')}</h4> <p class="settings-hint">${t('device.add_hint')}</p> ${!deviceCode && html` - <button class="admin-btn" onClick=${async () => { - try { - const transport = transportRef.current; - const out = await transport.requestDeviceAdd(userId); - setDeviceCode(out.code); - } catch (err) { setError(err.message); } + <button class="admin-btn" onClick=${() => { + // The refused connection is closed: a new one files the request. + fileDeviceRef.current = true; + setRetryKey(k => k + 1); }}>${t('device.add_btn')}</button> `} ${deviceCode && html` |