From 35a7764db3f58a93c32206cb3ce74bb2f03967e7 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 23 Sep 2026 18:05:14 +0200 Subject: feat(hub): open, create and join invitation links in the interface #/invite takes the link out of the address on load and keeps it in the tab through registration and sign-in; joining is one click, only the ticket goes to the hub, and the code goes only to the node the link names once it has signed its challenge. Members tab gains "Invite by link" (shared e-mail box, pending list, cancel both halves); home page takes a pasted link. Browser probe drives the real app, signed out and in. Co-Authored-By: Claude Opus 5.5 --- .../src/meshbay_hub/static/group-settings.js | 172 +++++++++++++++++++++ 1 file changed, 172 insertions(+) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/group-settings.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index 64ea3fa..121c51b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -8,6 +8,7 @@ import { CollapsibleSection, ToggleSwitch } from './settings-ui.js'; import { hubFetch, navigate } from './hub-client.js'; import { availableApps, configurableApps } from './apps.js'; import * as platform from './platform.js'; +import { inviteLinkHere, nodePkForLink } from './invite-link.js'; // The account preference behind the "send by e-mail" box. The hub's // ALLOWED_PREF_KEYS must list it, or every toggle snaps back. @@ -893,6 +894,112 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, } }, [groupId, token, inviteUser, inviteByEmail, loadMembers, transportRef]); + // ── Invitation links (docs/MESHBAY_DESIGN.md §3.4) ────────────────── + // + // Two halves, in the order that leaves nothing half-made: the node's code + // first, then the hub's ticket bound to the address; a ticket the hub then + // refuses takes the code back with it, since a code nobody can reach the node + // with only occupies one of the group's twenty places. The code reaches the + // hub only when the box asks the hub to write the mail. + const [linkEmail, setLinkEmail] = useState(''); + const [linking, setLinking] = useState(false); + const [linkError, setLinkError] = useState(''); + const [newLink, setNewLink] = useState(null); + const [linkCopied, setLinkCopied] = useState(false); + const [links, setLinks] = useState([]); + + const linkSignFn = useCallback(() => { + const transport = transportRef && transportRef.current; + const sk = transport && transport.sessionKeys && transport.sessionKeys.skEdB64; + return (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + }, [transportRef]); + + const loadLinks = useCallback(() => { + if (!(group && group.is_admin)) return; + hubFetch(`/v1/groups/${groupId}/invite-links`, { token }) + .then(data => setLinks(data.links || [])) + .catch(() => {}); + }, [groupId, token, group]); + + useEffect(() => { loadLinks(); }, [loadLinks]); + + const doCreateLink = useCallback(async (e) => { + e.preventDefault(); + const email = linkEmail.trim(); + if (!email) return; + setLinking(true); + setLinkError(''); + setNewLink(null); + setLinkCopied(false); + try { + const transport = transportRef && transportRef.current; + if (!transport || !transport.connected) { + throw new Error('Not connected to the node — it must be online to invite'); + } + const signFn = linkSignFn(); + const node = await transport.createLinkInvite(groupId, signFn); + const n = nodePkForLink(transport.nodePk); + let ticket; + try { + ticket = await hubFetch(`/v1/groups/${groupId}/invite-links`, { + method: 'POST', token, + body: { + email, expires_at: node.expires_at, node_invite_id: node.invite_id, + send_email: inviteByEmail, + ...(inviteByEmail ? { node_pk: n, code: node.code } : {}), + }, + }); + } catch (err) { + try { await transport.cancelLinkInvite(node.invite_id, signFn); } catch { /* expires */ } + throw err; + } + setNewLink({ + email, + link: inviteLinkHere({ g: groupId, t: ticket.ticket, n, c: node.code }), + emailStatus: ticket.email_status, + }); + setLinkEmail(''); + loadLinks(); + } catch (err) { + setLinkError(err.message); + } finally { + setLinking(false); + } + }, [groupId, token, linkEmail, inviteByEmail, transportRef, linkSignFn, loadLinks]); + + // Node half first, hub half regardless: a node that refuses (offline, or the + // code already expired there) must not leave the ticket open on the hub. + const cancelLink = useCallback(async (row) => { + setLinkError(''); + let nodeError = ''; + try { + const transport = transportRef && transportRef.current; + if (!transport || !transport.connected) throw new Error(t('group.offline_title')); + await transport.cancelLinkInvite(row.node_invite_id, linkSignFn()); + } catch (err) { + nodeError = err.message; + } + try { + await hubFetch(`/v1/groups/${groupId}/invite-links/${row.link_id}`, { + method: 'DELETE', token, + }); + } catch (err) { + nodeError = nodeError ? `${nodeError} — ${err.message}` : err.message; + } + if (nodeError) setLinkError(nodeError); + loadLinks(); + }, [groupId, token, transportRef, linkSignFn, loadLinks]); + + const copyLink = useCallback(async () => { + if (!newLink) return; + try { + await navigator.clipboard.writeText(newLink.link); + setLinkCopied(true); + } catch { /* the field is selectable */ } + }, [newLink]); + if (loading) return html`

${t('explore.loading')}

`; const isOwner = Boolean(isAdmin); @@ -948,6 +1055,71 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, `} + ${isAdmin && group?.join_policy !== 'open' && html` +
+

${t('members.link_title')}

+

${t('members.link_hint')}

+ ${linkError && html`
${linkError}
`} +
+ ${newLink && html` +
+

${t('members.link_ready', { email: newLink.email })}

+
+ e.target.select()} /> + +
+ ${newLink.emailStatus === 'sent' + ? html`

${t('members.link_email_sent')}

` + : newLink.emailStatus !== 'not_requested' + && html`

${t('members.link_email_refused')}

` + } +
+ `} +
+ setLinkEmail(e.target.value)} + disabled=${!connected || !operatorPaired} required /> + +
+ +
+ ${links.length > 0 && html` +

${t('members.links_pending')}

+ + `} +
+ `} + ${isNodeAdmin && !operatorPaired && connected && html`

${t('members.pair_title')}

-- cgit v1.2.3