summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 09:33:28 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 09:33:28 +0200
commit1e8eae1f3de765fcd8163716de6e5fe5aab5a034 (patch)
treedfdb004229c8b0f57ea1a64a726ed2752c3d18e5 /packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
parentadda9613fad48a68239e2dbd93321818a835c286 (diff)
downloadmeshbay-1e8eae1f3de765fcd8163716de6e5fe5aab5a034.tar.gz
feat(hub): one account, several linked nodes
An account links up to MAX_NODES_PER_ACCOUNT node keys (node_keys table) instead of the one users.pk_node_ed25519; the migration carries each account's key over and the downgrade keeps the last one linked. /v1/nodes/auth accepts any linked key, and its token names the key that won it (nk). PUT /me/node_key adds a key, idempotently, 409 past the limit; DELETE /me/node_key names the key: a browser session any of its own, a node only its own. Unlinking a key closes that node's socket and refuses its tokens at once, on REST and on the socket; the account's other nodes are untouched. /pubkeys no longer gives a node key to other accounts, and gives the caller its last linked one, which installed desktop apps read. The node reports waiting_for_node_key while its key is unlinked and signs in again as soon as the hub says so, so a key linked again is back within seconds; unlinking itself signs in first to hold a token naming its key. Profile -> Linked nodes lists each node (connected, last seen, IP, linked since, This machine in the desktop app) with Copy and Unlink, and adds a key by hand. The Node page banner, Create group and the desktop app add this machine's key instead of replacing another's. Messages that pointed at Settings -> Link Node point at Profile -> Linked nodes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/profile-page.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/profile-page.js157
1 files changed, 115 insertions, 42 deletions
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 b52a8c6..406ffc8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
@@ -6,7 +6,7 @@ import { ask } from './ask.js';
import { Icon } from './icon.js';
import * as platform from './platform.js';
import {
- hubFetch, HUB, session, setAuth, mirrorBrowserAccess,
+ hubFetch, HUB, session, setAuth, mirrorBrowserAccess, linkedNodeKeys,
_storeBundleKey, _loadBundleKey, _storeRecoveryKey,
} from './hub-client.js';
@@ -25,12 +25,36 @@ async function lockedMessage(token) {
}
}
+// An Ed25519 key is 44 characters with nothing to tell two apart at a glance;
+// both ends are what a person compares against `meshbay-node status`.
+export function shortNodeKey(pk) {
+ const k = String(pk || '');
+ return k.length > 16 ? `${k.slice(0, 8)}…${k.slice(-6)}` : k;
+}
+
+function nodeKeyMeta(k) {
+ const parts = [k.connected ? t('settings.node_keys_connected')
+ : k.last_seen ? t('settings.node_keys_last_seen',
+ { when: new Date(k.last_seen).toLocaleString() })
+ : t('settings.node_keys_never')];
+ if (k.observed_ip) parts.push(k.observed_ip);
+ if (k.linked_at) {
+ parts.push(t('settings.node_keys_linked_on',
+ { when: new Date(k.linked_at).toLocaleDateString() }));
+ }
+ return parts;
+}
+
export function ProfilePage({ user, onLogout }) {
const [nodeKey, setNodeKey] = useState('');
const [hubInput, setHubInput] = useState('');
const [hubError, setHubError] = useState('');
- const [currentNodeKey, setCurrentNodeKey] = useState(null);
- const [nodeKeyStatus, setNodeKeyStatus] = useState('');
+ // null until the hub answers, so "no node linked" is never said before it has.
+ const [nodeKeys, setNodeKeys] = useState(null);
+ const [nodeKeyLimit, setNodeKeyLimit] = useState(0);
+ const [thisMachineKey, setThisMachineKey] = useState('');
+ const [copiedKey, setCopiedKey] = useState('');
+ const [nodeKeyStatus, setNodeKeyStatus] = useState(null); // { text, ok }
const [nodeKeyLoading, setNodeKeyLoading] = useState(false);
const [email, setEmail] = useState('');
const [emailDraft, setEmailDraft] = useState('');
@@ -289,13 +313,29 @@ export function ProfilePage({ user, onLogout }) {
if (data.email) { setEmail(data.email); setEmailDraft(data.email); }
})
.catch(() => {});
- hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token })
- .then(data => {
- if (data.pk_node_ed25519) setCurrentNodeKey(data.pk_node_ed25519);
- })
- .catch(() => {});
}, [user.username, user.token]);
+ const loadNodeKeys = useCallback(async () => {
+ try {
+ const data = await linkedNodeKeys(user.token);
+ setNodeKeys(data.keys || []);
+ setNodeKeyLimit(data.limit || 0);
+ } catch (e) {
+ setNodeKeyStatus({ text: e.message, ok: false });
+ }
+ }, [user.token]);
+
+ useEffect(() => { loadNodeKeys(); }, [loadNodeKeys]);
+
+ // In the desktop app, which of the linked keys is this machine's own node:
+ // the keys alone do not say which machine is which.
+ useEffect(() => {
+ if (!platform.node.service || !platform.node.service.available) return;
+ platform.node.service.status()
+ .then(r => setThisMachineKey((r && r.pk_node_ed25519) || ''))
+ .catch(() => {});
+ }, []);
+
const saveEmail = useCallback(async () => {
const val = emailDraft.trim();
if (!val || val === email) { setEmailEditing(false); return; }
@@ -354,41 +394,52 @@ export function ProfilePage({ user, onLogout }) {
const key = nodeKey.trim();
if (!key) return;
setNodeKeyLoading(true);
- setNodeKeyStatus('');
+ setNodeKeyStatus(null);
try {
await hubFetch('/v1/users/me/node_key', {
method: 'PUT', token: user.token,
body: { pk_node_ed25519: key },
});
- setCurrentNodeKey(key);
setNodeKey('');
- setNodeKeyStatus(t('settings.node_key_success'));
+ setNodeKeyStatus({ text: t('settings.node_key_success'), ok: true });
+ await loadNodeKeys();
} catch (e) {
- setNodeKeyStatus(e.message);
+ setNodeKeyStatus({ text: e.message, ok: false });
} finally {
setNodeKeyLoading(false);
}
- }, [nodeKey, user.token]);
+ }, [nodeKey, user.token, loadNodeKeys]);
- // 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;
+ // Any session of the account can let go of any of its nodes, so a machine
+ // since wiped or lost is not stuck on the account. Only the one named stops
+ // serving; the hub closes its connection at once.
+ const unlinkNodeKey = useCallback(async (pk) => {
+ if (!await ask(t('settings.node_key_unlink_confirm', { key: shortNodeKey(pk) }))) return;
setNodeKeyLoading(true);
- setNodeKeyStatus('');
+ setNodeKeyStatus(null);
try {
- await hubFetch('/v1/users/me/node_key', { method: 'DELETE', token: user.token });
- setCurrentNodeKey(null);
- setNodeKeyStatus(t('settings.node_key_unlinked'));
+ await hubFetch('/v1/users/me/node_key', {
+ method: 'DELETE', token: user.token, body: { pk_node_ed25519: pk },
+ });
+ setNodeKeys(keys => (keys || []).filter(k => k.pk_node_ed25519 !== pk));
+ setNodeKeyStatus({ text: t('settings.node_key_unlinked'), ok: true });
} catch (e) {
- setNodeKeyStatus(e.message);
+ setNodeKeyStatus({ text: e.message, ok: false });
} finally {
setNodeKeyLoading(false);
}
}, [user.token]);
+ const copyNodeKey = useCallback(async (pk) => {
+ try {
+ await navigator.clipboard.writeText(pk);
+ setCopiedKey(pk);
+ setTimeout(() => setCopiedKey(c => (c === pk ? '' : c)), 2000);
+ } catch { /* clipboard blocked -- the full key is in the tooltip */ }
+ }, []);
+
+ const atLimit = nodeKeys !== null && nodeKeyLimit > 0 && nodeKeys.length >= nodeKeyLimit;
+
return html`
<div>
<h2>${t('profile.title')}</h2>
@@ -449,31 +500,53 @@ export function ProfilePage({ user, onLogout }) {
<div class="settings-section">
<h3 class="settings-heading">${t('settings.node_key')}</h3>
- <p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px">${t('settings.node_key_desc')}</p>
- ${currentNodeKey && html`
- <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">
- <input type="text" class="admin-search" style="flex:1;font-family:monospace;font-size:0.85em"
+ <p class="settings-hint">${t('settings.node_key_desc')}</p>
+ ${nodeKeys !== null && nodeKeys.length === 0 && html`
+ <p class="node-keys-empty">${t('settings.node_keys_none')}</p>`}
+ ${nodeKeys !== null && nodeKeys.length > 0 && html`
+ <ul class="node-keys">
+ ${nodeKeys.map(k => html`
+ <li class="node-keys-item" key=${k.pk_node_ed25519}>
+ <span class=${`node-keys-dot${k.connected ? ' on' : ''}`} aria-hidden="true"></span>
+ <div class="node-keys-main">
+ <div class="node-keys-line">
+ <code class="node-keys-key" title=${k.pk_node_ed25519}>
+ ${shortNodeKey(k.pk_node_ed25519)}</code>
+ ${k.pk_node_ed25519 === thisMachineKey && html`
+ <span class="node-keys-badge">${t('settings.node_keys_this_machine')}</span>`}
+ </div>
+ <div class="node-keys-meta">${nodeKeyMeta(k).join(' · ')}</div>
+ </div>
+ <div class="node-keys-actions">
+ <button class="btn-secondary btn-small" onClick=${() => copyNodeKey(k.pk_node_ed25519)}>
+ ${copiedKey === k.pk_node_ed25519 ? t('node.overview_node_key_copied')
+ : t('node.overview_node_key_copy')}</button>
+ <button class="admin-btn danger" disabled=${nodeKeyLoading}
+ onClick=${() => unlinkNodeKey(k.pk_node_ed25519)}>
+ ${t('settings.node_key_unlink')}</button>
+ </div>
+ </li>`)}
+ </ul>`}
+ <div class="node-keys-add">
+ <input type="text" class="admin-search node-keys-input"
placeholder=${t('settings.node_key_placeholder')}
value=${nodeKey} onInput=${e => setNodeKey(e.target.value)}
- onKeyDown=${e => e.key === 'Enter' && submitNodeKey()} />
+ onKeyDown=${e => e.key === 'Enter' && !atLimit && submitNodeKey()}
+ disabled=${atLimit} aria-label=${t('settings.node_key_placeholder')} />
<button class="admin-btn" onClick=${submitNodeKey}
- disabled=${nodeKeyLoading || !nodeKey.trim()}>
+ disabled=${nodeKeyLoading || atLimit || !nodeKey.trim()}>
${t('settings.node_key_submit')}
</button>
</div>
+ ${nodeKeys !== null && nodeKeyLimit > 0 && html`
+ <p class="settings-hint node-keys-count">
+ ${atLimit ? t('settings.node_keys_at_limit', { limit: nodeKeyLimit })
+ : t('settings.node_keys_count', { n: nodeKeys.length, limit: nodeKeyLimit })}
+ </p>`}
${nodeKeyStatus && html`
- <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>
- `}
+ <p class=${`node-keys-status ${nodeKeyStatus.ok ? 'ok' : 'error'}`} role="status">
+ ${nodeKeyStatus.text}
+ </p>`}
</div>
<div class="settings-section">