diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-15 09:33:28 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-15 09:33:28 +0200 |
| commit | 1e8eae1f3de765fcd8163716de6e5fe5aab5a034 (patch) | |
| tree | dfdb004229c8b0f57ea1a64a726ed2752c3d18e5 /packages/meshbay-hub/src/meshbay_hub/static/profile-page.js | |
| parent | adda9613fad48a68239e2dbd93321818a835c286 (diff) | |
| download | meshbay-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.js | 157 |
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"> |