From 876f4a351f859201ca68fbf8d585c86905571686 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 10 Oct 2026 09:57:17 +0200 Subject: fix(client): say when the account's node is elsewhere, and let go of it Signing in on a desktop links this machine's node to the account, but never over a key already linked (that would cut off the user's other machine) nor a node set up for another account. On a real install both left the node reading "Running" while the hub refused it in a loop, and nothing said why. And the only way to unlink was the linked machine's own Node page, of no use once that machine is gone. - The Node page works out, from the node and the hub each time it looks, whether this node can serve the signed-in account (nodeLinkProblem), and says why not: "Link this node instead" (asked first) puts this node's key on the account; "Use this node for my account" switches a node set up for another account through node:start, which takeOver now lets past a node that answers "running". The first version went through node:start for both, and clicking it on a real install did nothing: a node signed in before the account was linked elsewhere answers "running". - The Profile page unlinks the account's node (DELETE /v1/users/me/node_key), from any machine. Co-Authored-By: Claude Opus 5.5 --- docs/PACKAGING-GUIDE.md | 9 ++- docs/USERGUIDE.md | 3 + packages/meshbay-client/src/main.js | 5 +- .../src/meshbay_hub/static/locales/de.js | 9 +++ .../src/meshbay_hub/static/locales/en.js | 9 +++ .../src/meshbay_hub/static/locales/es.js | 9 +++ .../src/meshbay_hub/static/locales/fr.js | 9 +++ .../src/meshbay_hub/static/locales/it.js | 9 +++ .../src/meshbay_hub/static/locales/ja.js | 9 +++ .../src/meshbay_hub/static/locales/nl.js | 9 +++ .../src/meshbay_hub/static/locales/pl.js | 9 +++ .../src/meshbay_hub/static/locales/pt-BR.js | 9 +++ .../src/meshbay_hub/static/locales/zh-CN.js | 9 +++ .../src/meshbay_hub/static/node-page.js | 78 +++++++++++++++++- .../src/meshbay_hub/static/profile-page.js | 24 +++++- .../meshbay-hub/tests/test_node_link_banner.py | 93 ++++++++++++++++++++++ .../meshbay-hub/tests/test_node_page_pairing.py | 2 +- 17 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 packages/meshbay-hub/tests/test_node_link_banner.py diff --git a/docs/PACKAGING-GUIDE.md b/docs/PACKAGING-GUIDE.md index a9aa7fc..9f5d6cf 100644 --- a/docs/PACKAGING-GUIDE.md +++ b/docs/PACKAGING-GUIDE.md @@ -102,10 +102,13 @@ Two cases where the app does not do it on its own, because it would undo something: - the node was already set up for **another account or another hub** — the - **Start** button on the Node page asks before switching it; + Node page says so, and **Use this node for my account** asks before + switching it; - your account is already linked to a node on **another machine** — linking - this one would disconnect that one. The Node page shows this node's key; - **Profile → Link Node** moves the link here if that is what you want. + this one would disconnect that one. The Node page says so, and **Link this + node instead** asks before moving the link here. **Profile → Unlink** lets + go of the other machine's node from anywhere, which is the way out when that + machine is gone. The Node page's **Start / Stop / Restart** work in every mode, and its status is the node's own: **Running** when it answers, **Stopped** only once no diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index 74dae15..83dc05e 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -882,6 +882,9 @@ anything missing. **"No nodes are currently online for this group."** The node is not running, is not reachable, or has not linked its key to the hub. On the node: `meshbay-node status`, then Step 4 and 5 of the quickstart. +In the desktop app, the **Node** page says when your account is linked to the +node of another machine (**Link this node instead**); **Profile → Unlink** +lets go of a node from anywhere, for a machine that is gone. **A member sees the group but no files.** They never redeemed an invitation code, or the group has no key diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 603dedc..1af4386 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -2168,8 +2168,11 @@ Its log: ${nodeLogHint()}`); // other had just started. if (nodeWithAppStart) await nodeWithAppStart.catch(() => {}); if (nodeEnsuring) await nodeEnsuring.catch(() => {}); + // `takeOver`: the Node page's "use this node for my account", for a node + // set up for another account -- which is running, as that account, and + // must be switched and restarted all the same. const already = await probeNode(); - if (already && already.status === 'running') { + if (already && already.status === 'running' && !(opts && opts.takeOver)) { return { started: true, ...already }; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index c512fe5..1fc734c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -542,6 +542,9 @@ export default { 'settings.node_key_placeholder': 'Öffentlichen Ed25519-Schlüssel des Nodes einfügen (base64)', 'settings.node_key_submit': 'Node verknüpfen', 'settings.node_key_success': 'Node erfolgreich verknüpft', + 'settings.node_key_unlink': "Trennen", + 'settings.node_key_unlink_confirm': "Den Node von Ihrem Konto trennen? Er stellt Ihre Gruppen nicht mehr bereit, bis wieder ein Node verknüpft ist.", + 'settings.node_key_unlinked': "Node getrennt", 'settings.node_key_current': 'Schlüssel des verknüpften Nodes', // Sidebar @@ -951,6 +954,12 @@ export default { 'node.pair_button': 'Diesen Browser koppeln', 'node.pair_success': 'Erfolgreich gekoppelt.', 'node.pair_pending': 'Kopplungscode bereit: Er wird verwendet, wenn Sie das nächste Mal eine Gruppe dieses Nodes öffnen.', + 'node.link_other_node': "Ihr Konto ist mit dem Node eines anderen Rechners verknüpft, daher kann dieser Node Ihre Gruppen nicht bereitstellen.", + 'node.link_here': "Stattdessen diesen Node verknüpfen", + 'node.link_here_confirm': "Diesen Node mit Ihrem Konto verknüpfen? Der derzeit verknüpfte Node auf einem anderen Rechner stellt Ihre Gruppen nicht mehr bereit, bis er erneut verknüpft wird.", + 'node.link_other_account': "Dieser Node ist für {account} eingerichtet, nicht für Sie, daher kann er Ihre Gruppen nicht bereitstellen.", + 'node.link_take_over': "Diesen Node für mein Konto verwenden", + 'node.link_linking': "Verknüpfen…", 'node.reload': 'Konfiguration neu laden', 'node.reloaded': 'Konfiguration neu geladen. Verzeichnisänderungen an bestehenden Gruppen sind jetzt aktiv.', 'node.restart_needed': 'Nach dem Hinzufügen: Gruppenschlüssel initialisieren (meshbay-node gek-init --group ).', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index f5b3808..6f3be87 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -530,6 +530,9 @@ export default { 'settings.node_key_placeholder': 'Paste node Ed25519 public key (base64)', 'settings.node_key_submit': 'Link Node', 'settings.node_key_success': 'Node linked successfully', + 'settings.node_key_unlink': "Unlink", + 'settings.node_key_unlink_confirm': "Unlink the node from your account? It stops serving your groups until a node is linked again.", + 'settings.node_key_unlinked': "Node unlinked", 'settings.node_key_current': 'Linked node key', // Sidebar @@ -1093,6 +1096,12 @@ export default { 'node.pair_button': 'Pair this browser', 'node.pair_success': 'Paired successfully.', 'node.pair_pending': "Pairing code ready: it is used the next time you open one of this node's groups.", + 'node.link_other_node': "Your account is linked to the node of another machine, so this node cannot serve your groups.", + 'node.link_here': "Link this node instead", + 'node.link_here_confirm': "Link this node to your account? The node linked now, on another machine, stops serving your groups until it is linked again.", + 'node.link_other_account': "This node is set up for {account}, not for you, so it cannot serve your groups.", + 'node.link_take_over': "Use this node for my account", + 'node.link_linking': "Linking…", 'node.reload': 'Reload config', 'node.reloaded': 'Config reloaded. Root changes on existing groups are now active.', 'node.attach_group': 'Add group', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 6e152fb..47490d4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -537,6 +537,9 @@ export default { 'settings.node_key_placeholder': 'Pegue la clave pública Ed25519 del node (base64)', 'settings.node_key_submit': 'Vincular el node', 'settings.node_key_success': 'Node vinculado correctamente', + 'settings.node_key_unlink': "Desvincular", + 'settings.node_key_unlink_confirm': "¿Desvincular el nodo de tu cuenta? Dejará de servir tus grupos hasta que se vuelva a vincular un nodo.", + 'settings.node_key_unlinked': "Nodo desvinculado", 'settings.node_key_current': 'Clave del node vinculado', // Sidebar @@ -945,6 +948,12 @@ export default { 'node.pair_button': 'Emparejar este navegador', 'node.pair_success': 'Emparejamiento exitoso.', 'node.pair_pending': 'Código de emparejamiento listo: se usará la próxima vez que abra uno de los grupos de este Node.', + 'node.link_other_node': "Tu cuenta está vinculada al nodo de otra máquina, así que este nodo no puede servir tus grupos.", + 'node.link_here': "Vincular este nodo en su lugar", + 'node.link_here_confirm': "¿Vincular este nodo a tu cuenta? El nodo vinculado ahora, en otra máquina, dejará de servir tus grupos hasta que se vuelva a vincular.", + 'node.link_other_account': "Este nodo está configurado para {account}, no para ti, así que no puede servir tus grupos.", + 'node.link_take_over': "Usar este nodo para mi cuenta", + 'node.link_linking': "Vinculando…", 'node.reload': 'Recargar configuración', 'node.reloaded': 'Configuración recargada. Los cambios de directorios en los grupos existentes ya están activos.', 'node.restart_needed': 'Después de añadir: inicialice la clave de grupo (meshbay-node gek-init --group ).', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 40c7501..54588b0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -540,6 +540,9 @@ export default { 'settings.node_key_placeholder': 'Collez la clé publique Ed25519 du node (base64)', 'settings.node_key_submit': 'Associer le node', 'settings.node_key_success': 'Node associé avec succès', + 'settings.node_key_unlink': "Délier", + 'settings.node_key_unlink_confirm': "Délier le node de votre compte ? Il cessera de servir vos groupes jusqu'à ce qu'un node soit lié de nouveau.", + 'settings.node_key_unlinked': "Node délié", 'settings.node_key_current': 'Clé du node associé', // Sidebar @@ -948,6 +951,12 @@ export default { 'node.pair_button': 'Appairer ce navigateur', 'node.pair_success': 'Appairage réussi.', 'node.pair_pending': "Code d'appairage prêt : il sera utilisé à la prochaine ouverture d'un des groupes de ce node.", + 'node.link_other_node': "Votre compte est lié au node d'une autre machine : ce node-ci ne peut pas servir vos groupes.", + 'node.link_here': "Lier ce node à la place", + 'node.link_here_confirm': "Lier ce node à votre compte ? Le node lié actuellement, sur une autre machine, cessera de servir vos groupes jusqu'à ce qu'il soit lié de nouveau.", + 'node.link_other_account': "Ce node est configuré pour {account}, pas pour vous : il ne peut pas servir vos groupes.", + 'node.link_take_over': "Utiliser ce node pour mon compte", + 'node.link_linking': "Liaison…", 'node.reload': 'Recharger la configuration', 'node.reloaded': 'Configuration rechargée. Les modifications de répertoires sur les groupes existants sont maintenant actives.', 'node.restart_needed': 'Après l\'ajout : initialisez la clé de groupe (meshbay-node gek-init --group ).', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 29a1f5d..5d438a9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -540,6 +540,9 @@ export default { 'settings.node_key_placeholder': 'Incolli la chiave pubblica Ed25519 del node (base64)', 'settings.node_key_submit': 'Collega il node', 'settings.node_key_success': 'Node collegato correttamente', + 'settings.node_key_unlink': "Scollega", + 'settings.node_key_unlink_confirm': "Scollegare il node dal tuo account? Smetterà di servire i tuoi gruppi finché non verrà collegato di nuovo un node.", + 'settings.node_key_unlinked': "Node scollegato", 'settings.node_key_current': 'Chiave del node collegato', // Sidebar @@ -947,6 +950,12 @@ export default { 'node.pair_button': 'Associa questo browser', 'node.pair_success': 'Associazione riuscita.', 'node.pair_pending': 'Codice di associazione pronto: verrà usato la prossima volta che apri uno dei gruppi di questo Node.', + 'node.link_other_node': "Il tuo account è collegato al node di un'altra macchina, quindi questo node non può servire i tuoi gruppi.", + 'node.link_here': "Collega invece questo node", + 'node.link_here_confirm': "Collegare questo node al tuo account? Il node collegato ora, su un'altra macchina, smetterà di servire i tuoi gruppi finché non verrà ricollegato.", + 'node.link_other_account': "Questo node è configurato per {account}, non per te, quindi non può servire i tuoi gruppi.", + 'node.link_take_over': "Usa questo node per il mio account", + 'node.link_linking': "Collegamento…", 'node.reload': 'Ricarica configurazione', 'node.reloaded': 'Configurazione ricaricata. Le modifiche alle directory dei gruppi esistenti sono ora attive.', 'node.restart_needed': "Dopo l'aggiunta: inizializzi la chiave di gruppo (meshbay-node gek-init --group ).", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 4cb4787..1505026 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -534,6 +534,9 @@ export default { 'settings.node_key_placeholder': 'node の Ed25519 公開鍵を貼り付け(base64)', 'settings.node_key_submit': 'node を連携', 'settings.node_key_success': 'node を連携しました', + 'settings.node_key_unlink': "リンク解除", + 'settings.node_key_unlink_confirm': "アカウントからノードのリンクを解除しますか?再びノードがリンクされるまで、グループを提供しなくなります。", + 'settings.node_key_unlinked': "ノードのリンクを解除しました", 'settings.node_key_current': '連携済みの node 鍵', // Sidebar @@ -935,6 +938,12 @@ export default { 'node.pair_button': 'このブラウザをペアリング', 'node.pair_success': 'ペアリングに成功しました。', 'node.pair_pending': 'ペアリングコードの準備ができました。この Node のグループを次に開いたときに使用されます。', + 'node.link_other_node': "アカウントは別のマシンのノードにリンクされているため、このノードではグループを提供できません。", + 'node.link_here': "代わりにこのノードをリンク", + 'node.link_here_confirm': "このノードをアカウントにリンクしますか?現在リンクされている別のマシンのノードは、再びリンクされるまでグループを提供しなくなります。", + 'node.link_other_account': "このノードはあなたではなく {account} 用に設定されているため、グループを提供できません。", + 'node.link_take_over': "このノードを自分のアカウントで使う", + 'node.link_linking': "リンク中…", 'node.reload': '設定を再読み込み', 'node.reloaded': '設定を再読み込みしました。既存グループのルート変更が反映されました。', 'node.restart_needed': '追加後、グループ鍵を初期化してください(meshbay-node gek-init --group )。', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 926fadf..858acbf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -541,6 +541,9 @@ export default { 'settings.node_key_placeholder': 'Plak de openbare Ed25519-sleutel van de node (base64)', 'settings.node_key_submit': 'Node koppelen', 'settings.node_key_success': 'Node gekoppeld', + 'settings.node_key_unlink': "Ontkoppelen", + 'settings.node_key_unlink_confirm': "De node van je account ontkoppelen? Hij bedient je groepen niet meer tot er weer een node is gekoppeld.", + 'settings.node_key_unlinked': "Node ontkoppeld", 'settings.node_key_current': 'Sleutel van de gekoppelde node', // Sidebar @@ -949,6 +952,12 @@ export default { 'node.pair_button': 'Deze browser koppelen', 'node.pair_success': 'Succesvol gekoppeld.', 'node.pair_pending': 'Koppelingscode klaar: die wordt gebruikt zodra u een van de groepen van deze Node opent.', + 'node.link_other_node': "Je account is gekoppeld aan de node van een andere computer, dus deze node kan je groepen niet bedienen.", + 'node.link_here': "Koppel in plaats daarvan deze node", + 'node.link_here_confirm': "Deze node aan je account koppelen? De nu gekoppelde node op een andere computer bedient je groepen niet meer tot hij opnieuw wordt gekoppeld.", + 'node.link_other_account': "Deze node is ingesteld voor {account}, niet voor jou, dus hij kan je groepen niet bedienen.", + 'node.link_take_over': "Deze node voor mijn account gebruiken", + 'node.link_linking': "Koppelen…", 'node.reload': 'Configuratie herladen', 'node.reloaded': 'Configuratie herladen. Mapwijzigingen op bestaande groepen zijn nu actief.', 'node.restart_needed': 'Na het toevoegen: initialiseer de groepssleutel (meshbay-node gek-init --group ).', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 2f3a28c..57b4b8a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -553,6 +553,9 @@ export default { 'settings.node_key_placeholder': 'Wklej klucz publiczny Ed25519 node (base64)', 'settings.node_key_submit': 'Powiąż node', 'settings.node_key_success': 'Node został powiązany', + 'settings.node_key_unlink': "Odłącz", + 'settings.node_key_unlink_confirm': "Odłączyć węzeł od twojego konta? Przestanie obsługiwać twoje grupy, dopóki nie zostanie ponownie powiązany węzeł.", + 'settings.node_key_unlinked': "Węzeł odłączony", 'settings.node_key_current': 'Klucz powiązanego node', // Sidebar @@ -967,6 +970,12 @@ export default { 'node.pair_button': 'Sparuj tę przeglądarkę', 'node.pair_success': 'Parowanie zakończone sukcesem.', 'node.pair_pending': 'Kod parowania gotowy: zostanie użyty przy następnym otwarciu jednej z grup tego Node.', + 'node.link_other_node': "Twoje konto jest powiązane z węzłem innego komputera, więc ten węzeł nie może obsługiwać twoich grup.", + 'node.link_here': "Powiąż zamiast tego ten węzeł", + 'node.link_here_confirm': "Powiązać ten węzeł z twoim kontem? Węzeł powiązany teraz, na innym komputerze, przestanie obsługiwać twoje grupy, dopóki nie zostanie ponownie powiązany.", + 'node.link_other_account': "Ten węzeł jest skonfigurowany dla {account}, nie dla ciebie, więc nie może obsługiwać twoich grup.", + 'node.link_take_over': "Użyj tego węzła dla mojego konta", + 'node.link_linking': "Wiązanie…", 'node.reload': 'Przeładuj konfigurację', 'node.reloaded': 'Konfiguracja przeładowana. Zmiany katalogów w istniejących grupach są teraz aktywne.', 'node.restart_needed': 'Po dodaniu: zainicjalizuj klucz grupy (meshbay-node gek-init --group ).', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index faacbf6..23045fb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -539,6 +539,9 @@ export default { 'settings.node_key_placeholder': 'Cole a chave pública Ed25519 do node (base64)', 'settings.node_key_submit': 'Vincular o node', 'settings.node_key_success': 'Node vinculado com sucesso', + 'settings.node_key_unlink': "Desvincular", + 'settings.node_key_unlink_confirm': "Desvincular o node da sua conta? Ele deixará de servir seus grupos até que um node seja vinculado novamente.", + 'settings.node_key_unlinked': "Node desvinculado", 'settings.node_key_current': 'Chave do node vinculado', // Sidebar @@ -946,6 +949,12 @@ export default { 'node.pair_button': 'Parear este navegador', 'node.pair_success': 'Pareamento realizado com sucesso.', 'node.pair_pending': 'Código de pareamento pronto: ele será usado na próxima vez que você abrir um dos grupos deste Node.', + 'node.link_other_node': "Sua conta está vinculada ao node de outra máquina, então este node não pode servir seus grupos.", + 'node.link_here': "Vincular este node no lugar", + 'node.link_here_confirm': "Vincular este node à sua conta? O node vinculado agora, em outra máquina, deixará de servir seus grupos até ser vinculado novamente.", + 'node.link_other_account': "Este node está configurado para {account}, não para você, então não pode servir seus grupos.", + 'node.link_take_over': "Usar este node na minha conta", + 'node.link_linking': "Vinculando…", 'node.reload': 'Recarregar configuração', 'node.reloaded': 'Configuração recarregada. Alterações de diretórios em grupos existentes estão ativas.', 'node.restart_needed': 'Após adicionar: inicialize a chave do grupo (meshbay-node gek-init --group ).', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 232a4ca..8070b5f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -528,6 +528,9 @@ export default { 'settings.node_key_placeholder': '粘贴 node 的 Ed25519 公钥(base64)', 'settings.node_key_submit': '关联 node', 'settings.node_key_success': 'node 关联成功', + 'settings.node_key_unlink': "取消关联", + 'settings.node_key_unlink_confirm': "要从你的账户取消关联该节点吗?在再次关联节点之前,它将停止为你的群组提供服务。", + 'settings.node_key_unlinked': "已取消关联节点", 'settings.node_key_current': '已关联的 node 密钥', // Sidebar @@ -923,6 +926,12 @@ export default { 'node.pair_button': '配对此浏览器', 'node.pair_success': '配对成功。', 'node.pair_pending': '配对码已就绪:下次打开此 Node 的任一群组时将自动使用。', + 'node.link_other_node': "你的账户已关联到另一台机器的节点,因此此节点无法为你的群组提供服务。", + 'node.link_here': "改为关联此节点", + 'node.link_here_confirm': "将此节点关联到你的账户?当前关联的另一台机器上的节点将停止为你的群组提供服务,直到再次关联。", + 'node.link_other_account': "此节点是为 {account} 而不是为你设置的,因此无法为你的群组提供服务。", + 'node.link_take_over': "将此节点用于我的账户", + 'node.link_linking': "正在关联…", 'node.reload': '重新加载配置', 'node.reloaded': '配置已重新加载。对现有群组的目录更改现已生效。', 'node.restart_needed': '添加后请初始化群组密钥(meshbay-node gek-init --group <名称>)。', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js index 11cd07e..56166c6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js @@ -6,7 +6,7 @@ import { t } from './i18n.js'; import { ask } from './ask.js'; import * as platform from './platform.js'; import { Icon } from './icon.js'; -import { HUB, session } from './hub-client.js'; +import { HUB, session, hubFetch } from './hub-client.js'; // ── Node management (D5) ──────────────────────────────────────────────────── // @@ -22,6 +22,80 @@ export function pairedFrom(result) { return v === true || v === false ? v : null; } +// Why this machine's node cannot serve the signed-in account, if it cannot: +// 'other_account' when it is set up for another account or hub, 'other_node' +// when the account is linked to another node's key. Signing in links this +// node (main.js ensureNode) but never over either, since that would cut off +// the user's other machine or someone else's node; both left a node reading +// "Running" that the hub refused in a loop, with nothing here saying why +// (found on a real install, 2026-10-10). Worked out from the node and the hub +// each time the page looks, not kept from sign-in: a link made or removed on +// the Profile page or on the other machine changes the answer. +export function nodeLinkProblem(info, linkedKey, username, hub) { + if (!info || !username) return null; + const sameHub = (a, b) => String(a || '').replace(/\/+$/, '') === String(b || '').replace(/\/+$/, ''); + if ((info.username && info.username !== username) + || (info.hub_url && hub && !sameHub(info.hub_url, hub))) return 'other_account'; + if (linkedKey && info.pk_node_ed25519 && linkedKey !== info.pk_node_ed25519) return 'other_node'; + return null; +} + +function NodeLinkBanner({ info, token, username, onLinked }) { + const [linkedKey, setLinkedKey] = useState(null); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(''); + const nodeKey = info && info.pk_node_ed25519; + + useEffect(() => { + if (!username || !token) return; + let cancelled = false; + hubFetch(`/v1/users/${encodeURIComponent(username)}/pubkeys`, { token }) + .then((k) => { if (!cancelled) setLinkedKey((k && k.pk_node_ed25519) || null); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [username, token, nodeKey]); + + const problem = nodeLinkProblem(info, linkedKey, username, HUB); + if (!problem) return null; + + // Another node's key: this node's replaces it on the account, and a node + // waiting for its account signs in on its next attempt. Not node:start, + // which leaves a node that answers "running" alone, and one can (signed in + // before the account was linked elsewhere). Another account: node:start + // switches it, after asking, restarts it and links it. + const takeOver = async () => { + if (problem === 'other_node' && !await ask(t('node.link_here_confirm'))) return; + setBusy(true); + setErr(''); + try { + if (problem === 'other_node') { + await hubFetch('/v1/users/me/node_key', { + method: 'PUT', token, body: { pk_node_ed25519: nodeKey }, + }); + } else { + await platform.node.start({ hubUrl: HUB, username, token, takeOver: true }); + } + setLinkedKey(nodeKey); + if (onLinked) onLinked(); + } catch (e) { + setErr(platform.bridgeMessage(e)); + } finally { + setBusy(false); + } + }; + + return html` + `; +} + function NodeServicePanel({ onChanged, token, username }) { const [info, setInfo] = useState(null); const [busy, setBusy] = useState(''); @@ -725,6 +799,8 @@ export function NodePage({ groups, token, username }) { ${t('node.reload')} <${NodeServicePanel} onChanged=${fetchStatus} token=${token} username=${username} /> + <${NodeLinkBanner} info=${nodeInfo} token=${token} username=${username} + onLinked=${fetchStatus} /> ${actionMsg && html`
${actionMsg}
`} ${operatorPaired === false && nodeGroups.length > 0 && html`
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js index aac3c14..b52a8c6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js @@ -370,6 +370,25 @@ export function ProfilePage({ user, onLogout }) { } }, [nodeKey, user.token]); + // Unlinking was only on the linked machine's own Node page, so an account + // whose node was on a machine since wiped or lost had no way out here; and a + // new machine's node will not take over a key that is still linked + // (main.js ensureNode). The hub accepts it from any session of the account. + const unlinkNodeKey = useCallback(async () => { + if (!await ask(t('settings.node_key_unlink_confirm'))) return; + setNodeKeyLoading(true); + setNodeKeyStatus(''); + try { + await hubFetch('/v1/users/me/node_key', { method: 'DELETE', token: user.token }); + setCurrentNodeKey(null); + setNodeKeyStatus(t('settings.node_key_unlinked')); + } catch (e) { + setNodeKeyStatus(e.message); + } finally { + setNodeKeyLoading(false); + } + }, [user.token]); + return html`

