diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-10 09:57:17 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-10 09:57:29 +0200 |
| commit | 876f4a351f859201ca68fbf8d585c86905571686 (patch) | |
| tree | 7bf029b884ae427352e6c2e7af0cbba887dea824 /packages | |
| parent | 42b562fcf312ddceb78438b5daea49d4c9b465c8 (diff) | |
| download | meshbay-876f4a351f859201ca68fbf8d585c86905571686.tar.gz | |
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 <noreply@anthropic.com>
Diffstat (limited to 'packages')
15 files changed, 288 insertions, 4 deletions
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 <name>).', 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 <nombre>).', 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 <name>).', 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 <nome>).", 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 <name>)。', 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 <naam>).', 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 <nazwa>).', 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 <nome>).', 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` + <div class="node-pair-banner node-link-banner"> + <p>${problem === 'other_node' + ? t('node.link_other_node') + : t('node.link_other_account', { account: `${info.username} @ ${info.hub_url}` })}</p> + <button class="btn btn-primary btn-small" disabled=${busy} onClick=${takeOver}> + ${busy ? t('node.link_linking') + : problem === 'other_node' ? t('node.link_here') : t('node.link_take_over')}</button> + ${err && html`<p class="error-msg">${err}</p>`} + </div>`; +} + 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')}</button> </div> <${NodeServicePanel} onChanged=${fetchStatus} token=${token} username=${username} /> + <${NodeLinkBanner} info=${nodeInfo} token=${token} username=${username} + onLinked=${fetchStatus} /> ${actionMsg && html`<div class="node-message">${actionMsg}</div>`} ${operatorPaired === false && nodeGroups.length > 0 && html` <div class="node-pair-banner"> 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` <div> <h2>${t('profile.title')}</h2> @@ -435,6 +454,9 @@ export function ProfilePage({ user, onLogout }) { <div class="settings-row" style="margin-top:8px"> <span class="settings-label">${t('settings.node_key_current')}</span> <code class="settings-value" style="font-size:0.8em;word-break:break-all">${currentNodeKey}</code> + <button class="admin-btn" onClick=${unlinkNodeKey} disabled=${nodeKeyLoading}> + ${t('settings.node_key_unlink')} + </button> </div> `} <div style="display:flex;gap:8px;margin-top:10px;align-items:center"> @@ -448,7 +470,7 @@ export function ProfilePage({ user, onLogout }) { </button> </div> ${nodeKeyStatus && html` - <p style="margin-top:6px;font-size:0.85em;color:${nodeKeyStatus === t('settings.node_key_success') ? 'var(--success)' : 'var(--error)'}"> + <p style="margin-top:6px;font-size:0.85em;color:${[t('settings.node_key_success'), t('settings.node_key_unlinked')].includes(nodeKeyStatus) ? 'var(--success)' : 'var(--error)'}"> ${nodeKeyStatus} </p> `} 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(): |