diff options
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 | 72 |
1 files changed, 68 insertions, 4 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 acfcfc8..d7c81ad 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -6,7 +6,8 @@ import { Icon } from './icon.js'; import { transfers } from './transfers.js'; import { downloadEntry } from './file-utils.js'; import { - HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken, _loadBundleKey, + HUB, session, cacheGroupIndex, hubFetch, ensureFreshToken, + _loadBundleKey, _loadRecoveryKey, _storeBundleKey, } from './hub-client.js'; import { visibleApps } from './apps.js'; import { GroupName } from './group-name.js'; @@ -114,6 +115,12 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, const [needsDevice, setNeedsDevice] = useState(false); const [deviceCode, setDeviceCode] = useState(''); 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 + // rather than sending someone back to the browser they registered on. + const [needsPass, setNeedsPass] = useState(false); + const [passInput, setPassInput] = useState(''); + const [passBusy, setPassBusy] = useState(false); const [retryKey, setRetryKey] = useState(0); const transportRef = useRef(null); const gekRef = useRef(null); @@ -125,7 +132,10 @@ 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); - useEffect(() => { refreshedRef.current = false; }, [groupId]); + useEffect(() => { + refreshedRef.current = false; + setNeedsPass(false); + }, [groupId]); const submitJoinCode = useCallback((e) => { e.preventDefault(); @@ -138,6 +148,31 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, setRetryKey(k => k + 1); }, [codeInput]); + const submitPass = useCallback(async (e) => { + e.preventDefault(); + const pass = passInput; + if (!pass || !window.MeshBayKeys) return; + setPassBusy(true); + setError(''); + try { + // Same derivation as sign-in — the token is already ours, only the key + // that opens node bundles is missing here. Persisted so this browser is + // set up from now on. + session.bundleKey = { + v2: await window.MeshBayKeys.deriveEncryptionKey(pass, username), + v1: await window.MeshBayKeys.deriveEncryptionKeyV1(pass, username), + }; + await _storeBundleKey(session.bundleKey); + setPassInput(''); + setNeedsPass(false); + setRetryKey(k => k + 1); + } catch (err) { + setError(err.message); + } finally { + setPassBusy(false); + } + }, [passInput, username]); + // One place that takes an index from the node and puts it everywhere it has to // go. Deleting a file used to refresh the table and leave the cache alone, so // the search page went on offering a file that no longer existed until the @@ -191,6 +226,16 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, setError(''); gekRef.current = null; if (!session.bundleKey) session.bundleKey = await _loadBundleKey(); + // Persisted (docs/auth-confirm.md §4.3) so a group joined in a later + // session still leaves a recovery-wrapped identity copy on its node. + if (!session.recoveryKey) session.recoveryKey = await _loadRecoveryKey(); + if (!session.bundleKey && window.MeshBayKeys) { + // Nothing to sign or unwrap with in this browser yet — ask for the + // passphrase instead of failing into a "go back to your other browser" + // message. + if (!cancelled) { setNeedsPass(true); setStatus('idle'); } + return; + } try { const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); if (cancelled) return; @@ -225,7 +270,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, const ack = await transport.connect( nodeId, live, groupId, null, sessionKeys, session.bundleKey, username, - userId, session.pendingJoinCode); + userId, session.pendingJoinCode, session.recoveryKey); session.pendingJoinCode = null; if (cancelled) return; setIsNodeAdmin(!!ack.is_node_admin); @@ -281,8 +326,10 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // passphrase. It is this node's key and no other's. if (transport.connected && transport.newNodeBundle) { try { - await transport.storeKeypairBundle(transport.newNodeBundle); + await transport.storeKeypairBundle( + transport.newNodeBundle, transport.newNodeBundleRecovery); transport.newNodeBundle = null; + transport.newNodeBundleRecovery = null; } catch (e) { console.warn('[MeshBay] could not leave our key with the node:', e.message); } @@ -349,6 +396,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // 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); + // The node has no bundle for us and this browser derived no key to make + // one — the passphrase form below is the way in, not a support request. + if (err.reason === 'no_keys') setNeedsPass(true); // A key this node has never pinned, for an account it knows. The way in // is a device already trusted here, not an operator — which is the // whole point of device linking: a second browser or a native client @@ -549,6 +599,20 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, `} </div> `} + ${needsPass && html` + <form class="invite-form" style="margin-bottom:12px" onSubmit=${submitPass}> + <h4>${t('group.pass_title')}</h4> + <p class="settings-hint">${t('group.pass_hint')}</p> + <div style="display:flex;gap:8px"> + <input type="password" placeholder=${t('login.password')} + autocomplete="current-password" + value=${passInput} onInput=${e => setPassInput(e.target.value)} required /> + <button class="admin-btn" type="submit" disabled=${passBusy}> + ${passBusy ? '…' : t('group.pass_btn')} + </button> + </div> + </form> + `} ${needsCode && html` <form class="invite-form" style="margin-bottom:12px" onSubmit=${submitJoinCode}> <h4>${t('group.join_code_title')}</h4> |