aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
diff options
context:
space:
mode:
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.js72
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>