${t('profile.title')}

@@ -435,6 +454,9 @@ export function ProfilePage({ user, onLogout }) {
${t('settings.node_key_current')} ${currentNodeKey} +
`}
@@ -448,7 +470,7 @@ export function ProfilePage({ user, onLogout }) {
${nodeKeyStatus && html` -

+

${nodeKeyStatus}

`} diff --git a/packages/meshbay-hub/tests/test_node_link_banner.py b/packages/meshbay-hub/tests/test_node_link_banner.py new file mode 100644 index 0000000..eef29bf --- /dev/null +++ b/packages/meshbay-hub/tests/test_node_link_banner.py @@ -0,0 +1,93 @@ +""" +A node that cannot serve the signed-in account says so, and the account can +let go of a node from anywhere. + +Signing in on a desktop links this machine's node to the account (main.js +`ensureNode`), but never over a key already linked -- that would cut off the +user's other machine -- nor a node set up for another account. Found on a real +install (2026-10-10): the node read "Running" while the hub refused it in a +loop, and nothing said why. And the only way to unlink was the linked +machine's own Node page, of no use once that machine is gone. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +NODE_PAGE = STATIC / "node-page.js" +PROFILE_PAGE = STATIC / "profile-page.js" + + +def _src(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node is not available") +def test_the_problem_is_worked_out_from_the_node_and_the_hub(tmp_path): + m = re.search(r"^export function nodeLinkProblem\(.*?^\}", _src(NODE_PAGE), re.M | re.S) + assert m, "node-page.js no longer has nodeLinkProblem" + mine = {"username": "alice", "hub_url": "https://hub.example/", "pk_node_ed25519": "K1"} + cases = [ + [mine, "K1", "alice", "https://hub.example"], # linked here: fine + [mine, None, "alice", "https://hub.example"], # nothing linked: sign-in links it + [mine, "K2", "alice", "https://hub.example"], # another node's key + [{**mine, "username": "bob"}, "K1", "alice", "https://hub.example"], + [mine, "K1", "alice", "https://other.example"], # set up for another hub + [mine, "K2", "alice", ""], # browser: same-origin hub + [None, "K2", "alice", "https://hub.example"], # node not answering + ] + script = tmp_path / "case.js" + script.write_text( + m.group(0).replace("export ", "") + + f"\nconsole.log(JSON.stringify({json.dumps(cases)}" + ".map(c => nodeLinkProblem(...c))));", + encoding="utf-8") + out = subprocess.run(["node", str(script)], capture_output=True, encoding="utf-8", check=True) + assert json.loads(out.stdout) == [ + None, None, "other_node", "other_account", "other_account", "other_node", None] + + +def test_the_node_page_shows_the_banner_and_offers_the_link(): + src = _src(NODE_PAGE) + page = src.split("export function NodePage(", 1)[1] + assert "<${NodeLinkBanner} info=${nodeInfo} token=${token} username=${username}" in page + banner = src.split("function NodeLinkBanner(", 1)[1].split("\nfunction ", 1)[0] + # Asked of the hub, not remembered from sign-in. + assert "hubFetch(`/v1/users/${encodeURIComponent(username)}/pubkeys`" in banner + assert "nodeLinkProblem(info, linkedKey, username, HUB)" in banner + take_over = banner.split("const takeOver = async () => {", 1)[1].split("\n };", 1)[0] + # Replacing another machine's link is asked first, then this node's key + # replaces it directly: node:start left a node answering "running" alone, + # and a node signed in before the account was linked elsewhere does + # (found clicking it on a real install). For another account node:start + # asks itself before switching, and takeOver gets past "running". + assert "problem === 'other_node' && !await ask(t('node.link_here_confirm'))" in take_over + assert "method: 'PUT', token, body: { pk_node_ed25519: nodeKey }" in take_over + assert "platform.node.start({ hubUrl: HUB, username, token, takeOver: true })" in take_over + for key in ("node.link_other_node", "node.link_other_account", "node.link_here", + "node.link_take_over", "node.link_linking"): + assert f"'{key}'" in banner, key + + +def test_node_start_switches_a_running_node_when_asked_to_take_it_over(): + main_js = (Path(__file__).resolve().parents[3] / "packages" / "meshbay-client" / "src" + / "main.js").read_text(encoding="utf-8") + start = main_js.split("handle('node:start', async (_e, opts) => {", 1)[1] + start = start.split("\n });\n", 1)[0] + assert "already.status === 'running' && !(opts && opts.takeOver)" in start + + +def test_the_profile_page_can_unlink_the_node(): + src = _src(PROFILE_PAGE) + unlink = src.split("const unlinkNodeKey = useCallback(async () => {", 1)[1].split("}, [", 1)[0] + assert "if (!await ask(t('settings.node_key_unlink_confirm'))) return;" in unlink + assert "hubFetch('/v1/users/me/node_key', { method: 'DELETE', token: user.token })" in unlink + assert "setCurrentNodeKey(null)" in unlink + # Offered only where there is a key to let go of. + shown = src.split("${currentNodeKey && html`", 1)[1].split("`}", 1)[0] + assert "onClick=${unlinkNodeKey}" in shown diff --git a/packages/meshbay-hub/tests/test_node_page_pairing.py b/packages/meshbay-hub/tests/test_node_page_pairing.py index c716e0a..ac77356 100644 --- a/packages/meshbay-hub/tests/test_node_page_pairing.py +++ b/packages/meshbay-hub/tests/test_node_page_pairing.py @@ -56,7 +56,7 @@ def test_pair_this_browser_queues_the_code_instead_of_claiming_success(): pair = src.split("const doPairOperator = useCallback(", 1)[1].split("}, [refresh]);", 1)[0] assert "session.pendingJoinCode = result.code;" in pair assert "setOperatorPaired(true)" not in pair - assert "import { HUB, session } from './hub-client.js';" in src + assert re.search(r"import \{ HUB, session(, \w+)* \} from './hub-client.js';", src) def test_no_pairing_banner_for_a_node_with_no_group_yet(): -- cgit v1.2.3